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:
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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user