The shop's five permits restock when the character and weapon banners change together, which no week, month or fixed cycle can express: rotations ran 21 days and then 19. A rotation is not a game version, so it gets its own table and caption, and follows the version rule that only a recorded end resets the count. The dates and reset rule are the owner's in-game reading, as no page states them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
906 lines
34 KiB
TypeScript
906 lines
34 KiB
TypeScript
import { Fragment, useEffect, useRef, useState } from "react";
|
|
import {
|
|
catchUpDays,
|
|
CATCH_UP_DAYS,
|
|
dailiesId,
|
|
dayKey,
|
|
msUntilReset,
|
|
streakOf,
|
|
} from "../../shared/daily.ts";
|
|
import { useGameMeta } from "../state/gameMeta.tsx";
|
|
import { isCustomGameId, type DisplayEvent, type LaneId } from "../../shared/custom.ts";
|
|
import type { GameMeta } from "../../shared/games.ts";
|
|
import type { GameId, Region } from "../../shared/schema.ts";
|
|
import { goalCount, goalLabel, type GoalStatus } from "../../shared/goal.ts";
|
|
import { addDays, cadenceOf, isTimerPeriod, periodOf, type Period } from "../../shared/period.ts";
|
|
import type { TaskShape } from "../../shared/task.ts";
|
|
import { asOfLabel } from "../../shared/routines.ts";
|
|
import { AmountEditor } from "./AmountEditor.tsx";
|
|
import { formatRemaining } from "../../shared/time.ts";
|
|
import { DayPip } from "./DayPip.tsx";
|
|
import { EditPanel, type StripEditor } from "./EditPanel.tsx";
|
|
import { Fireworks } from "./Fireworks.tsx";
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* Running events that repeat sit here too, so ticking today off never means
|
|
* opening a sheet to find the checklist. The checklist is still where the whole
|
|
* run lives — this is just today's line of it.
|
|
*
|
|
* Sits above the event list because it is the one part of the page that is
|
|
* answerable in ten seconds and expires tonight.
|
|
*/
|
|
|
|
/**
|
|
* One thing to do: a game's standing chore, one of its repeating events, or a
|
|
* weekly or monthly task the reader set up.
|
|
*/
|
|
export interface DailyItem {
|
|
/** The day-log key — `dailies:<game>` for a chore, the event or task id otherwise. */
|
|
key: string;
|
|
game: LaneId;
|
|
label: string;
|
|
title: string;
|
|
ariaLabel: string;
|
|
today: string;
|
|
resetsIn: number;
|
|
/**
|
|
* Where a catch-up strip starts: the event's start, or null for a chore,
|
|
* which has no start because it is a routine rather than an event.
|
|
*/
|
|
notBefore: number | null;
|
|
/**
|
|
* The event's check-in goal or the task's status for its period. Chores have
|
|
* none. An amount here makes the chip open an editor instead of ticking.
|
|
*/
|
|
goal?: GoalStatus | undefined;
|
|
/**
|
|
* How often it is due. Chores and events are daily and make up the header's
|
|
* "today" count; tasks are counted by their own period beside it. An event
|
|
* whose goal is a number has no "today" — nothing ticks — so it counts with
|
|
* its week, or, over its whole run, in no tally at all.
|
|
*/
|
|
cadence: "day" | "week" | "month" | "cycle" | "event";
|
|
/** A task's period, for finding the one before it in Catch up. */
|
|
period?: Period | undefined;
|
|
/** A task's unit, printed after its target. */
|
|
unit?: string | undefined;
|
|
/**
|
|
* The server whose reset this item follows, when not the reader's — a
|
|
* built-in on one clock for everyone. Its day, countdown and catch-up days
|
|
* are cut on it.
|
|
*/
|
|
clock?: Region | undefined;
|
|
}
|
|
|
|
/**
|
|
* A task as the strip needs it: a reader's own, or a built-in routine, which
|
|
* also says what date its source gives (`asOf`) so the tooltip can show it.
|
|
*/
|
|
export type StripTask = TaskShape & { asOf?: string | undefined; clock?: Region | undefined };
|
|
|
|
/** A game and everything of its that wants ticking today. */
|
|
export interface DailyGroup {
|
|
game: LaneId;
|
|
meta: GameMeta;
|
|
items: DailyItem[];
|
|
}
|
|
|
|
/**
|
|
* A game's dailies, together.
|
|
*
|
|
* This used to be `[...chores, ...repeating]` — every game's standing chore,
|
|
* then every repeating event — which put Genshin's commissions and Genshin's
|
|
* login event at opposite ends of the strip with a dozen other games between
|
|
* them. The reader thinks in games: if they marked an event as repeating, it
|
|
* belongs beside the chores of the game it came from.
|
|
*
|
|
* The chore comes first inside a group because it is the one that exists every
|
|
* day; the events follow **in the order they arrived**, because grouping is not
|
|
* a licence to re-sort within a group — the same rule `lanes.ts` states for the
|
|
* timeline's lanes.
|
|
*
|
|
* A lane the reader invented contributes events but no chore: there is no
|
|
* routine we could name on their behalf (`docs/DATA-MODEL.md` § Reader-authored
|
|
* key spaces). A group with nothing in it is dropped rather than rendered as an
|
|
* empty heading.
|
|
*
|
|
* Pure, and exported so it is tested directly — the pattern `Timeline.tsx` uses
|
|
* for `boardWindow` and `splitAt`.
|
|
*/
|
|
export function dailyGroups(
|
|
games: readonly LaneId[],
|
|
events: readonly DisplayEvent[],
|
|
now: number,
|
|
region: Region,
|
|
meta: (id: LaneId) => GameMeta,
|
|
startOf: (event: DisplayEvent) => number,
|
|
showChores: boolean | ((lane: LaneId) => boolean) = true,
|
|
goalFor: (id: string) => GoalStatus | undefined = () => undefined,
|
|
tasks: readonly StripTask[] = [],
|
|
taskStatusFor: (task: StripTask) => GoalStatus | undefined = () => undefined,
|
|
): DailyGroup[] {
|
|
// A lane can arrive through an event without being in `games` — an event on a
|
|
// game the reader has since switched off, say — and dropping it here would
|
|
// quietly remove a tickable line. Ordered lanes first, then any straggler in
|
|
// the order its events came.
|
|
const lanes: LaneId[] = [...games];
|
|
for (const event of events) {
|
|
if (!lanes.includes(event.game)) lanes.push(event.game);
|
|
}
|
|
|
|
const groups: DailyGroup[] = [];
|
|
for (const lane of lanes) {
|
|
const game = meta(lane);
|
|
const items: DailyItem[] = [];
|
|
const today = dayKey(now, region, lane);
|
|
const resetsIn = msUntilReset(now, region, lane);
|
|
|
|
// The standing chore is the app's own invention — no source publishes
|
|
// "Commissions, resin" — so it is the one part of this strip a reader can
|
|
// reasonably want gone. Switched off it is simply not built; nothing reads
|
|
// or writes its ticks here, so `dailies:<game>` keeps every day the reader
|
|
// ever logged and switching back on restores the lot. A game the reader
|
|
// invented never had one to begin with.
|
|
// Per game where the reader has said so (`choreRule`, perGame.ts).
|
|
const chore = typeof showChores === "function" ? showChores(lane) : showChores;
|
|
if (chore && !isCustomGameId(lane)) {
|
|
items.push({
|
|
key: dailiesId(lane as GameId),
|
|
game: lane,
|
|
label: game.short,
|
|
title: game.dailyTasks,
|
|
ariaLabel: `${game.name} dailies — ${game.dailyTasks}`,
|
|
today,
|
|
resetsIn,
|
|
notBefore: null,
|
|
cadence: "day",
|
|
});
|
|
}
|
|
|
|
for (const event of events) {
|
|
if (event.game !== lane) continue;
|
|
const goal = goalFor(event.id);
|
|
items.push({
|
|
key: event.id,
|
|
game: lane,
|
|
label: event.title,
|
|
title: `${game.name} — ${event.title}`,
|
|
ariaLabel: `${event.title} (${game.name})`,
|
|
today,
|
|
resetsIn,
|
|
notBefore: startOf(event),
|
|
goal,
|
|
cadence: goal?.measure !== "amount" ? "day" : goal.scope === "week" ? "week" : "event",
|
|
});
|
|
}
|
|
|
|
// A game's weekly and monthly tasks come last in its group: they are the
|
|
// least urgent thing in it. Only for lanes the reader is looking at — unlike
|
|
// an event, a task of a game they switched off is not something they are in
|
|
// the middle of, and its ticks and numbers wait untouched.
|
|
if (games.includes(lane)) {
|
|
for (const task of tasks) {
|
|
if (task.game !== lane) continue;
|
|
const clock = task.clock;
|
|
items.push({
|
|
key: task.id,
|
|
game: lane,
|
|
label: task.title,
|
|
// A built-in is someone else's figure; say how old it is where the
|
|
// reader can find it without opening settings.
|
|
title:
|
|
task.asOf === undefined
|
|
? `${game.name} — ${task.title}`
|
|
: `${game.name} — ${task.title} · built in, from a source as of ${asOfLabel(task.asOf)}`,
|
|
ariaLabel: `${task.title} (${game.name})`,
|
|
today: clock === undefined ? today : dayKey(now, clock, lane),
|
|
resetsIn: clock === undefined ? resetsIn : msUntilReset(now, clock, lane),
|
|
...(clock === undefined ? {} : { clock }),
|
|
notBefore: null,
|
|
goal: taskStatusFor(task),
|
|
cadence: cadenceOf(task.period),
|
|
// A timer has no period before it to catch up on (spec 2026-10-22).
|
|
period: isTimerPeriod(task.period) ? undefined : task.period,
|
|
unit: task.measure.kind === "amount" ? task.measure.unit : undefined,
|
|
});
|
|
}
|
|
}
|
|
|
|
if (items.length > 0) groups.push({ game: lane, meta: game, items });
|
|
}
|
|
return groups;
|
|
}
|
|
|
|
/**
|
|
* Tasks the reader has not switched off, by period. Off hides a task from the
|
|
* strip and its counts; its ticks and numbers keep their keys untouched.
|
|
*/
|
|
export function tasksShown<T extends Pick<StripTask, "period">>(
|
|
tasks: readonly T[],
|
|
shown: { weekly: boolean; monthly: boolean; cycle: boolean },
|
|
): T[] {
|
|
const on = { week: shown.weekly, month: shown.monthly, cycle: shown.cycle } as const;
|
|
// A reader's own daily sits with the standing dailies, not these strips.
|
|
return tasks.filter((t) => t.period.unit !== "day" && on[cadenceOf(t.period) as keyof typeof on]);
|
|
}
|
|
|
|
/**
|
|
* Today's row and the one under it. Weekly and monthly items are not due
|
|
* tonight, so they sit below the dailies rather than among them; an event is
|
|
* in today's row whatever its goal, because it is ticked a day at a time. Order
|
|
* inside each row is kept, so a game's things stay together.
|
|
*/
|
|
export function splitByCadence(items: readonly DailyItem[]): {
|
|
today: DailyItem[];
|
|
longer: DailyItem[];
|
|
} {
|
|
const longer = (i: DailyItem) =>
|
|
i.cadence === "week" || i.cadence === "month" || i.cadence === "cycle";
|
|
return { today: items.filter((i) => !longer(i)), longer: items.filter(longer) };
|
|
}
|
|
|
|
interface PeriodTally {
|
|
total: number;
|
|
complete: number;
|
|
}
|
|
|
|
type Longer = "week" | "month" | "cycle";
|
|
const LONGER: readonly Longer[] = ["week", "month", "cycle"];
|
|
|
|
/** "week", "week or month", "week, month or cycle": the periods present, in order. */
|
|
function periodList(present: ReadonlySet<Longer>, conjunction: "&" | "or" | "and"): string {
|
|
const names = LONGER.filter((c) => present.has(c));
|
|
const last = names.at(-1) ?? "";
|
|
const head = names.slice(0, -1).join(", ");
|
|
return head === "" ? last : `${head} ${conjunction} ${last}`;
|
|
}
|
|
|
|
/**
|
|
* The lower row's heading: the periods it holds, in order — "This week",
|
|
* "This week & month", "This week, month & cycle".
|
|
*/
|
|
export function longerHeading(present: ReadonlySet<Longer>): string {
|
|
return present.size === 0 ? "" : `This ${periodList(present, "&")}`;
|
|
}
|
|
|
|
/**
|
|
* How much of the strip is finished. A daily item is complete when ticked today
|
|
* or when its goal is already met — nothing is ticked on its behalf.
|
|
* `tickedToday` is kept apart because only a real tick celebrates. Weekly and
|
|
* monthly tasks are tallied by their own period: a task due by Sunday is not
|
|
* "still waiting on you today" every morning.
|
|
*/
|
|
export function dailyTally(
|
|
items: readonly DailyItem[],
|
|
daysFor: (id: string) => string[],
|
|
): {
|
|
total: number;
|
|
complete: number;
|
|
tickedToday: number;
|
|
week: PeriodTally;
|
|
month: PeriodTally;
|
|
cycle: PeriodTally;
|
|
} {
|
|
let total = 0;
|
|
let complete = 0;
|
|
let tickedToday = 0;
|
|
const week = { total: 0, complete: 0 };
|
|
const month = { total: 0, complete: 0 };
|
|
const cycle = { total: 0, complete: 0 };
|
|
for (const i of items) {
|
|
const met = i.goal?.met === true;
|
|
if (i.cadence === "event") continue;
|
|
if (i.cadence !== "day") {
|
|
const tally = i.cadence === "week" ? week : i.cadence === "month" ? month : cycle;
|
|
tally.total += 1;
|
|
if (met) tally.complete += 1;
|
|
continue;
|
|
}
|
|
total += 1;
|
|
const ticked = daysFor(i.key).includes(i.today);
|
|
if (ticked) tickedToday += 1;
|
|
if (ticked || met) complete += 1;
|
|
}
|
|
return { total, complete, tickedToday, week, month, cycle };
|
|
}
|
|
|
|
/** The chip's accessible name: today's tick and the goal are separate facts. */
|
|
export function chipLabel(
|
|
base: string,
|
|
isDone: boolean,
|
|
goal: GoalStatus | undefined,
|
|
): string {
|
|
// A number has no "today": it is wherever the reader last said it was.
|
|
if (goal?.measure === "amount") return `${base}, ${goalLabel(goal)}`;
|
|
if (!isDone && goal?.met === true) return `${base}, ${goalLabel(goal)}`;
|
|
const today = isDone ? "done today" : "not done today";
|
|
return goal === undefined ? `${base}, ${today}` : `${base}, ${today}, ${goalLabel(goal)}`;
|
|
}
|
|
|
|
export function Dailies({
|
|
games,
|
|
events,
|
|
region,
|
|
now,
|
|
daysFor,
|
|
onToggleDay,
|
|
showChores,
|
|
goalFor,
|
|
tasks = [],
|
|
taskStatusFor,
|
|
onSetAmount,
|
|
amountFor,
|
|
ask,
|
|
edit,
|
|
}: {
|
|
/** Every lane the reader is looking at, in their own order. */
|
|
games: LaneId[];
|
|
/**
|
|
* Live events that repeat daily — detected, or marked by the reader — and
|
|
* that the reader has not already finished or ignored. An event they marked
|
|
* done has no line left to tick, and listing it is the app arguing with them.
|
|
*/
|
|
events: DisplayEvent[];
|
|
region: Region;
|
|
/** Whether to carry a game's standing chore — `choreRule(prefs)`, or one answer for all. */
|
|
showChores: boolean | ((lane: LaneId) => boolean);
|
|
now: number;
|
|
daysFor: (id: string) => string[];
|
|
onToggleDay: (id: string, day: string) => void;
|
|
/**
|
|
* An event's check-in goal status, when the reader set one. A met goal
|
|
* completes its item for the header without ticking anything.
|
|
*/
|
|
goalFor?: ((id: string) => GoalStatus | undefined) | undefined;
|
|
/**
|
|
* Weekly and monthly tasks — the reader's own and any built-ins they turned
|
|
* on — all of them; the strip picks its lanes'.
|
|
*/
|
|
tasks?: readonly StripTask[] | undefined;
|
|
taskStatusFor?: ((task: StripTask) => GoalStatus | undefined) | undefined;
|
|
/**
|
|
* A game whose built-in weekly and monthly tasks the reader has not been asked about yet,
|
|
* and how to record the answer. Asked here because this is where the tasks
|
|
* would appear; one line, never a modal, never twice.
|
|
*/
|
|
ask?: { game: LaneId; onAnswer: (yes: boolean) => void } | undefined;
|
|
/** Record a number for an item's period. */
|
|
onSetAmount?: ((key: string, periodStart: string, value: number) => void) | undefined;
|
|
/** The number recorded for an item's period, if any. */
|
|
amountFor?: ((key: string, periodStart: string) => number | undefined) | undefined;
|
|
/** What the Edit panel shows and does; without it there is no Edit. */
|
|
edit?: StripEditor | undefined;
|
|
}) {
|
|
const gameMeta = useGameMeta();
|
|
/**
|
|
* Whether the strip is showing the last fortnight.
|
|
*
|
|
* Per-visit state and deliberately not a stored preference, for the reason
|
|
* expanding a truncated list is not one: it is something a reader does while
|
|
* reading — "I did Tuesday, let me say so" — rather than a statement about how
|
|
* the app should work.
|
|
*/
|
|
const [panel, setPanel] = useState<"catchUp" | "edit" | null>(null);
|
|
const catchUp = panel === "catchUp";
|
|
const editingStrip = panel === "edit" && edit !== undefined;
|
|
/** Which amount chip's editor is open, by item key. One at a time. */
|
|
const [editing, setEditing] = useState<string | null>(null);
|
|
|
|
// Each game rolls on its own server clock, so "today" is asked per game
|
|
// rather than once for the section — Endfield's European day can still be
|
|
// yesterday's while every HoYo game has already turned over.
|
|
const groups = dailyGroups(
|
|
games,
|
|
events,
|
|
now,
|
|
region,
|
|
gameMeta,
|
|
(event) => Date.parse(event.startsAt),
|
|
showChores,
|
|
goalFor,
|
|
tasks,
|
|
taskStatusFor,
|
|
);
|
|
const items = groups.flatMap((group) => group.items);
|
|
const { total, complete, tickedToday, week, month, cycle } = dailyTally(items, daysFor);
|
|
const allDone = total > 0 && complete === total;
|
|
|
|
const [burst, setBurst] = useState(0);
|
|
// Null until the first render has been seen, so arriving at a page where
|
|
// everything is already ticked is not treated as having just finished it.
|
|
const previous = useRef<{ total: number; tickedToday: number } | null>(null);
|
|
|
|
useEffect(() => {
|
|
const was = previous.current;
|
|
previous.current = { total, tickedToday };
|
|
if (was === null || !allDone) return;
|
|
// Celebrate a tick today that finished the strip, and only that. The list
|
|
// also gets shorter when the reader focuses a single game or marks a
|
|
// repeating event done, which can land on "all complete" without them
|
|
// having ticked anything — a burst there is the app congratulating them for
|
|
// filtering. A backfilled day can complete the strip too, by meeting a
|
|
// week's goal, and that is not something they just did either; so the
|
|
// count watched is today's ticks, not completion.
|
|
if (was.total === total && tickedToday > was.tickedToday) setBurst((n) => n + 1);
|
|
}, [total, tickedToday, allDone]);
|
|
|
|
useEffect(() => {
|
|
if (burst === 0) return;
|
|
const id = setTimeout(() => setBurst(0), 1400);
|
|
return () => clearTimeout(id);
|
|
}, [burst]);
|
|
|
|
// An empty strip used to vanish. With something running that could be added,
|
|
// it stays as one line, so the way to add it is where the reader looks.
|
|
const canAdd = edit !== undefined && edit.addable.length > 0;
|
|
if (items.length === 0 && ask === undefined && !canAdd) return null;
|
|
|
|
/** The Edit switch. Beside Catch up, and like it a mode of the section, never a chip's. */
|
|
const editToggle = edit !== undefined && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setPanel(editingStrip ? null : "edit")}
|
|
aria-expanded={editingStrip}
|
|
className="shrink-0 text-[0.6875rem] text-faint transition-colors hover:text-muted"
|
|
>
|
|
{editingStrip ? "Done" : "Edit"}
|
|
</button>
|
|
);
|
|
const editPanel = editingStrip && <EditPanel games={games} editor={edit} />;
|
|
|
|
const question =
|
|
ask === undefined ? null : (
|
|
<div className="relative flex flex-wrap items-baseline gap-x-3 gap-y-1 text-xs">
|
|
<p className="text-muted">
|
|
Track {gameMeta(ask.game).name}'s built-in weekly and monthly tasks?
|
|
</p>
|
|
<div className="flex gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={() => ask.onAnswer(true)}
|
|
aria-label={`Yes, track ${gameMeta(ask.game).name}'s built-in weekly and monthly tasks`}
|
|
className="font-medium text-near transition-colors hover:text-ink"
|
|
>
|
|
Yes
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => ask.onAnswer(false)}
|
|
aria-label={`No thanks to ${gameMeta(ask.game).name}'s built-in weekly and monthly tasks`}
|
|
className="text-faint transition-colors hover:text-muted"
|
|
>
|
|
No thanks
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
if (items.length === 0) {
|
|
return (
|
|
<section className="relative border-b border-hairline px-4 py-3">
|
|
{question}
|
|
{canAdd && (
|
|
<>
|
|
{editPanel}
|
|
<div className={`relative flex items-baseline justify-between gap-3 ${question === null ? "" : "mt-2"}`}>
|
|
<p className="text-[0.6875rem] text-faint">
|
|
{editingStrip ? "Add an event to tick it off here each day." : "Nothing to tick today."}
|
|
</p>
|
|
{editToggle}
|
|
</div>
|
|
</>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
// With mixed reset clocks there is no single "resets in", so the header
|
|
// reports the next one to land and says that it is the next one.
|
|
// A built-in on another server's clock (`clock`) rolls a cycle, not the
|
|
// reader's day: counting it would announce a reset nothing daily follows.
|
|
const own = items.filter((i) => i.clock === undefined);
|
|
const clocks = own.length > 0 ? own : items;
|
|
const soonest = Math.min(...clocks.map((i) => i.resetsIn));
|
|
const mixed = new Set(clocks.map((i) => i.resetsIn)).size > 1;
|
|
const open = items.find((i) => i.key === editing && i.goal?.measure === "amount");
|
|
|
|
const { today, longer } = splitByCadence(items);
|
|
const tallies = { week, month, cycle };
|
|
const present = new Set(LONGER.filter((c) => tallies[c].total > 0));
|
|
const periodCounts = (
|
|
[["Weekly", week], ["Monthly", month], ["Cycles", cycle]] as const
|
|
).filter(([, t]) => t.total > 0);
|
|
const longerOpen = LONGER.reduce((n, c) => n + tallies[c].total - tallies[c].complete, 0);
|
|
const chip = (item: DailyItem) => (
|
|
<li key={item.key}>
|
|
{item.goal?.measure === "amount" ? (
|
|
<AmountChip
|
|
label={item.label}
|
|
hue={gameMeta(item.game).hue}
|
|
title={item.title}
|
|
ariaLabel={item.ariaLabel}
|
|
goal={item.goal}
|
|
open={editing === item.key}
|
|
onOpen={() => setEditing(editing === item.key ? null : item.key)}
|
|
/>
|
|
) : (
|
|
<TickChip
|
|
label={item.label}
|
|
hue={gameMeta(item.game).hue}
|
|
title={item.title}
|
|
ariaLabel={item.ariaLabel}
|
|
days={daysFor(item.key)}
|
|
today={item.today}
|
|
goal={item.goal}
|
|
onToggle={() => onToggleDay(item.key, item.today)}
|
|
/>
|
|
)}
|
|
</li>
|
|
);
|
|
|
|
return (
|
|
<section className="relative border-b border-hairline px-4 py-4">
|
|
{burst > 0 && <Fireworks key={burst} />}
|
|
{question !== null && <div className="mb-3">{question}</div>}
|
|
|
|
<div className="relative flex items-baseline justify-between gap-3">
|
|
<h2 className="eyebrow">
|
|
{total > 0 ? <>Today's dailies · {complete}/{total}</> : "Your tasks"}
|
|
{/* A line of their own: in the rail, three counts on one line wrapped
|
|
the heading and pushed the reset countdown onto two. */}
|
|
{periodCounts.length > 0 && (
|
|
<span className="mt-0.5 block text-faint">
|
|
{periodCounts.map(([name, t], n) => (
|
|
<Fragment key={name}>
|
|
{n > 0 && " · "}
|
|
{name} {t.complete}/{t.total}
|
|
</Fragment>
|
|
))}
|
|
</span>
|
|
)}
|
|
</h2>
|
|
<p className="tnum text-[0.6875rem] text-faint">
|
|
{mixed ? "next reset in " : "resets in "}
|
|
{formatRemaining(soonest)}
|
|
</p>
|
|
</div>
|
|
|
|
{catchUp ? (
|
|
<CatchUpPanel
|
|
groups={groups}
|
|
now={now}
|
|
region={region}
|
|
daysFor={daysFor}
|
|
onToggleDay={onToggleDay}
|
|
onSetAmount={onSetAmount}
|
|
amountFor={amountFor}
|
|
/>
|
|
) : editingStrip ? (
|
|
editPanel
|
|
) : (
|
|
/* Wrapping rows, with each game's chore and its events adjacent. No
|
|
per-game headings here: this is the part of the page answerable in ten
|
|
seconds, and a heading per game would make it the tallest block on it,
|
|
pushing "next to expire" — the answer the reader came for — down the
|
|
page. The hue already says which game a chip belongs to. */
|
|
<>
|
|
<ul className="relative mt-2.5 flex flex-wrap gap-1.5">
|
|
{today.map(chip)}
|
|
</ul>
|
|
{/* Not due tonight, so under the dailies rather than among them —
|
|
and switchable, in the Edit panel and in settings. */}
|
|
{longer.length > 0 && (
|
|
<>
|
|
<p className="relative mt-3 text-[0.6875rem] font-medium text-faint">
|
|
{longerHeading(new Set(longer.map((i) => i.cadence as Longer)))}
|
|
</p>
|
|
<ul className="relative mt-1.5 flex flex-wrap gap-1.5">
|
|
{longer.map(chip)}
|
|
</ul>
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* Under the strip rather than inside the chip: a chip is one target, and
|
|
a field inside it would be a second one waiting to be mis-tapped. */}
|
|
{panel === null && open?.goal !== undefined && onSetAmount !== undefined && (
|
|
<AmountEditor
|
|
key={`${open.key}:${open.goal.periodStart}`}
|
|
autoFocus
|
|
label={`${open.label} this ${open.goal.scope === "event" ? "event" : open.goal.scope}:`}
|
|
value={amountFor?.(open.key, open.goal.periodStart)}
|
|
target={open.goal.required}
|
|
unit={open.unit}
|
|
onSave={(value) => {
|
|
onSetAmount(open.key, open.goal!.periodStart, value);
|
|
setEditing(null);
|
|
}}
|
|
onCancel={() => setEditing(null)}
|
|
/>
|
|
)}
|
|
|
|
<div className="relative mt-2 flex items-baseline justify-between gap-3">
|
|
<p className="text-[0.6875rem] leading-relaxed text-faint">
|
|
{editingStrip
|
|
? "Add an event, give one a goal, or choose what the strip carries."
|
|
: catchUp
|
|
? `Tick a day you did but didn't record. The last ${CATCH_UP_DAYS} days, and nothing later than today.`
|
|
: total === 0
|
|
? longerOpen === 0
|
|
? // An event's whole-run number is in no period; nothing to name.
|
|
present.size === 0
|
|
? "All done for this week and month."
|
|
: `All done for this ${periodList(present, "and")}.`
|
|
: `${waiting(longerOpen)} still open this ${periodList(present, "or")}.`
|
|
: allDone
|
|
? "All done. Nothing else expires tonight."
|
|
: `${waiting(total - complete)} still waiting on you today.`}
|
|
</p>
|
|
{/* The way back to a day you already did. It lives on the section rather
|
|
than on each chip because a chip is a single tick target, and a
|
|
second control inside one is a mis-tap that costs a streak. */}
|
|
<div className="flex shrink-0 gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={() => setPanel(catchUp ? null : "catchUp")}
|
|
aria-expanded={catchUp}
|
|
className="shrink-0 text-[0.6875rem] text-faint transition-colors hover:text-muted"
|
|
>
|
|
{catchUp ? "Done" : "Catch up"}
|
|
</button>
|
|
{editToggle}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The last fortnight, for saying you did a day you never ticked.
|
|
*
|
|
* Its own component rather than a branch inside the section, so it can be
|
|
* rendered and asserted on directly — the interesting parts are which days
|
|
* appear and whose clock they were cut on, and neither is reachable through a
|
|
* `useState` from a test.
|
|
*
|
|
* Expanded there is vertical room, so the grouping becomes a heading per game.
|
|
* Collapsed it is adjacency alone: the strip is the part of the page answerable
|
|
* in ten seconds, and a heading per game would make it the tallest block on it.
|
|
*
|
|
* Every strip is cut with its own item's game, never the section's — Endfield
|
|
* serves Europe off the Americas machine, so a tick written under one clock and
|
|
* read under another is a day the reader loses.
|
|
*/
|
|
export function CatchUpPanel({
|
|
groups,
|
|
now,
|
|
region,
|
|
daysFor,
|
|
onToggleDay,
|
|
onSetAmount,
|
|
amountFor,
|
|
}: {
|
|
groups: DailyGroup[];
|
|
now: number;
|
|
region: Region;
|
|
daysFor: (id: string) => string[];
|
|
onToggleDay: (id: string, day: string) => void;
|
|
onSetAmount?: ((key: string, periodStart: string, value: number) => void) | undefined;
|
|
amountFor?: ((key: string, periodStart: string) => number | undefined) | undefined;
|
|
}) {
|
|
return (
|
|
<div className="relative mt-3 flex flex-col gap-3">
|
|
{groups.map((group) => (
|
|
<div key={group.game}>
|
|
<p
|
|
className="text-[0.6875rem] font-semibold"
|
|
style={{ color: group.meta.hue }}
|
|
>
|
|
{group.meta.name}
|
|
</p>
|
|
{group.items.map((item) => {
|
|
// A number is caught up a period at a time: the one just gone is
|
|
// the one a reader forgets to record. An event's number is edited
|
|
// in its sheet, where its whole run is.
|
|
if (item.goal?.measure === "amount") {
|
|
if (item.period === undefined || onSetAmount === undefined) return null;
|
|
const previous = periodOf(item.period, addDays(item.goal.periodStart, -1)).start;
|
|
return (
|
|
<div key={item.key} className="mt-1.5">
|
|
<p className="truncate text-xs text-muted">{item.label}</p>
|
|
<AmountEditor
|
|
key={previous}
|
|
label={`Last ${item.period.unit}:`}
|
|
value={amountFor?.(item.key, previous)}
|
|
target={item.goal.required}
|
|
unit={item.unit}
|
|
onSave={(value) => onSetAmount(item.key, previous, value)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<div key={item.key} className="mt-1.5">
|
|
<p className="truncate text-xs text-muted">{item.label}</p>
|
|
<div className="mt-1 flex flex-wrap gap-1">
|
|
{catchUpDays(now, item.clock ?? region, item.game, item.notBefore).map((day) => (
|
|
<DayPip
|
|
key={day}
|
|
day={day}
|
|
today={item.today}
|
|
done={daysFor(item.key).includes(day)}
|
|
onToggle={() => onToggleDay(item.key, day)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* A number against a target: `1,900/2,100 wk`, with a hairline under it filling
|
|
* to the same fraction.
|
|
*
|
|
* Not a toggle — there is nothing to tick — so no `aria-pressed`; it opens the
|
|
* editor under the strip and says so with `aria-expanded`. Met, it takes the
|
|
* goal-met look a tick chip has: full hue, no wash, since a wash means "done
|
|
* today" and a number is never that.
|
|
*/
|
|
function AmountChip({
|
|
label,
|
|
hue,
|
|
title,
|
|
ariaLabel,
|
|
goal,
|
|
open,
|
|
onOpen,
|
|
}: {
|
|
label: string;
|
|
hue: string;
|
|
title: string;
|
|
ariaLabel: string;
|
|
goal: GoalStatus;
|
|
open: boolean;
|
|
onOpen: () => void;
|
|
}) {
|
|
const share = Math.min(1, goal.required === 0 ? 0 : goal.count / goal.required);
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={onOpen}
|
|
aria-expanded={open}
|
|
aria-label={chipLabel(ariaLabel, false, goal)}
|
|
title={title}
|
|
className="hue-chip relative flex max-w-[15rem] items-center gap-2 overflow-hidden rounded-full border px-3 py-1.5 text-xs font-medium"
|
|
style={{
|
|
["--hue" as string]: hue,
|
|
borderColor: goal.met ? hue : `color-mix(in srgb, ${hue} 20%, transparent)`,
|
|
color: goal.met ? hue : "var(--color-muted)",
|
|
background: `color-mix(in srgb, ${hue} 3%, transparent)`,
|
|
}}
|
|
>
|
|
<span className="hue-chip-label truncate">{label}</span>
|
|
<span className="tnum shrink-0 text-[0.625rem] opacity-70">{goalCount(goal)}</span>
|
|
<span
|
|
aria-hidden
|
|
className="absolute bottom-0 left-0 h-[2px]"
|
|
style={{ width: `${share * 100}%`, background: hue, opacity: goal.met ? 1 : 0.6 }}
|
|
/>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* One thing to tick off today.
|
|
*
|
|
* The same pill whether it is a game's standing chore or an event that repeats:
|
|
* to the reader at 23:50 they are the same job, and the distinction between
|
|
* "the app knows about this" and "a wiki published it" is ours, not theirs.
|
|
*
|
|
* The game's hue is on the border in both states — faintly while the job is
|
|
* outstanding, fully once it is done. A chip that only takes its colour on
|
|
* completion means the strip you actually scan, the unfinished one, is a row of
|
|
* identical grey pills with no clue which game each belongs to.
|
|
*
|
|
* The outstanding tint is kept low enough to read as a hint rather than a
|
|
* state: it has to say *which game* without competing with the tick, which is
|
|
* the only thing on the chip that answers the question the reader came with.
|
|
* Ticking one should be a visible jump, not a nudge.
|
|
*
|
|
* An event with a check-in goal has a third state: nothing ticked today, but
|
|
* the week's count already met. It takes the done border and a full-strength
|
|
* check, drawn dashed, and keeps the outstanding ground — only a real tick
|
|
* earns the wash, so "done this week" never reads as "done today".
|
|
*/
|
|
function TickChip({
|
|
label,
|
|
hue,
|
|
title,
|
|
ariaLabel,
|
|
days,
|
|
today,
|
|
goal,
|
|
onToggle,
|
|
}: {
|
|
label: string;
|
|
hue: string;
|
|
title: string;
|
|
ariaLabel: string;
|
|
days: string[];
|
|
today: string;
|
|
goal?: GoalStatus | undefined;
|
|
onToggle: () => void;
|
|
}) {
|
|
const isDone = days.includes(today);
|
|
const met = !isDone && goal?.met === true;
|
|
const streak = streakOf(days, today);
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={onToggle}
|
|
aria-pressed={isDone}
|
|
aria-label={chipLabel(ariaLabel, isDone, goal)}
|
|
title={title}
|
|
// `.hue-chip` is the shared hover: the chip's own colours are the game's
|
|
// hue and arrive inline, which no rule can override, so it is drawn from
|
|
// what inline does not own. `--hue` below is the only thing it needs;
|
|
// pressed-ness it reads off `aria-pressed`, which is already up there.
|
|
className="hue-chip flex max-w-[15rem] items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-medium"
|
|
style={{
|
|
["--hue" as string]: hue,
|
|
borderColor: isDone || met ? hue : `color-mix(in srgb, ${hue} 20%, transparent)`,
|
|
// Colour identifies the game; done-ness is carried by the tick, the
|
|
// full-strength border and the wash. Keeping the label readable matters
|
|
// more than saturating it, so an outstanding chip stays on muted ink.
|
|
color: isDone || met ? hue : "var(--color-muted)",
|
|
background: isDone
|
|
? `color-mix(in srgb, ${hue} 14%, transparent)`
|
|
: `color-mix(in srgb, ${hue} 3%, transparent)`,
|
|
}}
|
|
>
|
|
<svg viewBox="0 0 16 16" aria-hidden className="size-3 shrink-0">
|
|
<path
|
|
d="M2.5 8.5l3.5 3.5 7.5-8"
|
|
fill="none"
|
|
stroke={isDone || met ? "currentColor" : hue}
|
|
strokeWidth="2.2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeDasharray={met ? "2 2.6" : undefined}
|
|
opacity={isDone || met ? 1 : 0.25}
|
|
/>
|
|
</svg>
|
|
<span className="hue-chip-label truncate">{label}</span>
|
|
{/* One counter per pill. With a goal, its count is the one that answers
|
|
"is this week done?"; a streak is beside the point when every day was
|
|
never required. */}
|
|
{goal !== undefined ? (
|
|
<span className="tnum shrink-0 text-[0.625rem] opacity-70">{goalCount(goal)}</span>
|
|
) : (
|
|
streak > 1 && (
|
|
<span className="tnum shrink-0 text-[0.625rem] opacity-70">{streak}d</span>
|
|
)
|
|
)}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function waiting(n: number): string {
|
|
return n === 1 ? "One thing" : `${n} things`;
|
|
}
|