fix(routines): an estimated version end is shown, never counted down

With no recorded end, the pinned row, the timeline labels, the routine
dialog and a group's "Next reset in" treated the estimate as a reset,
and read "ended" once it passed. They now print the version caption, and
the band reaches today.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-22 18:00:11 +02:00
1 parent 3c53fe1a06
commit 5896819810
6 files changed
+113 -22

No files matched your search

@@ -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({
<div className="pr-text">
<strong>{row.title}</strong>
<small>
{met ? `Done ${lower(period)}` : period} · resets in {resets}
{met ? `Done ${lower(period)}` : period} · {resets}
</small>
{amount && editing ? (
<AmountEditor
@@ -364,10 +364,16 @@ export function RoutinesPage({
<div className="band-reset">
<small>Next reset in</small>
<strong>
{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";
})()}
</strong>
</div>
</div>
+41 -15
View File
@@ -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<string, unknown>) =>
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({
/>
<span>
{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`}
</span>
</button>
<i className="reset-mark" style={{ left: pos(period.end) }} />
{!period.estimated && (
<i className="reset-mark" style={{ left: pos(period.end) }} />
)}
</div>
</div>
</div>
@@ -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({
</div>
<h2 className="detail-title">{row.title}</h2>
<p className="help">
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)}
</>
)}
</p>
<RoutineControl row={row} onAction={onAction} now={now} />
</dialog>
+1
View File
@@ -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(
+34
View File
@@ -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(<PinnedRoutine row={shop} hue="#3DD6A0" onAction={ok} now={t} />);
expect(pinned).toContain("resets with next version · expected ~23 Dec");
expect(pinned).not.toContain("resets in");
const page = render(<RoutinesPage state={state} onAction={ok} now={t} />);
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(<PinnedRoutine row={shop} hue="#3DD6A0" onAction={ok} now={Date.parse(iso)} />)).toContain(
"resets in 19d 20h",
);
});
+21
View File
@@ -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(
<TimelinePage state={s} rows={[]} now={Date.parse(at)} onAction={async () => ({ 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");
}
});