feat: track events that repeat daily, and each game's dailies
A login campaign is not one job with a deadline. It is twenty small jobs on twenty separate deadlines, and a day you miss is gone whatever you do afterwards — which a single "done" tick cannot express. Repeating events now get a checklist: today's tick, a strip of every day in the run showing what you got and what you missed, the streak, and how many chances are left. Past days stay editable, because people tick up later and a checklist you cannot correct stops being trusted after the first mistake. Alongside it sits today's dailies — commissions, sanity, daily training — one tick per game, keyed `dailies:<game>`, since no source publishes those and they are the only thing on the page that expires tonight rather than next patch. Days roll at 04:00 server time per region, not midnight: finishing at 02:00 is still yesterday, and a naive UTC date would tick the wrong box for four hours every night. Dailiness is read off what the source published — a login event type, or "daily"/"check-in"/"7-day" wording — never from a game's habits or an event's length. That adds no schema field, so the feed contract and every event ID are untouched. An unannounced end yields a tick count, not a checklist of invented length, and a tick is never removed except by the reader, including ticks outside the window the feed now claims: a source quietly moving a date must not erase a fortnight's streak that exists nowhere else. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ad3647bee2
commit
ccc5d369bd
@@ -104,8 +104,9 @@ export function Controls({
|
||||
<div className="mt-6 border-t border-hairline pt-4">
|
||||
<p className="eyebrow">Your progress</p>
|
||||
<p className="mt-1.5 max-w-md text-xs leading-relaxed text-faint">
|
||||
Completed events are saved in this browser only — there is no account.
|
||||
Move them to another device with a file.
|
||||
What you've finished, and every daily you've ticked off, are saved in
|
||||
this browser only — there is no account. Move them to another device
|
||||
with a file.
|
||||
</p>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { dailiesId, dayKey, msUntilReset, streakOf } from "../../shared/daily.ts";
|
||||
import { gameMeta } from "../../shared/games.ts";
|
||||
import type { GameId, Region } from "../../shared/schema.ts";
|
||||
import { formatRemaining } from "../../shared/time.ts";
|
||||
|
||||
/**
|
||||
* The chores no source publishes.
|
||||
*
|
||||
* Commissions, sanity, daily training — the routine that runs whether or not
|
||||
* an event is on. They are the most-missed thing in every one of these games
|
||||
* and they appear on no wiki page, so they are a fixed client-side list rather
|
||||
* than feed data. Ticking one is stored in exactly the same day log an event's
|
||||
* checklist uses, so streaks and exports work the same way for both.
|
||||
*
|
||||
* Sits above the event list because it is the one part of the page that is
|
||||
* answerable in ten seconds and expires tonight.
|
||||
*/
|
||||
export function Dailies({
|
||||
games,
|
||||
region,
|
||||
now,
|
||||
daysFor,
|
||||
onToggleDay,
|
||||
}: {
|
||||
games: GameId[];
|
||||
region: Region;
|
||||
now: number;
|
||||
daysFor: (id: string) => string[];
|
||||
onToggleDay: (id: string, day: string) => void;
|
||||
}) {
|
||||
if (games.length === 0) return null;
|
||||
|
||||
const today = dayKey(now, region);
|
||||
const done = games.filter((g) => daysFor(dailiesId(g)).includes(today));
|
||||
|
||||
return (
|
||||
<section className="border-b border-hairline px-4 py-4">
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<h2 className="eyebrow">
|
||||
Today's dailies · {done.length}/{games.length}
|
||||
</h2>
|
||||
<p className="tnum text-[0.6875rem] text-faint">
|
||||
resets in {formatRemaining(msUntilReset(now, region))}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul className="mt-2.5 flex flex-wrap gap-1.5">
|
||||
{games.map((id) => {
|
||||
const game = gameMeta(id);
|
||||
const key = dailiesId(id);
|
||||
const days = daysFor(key);
|
||||
const isDone = days.includes(today);
|
||||
const streak = streakOf(days, today);
|
||||
|
||||
return (
|
||||
<li key={id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggleDay(key, today)}
|
||||
aria-pressed={isDone}
|
||||
aria-label={`${game.name} dailies — ${game.dailyTasks}${
|
||||
isDone ? ", done today" : ", not done today"
|
||||
}`}
|
||||
title={game.dailyTasks}
|
||||
className="flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-medium transition-colors"
|
||||
style={{
|
||||
borderColor: isDone ? game.hue : "var(--color-hairline)",
|
||||
color: isDone ? game.hue : "var(--color-faint)",
|
||||
background: isDone
|
||||
? `color-mix(in srgb, ${game.hue} 14%, transparent)`
|
||||
: "transparent",
|
||||
}}
|
||||
>
|
||||
<svg viewBox="0 0 16 16" aria-hidden className="size-3">
|
||||
<path
|
||||
d="M2.5 8.5l3.5 3.5 7.5-8"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
opacity={isDone ? 1 : 0.3}
|
||||
/>
|
||||
</svg>
|
||||
{game.short}
|
||||
{streak > 1 && (
|
||||
<span className="tnum text-[0.625rem] opacity-70">
|
||||
{streak}d
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<p className="mt-2 text-[0.6875rem] leading-relaxed text-faint">
|
||||
{done.length === games.length
|
||||
? "All done. Nothing else expires tonight."
|
||||
: `${waiting(games.length - done.length)} still waiting on you today.`}
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function waiting(n: number): string {
|
||||
return n === 1 ? "One game" : `${n} games`;
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import {
|
||||
dailySummary,
|
||||
msUntilReset,
|
||||
type DailySummary,
|
||||
} from "../../shared/daily.ts";
|
||||
import type { Region } from "../../shared/schema.ts";
|
||||
import { formatRemaining } from "../../shared/time.ts";
|
||||
|
||||
/**
|
||||
* The checklist for an event you have to come back to every day.
|
||||
*
|
||||
* A single "done" tick is the wrong shape for these: the job is not finished
|
||||
* or unfinished, it is finished *today*, and yesterday's is gone whatever you
|
||||
* do now. So this shows the whole run as a strip of days — what you got, what
|
||||
* you missed, and how many chances are left — with today's tick as the one
|
||||
* prominent control.
|
||||
*
|
||||
* Past days stay clickable on purpose. People log in and tick up later, and a
|
||||
* checklist that cannot be corrected stops being trusted after the first
|
||||
* mistake.
|
||||
*/
|
||||
export function DailyChecklist({
|
||||
startsMs,
|
||||
endsMs,
|
||||
region,
|
||||
now,
|
||||
logged,
|
||||
onToggleDay,
|
||||
}: {
|
||||
startsMs: number;
|
||||
endsMs: number | null;
|
||||
region: Region;
|
||||
now: number;
|
||||
logged: string[];
|
||||
onToggleDay: (day: string) => void;
|
||||
}) {
|
||||
const summary = dailySummary({ startsMs, endsMs, region, now, logged });
|
||||
const { days, today, doneToday, todayInWindow } = summary;
|
||||
const started = now >= startsMs;
|
||||
|
||||
return (
|
||||
<div className="mt-5 rounded-xl border border-hairline p-4">
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<p className="eyebrow">Daily checklist</p>
|
||||
<p className="tnum text-[0.6875rem] text-faint">
|
||||
resets in {formatRemaining(msUntilReset(now, region))}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggleDay(today)}
|
||||
disabled={!started}
|
||||
aria-pressed={doneToday}
|
||||
className={`mt-3 flex w-full items-center gap-3 rounded-lg border px-3 py-2.5 text-left transition-colors ${
|
||||
doneToday
|
||||
? "border-near/60 bg-near/10 text-near"
|
||||
: "border-hairline text-ink hover:border-faint"
|
||||
} ${started ? "cursor-pointer" : "cursor-not-allowed opacity-50"}`}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={`grid size-6 shrink-0 place-items-center rounded-md border ${
|
||||
doneToday ? "border-transparent bg-near/25" : "border-hairline"
|
||||
}`}
|
||||
>
|
||||
<svg viewBox="0 0 16 16" className="size-3.5">
|
||||
<path
|
||||
d="M2.5 8.5l3.5 3.5 7.5-8"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
opacity={doneToday ? 1 : 0.25}
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<span className="text-sm font-medium">
|
||||
{!started
|
||||
? "Not started yet"
|
||||
: doneToday
|
||||
? "Done today"
|
||||
: "Do today's"}
|
||||
</span>
|
||||
{summary.streak > 1 && (
|
||||
<span className="ml-auto text-[0.6875rem] text-faint">
|
||||
{summary.streak}-day streak
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{days === null ? (
|
||||
<p className="mt-3 text-xs leading-relaxed text-faint">
|
||||
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.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="mt-3 flex flex-wrap gap-1">
|
||||
{days.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">
|
||||
{caption(summary, todayInWindow)}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
const label = new Date(`${day}T00:00:00Z`).toLocaleDateString(undefined, {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
});
|
||||
|
||||
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 {
|
||||
const { days, logged, remaining, missed } = summary;
|
||||
if (days === null || remaining === null) return `${logged} days ticked off.`;
|
||||
|
||||
const parts = [`${logged} of ${days.length} days`];
|
||||
if (remaining > 0) {
|
||||
parts.push(
|
||||
todayInWindow
|
||||
? `${remaining} left including today`
|
||||
: `${remaining} to come`,
|
||||
);
|
||||
} else {
|
||||
parts.push("the run is over");
|
||||
}
|
||||
if (missed !== null && missed > 0) parts.push(`${missed} missed`);
|
||||
return `${parts.join(" · ")}.`;
|
||||
}
|
||||
@@ -5,6 +5,9 @@ import type { RowEvent } from "./EventRow.tsx";
|
||||
import { pressure, pressureReason, type Effort } from "../../shared/effort.ts";
|
||||
import type { Status } from "../state/useProgress.ts";
|
||||
import { ProgressControls } from "./ProgressControls.tsx";
|
||||
import { DailyChecklist } from "./DailyChecklist.tsx";
|
||||
import { isDaily } from "../../shared/daily.ts";
|
||||
import type { Region } from "../../shared/schema.ts";
|
||||
import { Meter, URGENCY_COLOR } from "./Meter.tsx";
|
||||
|
||||
export function EventDetail({
|
||||
@@ -14,6 +17,10 @@ export function EventDetail({
|
||||
status,
|
||||
effort,
|
||||
note,
|
||||
region,
|
||||
now,
|
||||
dailyDays,
|
||||
onToggleDay,
|
||||
onToggle,
|
||||
onIgnore,
|
||||
onStatus,
|
||||
@@ -27,6 +34,11 @@ export function EventDetail({
|
||||
status: Status | undefined;
|
||||
effort: Effort | undefined;
|
||||
note: string;
|
||||
region: Region;
|
||||
now: number;
|
||||
/** Days already ticked off, for events that repeat. */
|
||||
dailyDays: string[];
|
||||
onToggleDay: (id: string, day: string) => void;
|
||||
onToggle: (id: string) => void;
|
||||
onIgnore: (id: string) => void;
|
||||
onStatus: (id: string, s: Status | undefined) => void;
|
||||
@@ -118,6 +130,20 @@ export function EventDetail({
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* A repeating event gets the checklist instead of nothing but a
|
||||
"mark done" — its work is spread over every day of the run, and one
|
||||
tick cannot express that. */}
|
||||
{isDaily(event) && (
|
||||
<DailyChecklist
|
||||
startsMs={clock.startsMs}
|
||||
endsMs={clock.endsMs}
|
||||
region={region}
|
||||
now={now}
|
||||
logged={dailyDays}
|
||||
onToggleDay={(day) => onToggleDay(event.id, day)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ProgressControls
|
||||
status={status}
|
||||
effort={effort}
|
||||
|
||||
@@ -14,11 +14,20 @@ export interface RowEvent {
|
||||
clock: EventClock;
|
||||
}
|
||||
|
||||
/** What a repeating event needs to say in a list: today, and how many left. */
|
||||
export interface DailyBadge {
|
||||
doneToday: boolean;
|
||||
/** Days left including today. Null when the end is unannounced. */
|
||||
remaining: number | null;
|
||||
}
|
||||
|
||||
interface EventRowProps {
|
||||
row: RowEvent;
|
||||
completed: boolean;
|
||||
status?: Status | undefined;
|
||||
effort?: Effort | undefined;
|
||||
/** Present only on events that repeat daily. */
|
||||
daily?: DailyBadge | undefined;
|
||||
/** Only ever true when the reader has chosen to reveal ignored events. */
|
||||
ignored?: boolean | undefined;
|
||||
onRestore?: ((id: string) => void) | undefined;
|
||||
@@ -30,6 +39,7 @@ export function EventRow({
|
||||
completed,
|
||||
status,
|
||||
effort,
|
||||
daily,
|
||||
ignored = false,
|
||||
onRestore,
|
||||
onOpen,
|
||||
@@ -102,8 +112,28 @@ export function EventRow({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{(status === "doing" || effort !== undefined || risk !== "fine") && (
|
||||
{(status === "doing" ||
|
||||
effort !== undefined ||
|
||||
daily !== undefined ||
|
||||
risk !== "fine") && (
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
|
||||
{/* A repeating event's real deadline is tonight's reset, not the
|
||||
end date the countdown shows, so the row says both. */}
|
||||
{daily !== undefined && (
|
||||
<span
|
||||
className={`rounded-[3px] px-1.5 py-px text-[0.625rem] font-medium ${
|
||||
daily.doneToday
|
||||
? "bg-near/15 text-near"
|
||||
: "bg-soon/15 text-soon"
|
||||
}`}
|
||||
>
|
||||
{daily.doneToday
|
||||
? "daily · done today"
|
||||
: daily.remaining === null
|
||||
? "daily · not today"
|
||||
: `daily · ${daily.remaining} left`}
|
||||
</span>
|
||||
)}
|
||||
{status === "doing" && (
|
||||
<span className="rounded-[3px] bg-near/15 px-1.5 py-px text-[0.625rem] font-medium text-near">
|
||||
doing
|
||||
@@ -158,8 +188,8 @@ export function EventRow({
|
||||
does not any more: "done" was never the only thing a reader wants
|
||||
to say about an event, and a tick they can hit by accident on the
|
||||
way to opening it is a bad trade. The row opens the sheet, where
|
||||
status, effort and notes all live; this is just the affordance
|
||||
saying so.
|
||||
status, effort, notes and a daily checklist all live; this is just
|
||||
the affordance saying so.
|
||||
|
||||
The one exception is a revealed ignored row, where undo is a real
|
||||
action with nowhere better to sit. */}
|
||||
|
||||
Reference in New Issue
Block a user