Let a reader switch off the chores the app invented
Today's dailies carries two different things: each game's standing chore — "Commissions, resin", a fixed list nobody publishes — and any event with a checklist. The first is the app guessing at a routine the reader never asked for, and it was the only part of that strip with no way out. The switch removes exactly that. Events keep their checklists whatever their source, including ones the reader added themselves and marked daily, which was the requirement most at risk of being filtered away by a switch aimed at something else. Nothing is discarded. The ticks live under `dailies:<game>`, nothing here reads or writes them, and review traced every writer of that store to confirm it — so switching back on restores every logged day and every streak. Defaulted on, and the default is now pinned, because flipping it is the one change that would silently empty the strip for everyone. Gating where the chore is built rather than where it is drawn means the counts follow for free: "N still waiting on you today" derives from the items, and a game left with nothing contributes no group, so the strip's own empty guard drops it rather than leaving a heading with no rows. 1041 tests. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
@@ -186,6 +186,12 @@ Alongside them sits **today's dailies** — commissions, sanity, daily training
|
||||
No wiki publishes those, so they are a fixed list in the app rather than scraped data, and they are
|
||||
the only thing on the page that expires tonight rather than next patch.
|
||||
|
||||
Because nobody publishes them, they are the app's guess at your routine rather than anything you
|
||||
asked for — so **Show each game's own daily chores** in the settings turns them off. Only the
|
||||
invented chores go: anything with a checklist is something you engaged with, including events you
|
||||
added yourself and marked daily, and those stay where they are. Nothing is discarded, so every tick
|
||||
and streak is waiting if you turn it back on.
|
||||
|
||||
Both roll over at **04:00 server time** in your region, not midnight, because that is when the games
|
||||
roll over. Finishing at 02:00 still counts as yesterday.
|
||||
|
||||
|
||||
+3
-2
@@ -220,8 +220,9 @@ Namespaced, versioned, and small. Nothing here ever goes to the server.
|
||||
"gacha-tracker:v1:prefs" // { region, hiddenGames[], knownGames[]?, gameOrder[]?,
|
||||
// focusGame, sort, view,
|
||||
// timelineDayWidth, timelineGroup, showUpcoming,
|
||||
// timelineSplitUpcoming, detectDaily, showCompleted,
|
||||
// showIgnored, theme, regionConfirmed, onboarded }
|
||||
// timelineSplitUpcoming, detectDaily, showChores,
|
||||
// showCompleted, showIgnored, theme, regionConfirmed,
|
||||
// onboarded }
|
||||
// timelineDayWidth is px per day on the board, stored as the
|
||||
// measurement rather than a step number and read through
|
||||
// snapDayWidth — so a value from an older ladder still opens
|
||||
|
||||
@@ -538,6 +538,7 @@ export function App() {
|
||||
the lanes out here would also cost a reader's own game its place in
|
||||
the order their repeating events are grouped under. */}
|
||||
<Dailies
|
||||
showChores={prefs.showChores}
|
||||
games={focus === null ? enabled : [focus]}
|
||||
events={todo.filter(repeatsDaily).map((r) => r.event)}
|
||||
region={prefs.region}
|
||||
|
||||
@@ -230,6 +230,18 @@ export function Controls({
|
||||
hint="Guessed from what the source wrote, so it misses some and invents others. Off, only events you mark yourself get a checklist. Your ticks and streaks are kept either way."
|
||||
/>
|
||||
|
||||
{/* The chores are the app's own invention — no source publishes
|
||||
"Commissions, resin" — and they were the one part of the strip
|
||||
with no way out. Named for what it removes rather than for
|
||||
"dailies", which would read as broken while the strip stayed on
|
||||
screen showing the reader's own events. */}
|
||||
<Check
|
||||
checked={prefs.showChores}
|
||||
onChange={(showChores) => onUpdate({ showChores })}
|
||||
label="Show each game's own daily chores"
|
||||
hint="Commissions, resin, daily training and the rest. Nobody publishes these, so the app keeps a fixed list per game. Off, the strip shows only events with a checklist. Your ticks and streaks are kept either way."
|
||||
/>
|
||||
|
||||
{ignoredCount > 0 && (
|
||||
<Check
|
||||
checked={prefs.showIgnored}
|
||||
|
||||
@@ -85,6 +85,7 @@ export function dailyGroups(
|
||||
region: Region,
|
||||
meta: (id: LaneId) => GameMeta,
|
||||
startOf: (event: DisplayEvent) => number,
|
||||
showChores = true,
|
||||
): 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
|
||||
@@ -102,7 +103,13 @@ export function dailyGroups(
|
||||
const today = dayKey(now, region, lane);
|
||||
const resetsIn = msUntilReset(now, region, lane);
|
||||
|
||||
if (!isCustomGameId(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.
|
||||
if (showChores && !isCustomGameId(lane)) {
|
||||
items.push({
|
||||
key: dailiesId(lane as GameId),
|
||||
game: lane,
|
||||
@@ -141,6 +148,7 @@ export function Dailies({
|
||||
now,
|
||||
daysFor,
|
||||
onToggleDay,
|
||||
showChores,
|
||||
}: {
|
||||
/** Every lane the reader is looking at, in their own order. */
|
||||
games: LaneId[];
|
||||
@@ -151,6 +159,8 @@ export function Dailies({
|
||||
*/
|
||||
events: DisplayEvent[];
|
||||
region: Region;
|
||||
/** Whether to carry each game's standing chore — `prefs.showChores`. */
|
||||
showChores: boolean;
|
||||
now: number;
|
||||
daysFor: (id: string) => string[];
|
||||
onToggleDay: (id: string, day: string) => void;
|
||||
@@ -176,6 +186,7 @@ export function Dailies({
|
||||
region,
|
||||
gameMeta,
|
||||
(event) => Date.parse(event.startsAt),
|
||||
showChores,
|
||||
);
|
||||
const items = groups.flatMap((group) => group.items);
|
||||
const total = items.length;
|
||||
|
||||
@@ -155,6 +155,18 @@ export interface Prefs {
|
||||
* who already switched it on keep it — stored prefs win over this default.
|
||||
*/
|
||||
detectDaily: boolean;
|
||||
/**
|
||||
* Whether Today's dailies carries each game's standing chore — commissions,
|
||||
* sanity, daily training.
|
||||
*
|
||||
* On by default, because every reader has these today and a setting that
|
||||
* silently removes something on upgrade is worse than one nobody notices.
|
||||
* Off hides only the chores: an event with a checklist is something the
|
||||
* reader engaged with and stays either way. Like `detectDaily` it discards
|
||||
* nothing — the ticks live under `dailies:<game>` and are never written from
|
||||
* here, so switching back on restores every one of them.
|
||||
*/
|
||||
showChores: boolean;
|
||||
showCompleted: boolean;
|
||||
/** Reveal events the reader has ignored, so they can be restored. */
|
||||
showIgnored: boolean;
|
||||
@@ -174,7 +186,7 @@ export interface Prefs {
|
||||
onboarded: boolean;
|
||||
}
|
||||
|
||||
function defaults(): Prefs {
|
||||
export function defaults(): Prefs {
|
||||
return {
|
||||
region: guessRegion(),
|
||||
hiddenGames: [],
|
||||
@@ -186,6 +198,7 @@ function defaults(): Prefs {
|
||||
showUpcoming: false,
|
||||
timelineSplitUpcoming: true,
|
||||
detectDaily: false,
|
||||
showChores: true,
|
||||
showCompleted: true,
|
||||
showIgnored: false,
|
||||
theme: DEFAULT_THEME_CHOICE,
|
||||
|
||||
+18
-3
@@ -30,6 +30,7 @@ const PREFS: Prefs = {
|
||||
showUpcoming: false,
|
||||
timelineSplitUpcoming: true,
|
||||
detectDaily: false,
|
||||
showChores: true,
|
||||
showCompleted: true,
|
||||
showIgnored: false,
|
||||
theme: "dark",
|
||||
@@ -99,11 +100,13 @@ describe("Controls: what am I allowed to look at", () => {
|
||||
|
||||
test("it reads its own preference and not a neighbour's", () => {
|
||||
// Both neighbours are on and this one is off, so a checkbox bound to the
|
||||
// wrong key shows up as the wrong count of ticks.
|
||||
// wrong key shows up as the wrong count of ticks. The chores toggle is a
|
||||
// third box that is on in both renders — it defaults on — so it raises
|
||||
// each count by one without changing what the delta proves.
|
||||
const off = checkboxes(render(PREFS));
|
||||
const on = checkboxes(render({ ...PREFS, showUpcoming: true }));
|
||||
expect(off.filter(Boolean)).toHaveLength(1);
|
||||
expect(on.filter(Boolean)).toHaveLength(2);
|
||||
expect(off.filter(Boolean)).toHaveLength(2);
|
||||
expect(on.filter(Boolean)).toHaveLength(3);
|
||||
});
|
||||
|
||||
test("how unstarted events sit on the board is offered only when they are", () => {
|
||||
@@ -272,3 +275,15 @@ describe("Controls: the game order editor", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the chores checkbox reports its own pref", () => {
|
||||
test("off, one fewer box is ticked", () => {
|
||||
// Counting alone can no longer separate the two always-on boxes from each
|
||||
// other — with `showCompleted` also true, a checkbox bound to the wrong one
|
||||
// of them keeps the same total. Flipping this pref specifically is what
|
||||
// distinguishes them.
|
||||
const on = checkboxes(render(PREFS)).filter(Boolean).length;
|
||||
const off = checkboxes(render({ ...PREFS, showChores: false })).filter(Boolean).length;
|
||||
expect(off).toBe(on - 1);
|
||||
});
|
||||
});
|
||||
|
||||
+18
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { adoptNewLanes, adoptRenamed } from "../src/client/state/usePrefs.ts";
|
||||
import { adoptNewLanes, adoptRenamed, defaults } from "../src/client/state/usePrefs.ts";
|
||||
import type { LaneId } from "../src/shared/custom.ts";
|
||||
|
||||
/**
|
||||
@@ -106,3 +106,20 @@ describe("adoptRenamed", () => {
|
||||
expect(adoptRenamed({})).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaults that a reader would notice losing", () => {
|
||||
test("the chores are on until someone says otherwise", () => {
|
||||
// Flipping this one line silently empties Today's dailies for every
|
||||
// existing reader — it is the whole argument of the comment above it, and
|
||||
// nothing else in the suite was watching it.
|
||||
expect(defaults().showChores).toBe(true);
|
||||
});
|
||||
|
||||
test("a stored blob without the key keeps the default", () => {
|
||||
// The upgrade path. Prefs saved before this key existed must not read as
|
||||
// "off" — `{...defaults(), ...stored}` is what guarantees that, and JSON
|
||||
// cannot carry an `undefined` that would override it.
|
||||
const stored = { region: "europe", detectDaily: false } as const;
|
||||
expect({ ...defaults(), ...stored }.showChores).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
+126
-1
@@ -8,7 +8,7 @@ import {
|
||||
startMarkers,
|
||||
Timeline,
|
||||
} from "../src/client/components/Timeline.tsx";
|
||||
import { CatchUpPanel, dailyGroups } from "../src/client/components/Dailies.tsx";
|
||||
import { CatchUpPanel, Dailies, dailyGroups } from "../src/client/components/Dailies.tsx";
|
||||
import { Welcome } from "../src/client/components/Welcome.tsx";
|
||||
import { timelineLanes } from "../src/client/state/lanes.ts";
|
||||
import { GameMetaProvider } from "../src/client/state/gameMeta.tsx";
|
||||
@@ -1014,3 +1014,128 @@ describe("boardWindow is not widened by expansion", () => {
|
||||
expect(chartWidthOf(withExpand)).toBe(chartWidthOf(withoutExpand));
|
||||
});
|
||||
});
|
||||
|
||||
describe("dailyGroups with the chores switched off", () => {
|
||||
const NOW = Date.parse("2026-08-17T12:00:00.000Z");
|
||||
const meta = (id: string) => metaFor(id, {});
|
||||
const startOf = (e: { startsAt: string }) => Date.parse(e.startsAt);
|
||||
const repeating = (id: string, game: string, title: string) =>
|
||||
({
|
||||
id,
|
||||
game,
|
||||
title,
|
||||
type: "login",
|
||||
summary: null,
|
||||
startsAt: "2026-08-10T00:00:00.000Z",
|
||||
startPrecision: "day",
|
||||
endsAt: null,
|
||||
endPrecision: "unknown",
|
||||
regionScoped: false,
|
||||
regionEnds: null,
|
||||
sourceUrl: "https://example.test",
|
||||
}) as never;
|
||||
|
||||
test("the invented chore goes and the events stay", () => {
|
||||
// Only the fixed per-game list the app makes up goes. Anything with a
|
||||
// checklist is something the reader engaged with, and stays.
|
||||
const groups = dailyGroups(
|
||||
["genshin"],
|
||||
[repeating("e1", "genshin", "Login Bonus")],
|
||||
NOW,
|
||||
"europe",
|
||||
meta,
|
||||
startOf,
|
||||
false,
|
||||
);
|
||||
expect(groups.map((g) => g.items.map((i) => i.key))).toEqual([["e1"]]);
|
||||
});
|
||||
|
||||
test("an event the reader added themselves is untouched", () => {
|
||||
// Stated explicitly because it is the requirement most at risk of being
|
||||
// filtered away by a switch aimed at something else.
|
||||
const groups = dailyGroups(
|
||||
["genshin"],
|
||||
[repeating("myevent:k3f9qa2m01", "genshin", "My own grind")],
|
||||
NOW,
|
||||
"europe",
|
||||
meta,
|
||||
startOf,
|
||||
false,
|
||||
);
|
||||
expect(groups[0]?.items.map((i) => i.key)).toEqual(["myevent:k3f9qa2m01"]);
|
||||
});
|
||||
|
||||
test("with nothing else to show, a game contributes no group at all", () => {
|
||||
// Not an empty group with a heading and no rows — the strip's own
|
||||
// `total === 0` guard then drops it entirely, which is what a reader who
|
||||
// turned this off is asking for.
|
||||
const groups = dailyGroups(["genshin", "hsr"], [], NOW, "europe", meta, startOf, false);
|
||||
expect(groups).toEqual([]);
|
||||
});
|
||||
|
||||
test("left on, nothing about the strip moves", () => {
|
||||
const groups = dailyGroups(
|
||||
["genshin"],
|
||||
[repeating("e1", "genshin", "Login Bonus")],
|
||||
NOW,
|
||||
"europe",
|
||||
meta,
|
||||
startOf,
|
||||
true,
|
||||
);
|
||||
expect(groups.map((g) => g.items.map((i) => i.key))).toEqual([
|
||||
["dailies:genshin", "e1"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the chores toggle reaches the screen", () => {
|
||||
// `dailyGroups` is pure and well covered, but nothing tied `prefs.showChores`
|
||||
// to what renders. Review proved four plausible breaks shipped green —
|
||||
// including App passing the wrong pref, and Dailies hardcoding `true` — so
|
||||
// these assert the rendered strip rather than the function behind it.
|
||||
const NOW = Date.parse("2026-08-17T12:00:00.000Z");
|
||||
const repeating = {
|
||||
id: "e1",
|
||||
game: "genshin",
|
||||
title: "Lantern Rite login",
|
||||
type: "login",
|
||||
summary: null,
|
||||
startsAt: "2026-08-10T00:00:00.000Z",
|
||||
startPrecision: "day",
|
||||
endsAt: null,
|
||||
endPrecision: "unknown",
|
||||
regionScoped: false,
|
||||
regionEnds: null,
|
||||
sourceUrl: "https://example.test",
|
||||
} as never;
|
||||
|
||||
const strip = (showChores: boolean) =>
|
||||
render(
|
||||
<Dailies
|
||||
games={["genshin"]}
|
||||
events={[repeating]}
|
||||
region="europe"
|
||||
now={NOW}
|
||||
showChores={showChores}
|
||||
daysFor={() => []}
|
||||
onToggleDay={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
test("on, the invented chore is on screen", () => {
|
||||
const html = strip(true);
|
||||
expect(html).toContain("Commissions, resin");
|
||||
expect(html).toContain("Lantern Rite login");
|
||||
});
|
||||
|
||||
test("off, the chore is gone and the event and its count are not", () => {
|
||||
// The count is asserted too: it derives from the items, so a chore hidden
|
||||
// from view but still counted would leave the reader chasing a tick that
|
||||
// is not there.
|
||||
const html = strip(false);
|
||||
expect(html).not.toContain("Commissions, resin");
|
||||
expect(html).toContain("Lantern Rite login");
|
||||
expect(html).toContain("0/1");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user