diff --git a/src/client/App.tsx b/src/client/App.tsx
index e327e69..e610ccd 100644
--- a/src/client/App.tsx
+++ b/src/client/App.tsx
@@ -31,6 +31,7 @@ import {
} from "./state/lens.ts";
import { clockFor, formatRemaining } from "../shared/time.ts";
import { dailySummary, isDaily, resolveDaily } from "../shared/daily.ts";
+import { goalStatus, readGoal, type GoalStatus } from "../shared/goal.ts";
import { strandedOccurrences } from "../shared/recurrence.ts";
import { orderGames } from "./state/gameOrder.ts";
import { GameMetaProvider, type MetaResolver } from "./state/gameMeta.tsx";
@@ -152,17 +153,37 @@ export function App() {
return daily.daysFor(id).length > 0 ? "doing" : "idle";
};
+ /** The reader's check-in goal for an event, if they set a readable one. */
+ const goalOf = (id: string) => readGoal(prog.progress[id]?.goal);
+
/**
* Whether an event repeats, the reader's own answer included. Detection reads
- * the source's wording; they can overrule it either way.
+ * the source's wording; they can overrule it either way. A goal settles it
+ * outright: the reader has told us they come back to this one.
*/
const repeatsDaily = (row: RowEvent): boolean =>
+ goalOf(row.event.id) !== undefined ||
resolveDaily(
row.event,
prog.progress[row.event.id]?.daily,
prefs.detectDaily,
);
+ /** Where the reader is against an event's goal, or undefined without one. */
+ const goalStatusOf = (row: RowEvent): GoalStatus | undefined => {
+ const goal = goalOf(row.event.id);
+ if (goal === undefined) return undefined;
+ return goalStatus({
+ goal,
+ startsMs: row.clock.startsMs,
+ endsMs: row.clock.endsMs,
+ region: prefs.region,
+ game: row.event.game,
+ now,
+ logged: daily.daysFor(row.event.id),
+ });
+ };
+
/** Today's state for a repeating event, or undefined if it does not repeat. */
const dailyBadge = (row: RowEvent): DailyBadge | undefined => {
if (!repeatsDaily(row)) return undefined;
@@ -546,6 +567,10 @@ export function App() {
now={now}
daysFor={daily.daysFor}
onToggleDay={daily.toggleDay}
+ goalFor={(id) => {
+ const row = todo.find((r) => r.event.id === id);
+ return row === undefined ? undefined : goalStatusOf(row);
+ }}
/>
diff --git a/src/client/components/Dailies.tsx b/src/client/components/Dailies.tsx
index 8d0077e..5dbd240 100644
--- a/src/client/components/Dailies.tsx
+++ b/src/client/components/Dailies.tsx
@@ -11,6 +11,7 @@ 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 { formatRemaining } from "../../shared/time.ts";
import { DayPip } from "./DayPip.tsx";
import { Fireworks } from "./Fireworks.tsx";
@@ -47,6 +48,8 @@ export interface DailyItem {
* which has no start because it is a routine rather than an event.
*/
notBefore: number | null;
+ /** The event's check-in goal, when the reader set one. Chores have none. */
+ goal?: GoalStatus | undefined;
}
/** A game and everything of its that wants ticking today. */
@@ -86,6 +89,7 @@ export function dailyGroups(
meta: (id: LaneId) => GameMeta,
startOf: (event: DisplayEvent) => number,
showChores = true,
+ goalFor: (id: string) => 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
@@ -133,6 +137,7 @@ export function dailyGroups(
today,
resetsIn,
notBefore: startOf(event),
+ goal: goalFor(event.id),
});
}
@@ -141,6 +146,36 @@ export function dailyGroups(
return groups;
}
+/**
+ * How much of the strip is finished. An 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.
+ */
+export function dailyTally(
+ items: readonly DailyItem[],
+ daysFor: (id: string) => string[],
+): { total: number; complete: number; tickedToday: number } {
+ let complete = 0;
+ let tickedToday = 0;
+ for (const i of items) {
+ const ticked = daysFor(i.key).includes(i.today);
+ if (ticked) tickedToday += 1;
+ if (ticked || i.goal?.met === true) complete += 1;
+ }
+ return { total: items.length, complete, tickedToday };
+}
+
+/** 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 {
+ 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,
@@ -149,6 +184,7 @@ export function Dailies({
daysFor,
onToggleDay,
showChores,
+ goalFor,
}: {
/** Every lane the reader is looking at, in their own order. */
games: LaneId[];
@@ -164,6 +200,11 @@ export function Dailies({
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;
}) {
const gameMeta = useGameMeta();
/**
@@ -187,28 +228,30 @@ export function Dailies({
gameMeta,
(event) => Date.parse(event.startsAt),
showChores,
+ goalFor,
);
const items = groups.flatMap((group) => group.items);
- const total = items.length;
- const complete = items.filter((i) => daysFor(i.key).includes(i.today)).length;
+ const { total, complete, tickedToday } = 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; complete: number } | null>(null);
+ const previous = useRef<{ total: number; tickedToday: number } | null>(null);
useEffect(() => {
const was = previous.current;
- previous.current = { total, complete };
+ previous.current = { total, tickedToday };
if (was === null || !allDone) return;
- // Celebrate finishing the last one, 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.
- // Backfilling a past day moves no count here, so it never bursts.
- if (was.total === total && complete > was.complete) setBurst((n) => n + 1);
- }, [total, complete, allDone]);
+ // 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;
@@ -261,6 +304,7 @@ export function Dailies({
ariaLabel={item.ariaLabel}
days={daysFor(item.key)}
today={item.today}
+ goal={item.goal}
onToggle={() => onToggleDay(item.key, item.today)}
/>
@@ -369,6 +413,11 @@ export function CatchUpPanel({
* 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,
@@ -377,6 +426,7 @@ function TickChip({
ariaLabel,
days,
today,
+ goal,
onToggle,
}: {
label: string;
@@ -385,9 +435,11 @@ function TickChip({
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 (
@@ -395,7 +447,7 @@ function TickChip({
type="button"
onClick={onToggle}
aria-pressed={isDone}
- aria-label={`${ariaLabel}${isDone ? ", done today" : ", not done today"}`}
+ 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
@@ -404,11 +456,11 @@ function TickChip({
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 ? hue : `color-mix(in srgb, ${hue} 20%, transparent)`,
+ 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 ? hue : "var(--color-muted)",
+ color: isDone || met ? hue : "var(--color-muted)",
background: isDone
? `color-mix(in srgb, ${hue} 14%, transparent)`
: `color-mix(in srgb, ${hue} 3%, transparent)`,
@@ -418,16 +470,24 @@ function TickChip({
{label}
- {streak > 1 && (
- {streak}d
+ {/* 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 ? (
+ {goalCount(goal)}
+ ) : (
+ streak > 1 && (
+ {streak}d
+ )
)}
);
diff --git a/test/views.test.tsx b/test/views.test.tsx
index a9ed28f..0d5f3a5 100644
--- a/test/views.test.tsx
+++ b/test/views.test.tsx
@@ -8,7 +8,14 @@ import {
startMarkers,
Timeline,
} from "../src/client/components/Timeline.tsx";
-import { CatchUpPanel, Dailies, dailyGroups } from "../src/client/components/Dailies.tsx";
+import {
+ CatchUpPanel,
+ chipLabel,
+ Dailies,
+ dailyGroups,
+ dailyTally,
+} from "../src/client/components/Dailies.tsx";
+import type { GoalStatus } from "../src/shared/goal.ts";
import { Welcome } from "../src/client/components/Welcome.tsx";
import { timelineLanes } from "../src/client/state/lanes.ts";
import { GameMetaProvider } from "../src/client/state/gameMeta.tsx";
@@ -1139,3 +1146,83 @@ describe("the chores toggle reaches the screen", () => {
expect(html).toContain("0/1");
});
});
+
+describe("goals in the dailies strip", () => {
+ // A goal lets an event want five days of seven. A met week completes the
+ // item without a tick being invented, and the chip says which it is.
+ const NOW = Date.parse("2026-10-15T12:00:00.000Z");
+ const meta = (id: string) => metaFor(id, {});
+ const startOf = (e: { startsAt: string }) => Date.parse(e.startsAt);
+ const ev = {
+ id: "g1",
+ game: "genshin",
+ title: "To Temper Thyself",
+ type: "other",
+ summary: null,
+ startsAt: "2026-08-10T00:00:00.000Z",
+ startPrecision: "day",
+ endsAt: "2026-11-02T00:00:00.000Z",
+ endPrecision: "day",
+ regionScoped: false,
+ regionEnds: null,
+ sourceUrl: "https://example.test",
+ } as never;
+ const status = (over: Partial = {}): GoalStatus => ({
+ scope: "week", required: 5, count: 3, met: false, needed: 2,
+ open: 4, shortfall: false, ...over,
+ });
+ const strip = (goal: GoalStatus) =>
+ render(
+ []}
+ onToggleDay={() => {}}
+ goalFor={() => goal}
+ />,
+ );
+
+ test("an event's item carries its goal status", () => {
+ const [group] = dailyGroups(["genshin"], [ev], NOW, "europe", meta, startOf, false, (id) =>
+ id === "g1" ? status() : undefined,
+ );
+ expect(group!.items[0]!.goal).toEqual(status());
+ });
+
+ test("a met goal completes the item without counting as a tick", () => {
+ const [group] = dailyGroups(["genshin"], [ev], NOW, "europe", meta, startOf, false, () =>
+ status({ count: 5, met: true, needed: 0 }),
+ );
+ expect(dailyTally(group!.items, () => [])).toEqual({
+ total: 1,
+ complete: 1,
+ tickedToday: 0,
+ });
+ });
+
+ test("labels separate done today, done this week and goal reached", () => {
+ expect(chipLabel("X", true, status())).toBe("X, done today, 3 of 5 this week");
+ expect(chipLabel("X", false, status())).toBe("X, not done today, 3 of 5 this week");
+ expect(chipLabel("X", false, status({ met: true }))).toBe("X, done this week");
+ expect(chipLabel("X", false, status({ scope: "event", met: true }))).toBe(
+ "X, goal reached",
+ );
+ expect(chipLabel("X", true, status({ met: true }))).toBe("X, done today, done this week");
+ expect(chipLabel("X", false, undefined)).toBe("X, not done today");
+ });
+
+ test("the chip shows the count, and an unmet week is still waiting", () => {
+ const html = strip(status());
+ expect(html).toContain("3/5 wk");
+ expect(html).toMatch(/dailies · ()?0()?\/()?1/);
+ });
+
+ test("a met week counts as complete in the header", () => {
+ expect(strip(status({ count: 5, met: true, needed: 0 }))).toMatch(
+ /dailies · ()?1()?\/()?1/,
+ );
+ });
+});