feat(dailies): edit the strip in place, and give weekly and monthly tasks their own row
Giving Genshin's "To Temper Thyself and Journey Far" its five-of-seven goal meant finding it in the list, opening its sheet, saying it repeats and only then setting the goal. The strip now has an Edit mode beside Catch up that lists its events by game with their goals, opens the sheet's goal control in place, and adds any running event a game at a time. It calls the same setDaily/setGoal the sheet does, so it can store nothing the sheet would not. Weekly and monthly tasks are not due tonight, so they move to a row under the dailies, and each period can be switched off (showWeeklyTasks, showMonthlyTasks; default on, so nothing disappears on upgrade). Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
3c928ee795
commit
9477ac54b6
@@ -988,6 +988,11 @@ event's length. It adds **no schema field**, so the feed contract is untouched.
|
||||
want five days of seven (`progress.goal`, `src/shared/goal.ts`, PRD F12). A met week stays in the
|
||||
strip as done this week without a tick being invented; only the reader marks the event done. A
|
||||
goal this build cannot read is kept in storage and treated as none — never stripped on load.
|
||||
- **The strip's Edit panel is the sheet's controls, reached from the strip** (`EditPanel.tsx`). It
|
||||
calls the same `setDaily` and `setGoal` with the same `dailyOverride` arguments, so it can never
|
||||
store a combination the sheet would not. Keep it that way: a second way to write `daily` or `goal`
|
||||
with its own rules is two answers to one question. It is a mode of the section, like Catch up —
|
||||
never a control inside a chip.
|
||||
|
||||
## Events the reader entered themselves
|
||||
|
||||
|
||||
+5
-1
@@ -221,12 +221,16 @@ Namespaced, versioned, and small. Nothing here ever goes to the server.
|
||||
// focusGame, sort, view,
|
||||
// timelineDayWidth, timelineGroup, showUpcoming,
|
||||
// timelineSplitUpcoming, detectDaily, showChores,
|
||||
// showCompleted, showIgnored, theme, regionConfirmed,
|
||||
// showWeeklyTasks, showMonthlyTasks, showCompleted, showIgnored, theme, regionConfirmed,
|
||||
// onboarded, routineGames?, routinesAsked? }
|
||||
// routineGames is the games whose built-in weekly tasks
|
||||
// the reader turned on; routinesAsked the games already
|
||||
// asked about. Both absent means none — built-ins are
|
||||
// opt-in for every install. PRD F17.
|
||||
// showWeeklyTasks / showMonthlyTasks say whether tasks of
|
||||
// that period sit in the strip's row under the dailies.
|
||||
// Both default true, so an install from before them keeps
|
||||
// its tasks on screen; off hides and never deletes.
|
||||
// changelogSeen is the newest changelog entry id the
|
||||
// reader dismissed. Absent means unrecorded: first-run
|
||||
// setup sets it, so absent is an install from before the
|
||||
|
||||
@@ -429,6 +429,16 @@ selected tab fills with `raised` and turns ink; unselected tabs are faint and go
|
||||
Off / Each week / Across the event — then a small number field and, for weeks, a weekday select,
|
||||
both on `ground` with a hairline border like the notes field. Once a goal is set, a smaller
|
||||
Days · Points pill pair picks its measure.
|
||||
- **This week & month row** (dailies strip): weekly and monthly items leave today's chips and sit
|
||||
under them, after a faint 11px `This week & month` label, in the same pills and game order.
|
||||
- **Strip Edit panel** (dailies strip, from a faint **Edit** text button beside **Catch up**, which
|
||||
reads **Done** while open): replaces the chips. A hue-coloured game name per lane; each event in
|
||||
the strip is a full-width row — title in muted, its goal in words (`5 days each week`) in faint
|
||||
— that opens the check-in goal control indented behind a hairline left rule, with **Open
|
||||
details** (muted) and **Remove from dailies** (faint) under it. A faint **Add an event · N** per
|
||||
game opens that game's running events as dashed hairline pills with a drawn plus; one game open
|
||||
at a time, because every game's list at once is a hundred pills. A hairline rule, then small
|
||||
checkboxes for what the strip carries — Daily chores, and Weekly / Monthly tasks when any exist.
|
||||
|
||||
### Event row (signature)
|
||||
The unit of the checklist. It is one full-bleed target that opens the event and does nothing else.
|
||||
|
||||
+13
-3
@@ -313,6 +313,13 @@ title — and a goal makes an event a daily even with detection off.
|
||||
event-wide goal reached offers *Mark event done*; the reader decides.
|
||||
- **The target is never lowered.** A short first or last week keeps it and says when too few days
|
||||
are left; an unknown end leaves the days left unknown.
|
||||
- **Set up from the strip, not just the sheet.** Giving Temper Thyself its goal used to mean finding
|
||||
it in the list, opening it, saying it repeats and then setting the goal. The strip's **Edit** —
|
||||
beside *Catch up*, and like it a mode of the section rather than a control on a chip — lists the
|
||||
strip's events by game with each goal in words; one opens to the sheet's goal control, *Open
|
||||
details* and *Remove from dailies*. Under each game, *Add an event · N* lists its running events
|
||||
that are not in the strip yet; adding one opens its goal at once. It writes exactly what the sheet
|
||||
writes, so nothing about a goal or an override changes meaning.
|
||||
|
||||
**F13 — Your own games and your own events.**
|
||||
No feasible adapter set covers everyone. Fourteen games were named in the first release thread and
|
||||
@@ -557,9 +564,12 @@ default) or **reach a number** (a target such as 2,100, with an optional unit).
|
||||
|
||||
- **They roll on the game's clock.** A week starts at the game's daily reset on the chosen day, on
|
||||
the reader's server — the same clock every daily tick is cut on, so nothing new to get wrong.
|
||||
- **They sit with their game's dailies, and are counted apart.** A task follows its game's chore and
|
||||
events in the strip, but the header counts it under *Weekly* or *Monthly*, not "today": a chore
|
||||
due by Sunday is not waiting on anyone every morning, and only today's dailies celebrate.
|
||||
- **They sit under the dailies, and are counted apart.** Tasks have a row of their own, *This week &
|
||||
month*, under today's chips — in game order — and the header counts them under *Weekly* or
|
||||
*Monthly*, not "today": a chore due by Sunday is not waiting on anyone every morning, and only
|
||||
today's dailies celebrate. **Each period can be switched off** (`showWeeklyTasks`,
|
||||
`showMonthlyTasks`), in *What you see* and in the strip's Edit panel, offered only once a task of
|
||||
that period exists. Off hides tasks only — an event with a goal stays — and discards nothing.
|
||||
- **A number is entered, not ticked.** Its chip opens an editor under the strip — the value as the
|
||||
game shows it, not an increment — because a chip is one target. Catch up offers last period's
|
||||
number the way it offers yesterday's tick.
|
||||
|
||||
+58
-2
@@ -32,7 +32,16 @@ import {
|
||||
resolveFocus,
|
||||
} from "./state/lens.ts";
|
||||
import { clockFor, formatRemaining } from "../shared/time.ts";
|
||||
import { dailySummary, isDaily, resolveDaily } from "../shared/daily.ts";
|
||||
import {
|
||||
dailyDays as runDays,
|
||||
dailySummary,
|
||||
dayKey,
|
||||
isDaily,
|
||||
resolveDaily,
|
||||
} from "../shared/daily.ts";
|
||||
import { isoWeekday } from "../shared/period.ts";
|
||||
import { tasksShown } from "./components/Dailies.tsx";
|
||||
import type { StripEditor, StripEvent } from "./components/EditPanel.tsx";
|
||||
import { goalStatus, readGoal, type GoalStatus } from "../shared/goal.ts";
|
||||
import { taskStatus, type TaskShape } from "../shared/task.ts";
|
||||
import {
|
||||
@@ -253,6 +262,21 @@ export function App() {
|
||||
|
||||
const isIgnored = (id: string) => ignored.marks[id] !== undefined;
|
||||
|
||||
/** An event as the strip's Edit panel needs it. */
|
||||
const stripEvent = (row: RowEvent): StripEvent => {
|
||||
const run = runDays(row.clock.startsMs, row.clock.endsMs, prefs.region, row.event.game);
|
||||
return {
|
||||
id: row.event.id,
|
||||
game: row.event.game,
|
||||
title: row.event.title,
|
||||
goal: goalOf(row.event.id),
|
||||
hasStoredGoal: prog.progress[row.event.id]?.goal !== undefined,
|
||||
detected: prefs.detectDaily && isDaily(row.event),
|
||||
defaultWeekStartsOn: isoWeekday(dayKey(row.clock.startsMs, prefs.region, row.event.game)),
|
||||
maxDays: run === null ? null : run.length,
|
||||
};
|
||||
};
|
||||
|
||||
const toggleIgnored = (id: string, title: string) => {
|
||||
const wasIgnored = isIgnored(id);
|
||||
ignored.toggle(id);
|
||||
@@ -423,6 +447,34 @@ export function App() {
|
||||
* them on screen, not keep nagging me about them.
|
||||
*/
|
||||
const todo = outstanding(live, isDone, isIgnored);
|
||||
|
||||
/**
|
||||
* The strip's Edit panel: the same `daily` and `goal` writes the detail sheet
|
||||
* makes, offered where the reader is ticking. What can be added is what the
|
||||
* strip would show if it repeated — running, not done, not ignored.
|
||||
*/
|
||||
const stripEditor: StripEditor = {
|
||||
inStrip: todo.filter(repeatsDaily).map(stripEvent),
|
||||
addable: todo.filter((r) => !repeatsDaily(r)).map(stripEvent),
|
||||
onGoal: prog.setGoal,
|
||||
onDaily: prog.setDaily,
|
||||
onOpen: setOpenId,
|
||||
shown: {
|
||||
chores: prefs.showChores,
|
||||
weekly: prefs.showWeeklyTasks,
|
||||
monthly: prefs.showMonthlyTasks,
|
||||
},
|
||||
hasTasks: {
|
||||
weekly: stripTasks.some((t) => t.period.unit === "week"),
|
||||
monthly: stripTasks.some((t) => t.period.unit === "month"),
|
||||
},
|
||||
onShown: (patch) =>
|
||||
update({
|
||||
...(patch.chores !== undefined ? { showChores: patch.chores } : {}),
|
||||
...(patch.weekly !== undefined ? { showWeeklyTasks: patch.weekly } : {}),
|
||||
...(patch.monthly !== undefined ? { showMonthlyTasks: patch.monthly } : {}),
|
||||
}),
|
||||
};
|
||||
const headline = nextToExpire(todo, HEADLINE_DEADLINES);
|
||||
|
||||
// Counted across every game the reader plays, not just the focused one — a
|
||||
@@ -620,7 +672,11 @@ export function App() {
|
||||
const row = todo.find((r) => r.event.id === id);
|
||||
return row === undefined ? undefined : goalStatusOf(row);
|
||||
}}
|
||||
tasks={stripTasks}
|
||||
tasks={tasksShown(stripTasks, {
|
||||
weekly: prefs.showWeeklyTasks,
|
||||
monthly: prefs.showMonthlyTasks,
|
||||
})}
|
||||
edit={stripEditor}
|
||||
taskStatusFor={taskStatusOf}
|
||||
ask={(() => {
|
||||
const game = routineToAsk(
|
||||
|
||||
@@ -96,6 +96,13 @@ export function Controls({
|
||||
const shown = games.filter((g) => !prefs.hiddenGames.includes(g)).length;
|
||||
const ownGames = Object.keys(own.games).length;
|
||||
const ownEvents = Object.keys(own.events).length;
|
||||
// Which periods have a task at all — the reader's own, or a built-in they
|
||||
// turned on — so a switch is offered only with something behind it.
|
||||
const periods = [
|
||||
...(own.tasks ?? []).map((t) => t.period.unit),
|
||||
...(prefs.routineGames ?? []).flatMap((g) => routinesFor(g).map((r) => r.period.unit)),
|
||||
];
|
||||
const taskPeriods = { week: periods.includes("week"), month: periods.includes("month") };
|
||||
|
||||
return (
|
||||
<section
|
||||
@@ -248,6 +255,26 @@ export function Controls({
|
||||
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."
|
||||
/>
|
||||
|
||||
{/* Where weekly and monthly tasks sit: their own row under the
|
||||
dailies. Only offered once there is a task of that period — a
|
||||
switch with nothing behind it is a control that does nothing. */}
|
||||
{taskPeriods.week && (
|
||||
<Check
|
||||
checked={prefs.showWeeklyTasks}
|
||||
onChange={(showWeeklyTasks) => onUpdate({ showWeeklyTasks })}
|
||||
label="Show weekly tasks under the dailies"
|
||||
hint="Your own and any built-ins you turned on. Off hides them from the strip and its counts; your ticks and numbers are kept."
|
||||
/>
|
||||
)}
|
||||
{taskPeriods.month && (
|
||||
<Check
|
||||
checked={prefs.showMonthlyTasks}
|
||||
onChange={(showMonthlyTasks) => onUpdate({ showMonthlyTasks })}
|
||||
label="Show monthly tasks under the dailies"
|
||||
hint="Off hides them from the strip and its counts; your ticks and numbers are kept."
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Beside the chores rather than on each game's row, which already
|
||||
carries a handle, two arrows and a switch. Opt-in per game: these
|
||||
are someone else's figures, each dated by its source. */}
|
||||
|
||||
@@ -18,6 +18,7 @@ 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";
|
||||
|
||||
/**
|
||||
@@ -207,6 +208,31 @@ export function dailyGroups(
|
||||
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 },
|
||||
): T[] {
|
||||
return tasks.filter((t) => (t.period.unit === "week" ? shown.weekly : shown.monthly));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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";
|
||||
return { today: items.filter((i) => !longer(i)), longer: items.filter(longer) };
|
||||
}
|
||||
|
||||
interface PeriodTally {
|
||||
total: number;
|
||||
complete: number;
|
||||
@@ -278,6 +304,7 @@ export function Dailies({
|
||||
onSetAmount,
|
||||
amountFor,
|
||||
ask,
|
||||
edit,
|
||||
}: {
|
||||
/** Every lane the reader is looking at, in their own order. */
|
||||
games: LaneId[];
|
||||
@@ -314,6 +341,8 @@ export function Dailies({
|
||||
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();
|
||||
/**
|
||||
@@ -324,7 +353,9 @@ export function Dailies({
|
||||
* reading — "I did Tuesday, let me say so" — rather than a statement about how
|
||||
* the app should work.
|
||||
*/
|
||||
const [catchUp, setCatchUp] = useState(false);
|
||||
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);
|
||||
|
||||
@@ -372,7 +403,23 @@ export function Dailies({
|
||||
return () => clearTimeout(id);
|
||||
}, [burst]);
|
||||
|
||||
if (items.length === 0 && ask === undefined) return null;
|
||||
// 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 : (
|
||||
@@ -402,7 +449,22 @@ export function Dailies({
|
||||
);
|
||||
|
||||
if (items.length === 0) {
|
||||
return <section className="relative border-b border-hairline px-4 py-3">{question}</section>;
|
||||
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
|
||||
@@ -411,6 +473,34 @@ export function Dailies({
|
||||
const mixed = new Set(items.map((i) => i.resetsIn)).size > 1;
|
||||
const open = items.find((i) => i.key === editing && i.goal?.measure === "amount");
|
||||
|
||||
const { today, longer } = splitByCadence(items);
|
||||
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} />}
|
||||
@@ -445,45 +535,36 @@ export function Dailies({
|
||||
onSetAmount={onSetAmount}
|
||||
amountFor={amountFor}
|
||||
/>
|
||||
) : editingStrip ? (
|
||||
editPanel
|
||||
) : (
|
||||
/* One wrapping row, with each game's chore and its events adjacent. No
|
||||
/* 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">
|
||||
{items.map((item) => (
|
||||
<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>
|
||||
))}
|
||||
</ul>
|
||||
<>
|
||||
<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">
|
||||
This week & month
|
||||
</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. */}
|
||||
{!catchUp && open?.goal !== undefined && onSetAmount !== undefined && (
|
||||
{panel === null && open?.goal !== undefined && onSetAmount !== undefined && (
|
||||
<AmountEditor
|
||||
key={`${open.key}:${open.goal.periodStart}`}
|
||||
autoFocus
|
||||
@@ -501,7 +582,9 @@ export function Dailies({
|
||||
|
||||
<div className="relative mt-2 flex items-baseline justify-between gap-3">
|
||||
<p className="text-[0.6875rem] leading-relaxed text-faint">
|
||||
{catchUp
|
||||
{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
|
||||
? week.complete + month.complete === week.total + month.total
|
||||
@@ -514,14 +597,17 @@ export function Dailies({
|
||||
{/* 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. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCatchUp((on) => !on)}
|
||||
aria-expanded={catchUp}
|
||||
className="shrink-0 text-[0.6875rem] text-faint transition-colors hover:text-muted"
|
||||
>
|
||||
{catchUp ? "Done" : "Catch up"}
|
||||
</button>
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
import { useState } from "react";
|
||||
import type { LaneId } from "../../shared/custom.ts";
|
||||
import { dailyOverride } from "../../shared/daily.ts";
|
||||
import { goalSummary, type Goal } from "../../shared/goal.ts";
|
||||
import { useGameMeta } from "../state/gameMeta.tsx";
|
||||
import { GoalControl } from "./GoalControl.tsx";
|
||||
|
||||
/**
|
||||
* Editing Today's dailies from the strip.
|
||||
*
|
||||
* Giving Genshin's “To Temper Thyself and Journey Far” its five-of-seven goal
|
||||
* used to mean finding the event in the list, opening its sheet, saying it
|
||||
* repeats and only then setting the goal. This panel does the same things with
|
||||
* the same handlers the sheet uses — `progress.daily` and `progress.goal` —
|
||||
* so nothing here stores anything the sheet would not.
|
||||
*
|
||||
* A section-level mode, like Catch up, and never a control inside a chip: a
|
||||
* chip is one tick target, and a second one inside it is a mis-tap that costs
|
||||
* a streak.
|
||||
*/
|
||||
|
||||
/** An event as the panel needs it: what it is, its goal, and what bounds one. */
|
||||
export interface StripEvent {
|
||||
id: string;
|
||||
game: LaneId;
|
||||
title: string;
|
||||
/** The goal, when it is one this build can read. */
|
||||
goal: Goal | undefined;
|
||||
/** Something is stored under `goal`, readable or not. */
|
||||
hasStoredGoal: boolean;
|
||||
/** Detection is on and says this one repeats — what `dailyOverride` compares against. */
|
||||
detected: boolean;
|
||||
/** The weekday of the event's first game-day: its weeks most likely start there. */
|
||||
defaultWeekStartsOn: number;
|
||||
/** Claimable days in the run, or null when the end is unannounced. */
|
||||
maxDays: number | null;
|
||||
}
|
||||
|
||||
/** Which parts of the strip are switched on. */
|
||||
export interface StripShown {
|
||||
chores: boolean;
|
||||
weekly: boolean;
|
||||
monthly: boolean;
|
||||
}
|
||||
|
||||
/** Everything the strip needs to be edited in place. Built by App. */
|
||||
export interface StripEditor {
|
||||
/** Running events already in the strip. */
|
||||
inStrip: StripEvent[];
|
||||
/** Running events the reader has not finished or ignored that are not in it. */
|
||||
addable: StripEvent[];
|
||||
onGoal: (id: string, goal: Goal | undefined) => void;
|
||||
/** `useProgress().setDaily`: the override, and whether the goal goes with it. */
|
||||
onDaily: (id: string, daily: boolean | undefined, clearsGoal?: boolean) => void;
|
||||
/** Open the event's detail sheet. */
|
||||
onOpen: (id: string) => void;
|
||||
shown: StripShown;
|
||||
/** Whether there is any weekly or monthly task to switch: a switch with nothing behind it is not offered. */
|
||||
hasTasks: { weekly: boolean; monthly: boolean };
|
||||
onShown: (patch: Partial<StripShown>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Events by lane: the reader's order first, then any lane only an event brought
|
||||
* — the rule `dailyGroups` follows — and never re-sorted inside a lane. A lane
|
||||
* with nothing in it is dropped. Pure.
|
||||
*/
|
||||
export function laneGroups(
|
||||
games: readonly LaneId[],
|
||||
events: readonly StripEvent[],
|
||||
): { game: LaneId; events: StripEvent[] }[] {
|
||||
const lanes: LaneId[] = [...games];
|
||||
for (const event of events) if (!lanes.includes(event.game)) lanes.push(event.game);
|
||||
return lanes
|
||||
.map((game) => ({ game, events: events.filter((e) => e.game === game) }))
|
||||
.filter((group) => group.events.length > 0);
|
||||
}
|
||||
|
||||
export function EditPanel({
|
||||
games,
|
||||
editor,
|
||||
initialOpen = null,
|
||||
initialAdding = null,
|
||||
}: {
|
||||
games: readonly LaneId[];
|
||||
editor: StripEditor;
|
||||
/** The row open on first render; for tests, which cannot press a button. */
|
||||
initialOpen?: string | null;
|
||||
/** The game whose add list is open on first render; for tests, likewise. */
|
||||
initialAdding?: LaneId | null;
|
||||
}) {
|
||||
const gameMeta = useGameMeta();
|
||||
/** Which event's goal is open, by id. One at a time, as with the amount editor. */
|
||||
const [open, setOpen] = useState<string | null>(initialOpen);
|
||||
/**
|
||||
* The game whose add list is showing. Closed by default and one at a time:
|
||||
* a reader with every game on has a hundred running events, and listing them
|
||||
* all at once buried the strip's own events under a wall of pills.
|
||||
*/
|
||||
const [adding, setAdding] = useState<LaneId | null>(initialAdding);
|
||||
const { inStrip, addable, shown, hasTasks } = editor;
|
||||
|
||||
const add = (event: StripEvent) => {
|
||||
editor.onDaily(event.id, dailyOverride(true, event.detected));
|
||||
// Straight to its goal: adding one is nearly always so as to give it one.
|
||||
setOpen(event.id);
|
||||
};
|
||||
|
||||
const groups = laneGroups(games, [...inStrip, ...addable]);
|
||||
|
||||
return (
|
||||
<div className="relative mt-3 flex flex-col gap-3">
|
||||
{groups.map(({ game, events }) => {
|
||||
const meta = gameMeta(game);
|
||||
const here = events.filter((e) => inStrip.includes(e));
|
||||
const more = events.filter((e) => addable.includes(e));
|
||||
return (
|
||||
<div key={game}>
|
||||
<p className="text-[0.6875rem] font-semibold" style={{ color: meta.hue }}>
|
||||
{meta.name}
|
||||
</p>
|
||||
{here.map((event) => {
|
||||
const isOpen = open === event.id;
|
||||
return (
|
||||
<div key={event.id} className="mt-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(isOpen ? null : event.id)}
|
||||
aria-expanded={isOpen}
|
||||
className="flex w-full items-baseline justify-between gap-3 text-left text-xs"
|
||||
>
|
||||
<span className="truncate text-muted">{event.title}</span>
|
||||
<span className="tnum shrink-0 text-faint">
|
||||
{/* Unreadable is not "none": a newer version's goal is
|
||||
still stored, and saying "No goal" would invite the
|
||||
reader to think there is nothing to lose. */}
|
||||
{event.goal === undefined && event.hasStoredGoal
|
||||
? "A goal this version can't read"
|
||||
: goalSummary(event.goal)}
|
||||
</span>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="mb-2 ml-3 border-l border-hairline pl-3">
|
||||
<GoalControl
|
||||
goal={event.goal}
|
||||
defaultWeekStartsOn={event.defaultWeekStartsOn}
|
||||
maxDays={event.maxDays}
|
||||
onChange={(goal) => editor.onGoal(event.id, goal)}
|
||||
/>
|
||||
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editor.onOpen(event.id)}
|
||||
className="text-muted transition-colors hover:text-ink"
|
||||
>
|
||||
Open details
|
||||
</button>
|
||||
{/* The sheet's "not a daily" control and wording: the
|
||||
goal goes with it, so the two never contradict. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
editor.onDaily(event.id, dailyOverride(false, event.detected), true);
|
||||
setOpen(null);
|
||||
}}
|
||||
className="text-faint transition-colors hover:text-muted"
|
||||
>
|
||||
{event.hasStoredGoal
|
||||
? "Remove from dailies — removes the goal"
|
||||
: "Remove from dailies"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{more.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAdding(adding === game ? null : game)}
|
||||
aria-expanded={adding === game}
|
||||
className="mt-1.5 text-xs text-faint transition-colors hover:text-muted"
|
||||
>
|
||||
Add an event · {more.length}
|
||||
</button>
|
||||
)}
|
||||
{more.length > 0 && adding === game && (
|
||||
<ul className="mt-2 flex flex-wrap gap-1.5">
|
||||
{more.map((event) => (
|
||||
<li key={event.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => add(event)}
|
||||
aria-label={`Add ${event.title} to dailies`}
|
||||
className="flex max-w-[15rem] items-center gap-1.5 rounded-full border border-dashed border-hairline px-3 py-1.5 text-xs text-faint transition-colors hover:border-faint hover:text-muted"
|
||||
>
|
||||
<svg viewBox="0 0 16 16" aria-hidden className="size-3 shrink-0">
|
||||
<path
|
||||
d="M8 3v10M3 8h10"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
<span className="truncate">{event.title}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{addable.length === 0 && (
|
||||
<p className="text-[0.6875rem] leading-relaxed text-faint">
|
||||
Every running event is already here. One that hasn't started joins the list to add once
|
||||
it opens.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* The same prefs the settings' What you see group writes: one answer,
|
||||
reachable from where its effect is. */}
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1.5 border-t border-hairline pt-3 text-xs text-muted">
|
||||
<Switch
|
||||
label="Daily chores"
|
||||
checked={shown.chores}
|
||||
onChange={(chores) => editor.onShown({ chores })}
|
||||
/>
|
||||
{hasTasks.weekly && (
|
||||
<Switch
|
||||
label="Weekly tasks"
|
||||
checked={shown.weekly}
|
||||
onChange={(weekly) => editor.onShown({ weekly })}
|
||||
/>
|
||||
)}
|
||||
{hasTasks.monthly && (
|
||||
<Switch
|
||||
label="Monthly tasks"
|
||||
checked={shown.monthly}
|
||||
onChange={(monthly) => editor.onShown({ monthly })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Switch({
|
||||
label,
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<label className="flex cursor-pointer items-center gap-1.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
className="accent-[var(--color-near)]"
|
||||
/>
|
||||
<span>{label}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -168,6 +168,18 @@ export interface Prefs {
|
||||
* here, so switching back on restores every one of them.
|
||||
*/
|
||||
showChores: boolean;
|
||||
/**
|
||||
* Whether weekly tasks — the reader's own and any built-ins they turned on —
|
||||
* sit in the strip's row under the dailies. `showMonthlyTasks` is the same
|
||||
* for monthly ones.
|
||||
*
|
||||
* On by default, for `showChores`' reason: a reader who set tasks up before
|
||||
* these existed must not find them gone. Off hides only tasks — an event with
|
||||
* a goal is something the reader engaged with and stays either way — and,
|
||||
* like the chores, it discards nothing: ticks and numbers keep their keys.
|
||||
*/
|
||||
showWeeklyTasks: boolean;
|
||||
showMonthlyTasks: boolean;
|
||||
showCompleted: boolean;
|
||||
/** Reveal events the reader has ignored, so they can be restored. */
|
||||
showIgnored: boolean;
|
||||
@@ -236,6 +248,8 @@ export function defaults(): Prefs {
|
||||
timelineSplitUpcoming: true,
|
||||
detectDaily: false,
|
||||
showChores: true,
|
||||
showWeeklyTasks: true,
|
||||
showMonthlyTasks: true,
|
||||
showCompleted: true,
|
||||
showIgnored: false,
|
||||
theme: DEFAULT_THEME_CHOICE,
|
||||
@@ -400,6 +414,8 @@ export function restorePrefsValue(incoming: unknown): Prefs {
|
||||
result.timelineSplitUpcoming = typeof result.timelineSplitUpcoming === "boolean" ? result.timelineSplitUpcoming : base.timelineSplitUpcoming;
|
||||
result.detectDaily = typeof result.detectDaily === "boolean" ? result.detectDaily : base.detectDaily;
|
||||
result.showChores = typeof result.showChores === "boolean" ? result.showChores : base.showChores;
|
||||
result.showWeeklyTasks = typeof result.showWeeklyTasks === "boolean" ? result.showWeeklyTasks : base.showWeeklyTasks;
|
||||
result.showMonthlyTasks = typeof result.showMonthlyTasks === "boolean" ? result.showMonthlyTasks : base.showMonthlyTasks;
|
||||
result.showCompleted = typeof result.showCompleted === "boolean" ? result.showCompleted : base.showCompleted;
|
||||
result.showIgnored = typeof result.showIgnored === "boolean" ? result.showIgnored : base.showIgnored;
|
||||
result.regionConfirmed = typeof result.regionConfirmed === "boolean" ? result.regionConfirmed : base.regionConfirmed;
|
||||
|
||||
@@ -194,6 +194,16 @@ export function goalCaption(s: GoalStatus): string {
|
||||
return `${head} · ${s.needed} needed · ${days(s.open)} left${left}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a goal asks for, in a few words — the Edit panel's line for an event
|
||||
* whose goal is not open. Says nothing about progress: that is the chip's job.
|
||||
*/
|
||||
export function goalSummary(goal: Goal | undefined): string {
|
||||
if (goal === undefined) return "No goal";
|
||||
const what = goal.measure === "days" ? days(goal.required) : `${num(goal.target)} points`;
|
||||
return `${what} ${goal.period === "week" ? "each week" : "across the event"}`;
|
||||
}
|
||||
|
||||
/** The Dailies chip's short count. */
|
||||
export function goalCount(s: GoalStatus): string {
|
||||
const unit = s.scope === "week" ? " wk" : s.scope === "month" ? " mo" : "";
|
||||
|
||||
@@ -31,6 +31,8 @@ const PREFS: Prefs = {
|
||||
timelineSplitUpcoming: true,
|
||||
detectDaily: false,
|
||||
showChores: true,
|
||||
showWeeklyTasks: true,
|
||||
showMonthlyTasks: true,
|
||||
showCompleted: true,
|
||||
showIgnored: false,
|
||||
theme: "dark",
|
||||
@@ -325,3 +327,21 @@ describe("Controls: the Your own summary counts tasks", () => {
|
||||
expect(ownState(0, 0, 0)).toBe("none yet");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Controls: weekly and monthly tasks under the dailies", () => {
|
||||
test("each switch appears once there is a task of its period, and reads its own pref", () => {
|
||||
// ZZZ's built-ins are weekly; there is no monthly task, so no monthly switch.
|
||||
const prefs = { ...PREFS, routineGames: ["zzz"] };
|
||||
const on = render(prefs, 0, ["zzz"]);
|
||||
expect(on).toContain("Show weekly tasks");
|
||||
expect(on).not.toContain("Show monthly tasks");
|
||||
const off = render({ ...prefs, showWeeklyTasks: false }, 0, ["zzz"]);
|
||||
expect(checkboxes(off).filter(Boolean).length).toBe(checkboxes(on).filter(Boolean).length - 1);
|
||||
});
|
||||
|
||||
test("with no tasks at all, neither switch is offered", () => {
|
||||
const html = render(PREFS);
|
||||
expect(html).not.toContain("Show weekly tasks");
|
||||
expect(html).not.toContain("Show monthly tasks");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import {
|
||||
Dailies,
|
||||
splitByCadence,
|
||||
tasksShown,
|
||||
type DailyItem,
|
||||
} from "../src/client/components/Dailies.tsx";
|
||||
import {
|
||||
EditPanel,
|
||||
laneGroups,
|
||||
type StripEditor,
|
||||
type StripEvent,
|
||||
} from "../src/client/components/EditPanel.tsx";
|
||||
import { GameMetaProvider } from "../src/client/state/gameMeta.tsx";
|
||||
import { metaFor } from "../src/shared/games.ts";
|
||||
import { goalSummary, type GoalStatus } from "../src/shared/goal.ts";
|
||||
import type { Task } from "../src/shared/task.ts";
|
||||
|
||||
/**
|
||||
* Editing the dailies strip from the strip itself.
|
||||
*
|
||||
* Setting up Genshin's “To Temper Thyself and Journey Far” — five days of seven,
|
||||
* weeks from Monday — used to mean finding it in the list, opening its sheet,
|
||||
* saying it repeats, then setting the goal. The strip's Edit panel does the
|
||||
* same with the same handlers, and weekly and monthly tasks get a row of their
|
||||
* own under the dailies that the reader can switch off.
|
||||
*/
|
||||
|
||||
const NOW = Date.parse("2026-10-14T12:00:00.000Z");
|
||||
|
||||
function render(node: React.ReactElement): string {
|
||||
return renderToStaticMarkup(
|
||||
<GameMetaProvider value={(id) => metaFor(id, {})}>{node}</GameMetaProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const temper: StripEvent = {
|
||||
id: "genshin:to-temper-thyself-and-journey-far:2026-08-10",
|
||||
game: "genshin",
|
||||
title: "To Temper Thyself and Journey Far",
|
||||
goal: { period: "week", measure: "days", required: 5, weekStartsOn: 1 },
|
||||
hasStoredGoal: true,
|
||||
detected: false,
|
||||
defaultWeekStartsOn: 1,
|
||||
maxDays: 84,
|
||||
};
|
||||
const login: StripEvent = {
|
||||
id: "hsr:gift-of-odyssey:2026-10-01",
|
||||
game: "hsr",
|
||||
title: "Gift of Odyssey",
|
||||
goal: undefined,
|
||||
hasStoredGoal: false,
|
||||
detected: true,
|
||||
defaultWeekStartsOn: 3,
|
||||
maxDays: 7,
|
||||
};
|
||||
const abyss: StripEvent = {
|
||||
id: "genshin:spiral-abyss:2026-10-01",
|
||||
game: "genshin",
|
||||
title: "Spiral Abyss",
|
||||
goal: undefined,
|
||||
hasStoredGoal: false,
|
||||
detected: false,
|
||||
defaultWeekStartsOn: 3,
|
||||
maxDays: null,
|
||||
};
|
||||
|
||||
function editor(over: Partial<StripEditor> = {}): StripEditor {
|
||||
return {
|
||||
inStrip: [temper, login],
|
||||
addable: [abyss],
|
||||
onGoal: () => {},
|
||||
onDaily: () => {},
|
||||
onOpen: () => {},
|
||||
shown: { chores: true, weekly: true, monthly: true },
|
||||
hasTasks: { weekly: true, monthly: false },
|
||||
onShown: () => {},
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("goalSummary", () => {
|
||||
test("says what each goal asks for in a few words", () => {
|
||||
expect(goalSummary(undefined)).toBe("No goal");
|
||||
expect(goalSummary({ period: "week", measure: "days", required: 5, weekStartsOn: 1 })).toBe(
|
||||
"5 days each week",
|
||||
);
|
||||
expect(goalSummary({ period: "week", measure: "days", required: 1, weekStartsOn: 1 })).toBe(
|
||||
"1 day each week",
|
||||
);
|
||||
expect(goalSummary({ period: "event", measure: "days", required: 12 })).toBe(
|
||||
"12 days across the event",
|
||||
);
|
||||
expect(
|
||||
goalSummary({ period: "week", measure: "amount", target: 2100, weekStartsOn: 1 }),
|
||||
).toBe("2,100 points each week");
|
||||
expect(goalSummary({ period: "event", measure: "amount", target: 500 })).toBe(
|
||||
"500 points across the event",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("laneGroups", () => {
|
||||
test("the reader's lane order first, then stragglers, never re-sorted inside a lane", () => {
|
||||
const zzz: StripEvent = { ...abyss, id: "z", game: "zzz", title: "Z" };
|
||||
const groups = laneGroups(["hsr", "genshin"], [temper, zzz, login, abyss]);
|
||||
expect(groups.map((g) => [g.game, g.events.map((e) => e.title)])).toEqual([
|
||||
["hsr", ["Gift of Odyssey"]],
|
||||
["genshin", ["To Temper Thyself and Journey Far", "Spiral Abyss"]],
|
||||
["zzz", ["Z"]],
|
||||
]);
|
||||
});
|
||||
|
||||
test("a lane with nothing in it is dropped", () => {
|
||||
expect(laneGroups(["hsr", "genshin"], [temper]).map((g) => g.game)).toEqual(["genshin"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tasksShown", () => {
|
||||
const AT = "2026-10-01T00:00:00.000Z";
|
||||
const weekly: Task = {
|
||||
id: "mytask:aaaaaa1111", game: "zzz", title: "Activity points",
|
||||
period: { unit: "week", startsOn: 1 },
|
||||
measure: { kind: "amount", target: 2100, unit: "points" }, at: AT, updatedAt: AT,
|
||||
};
|
||||
const monthly: Task = {
|
||||
id: "mytask:bbbbbb2222", game: "zzz", title: "Shop reset",
|
||||
period: { unit: "month", startsOn: 1 }, measure: { kind: "days", required: 1 },
|
||||
at: AT, updatedAt: AT,
|
||||
};
|
||||
|
||||
test("each switch hides its own period and nothing else", () => {
|
||||
const all = [weekly, monthly];
|
||||
expect(tasksShown(all, { weekly: true, monthly: true })).toEqual(all);
|
||||
expect(tasksShown(all, { weekly: false, monthly: true })).toEqual([monthly]);
|
||||
expect(tasksShown(all, { weekly: true, monthly: false })).toEqual([weekly]);
|
||||
expect(tasksShown(all, { weekly: false, monthly: false })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitByCadence", () => {
|
||||
const item = (key: string, cadence: DailyItem["cadence"]): DailyItem => ({
|
||||
key, game: "zzz", label: key, title: key, ariaLabel: key, today: "2026-10-14",
|
||||
resetsIn: 0, notBefore: null, cadence,
|
||||
});
|
||||
|
||||
test("today's things stay on top, weekly and monthly go under, order kept", () => {
|
||||
const items = [item("chore", "day"), item("wk", "week"), item("ev", "event"),
|
||||
item("mo", "month"), item("ev2", "day")];
|
||||
const { today, longer } = splitByCadence(items);
|
||||
expect(today.map((i) => i.key)).toEqual(["chore", "ev", "ev2"]);
|
||||
expect(longer.map((i) => i.key)).toEqual(["wk", "mo"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("EditPanel", () => {
|
||||
test("lists the strip's events by game, each with its goal in words", () => {
|
||||
const html = render(<EditPanel games={["genshin", "hsr"]} editor={editor()} />);
|
||||
expect(html).toContain("To Temper Thyself and Journey Far");
|
||||
expect(html).toContain("5 days each week");
|
||||
expect(html).toContain("Gift of Odyssey");
|
||||
expect(html).toContain("No goal");
|
||||
// Genshin's heading comes before Honkai's, as the reader ordered them.
|
||||
expect(html.indexOf("Genshin Impact")).toBeLessThan(html.indexOf("Honkai: Star Rail"));
|
||||
});
|
||||
|
||||
test("offers running events that are not in the strip yet, a game at a time", () => {
|
||||
// Closed, a game says how many it could add and lists none of them: with
|
||||
// every game on, the open list was a hundred pills deep.
|
||||
const closed = render(<EditPanel games={["genshin", "hsr"]} editor={editor()} />);
|
||||
expect(closed).toMatch(/Add an event · (<!-- -->)?1/);
|
||||
expect(closed).not.toContain("Add Spiral Abyss to dailies");
|
||||
const open = render(
|
||||
<EditPanel games={["genshin", "hsr"]} editor={editor()} initialAdding="genshin" />,
|
||||
);
|
||||
expect(open).toMatch(/aria-label="Add Spiral Abyss to dailies"/);
|
||||
});
|
||||
|
||||
test("a goal this version cannot read is said, not shown as none", () => {
|
||||
const unread: StripEvent = { ...login, hasStoredGoal: true };
|
||||
const html = render(
|
||||
<EditPanel games={["hsr"]} editor={editor({ inStrip: [unread], addable: [] })} />,
|
||||
);
|
||||
expect(html).toContain("A goal this version can't read");
|
||||
expect(html).not.toContain("No goal");
|
||||
});
|
||||
|
||||
test("an open row carries the goal control, the sheet and the way out", () => {
|
||||
const html = render(
|
||||
<EditPanel games={["genshin"]} editor={editor({ inStrip: [temper], addable: [] })}
|
||||
initialOpen={temper.id} />,
|
||||
);
|
||||
expect(html).toContain("Check-in goal");
|
||||
expect(html).toContain("Open details");
|
||||
// Same wording as the sheet, so the reader is told what goes with it.
|
||||
expect(html).toContain("Remove from dailies — removes the goal");
|
||||
});
|
||||
|
||||
test("the switches are offered only for what exists", () => {
|
||||
const html = render(<EditPanel games={["genshin"]} editor={editor()} />);
|
||||
expect(html).toContain("Daily chores");
|
||||
expect(html).toContain("Weekly tasks");
|
||||
expect(html).not.toContain("Monthly tasks");
|
||||
});
|
||||
|
||||
test("with nothing to add, it says where events come from", () => {
|
||||
const html = render(
|
||||
<EditPanel games={["genshin"]} editor={editor({ inStrip: [temper], addable: [] })} />,
|
||||
);
|
||||
expect(html).not.toContain("to dailies\"");
|
||||
expect(html).toContain("Every running event is already here");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the strip with an editor", () => {
|
||||
const AT = "2026-10-01T00:00:00.000Z";
|
||||
const weekly: Task = {
|
||||
id: "mytask:aaaaaa1111", game: "zzz", title: "Activity points",
|
||||
period: { unit: "week", startsOn: 1 },
|
||||
measure: { kind: "amount", target: 2100, unit: "points" }, at: AT, updatedAt: AT,
|
||||
};
|
||||
const status: GoalStatus = {
|
||||
scope: "week", measure: "amount", periodStart: "2026-10-12", required: 2100, count: 0,
|
||||
met: false, needed: 2100, open: 5, shortfall: false,
|
||||
};
|
||||
|
||||
test("offers Edit beside Catch up", () => {
|
||||
const html = render(
|
||||
<Dailies games={["genshin"]} events={[]} region="europe" now={NOW} showChores
|
||||
daysFor={() => []} onToggleDay={() => {}} edit={editor()} />,
|
||||
);
|
||||
expect(html).toContain("Catch up");
|
||||
expect(html).toMatch(/>Edit</);
|
||||
});
|
||||
|
||||
test("weekly and monthly tasks sit in their own row under today's", () => {
|
||||
const html = render(
|
||||
<Dailies games={["zzz"]} events={[]} region="europe" now={NOW} showChores
|
||||
daysFor={() => []} onToggleDay={() => {}} tasks={[weekly]}
|
||||
taskStatusFor={() => status} onSetAmount={() => {}} amountFor={() => undefined} />,
|
||||
);
|
||||
const heading = html.indexOf("This week & month");
|
||||
expect(heading).toBeGreaterThan(-1);
|
||||
expect(html.indexOf("ZZZ")).toBeLessThan(heading);
|
||||
expect(html.indexOf("Activity points")).toBeGreaterThan(heading);
|
||||
});
|
||||
|
||||
test("without weekly or monthly things there is no second row", () => {
|
||||
const html = render(
|
||||
<Dailies games={["zzz"]} events={[]} region="europe" now={NOW} showChores
|
||||
daysFor={() => []} onToggleDay={() => {}} />,
|
||||
);
|
||||
expect(html).not.toContain("This week & month");
|
||||
});
|
||||
|
||||
test("an empty strip still offers a way to add an event", () => {
|
||||
const html = render(
|
||||
<Dailies games={["genshin"]} events={[]} region="europe" now={NOW} showChores={false}
|
||||
daysFor={() => []} onToggleDay={() => {}} edit={editor({ inStrip: [] })} />,
|
||||
);
|
||||
expect(html).toMatch(/>Edit</);
|
||||
});
|
||||
|
||||
test("an empty strip with nothing to add stays away, as it did before", () => {
|
||||
const html = render(
|
||||
<Dailies games={["genshin"]} events={[]} region="europe" now={NOW} showChores={false}
|
||||
daysFor={() => []} onToggleDay={() => {}}
|
||||
edit={editor({ inStrip: [], addable: [], shown: { chores: false, weekly: true, monthly: true } })} />,
|
||||
);
|
||||
expect(html).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -124,6 +124,23 @@ describe("defaults that a reader would notice losing", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("weekly and monthly tasks under the dailies", () => {
|
||||
test("both are shown until someone says otherwise", () => {
|
||||
// A reader who set up tasks before these switches existed must not find
|
||||
// them gone on upgrade.
|
||||
expect(defaults().showWeeklyTasks).toBe(true);
|
||||
expect(defaults().showMonthlyTasks).toBe(true);
|
||||
const stored = { region: "europe", showChores: false } as const;
|
||||
expect({ ...defaults(), ...stored }.showWeeklyTasks).toBe(true);
|
||||
});
|
||||
|
||||
test("a restored value that is not a boolean falls back to shown", () => {
|
||||
const restored = restorePrefsValue({ showWeeklyTasks: "no", showMonthlyTasks: false });
|
||||
expect(restored.showWeeklyTasks).toBe(true);
|
||||
expect(restored.showMonthlyTasks).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("restorePrefsValue", () => {
|
||||
test("returns defaults when passed null or non-object", () => {
|
||||
expect(restorePrefsValue(null)).toEqual(defaults());
|
||||
@@ -146,6 +163,8 @@ describe("restorePrefsValue", () => {
|
||||
timelineSplitUpcoming: false,
|
||||
detectDaily: true,
|
||||
showChores: false,
|
||||
showWeeklyTasks: false,
|
||||
showMonthlyTasks: true,
|
||||
showCompleted: false,
|
||||
showIgnored: true,
|
||||
theme: "light" as const,
|
||||
|
||||
Reference in New Issue
Block a user