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
12 changes: 10 additions & 2 deletions web/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -820,6 +820,12 @@ button:focus-visible, input:focus-visible, textarea:focus-visible {
.weight-stats > strong { font-family: Consolas, monospace; font-size: 29px; }
.weight-stats strong small { font-size: 12px; }
.weight-stats > div { grid-column: 1 / -1; display: flex; align-items: center; justify-content: space-between; margin-top: 5px; color: var(--muted); }
.weight-form--quick { width: min(100%,240px); margin: 12px 0 8px; align-items: stretch; }
.weight-form--quick > div { min-width: 0; flex: 1 1 auto; box-shadow: 2px 2px 0 var(--ink); }
.weight-form--quick input { width: 100%; min-width: 0; min-height: 34px; padding: 4px 7px; font-size: 16px; }
.weight-form--quick input:focus, .weight-form--quick input:focus-visible { outline: none; box-shadow: none; }
.weight-form--quick .pixel-button { min-height: 38px; padding: 6px 11px; box-shadow: 2px 2px 0 var(--ink); font-size: 11px; white-space: nowrap; }
.weight-form--quick .pixel-button:disabled { opacity: .5; cursor: not-allowed; box-shadow: none; }
.trend-down { color: #177d59 !important; font-style: normal; font-weight: 900; }
.weight-chart { position: relative; height: 260px; margin-top: 28px; border-left: 3px solid var(--ink); border-bottom: 3px solid var(--ink); background-image: linear-gradient(rgba(48,36,71,.08) 1px, transparent 1px); background-size: 100% 25%; }
.weight-chart--compact { height: 90px; margin-top: 12px; }
Expand Down Expand Up @@ -924,8 +930,10 @@ button:focus-visible, input:focus-visible, textarea:focus-visible {
.drawer-plan-group__title span { color: var(--muted); font-size: 9px; }
.drawer-plan { display: grid; grid-template-columns: 104px minmax(0,1fr); align-items: center; gap: 5px 12px; padding: 12px 0; border-bottom: 1px dashed rgba(48,36,71,.35); }
.drawer-plan--unscheduled { grid-template-columns: 104px minmax(0,1fr); }
.drawer-plan__time { min-height: 38px; padding: 6px 8px; border: 2px solid var(--ink); background: white; color: var(--violet); font-family: Consolas,monospace; font-size: 10px; font-weight: 900; cursor: pointer; }
.drawer-plan__time.is-unscheduled { background: var(--yellow); color: var(--ink); font-family: inherit; }
.drawer-plan__time { display: grid; width: 100%; min-height: 42px; place-items: center; padding: 7px 9px; border: 2px solid var(--ink); background: var(--yellow); color: var(--ink); font-family: inherit; font-size: 10px; font-weight: 900; line-height: 1.25; text-align: center; }
button.drawer-plan__time { cursor: pointer; }
button.drawer-plan__time:hover, button.drawer-plan__time:focus-visible { background: var(--cyan); box-shadow: 2px 2px 0 var(--ink); }
.drawer-plan__time.is-unscheduled { background: var(--yellow); color: var(--ink); }
.drawer-plan__copy { display: flex; min-width: 0; flex-direction: column; }
.drawer-plan__copy b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.drawer-plan__copy span { color: var(--muted); font-size: 10px; }
Expand Down
53 changes: 39 additions & 14 deletions web/app/page.tsx

Large diffs are not rendered by default.

15 changes: 15 additions & 0 deletions web/lib/unconfigured-time.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { PlanItem } from "./demo-model.ts";

export function selectUnconfiguredPlansForDate(
dateKey: string,
scheduledPlans: readonly PlanItem[],
untimedCompletionPlans: readonly PlanItem[],
settledDates: ReadonlySet<string>,
): PlanItem[] {
const source = settledDates.has(dateKey) ? untimedCompletionPlans : scheduledPlans;
return source.filter((plan) =>
plan.scheduledDate === dateKey
&& plan.timeStatus === "unscheduled"
&& (!settledDates.has(dateKey) || plan.completed)
);
}
57 changes: 49 additions & 8 deletions web/lib/week-up-domain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { colorIdForCategory, isCategoryColorId } from "./category-palette.ts";

import { canRescheduleInsideWeekUp, participatesInOverdueQueue } from "./overdue-policy.ts";

export const WEEK_UP_SCHEMA_VERSION = 17 as const;
export const WEEK_UP_SCHEMA_VERSION = 18 as const;

export type AiProviderId = "codex-cli" | "api";

Expand Down Expand Up @@ -645,16 +645,20 @@ function completePlan(
const plan = state.plans.find((item) => item.id === planId && item.removedAt === undefined);
if (!plan) throw new Error("plan_not_found");
if (options.source === "week-up" && planIsOverdue(plan, options.completedAt)) throw new Error("plan_overdue");
const actualSegments = options.actualSegments?.length
? options.actualSegments
: configuredPlanSegments(plan);
const resolvedOptions = { ...options, actualSegments };
const duplicateExternal = options.externalFactId && state.completionFacts.find((fact) => fact.externalFactId === options.externalFactId && fact.revertedAt === undefined);
if (duplicateExternal) {
const updated = mergeCompletionFactDetails(duplicateExternal, options);
const updated = mergeCompletionFactDetails(duplicateExternal, resolvedOptions);
return updated === duplicateExternal
? unchanged(state, duplicateExternal.id)
: changed(state, { completionFacts: state.completionFacts.map((fact) => fact.id === updated.id ? updated : fact) }, duplicateExternal.id);
}
const existing = activeCompletion(state, planId);
if (existing) {
const updated = mergeCompletionFactDetails(existing, options);
const updated = mergeCompletionFactDetails(existing, resolvedOptions);
return updated === existing
? unchanged(state, existing.id)
: changed(state, { completionFacts: state.completionFacts.map((fact) => fact.id === updated.id ? updated : fact) }, existing.id);
Expand All @@ -667,7 +671,7 @@ function completePlan(
completedAt: options.completedAt,
source: options.source,
...(options.externalFactId ? { externalFactId: options.externalFactId } : {}),
actualSegments: normalizeActualSegments(options.actualSegments),
actualSegments: normalizeActualSegments(actualSegments),
rewardSnapshot,
};
const transactions = rewardSnapshot.map<XpTransaction>((reward) => ({
Expand All @@ -694,6 +698,15 @@ function normalizeActualSegments(segments: readonly PlanTimeSegmentInput[] | und
return [...unique.values()].sort((left, right) => left.startAt.localeCompare(right.startAt) || left.endAt.localeCompare(right.endAt));
}

function configuredPlanSegments(plan: Pick<PlanRecord, "startAt" | "endAt" | "timeSegments" | "timeStatus">): readonly PlanTimeSegmentInput[] {
if (plan.timeStatus === "unscheduled") return [];
return normalizeActualSegments(
plan.timeSegments?.length
? plan.timeSegments.map(({ startAt, endAt }) => ({ startAt, endAt }))
: [{ startAt: plan.startAt, endAt: plan.endAt }],
);
}

function mergeCompletionFactDetails(
fact: CompletionFact,
options: { externalFactId?: string; actualSegments?: readonly PlanTimeSegmentInput[] },
Expand Down Expand Up @@ -1209,8 +1222,28 @@ export function dispatchWeekUp(state: WeekUpState, command: WeekUpCommand, conte
next = { ...next, category: project.category, unitKind: project.unit, unitQuantity: derived.quantity, rewards: derived.rewards };
}
}
const completion = activeCompletion(state, current.id);
const scheduleChanged = makesUnscheduled || requestedSegments !== undefined;
if (completion && current.source === "week-up" && scheduleChanged && next.timeSegments?.length) {
next = {
...next,
timeSegments: next.timeSegments.map((segment) => ({
...segment,
completedAt: segment.completedAt ?? completion.completedAt,
})),
};
}
let base: WeekUpState = { ...state, plans: state.plans.map((item) => item.id === command.id ? next : item) };
if (activeCompletion(state, current.id) && !allSegmentsCompleted(next)) base = revertPlanCompletionInState(base, current.id, context, now);
if (completion && current.source === "week-up" && scheduleChanged) {
base = {
...base,
completionFacts: base.completionFacts.map((fact) => fact.id === completion.id
? { ...fact, actualSegments: configuredPlanSegments(next) }
: fact),
};
} else if (completion && !allSegmentsCompleted(next)) {
base = revertPlanCompletionInState(base, current.id, context, now);
}
return changed(state, { plans: base.plans, completionFacts: base.completionFacts, xpTransactions: base.xpTransactions }, command.id);
}
case "plan.remove": {
Expand Down Expand Up @@ -1823,7 +1856,7 @@ export function migrateWeekUpState(value: unknown): WeekUpState {
if (value === undefined || value === null) return createEmptyWeekUpState();
if (typeof value !== "object") throw new Error("database_state_invalid");
const version = (value as { schemaVersion?: unknown }).schemaVersion;
if (version !== 1 && version !== 2 && version !== 3 && version !== 4 && version !== 5 && version !== 6 && version !== 7 && version !== 8 && version !== 9 && version !== 10 && version !== 11 && version !== 12 && version !== 13 && version !== 14 && version !== 15 && version !== 16 && version !== WEEK_UP_SCHEMA_VERSION) throw new Error("database_schema_unsupported");
if (version !== 1 && version !== 2 && version !== 3 && version !== 4 && version !== 5 && version !== 6 && version !== 7 && version !== 8 && version !== 9 && version !== 10 && version !== 11 && version !== 12 && version !== 13 && version !== 14 && version !== 15 && version !== 16 && version !== 17 && version !== WEEK_UP_SCHEMA_VERSION) throw new Error("database_schema_unsupported");
type MigratableSettlement = Omit<SettlementRecord, "harvest"> & {
harvest?: SettlementRecord["harvest"];
reflection?: string;
Expand All @@ -1842,7 +1875,7 @@ export function migrateWeekUpState(value: unknown): WeekUpState {
revertedAt?: string;
}>;
const raw = value as Omit<WeekUpState, "schemaVersion" | "attributeCategories" | "projectCategories" | "plans" | "projects" | "learningMoreCourses" | "learningMoreLessons" | "completionFacts" | "dailySettlements" | "settlements" | "aiReview"> & {
schemaVersion: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17;
schemaVersion: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18;
attributeCategories?: readonly MigratableCategory[];
projectCategories?: readonly MigratableCategory[];
plans: readonly (Omit<PlanRecord, "rewardMode"> & Partial<Pick<PlanRecord, "rewardMode">>)[];
Expand All @@ -1861,6 +1894,7 @@ export function migrateWeekUpState(value: unknown): WeekUpState {
return counts;
}, new Map());
const completionFacts: CompletionFact[] = raw.completionFacts.map((fact) => {
const plan = raw.plans.find((item) => item.id === fact.planId);
const legacySegments = legacyExecutionRecords
.filter((record) =>
record.revertedAt === undefined
Expand All @@ -1874,9 +1908,16 @@ export function migrateWeekUpState(value: unknown): WeekUpState {
)
)
.map(({ startAt, endAt }) => ({ startAt, endAt }));
const configuredSegments = plan ? configuredPlanSegments(plan) : [];
return {
...fact,
actualSegments: normalizeActualSegments(fact.actualSegments ?? legacySegments),
actualSegments: normalizeActualSegments(
fact.actualSegments?.length
? fact.actualSegments
: legacySegments.length
? legacySegments
: configuredSegments,
),
};
});
const plans: PlanRecord[] = raw.plans.map((plan) => {
Expand Down
8 changes: 5 additions & 3 deletions web/server/week-up-database.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { mkdir, readdir, unlink } from "node:fs/promises";
import { dirname, join } from "node:path";
import { backup, DatabaseSync } from "node:sqlite";

import { createEmptyWeekUpState, dispatchWeekUp, migrateWeekUpState } from "../lib/week-up-domain.ts";
import { createEmptyWeekUpState, dispatchWeekUp, migrateWeekUpState, WEEK_UP_SCHEMA_VERSION } from "../lib/week-up-domain.ts";
import { createLearningMoreDelta } from "../lib/learning-more-delta.ts";
import { createWeekUpStatePatch } from "../lib/state-patch.ts";

Expand Down Expand Up @@ -143,12 +143,14 @@ export async function createWeekUpDatabase(databasePath) {
}

const hybridVersion = database.prepare("SELECT value FROM week_up_meta WHERE key = 'hybrid_model_version'").get()?.value;
const persistedSchemaVersion = JSON.parse(database.prepare("SELECT state_json FROM week_up_snapshots ORDER BY revision DESC LIMIT 1").get().state_json).schemaVersion;
let migrationBackupPath;
if (hybridVersion !== HYBRID_MODEL_VERSION) {
if (hybridVersion !== HYBRID_MODEL_VERSION || persistedSchemaVersion !== WEEK_UP_SCHEMA_VERSION) {
const eventCount = database.prepare("SELECT COUNT(*) AS count FROM week_up_events").get().count;
if (currentState.revision > 0 || eventCount > 0) {
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
migrationBackupPath = join(dirname(dirname(databasePath)), "backups", `pre-hybrid-${stamp}.sqlite`);
const backupPrefix = hybridVersion !== HYBRID_MODEL_VERSION ? "pre-hybrid" : `pre-schema-${WEEK_UP_SCHEMA_VERSION}`;
migrationBackupPath = join(dirname(dirname(databasePath)), "backups", `${backupPrefix}-${stamp}.sqlite`);
await mkdir(dirname(migrationBackupPath), { recursive: true });
await backup(database, migrationBackupPath);
}
Expand Down
33 changes: 29 additions & 4 deletions web/tests/demo-source.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,24 @@ test("opens the month calendar directly from the month dashboard", async () => {
assert.match(monthDashboard, /打开月日历/);
assert.doesNotMatch(monthDashboard, /+ 月方向/);
assert.match(page, /onOpenCalendar=\{\(\) => openCalendar\("month"\)\}/);
assert.match(page, /<CalendarView plans=\{calendarContent === "timeline" \? weekUp\.view\.timelinePlans : plans\} initialMode=\{calendarInitialMode\} content=\{calendarContent\}/);
assert.match(page, /<CalendarView plans=\{calendarContent === "timeline" \? weekUp\.view\.timelinePlans : plans\} unconfiguredPlans=\{plans\} untimedCompletionPlans=\{weekUp\.view\.timelinePlans\} settledDates=\{weekUp\.state\.dailySettlements\.map/);
});

test("offers one visually integrated quick weight entry until today is recorded", async () => {
const [page, css] = await Promise.all([
readFile(new URL("../app/page.tsx", import.meta.url), "utf8"),
readFile(new URL("../app/globals.css", import.meta.url), "utf8"),
]);
assert.match(page, /const hasTodayWeight = weights\.some\(\(entry\) => entry\.date === currentLocalDate\(\)\)/);
assert.match(page, /!hasTodayWeight && <QuickWeightEntry initialValue=\{latest\?\.value\} onSave=\{onRecordWeight\} \/>/);
assert.match(page, /className="weight-form weight-form--quick"/);
assert.match(page, /保存记录/);
assert.match(page, /onRecordWeight=\{addWeight\}/);
const weightView = page.slice(page.indexOf("function WeightView"), page.indexOf("function RewardAttributeTiles"));
assert.doesNotMatch(weightView, /className="weight-form"/);
assert.match(css, /\.weight-form--quick \{[^}]*width: min\(100%,240px\)/);
assert.match(css, /\.weight-form--quick input:focus, \.weight-form--quick input:focus-visible \{ outline: none; box-shadow: none; \}/);
assert.match(css, /\.weight-form--quick \.pixel-button \{[^}]*min-height: 38px/);
});

test("keeps unscheduled plans outside time slots and uses one clickable time ticket", async () => {
Expand All @@ -210,16 +227,24 @@ test("keeps unscheduled plans outside time slots and uses one clickable time tic
assert.match(page, /点击时间票券直接设置/);
assert.match(page, /plan-time--button/);
assert.match(css, /\.unscheduled-dock/);
assert.match(css, /\.drawer-plan__time \{[^}]*background: var\(--yellow\)/);
assert.match(css, /button\.drawer-plan__time \{ cursor: pointer; \}/);
assert.match(css, /button\.drawer-plan__time:hover, button\.drawer-plan__time:focus-visible/);
assert.doesNotMatch(css, /\.drawer-plan__time\.is-unscheduled \{[^}]*font-family/);
assert.match(css, /\.dashboard-action-list \{[^}]*grid-template-columns: repeat\(2,minmax\(0,1fr\)\)/);
});

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" && <div className="unscheduled-dock">/);
assert.match(page, /content === "timeline" \? "未配置时间" : "待安排"/);
assert.match(page, /const countDescription = content === "timeline" \? "条完成记录未配置实际时间"/);
assert.match(page, /content === "timeline" \? "已完成但未记录实际时段"/);
assert.match(page, /readOnly=\{content === "timeline"\}/);
assert.match(page, /const count = unconfiguredPlansForDate\(date\.key\)\.length/);
assert.match(page, /const countDescription = content === "timeline" \? "项未配置时间任务"/);
assert.match(page, /content === "timeline" \? "未配置时间任务" : "待安排"/);
assert.match(page, /unconfiguredPlans=\{plans\}/);
assert.match(page, /selectUnconfiguredPlansForDate/);
assert.match(page, /settledDates=\{weekUp\.state\.dailySettlements\.map/);
assert.match(page, /selectedUnscheduled\.map\(\(plan\) => <CalendarDrawerPlan[^>]*mode=\{mode\} onEditPlan=\{onEditPlan\}/);
});

test("does not hide non-overlapping calendar plans behind a fixed-count overflow ticket", async () => {
Expand Down
52 changes: 52 additions & 0 deletions web/tests/unconfigured-time.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import assert from "node:assert/strict";
import test from "node:test";

import { selectUnconfiguredPlansForDate } from "../lib/unconfigured-time.ts";

function plan(id, overrides = {}) {
return {
id,
title: id,
detail: "",
start: "时间未配置",
end: "",
scheduledDate: "2026-07-25",
timeStatus: "unscheduled",
category: "学习",
categoryColor: "#fff",
categoryTextColor: "#000",
completed: false,
rewards: [],
...overrides,
};
}

test("counts only untimed completed work for a settled date", () => {
const scheduled = [
plan("unfinished"),
plan("overdue", { overdue: true }),
plan("completed-schedule", { completed: true }),
];
const completions = [
plan("untimed-completion", { completed: true }),
plan("timed-completion", { completed: true, timeStatus: "scheduled", start: "09:00", end: "10:00" }),
];

assert.deepEqual(
selectUnconfiguredPlansForDate("2026-07-25", scheduled, completions, new Set(["2026-07-25"])).map((item) => item.id),
["untimed-completion"],
);
});

test("counts every unconfigured plan for an unsettled date", () => {
const scheduled = [
plan("unfinished", { scheduledDate: "2026-07-26" }),
plan("completed", { scheduledDate: "2026-07-26", completed: true }),
plan("timed", { scheduledDate: "2026-07-26", timeStatus: "scheduled", start: "09:00", end: "10:00" }),
];

assert.deepEqual(
selectUnconfiguredPlansForDate("2026-07-26", scheduled, [], new Set()).map((item) => item.id),
["unfinished", "completed"],
);
});
2 changes: 2 additions & 0 deletions web/tests/view-projection.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ test("keeps an untimed completion visible at the top of its completion day", ()
category: "Work",
startAt: "2026-07-29T17:00:00+08:00",
endAt: "2026-07-29T19:00:00+08:00",
timeStatus: "unscheduled",
timeSegments: [],
goalIds: [],
rewards: [],
},
Expand Down
Loading
Loading