From 81936aa9e862b2c6f129ac654f921a05dc602bbb Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 26 Jul 2026 10:20:55 +0800 Subject: [PATCH 1/2] Document completion execution unification --- ...completion-execution-unification-design.md | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-26-completion-execution-unification-design.md diff --git a/docs/superpowers/specs/2026-07-26-completion-execution-unification-design.md b/docs/superpowers/specs/2026-07-26-completion-execution-unification-design.md new file mode 100644 index 0000000..8154998 --- /dev/null +++ b/docs/superpowers/specs/2026-07-26-completion-execution-unification-design.md @@ -0,0 +1,117 @@ +# 完成记录与执行时间统一设计 + +## 背景 + +当前版本同时持久化 `completionFacts` 与 `executionRecords`。一次完成可能已经产生完成事实、奖励和结算结果,却没有生成独立执行记录。时间轨迹只投影 `executionRecords`,因此出现“本周已有完成记录,但时间线完全为空”的非法状态。 + +根因不是历史数据缺失,而是领域模型把同一次完成拆成了两套可独立变化的事实。 + +## 设计原则 + +一次完成只产生一条权威完成记录。完成记录本身就是执行事实,同时承载: + +- 完成的行动; +- 完成时间和来源; +- 零个或多个实际执行时间段; +- 完成时冻结的奖励快照; +- 撤销状态。 + +实际执行时间允许为空。缺少实际时间不影响“已经完成”的真实性,也不能让完成记录从时间轨迹中消失。 + +## 数据模型 + +`CompletionFact` 增加 `actualSegments`: + +```ts +type CompletionFact = Readonly<{ + id: string; + planId: string; + completedAt: string; + source: "week-up" | "learning-more"; + externalFactId?: string; + actualSegments: readonly PlanTimeSegmentInput[]; + rewardSnapshot: readonly AttributeReward[]; + revertedAt?: string; +}>; +``` + +顶层 `WeekUpState.executionRecords` 和独立的 `ExecutionRecord` 类型不再作为持久化模型存在。一次完成包含多个实际时间段时,仍然只有一条 `CompletionFact`;时间轨迹在投影阶段把这些时间段展开为多个可视条目。 + +## 写入与撤销 + +### 完成行动 + +`plan.complete` 原子地创建一条 `CompletionFact`: + +- 用户确认了实际时间时,写入全部 `actualSegments`; +- 来源没有提供实际时间时,写入空数组; +- 奖励快照和 XP 继续与该完成记录关联。 + +重复导入同一外部完成事实时,只补充或更新同一完成记录的来源标识与实际时间段,不创建第二套执行事实。 + +### 分段完成 + +行动分段完成时,将已经确认的实际开始和结束时间写入对应行动分段的 `actualStartAt`、`actualEndAt` 字段。最后一个分段完成并生成 `CompletionFact` 时,将所有已确认的实际执行段汇总到该完成记录。完成记录创建后,行动分段仍保留这些字段作为编辑状态,但时间轨迹只读取完成记录。 + +### 撤销 + +撤销完成只需将对应 `CompletionFact` 标记为已撤销,并生成 XP 补偿。时间轨迹直接排除已撤销的完成记录,不再需要同步撤销另一张执行账本。 + +## 时间轨迹投影 + +时间轨迹直接读取有效 `CompletionFact`: + +- `actualSegments` 非空:按每段真实的开始日期和起止时间绘制到时间轴; +- `actualSegments` 为空:按 `completedAt` 的上海本地日期放入当天顶部“未配置时间”区; +- 同一完成记录包含多个实际时间段时,时间轴可显示多个条目,但详情和编辑入口仍指向同一行动与完成事实。 + +“未配置时间”区域在日程与时间轨迹两种模式下都可见: + +- 日程模式展示尚未安排时间的计划; +- 时间轨迹模式展示已经完成但没有实际执行时间的记录。 + +空状态只在当前视图范围内既没有已配置时间的完成记录,也没有未配置时间的完成记录时出现。 + +## 状态迁移 + +模式版本升级时执行确定性迁移: + +1. 遍历所有现有 `completionFacts`。 +2. 按 `completionFactId` 优先匹配现有有效 `executionRecords`;旧记录缺少关联字段时,仅当该 `planId` 只有一条有效完成事实时按 `planId` 匹配。 +3. 将匹配到的执行记录按开始时间排序,并转换为该完成记录的 `actualSegments`。 +4. 没有匹配执行记录的完成事实写入空数组。 +5. 已撤销的执行记录不进入有效完成记录。 +6. 完成迁移后移除顶层 `executionRecords`。 + +迁移不得使用计划的排期时间或 `completedAt` 伪造实际执行时段。旧完成记录没有实际时间时,应进入“未配置时间”区。 + +迁移必须幂等:已经包含 `actualSegments` 的新版本状态再次加载时,不重复追加时间段。 + +## 兼容性与错误处理 + +- Learning MORE 提供有效 `actualStartedAt` 与 `actualEndedAt` 时写入实际时间段;缺任一端或范围无效时保留空数组。 +- 同一完成事实重复同步时,相同时间段去重。 +- 行动被永久移除后,现有完成与回放策略保持不变;时间轨迹只显示仍可解析到行动内容的记录。 +- 数据库升级继续沿用现有迁移前备份机制。 + +## 验证 + +回归测试覆盖: + +1. 完成记录没有实际时间时仍出现在时间轨迹的“未配置时间”区。 +2. 一条完成记录包含一个或多个实际时间段时,时间轴正确展开。 +3. 时间轨迹不再依赖顶层 `executionRecords`。 +4. 旧状态中的执行记录正确合并进完成记录。 +5. 旧完成记录没有执行记录时迁移为空数组。 +6. 重复迁移与重复 Learning MORE 同步不产生重复时间段。 +7. 撤销完成后,时间轴条目和未配置时间条目同时消失,XP 补偿保持正确。 +8. 当前真实数据库的复现条件从“34 条本周完成、0 条时间线记录”变为“34 条本周完成均可见,其中无实际时间者位于顶部区域”。 + +类型检查、完整测试集和生产构建均需通过。 + +## 非目标 + +- 不推断或补写历史实际执行时间。 +- 不改变计划排期。 +- 不改变奖励、XP 或结算的业务规则。 +- 不在本次修复中增加完成记录的事后时间编辑功能。 From 4b788c5cd79ee5807d03914a4417e94cbc3d2abf Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 26 Jul 2026 10:35:27 +0800 Subject: [PATCH 2/2] Unify completion facts with execution time --- ...completion-execution-unification-design.md | 117 -------- web/app/page.tsx | 20 +- web/lib/state-patch.ts | 2 +- web/lib/use-week-up.ts | 43 +-- web/lib/week-up-domain.ts | 249 +++++++++--------- web/tests/demo-source.test.mjs | 9 + web/tests/view-projection.test.mjs | 47 ++++ web/tests/week-up-domain.test.mjs | 62 ++++- 8 files changed, 263 insertions(+), 286 deletions(-) delete mode 100644 docs/superpowers/specs/2026-07-26-completion-execution-unification-design.md diff --git a/docs/superpowers/specs/2026-07-26-completion-execution-unification-design.md b/docs/superpowers/specs/2026-07-26-completion-execution-unification-design.md deleted file mode 100644 index 8154998..0000000 --- a/docs/superpowers/specs/2026-07-26-completion-execution-unification-design.md +++ /dev/null @@ -1,117 +0,0 @@ -# 完成记录与执行时间统一设计 - -## 背景 - -当前版本同时持久化 `completionFacts` 与 `executionRecords`。一次完成可能已经产生完成事实、奖励和结算结果,却没有生成独立执行记录。时间轨迹只投影 `executionRecords`,因此出现“本周已有完成记录,但时间线完全为空”的非法状态。 - -根因不是历史数据缺失,而是领域模型把同一次完成拆成了两套可独立变化的事实。 - -## 设计原则 - -一次完成只产生一条权威完成记录。完成记录本身就是执行事实,同时承载: - -- 完成的行动; -- 完成时间和来源; -- 零个或多个实际执行时间段; -- 完成时冻结的奖励快照; -- 撤销状态。 - -实际执行时间允许为空。缺少实际时间不影响“已经完成”的真实性,也不能让完成记录从时间轨迹中消失。 - -## 数据模型 - -`CompletionFact` 增加 `actualSegments`: - -```ts -type CompletionFact = Readonly<{ - id: string; - planId: string; - completedAt: string; - source: "week-up" | "learning-more"; - externalFactId?: string; - actualSegments: readonly PlanTimeSegmentInput[]; - rewardSnapshot: readonly AttributeReward[]; - revertedAt?: string; -}>; -``` - -顶层 `WeekUpState.executionRecords` 和独立的 `ExecutionRecord` 类型不再作为持久化模型存在。一次完成包含多个实际时间段时,仍然只有一条 `CompletionFact`;时间轨迹在投影阶段把这些时间段展开为多个可视条目。 - -## 写入与撤销 - -### 完成行动 - -`plan.complete` 原子地创建一条 `CompletionFact`: - -- 用户确认了实际时间时,写入全部 `actualSegments`; -- 来源没有提供实际时间时,写入空数组; -- 奖励快照和 XP 继续与该完成记录关联。 - -重复导入同一外部完成事实时,只补充或更新同一完成记录的来源标识与实际时间段,不创建第二套执行事实。 - -### 分段完成 - -行动分段完成时,将已经确认的实际开始和结束时间写入对应行动分段的 `actualStartAt`、`actualEndAt` 字段。最后一个分段完成并生成 `CompletionFact` 时,将所有已确认的实际执行段汇总到该完成记录。完成记录创建后,行动分段仍保留这些字段作为编辑状态,但时间轨迹只读取完成记录。 - -### 撤销 - -撤销完成只需将对应 `CompletionFact` 标记为已撤销,并生成 XP 补偿。时间轨迹直接排除已撤销的完成记录,不再需要同步撤销另一张执行账本。 - -## 时间轨迹投影 - -时间轨迹直接读取有效 `CompletionFact`: - -- `actualSegments` 非空:按每段真实的开始日期和起止时间绘制到时间轴; -- `actualSegments` 为空:按 `completedAt` 的上海本地日期放入当天顶部“未配置时间”区; -- 同一完成记录包含多个实际时间段时,时间轴可显示多个条目,但详情和编辑入口仍指向同一行动与完成事实。 - -“未配置时间”区域在日程与时间轨迹两种模式下都可见: - -- 日程模式展示尚未安排时间的计划; -- 时间轨迹模式展示已经完成但没有实际执行时间的记录。 - -空状态只在当前视图范围内既没有已配置时间的完成记录,也没有未配置时间的完成记录时出现。 - -## 状态迁移 - -模式版本升级时执行确定性迁移: - -1. 遍历所有现有 `completionFacts`。 -2. 按 `completionFactId` 优先匹配现有有效 `executionRecords`;旧记录缺少关联字段时,仅当该 `planId` 只有一条有效完成事实时按 `planId` 匹配。 -3. 将匹配到的执行记录按开始时间排序,并转换为该完成记录的 `actualSegments`。 -4. 没有匹配执行记录的完成事实写入空数组。 -5. 已撤销的执行记录不进入有效完成记录。 -6. 完成迁移后移除顶层 `executionRecords`。 - -迁移不得使用计划的排期时间或 `completedAt` 伪造实际执行时段。旧完成记录没有实际时间时,应进入“未配置时间”区。 - -迁移必须幂等:已经包含 `actualSegments` 的新版本状态再次加载时,不重复追加时间段。 - -## 兼容性与错误处理 - -- Learning MORE 提供有效 `actualStartedAt` 与 `actualEndedAt` 时写入实际时间段;缺任一端或范围无效时保留空数组。 -- 同一完成事实重复同步时,相同时间段去重。 -- 行动被永久移除后,现有完成与回放策略保持不变;时间轨迹只显示仍可解析到行动内容的记录。 -- 数据库升级继续沿用现有迁移前备份机制。 - -## 验证 - -回归测试覆盖: - -1. 完成记录没有实际时间时仍出现在时间轨迹的“未配置时间”区。 -2. 一条完成记录包含一个或多个实际时间段时,时间轴正确展开。 -3. 时间轨迹不再依赖顶层 `executionRecords`。 -4. 旧状态中的执行记录正确合并进完成记录。 -5. 旧完成记录没有执行记录时迁移为空数组。 -6. 重复迁移与重复 Learning MORE 同步不产生重复时间段。 -7. 撤销完成后,时间轴条目和未配置时间条目同时消失,XP 补偿保持正确。 -8. 当前真实数据库的复现条件从“34 条本周完成、0 条时间线记录”变为“34 条本周完成均可见,其中无实际时间者位于顶部区域”。 - -类型检查、完整测试集和生产构建均需通过。 - -## 非目标 - -- 不推断或补写历史实际执行时间。 -- 不改变计划排期。 -- 不改变奖励、XP 或结算的业务规则。 -- 不在本次修复中增加完成记录的事后时间编辑功能。 diff --git a/web/app/page.tsx b/web/app/page.tsx index 4acbf96..3475c31 100644 --- a/web/app/page.tsx +++ b/web/app/page.tsx @@ -694,10 +694,13 @@ function MonthDashboard({ attributes, plans, planRecords, goals, projects, proje ; } -function CalendarDrawerPlan({ plan, mode, onEditPlan }: { plan: PlanItem; mode: "week" | "month"; onEditPlan: (id: string) => void }) { +function CalendarDrawerPlan({ plan, mode, readOnly = false, onEditPlan }: { plan: PlanItem; mode: "week" | "month"; readOnly?: boolean; onEditPlan: (id: string) => void }) { const unscheduled = mode === "week" && plan.timeStatus === "unscheduled"; + const timeLabel = mode === "month" ? "✓ 已完成" : unscheduled ? "时间未配置" : (plan.timeSegments?.length ?? 0) > 1 ? `${plan.timeSegments!.length} 个时段` : `${plan.start}—${plan.end}`; return
- + {readOnly + ? {timeLabel} + : }
{plan.title}{plan.category}{plan.source === "learning-more" ? " · Learning MORE" : ""}
; } @@ -751,7 +754,14 @@ function CalendarView({ plans, initialMode, content, onEditPlan }: { plans: Plan
{content === "timeline" ? "TIMELINE" : "CALENDAR"}

{content === "timeline" ? "时间轨迹" : "日程表"}

{mode === "week" ? rangeLabel : monthLabel} · Asia/Shanghai

{mode === "week" ?
时间{dayDates.map((date, index) =>
{date.weekday}{date.day}
)}
- {content === "schedule" &&
POCKET待安排
{dayDates.map((date, dayIndex) => { const count = plansForDate(date.key).filter((plan) => plan.timeStatus === "unscheduled").length; return ; })}
} +
+
{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 ; + })} +
{["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) => { const allDayPlans = plansForDate(date.key); const timedPlans = expandCalendarSegments(allDayPlans.filter((plan) => plan.timeStatus !== "unscheduled")); @@ -771,14 +781,14 @@ function CalendarView({ plans, initialMode, content, onEditPlan }: { plans: Plan const timedPlans = expandCalendarSegments(dayPlans.filter((plan) => plan.timeStatus !== "unscheduled")); const expanded = expandedDays.includes(dayIndex); const shown = expanded ? timedPlans : timedPlans.slice(0, 6); - return
{date.weekday} {date.day}{timedPlans.length} 已排 · {unscheduledPlans.length} 待安排
{unscheduledPlans.length > 0 && }{shown.map((plan) =>
{plan.title}{plan.completed ? "已完成" : plan.source === "learning-more" ? "LM 同步" : plan.category}
)}{dayPlans.length === 0 &&

没有安排

}{timedPlans.length > 6 && }
; + return
{date.weekday} {date.day}{timedPlans.length} {content === "timeline" ? "已记录" : "已排"} · {unscheduledPlans.length} {content === "timeline" ? "未配置时间" : "待安排"}
{unscheduledPlans.length > 0 && }{shown.map((plan) =>
{content === "timeline" ? {plan.start}—{plan.end} : }{plan.title}{plan.completed ? "已完成" : plan.source === "learning-more" ? "LM 同步" : plan.category}
)}{dayPlans.length === 0 &&

{content === "timeline" ? "没有完成记录" : "没有安排"}

}{timedPlans.length > 6 && }
; })}
:
{monthLabel}
{weekdays.map((day) => {day})}
{monthCells.map((cell) =>
{cell.plans.slice(0, 3).map((plan) => )}
{cell.plans.length > 3 && }
)}
} - {selectedDate !== null &&
{mode === "month" ? "DONE TODAY" : "DAY AGENDA"}

{selectedDateLabel} · {mode === "month" ? "完成内容" : "计划口袋"}

{selectedPlans.length > 0 ? <>{mode === "week" && selectedUnscheduled.length > 0 &&
待安排{selectedUnscheduled.length} 项 · 点击时间票券直接设置
{selectedUnscheduled.map((plan) => )}
}{selectedTimed.length > 0 &&
{mode === "month" ? "完成内容" : "已安排"}{selectedTimed.length} 项 · 点击时间可再次修改
{selectedTimed.map((plan) => )}
} :
{mode === "month" ? "这一天还没有完成记录。" : "这一天还没有计划。"}
}
} + {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" ? "这一天还没有完成记录。" : "这一天还没有计划。"}
}
} ); } diff --git a/web/lib/state-patch.ts b/web/lib/state-patch.ts index 0073b0b..e7a6e5a 100644 --- a/web/lib/state-patch.ts +++ b/web/lib/state-patch.ts @@ -3,7 +3,7 @@ import type { WeekUpState } from "./week-up-domain.ts"; const COLLECTION_KEYS = [ "attributeCategories", "projectCategories", "attributes", "projects", "learningMoreCourses", "learningMoreLessons", "goals", "plans", - "completionFacts", "executionRecords", "xpTransactions", "weightRevisions", "dailySettlements", "settlements", "skillbooks", + "completionFacts", "xpTransactions", "weightRevisions", "dailySettlements", "settlements", "skillbooks", ] as const; type CollectionKey = typeof COLLECTION_KEYS[number]; diff --git a/web/lib/use-week-up.ts b/web/lib/use-week-up.ts index 96132aa..9836ae9 100644 --- a/web/lib/use-week-up.ts +++ b/web/lib/use-week-up.ts @@ -129,12 +129,11 @@ 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.executionRecords - .filter((record) => record.revertedAt === undefined) - .flatMap((record): PlanItem[] => { - const item = activePlans.get(record.planId); - const fact = activeFacts.get(record.planId); - if (!item || !fact) return []; + const timelinePlans: 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 @@ -142,22 +141,25 @@ export function projectWeekUpView(state: WeekUpState, at = new Date()): WeekUpVi : undefined; const category = currentProject?.category ?? item.category; const categoryColor = colorForCategory(category, state.projectCategories.find((entry) => entry.name === category)?.color); - return [{ - id: record.id, + const segments = fact.actualSegments.length > 0 ? fact.actualSegments : [undefined]; + return segments.map((segment, index): PlanItem => { + const anchor = segment?.startAt ?? fact.completedAt; + return { + id: segment ? `${fact.id}:actual:${index}` : fact.id, calendarSourceId: item.id, ...(item.projectId ? { projectId: item.projectId } : {}), - scheduledDate: dateInShanghai(record.startAt), + scheduledDate: dateInShanghai(anchor), title: item.title, detail: item.detail, - start: timeInShanghai(record.startAt), - end: timeInShanghai(record.endAt), - timeSegments: [{ - id: record.planSegmentId ?? record.id, - start: timeInShanghai(record.startAt), - end: timeInShanghai(record.endAt), + 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: "scheduled", + }] : [], + timeStatus: segment ? "scheduled" : "unscheduled", category, categoryColor, categoryTextColor: readableTextColor(categoryColor), @@ -167,9 +169,9 @@ export function projectWeekUpView(state: WeekUpState, at = new Date()): WeekUpVi completedEarly: completedBeforeSchedule(fact.completedAt, item), rewards: item.rewards.map((reward) => ({ ...reward })), source: item.source, - executionSource: record.source, + executionSource: fact.source, syncState: "completed", - dayIndex: dayIndexFor(record.startAt), + dayIndex: dayIndexFor(anchor), scheduleGroup: "completed", rewardMode: item.rewardMode, templateLabel: item.projectId @@ -180,7 +182,8 @@ export function projectWeekUpView(state: WeekUpState, at = new Date()): WeekUpVi unitLabel: item.unitKind && item.unitQuantity !== undefined ? `${item.unitQuantity} ${item.unitKind === "hour" ? "时" : item.unitKind === "lesson" ? "节" : "次"}` : undefined, - }]; + }; + }); }) .sort((left, right) => (left.scheduledDate ?? "").localeCompare(right.scheduledDate ?? "") diff --git a/web/lib/week-up-domain.ts b/web/lib/week-up-domain.ts index d393349..9ae23a2 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 = 16 as const; +export const WEEK_UP_SCHEMA_VERSION = 17 as const; export type AiProviderId = "codex-cli" | "api"; @@ -13,6 +13,8 @@ export type PlanTimeSegment = Readonly<{ startAt: string; endAt: string; completedAt?: string; + actualStartAt?: string; + actualEndAt?: string; }>; export type PlanTimeSegmentInput = Readonly<{ startAt: string; endAt: string }>; @@ -121,22 +123,11 @@ export type CompletionFact = Readonly<{ completedAt: string; source: "week-up" | "learning-more"; externalFactId?: string; + actualSegments: readonly PlanTimeSegmentInput[]; rewardSnapshot: readonly AttributeReward[]; revertedAt?: string; }>; -export type ExecutionRecord = Readonly<{ - id: string; - planId: string; - planSegmentId?: string; - startAt: string; - endAt: string; - source: "week-up" | "learning-more"; - recordedAt: string; - completionFactId?: string; - revertedAt?: string; -}>; - export type XpTransaction = Readonly<{ id: string; attributeId: string; @@ -219,7 +210,6 @@ export type WeekUpState = Readonly<{ goals: readonly GoalRecord[]; plans: readonly PlanRecord[]; completionFacts: readonly CompletionFact[]; - executionRecords: readonly ExecutionRecord[]; xpTransactions: readonly XpTransaction[]; weightRevisions: readonly WeightRevision[]; dailySettlements: readonly DailySettlementRecord[]; @@ -327,7 +317,6 @@ export function createEmptyWeekUpState(baseUrl = "/learning-more-api"): WeekUpSt goals: [], plans: [], completionFacts: [], - executionRecords: [], xpTransactions: [], weightRevisions: [], dailySettlements: [], @@ -658,46 +647,17 @@ function completePlan( if (options.source === "week-up" && planIsOverdue(plan, options.completedAt)) throw new Error("plan_overdue"); const duplicateExternal = options.externalFactId && state.completionFacts.find((fact) => fact.externalFactId === options.externalFactId && fact.revertedAt === undefined); if (duplicateExternal) { - const executionRecords = appendExecutionRecords( - state.executionRecords, - planId, - options.source, - options.actualSegments, - context, - options.completedAt, - duplicateExternal.id, - ); - return executionRecords === state.executionRecords + const updated = mergeCompletionFactDetails(duplicateExternal, options); + return updated === duplicateExternal ? unchanged(state, duplicateExternal.id) - : changed(state, { executionRecords }, duplicateExternal.id); + : changed(state, { completionFacts: state.completionFacts.map((fact) => fact.id === updated.id ? updated : fact) }, duplicateExternal.id); } const existing = activeCompletion(state, planId); if (existing) { - if (options.source === "learning-more" && existing.source === "week-up" && options.externalFactId) { - const facts = state.completionFacts.map((fact) => fact.id === existing.id ? { ...fact, externalFactId: options.externalFactId } : fact); - const executionRecords = appendExecutionRecords( - state.executionRecords, - planId, - options.source, - options.actualSegments, - context, - options.completedAt, - existing.id, - ); - return changed(state, { completionFacts: facts, executionRecords }, existing.id); - } - const executionRecords = appendExecutionRecords( - state.executionRecords, - planId, - options.source, - options.actualSegments, - context, - options.completedAt, - existing.id, - ); - return executionRecords === state.executionRecords + const updated = mergeCompletionFactDetails(existing, options); + return updated === existing ? unchanged(state, existing.id) - : changed(state, { executionRecords }, existing.id); + : changed(state, { completionFacts: state.completionFacts.map((fact) => fact.id === updated.id ? updated : fact) }, existing.id); } const factId = context.id("completion"); const rewardSnapshot = plan.rewards.map((reward) => ({ ...reward })); @@ -707,6 +667,7 @@ function completePlan( completedAt: options.completedAt, source: options.source, ...(options.externalFactId ? { externalFactId: options.externalFactId } : {}), + actualSegments: normalizeActualSegments(options.actualSegments), rewardSnapshot, }; const transactions = rewardSnapshot.map((reward) => ({ @@ -719,57 +680,32 @@ function completePlan( })); return changed(state, { completionFacts: [...state.completionFacts, fact], - executionRecords: appendExecutionRecords( - state.executionRecords, - planId, - options.source, - options.actualSegments, - context, - options.completedAt, - factId, - ), xpTransactions: [...state.xpTransactions, ...transactions], }, factId); } -function appendExecutionRecords( - current: readonly ExecutionRecord[], - planId: string, - source: ExecutionRecord["source"], - segments: readonly PlanTimeSegmentInput[] | undefined, - context: DomainContext, - recordedAt: string, - completionFactId?: string, - planSegmentId?: string, -): readonly ExecutionRecord[] { - if (!segments?.length) return current; - const normalized = segments - .map((segment) => { - assertPeriod(segment.startAt, segment.endAt); - return segment; - }) - .sort((left, right) => left.startAt.localeCompare(right.startAt)); - const additions = normalized.flatMap((segment): ExecutionRecord[] => { - const duplicate = current.some((record) => - record.revertedAt === undefined - && record.planId === planId - && record.planSegmentId === planSegmentId - && record.startAt === segment.startAt - && record.endAt === segment.endAt - ); - if (duplicate) return []; - return [{ - id: context.id("execution"), - planId, - ...(planSegmentId ? { planSegmentId } : {}), - startAt: segment.startAt, - endAt: segment.endAt, - source, - recordedAt, - ...(completionFactId ? { completionFactId } : {}), - }]; - }); - return additions.length ? [...current, ...additions] : current; +function normalizeActualSegments(segments: readonly PlanTimeSegmentInput[] | undefined): readonly PlanTimeSegmentInput[] { + if (!segments?.length) return []; + const unique = new Map(); + for (const segment of segments) { + assertPeriod(segment.startAt, segment.endAt); + unique.set(`${segment.startAt}\u0000${segment.endAt}`, { startAt: segment.startAt, endAt: segment.endAt }); + } + return [...unique.values()].sort((left, right) => left.startAt.localeCompare(right.startAt) || left.endAt.localeCompare(right.endAt)); +} + +function mergeCompletionFactDetails( + fact: CompletionFact, + options: { externalFactId?: string; actualSegments?: readonly PlanTimeSegmentInput[] }, +): CompletionFact { + const actualSegments = normalizeActualSegments([...(fact.actualSegments ?? []), ...(options.actualSegments ?? [])]); + const externalFactId = fact.externalFactId ?? options.externalFactId; + if (externalFactId === fact.externalFactId && JSON.stringify(actualSegments) === JSON.stringify(fact.actualSegments)) return fact; + return { + ...fact, + ...(externalFactId ? { externalFactId } : {}), + actualSegments, + }; } function revertPlanCompletionInState(state: WeekUpState, planId: string, context: DomainContext, now: string): WeekUpState { @@ -786,7 +722,6 @@ function revertPlanCompletionInState(state: WeekUpState, planId: string, context return { ...state, completionFacts: state.completionFacts.map((item) => item.id === fact.id ? { ...item, revertedAt: now } : item), - executionRecords: state.executionRecords.map((item) => item.planId === planId && item.revertedAt === undefined ? { ...item, revertedAt: now } : item), xpTransactions: [...state.xpTransactions, ...compensation], }; } @@ -1462,7 +1397,6 @@ export function dispatchWeekUp(state: WeekUpState, command: WeekUpCommand, conte return changed(state, { plans: reverted.plans.map((plan) => plan.id === command.id && plan.timeSegments?.length ? { ...plan, timeSegments: plan.timeSegments.map((segment) => ({ id: segment.id, startAt: segment.startAt, endAt: segment.endAt })), updatedAt: now } : plan), completionFacts: reverted.completionFacts, - executionRecords: reverted.executionRecords, xpTransactions: reverted.xpTransactions, }, fact.id); } @@ -1475,25 +1409,35 @@ export function dispatchWeekUp(state: WeekUpState, command: WeekUpCommand, conte if (!segments.some((segment) => segment.id === command.segmentId)) throw new Error("plan_segment_not_found"); if (segments.find((segment) => segment.id === command.segmentId)?.completedAt) return unchanged(state, command.segmentId); const completedAt = command.completedAt ?? now; - const updatedPlan: PlanRecord = { ...plan, timeSegments: segments.map((segment) => segment.id === command.segmentId ? { ...segment, completedAt } : segment), updatedAt: now }; - const executionRecords = appendExecutionRecords( - state.executionRecords, - plan.id, - "week-up", - command.actualSegment ? [command.actualSegment] : undefined, - context, - completedAt, - undefined, - command.segmentId, - ); - const prepared = { ...state, plans: state.plans.map((item) => item.id === plan.id ? updatedPlan : item), executionRecords }; + if (command.actualSegment) assertPeriod(command.actualSegment.startAt, command.actualSegment.endAt); + const updatedPlan: PlanRecord = { + ...plan, + timeSegments: segments.map((segment) => segment.id === command.segmentId + ? { + ...segment, + completedAt, + ...(command.actualSegment ? { + actualStartAt: command.actualSegment.startAt, + actualEndAt: command.actualSegment.endAt, + } : {}), + } + : segment), + updatedAt: now, + }; + const prepared = { ...state, plans: state.plans.map((item) => item.id === plan.id ? updatedPlan : item) }; if (!allSegmentsCompleted(updatedPlan)) { - return changed(state, { - plans: prepared.plans, - executionRecords: prepared.executionRecords, - }, command.segmentId); + return changed(state, { plans: prepared.plans }, command.segmentId); } - const outcome = completePlan(prepared, plan.id, context, { source: "week-up", completedAt }); + const actualSegments = (updatedPlan.timeSegments ?? []).flatMap((segment): PlanTimeSegmentInput[] => + segment.actualStartAt && segment.actualEndAt + ? [{ startAt: segment.actualStartAt, endAt: segment.actualEndAt }] + : [] + ); + const outcome = completePlan(prepared, plan.id, context, { + source: "week-up", + completedAt, + ...(actualSegments.length ? { actualSegments } : {}), + }); const refreshed = refreshSettlementsAfterFactChanges(outcome.state, now); return refreshed === outcome.state ? outcome : { ...outcome, state: refreshed }; } @@ -1505,13 +1449,8 @@ export function dispatchWeekUp(state: WeekUpState, command: WeekUpCommand, conte if (!segment.completedAt) return unchanged(state, command.segmentId); const reverted = revertPlanCompletionInState(state, plan.id, context, now); const plans = reverted.plans.map((item) => item.id === plan.id ? { ...item, timeSegments: item.timeSegments?.map((entry) => entry.id === command.segmentId ? { id: entry.id, startAt: entry.startAt, endAt: entry.endAt } : entry), updatedAt: now } : item); - const executionRecords = state.executionRecords.map((record) => - record.planId === plan.id && record.planSegmentId === command.segmentId && record.revertedAt === undefined - ? { ...record, revertedAt: now } - : record - ); - const refreshed = refreshSettlementsAfterFactChanges({ ...reverted, plans, executionRecords }, now); - return changed(state, { plans: refreshed.plans, completionFacts: refreshed.completionFacts, executionRecords: refreshed.executionRecords, xpTransactions: refreshed.xpTransactions, settlements: refreshed.settlements }, command.segmentId); + const refreshed = refreshSettlementsAfterFactChanges({ ...reverted, plans }, now); + return changed(state, { plans: refreshed.plans, completionFacts: refreshed.completionFacts, xpTransactions: refreshed.xpTransactions, settlements: refreshed.settlements }, command.segmentId); } case "plan.follow-template": { const plan = state.plans.find((item) => item.id === command.id && item.removedAt === undefined); @@ -1884,35 +1823,82 @@ 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 !== 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 !== WEEK_UP_SCHEMA_VERSION) throw new Error("database_schema_unsupported"); type MigratableSettlement = Omit & { harvest?: SettlementRecord["harvest"]; reflection?: string; }; type MigratableCategory = Omit & { color?: string }; - const raw = value as Omit & { - schemaVersion: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16; + type MigratableCompletionFact = Omit & { actualSegments?: readonly PlanTimeSegmentInput[] }; + type LegacyExecutionRecord = Readonly<{ + id: string; + planId: string; + planSegmentId?: string; + startAt: string; + endAt: string; + source: "week-up" | "learning-more"; + recordedAt: string; + completionFactId?: string; + 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; attributeCategories?: readonly MigratableCategory[]; projectCategories?: readonly MigratableCategory[]; plans: readonly (Omit & Partial>)[]; + completionFacts: readonly MigratableCompletionFact[]; projects?: readonly ProjectRecord[]; learningMoreCourses?: readonly LearningMoreCourse[]; learningMoreLessons?: readonly (Omit & Partial>)[]; - executionRecords?: readonly ExecutionRecord[]; + executionRecords?: readonly LegacyExecutionRecord[]; settlements: readonly MigratableSettlement[]; aiReview?: Partial & { baseUrl: string }; preferences?: WeekUpState["preferences"]; }; + const legacyExecutionRecords = raw.executionRecords ?? []; + const activeFactCountByPlan = raw.completionFacts.reduce>((counts, fact) => { + if (fact.revertedAt === undefined) counts.set(fact.planId, (counts.get(fact.planId) ?? 0) + 1); + return counts; + }, new Map()); + const completionFacts: CompletionFact[] = raw.completionFacts.map((fact) => { + const legacySegments = legacyExecutionRecords + .filter((record) => + record.revertedAt === undefined + && ( + record.completionFactId === fact.id + || ( + record.completionFactId === undefined + && record.planId === fact.planId + && activeFactCountByPlan.get(fact.planId) === 1 + ) + ) + ) + .map(({ startAt, endAt }) => ({ startAt, endAt })); + return { + ...fact, + actualSegments: normalizeActualSegments(fact.actualSegments ?? legacySegments), + }; + }); const plans: PlanRecord[] = raw.plans.map((plan) => { - const activeFact = raw.completionFacts.find((fact) => fact.planId === plan.id && fact.revertedAt === undefined); + const activeFact = completionFacts.find((fact) => fact.planId === plan.id && fact.revertedAt === undefined); const isCompleted = activeFact !== undefined; const legacyExternalSchedule = raw.schemaVersion < 4 && plan.source === "learning-more" && !plan.sourceRef?.startsWith("week-up:"); const migratedTimeStatus = raw.schemaVersion < 6 && plan.source === "learning-more" && !isCompleted && plan.removedAt === undefined ? "unscheduled" as const : plan.timeStatus; - const timeSegments = plan.timeSegments?.length + const baseTimeSegments = plan.timeSegments?.length ? plan.timeSegments : migratedTimeStatus === "unscheduled" ? [] : [{ id: `${plan.id}:segment:0`, startAt: plan.startAt, endAt: plan.endAt, ...(activeFact ? { completedAt: activeFact.completedAt } : {}) }]; + const timeSegments = baseTimeSegments.map((segment) => { + const legacyExecution = legacyExecutionRecords.find((record) => + record.revertedAt === undefined + && record.planId === plan.id + && record.planSegmentId === segment.id + ); + return legacyExecution + ? { ...segment, actualStartAt: legacyExecution.startAt, actualEndAt: legacyExecution.endAt } + : segment; + }); return { ...plan, timeSegments, @@ -1961,8 +1947,9 @@ export function migrateWeekUpState(value: unknown): WeekUpState { }); return categories; }, []); + const { executionRecords: _legacyExecutionRecords, completionFacts: _legacyCompletionFacts, ...rawState } = raw; return { - ...raw, + ...rawState, schemaVersion: WEEK_UP_SCHEMA_VERSION, attributeCategories, projectCategories, @@ -1976,7 +1963,7 @@ export function migrateWeekUpState(value: unknown): WeekUpState { }), learningMoreCourses: raw.schemaVersion < 5 ? [] : raw.learningMoreCourses ?? [], learningMoreLessons: raw.schemaVersion < 5 ? [] : (raw.learningMoreLessons ?? []).map((lesson, index) => ({ ...lesson, scheduleItemId: lesson.scheduleItemId!, scheduledDate: lesson.scheduledDate!, order: lesson.order ?? index })), - executionRecords: raw.executionRecords ?? [], + completionFacts, dailySettlements: "dailySettlements" in raw && Array.isArray(raw.dailySettlements) ? raw.dailySettlements : [], settlements: (raw.settlements ?? []).map(({ reflection: _legacyReflection, ...settlement }) => ({ ...settlement, diff --git a/web/tests/demo-source.test.mjs b/web/tests/demo-source.test.mjs index 551bbe6..37c292b 100644 --- a/web/tests/demo-source.test.mjs +++ b/web/tests/demo-source.test.mjs @@ -213,6 +213,15 @@ test("keeps unscheduled plans outside time slots and uses one clickable time tic assert.match(css, /\.dashboard-action-list \{[^}]*grid-template-columns: repeat\(2,minmax\(0,1fr\)\)/); }); +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"\}/); +}); + test("does not hide non-overlapping calendar plans behind a fixed-count overflow ticket", async () => { const page = await readFile(new URL("../app/page.tsx", import.meta.url), "utf8"); assert.doesNotMatch(page, /const visiblePlans = timedPlans\.slice\(0, 6\)/); diff --git a/web/tests/view-projection.test.mjs b/web/tests/view-projection.test.mjs index d137d43..c656cf7 100644 --- a/web/tests/view-projection.test.mjs +++ b/web/tests/view-projection.test.mjs @@ -75,3 +75,50 @@ test("keeps schedule projection separate from completed execution timeline", () [{ calendarSourceId: completedPlanId, scheduledDate: "2026-07-28", start: "10:00", end: "11:00" }], ); }); + +test("keeps an untimed completion visible at the top of its completion day", () => { + let sequence = 0; + const context = { + now: () => "2026-07-28T12:00:00+08:00", + id: (prefix) => `${prefix}-${++sequence}`, + }; + let state = createEmptyWeekUpState(); + state = dispatchWeekUp(state, { + type: "plan.create", + value: { + title: "Completed without actual time", + detail: "", + category: "Work", + startAt: "2026-07-29T17:00:00+08:00", + endAt: "2026-07-29T19:00:00+08:00", + goalIds: [], + rewards: [], + }, + }, context).state; + const planId = state.plans[0].id; + state = dispatchWeekUp(state, { + type: "plan.complete", + id: planId, + completedAt: "2026-07-28T11:00:00+08:00", + }, context).state; + + const view = projectWeekUpView(state, new Date("2026-07-28T12:00:00+08:00")); + assert.deepEqual( + view.timelinePlans.map(({ calendarSourceId, scheduledDate, start, end, timeStatus, completed }) => ({ + calendarSourceId, + scheduledDate, + start, + end, + timeStatus, + completed, + })), + [{ + calendarSourceId: planId, + scheduledDate: "2026-07-28", + start: "时间未配置", + end: "", + timeStatus: "unscheduled", + completed: true, + }], + ); +}); diff --git a/web/tests/week-up-domain.test.mjs b/web/tests/week-up-domain.test.mjs index 1e7d747..1f690bc 100644 --- a/web/tests/week-up-domain.test.mjs +++ b/web/tests/week-up-domain.test.mjs @@ -818,6 +818,40 @@ test("round-trips a versioned backup", () => { assert.throws(() => migrateWeekUpState({ schemaVersion: 99 }), /database_schema_unsupported/); }); +test("folds legacy execution records into their completion fact", () => { + 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 fact = state.completionFacts[0]; + const { executionRecords: _removedExecutionRecords, ...currentWithoutExecutions } = state; + const migrated = migrateWeekUpState({ + ...currentWithoutExecutions, + schemaVersion: 16, + completionFacts: [{ ...fact, actualSegments: undefined }], + executionRecords: [{ + id: "legacy-execution", + planId, + startAt: "2026-07-20T09:10:00+08:00", + endAt: "2026-07-20T09:55:00+08:00", + source: "week-up", + recordedAt: fact.completedAt, + completionFactId: fact.id, + }], + }); + + assert.deepEqual(migrated.completionFacts[0].actualSegments, [{ + startAt: "2026-07-20T09:10:00+08:00", + endAt: "2026-07-20T09:55:00+08:00", + }]); + assert.equal("executionRecords" in migrated, false); +}); + test("migrates legacy Learning MORE clock times back to time-unconfigured", () => { const h = harness(); let state = addAttribute(h, createEmptyWeekUpState()); @@ -857,8 +891,8 @@ test("imports Learning MORE course tables and facts while Week UP owns schedule assert.equal(state.plans[0].endAt, "2026-07-20T10:00:00+08:00"); assert.equal(state.plans[0].timeStatus, "scheduled"); assert.deepEqual( - state.executionRecords.map(({ startAt, endAt, source }) => ({ startAt, endAt, source })), - [{ startAt: "2026-07-20T02:12:00Z", endAt: "2026-07-20T03:47:00Z", source: "learning-more" }], + state.completionFacts[0].actualSegments, + [{ startAt: "2026-07-20T02:12:00Z", endAt: "2026-07-20T03:47:00Z" }], ); assert.equal(state.plans[0].title, "概率论:条件概率"); assert.equal(state.plans[0].rewards[0].amount, 3); @@ -906,7 +940,7 @@ test("keeps a directly completed overdue Learning MORE lesson on its original sc assert.equal(state.plans[0].timeStatus, "unscheduled"); assert.deepEqual(state.plans[0].timeSegments ?? [], []); assert.equal(state.completionFacts[0].completedAt, completion.occurredAt); - assert.deepEqual(state.executionRecords, []); + assert.deepEqual(state.completionFacts[0].actualSegments, []); assert.equal(totalXpForAttribute(state, attributeId), 2); state = h.run(state, { type: "learning-more.import", facts: [completion] }); @@ -1208,12 +1242,10 @@ test("keeps a future schedule in place when an ordinary plan is completed early" assert.equal(state.plans[0].endAt, "2026-07-21T19:00:00+08:00"); assert.equal(state.completionFacts[0].completedAt, "2026-07-20T16:00:00+08:00"); assert.deepEqual( - state.executionRecords.map(({ planId: id, startAt, endAt, source }) => ({ id, startAt, endAt, source })), + state.completionFacts[0].actualSegments, [{ - id: planId, startAt: "2026-07-20T15:00:00+08:00", endAt: "2026-07-20T16:00:00+08:00", - source: "week-up", }], ); @@ -1263,17 +1295,15 @@ test("records Learning MORE actual time without moving its original schedule", ( assert.equal(state.completionFacts[0].planId, planId); assert.equal(state.completionFacts[0].completedAt, "2026-07-20T16:00:00+08:00"); assert.deepEqual( - state.executionRecords.map(({ planId: id, startAt, endAt, source }) => ({ id, startAt, endAt, source })), + state.completionFacts[0].actualSegments, [{ - id: planId, startAt: "2026-07-20T15:10:00+08:00", endAt: "2026-07-20T16:00:00+08:00", - source: "learning-more", }], ); }); -test("undoing one completed segment preserves other actual execution records", () => { +test("undoing one completed segment preserves other segment actual time", () => { const h = harness(); let state = addAttribute(h, createEmptyWeekUpState()); state = addPlan(h, state, state.attributes[0].id, { @@ -1297,10 +1327,18 @@ test("undoing one completed segment preserves other actual execution records", ( completedAt: "2026-07-20T15:25:00+08:00", actualSegment: { startAt: "2026-07-20T15:05:00+08:00", endAt: "2026-07-20T15:25:00+08:00" }, }); + assert.deepEqual(state.completionFacts[0].actualSegments, [ + { startAt: "2026-07-20T09:05:00+08:00", endAt: "2026-07-20T09:20:00+08:00" }, + { startAt: "2026-07-20T15:05:00+08:00", endAt: "2026-07-20T15:25:00+08:00" }, + ]); state = h.run(state, { type: "plan.segment.undo", id: planId, segmentId: "first" }); - assert.equal(state.executionRecords.find((record) => record.planSegmentId === "first").revertedAt !== undefined, true); - assert.equal(state.executionRecords.find((record) => record.planSegmentId === "second").revertedAt, undefined); + const first = state.plans[0].timeSegments.find((segment) => segment.id === "first"); + const second = state.plans[0].timeSegments.find((segment) => segment.id === "second"); + assert.equal(first.actualStartAt, undefined); + assert.equal(first.actualEndAt, undefined); + assert.equal(second.actualStartAt, "2026-07-20T15:05:00+08:00"); + assert.equal(second.actualEndAt, "2026-07-20T15:25:00+08:00"); }); test("serializes load, dispatch, refresh, and replace through one store queue", async () => {