feat: let a day already gone be ticked
People play at midnight and tick at breakfast, or forget for a week and come back. A log that only accepts today goes wrong on its first bad evening and is never trusted again. An event whose end was published already allowed this — its checklist draws the whole run and past pips are clickable. `catchUpDays` answers the two cases `dailyDays` cannot: a standing chore, which has no start or end because it is a routine rather than an event, and an event with `endsAt: null`, where the checklist could draw no run at all and so offered only today. That second one is the worse of the two, because it looks like a working checklist. Never a day past today. A tick claims you did it, and nobody can have done tomorrow, so a future day is absent rather than rendered and disabled — a control for a claim that cannot be true. An event's strip starts when the event did, capped at the same fortnight so a login campaign that opened in March does not become a wall of pips. The window bounds display and never data: a tick older than it stays logged and keeps counting toward the streak. `DayPip` moves to its own module rather than being copied, so there is one answer to what a missed day looks like. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
88d5ea2e43
commit
bf7959c916
@@ -1,9 +1,12 @@
|
|||||||
import {
|
import {
|
||||||
|
CATCH_UP_DAYS,
|
||||||
|
catchUpDays,
|
||||||
dailySummary,
|
dailySummary,
|
||||||
msUntilReset,
|
msUntilReset,
|
||||||
type DailySummary,
|
type DailySummary,
|
||||||
} from "../../shared/daily.ts";
|
} from "../../shared/daily.ts";
|
||||||
import type { LaneId } from "../../shared/custom.ts";
|
import type { LaneId } from "../../shared/custom.ts";
|
||||||
|
import { DayPip } from "./DayPip.tsx";
|
||||||
import type { Region } from "../../shared/schema.ts";
|
import type { Region } from "../../shared/schema.ts";
|
||||||
import { formatRemaining } from "../../shared/time.ts";
|
import { formatRemaining } from "../../shared/time.ts";
|
||||||
|
|
||||||
@@ -95,10 +98,30 @@ export function DailyChecklist({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{days === null ? (
|
{days === null ? (
|
||||||
<p className="mt-3 text-xs leading-relaxed text-faint">
|
<>
|
||||||
|
{/* No published end means no run to draw, but the days already gone
|
||||||
|
are just as claimable as an announced event's — and until this
|
||||||
|
existed they were unreachable, so a reader who forgot to tick
|
||||||
|
yesterday could never say so. Only the past, and only as far back
|
||||||
|
as `catchUpDays` goes. */}
|
||||||
|
<div className="mt-3 flex flex-wrap gap-1">
|
||||||
|
{catchUpDays(now, region, game, startsMs).map((day) => (
|
||||||
|
<DayPip
|
||||||
|
key={day}
|
||||||
|
day={day}
|
||||||
|
today={today}
|
||||||
|
done={logged.includes(day)}
|
||||||
|
onToggle={() => onToggleDay(day)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="mt-2.5 text-xs leading-relaxed text-faint">
|
||||||
The source hasn't announced an end date, so how many days are left is
|
The source hasn't announced an end date, so how many days are left is
|
||||||
unknown. Your ticks are still counted — {summary.logged} so far.
|
unknown. Your ticks are still counted — {summary.logged} so far, and
|
||||||
|
the last {CATCH_UP_DAYS} days are above if you did one and forgot to
|
||||||
|
say.
|
||||||
</p>
|
</p>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="mt-3 flex flex-wrap gap-1">
|
<div className="mt-3 flex flex-wrap gap-1">
|
||||||
@@ -121,57 +144,6 @@ export function DailyChecklist({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* One day. Future days are dimmed but not disabled-looking, past misses read as
|
|
||||||
* empty rather than as an error — a missed daily is information, not a telling
|
|
||||||
* off.
|
|
||||||
*/
|
|
||||||
function DayPip({
|
|
||||||
day,
|
|
||||||
today,
|
|
||||||
done,
|
|
||||||
onToggle,
|
|
||||||
}: {
|
|
||||||
day: string;
|
|
||||||
today: string;
|
|
||||||
done: boolean;
|
|
||||||
onToggle: () => void;
|
|
||||||
}) {
|
|
||||||
const isToday = day === today;
|
|
||||||
const isFuture = day > today;
|
|
||||||
// Rendered in UTC on purpose. A day key is a game-day, not an instant, and
|
|
||||||
// formatting it in the reader's own zone shifts it a day backwards for
|
|
||||||
// everyone west of UTC — so the pip would read "12" while the label a screen
|
|
||||||
// reader announces said "Aug 11".
|
|
||||||
const label = new Date(`${day}T00:00:00Z`).toLocaleDateString(undefined, {
|
|
||||||
day: "numeric",
|
|
||||||
month: "short",
|
|
||||||
timeZone: "UTC",
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onToggle}
|
|
||||||
disabled={isFuture}
|
|
||||||
aria-pressed={done}
|
|
||||||
aria-label={`${label}${done ? ", done" : ", not done"}`}
|
|
||||||
title={label}
|
|
||||||
className={`tnum size-6 rounded-[5px] border text-[0.625rem] leading-none transition-colors ${
|
|
||||||
done
|
|
||||||
? "border-transparent bg-near/25 text-near"
|
|
||||||
: isFuture
|
|
||||||
? "border-hairline/60 text-faint/50"
|
|
||||||
: "border-hairline text-faint hover:border-faint"
|
|
||||||
} ${isToday ? "ring-1 ring-ink/40" : ""} ${
|
|
||||||
isFuture ? "cursor-default" : "cursor-pointer"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{day.slice(8)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function caption(summary: DailySummary, todayInWindow: boolean): string {
|
function caption(summary: DailySummary, todayInWindow: boolean): string {
|
||||||
const { days, logged, remaining, missed } = summary;
|
const { days, logged, remaining, missed } = summary;
|
||||||
if (days === null || remaining === null) return `${logged} days ticked off.`;
|
if (days === null || remaining === null) return `${logged} days ticked off.`;
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* One game-day, ticked or not.
|
||||||
|
*
|
||||||
|
* Shared by the two places a run of days is drawn: an event's checklist in the
|
||||||
|
* detail sheet, and the catch-up strips on the dailies section and on an event
|
||||||
|
* whose end was never announced. One pip rather than two, because they mean the
|
||||||
|
* same thing to the reader and a second copy is a second set of rules about
|
||||||
|
* what a missed day looks like.
|
||||||
|
*
|
||||||
|
* Future days are dimmed but not disabled-looking, and past misses read as
|
||||||
|
* empty rather than as an error — a missed daily is information, not a telling
|
||||||
|
* off. A catch-up strip never contains a future day at all, so there the
|
||||||
|
* dimming never appears.
|
||||||
|
*/
|
||||||
|
export function DayPip({
|
||||||
|
day,
|
||||||
|
today,
|
||||||
|
done,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
day: string;
|
||||||
|
today: string;
|
||||||
|
done: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
}) {
|
||||||
|
const isToday = day === today;
|
||||||
|
const isFuture = day > today;
|
||||||
|
// Rendered in UTC on purpose. A day key is a game-day, not an instant, and
|
||||||
|
// formatting it in the reader's own zone shifts it a day backwards for
|
||||||
|
// everyone west of UTC — so the pip would read "12" while the label a screen
|
||||||
|
// reader announces said "Aug 11".
|
||||||
|
const label = new Date(`${day}T00:00:00Z`).toLocaleDateString(undefined, {
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
timeZone: "UTC",
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggle}
|
||||||
|
disabled={isFuture}
|
||||||
|
aria-pressed={done}
|
||||||
|
aria-label={`${label}${done ? ", done" : ", not done"}`}
|
||||||
|
title={label}
|
||||||
|
className={`tnum size-6 rounded-[5px] border text-[0.625rem] leading-none transition-colors ${
|
||||||
|
done
|
||||||
|
? "border-transparent bg-near/25 text-near"
|
||||||
|
: isFuture
|
||||||
|
? "border-hairline/60 text-faint/50"
|
||||||
|
: "border-hairline text-faint hover:border-faint"
|
||||||
|
} ${isToday ? "ring-1 ring-ink/40" : ""} ${
|
||||||
|
isFuture ? "cursor-default" : "cursor-pointer"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{day.slice(8)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -161,6 +161,59 @@ export function dailyDays(
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far back a catch-up strip reaches.
|
||||||
|
*
|
||||||
|
* A fortnight: long enough to repair a holiday or a bad week, short enough that
|
||||||
|
* it is still recording what you did rather than reconstructing a month from
|
||||||
|
* memory. It bounds *display* only — see `catchUpDays`.
|
||||||
|
*/
|
||||||
|
export const CATCH_UP_DAYS = 14;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The days a reader can still say they did, oldest first.
|
||||||
|
*
|
||||||
|
* `dailyDays` answers this for an event whose end was announced, because there
|
||||||
|
* the whole run is known. Two things it cannot answer: a game's standing chore,
|
||||||
|
* which has no start and no end because it is a routine rather than an event,
|
||||||
|
* and an event with `endsAt: null`, where `dailyDays` returns null and the
|
||||||
|
* checklist has nothing to draw. Both leave the reader with today and no way to
|
||||||
|
* record yesterday, which is the day they actually did and forgot to tick.
|
||||||
|
*
|
||||||
|
* `notBefore` is an instant to clip at — an event's start — or null when there
|
||||||
|
* is nothing to clip at, which is the chore case. Never returns a day past
|
||||||
|
* today: a tick is a claim you did it, and tomorrow is not something anyone can
|
||||||
|
* have done, so it is absent rather than present-and-disabled.
|
||||||
|
*
|
||||||
|
* **This bounds what is shown and never what is stored.** A tick older than the
|
||||||
|
* window stays in the log, keeps counting toward `streakOf` and toward
|
||||||
|
* `dailySummary`'s totals, and is simply off-screen — nothing here removes a day
|
||||||
|
* the reader did not remove themselves.
|
||||||
|
*/
|
||||||
|
export function catchUpDays(
|
||||||
|
now: number,
|
||||||
|
region: Region,
|
||||||
|
game: LaneId | undefined,
|
||||||
|
notBefore: number | null,
|
||||||
|
span = CATCH_UP_DAYS,
|
||||||
|
): string[] {
|
||||||
|
const today = dayKey(now, region, game);
|
||||||
|
const floor = notBefore === null ? null : dayKey(notBefore, region, game);
|
||||||
|
|
||||||
|
const out: string[] = [];
|
||||||
|
// Walked in day-key space rather than in instants, like `streakOf`: these are
|
||||||
|
// keys cut on a game's reset clock, and stepping a calendar day back is the
|
||||||
|
// only operation that keeps them lined up with what was written.
|
||||||
|
let cursor = Date.parse(`${today}T00:00:00Z`);
|
||||||
|
while (out.length < span) {
|
||||||
|
const key = keyOf(cursor);
|
||||||
|
if (floor !== null && key < floor) break;
|
||||||
|
out.push(key);
|
||||||
|
cursor -= DAY;
|
||||||
|
}
|
||||||
|
return out.reverse();
|
||||||
|
}
|
||||||
|
|
||||||
export interface DailySummary {
|
export interface DailySummary {
|
||||||
/** Every claimable day, oldest first. Null when the end is unannounced. */
|
/** Every claimable day, oldest first. Null when the end is unannounced. */
|
||||||
days: string[] | null;
|
days: string[] | null;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import {
|
import {
|
||||||
|
catchUpDays,
|
||||||
|
CATCH_UP_DAYS,
|
||||||
dailiesId,
|
dailiesId,
|
||||||
dailyDays,
|
dailyDays,
|
||||||
dailyOverride,
|
dailyOverride,
|
||||||
@@ -408,3 +410,87 @@ describe("a game whose day rolls on a different hour", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("catchUpDays", () => {
|
||||||
|
const NOW = at("2026-08-20T12:00:00.000Z");
|
||||||
|
|
||||||
|
test("the last fortnight, oldest first, ending today", () => {
|
||||||
|
const days = catchUpDays(NOW, "europe", "genshin", null);
|
||||||
|
expect(days).toHaveLength(CATCH_UP_DAYS);
|
||||||
|
expect(days[days.length - 1]).toBe(dayKey(NOW, "europe", "genshin"));
|
||||||
|
expect(days[0]).toBe(dayKey(NOW - 13 * DAY, "europe", "genshin"));
|
||||||
|
expect([...days].sort()).toEqual(days);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("never a day past today", () => {
|
||||||
|
// A tick is a claim that you did it. Tomorrow is not a thing a reader can
|
||||||
|
// have done, so it is not rendered at all rather than rendered and disabled.
|
||||||
|
const days = catchUpDays(NOW, "europe", "genshin", null);
|
||||||
|
const today = dayKey(NOW, "europe", "genshin");
|
||||||
|
expect(days.filter((d) => d > today)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stops at the day the event began", () => {
|
||||||
|
// An event that opened three days ago has three days to catch up on, not
|
||||||
|
// fourteen — the days before it existed were never claimable.
|
||||||
|
const started = NOW - 2 * DAY;
|
||||||
|
const days = catchUpDays(NOW, "europe", "genshin", started);
|
||||||
|
expect(days).toEqual([
|
||||||
|
dayKey(started, "europe", "genshin"),
|
||||||
|
dayKey(NOW - DAY, "europe", "genshin"),
|
||||||
|
dayKey(NOW, "europe", "genshin"),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a long-running event is still capped at the fortnight", () => {
|
||||||
|
// A standing login campaign can have opened half a year ago. Its whole
|
||||||
|
// history would be a wall of pips nobody scrolls, and reconstructing March
|
||||||
|
// from memory is not recording what you did.
|
||||||
|
const days = catchUpDays(NOW, "europe", "genshin", NOW - 200 * DAY);
|
||||||
|
expect(days).toHaveLength(CATCH_UP_DAYS);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an event that has not started yet offers nothing", () => {
|
||||||
|
expect(catchUpDays(NOW, "europe", "genshin", NOW + 3 * DAY)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cut on the game's own reset clock, not the region's", () => {
|
||||||
|
// Endfield serves Europe off the Americas machine, so its European day
|
||||||
|
// rolls at 09:00 UTC rather than 03:00 — a strip cut on the wrong clock
|
||||||
|
// writes a tick under one day key and reads it under another.
|
||||||
|
const dawn = at("2026-08-20T05:00:00.000Z");
|
||||||
|
const generic = catchUpDays(dawn, "europe", undefined, null);
|
||||||
|
const endfield = catchUpDays(dawn, "europe", "endfield", null);
|
||||||
|
expect(endfield).not.toEqual(generic);
|
||||||
|
expect(endfield[endfield.length - 1]).toBe(dayKey(dawn, "europe", "endfield"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the window bounds what is shown and never what is stored", () => {
|
||||||
|
// A tick from five weeks ago is off-screen, still logged, and still counted.
|
||||||
|
// Nothing here removes a day the reader did not remove themselves.
|
||||||
|
const old = dayKey(NOW - 35 * DAY, "europe", "genshin");
|
||||||
|
const days = catchUpDays(NOW, "europe", "genshin", null);
|
||||||
|
expect(days).not.toContain(old);
|
||||||
|
|
||||||
|
const logged = [old, dayKey(NOW, "europe", "genshin")];
|
||||||
|
const summary = dailySummary({
|
||||||
|
startsMs: NOW - 40 * DAY,
|
||||||
|
endsMs: null,
|
||||||
|
region: "europe",
|
||||||
|
game: "genshin",
|
||||||
|
now: NOW,
|
||||||
|
logged,
|
||||||
|
});
|
||||||
|
expect(summary.logged).toBe(2);
|
||||||
|
expect(summary.doneToday).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a streak built outside the window still counts", () => {
|
||||||
|
const today = dayKey(NOW, "europe", "genshin");
|
||||||
|
const logged = Array.from({ length: 30 }, (_, i) =>
|
||||||
|
dayKey(NOW - i * DAY, "europe", "genshin"),
|
||||||
|
);
|
||||||
|
expect(streakOf(logged, today)).toBe(30);
|
||||||
|
expect(catchUpDays(NOW, "europe", "genshin", null)).toHaveLength(CATCH_UP_DAYS);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user