feat(tasks): a task can repeat every N days from a dated start

A third period, { unit: "cycle", days, from }, cut in day-key space like
weeks and months, so each game's per-server reset carries over. Tasks on
it are tallied and switched apart (showCycleTasks, on by default), show
the days left in their chip, and the lower row's heading now names the
periods it holds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-16 05:25:14 +02:00
1 parent 42e3325a1f
commit a80f581d8b
17 files changed
+286 -45

No files matched your search

+4
View File
@@ -504,16 +504,19 @@ export function App() {
chores: prefs.showChores,
weekly: prefs.showWeeklyTasks,
monthly: prefs.showMonthlyTasks,
cycle: prefs.showCycleTasks,
},
hasTasks: {
weekly: stripTasks.some((t) => t.period.unit === "week"),
monthly: stripTasks.some((t) => t.period.unit === "month"),
cycle: stripTasks.some((t) => t.period.unit === "cycle"),
},
onShown: (patch) =>
update({
...(patch.chores !== undefined ? { showChores: patch.chores } : {}),
...(patch.weekly !== undefined ? { showWeeklyTasks: patch.weekly } : {}),
...(patch.monthly !== undefined ? { showMonthlyTasks: patch.monthly } : {}),
...(patch.cycle !== undefined ? { showCycleTasks: patch.cycle } : {}),
}),
standing: (focus === null ? enabled : [focus]).flatMap((game): StripStanding[] => {
if (isCustomGameId(game)) return [];
@@ -749,6 +752,7 @@ export function App() {
tasks={tasksShown(stripTasks, {
weekly: prefs.showWeeklyTasks,
monthly: prefs.showMonthlyTasks,
cycle: prefs.showCycleTasks,
})}
edit={stripEditor}
taskStatusFor={taskStatusOf}
+13 -1
View File
@@ -114,7 +114,11 @@ export function Controls({
.filter((g) => shownFor(routines, g))
.flatMap((g) => routinesFor(g).filter((r) => offered(r, standardPulls)).map((r) => r.period.unit)),
];
const taskPeriods = { week: periods.includes("week"), month: periods.includes("month") };
const taskPeriods = {
week: periods.includes("week"),
month: periods.includes("month"),
cycle: periods.includes("cycle"),
};
return (
<section
@@ -344,6 +348,14 @@ export function Controls({
hint="Off hides them from the strip and its counts; your ticks and numbers are kept."
/>
)}
{taskPeriods.cycle && (
<Check
checked={prefs.showCycleTasks}
onChange={(showCycleTasks) => onUpdate({ showCycleTasks })}
label="Show cycle tasks under the dailies"
hint="Tasks that repeat every so many days, like a two-week endgame mode. Off hides them from the strip and its counts; your ticks and numbers are kept."
/>
)}
{ignoredCount > 0 && (
<Check
+51 -16
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { Fragment, useEffect, useRef, useState } from "react";
import {
catchUpDays,
CATCH_UP_DAYS,
@@ -67,7 +67,7 @@ export interface DailyItem {
* whose goal is a number has no "today" — nothing ticks — so it counts with
* its week, or, over its whole run, in no tally at all.
*/
cadence: "day" | "week" | "month" | "event";
cadence: "day" | "week" | "month" | "cycle" | "event";
/** A task's period, for finding the one before it in Catch up. */
period?: Period | undefined;
/** A task's unit, printed after its target. */
@@ -216,9 +216,10 @@ export function dailyGroups(
*/
export function tasksShown<T extends Pick<StripTask, "period">>(
tasks: readonly T[],
shown: { weekly: boolean; monthly: boolean },
shown: { weekly: boolean; monthly: boolean; cycle: boolean },
): T[] {
return tasks.filter((t) => (t.period.unit === "week" ? shown.weekly : shown.monthly));
const on = { week: shown.weekly, month: shown.monthly, cycle: shown.cycle } as const;
return tasks.filter((t) => on[t.period.unit]);
}
/**
@@ -231,7 +232,8 @@ export function splitByCadence(items: readonly DailyItem[]): {
today: DailyItem[];
longer: DailyItem[];
} {
const longer = (i: DailyItem) => i.cadence === "week" || i.cadence === "month";
const longer = (i: DailyItem) =>
i.cadence === "week" || i.cadence === "month" || i.cadence === "cycle";
return { today: items.filter((i) => !longer(i)), longer: items.filter(longer) };
}
@@ -240,6 +242,25 @@ interface PeriodTally {
complete: number;
}
type Longer = "week" | "month" | "cycle";
const LONGER: readonly Longer[] = ["week", "month", "cycle"];
/** "week", "week or month", "week, month or cycle": the periods present, in order. */
function periodList(present: ReadonlySet<Longer>, conjunction: "&" | "or" | "and"): string {
const names = LONGER.filter((c) => present.has(c));
const last = names.at(-1) ?? "";
const head = names.slice(0, -1).join(", ");
return head === "" ? last : `${head} ${conjunction} ${last}`;
}
/**
* The lower row's heading: the periods it holds, in order — "This week",
* "This week & month", "This week, month & cycle".
*/
export function longerHeading(present: ReadonlySet<Longer>): string {
return present.size === 0 ? "" : `This ${periodList(present, "&")}`;
}
/**
* How much of the strip is finished. A daily item is complete when ticked today
* or when its goal is already met — nothing is ticked on its behalf.
@@ -256,17 +277,19 @@ export function dailyTally(
tickedToday: number;
week: PeriodTally;
month: PeriodTally;
cycle: PeriodTally;
} {
let total = 0;
let complete = 0;
let tickedToday = 0;
const week = { total: 0, complete: 0 };
const month = { total: 0, complete: 0 };
const cycle = { total: 0, complete: 0 };
for (const i of items) {
const met = i.goal?.met === true;
if (i.cadence === "event") continue;
if (i.cadence !== "day") {
const tally = i.cadence === "week" ? week : month;
const tally = i.cadence === "week" ? week : i.cadence === "month" ? month : cycle;
tally.total += 1;
if (met) tally.complete += 1;
continue;
@@ -276,7 +299,7 @@ export function dailyTally(
if (ticked) tickedToday += 1;
if (ticked || met) complete += 1;
}
return { total, complete, tickedToday, week, month };
return { total, complete, tickedToday, week, month, cycle };
}
/** The chip's accessible name: today's tick and the goal are separate facts. */
@@ -377,7 +400,7 @@ export function Dailies({
taskStatusFor,
);
const items = groups.flatMap((group) => group.items);
const { total, complete, tickedToday, week, month } = dailyTally(items, daysFor);
const { total, complete, tickedToday, week, month, cycle } = dailyTally(items, daysFor);
const allDone = total > 0 && complete === total;
const [burst, setBurst] = useState(0);
@@ -476,6 +499,12 @@ export function Dailies({
const open = items.find((i) => i.key === editing && i.goal?.measure === "amount");
const { today, longer } = splitByCadence(items);
const tallies = { week, month, cycle };
const present = new Set(LONGER.filter((c) => tallies[c].total > 0));
const periodCounts = (
[["Weekly", week], ["Monthly", month], ["Cycles", cycle]] as const
).filter(([, t]) => t.total > 0);
const longerOpen = LONGER.reduce((n, c) => n + tallies[c].total - tallies[c].complete, 0);
const chip = (item: DailyItem) => (
<li key={item.key}>
{item.goal?.measure === "amount" ? (
@@ -513,11 +542,14 @@ export function Dailies({
{total > 0 ? <>Today's dailies · {complete}/{total}</> : "Your tasks"}
{/* A line of their own: in the rail, three counts on one line wrapped
the heading and pushed the reset countdown onto two. */}
{(week.total > 0 || month.total > 0) && (
{periodCounts.length > 0 && (
<span className="mt-0.5 block text-faint">
{week.total > 0 && <>Weekly {week.complete}/{week.total}</>}
{week.total > 0 && month.total > 0 && " · "}
{month.total > 0 && <>Monthly {month.complete}/{month.total}</>}
{periodCounts.map(([name, t], n) => (
<Fragment key={name}>
{n > 0 && " · "}
{name} {t.complete}/{t.total}
</Fragment>
))}
</span>
)}
</h2>
@@ -554,7 +586,7 @@ export function Dailies({
{longer.length > 0 && (
<>
<p className="relative mt-3 text-[0.6875rem] font-medium text-faint">
This week &amp; month
{longerHeading(new Set(longer.map((i) => i.cadence as Longer)))}
</p>
<ul className="relative mt-1.5 flex flex-wrap gap-1.5">
{longer.map(chip)}
@@ -589,9 +621,12 @@ export function Dailies({
: 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
? "All done for this week and month."
: `${waiting(week.total + month.total - week.complete - month.complete)} still open this week or month.`
? longerOpen === 0
? // An event's whole-run number is in no period; nothing to name.
present.size === 0
? "All done for this week and month."
: `All done for this ${periodList(present, "and")}.`
: `${waiting(longerOpen)} still open this ${periodList(present, "or")}.`
: allDone
? "All done. Nothing else expires tonight."
: `${waiting(total - complete)} still waiting on you today.`}
+10 -2
View File
@@ -54,6 +54,7 @@ export interface StripShown {
chores: boolean;
weekly: boolean;
monthly: boolean;
cycle: boolean;
}
/** Everything the strip needs to be edited in place. Built by App. */
@@ -68,8 +69,8 @@ export interface StripEditor {
/** 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 };
/** Whether there is any task of each period to switch: a switch with nothing behind it is not offered. */
hasTasks: { weekly: boolean; monthly: boolean; cycle: 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[];
@@ -282,6 +283,13 @@ export function EditPanel({
onChange={(monthly) => editor.onShown({ monthly })}
/>
)}
{hasTasks.cycle && (
<Switch
label="Cycle tasks"
checked={shown.cycle}
onChange={(cycle) => editor.onShown({ cycle })}
/>
)}
</div>
</div>
);
+1 -1
View File
@@ -122,7 +122,7 @@ export function TaskForm({
const [f, setF] = useState<TaskFields>(() => ({
game: initial?.game ?? lanes[0] ?? "",
title: initial?.title ?? "",
unit: initial?.period.unit ?? "week",
unit: initial?.period.unit === "month" ? "month" : "week",
weekday: initial?.period.unit === "week" ? initial.period.startsOn : 1,
monthDay: String(initial?.period.unit === "month" ? initial.period.startsOn : 1),
kind: initial?.measure.kind ?? "days",
+4
View File
@@ -189,6 +189,8 @@ export interface Prefs {
*/
showWeeklyTasks: boolean;
showMonthlyTasks: boolean;
/** The same for tasks that repeat every N days (`period.ts`), on by default likewise. */
showCycleTasks: boolean;
showCompleted: boolean;
/** Reveal events the reader has ignored, so they can be restored. */
showIgnored: boolean;
@@ -317,6 +319,7 @@ export function defaults(): Prefs {
showChores: true,
showWeeklyTasks: true,
showMonthlyTasks: true,
showCycleTasks: true,
showCompleted: true,
showIgnored: false,
theme: DEFAULT_THEME_CHOICE,
@@ -490,6 +493,7 @@ export function restorePrefsValue(incoming: unknown): Prefs {
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.showCycleTasks = typeof result.showCycleTasks === "boolean" ? result.showCycleTasks : base.showCycleTasks;
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;