feat(dailies): track or stop tracking anything in the strip from Edit

The strip's Edit panel lists each game's chore, then its built-ins, then its
events, each with Stop tracking, and Track · N offers everything a game could
track and does not. Your own lists every built-in of the games that are on,
untracked ones included and marked, so none is off with no way back.

Renames Remove from dailies / Add an event to Stop tracking / Track.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-16 03:00:18 +02:00
co-authored by Claude Opus 5.5
parent 612e0a23e4
commit 3fd2b3c2ed
5 changed files with 193 additions and 30 deletions
+45 -3
View File
@@ -25,7 +25,7 @@ import { useProgress, type ProgressMap } from "./state/useProgress.ts";
import { useDailyLog, type DailyLogMap } from "./state/useDailyLog.ts";
import { useTasks } from "./state/useTasks.ts";
import { useAmounts } from "./state/useAmounts.ts";
import { adoptNewLanes, choreRule, firstRunPrefs, routineRule, usePrefs, type Prefs } from "./state/usePrefs.ts";
import { adoptNewLanes, choreRule, firstRunPrefs, routineRule, trackRoutinePatch, usePrefs, type Prefs } from "./state/usePrefs.ts";
import { buildExportData, parseImportData, triggerDownload } from "./state/export.ts";
import { snapDayWidth } from "./state/zoom.ts";
import { useCustom, type EventDraft } from "./state/useCustom.ts";
@@ -39,6 +39,7 @@ import {
} from "./state/lens.ts";
import { clockFor, formatRemaining } from "../shared/time.ts";
import {
dailiesId,
dailyDays as runDays,
dailySummary,
dayKey,
@@ -47,13 +48,14 @@ import {
} 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 type { StripEditor, StripEvent, StripStanding } from "./components/EditPanel.tsx";
import { goalStatus, readGoal, type GoalStatus } from "../shared/goal.ts";
import { taskStatus, type TaskShape } from "../shared/task.ts";
import {
activeRoutines,
copyRoutineState,
mineFrom,
routinesFor,
routineToAsk,
type Routine,
} from "../shared/routines.ts";
@@ -67,9 +69,11 @@ import { GameMetaProvider, type MetaResolver } from "./state/gameMeta.tsx";
import { metaOnTheme, useTheme } from "./state/theme.ts";
import {
displayEventFor,
isCustomGameId,
recordFor,
type LaneId,
} from "../shared/custom.ts";
import type { GameId } from "../shared/schema.ts";
import { metaFor } from "../shared/games.ts";
/**
@@ -227,8 +231,11 @@ export function App() {
const copiedFrom = new Set(
tasks.tasks.flatMap((t) => (t.from === undefined ? [] : [t.from])),
);
// Every built-in the reader could track, for the lists that offer them; the
// strip carries only those not stopped one by one.
const routinesHidden = new Set(prefs.routinesHidden ?? []);
const routines = activeRoutines(routineRule(prefs), copiedFrom);
const stripTasks = [...tasks.tasks, ...routines];
const stripTasks = [...tasks.tasks, ...routines.filter((r) => !routinesHidden.has(r.id))];
/**
* Copy a built-in into the reader's own tasks, ticks and numbers included.
@@ -506,6 +513,39 @@ export function App() {
...(patch.weekly !== undefined ? { showWeeklyTasks: patch.weekly } : {}),
...(patch.monthly !== undefined ? { showMonthlyTasks: patch.monthly } : {}),
}),
standing: (focus === null ? enabled : [focus]).flatMap((game): StripStanding[] => {
if (isCustomGameId(game)) return [];
const rule = routineRule(prefs);
return [
{
id: dailiesId(game as GameId),
game,
title: gameMeta(game).dailyTasks,
kind: "chore",
tracked: shownFor(choreRule(prefs), game),
},
// A copy the reader made stands in for its built-in, so the original is
// not offered; the copy is theirs, in Your own.
...routinesFor(game)
.filter((r) => !copiedFrom.has(r.id))
.map((r): StripStanding => ({
id: r.id,
game,
title: r.title,
kind: "routine",
tracked: shownFor(rule, game) && !routinesHidden.has(r.id),
})),
];
}),
onTrack: (item, track) => {
if (item.kind === "chore") {
const { on, off } = setFor(choreRule(prefs), item.game, track);
update({ choresOn: on, choresOff: off });
return;
}
const routine = routinesFor(item.game).find((r) => r.id === item.id);
if (routine !== undefined) update(trackRoutinePatch(prefs, routine, track));
},
};
const headline = nextToExpire(todo, HEADLINE_DEADLINES);
@@ -851,6 +891,8 @@ export function App() {
onRemoveTask: tasks.removeTask,
routines,
onMakeItMine: makeItMine,
routinesHidden,
onTrackRoutine: (routine: Routine, track: boolean) => update(trackRoutinePatch(prefs, routine, track)),
now,
// The index lists rules; the sheet opens rows. A repeating rule's
// own id is never a row — the lists hold its occurrences — so it is
+57 -18
View File
@@ -36,6 +36,19 @@ export interface StripEvent {
maxDays: number | null;
}
/**
* Something the strip carries on a game's behalf rather than from the feed: its
* standing chore (`dailies:<game>`) or a built-in weekly or monthly task
* (`routine:`). No goal to set — only whether the reader tracks it.
*/
export interface StripStanding {
id: string;
game: LaneId;
title: string;
kind: "chore" | "routine";
tracked: boolean;
}
/** Which parts of the strip are switched on. */
export interface StripShown {
chores: boolean;
@@ -58,17 +71,21 @@ export interface StripEditor {
/** 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;
/** Chores and built-ins of the strip's games, tracked or not; chore first in each game. */
standing: StripStanding[];
/** Track one, or stop. App writes the prefs that already decide it — nothing new for a chore. */
onTrack: (item: StripStanding, track: boolean) => void;
}
/**
* Events by lane: the reader's order first, then any lane only an event brought
* Items by lane: the reader's order first, then any lane only an item brought
* — the rule `dailyGroups` follows — and never re-sorted inside a lane. A lane
* with nothing in it is dropped. Pure.
*/
export function laneGroups(
export function laneGroups<T extends { game: LaneId }>(
games: readonly LaneId[],
events: readonly StripEvent[],
): { game: LaneId; events: StripEvent[] }[] {
events: readonly T[],
): { game: LaneId; events: T[] }[] {
const lanes: LaneId[] = [...games];
for (const event of events) if (!lanes.includes(event.game)) lanes.push(event.game);
return lanes
@@ -98,7 +115,7 @@ export function EditPanel({
* 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 { inStrip, addable, shown, hasTasks, standing } = editor;
const add = (event: StripEvent) => {
editor.onDaily(event.id, dailyOverride(true, event.detected));
@@ -106,19 +123,40 @@ export function EditPanel({
setOpen(event.id);
};
const groups = laneGroups(games, [...inStrip, ...addable]);
const groups = laneGroups<StripEvent | StripStanding>(games, [...standing, ...inStrip, ...addable]);
const untracked = standing.filter((s) => !s.tracked);
return (
<div className="relative mt-3 flex flex-col gap-3">
{groups.map(({ game, events }) => {
{groups.map(({ game, events: items }) => {
const meta = gameMeta(game);
const here = events.filter((e) => inStrip.includes(e));
const more = events.filter((e) => addable.includes(e));
const kept = standing.filter((s) => s.game === game && s.tracked);
const here = inStrip.filter((e) => items.includes(e));
// What the game could track and does not: its chore or built-ins when
// off, then its running events — one list, so "Track" means one thing.
const more: (StripEvent | StripStanding)[] = [
...untracked.filter((s) => s.game === game),
...addable.filter((e) => items.includes(e)),
];
return (
<div key={game}>
<p className="text-[0.6875rem] font-semibold" style={{ color: meta.hue }}>
{meta.name}
</p>
{/* No goal to open, so the way out sits on the row itself. */}
{kept.map((item) => (
<div key={item.id} className="mt-1.5 flex items-baseline justify-between gap-3 text-xs">
<span className="truncate text-muted">{item.title}</span>
<button
type="button"
onClick={() => editor.onTrack(item, false)}
aria-label={`Stop tracking ${item.title}`}
className="shrink-0 text-faint transition-colors hover:text-muted"
>
Stop tracking
</button>
</div>
))}
{here.map((event) => {
const isOpen = open === event.id;
return (
@@ -155,8 +193,9 @@ export function EditPanel({
>
Open details
</button>
{/* The sheet's "not a daily" control and wording: the
goal goes with it, so the two never contradict. */}
{/* The sheet's "not a daily" control: the goal goes
with it, so the two never contradict. Named as the
chore's and built-ins' are, since it does the same. */}
<button
type="button"
onClick={() => {
@@ -166,8 +205,8 @@ export function EditPanel({
className="text-faint transition-colors hover:text-muted"
>
{event.hasStoredGoal
? "Remove from dailies — removes the goal"
: "Remove from dailies"}
? "Stop tracking — removes the goal"
: "Stop tracking"}
</button>
</div>
</div>
@@ -182,7 +221,7 @@ export function EditPanel({
aria-expanded={adding === game}
className="mt-1.5 text-xs text-faint transition-colors hover:text-muted"
>
Add an event · {more.length}
Track · {more.length}
</button>
)}
{more.length > 0 && adding === game && (
@@ -191,8 +230,8 @@ export function EditPanel({
<li key={event.id}>
<button
type="button"
onClick={() => add(event)}
aria-label={`Add ${event.title} to dailies`}
onClick={() => ("kind" in event ? editor.onTrack(event, true) : add(event))}
aria-label={`Track ${event.title}`}
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">
@@ -214,9 +253,9 @@ export function EditPanel({
);
})}
{addable.length === 0 && (
{addable.length === 0 && untracked.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
Everything running is tracked. An event that hasn't started joins the list to track once
it opens.
</p>
)}
+25 -3
View File
@@ -71,6 +71,8 @@ export function YourOwn({
onRemoveTask,
routines = [],
onMakeItMine,
routinesHidden,
onTrackRoutine,
}: {
games: CustomGames;
events: CustomEvents;
@@ -92,9 +94,15 @@ export function YourOwn({
onAddTask?: ((draft: TaskDraft) => void) | undefined;
onEditTask?: ((id: string, draft: TaskDraft) => void) | undefined;
onRemoveTask?: ((id: string) => void) | undefined;
/** Built-in weekly tasks the reader has on, and how to copy one into their own. */
/** Built-in tasks of the games the reader has on, and how to copy one into their own. */
routines?: readonly Routine[] | undefined;
onMakeItMine?: ((routine: Routine) => void) | undefined;
/**
* The ones stopped one by one. Still listed: this is where a reader finds one
* again, and a built-in absent from here would be off with no way back on.
*/
routinesHidden?: ReadonlySet<string> | undefined;
onTrackRoutine?: ((routine: Routine, track: boolean) => void) | undefined;
}) {
const gameMeta = useGameMeta();
const [adding, setAdding] = useState<"game" | "event" | "task" | null>(null);
@@ -326,7 +334,9 @@ export function YourOwn({
<>
<p className="mt-4 text-xs text-faint">Built-in weekly tasks</p>
<ul className="mt-1.5 flex flex-col gap-1">
{routines.map((routine) => (
{routines.map((routine) => {
const tracked = !(routinesHidden?.has(routine.id) ?? false);
return (
<li key={routine.id}>
<div className="flex items-baseline gap-2">
<span className="shrink-0 text-xs text-faint">{gameMeta(routine.game).short}</span>
@@ -346,13 +356,25 @@ export function YourOwn({
>
Make it mine
</button>
{onTrackRoutine !== undefined && (
<button
type="button"
onClick={() => onTrackRoutine(routine, !tracked)}
aria-label={`${tracked ? "Stop tracking" : "Track"} ${routine.title}`}
className="shrink-0 text-xs text-faint transition-colors hover:text-ink"
>
{tracked ? "Stop tracking" : "Track"}
</button>
)}
</div>
<p className="text-xs text-faint">
{!tracked && "Not tracked · "}
{taskCaption(routine)}
{routine.note !== undefined && ` · ${routine.note}`}
</p>
</li>
))}
);
})}
</ul>
</>
)}