From 1b4cf66ea0e240adab6aa538359d0fda6c7395f9 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 26 Jul 2026 11:14:46 +0800 Subject: [PATCH] Restore completion times and refine daily inputs --- web/app/globals.css | 12 ++- web/app/page.tsx | 53 ++++++++---- web/lib/unconfigured-time.ts | 15 ++++ web/lib/week-up-domain.ts | 57 +++++++++++-- web/server/week-up-database.mjs | 8 +- web/tests/demo-source.test.mjs | 33 +++++++- web/tests/unconfigured-time.test.mjs | 52 ++++++++++++ web/tests/view-projection.test.mjs | 2 + web/tests/week-up-domain.test.mjs | 120 +++++++++++++++++++++++++++ 9 files changed, 321 insertions(+), 31 deletions(-) create mode 100644 web/lib/unconfigured-time.ts create mode 100644 web/tests/unconfigured-time.test.mjs diff --git a/web/app/globals.css b/web/app/globals.css index f40ef17..6ddc4d5 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -820,6 +820,12 @@ button:focus-visible, input:focus-visible, textarea:focus-visible { .weight-stats > strong { font-family: Consolas, monospace; font-size: 29px; } .weight-stats strong small { font-size: 12px; } .weight-stats > div { grid-column: 1 / -1; display: flex; align-items: center; justify-content: space-between; margin-top: 5px; color: var(--muted); } +.weight-form--quick { width: min(100%,240px); margin: 12px 0 8px; align-items: stretch; } +.weight-form--quick > div { min-width: 0; flex: 1 1 auto; box-shadow: 2px 2px 0 var(--ink); } +.weight-form--quick input { width: 100%; min-width: 0; min-height: 34px; padding: 4px 7px; font-size: 16px; } +.weight-form--quick input:focus, .weight-form--quick input:focus-visible { outline: none; box-shadow: none; } +.weight-form--quick .pixel-button { min-height: 38px; padding: 6px 11px; box-shadow: 2px 2px 0 var(--ink); font-size: 11px; white-space: nowrap; } +.weight-form--quick .pixel-button:disabled { opacity: .5; cursor: not-allowed; box-shadow: none; } .trend-down { color: #177d59 !important; font-style: normal; font-weight: 900; } .weight-chart { position: relative; height: 260px; margin-top: 28px; border-left: 3px solid var(--ink); border-bottom: 3px solid var(--ink); background-image: linear-gradient(rgba(48,36,71,.08) 1px, transparent 1px); background-size: 100% 25%; } .weight-chart--compact { height: 90px; margin-top: 12px; } @@ -924,8 +930,10 @@ button:focus-visible, input:focus-visible, textarea:focus-visible { .drawer-plan-group__title span { color: var(--muted); font-size: 9px; } .drawer-plan { display: grid; grid-template-columns: 104px minmax(0,1fr); align-items: center; gap: 5px 12px; padding: 12px 0; border-bottom: 1px dashed rgba(48,36,71,.35); } .drawer-plan--unscheduled { grid-template-columns: 104px minmax(0,1fr); } -.drawer-plan__time { min-height: 38px; padding: 6px 8px; border: 2px solid var(--ink); background: white; color: var(--violet); font-family: Consolas,monospace; font-size: 10px; font-weight: 900; cursor: pointer; } -.drawer-plan__time.is-unscheduled { background: var(--yellow); color: var(--ink); font-family: inherit; } +.drawer-plan__time { display: grid; width: 100%; min-height: 42px; place-items: center; padding: 7px 9px; border: 2px solid var(--ink); background: var(--yellow); color: var(--ink); font-family: inherit; font-size: 10px; font-weight: 900; line-height: 1.25; text-align: center; } +button.drawer-plan__time { cursor: pointer; } +button.drawer-plan__time:hover, button.drawer-plan__time:focus-visible { background: var(--cyan); box-shadow: 2px 2px 0 var(--ink); } +.drawer-plan__time.is-unscheduled { background: var(--yellow); color: var(--ink); } .drawer-plan__copy { display: flex; min-width: 0; flex-direction: column; } .drawer-plan__copy b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .drawer-plan__copy span { color: var(--muted); font-size: 10px; } diff --git a/web/app/page.tsx b/web/app/page.tsx index 3475c31..4666502 100644 --- a/web/app/page.tsx +++ b/web/app/page.tsx @@ -20,6 +20,7 @@ import { attributeGainsForCompletedDate, sortAttributeRewardsByAmount } from ".. import { selectDailyPlans } from "../lib/daily-plan-selection"; import { overdueDisposition } from "../lib/overdue-policy"; import { summarizeWeekRouteDay } from "../lib/week-route-summary"; +import { selectUnconfiguredPlansForDate } from "../lib/unconfigured-time"; import { expandRecurrenceDates, recurrenceSummary, type RecurrenceRule } from "../lib/recurrence"; import { useWeekUp } from "../lib/use-week-up"; import { AiStatusControl } from "./ai-status-control"; @@ -338,6 +339,20 @@ function WeightChart({ entries, target, compact = false, averageValues }: { entr ); } +function QuickWeightEntry({ initialValue, onSave }: { initialValue?: number; onSave: (value: number) => void }) { + const [value, setValue] = useState(initialValue?.toFixed(1) ?? ""); + const submit = (event: FormEvent) => { + event.preventDefault(); + const parsed = Number(value); + if (parsed >= 20 && parsed <= 300) onSave(Math.round(parsed * 10) / 10); + }; + return
+ +
setValue(event.target.value)} placeholder="--.-" />kg
+ +
; +} + function TodayView({ plans, attributes, @@ -347,6 +362,7 @@ function TodayView({ onExternalComplete, onQuickAdd, onOpenWeight, + onRecordWeight, onEdit, onUndo, onRemove, @@ -360,6 +376,7 @@ function TodayView({ onExternalComplete: () => void; onQuickAdd: () => void; onOpenWeight: () => void; + onRecordWeight: (value: number) => void; onEdit: (id: string) => void; onUndo: (id: string, segmentId?: string) => void; onRemove: (id: string) => void; @@ -383,6 +400,7 @@ function TodayView({ const completed = completedPlans.length; const learningPlan = todayPlans.find((plan) => plan.source === "learning-more"); const latest = weights.at(-1); + const hasTodayWeight = weights.some((entry) => entry.date === currentLocalDate()); const averages = movingAverage(weights); const dailyGains = attributeGainsForCompletedDate(completionFacts, currentLocalDate()) .map((reward) => ({ attribute: attributes.find((attribute) => attribute.id === reward.attributeId), amount: reward.amount })) @@ -433,6 +451,7 @@ function TodayView({
BODY TRACK

体重趋势

+ {!hasTodayWeight && } {latest ?
{latest.value.toFixed(1)} kg
7日均值{averages.at(-1)?.toFixed(1) ?? "—"} kg
:
还没有体重记录。进入体重趋势页录入第一条数据。
}
@@ -711,7 +730,7 @@ function expandCalendarSegments(plans: readonly PlanItem[]): PlanItem[] { : [plan]); } -function CalendarView({ plans, initialMode, content, onEditPlan }: { plans: PlanItem[]; initialMode: "week" | "month"; content: "timeline" | "schedule"; onEditPlan: (id: string) => void }) { +function CalendarView({ plans, unconfiguredPlans, untimedCompletionPlans, settledDates, initialMode, content, onEditPlan }: { plans: PlanItem[]; unconfiguredPlans: PlanItem[]; untimedCompletionPlans: PlanItem[]; settledDates: readonly string[]; initialMode: "week" | "month"; content: "timeline" | "schedule"; onEditPlan: (id: string) => void }) { const todayKey = currentLocalDate(); const weekRange = currentWeekRange(); const todayIndex = Math.max(0, Math.min(6, Math.round((Date.parse(`${todayKey}T00:00:00Z`) - Date.parse(`${weekRange.startDate}T00:00:00Z`)) / 86_400_000))); @@ -726,10 +745,18 @@ function CalendarView({ plans, initialMode, content, onEditPlan }: { plans: Plan const [monthAnchor, setMonthAnchor] = useState(todayKey.slice(0, 7)); const [selectedDate, setSelectedDate] = useState(null); const [expandedDays, setExpandedDays] = useState([]); + const settledDateSet = useMemo(() => new Set(settledDates), [settledDates]); const planDateKey = (plan: PlanItem) => plan.scheduledDate ?? shiftLocalDate(weekRange.startDate, plan.dayIndex ?? 0); const plansForDate = (dateKey: string) => plans .filter((plan) => planDateKey(plan) === dateKey) .sort((left, right) => left.start.localeCompare(right.start)); + const unconfiguredPlansForDate = (dateKey: string) => selectUnconfiguredPlansForDate( + dateKey, + unconfiguredPlans, + untimedCompletionPlans, + settledDateSet, + ) + .sort((left, right) => left.title.localeCompare(right.title, "zh-CN")); const monthStart = `${monthAnchor}-01`; const monthStartDate = new Date(`${monthStart}T00:00:00Z`); const monthGridStart = shiftLocalDate(monthStart, -((monthStartDate.getUTCDay() + 6) % 7)); @@ -746,7 +773,7 @@ function CalendarView({ plans, initialMode, content, onEditPlan }: { plans: Plan setSelectedDate(null); }; const selectedPlans = selectedDate ? plansForDate(selectedDate).filter((plan) => mode === "week" || plan.completed) : []; - const selectedUnscheduled = selectedPlans.filter((plan) => plan.timeStatus === "unscheduled"); + const selectedUnscheduled = selectedDate && mode === "week" ? unconfiguredPlansForDate(selectedDate) : []; const selectedTimed = selectedPlans.filter((plan) => mode === "month" || plan.timeStatus !== "unscheduled"); const selectedDateLabel = selectedDate ? `${Number(selectedDate.slice(5, 7))}月${Number(selectedDate.slice(8, 10))}日` : ""; return ( @@ -757,9 +784,9 @@ function CalendarView({ plans, initialMode, content, onEditPlan }: { plans: Plan
{content === "timeline" ? "UNTIMED" : "POCKET"}{content === "timeline" ? "未配置时间" : "待安排"}
{dayDates.map((date, dayIndex) => { - const count = plansForDate(date.key).filter((plan) => plan.timeStatus === "unscheduled").length; - const countDescription = content === "timeline" ? "条完成记录未配置实际时间" : "项待安排计划"; - return ; + const count = unconfiguredPlansForDate(date.key).length; + const countDescription = content === "timeline" ? "项未配置时间任务" : "项待安排计划"; + return ; })}
{["06:00", "08:00", "10:00", "12:00", "14:00", "16:00", "18:00", "20:00", "22:00"].map((time) => {time})}
{dayDates.map((date, dayIndex) => { @@ -777,7 +804,7 @@ function CalendarView({ plans, initialMode, content, onEditPlan }: { plans: Plan {plans.length === 0 &&
{content === "timeline" ? "本周还没有实际完成记录" : "本周没有已安排的计划"}
}
{dayDates.map((date, dayIndex) => { const dayPlans = plansForDate(date.key); - const unscheduledPlans = dayPlans.filter((plan) => plan.timeStatus === "unscheduled"); + const unscheduledPlans = unconfiguredPlansForDate(date.key); const timedPlans = expandCalendarSegments(dayPlans.filter((plan) => plan.timeStatus !== "unscheduled")); const expanded = expandedDays.includes(dayIndex); const shown = expanded ? timedPlans : timedPlans.slice(0, 6); @@ -788,7 +815,7 @@ function CalendarView({ plans, initialMode, content, onEditPlan }: { plans: Plan
{weekdays.map((day) => {day})}
{monthCells.map((cell) =>
{cell.plans.slice(0, 3).map((plan) => )}
{cell.plans.length > 3 && }
)}
} - {selectedDate !== null &&
{mode === "month" ? "DONE TODAY" : content === "timeline" ? "COMPLETION FACTS" : "DAY AGENDA"}

{selectedDateLabel} · {mode === "month" || content === "timeline" ? "完成记录" : "计划口袋"}

{selectedPlans.length > 0 ? <>{mode === "week" && selectedUnscheduled.length > 0 &&
{content === "timeline" ? "未配置时间" : "待安排"}{selectedUnscheduled.length} 项 · {content === "timeline" ? "已完成但未记录实际时段" : "点击时间票券直接设置"}
{selectedUnscheduled.map((plan) => )}
}{selectedTimed.length > 0 &&
{mode === "month" ? "完成内容" : content === "timeline" ? "实际执行" : "已安排"}{selectedTimed.length} 项{content === "schedule" ? " · 点击时间可再次修改" : ""}
{selectedTimed.map((plan) => )}
} :
{mode === "month" || content === "timeline" ? "这一天还没有完成记录。" : "这一天还没有计划。"}
}
} + {selectedDate !== null &&
{mode === "month" ? "DONE TODAY" : content === "timeline" ? "COMPLETION FACTS" : "DAY AGENDA"}

{selectedDateLabel} · {mode === "month" || content === "timeline" ? "完成记录" : "计划口袋"}

{selectedUnscheduled.length > 0 || selectedTimed.length > 0 ? <>{mode === "week" && selectedUnscheduled.length > 0 &&
{content === "timeline" ? "未配置时间任务" : "待安排"}{selectedUnscheduled.length} 项 · 点击时间票券直接设置
{selectedUnscheduled.map((plan) => )}
}{selectedTimed.length > 0 &&
{mode === "month" ? "完成内容" : content === "timeline" ? "实际执行" : "已安排"}{selectedTimed.length} 项{content === "schedule" ? " · 点击时间可再次修改" : ""}
{selectedTimed.map((plan) => )}
} :
{mode === "month" || content === "timeline" ? "这一天还没有完成记录。" : "这一天还没有计划。"}
}
}
); } @@ -921,8 +948,7 @@ function GrowthView({ attributes, skillbooks, goals }: { attributes: Attribute[] ); } -function WeightView({ entries, target, onAdd, onTarget }: { entries: WeightEntry[]; target?: number; onAdd: (value: number) => void; onTarget: (value: number) => void }) { - const [value, setValue] = useState(entries.at(-1)?.value.toFixed(1) ?? ""); +function WeightView({ entries, target, onTarget }: { entries: WeightEntry[]; target?: number; onTarget: (value: number) => void }) { const [range, setRange] = useState<7 | 30 | 90 | 365>(30); const [page, setPage] = useState(1); const [targetValue, setTargetValue] = useState(target?.toFixed(1) ?? ""); @@ -937,10 +963,9 @@ function WeightView({ entries, target, onAdd, onTarget }: { entries: WeightEntry const history = [...entries].reverse(); const pageCount = Math.max(1, Math.ceil(history.length / pageSize)); const pageEntries = history.slice((page - 1) * pageSize, page * pageSize); - const submit = (event: FormEvent) => { event.preventDefault(); const parsed = Number(value); if (parsed >= 20 && parsed <= 300) onAdd(Math.round(parsed * 10) / 10); }; return (
-
BODY TRACK

体重趋势

只记录事实与趋势,不提供健康判断。

setValue(event.target.value)} />kg
+
BODY TRACK

体重趋势

只记录事实与趋势,不提供健康判断。

最新体重{latest ? latest.value.toFixed(1) : "—"} kg{latest && previous ? `较上次 ${(latest.value - previous.value).toFixed(1)} kg` : "暂无历史记录"}
7 日移动平均{averages.at(-1)?.toFixed(1) ?? "—"} kg{entries.length > 1 ? `基于 ${Math.min(entries.length, 7)} 条有效记录` : "至少需要两条记录"}
目标体重
{ event.preventDefault(); const parsed = Number(targetValue); if (parsed >= 20 && parsed <= 300) onTarget(parsed); }}> setTargetValue(event.target.value)} />kg
{target ? `目标线 ${target.toFixed(1)} kg` : "暂未设置目标"}
{range} DAYS

每日值与移动平均

{([7, 30, 90, 365] as const).map((valueOption) => )}
每日值7日均值目标线{rangeEntries.length > chartEntries.length && {rangeEntries.length} 条记录已压缩为 {chartEntries.length} 个关键点}
HISTORY

历史记录

共 {entries.length} 条 · 每页 50 条
{entries.length === 0 ?
暂无体重记录
: <>
{pageEntries.map((entry, index) =>
{entry.date} · {entry.label}{entry.value.toFixed(1)}{page === 1 && index === 0 ? "当前有效值" : "已同步"}
)}
第 {page} / {pageCount} 页
}
@@ -1400,13 +1425,13 @@ export default function Home() {
WEEK UP!
{activeNav.eyebrow}{activeNav.label}
void weekUp.dispatch({ type: "ai-review.configure", ...value })} onRefresh={() => void weekUp.refreshAiStatus(true)} />
{weekUp.persistenceStatus === "offline" &&
本地服务暂时离线当前展示的是最近缓存,修改操作不会生效。请重新启动 Week UP 服务后刷新页面。
} - {tab === "today" && openQuickAdd()} onOpenWeight={() => setTab("weight")} onEdit={(id) => setPlanEditor(weekUp.state.plans.find((plan) => plan.id === id) ?? null)} onUndo={undoPlan} onRemove={(id) => void weekUp.dispatch({ type: "plan.remove", id })} onRescheduleOverdue={(id) => setOverdueEditor(weekUp.state.plans.find((plan) => plan.id === id) ?? null)} />} + {tab === "today" && openQuickAdd()} onOpenWeight={() => setTab("weight")} onRecordWeight={addWeight} onEdit={(id) => setPlanEditor(weekUp.state.plans.find((plan) => plan.id === id) ?? null)} onUndo={undoPlan} onRemove={(id) => void weekUp.dispatch({ type: "plan.remove", id })} onRescheduleOverdue={(id) => setOverdueEditor(weekUp.state.plans.find((plan) => plan.id === id) ?? null)} />} {tab === "week" && plan.removedAt === undefined)} goals={weekUp.state.goals} dailySettlements={weekUp.state.dailySettlements} settlements={weekUp.state.settlements} initialRange={selectedWeekRange} generatingHarvestIds={weekUp.generatingHarvestIds} onRetryHarvest={(id) => void weekUp.dispatch({ type: "settlement.harvest.retry", id })} onNewGoal={() => setGoalEditor({ period: "week" })} onEditGoal={(goal) => setGoalEditor({ period: "week", initial: goal })} onQuickAdd={(goalIds) => openQuickAdd(undefined, goalIds)} onOpenCalendar={() => openCalendar("week")} onOpenGrowth={() => setTab("growth")} onComplete={completePlan} onEditPlan={(id) => setPlanEditor(weekUp.state.plans.find((plan) => plan.id === id) ?? null)} onUndoPlan={undoPlan} onRemovePlan={(id) => void weekUp.dispatch({ type: "plan.remove", id })} onRescheduleOverdue={(id) => setOverdueEditor(weekUp.state.plans.find((plan) => plan.id === id) ?? null)} />} {tab === "month" && plan.removedAt === undefined)} goals={weekUp.state.goals} projects={weekUp.state.projects} projectCategories={weekUp.state.projectCategories} settlements={weekUp.state.settlements} weights={weights} generatingHarvestIds={weekUp.generatingHarvestIds} onRetryHarvest={(id) => void weekUp.dispatch({ type: "settlement.harvest.retry", id })} onNewGoal={() => setGoalEditor({ period: "month" })} onEditGoal={(goal) => setGoalEditor({ period: "month", initial: goal })} onOpenWeek={(weekRange) => { setSelectedWeekRange(weekRange); setTab("week"); }} onOpenCalendar={() => openCalendar("month")} onOpenWeight={() => setTab("weight")} />} - {tab === "calendar" && setPlanEditor(weekUp.state.plans.find((plan) => plan.id === id) ?? null)} />} + {tab === "calendar" && settlement.localDate)} initialMode={calendarInitialMode} content={calendarContent} onEditPlan={(id) => setPlanEditor(weekUp.state.plans.find((plan) => plan.id === id) ?? null)} />} {tab === "action-config" && project.source === "week-up" && project.archivedAt === undefined)} courses={weekUp.state.learningMoreCourses} courseProjects={weekUp.state.projects.filter((project) => project.source === "learning-more" && project.archivedAt === undefined)} onNewAttribute={() => setAttributeEditor("new")} onEditAttribute={(attribute) => setAttributeEditor(weekUp.state.attributes.find((item) => item.id === attribute.id) ?? null)} onNewProject={() => setProjectEditor("new")} onEditProject={setProjectEditor} onConfigureCourse={setProjectEditor} onCreateCategory={(name) => { void weekUp.dispatch({ type: "attribute-category.create", name }); }} onRenameCategory={(id, name) => { void weekUp.dispatch({ type: "attribute-category.rename", id, name }); }} onDeleteCategory={(id) => { void weekUp.dispatch({ type: "attribute-category.delete", id }); }} onCreateProjectCategory={(name, color) => { void weekUp.dispatch({ type: "project-category.create", name, color }); }} onRenameProjectCategory={(id, name, color) => { void weekUp.dispatch({ type: "project-category.rename", id, name, color }); }} onDeleteProjectCategory={(id) => { void weekUp.dispatch({ type: "project-category.delete", id }); }} />} {tab === "growth" && } - {tab === "weight" && void weekUp.dispatch({ type: "weight.target", valueKg })} />} + {tab === "weight" && void weekUp.dispatch({ type: "weight.target", valueKg })} />}
diff --git a/web/lib/unconfigured-time.ts b/web/lib/unconfigured-time.ts new file mode 100644 index 0000000..eaf1aa7 --- /dev/null +++ b/web/lib/unconfigured-time.ts @@ -0,0 +1,15 @@ +import type { PlanItem } from "./demo-model.ts"; + +export function selectUnconfiguredPlansForDate( + dateKey: string, + scheduledPlans: readonly PlanItem[], + untimedCompletionPlans: readonly PlanItem[], + settledDates: ReadonlySet, +): PlanItem[] { + const source = settledDates.has(dateKey) ? untimedCompletionPlans : scheduledPlans; + return source.filter((plan) => + plan.scheduledDate === dateKey + && plan.timeStatus === "unscheduled" + && (!settledDates.has(dateKey) || plan.completed) + ); +} diff --git a/web/lib/week-up-domain.ts b/web/lib/week-up-domain.ts index 9ae23a2..1cd6e85 100644 --- a/web/lib/week-up-domain.ts +++ b/web/lib/week-up-domain.ts @@ -2,7 +2,7 @@ import { colorIdForCategory, isCategoryColorId } from "./category-palette.ts"; import { canRescheduleInsideWeekUp, participatesInOverdueQueue } from "./overdue-policy.ts"; -export const WEEK_UP_SCHEMA_VERSION = 17 as const; +export const WEEK_UP_SCHEMA_VERSION = 18 as const; export type AiProviderId = "codex-cli" | "api"; @@ -645,16 +645,20 @@ function completePlan( const plan = state.plans.find((item) => item.id === planId && item.removedAt === undefined); if (!plan) throw new Error("plan_not_found"); if (options.source === "week-up" && planIsOverdue(plan, options.completedAt)) throw new Error("plan_overdue"); + const actualSegments = options.actualSegments?.length + ? options.actualSegments + : configuredPlanSegments(plan); + const resolvedOptions = { ...options, actualSegments }; const duplicateExternal = options.externalFactId && state.completionFacts.find((fact) => fact.externalFactId === options.externalFactId && fact.revertedAt === undefined); if (duplicateExternal) { - const updated = mergeCompletionFactDetails(duplicateExternal, options); + const updated = mergeCompletionFactDetails(duplicateExternal, resolvedOptions); return updated === duplicateExternal ? unchanged(state, duplicateExternal.id) : changed(state, { completionFacts: state.completionFacts.map((fact) => fact.id === updated.id ? updated : fact) }, duplicateExternal.id); } const existing = activeCompletion(state, planId); if (existing) { - const updated = mergeCompletionFactDetails(existing, options); + const updated = mergeCompletionFactDetails(existing, resolvedOptions); return updated === existing ? unchanged(state, existing.id) : changed(state, { completionFacts: state.completionFacts.map((fact) => fact.id === updated.id ? updated : fact) }, existing.id); @@ -667,7 +671,7 @@ function completePlan( completedAt: options.completedAt, source: options.source, ...(options.externalFactId ? { externalFactId: options.externalFactId } : {}), - actualSegments: normalizeActualSegments(options.actualSegments), + actualSegments: normalizeActualSegments(actualSegments), rewardSnapshot, }; const transactions = rewardSnapshot.map((reward) => ({ @@ -694,6 +698,15 @@ function normalizeActualSegments(segments: readonly PlanTimeSegmentInput[] | und return [...unique.values()].sort((left, right) => left.startAt.localeCompare(right.startAt) || left.endAt.localeCompare(right.endAt)); } +function configuredPlanSegments(plan: Pick): readonly PlanTimeSegmentInput[] { + if (plan.timeStatus === "unscheduled") return []; + return normalizeActualSegments( + plan.timeSegments?.length + ? plan.timeSegments.map(({ startAt, endAt }) => ({ startAt, endAt })) + : [{ startAt: plan.startAt, endAt: plan.endAt }], + ); +} + function mergeCompletionFactDetails( fact: CompletionFact, options: { externalFactId?: string; actualSegments?: readonly PlanTimeSegmentInput[] }, @@ -1209,8 +1222,28 @@ export function dispatchWeekUp(state: WeekUpState, command: WeekUpCommand, conte next = { ...next, category: project.category, unitKind: project.unit, unitQuantity: derived.quantity, rewards: derived.rewards }; } } + const completion = activeCompletion(state, current.id); + const scheduleChanged = makesUnscheduled || requestedSegments !== undefined; + if (completion && current.source === "week-up" && scheduleChanged && next.timeSegments?.length) { + next = { + ...next, + timeSegments: next.timeSegments.map((segment) => ({ + ...segment, + completedAt: segment.completedAt ?? completion.completedAt, + })), + }; + } let base: WeekUpState = { ...state, plans: state.plans.map((item) => item.id === command.id ? next : item) }; - if (activeCompletion(state, current.id) && !allSegmentsCompleted(next)) base = revertPlanCompletionInState(base, current.id, context, now); + if (completion && current.source === "week-up" && scheduleChanged) { + base = { + ...base, + completionFacts: base.completionFacts.map((fact) => fact.id === completion.id + ? { ...fact, actualSegments: configuredPlanSegments(next) } + : fact), + }; + } else if (completion && !allSegmentsCompleted(next)) { + base = revertPlanCompletionInState(base, current.id, context, now); + } return changed(state, { plans: base.plans, completionFacts: base.completionFacts, xpTransactions: base.xpTransactions }, command.id); } case "plan.remove": { @@ -1823,7 +1856,7 @@ export function migrateWeekUpState(value: unknown): WeekUpState { if (value === undefined || value === null) return createEmptyWeekUpState(); if (typeof value !== "object") throw new Error("database_state_invalid"); const version = (value as { schemaVersion?: unknown }).schemaVersion; - if (version !== 1 && version !== 2 && version !== 3 && version !== 4 && version !== 5 && version !== 6 && version !== 7 && version !== 8 && version !== 9 && version !== 10 && version !== 11 && version !== 12 && version !== 13 && version !== 14 && version !== 15 && version !== 16 && version !== WEEK_UP_SCHEMA_VERSION) throw new Error("database_schema_unsupported"); + if (version !== 1 && version !== 2 && version !== 3 && version !== 4 && version !== 5 && version !== 6 && version !== 7 && version !== 8 && version !== 9 && version !== 10 && version !== 11 && version !== 12 && version !== 13 && version !== 14 && version !== 15 && version !== 16 && version !== 17 && version !== WEEK_UP_SCHEMA_VERSION) throw new Error("database_schema_unsupported"); type MigratableSettlement = Omit & { harvest?: SettlementRecord["harvest"]; reflection?: string; @@ -1842,7 +1875,7 @@ export function migrateWeekUpState(value: unknown): WeekUpState { revertedAt?: string; }>; const raw = value as Omit & { - schemaVersion: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17; + schemaVersion: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18; attributeCategories?: readonly MigratableCategory[]; projectCategories?: readonly MigratableCategory[]; plans: readonly (Omit & Partial>)[]; @@ -1861,6 +1894,7 @@ export function migrateWeekUpState(value: unknown): WeekUpState { return counts; }, new Map()); const completionFacts: CompletionFact[] = raw.completionFacts.map((fact) => { + const plan = raw.plans.find((item) => item.id === fact.planId); const legacySegments = legacyExecutionRecords .filter((record) => record.revertedAt === undefined @@ -1874,9 +1908,16 @@ export function migrateWeekUpState(value: unknown): WeekUpState { ) ) .map(({ startAt, endAt }) => ({ startAt, endAt })); + const configuredSegments = plan ? configuredPlanSegments(plan) : []; return { ...fact, - actualSegments: normalizeActualSegments(fact.actualSegments ?? legacySegments), + actualSegments: normalizeActualSegments( + fact.actualSegments?.length + ? fact.actualSegments + : legacySegments.length + ? legacySegments + : configuredSegments, + ), }; }); const plans: PlanRecord[] = raw.plans.map((plan) => { diff --git a/web/server/week-up-database.mjs b/web/server/week-up-database.mjs index 8ba400b..28f85e2 100644 --- a/web/server/week-up-database.mjs +++ b/web/server/week-up-database.mjs @@ -3,7 +3,7 @@ import { mkdir, readdir, unlink } from "node:fs/promises"; import { dirname, join } from "node:path"; import { backup, DatabaseSync } from "node:sqlite"; -import { createEmptyWeekUpState, dispatchWeekUp, migrateWeekUpState } from "../lib/week-up-domain.ts"; +import { createEmptyWeekUpState, dispatchWeekUp, migrateWeekUpState, WEEK_UP_SCHEMA_VERSION } from "../lib/week-up-domain.ts"; import { createLearningMoreDelta } from "../lib/learning-more-delta.ts"; import { createWeekUpStatePatch } from "../lib/state-patch.ts"; @@ -143,12 +143,14 @@ export async function createWeekUpDatabase(databasePath) { } const hybridVersion = database.prepare("SELECT value FROM week_up_meta WHERE key = 'hybrid_model_version'").get()?.value; + const persistedSchemaVersion = JSON.parse(database.prepare("SELECT state_json FROM week_up_snapshots ORDER BY revision DESC LIMIT 1").get().state_json).schemaVersion; let migrationBackupPath; - if (hybridVersion !== HYBRID_MODEL_VERSION) { + if (hybridVersion !== HYBRID_MODEL_VERSION || persistedSchemaVersion !== WEEK_UP_SCHEMA_VERSION) { const eventCount = database.prepare("SELECT COUNT(*) AS count FROM week_up_events").get().count; if (currentState.revision > 0 || eventCount > 0) { const stamp = new Date().toISOString().replace(/[:.]/g, "-"); - migrationBackupPath = join(dirname(dirname(databasePath)), "backups", `pre-hybrid-${stamp}.sqlite`); + const backupPrefix = hybridVersion !== HYBRID_MODEL_VERSION ? "pre-hybrid" : `pre-schema-${WEEK_UP_SCHEMA_VERSION}`; + migrationBackupPath = join(dirname(dirname(databasePath)), "backups", `${backupPrefix}-${stamp}.sqlite`); await mkdir(dirname(migrationBackupPath), { recursive: true }); await backup(database, migrationBackupPath); } diff --git a/web/tests/demo-source.test.mjs b/web/tests/demo-source.test.mjs index 37c292b..10bf0bd 100644 --- a/web/tests/demo-source.test.mjs +++ b/web/tests/demo-source.test.mjs @@ -193,7 +193,24 @@ test("opens the month calendar directly from the month dashboard", async () => { assert.match(monthDashboard, /打开月日历/); assert.doesNotMatch(monthDashboard, /+ 月方向/); assert.match(page, /onOpenCalendar=\{\(\) => openCalendar\("month"\)\}/); - assert.match(page, / { + const [page, css] = await Promise.all([ + readFile(new URL("../app/page.tsx", import.meta.url), "utf8"), + readFile(new URL("../app/globals.css", import.meta.url), "utf8"), + ]); + assert.match(page, /const hasTodayWeight = weights\.some\(\(entry\) => entry\.date === currentLocalDate\(\)\)/); + assert.match(page, /!hasTodayWeight && /); + assert.match(page, /className="weight-form weight-form--quick"/); + assert.match(page, /保存记录/); + assert.match(page, /onRecordWeight=\{addWeight\}/); + const weightView = page.slice(page.indexOf("function WeightView"), page.indexOf("function RewardAttributeTiles")); + assert.doesNotMatch(weightView, /className="weight-form"/); + assert.match(css, /\.weight-form--quick \{[^}]*width: min\(100%,240px\)/); + assert.match(css, /\.weight-form--quick input:focus, \.weight-form--quick input:focus-visible \{ outline: none; box-shadow: none; \}/); + assert.match(css, /\.weight-form--quick \.pixel-button \{[^}]*min-height: 38px/); }); test("keeps unscheduled plans outside time slots and uses one clickable time ticket", async () => { @@ -210,6 +227,10 @@ test("keeps unscheduled plans outside time slots and uses one clickable time tic assert.match(page, /点击时间票券直接设置/); assert.match(page, /plan-time--button/); assert.match(css, /\.unscheduled-dock/); + assert.match(css, /\.drawer-plan__time \{[^}]*background: var\(--yellow\)/); + assert.match(css, /button\.drawer-plan__time \{ cursor: pointer; \}/); + assert.match(css, /button\.drawer-plan__time:hover, button\.drawer-plan__time:focus-visible/); + assert.doesNotMatch(css, /\.drawer-plan__time\.is-unscheduled \{[^}]*font-family/); assert.match(css, /\.dashboard-action-list \{[^}]*grid-template-columns: repeat\(2,minmax\(0,1fr\)\)/); }); @@ -217,9 +238,13 @@ test("shows untimed completion facts in the Timeline top area", async () => { const page = await readFile(new URL("../app/page.tsx", import.meta.url), "utf8"); assert.doesNotMatch(page, /\{content === "schedule" &&
/); assert.match(page, /content === "timeline" \? "未配置时间" : "待安排"/); - assert.match(page, /const countDescription = content === "timeline" \? "条完成记录未配置实际时间"/); - assert.match(page, /content === "timeline" \? "已完成但未记录实际时段"/); - assert.match(page, /readOnly=\{content === "timeline"\}/); + assert.match(page, /const count = unconfiguredPlansForDate\(date\.key\)\.length/); + assert.match(page, /const countDescription = content === "timeline" \? "项未配置时间任务"/); + assert.match(page, /content === "timeline" \? "未配置时间任务" : "待安排"/); + assert.match(page, /unconfiguredPlans=\{plans\}/); + assert.match(page, /selectUnconfiguredPlansForDate/); + assert.match(page, /settledDates=\{weekUp\.state\.dailySettlements\.map/); + assert.match(page, /selectedUnscheduled\.map\(\(plan\) => ]*mode=\{mode\} onEditPlan=\{onEditPlan\}/); }); test("does not hide non-overlapping calendar plans behind a fixed-count overflow ticket", async () => { diff --git a/web/tests/unconfigured-time.test.mjs b/web/tests/unconfigured-time.test.mjs new file mode 100644 index 0000000..79f3efa --- /dev/null +++ b/web/tests/unconfigured-time.test.mjs @@ -0,0 +1,52 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { selectUnconfiguredPlansForDate } from "../lib/unconfigured-time.ts"; + +function plan(id, overrides = {}) { + return { + id, + title: id, + detail: "", + start: "时间未配置", + end: "", + scheduledDate: "2026-07-25", + timeStatus: "unscheduled", + category: "学习", + categoryColor: "#fff", + categoryTextColor: "#000", + completed: false, + rewards: [], + ...overrides, + }; +} + +test("counts only untimed completed work for a settled date", () => { + const scheduled = [ + plan("unfinished"), + plan("overdue", { overdue: true }), + plan("completed-schedule", { completed: true }), + ]; + const completions = [ + plan("untimed-completion", { completed: true }), + plan("timed-completion", { completed: true, timeStatus: "scheduled", start: "09:00", end: "10:00" }), + ]; + + assert.deepEqual( + selectUnconfiguredPlansForDate("2026-07-25", scheduled, completions, new Set(["2026-07-25"])).map((item) => item.id), + ["untimed-completion"], + ); +}); + +test("counts every unconfigured plan for an unsettled date", () => { + const scheduled = [ + plan("unfinished", { scheduledDate: "2026-07-26" }), + plan("completed", { scheduledDate: "2026-07-26", completed: true }), + plan("timed", { scheduledDate: "2026-07-26", timeStatus: "scheduled", start: "09:00", end: "10:00" }), + ]; + + assert.deepEqual( + selectUnconfiguredPlansForDate("2026-07-26", scheduled, [], new Set()).map((item) => item.id), + ["unfinished", "completed"], + ); +}); diff --git a/web/tests/view-projection.test.mjs b/web/tests/view-projection.test.mjs index c656cf7..9ecf6b9 100644 --- a/web/tests/view-projection.test.mjs +++ b/web/tests/view-projection.test.mjs @@ -91,6 +91,8 @@ test("keeps an untimed completion visible at the top of its completion day", () category: "Work", startAt: "2026-07-29T17:00:00+08:00", endAt: "2026-07-29T19:00:00+08:00", + timeStatus: "unscheduled", + timeSegments: [], goalIds: [], rewards: [], }, diff --git a/web/tests/week-up-domain.test.mjs b/web/tests/week-up-domain.test.mjs index 1f690bc..c43fa57 100644 --- a/web/tests/week-up-domain.test.mjs +++ b/web/tests/week-up-domain.test.mjs @@ -852,6 +852,126 @@ test("folds legacy execution records into their completion fact", () => { assert.equal("executionRecords" in migrated, false); }); +test("restores completion time from configured plan segments when migrating", () => { + const h = harness(); + let state = addAttribute(h, createEmptyWeekUpState()); + state = addPlan(h, state, state.attributes[0].id, { + timeSegments: [ + { id: "morning", startAt: "2026-07-20T09:10:00+08:00", endAt: "2026-07-20T09:55:00+08:00" }, + { id: "afternoon", startAt: "2026-07-20T14:00:00+08:00", endAt: "2026-07-20T14:45:00+08:00" }, + ], + }); + state = h.run(state, { + type: "plan.complete", + id: state.plans[0].id, + completedAt: "2026-07-20T15:00:00+08:00", + }); + + const migrated = migrateWeekUpState({ + ...state, + schemaVersion: 17, + completionFacts: state.completionFacts.map((fact) => ({ ...fact, actualSegments: [] })), + }); + + assert.deepEqual(migrated.completionFacts[0].actualSegments, [ + { startAt: "2026-07-20T09:10:00+08:00", endAt: "2026-07-20T09:55:00+08:00" }, + { startAt: "2026-07-20T14:00:00+08:00", endAt: "2026-07-20T14:45:00+08:00" }, + ]); +}); + +test("restores Learning MORE completion time from synchronized lesson segments", () => { + const h = harness(); + let state = addAttribute(h, createEmptyWeekUpState()); + state = addPlan(h, state, state.attributes[0].id, { + source: "learning-more", + sourceRef: "learning-more:schedule-1", + sourceLessonId: "lesson-1", + sourceCourseId: "course-1", + timeStatus: "scheduled", + timeSegments: [{ + id: "synced-lesson", + startAt: "2026-07-20T13:15:00+08:00", + endAt: "2026-07-20T14:05:00+08:00", + }], + }); + state = h.run(state, { + type: "plan.complete", + id: state.plans[0].id, + source: "learning-more", + completedAt: "2026-07-20T14:05:00+08:00", + }); + + const migrated = migrateWeekUpState({ + ...state, + schemaVersion: 17, + completionFacts: state.completionFacts.map((fact) => ({ ...fact, actualSegments: [] })), + }); + + assert.deepEqual(migrated.completionFacts[0].actualSegments, [{ + startAt: "2026-07-20T13:15:00+08:00", + endAt: "2026-07-20T14:05:00+08:00", + }]); +}); + +test("completing a configured plan records its plan segments as execution time", () => { + const h = harness(); + let state = addAttribute(h, createEmptyWeekUpState()); + state = addPlan(h, state, state.attributes[0].id, { + timeSegments: [ + { id: "first", startAt: "2026-07-20T09:00:00+08:00", endAt: "2026-07-20T09:30:00+08:00" }, + { id: "second", startAt: "2026-07-20T15:00:00+08:00", endAt: "2026-07-20T15:30:00+08:00" }, + ], + }); + + state = h.run(state, { + type: "plan.complete", + id: state.plans[0].id, + completedAt: "2026-07-20T16:00:00+08:00", + }); + + assert.deepEqual(state.completionFacts[0].actualSegments, [ + { startAt: "2026-07-20T09:00:00+08:00", endAt: "2026-07-20T09:30:00+08:00" }, + { startAt: "2026-07-20T15:00:00+08:00", endAt: "2026-07-20T15:30:00+08:00" }, + ]); +}); + +test("editing time on a completed ordinary plan updates execution without undoing completion", () => { + const h = harness(); + let state = addAttribute(h, createEmptyWeekUpState()); + state = addPlan(h, state, state.attributes[0].id); + const planId = state.plans[0].id; + state = h.run(state, { + type: "plan.complete", + id: planId, + completedAt: "2026-07-20T10:00:00+08:00", + }); + const factId = state.completionFacts[0].id; + + state = h.run(state, { + type: "plan.update", + id: planId, + patch: { + startAt: "2026-07-20T14:00:00+08:00", + endAt: "2026-07-20T15:00:00+08:00", + timeStatus: "scheduled", + timeSegments: [{ + id: "edited", + startAt: "2026-07-20T14:00:00+08:00", + endAt: "2026-07-20T15:00:00+08:00", + }], + }, + }); + + const fact = state.completionFacts.find((item) => item.id === factId); + assert.equal(fact.revertedAt, undefined); + assert.deepEqual(fact.actualSegments, [{ + startAt: "2026-07-20T14:00:00+08:00", + endAt: "2026-07-20T15:00:00+08:00", + }]); + assert.equal(state.plans[0].timeSegments[0].completedAt, "2026-07-20T10:00:00+08:00"); + assert.equal(totalXpForAttribute(state, state.attributes[0].id), 2); +}); + test("migrates legacy Learning MORE clock times back to time-unconfigured", () => { const h = harness(); let state = addAttribute(h, createEmptyWeekUpState());