Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 21 additions & 10 deletions web/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -1361,28 +1361,39 @@ 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 }) => {
if (!executionEditor) return;
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) => {
Expand Down
130 changes: 82 additions & 48 deletions web/lib/use-week-up.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import {
createEmptyWeekUpState,
currentWeightEntries,
totalXpForAttribute,
type CompletionFact,
type PlanRecord,
type PlanTimeSegmentInput,
type WeekUpCommand,
type WeekUpState,
} from "./week-up-domain.ts";
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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)
Expand Down
55 changes: 45 additions & 10 deletions web/lib/week-up-domain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 } : {}),
};
});
Expand Down
10 changes: 10 additions & 0 deletions web/tests/demo-source.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
54 changes: 54 additions & 0 deletions web/tests/view-projection.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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" }],
);
});
Loading
Loading