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:
co-authored by
Claude Opus 5.5
parent
42e3325a1f
commit
a80f581d8b
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 & 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.`}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
+5
-3
@@ -72,8 +72,8 @@ export function readGoal(stored: unknown): Goal | undefined {
|
||||
}
|
||||
|
||||
export interface GoalStatus {
|
||||
/** Month is a task's period (task.ts); an event's goal is a week or the event. */
|
||||
scope: "week" | "month" | "event";
|
||||
/** Month and cycle are a task's period (task.ts); an event's goal is a week or the event. */
|
||||
scope: "week" | "month" | "cycle" | "event";
|
||||
measure: "days" | "amount";
|
||||
/** The period's first game-day: the key an amount is stored under. */
|
||||
periodStart: string;
|
||||
@@ -172,7 +172,7 @@ export function goalStatus(input: {
|
||||
const days = (n: number) => (n === 1 ? "1 day" : `${n} days`);
|
||||
/** Thousands grouped: 2,100 reads as a number of points, 2100 as a year. */
|
||||
const num = (n: number) => n.toLocaleString("en-US");
|
||||
/** " this week", " this month", or nothing for a whole event. */
|
||||
/** " this week", " this month", " this cycle", or nothing for a whole event. */
|
||||
const within = (s: GoalStatus) => (s.scope === "event" ? "" : ` this ${s.scope}`);
|
||||
const doneLabel = (s: GoalStatus) => (s.scope === "event" ? "goal reached" : `done this ${s.scope}`);
|
||||
|
||||
@@ -206,6 +206,8 @@ export function goalSummary(goal: Goal | undefined): string {
|
||||
|
||||
/** The Dailies chip's short count. */
|
||||
export function goalCount(s: GoalStatus): string {
|
||||
// "cy" means nothing; the days left in a cycle say where it is.
|
||||
if (s.scope === "cycle") return `${num(s.count)}/${num(s.required)} · ${s.open ?? 0}d`;
|
||||
const unit = s.scope === "week" ? " wk" : s.scope === "month" ? " mo" : "";
|
||||
return `${num(s.count)}/${num(s.required)}${unit}`;
|
||||
}
|
||||
|
||||
+14
-1
@@ -2,7 +2,8 @@ import { z } from "zod";
|
||||
import { DAY } from "./time.ts";
|
||||
|
||||
/**
|
||||
* Periods longer than a day — a week, a month — in day-key space.
|
||||
* Periods longer than a day — a week, a month, a cycle of N days — in day-key
|
||||
* space.
|
||||
*
|
||||
* Every key here is a `YYYY-MM-DD` game-day that `dayKey` already cut on a
|
||||
* game's own reset clock for the reader's region. Working on keys rather than
|
||||
@@ -16,10 +17,18 @@ import { DAY } from "./time.ts";
|
||||
/** ISO weekday: Monday is 1, Sunday is 7. */
|
||||
export const WeekdaySchema = z.number().int().min(1).max(7);
|
||||
|
||||
const DayKey = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
|
||||
|
||||
export const PeriodSchema = z.union([
|
||||
z.object({ unit: z.literal("week"), startsOn: WeekdaySchema }),
|
||||
/** Day of month; 31 means "the last day" in a shorter month. */
|
||||
z.object({ unit: z.literal("month"), startsOn: z.number().int().min(1).max(31) }),
|
||||
/**
|
||||
* Every `days` game-days, counted from `from`, a game-day on which a cycle
|
||||
* began — any one, not necessarily the first: cycles run both ways from it,
|
||||
* so an anchor read off this cycle's timer stays right for the ones before.
|
||||
*/
|
||||
z.object({ unit: z.literal("cycle"), days: z.number().int().min(2).max(366), from: DayKey }),
|
||||
]);
|
||||
export type Period = z.infer<typeof PeriodSchema>;
|
||||
|
||||
@@ -56,6 +65,10 @@ export function periodOf(
|
||||
if (period.unit === "week") {
|
||||
start = addDays(today, -((isoWeekday(today) - period.startsOn + 7) % 7));
|
||||
next = addDays(start, 7);
|
||||
} else if (period.unit === "cycle") {
|
||||
const since = Math.round((msOf(today) - msOf(period.from)) / DAY);
|
||||
start = addDays(period.from, Math.floor(since / period.days) * period.days);
|
||||
next = addDays(start, period.days);
|
||||
} else {
|
||||
const t = new Date(msOf(today));
|
||||
const y = t.getUTCFullYear();
|
||||
|
||||
@@ -4,6 +4,8 @@ import { GameId } from "./schema.ts";
|
||||
import { shownFor, type PerGame } from "./perGame.ts";
|
||||
import { MeasureSchema, type TaskShape } from "./task.ts";
|
||||
|
||||
export { asOfLabel } from "./task.ts";
|
||||
|
||||
/**
|
||||
* Weekly and monthly tasks the app ships for a game — the built-in counterpart of a
|
||||
* reader's own `mytask:` (PRD F17).
|
||||
@@ -549,14 +551,6 @@ export function routineToAsk(
|
||||
return null;
|
||||
}
|
||||
|
||||
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||
|
||||
/** `2025-12-02` → `2 Dec 2025`: the same in every locale, so it reads the same wherever it is quoted. */
|
||||
export function asOfLabel(day: string): string {
|
||||
const [y, m, d] = day.split("-").map(Number);
|
||||
return `${d} ${MONTHS[(m ?? 1) - 1]} ${y}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A reader's own task copied from a built-in, remembering where it came from.
|
||||
* The copy is theirs to edit; the original stays hidden while it exists.
|
||||
|
||||
+12
-2
@@ -6,7 +6,7 @@ import { periodOf, PeriodSchema } from "./period.ts";
|
||||
import type { Region } from "./schema.ts";
|
||||
|
||||
/**
|
||||
* Weekly and monthly chores the reader sets up per game — ZZZ's weekly
|
||||
* Weekly, monthly and every-N-days chores the reader sets up per game — ZZZ's weekly
|
||||
* activity points, Endfield's weekly routine.
|
||||
*
|
||||
* Not events: no window, no end, no done or ignored. A task is a period that
|
||||
@@ -110,12 +110,22 @@ function ordinal(n: number): string {
|
||||
return `${n}${["th", "st", "nd", "rd"][n % 10] ?? "th"}`;
|
||||
}
|
||||
|
||||
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||
|
||||
/** `2025-12-02` → `2 Dec 2025`: the same in every locale, so it reads the same wherever it is quoted. */
|
||||
export function asOfLabel(day: string): string {
|
||||
const [y, m, d] = day.split("-").map(Number);
|
||||
return `${d} ${MONTHS[(m ?? 1) - 1]} ${y}`;
|
||||
}
|
||||
|
||||
/** One line saying when it rolls and what it wants. */
|
||||
export function taskCaption(task: Pick<Task, "period" | "measure">): string {
|
||||
const when =
|
||||
task.period.unit === "week"
|
||||
? `every week from ${WEEKDAYS[task.period.startsOn - 1]}`
|
||||
: `every month from the ${ordinal(task.period.startsOn)}`;
|
||||
: task.period.unit === "month"
|
||||
? `every month from the ${ordinal(task.period.startsOn)}`
|
||||
: `every ${task.period.days} days from ${asOfLabel(task.period.from)}`;
|
||||
const m = task.measure;
|
||||
const what =
|
||||
m.kind === "amount"
|
||||
|
||||
+24
-1
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { Task } from "../src/shared/task.ts";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { Controls, ownState, PerGameChips } from "../src/client/components/Controls.tsx";
|
||||
import { GameMetaProvider } from "../src/client/state/gameMeta.tsx";
|
||||
@@ -33,6 +34,7 @@ const PREFS: Prefs = {
|
||||
showChores: true,
|
||||
showWeeklyTasks: true,
|
||||
showMonthlyTasks: true,
|
||||
showCycleTasks: true,
|
||||
showCompleted: true,
|
||||
showIgnored: false,
|
||||
theme: "dark",
|
||||
@@ -40,7 +42,12 @@ const PREFS: Prefs = {
|
||||
onboarded: true,
|
||||
};
|
||||
|
||||
function render(prefs: Prefs, ignoredCount = 0, games = ["genshin", "hsr"]): string {
|
||||
function render(
|
||||
prefs: Prefs,
|
||||
ignoredCount = 0,
|
||||
games = ["genshin", "hsr"],
|
||||
tasks?: Task[],
|
||||
): string {
|
||||
return renderToStaticMarkup(
|
||||
<GameMetaProvider value={(id) => metaFor(id, {})}>
|
||||
<Controls
|
||||
@@ -63,6 +70,7 @@ function render(prefs: Prefs, ignoredCount = 0, games = ["genshin", "hsr"]): str
|
||||
onEditGame: () => {},
|
||||
onRemoveGame: () => ({ removed: true, blockedBy: 0 }),
|
||||
onAddEvent: () => {},
|
||||
...(tasks === undefined ? {} : { tasks }),
|
||||
}}
|
||||
/>
|
||||
</GameMetaProvider>,
|
||||
@@ -415,9 +423,24 @@ describe("Controls: weekly and monthly tasks under the dailies", () => {
|
||||
expect(checkboxes(off).filter(Boolean).length).toBe(checkboxes(on).filter(Boolean).length - 1);
|
||||
});
|
||||
|
||||
test("a cycle task brings its own switch, which reads showCycleTasks", () => {
|
||||
const AT = "2026-10-01T00:00:00.000Z";
|
||||
const shiyu: Task = {
|
||||
id: "mytask:dddddd4444", game: "hsr", title: "Shiyu Defense",
|
||||
period: { unit: "cycle", days: 14, from: "2026-10-16" }, measure: { kind: "days", required: 1 },
|
||||
at: AT, updatedAt: AT,
|
||||
};
|
||||
const on = render(PREFS, 0, undefined, [shiyu]);
|
||||
expect(on).toContain("Show cycle tasks");
|
||||
expect(on).not.toContain("Show weekly tasks");
|
||||
const off = render({ ...PREFS, showCycleTasks: false }, 0, undefined, [shiyu]);
|
||||
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");
|
||||
expect(html).not.toContain("Show cycle tasks");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -74,8 +74,8 @@ function editor(over: Partial<StripEditor> = {}): StripEditor {
|
||||
onGoal: () => {},
|
||||
onDaily: () => {},
|
||||
onOpen: () => {},
|
||||
shown: { chores: true, weekly: true, monthly: true },
|
||||
hasTasks: { weekly: true, monthly: false },
|
||||
shown: { chores: true, weekly: true, monthly: true, cycle: true },
|
||||
hasTasks: { weekly: true, monthly: false, cycle: false },
|
||||
onShown: () => {},
|
||||
standing: [],
|
||||
onTrack: () => {},
|
||||
@@ -135,10 +135,11 @@ describe("tasksShown", () => {
|
||||
|
||||
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([]);
|
||||
const on = { cycle: true };
|
||||
expect(tasksShown(all, { weekly: true, monthly: true, ...on })).toEqual(all);
|
||||
expect(tasksShown(all, { weekly: false, monthly: true, ...on })).toEqual([monthly]);
|
||||
expect(tasksShown(all, { weekly: true, monthly: false, ...on })).toEqual([weekly]);
|
||||
expect(tasksShown(all, { weekly: false, monthly: false, ...on })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -205,6 +206,15 @@ describe("EditPanel", () => {
|
||||
expect(html).toContain("Daily chores");
|
||||
expect(html).toContain("Weekly tasks");
|
||||
expect(html).not.toContain("Monthly tasks");
|
||||
expect(html).not.toContain("Cycle tasks");
|
||||
});
|
||||
|
||||
test("a cycle task brings the cycle switch", () => {
|
||||
const html = render(
|
||||
<EditPanel games={["genshin"]} editor={editor({ hasTasks: { weekly: false, monthly: false, cycle: true } })} />,
|
||||
);
|
||||
expect(html).toContain("Cycle tasks");
|
||||
expect(html).not.toContain("Weekly tasks");
|
||||
});
|
||||
|
||||
test("with nothing to add, it says where events come from", () => {
|
||||
@@ -285,7 +295,8 @@ describe("the strip with an editor", () => {
|
||||
daysFor={() => []} onToggleDay={() => {}} tasks={[weekly]}
|
||||
taskStatusFor={() => status} onSetAmount={() => {}} amountFor={() => undefined} />,
|
||||
);
|
||||
const heading = html.indexOf("This week & month");
|
||||
// The heading names the periods the row holds: only a week here.
|
||||
const heading = html.indexOf(">This week<");
|
||||
expect(heading).toBeGreaterThan(-1);
|
||||
expect(html.indexOf("ZZZ")).toBeLessThan(heading);
|
||||
expect(html.indexOf("Activity points")).toBeGreaterThan(heading);
|
||||
@@ -296,7 +307,7 @@ describe("the strip with an editor", () => {
|
||||
<Dailies games={["zzz"]} events={[]} region="europe" now={NOW} showChores
|
||||
daysFor={() => []} onToggleDay={() => {}} />,
|
||||
);
|
||||
expect(html).not.toContain("This week & month");
|
||||
expect(html).not.toContain(">This week");
|
||||
});
|
||||
|
||||
test("an empty strip still offers a way to add an event", () => {
|
||||
@@ -311,7 +322,7 @@ describe("the strip with an editor", () => {
|
||||
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 } })} />,
|
||||
edit={editor({ inStrip: [], addable: [], shown: { chores: false, weekly: true, monthly: true, cycle: true } })} />,
|
||||
);
|
||||
expect(html).toBe("");
|
||||
});
|
||||
|
||||
@@ -279,3 +279,15 @@ describe("copy for amounts and months", () => {
|
||||
expect(goalRequired({ period: "event", measure: "amount", target: 2100 })).toBe(2100);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a cycle's labels", () => {
|
||||
test("it reads 'this cycle', and its short count is the days left", () => {
|
||||
const s: GoalStatus = {
|
||||
scope: "cycle", measure: "days", periodStart: "2026-10-16", required: 1, count: 0,
|
||||
met: false, needed: 1, open: 9, shortfall: false,
|
||||
};
|
||||
expect(goalCount(s)).toBe("0/1 · 9d");
|
||||
expect(goalLabel(s)).toBe("0 of 1 this cycle");
|
||||
expect(goalLabel({ ...s, count: 1, met: true, needed: 0 })).toBe("done this cycle");
|
||||
});
|
||||
});
|
||||
|
||||
+34
-1
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { dayKey, nextResetMs } from "../src/shared/daily.ts";
|
||||
import { addDays, isoWeekday, periodOf } from "../src/shared/period.ts";
|
||||
import { addDays, isoWeekday, periodOf, PeriodSchema } from "../src/shared/period.ts";
|
||||
|
||||
describe("isoWeekday", () => {
|
||||
test("Monday is 1 and Sunday is 7", () => {
|
||||
@@ -92,3 +92,36 @@ describe("periodOf: months", () => {
|
||||
expect(periodOf(month(30), "2027-02-28").start).toBe("2027-02-28");
|
||||
});
|
||||
});
|
||||
|
||||
describe("periodOf: cycles", () => {
|
||||
const cycle = (days: number, from: string) => ({ unit: "cycle" as const, days, from });
|
||||
|
||||
test("the anchor starts its own cycle, which holds `days` keys", () => {
|
||||
const p = periodOf(cycle(14, "2026-10-16"), "2026-10-16");
|
||||
expect(p.start).toBe("2026-10-16");
|
||||
expect(p.days.length).toBe(14);
|
||||
expect(p.days.at(-1)).toBe("2026-10-29");
|
||||
});
|
||||
|
||||
test("the last day is in it; the day after starts the next", () => {
|
||||
expect(periodOf(cycle(14, "2026-10-16"), "2026-10-29").start).toBe("2026-10-16");
|
||||
expect(periodOf(cycle(14, "2026-10-16"), "2026-10-30").start).toBe("2026-10-30");
|
||||
});
|
||||
|
||||
test("a day before the anchor is in the cycle before it, not the anchor's", () => {
|
||||
expect(periodOf(cycle(42, "2026-11-16"), "2026-10-16").start).toBe("2026-10-05");
|
||||
expect(periodOf(cycle(14, "2026-10-16"), "2026-10-15").start).toBe("2026-10-02");
|
||||
});
|
||||
|
||||
test("lengths 2 and 366", () => {
|
||||
expect(periodOf(cycle(2, "2026-01-01"), "2026-01-04").days).toEqual(["2026-01-03", "2026-01-04"]);
|
||||
expect(periodOf(cycle(366, "2026-01-01"), "2027-01-01").days.length).toBe(366);
|
||||
});
|
||||
|
||||
test("the schema bounds", () => {
|
||||
expect(PeriodSchema.safeParse(cycle(14, "2026-10-16")).success).toBe(true);
|
||||
expect(PeriodSchema.safeParse(cycle(1, "2026-01-01")).success).toBe(false);
|
||||
expect(PeriodSchema.safeParse(cycle(367, "2026-01-01")).success).toBe(false);
|
||||
expect(PeriodSchema.safeParse(cycle(14, "16 Oct")).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -139,6 +139,14 @@ describe("weekly and monthly tasks under the dailies", () => {
|
||||
expect(restored.showWeeklyTasks).toBe(true);
|
||||
expect(restored.showMonthlyTasks).toBe(false);
|
||||
});
|
||||
|
||||
test("cycle tasks are shown when the pref is absent or unreadable", () => {
|
||||
expect(defaults().showCycleTasks).toBe(true);
|
||||
expect({ ...defaults(), ...{ region: "europe" } }.showCycleTasks).toBe(true);
|
||||
expect(restorePrefsValue({}).showCycleTasks).toBe(true);
|
||||
expect(restorePrefsValue({ showCycleTasks: "no" }).showCycleTasks).toBe(true);
|
||||
expect(restorePrefsValue({ showCycleTasks: false }).showCycleTasks).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("restorePrefsValue", () => {
|
||||
@@ -165,6 +173,7 @@ describe("restorePrefsValue", () => {
|
||||
showChores: false,
|
||||
showWeeklyTasks: false,
|
||||
showMonthlyTasks: true,
|
||||
showCycleTasks: false,
|
||||
showCompleted: false,
|
||||
showIgnored: true,
|
||||
theme: "light" as const,
|
||||
|
||||
@@ -124,3 +124,27 @@ describe("taskCaption", () => {
|
||||
).toBe("every month from the 22nd · 3 days");
|
||||
});
|
||||
});
|
||||
|
||||
describe("a task on a cycle", () => {
|
||||
test("its status is by its cycle, and its caption names the start", () => {
|
||||
const t = task({
|
||||
period: { unit: "cycle", days: 14, from: "2026-10-16" },
|
||||
measure: { kind: "days", required: 5 },
|
||||
});
|
||||
expect(TaskSchema.safeParse(t).success).toBe(true);
|
||||
expect(taskCaption(t)).toBe("every 14 days from 16 Oct 2026 · 5 days");
|
||||
const s = taskStatus({
|
||||
task: t, region: "europe", now: noon("2026-10-20"),
|
||||
logged: ["2026-10-15", "2026-10-16", "2026-10-18"],
|
||||
});
|
||||
expect(s).toMatchObject({ scope: "cycle", periodStart: "2026-10-16", count: 2, required: 5, open: 10 });
|
||||
});
|
||||
|
||||
test("a short cycle never asks for more days than it holds", () => {
|
||||
const t = task({
|
||||
period: { unit: "cycle", days: 3, from: "2026-10-16" },
|
||||
measure: { kind: "days", required: 5 },
|
||||
});
|
||||
expect(taskStatus({ task: t, region: "europe", now: noon("2026-10-16"), logged: [] }).required).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
Dailies,
|
||||
dailyGroups,
|
||||
dailyTally,
|
||||
longerHeading,
|
||||
tasksShown,
|
||||
} from "../src/client/components/Dailies.tsx";
|
||||
import type { GoalStatus } from "../src/shared/goal.ts";
|
||||
import { Welcome } from "../src/client/components/Welcome.tsx";
|
||||
@@ -1387,6 +1389,7 @@ describe("weekly and monthly tasks in the dailies strip", () => {
|
||||
expect(dailyTally(group!.items, () => [])).toEqual({
|
||||
total: 1, complete: 0, tickedToday: 0,
|
||||
week: { total: 1, complete: 0 }, month: { total: 1, complete: 1 },
|
||||
cycle: { total: 0, complete: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1418,6 +1421,50 @@ describe("weekly and monthly tasks in the dailies strip", () => {
|
||||
expect(chipLabel("X", false, amountStatus)).toBe("X, 1,900 of 2,100 this week");
|
||||
expect(chipLabel("X", false, monthStatus)).toBe("X, done this month");
|
||||
});
|
||||
|
||||
const shiyu: Task = {
|
||||
id: "mytask:dddddd4444", game: "zzz", title: "Shiyu Defense",
|
||||
period: { unit: "cycle", days: 14, from: "2026-10-16" }, measure: { kind: "days", required: 1 },
|
||||
at: AT, updatedAt: AT,
|
||||
};
|
||||
const cycleStatus: GoalStatus = {
|
||||
scope: "cycle", measure: "days", periodStart: "2026-10-02", required: 1, count: 0,
|
||||
met: false, needed: 1, open: 1, shortfall: false,
|
||||
};
|
||||
const anyStatus = (t: Pick<Task, "period" | "measure">) =>
|
||||
t.period.unit === "cycle" ? cycleStatus : statusOf(t);
|
||||
|
||||
test("the lower row names the periods it holds", () => {
|
||||
expect(longerHeading(new Set(["week"]))).toBe("This week");
|
||||
expect(longerHeading(new Set(["week", "month"]))).toBe("This week & month");
|
||||
expect(longerHeading(new Set(["week", "month", "cycle"]))).toBe("This week, month & cycle");
|
||||
expect(longerHeading(new Set(["cycle"]))).toBe("This cycle");
|
||||
});
|
||||
|
||||
test("cycle tasks sit in the lower row, are tallied apart and switched apart", () => {
|
||||
const [group] = dailyGroups(["zzz"], [], NOW, "europe", meta, startOf, true, undefined,
|
||||
[points, shiyu], anyStatus);
|
||||
expect(group!.items.map((i) => i.cadence)).toEqual(["day", "week", "cycle"]);
|
||||
expect(dailyTally(group!.items, () => [])).toMatchObject({
|
||||
week: { total: 1, complete: 0 }, cycle: { total: 1, complete: 0 },
|
||||
});
|
||||
expect(tasksShown([points, monthly, shiyu], { weekly: true, monthly: true, cycle: false }))
|
||||
.toEqual([points, monthly]);
|
||||
expect(tasksShown([points, monthly, shiyu], { weekly: false, monthly: false, cycle: true }))
|
||||
.toEqual([shiyu]);
|
||||
});
|
||||
|
||||
test("a cycle chip shows its days left, under a heading that names it", () => {
|
||||
const html = render(
|
||||
<Dailies games={["zzz"]} events={[]} region="europe" now={NOW} showChores={false}
|
||||
daysFor={() => []} onToggleDay={() => {}} tasks={[points, shiyu]}
|
||||
taskStatusFor={anyStatus} onSetAmount={() => {}} amountFor={() => undefined} />,
|
||||
);
|
||||
expect(html).toContain("0/1 · 1d");
|
||||
expect(html).toContain("This week & cycle");
|
||||
expect(html).toMatch(/Cycles (<!-- -->)?0(<!-- -->)?\/(<!-- -->)?1/);
|
||||
expect(html).toContain("still open this week or cycle.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AmountEditor", () => {
|
||||
|
||||
Reference in New Issue
Block a user