diff --git a/src/client/components/redesign/PinnedRoutine.tsx b/src/client/components/redesign/PinnedRoutine.tsx index 1a272f5..da1f0f1 100644 --- a/src/client/components/redesign/PinnedRoutine.tsx +++ b/src/client/components/redesign/PinnedRoutine.tsx @@ -7,7 +7,7 @@ import type { RoutineRow } from "../../state/reader/routines.ts"; import { actionMeta } from "../../state/reader/actions.ts"; import { Fireworks } from "../Fireworks.tsx"; import type { ActionHandler } from "./GameSettings.tsx"; -import { routinePeriod } from "./TimelinePage.tsx"; +import { estimatedReset, routinePeriod } from "./TimelinePage.tsx"; /** The latest logged day inside the routine's current period, if any. */ export function lastDayInPeriod(row: RoutineRow, today: string): string | null { @@ -128,7 +128,10 @@ export function PinnedRoutine({ }; const period = row.cadence === "day" ? "Today" : `This ${row.cadence}`; - const resets = formatRemaining(routinePeriod(row, now).end - now); + const span = routinePeriod(row, now); + const resets = span.estimated + ? estimatedReset(row, now) + : `resets in ${formatRemaining(span.end - now)}`; const progress = amount ? `${count.toLocaleString()} / ${required.toLocaleString()}${unit ? ` ${unit}` : ""}` : required > 1 @@ -145,7 +148,7 @@ export function PinnedRoutine({
{row.title} - {met ? `Done ${lower(period)}` : period} · resets in {resets} + {met ? `Done ${lower(period)}` : period} · {resets} {amount && editing ? ( Next reset in - {formatRemaining( - Math.min(...rows.map((r) => routinePeriod(r, now).end)) - - now, - )} + {(() => { + // An estimated end is not a reset (spec 2026-10-22). + const ends = rows + .map((r) => routinePeriod(r, now)) + .filter((p) => !p.estimated) + .map((p) => p.end); + return ends.length > 0 + ? formatRemaining(Math.min(...ends) - now) + : "not yet dated"; + })()}
diff --git a/src/client/components/redesign/TimelinePage.tsx b/src/client/components/redesign/TimelinePage.tsx index 4352102..eca130a 100644 --- a/src/client/components/redesign/TimelinePage.tsx +++ b/src/client/components/redesign/TimelinePage.tsx @@ -22,7 +22,7 @@ import { actionMeta } from "../../state/reader/actions.ts"; import { useGameMeta } from "../../state/gameMeta.tsx"; import { DAY, formatRemaining, resetShiftMs } from "../../../shared/time.ts"; import { periodOf } from "../../../shared/period.ts"; -import { versionWindow } from "../../../shared/versions.ts"; +import { versionCaption, versionWindow } from "../../../shared/versions.ts"; import { routinesFor } from "../../../shared/routines.ts"; import { canStep, snapDayWidth, stepDayWidth } from "../../state/zoom.ts"; import { orderGames } from "../../state/gameOrder.ts"; @@ -44,22 +44,36 @@ export const timelineLook = (prefs: Record) => export const onTimeline = (r: RoutineRow) => r.placement.followed && r.placement.timeline && r.cadence !== "day"; -/** Periods use each routine's resolved clock, including built-in clock overrides. */ -export function routinePeriod(row: RoutineRow, now: number) { +/** + * Periods use each routine's resolved clock, including built-in clock overrides. + * `estimated`: a version with no recorded end — `end` is then only where the + * band stops (the estimate, or tomorrow once that has passed), never a reset to + * count down to (spec 2026-10-22, decision 3). Say `estimatedReset` instead. + */ +export function routinePeriod( + row: RoutineRow, + now: number, +): { start: number; end: number; estimated: boolean } { const key = readerDayKey(now, row.clock), task = row.task ?? routinesFor(row.game).find((r) => r.id === row.id); if (task?.period.unit === "version") { - // Drawn to the recorded end, else to the estimate: a picture, not a reset. const w = versionWindow(row.game, now); - if (w) return { start: w.start, end: w.end ?? w.estimate ?? now + DAY }; + if (w?.end !== undefined) return { start: w.start, end: w.end, estimated: false }; + if (w) return { start: w.start, end: Math.max(w.estimate ?? 0, now + DAY), estimated: true }; } const period = task && task.period.unit !== "version" ? periodOf(task.period, key) : { start: key, days: [key] }; const shift = resetShiftMs(row.clock.region, row.game, row.clock); const start = Date.parse(period.start + "T00:00:00Z") - shift; - return { start, end: start + period.days.length * DAY }; + return { start, end: start + period.days.length * DAY, estimated: false }; } + +/** What to print instead of a countdown when `routinePeriod` is estimated. */ +export const estimatedReset = (row: RoutineRow, now: number): string => { + const w = versionWindow(row.game, now); + return w ? versionCaption(w, now) : "resets with next version"; +}; export function TimelinePage({ state, focus = null, @@ -284,12 +298,16 @@ export function TimelinePage({ /> {r.status.count}/{r.status.required} ·{" "} - {showEnd - ? `resets ${formatInZone(period.end, true, zone)}` - : `${formatRemaining(period.end - now)} to reset`} + {period.estimated + ? estimatedReset(r, now) + : showEnd + ? `resets ${formatInZone(period.end, true, zone)}` + : `${formatRemaining(period.end - now)} to reset`} - + {!period.estimated && ( + + )} @@ -389,9 +407,11 @@ export function TimelinePage({ start: period.start, end: period.end, done: r.status.met, - label: showEnd - ? `resets ${day(period.end)}` - : `${formatRemaining(period.end - now)} to reset`, + label: period.estimated + ? estimatedReset(r, now) + : showEnd + ? `resets ${day(period.end)}` + : `${formatRemaining(period.end - now)} to reset`, onOpen: () => setRoutine(r), }; })} @@ -547,8 +567,14 @@ function RoutineDetail({

{row.title}

- Resets {formatInZone(period.end, true, zone)} · in{" "} - {formatRemaining(period.end - now)} + {period.estimated ? ( + estimatedReset(row, now).replace(/^r/, "R") + ) : ( + <> + Resets {formatInZone(period.end, true, zone)} · in{" "} + {formatRemaining(period.end - now)} + + )}

diff --git a/test/reader-routines.test.ts b/test/reader-routines.test.ts index 089424c..959e45a 100644 --- a/test/reader-routines.test.ts +++ b/test/reader-routines.test.ts @@ -76,6 +76,7 @@ test("a routine that resets with the version is a cycle row, banded to its recor expect(routinePeriod(radiant, Date.parse(now))).toEqual({ start: Date.parse("2026-09-30T03:00:00Z"), end: Date.parse("2026-11-11T10:30:00Z"), + estimated: false, }); // Past the recorded end, drawn to the estimate — a picture, not a reset. expect(routinePeriod(radiant, Date.parse("2026-11-20T00:00:00Z")).end).toBe( diff --git a/test/redesign-pinned-routine.test.tsx b/test/redesign-pinned-routine.test.tsx index 8b44f7e..09bb0ae 100644 --- a/test/redesign-pinned-routine.test.tsx +++ b/test/redesign-pinned-routine.test.tsx @@ -61,3 +61,37 @@ test("undoing a check-in removes the period's latest tick, not one from before i lastDayInPeriod({ ...ticked, days: ["2026-10-08"] }, today), ).toBeNull(); }); + +// Review fix (spec 2026-10-22, decision 3): with no recorded end, an estimate +// is shown as one — never as a reset, and never "ended" once it passes. +import { RoutinesPage } from "../src/client/components/redesign/RoutinesPage.tsx"; +import { routinePeriod } from "../src/client/components/redesign/TimelinePage.tsx"; +const wuwa = (iso: string) => { + const state = migrateLegacy({}, ["wuwa"], iso, () => "account:pin"); + for (const id of ["routine:wuwa:shop-radiant-tide", "routine:wuwa:tower-of-adversity"]) + state.accounts["account:pin"]!.routines[id] = { followed: true, checklist: true, timeline: true }; + const rows = routineRows(state, "account:pin", Date.parse(iso)); + return { state, shop: rows.find((r) => r.id === "routine:wuwa:shop-radiant-tide")! }; +}; +for (const iso of ["2026-11-20T00:00:00.000Z", "2027-01-02T00:00:00.000Z"]) { + test(`an open-ended version shows its estimate as one (${iso.slice(0, 10)})`, () => { + const t = Date.parse(iso); + const { state, shop } = wuwa(iso); + const period = routinePeriod(shop, t); + expect(period.estimated).toBe(true); + expect(period.end).toBeGreaterThan(t); // the band still covers today + const pinned = render(); + expect(pinned).toContain("resets with next version · expected ~23 Dec"); + expect(pinned).not.toContain("resets in"); + const page = render(); + expect(page).not.toContain("ended"); + }); +} +test("a recorded version end is still a countdown", () => { + const iso = "2026-10-22T14:30:00.000Z"; + const { shop } = wuwa(iso); + expect(routinePeriod(shop, Date.parse(iso)).estimated).toBe(false); + expect(render()).toContain( + "resets in 19d 20h", + ); +}); diff --git a/test/redesign-timeline.test.tsx b/test/redesign-timeline.test.tsx index a4699b8..50adfc9 100644 --- a/test/redesign-timeline.test.tsx +++ b/test/redesign-timeline.test.tsx @@ -172,3 +172,24 @@ test("the board opens back to the running starts, with now a quarter to half acr // In between: opens just before the start. expect(openingLeft(1000, 700, 800)).toBe(672); }); + +test("an open-ended version's row says expected, never a countdown or 'ended'", () => { + // Review fix (spec 2026-10-22, decision 3), in both looks. + const at = "2027-01-02T00:00:00.000Z", + s = migrateLegacy({}, ["wuwa"], at, () => "account:test"); + s.accounts["account:test"]!.routines["routine:wuwa:shop-radiant-tide"] = { + followed: true, + checklist: true, + timeline: true, + }; + for (const look of ["new", "classic"]) { + s.prefs.timelineLook = look; + const html = render( + ({ ok: true })} onOpen={() => {}} />, + ); + if (look === "new") + expect(html).toContain("resets with next version · expected ~23 Dec, not yet confirmed"); + expect(html).not.toContain("ended"); + expect(html).not.toContain("to reset"); + } +});