diff --git a/AGENTS.md b/AGENTS.md
index efe1dd0..8e1f8b7 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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
diff --git a/docs/DATA-MODEL.md b/docs/DATA-MODEL.md
index 54bf393..aedd200 100644
--- a/docs/DATA-MODEL.md
+++ b/docs/DATA-MODEL.md
@@ -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
diff --git a/docs/DESIGN.md b/docs/DESIGN.md
index 254b626..7ca2d12 100644
--- a/docs/DESIGN.md
+++ b/docs/DESIGN.md
@@ -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.
diff --git a/docs/PRD.md b/docs/PRD.md
index 7077bb4..19342a8 100644
--- a/docs/PRD.md
+++ b/docs/PRD.md
@@ -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.
diff --git a/src/client/App.tsx b/src/client/App.tsx
index bf49f88..549ad79 100644
--- a/src/client/App.tsx
+++ b/src/client/App.tsx
@@ -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(
diff --git a/src/client/components/Controls.tsx b/src/client/components/Controls.tsx
index a4ea92d..0ec2846 100644
--- a/src/client/components/Controls.tsx
+++ b/src/client/components/Controls.tsx
@@ -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 (
+ {/* 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 && (
+ 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 && (
+ 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. */}
diff --git a/src/client/components/Dailies.tsx b/src/client/components/Dailies.tsx
index c108a4a..f03b66a 100644
--- a/src/client/components/Dailies.tsx
+++ b/src/client/components/Dailies.tsx
@@ -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>(
+ 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(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 && (
+
+ );
+ const editPanel = editingStrip && ;
const question =
ask === undefined ? null : (
@@ -402,7 +449,22 @@ export function Dailies({
);
if (items.length === 0) {
- return {question};
+ return (
+
+ {question}
+ {canAdd && (
+ <>
+ {editPanel}
+
+
+ {editingStrip ? "Add an event to tick it off here each day." : "Nothing to tick today."}
+
+ {editToggle}
+
+ >
+ )}
+
+ );
}
// 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) => (
+
+ );
+
return (
{burst > 0 && }
@@ -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. */
-
+ {/* Not due tonight, so under the dailies rather than among them —
+ and switchable, in the Edit panel and in settings. */}
+ {longer.length > 0 && (
+ <>
+
+ This week & month
+
+
+ {longer.map(chip)}
+
+ >
+ )}
+ >
)}
{/* 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 && (
- {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. */}
-
+
+
+ {editToggle}
+
);
diff --git a/src/client/components/EditPanel.tsx b/src/client/components/EditPanel.tsx
new file mode 100644
index 0000000..be1e679
--- /dev/null
+++ b/src/client/components/EditPanel.tsx
@@ -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) => 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(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(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 (
+
+ {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 (
+