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");
+ }
+});