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..40a4268 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, @@ -1788,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) @@ -1940,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/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" }], + ); +}); 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());