From edcbe76b8b79c3a24bf8bf4a2cb2ab37d8f0f9ba Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 26 Jul 2026 12:30:58 +0800 Subject: [PATCH 1/3] Document segment timeline sync --- ...2026-07-26-segment-timeline-sync-design.md | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-26-segment-timeline-sync-design.md diff --git a/docs/superpowers/specs/2026-07-26-segment-timeline-sync-design.md b/docs/superpowers/specs/2026-07-26-segment-timeline-sync-design.md new file mode 100644 index 0000000..2b1b7de --- /dev/null +++ b/docs/superpowers/specs/2026-07-26-segment-timeline-sync-design.md @@ -0,0 +1,22 @@ +# 分段执行同步时间线设计 + +## 行为 + +- 已安排时间的普通任务点击完成后直接完成,不弹出实际执行时间确认。 +- 已安排时间的分段任务完成一段后,立即用该段的计划时段显示在 Timeline。 +- 未配置时间的任务完成时保留实际执行日期与时段确认,并以确认值显示在 Timeline。 +- 分段任务只有全部分段完成后才完成整项并发放一次奖励。 +- 撤销一个已完成分段时,该段立即从 Timeline 移除;若整项原本已完成,同时撤销整项完成与奖励。 + +## 数据与投影 + +- 不新增“部分完成事实”或数据库结构。 +- 分段进度继续存放在 `PlanRecord.timeSegments[].completedAt`。 +- Timeline 在没有整项完成事实时,投影已完成分段;整项完成后改由 `CompletionFact.actualSegments` 投影,避免重复。 +- 已安排时间的完成命令直接采用计划时段;只有 `timeStatus === "unscheduled"` 时才要求用户确认实际时段。 + +## 验证 + +- 投影测试覆盖完成第一段后 Timeline 立即出现、未完成段不出现、全部完成后不重复以及撤销后移除。 +- UI 测试覆盖已安排任务不打开确认窗口、未配置时间任务仍打开确认窗口。 +- 完整类型检查、测试、构建和浏览器核验通过。 From 1a8012b405e10aa66c3c914d16aec9b1c3b164ee Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 26 Jul 2026 12:36:22 +0800 Subject: [PATCH 2/3] Sync completed segments to Timeline --- ...2026-07-26-segment-timeline-sync-design.md | 22 --- web/app/page.tsx | 31 +++-- web/lib/use-week-up.ts | 130 +++++++++++------- web/lib/week-up-domain.ts | 9 +- web/tests/demo-source.test.mjs | 10 ++ web/tests/view-projection.test.mjs | 54 ++++++++ 6 files changed, 171 insertions(+), 85 deletions(-) delete mode 100644 docs/superpowers/specs/2026-07-26-segment-timeline-sync-design.md diff --git a/docs/superpowers/specs/2026-07-26-segment-timeline-sync-design.md b/docs/superpowers/specs/2026-07-26-segment-timeline-sync-design.md deleted file mode 100644 index 2b1b7de..0000000 --- a/docs/superpowers/specs/2026-07-26-segment-timeline-sync-design.md +++ /dev/null @@ -1,22 +0,0 @@ -# 分段执行同步时间线设计 - -## 行为 - -- 已安排时间的普通任务点击完成后直接完成,不弹出实际执行时间确认。 -- 已安排时间的分段任务完成一段后,立即用该段的计划时段显示在 Timeline。 -- 未配置时间的任务完成时保留实际执行日期与时段确认,并以确认值显示在 Timeline。 -- 分段任务只有全部分段完成后才完成整项并发放一次奖励。 -- 撤销一个已完成分段时,该段立即从 Timeline 移除;若整项原本已完成,同时撤销整项完成与奖励。 - -## 数据与投影 - -- 不新增“部分完成事实”或数据库结构。 -- 分段进度继续存放在 `PlanRecord.timeSegments[].completedAt`。 -- Timeline 在没有整项完成事实时,投影已完成分段;整项完成后改由 `CompletionFact.actualSegments` 投影,避免重复。 -- 已安排时间的完成命令直接采用计划时段;只有 `timeStatus === "unscheduled"` 时才要求用户确认实际时段。 - -## 验证 - -- 投影测试覆盖完成第一段后 Timeline 立即出现、未完成段不出现、全部完成后不重复以及撤销后移除。 -- UI 测试覆盖已安排任务不打开确认窗口、未配置时间任务仍打开确认窗口。 -- 完整类型检查、测试、构建和浏览器核验通过。 diff --git a/web/app/page.tsx b/web/app/page.tsx index 31c0a7c..651addd 100644 --- a/web/app/page.tsx +++ b/web/app/page.tsx @@ -26,7 +26,7 @@ import { useWeekUp } from "../lib/use-week-up"; import { AiStatusControl } from "./ai-status-control"; import { ExecutionCompletionModal } from "./execution-completion-modal"; import { MilestoneRunner } from "./milestone-runner"; -import { exportWeekUpBackup, importWeekUpBackup, type AttributeCategoryRecord, type AttributeRecord, type CompletionFact, type GoalRecord, type LearningMoreCourse, type LearningMoreLesson, type PlanRecord, type PlanTimeSegment, type PlanTimeSegmentInput, type ProjectRecord, type RewardUnit, type SettlementRecord, type SkillbookRecord, type WeekUpState } from "../lib/week-up-domain"; +import { exportWeekUpBackup, importWeekUpBackup, type AttributeCategoryRecord, type AttributeRecord, type CompletionFact, type GoalRecord, type LearningMoreCourse, type LearningMoreLesson, type PlanRecord, type PlanTimeSegment, type PlanTimeSegmentInput, type ProjectRecord, type RewardUnit, type SettlementRecord, type SkillbookRecord, type WeekUpCommand, type WeekUpState } from "../lib/week-up-domain"; type TabId = "today" | "week" | "month" | "calendar" | "action-config" | "growth" | "weight"; @@ -1361,12 +1361,28 @@ export default function Home() { setQuickAddOpen(true); }; + const dispatchPlanCompletion = (record: PlanRecord, plan: PlanItem | undefined, command: WeekUpCommand) => { + void weekUp.dispatch(command).then((next) => { + const savedPlan = next.plans.find((item) => item.id === record.id); + const completed = next.completionFacts.some((fact) => fact.planId === record.id && fact.revertedAt === undefined); + if (!completed) return; + showCompletionFeedback({ kind: "single", title: savedPlan?.title ?? plan?.title ?? record.title, rewards: [...(savedPlan?.rewards ?? plan?.rewards ?? record.rewards)], source: "week-up" }); + }); + }; + const completePlan = (id: string, segmentId?: string) => { const plan = plans.find((item) => item.id === id); if (!plan || plan.completed) return; const record = weekUp.state.plans.find((item) => item.id === id && item.removedAt === undefined); if (!record || record.source === "learning-more") return; - setExecutionEditor({ plan: record, ...(segmentId ? { segmentId } : {}) }); + if (record.timeStatus === "unscheduled") { + setExecutionEditor({ plan: record, ...(segmentId ? { segmentId } : {}) }); + return; + } + const command: WeekUpCommand = segmentId + ? { type: "plan.segment.complete", id: record.id, segmentId } + : { type: "plan.complete", id: record.id }; + dispatchPlanCompletion(record, plan, command); }; const confirmPlanCompletion = (value: { actualSegments: readonly PlanTimeSegmentInput[]; completedAt: string }) => { @@ -1374,15 +1390,10 @@ export default function Home() { const { plan: record, segmentId } = executionEditor; const plan = plans.find((item) => item.id === record.id); setExecutionEditor(null); - void weekUp.dispatch(segmentId + const command: WeekUpCommand = segmentId ? { type: "plan.segment.complete", id: record.id, segmentId, actualSegment: value.actualSegments[0]!, completedAt: value.completedAt } - : { type: "plan.complete", id: record.id, actualSegments: value.actualSegments, completedAt: value.completedAt } - ).then((next) => { - const savedPlan = next.plans.find((item) => item.id === record.id); - const completed = next.completionFacts.some((fact) => fact.planId === record.id && fact.revertedAt === undefined); - if (!completed) return; - showCompletionFeedback({ kind: "single", title: savedPlan?.title ?? plan?.title ?? record.title, rewards: [...(savedPlan?.rewards ?? plan?.rewards ?? record.rewards)], source: "week-up" }); - }); + : { type: "plan.complete", id: record.id, actualSegments: value.actualSegments, completedAt: value.completedAt }; + dispatchPlanCompletion(record, plan, command); }; const undoPlan = (id: string, segmentId?: string) => { diff --git a/web/lib/use-week-up.ts b/web/lib/use-week-up.ts index 9836ae9..73884bf 100644 --- a/web/lib/use-week-up.ts +++ b/web/lib/use-week-up.ts @@ -5,6 +5,9 @@ import { createEmptyWeekUpState, currentWeightEntries, totalXpForAttribute, + type CompletionFact, + type PlanRecord, + type PlanTimeSegmentInput, type WeekUpCommand, type WeekUpState, } from "./week-up-domain.ts"; @@ -57,6 +60,65 @@ export type WeekUpViewModel = Readonly<{ weights: WeightEntry[]; }>; +function projectTimelinePlan( + state: WeekUpState, + item: PlanRecord, + value: { + id: string; + segment?: PlanTimeSegmentInput; + completedAt: string; + executionSource: CompletionFact["source"]; + }, +): PlanItem { + const currentProject = item.projectId + ? state.projects.find((project) => project.id === item.projectId) + : item.sourceCourseId + ? state.projects.find((project) => project.sourceCourseId === item.sourceCourseId) + : undefined; + const category = currentProject?.category ?? item.category; + const categoryColor = colorForCategory(category, state.projectCategories.find((entry) => entry.name === category)?.color); + const anchor = value.segment?.startAt ?? value.completedAt; + return { + id: value.id, + calendarSourceId: item.id, + ...(item.projectId ? { projectId: item.projectId } : {}), + scheduledDate: dateInShanghai(anchor), + title: item.title, + detail: item.detail, + start: value.segment ? timeInShanghai(value.segment.startAt) : "时间未配置", + end: value.segment ? timeInShanghai(value.segment.endAt) : "", + timeSegments: value.segment ? [{ + id: value.id, + start: timeInShanghai(value.segment.startAt), + end: timeInShanghai(value.segment.endAt), + completed: true, + }] : [], + timeStatus: value.segment ? "scheduled" : "unscheduled", + category, + categoryColor, + categoryTextColor: readableTextColor(categoryColor), + completed: true, + completedAt: value.completedAt, + completedDate: dateInShanghai(value.completedAt), + completedEarly: completedBeforeSchedule(value.completedAt, item), + rewards: item.rewards.map((reward) => ({ ...reward })), + source: item.source, + executionSource: value.executionSource, + syncState: "completed", + dayIndex: dayIndexFor(anchor), + scheduleGroup: "completed", + rewardMode: item.rewardMode, + templateLabel: item.projectId + ? state.projects.find((project) => project.id === item.projectId)?.name + : item.sourceCourseId + ? state.learningMoreCourses.find((course) => course.courseId === item.sourceCourseId)?.title + : undefined, + unitLabel: item.unitKind && item.unitQuantity !== undefined + ? `${item.unitQuantity} ${item.unitKind === "hour" ? "时" : item.unitKind === "lesson" ? "节" : "次"}` + : undefined, + }; +} + export function projectWeekUpView(state: WeekUpState, at = new Date()): WeekUpViewModel { const activeFacts = new Map(state.completionFacts.filter((fact) => fact.revertedAt === undefined).map((fact) => [fact.planId, fact])); const today = dateInShanghai(at.toISOString()); @@ -129,62 +191,34 @@ export function projectWeekUpView(state: WeekUpState, at = new Date()): WeekUpVi }; }); const activePlans = new Map(state.plans.filter((item) => item.removedAt === undefined).map((item) => [item.id, item])); - const timelinePlans: PlanItem[] = state.completionFacts + const completedTimelinePlans: PlanItem[] = state.completionFacts .filter((fact) => fact.revertedAt === undefined) .flatMap((fact): PlanItem[] => { const item = activePlans.get(fact.planId); if (!item) return []; - const currentProject = item.projectId - ? state.projects.find((project) => project.id === item.projectId) - : item.sourceCourseId - ? state.projects.find((project) => project.sourceCourseId === item.sourceCourseId) - : undefined; - const category = currentProject?.category ?? item.category; - const categoryColor = colorForCategory(category, state.projectCategories.find((entry) => entry.name === category)?.color); const segments = fact.actualSegments.length > 0 ? fact.actualSegments : [undefined]; - return segments.map((segment, index): PlanItem => { - const anchor = segment?.startAt ?? fact.completedAt; - return { + return segments.map((segment, index) => projectTimelinePlan(state, item, { id: segment ? `${fact.id}:actual:${index}` : fact.id, - calendarSourceId: item.id, - ...(item.projectId ? { projectId: item.projectId } : {}), - scheduledDate: dateInShanghai(anchor), - title: item.title, - detail: item.detail, - start: segment ? timeInShanghai(segment.startAt) : "时间未配置", - end: segment ? timeInShanghai(segment.endAt) : "", - timeSegments: segment ? [{ - id: `${fact.id}:actual:${index}`, - start: timeInShanghai(segment.startAt), - end: timeInShanghai(segment.endAt), - completed: true, - }] : [], - timeStatus: segment ? "scheduled" : "unscheduled", - category, - categoryColor, - categoryTextColor: readableTextColor(categoryColor), - completed: true, + ...(segment ? { segment } : {}), completedAt: fact.completedAt, - completedDate: dateInShanghai(fact.completedAt), - completedEarly: completedBeforeSchedule(fact.completedAt, item), - rewards: item.rewards.map((reward) => ({ ...reward })), - source: item.source, executionSource: fact.source, - syncState: "completed", - dayIndex: dayIndexFor(anchor), - scheduleGroup: "completed", - rewardMode: item.rewardMode, - templateLabel: item.projectId - ? state.projects.find((project) => project.id === item.projectId)?.name - : item.sourceCourseId - ? state.learningMoreCourses.find((course) => course.courseId === item.sourceCourseId)?.title - : undefined, - unitLabel: item.unitKind && item.unitQuantity !== undefined - ? `${item.unitQuantity} ${item.unitKind === "hour" ? "时" : item.unitKind === "lesson" ? "节" : "次"}` - : undefined, - }; - }); - }) + })); + }); + const partialSegmentTimelinePlans: PlanItem[] = state.plans + .filter((item) => item.removedAt === undefined && !activeFacts.has(item.id)) + .flatMap((item) => (item.timeSegments ?? []).flatMap((segment): PlanItem[] => { + if (!segment.completedAt) return []; + return [projectTimelinePlan(state, item, { + id: `${item.id}:segment:${segment.id}`, + segment: { + startAt: segment.actualStartAt ?? segment.startAt, + endAt: segment.actualEndAt ?? segment.endAt, + }, + completedAt: segment.completedAt, + executionSource: "week-up", + })]; + })); + const timelinePlans = [...completedTimelinePlans, ...partialSegmentTimelinePlans] .sort((left, right) => (left.scheduledDate ?? "").localeCompare(right.scheduledDate ?? "") || left.start.localeCompare(right.start) diff --git a/web/lib/week-up-domain.ts b/web/lib/week-up-domain.ts index 1cd6e85..a48f153 100644 --- a/web/lib/week-up-domain.ts +++ b/web/lib/week-up-domain.ts @@ -1461,11 +1461,10 @@ export function dispatchWeekUp(state: WeekUpState, command: WeekUpCommand, conte if (!allSegmentsCompleted(updatedPlan)) { return changed(state, { plans: prepared.plans }, command.segmentId); } - const actualSegments = (updatedPlan.timeSegments ?? []).flatMap((segment): PlanTimeSegmentInput[] => - segment.actualStartAt && segment.actualEndAt - ? [{ startAt: segment.actualStartAt, endAt: segment.actualEndAt }] - : [] - ); + const actualSegments = (updatedPlan.timeSegments ?? []).map((segment): PlanTimeSegmentInput => ({ + startAt: segment.actualStartAt ?? segment.startAt, + endAt: segment.actualEndAt ?? segment.endAt, + })); const outcome = completePlan(prepared, plan.id, context, { source: "week-up", completedAt, diff --git a/web/tests/demo-source.test.mjs b/web/tests/demo-source.test.mjs index c894091..7b735e1 100644 --- a/web/tests/demo-source.test.mjs +++ b/web/tests/demo-source.test.mjs @@ -386,6 +386,16 @@ test("supports multiple execution segments with one final plan settlement", asyn assert.match(css, /\.segment-complete-button/); }); +test("confirms execution time only for plans without configured time", async () => { + const page = await readFile(new URL("../app/page.tsx", import.meta.url), "utf8"); + const completionFlow = page.slice(page.indexOf("const dispatchPlanCompletion ="), page.indexOf("const undoPlan =")); + assert.match(completionFlow, /record\.timeStatus === "unscheduled"/); + assert.match(completionFlow, /setExecutionEditor\(\{ plan: record/); + assert.match(completionFlow, /type: "plan\.segment\.complete"/); + assert.match(completionFlow, /type: "plan\.complete"/); + assert.match(completionFlow, /weekUp\.dispatch\(command\)\.then/); +}); + test("keeps original edit times and allows every plan editor to clear them", async () => { const [page, css] = await Promise.all([ readFile(new URL("../app/page.tsx", import.meta.url), "utf8"), diff --git a/web/tests/view-projection.test.mjs b/web/tests/view-projection.test.mjs index 9ecf6b9..998fcbf 100644 --- a/web/tests/view-projection.test.mjs +++ b/web/tests/view-projection.test.mjs @@ -124,3 +124,57 @@ test("keeps an untimed completion visible at the top of its completion day", () }], ); }); + +test("projects each completed segment into Timeline before the whole plan is complete", () => { + let sequence = 0; + const context = { + now: () => "2026-07-26T12:00:00+08:00", + id: (prefix) => `${prefix}-${++sequence}`, + }; + const run = (state, command) => dispatchWeekUp(state, command, context).state; + let state = createEmptyWeekUpState(); + state = run(state, { + type: "plan.create", + value: { + title: "Segmented routine", + detail: "", + category: "Life", + startAt: "2026-07-26T08:30:00+08:00", + endAt: "2026-07-26T23:40:00+08:00", + timeSegments: [ + { id: "morning", startAt: "2026-07-26T08:30:00+08:00", endAt: "2026-07-26T08:40:00+08:00" }, + { id: "night", startAt: "2026-07-26T23:30:00+08:00", endAt: "2026-07-26T23:40:00+08:00" }, + ], + goalIds: [], + rewards: [], + }, + }); + const planId = state.plans[0].id; + + state = run(state, { type: "plan.segment.complete", id: planId, segmentId: "morning" }); + let view = projectWeekUpView(state, new Date("2026-07-26T12:00:00+08:00")); + assert.equal(state.completionFacts.length, 0); + assert.equal(view.plans.find((plan) => plan.id === planId)?.completed, false); + assert.deepEqual( + view.timelinePlans.map(({ calendarSourceId, scheduledDate, start, end }) => ({ calendarSourceId, scheduledDate, start, end })), + [{ calendarSourceId: planId, scheduledDate: "2026-07-26", start: "08:30", end: "08:40" }], + ); + + state = run(state, { type: "plan.segment.complete", id: planId, segmentId: "night" }); + view = projectWeekUpView(state, new Date("2026-07-26T23:50:00+08:00")); + assert.equal(state.completionFacts.filter((fact) => fact.revertedAt === undefined).length, 1); + assert.deepEqual( + view.timelinePlans.map(({ calendarSourceId, start, end }) => ({ calendarSourceId, start, end })), + [ + { calendarSourceId: planId, start: "08:30", end: "08:40" }, + { calendarSourceId: planId, start: "23:30", end: "23:40" }, + ], + ); + + state = run(state, { type: "plan.segment.undo", id: planId, segmentId: "morning" }); + view = projectWeekUpView(state, new Date("2026-07-26T23:55:00+08:00")); + assert.deepEqual( + view.timelinePlans.map(({ calendarSourceId, start, end }) => ({ calendarSourceId, start, end })), + [{ calendarSourceId: planId, start: "23:30", end: "23:40" }], + ); +}); From 0b4382ea5e08cd90e38d3440c4d4d1524b657776 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 26 Jul 2026 16:33:27 +0800 Subject: [PATCH 3/3] Sync completed course time to schedule --- web/lib/week-up-domain.ts | 46 ++++++++++++++++--- web/tests/week-up-domain.test.mjs | 73 +++++++++++++++++++++++++++---- 2 files changed, 105 insertions(+), 14 deletions(-) diff --git a/web/lib/week-up-domain.ts b/web/lib/week-up-domain.ts index a48f153..40a4268 100644 --- a/web/lib/week-up-domain.ts +++ b/web/lib/week-up-domain.ts @@ -1787,9 +1787,27 @@ export function dispatchWeekUp(state: WeekUpState, command: WeekUpCommand, conte Date.parse(actualEnd) > Date.parse(actualStart); next = { ...next, - plans: next.plans.map((item) => item.id === plan.id && item.timeSegments?.length - ? { ...item, timeSegments: item.timeSegments.map((segment) => ({ ...segment, completedAt: segment.completedAt ?? fact.occurredAt })), updatedAt: now } - : item), + plans: next.plans.map((item): PlanRecord => { + if (item.id !== plan.id) return item; + if (hasActualRange) { + return { + ...item, + startAt: actualStart, + endAt: actualEnd, + timeStatus: "scheduled", + timeSegments: [{ + id: item.timeSegments?.[0]?.id ?? context.id("segment"), + startAt: actualStart, + endAt: actualEnd, + completedAt: fact.occurredAt, + }], + updatedAt: now, + }; + } + return item.timeSegments?.length + ? { ...item, timeSegments: item.timeSegments.map((segment) => ({ ...segment, completedAt: segment.completedAt ?? fact.occurredAt })), updatedAt: now } + : item; + }), }; const duplicateExternal = fact.factId ? next.completionFacts.find((item) => item.externalFactId === fact.factId && item.revertedAt === undefined) @@ -1939,13 +1957,31 @@ export function migrateWeekUpState(value: unknown): WeekUpState { ? { ...segment, actualStartAt: legacyExecution.startAt, actualEndAt: legacyExecution.endAt } : segment; }); + const learningMoreActualSegments = plan.source === "learning-more" + && activeFact?.actualSegments.length + && activeFact.actualSegments.every((segment) => + localDate(segment.startAt) === localDate(activeFact.actualSegments[0]!.startAt) + && localDate(segment.endAt) === localDate(activeFact.actualSegments[0]!.startAt)) + ? activeFact.actualSegments + : undefined; + const synchronizedTimeSegments = learningMoreActualSegments?.map((segment, index): PlanTimeSegment => ({ + id: plan.timeSegments?.[index]?.id ?? `${plan.id}:segment:${index}`, + startAt: segment.startAt, + endAt: segment.endAt, + completedAt: activeFact!.completedAt, + })); return { ...plan, - timeSegments, + ...(synchronizedTimeSegments?.length ? { + startAt: synchronizedTimeSegments[0]!.startAt, + endAt: synchronizedTimeSegments.at(-1)!.endAt, + timeStatus: "scheduled" as const, + timeSegments: synchronizedTimeSegments, + } : { timeSegments }), rewardMode: plan.rewardMode ?? (plan.rewards.length > 0 ? "custom" : "none"), titleMode: plan.titleMode ?? "custom", ...(plan.source === "learning-more" ? { templateKind: plan.templateKind ?? "course", unitKind: plan.unitKind ?? "lesson", unitQuantity: plan.unitQuantity ?? 1 } : {}), - ...(migratedTimeStatus ? { timeStatus: migratedTimeStatus } : {}), + ...(!synchronizedTimeSegments?.length && migratedTimeStatus ? { timeStatus: migratedTimeStatus } : {}), ...(legacyExternalSchedule && !isCompleted && plan.removedAt === undefined ? { removedAt: plan.updatedAt } : {}), }; }); diff --git a/web/tests/week-up-domain.test.mjs b/web/tests/week-up-domain.test.mjs index c43fa57..de2a77a 100644 --- a/web/tests/week-up-domain.test.mjs +++ b/web/tests/week-up-domain.test.mjs @@ -986,7 +986,7 @@ test("migrates legacy Learning MORE clock times back to time-unconfigured", () = assert.equal(migrated.plans[0].timeStatus, "unscheduled"); }); -test("imports Learning MORE course tables and facts while Week UP owns schedule time", () => { +test("imports Learning MORE course tables and replaces configured time with authoritative completion time", () => { const h = harness(); let state = addAttribute(h, createEmptyWeekUpState()); const lessons = [{ @@ -1007,8 +1007,8 @@ test("imports Learning MORE course tables and facts while Week UP owns schedule ]; const updatedLessons = [{ ...lessons[0], title: "概率论:条件概率" }]; state = h.run(state, { type: "learning-more.import", courses: [{ courseId: "course-1", title: "概率论", status: "closed" }], lessons: updatedLessons, facts, nextCursor: "cursor-2" }); - assert.equal(state.plans[0].startAt, "2026-07-20T09:00:00+08:00"); - assert.equal(state.plans[0].endAt, "2026-07-20T10:00:00+08:00"); + assert.equal(state.plans[0].startAt, "2026-07-20T02:12:00Z"); + assert.equal(state.plans[0].endAt, "2026-07-20T03:47:00Z"); assert.equal(state.plans[0].timeStatus, "scheduled"); assert.deepEqual( state.completionFacts[0].actualSegments, @@ -1024,6 +1024,8 @@ test("imports Learning MORE course tables and facts while Week UP owns schedule assert.equal(totalXpForAttribute(duplicate, duplicate.attributes[0].id), 3); assert.equal(duplicate.learningMoreLessons[0].completedAt, "2026-07-20T02:00:00Z"); assert.equal(duplicate.plans[0].removedAt, undefined); + assert.equal(duplicate.plans[0].startAt, "2026-07-20T02:12:00Z"); + assert.equal(duplicate.plans[0].endAt, "2026-07-20T03:47:00Z"); }); test("keeps a directly completed overdue Learning MORE lesson on its original schedule", () => { @@ -1375,7 +1377,7 @@ test("keeps a future schedule in place when an ordinary plan is completed early" assert.deepEqual(state.dailySettlements[0].completedPlanIds, [planId]); }); -test("records Learning MORE actual time without moving its original schedule", () => { +test("syncs Learning MORE actual time back to the completed plan schedule", () => { const h = harness("2026-07-20T08:00:00.000Z"); const course = { courseId: "course-actual", title: "Course", status: "active" }; const lesson = { @@ -1393,9 +1395,6 @@ test("records Learning MORE actual time without moving its original schedule", ( facts: [], }); const planId = state.plans[0].id; - const scheduledStart = state.plans[0].startAt; - const scheduledEnd = state.plans[0].endAt; - state = h.run(state, { type: "learning-more.import", facts: [{ @@ -1410,8 +1409,14 @@ test("records Learning MORE actual time without moving its original schedule", ( }], }); - assert.equal(state.plans[0].startAt, scheduledStart); - assert.equal(state.plans[0].endAt, scheduledEnd); + assert.equal(state.plans[0].startAt, "2026-07-20T15:10:00+08:00"); + assert.equal(state.plans[0].endAt, "2026-07-20T16:00:00+08:00"); + assert.equal(state.plans[0].timeStatus, "scheduled"); + assert.deepEqual(state.plans[0].timeSegments?.map(({ startAt, endAt, completedAt }) => ({ startAt, endAt, completedAt })), [{ + startAt: "2026-07-20T15:10:00+08:00", + endAt: "2026-07-20T16:00:00+08:00", + completedAt: "2026-07-20T16:00:00+08:00", + }]); assert.equal(state.completionFacts[0].planId, planId); assert.equal(state.completionFacts[0].completedAt, "2026-07-20T16:00:00+08:00"); assert.deepEqual( @@ -1423,6 +1428,56 @@ test("records Learning MORE actual time without moving its original schedule", ( ); }); +test("backfills existing completed Learning MORE plan time from completion facts", () => { + const h = harness("2026-07-20T08:00:00.000Z"); + let state = h.run(createEmptyWeekUpState(), { + type: "learning-more.import", + courses: [{ courseId: "course-backfill", title: "Course", status: "active" }], + lessons: [{ + courseId: "course-backfill", + lessonId: "lesson-backfill", + scheduleItemId: "schedule-backfill", + scheduledDate: "2026-07-20", + title: "Lesson", + order: 0, + }], + facts: [{ + factId: "fact-backfill", + type: "lesson-completed", + occurredAt: "2026-07-20T16:00:00+08:00", + actualStartedAt: "2026-07-20T15:10:00+08:00", + actualEndedAt: "2026-07-20T16:00:00+08:00", + courseId: "course-backfill", + lessonId: "lesson-backfill", + scheduleItemId: "schedule-backfill", + }], + }); + state = { + ...state, + plans: state.plans.map((plan) => ({ + ...plan, + startAt: "2026-07-20T00:00:00+08:00", + endAt: "2026-07-20T01:00:00+08:00", + timeStatus: "unscheduled", + timeSegments: [], + })), + }; + + const migrated = migrateWeekUpState(state); + + assert.equal(migrated.plans[0].startAt, "2026-07-20T15:10:00+08:00"); + assert.equal(migrated.plans[0].endAt, "2026-07-20T16:00:00+08:00"); + assert.equal(migrated.plans[0].timeStatus, "scheduled"); + assert.deepEqual( + migrated.plans[0].timeSegments?.map(({ startAt, endAt, completedAt }) => ({ startAt, endAt, completedAt })), + [{ + startAt: "2026-07-20T15:10:00+08:00", + endAt: "2026-07-20T16:00:00+08:00", + completedAt: "2026-07-20T16:00:00+08:00", + }], + ); +}); + test("undoing one completed segment preserves other segment actual time", () => { const h = harness(); let state = addAttribute(h, createEmptyWeekUpState());