feat(dailies): put weekly and monthly tasks beside a game's dailies
Tasks sit at the end of their game's group and are counted by their own period, so a chore due Sunday is not "waiting on you today" every morning. A number chip opens an editor under the strip instead of ticking, since a chip is one target. Catch up offers last period's number the way it offers yesterday's tick. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
e97e8b002f
commit
595ae55f4a
@@ -34,6 +34,7 @@ import {
|
||||
import { clockFor, formatRemaining } from "../shared/time.ts";
|
||||
import { dailySummary, isDaily, resolveDaily } from "../shared/daily.ts";
|
||||
import { goalStatus, readGoal, type GoalStatus } from "../shared/goal.ts";
|
||||
import { taskStatus, type Task } from "../shared/task.ts";
|
||||
import { strandedOccurrences } from "../shared/recurrence.ts";
|
||||
import { orderGames } from "./state/gameOrder.ts";
|
||||
import { GameMetaProvider, type MetaResolver } from "./state/gameMeta.tsx";
|
||||
@@ -187,9 +188,19 @@ export function App() {
|
||||
game: row.event.game,
|
||||
now,
|
||||
logged: daily.daysFor(row.event.id),
|
||||
amounts: amounts.amountsFor(row.event.id),
|
||||
});
|
||||
};
|
||||
|
||||
const taskStatusOf = (task: Task): GoalStatus =>
|
||||
taskStatus({
|
||||
task,
|
||||
region: prefs.region,
|
||||
now,
|
||||
logged: daily.daysFor(task.id),
|
||||
amounts: amounts.amountsFor(task.id),
|
||||
});
|
||||
|
||||
/** Today's state for a repeating event, or undefined if it does not repeat. */
|
||||
const dailyBadge = (row: RowEvent): DailyBadge | undefined => {
|
||||
if (!repeatsDaily(row)) return undefined;
|
||||
@@ -577,6 +588,10 @@ export function App() {
|
||||
const row = todo.find((r) => r.event.id === id);
|
||||
return row === undefined ? undefined : goalStatusOf(row);
|
||||
}}
|
||||
tasks={tasks.tasks}
|
||||
taskStatusFor={taskStatusOf}
|
||||
onSetAmount={amounts.setAmount}
|
||||
amountFor={(key, period) => amounts.amountsFor(key)[period]?.value}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useState } from "react";
|
||||
|
||||
/**
|
||||
* An amount the reader typed, as a whole non-negative number — or null while
|
||||
* the field holds nothing readable yet, which is mid-edit rather than an answer.
|
||||
*
|
||||
* Separators are stripped because people type the number the game shows, and
|
||||
* the game shows `1,900`. Decimals round: no game here counts fractions of an
|
||||
* activity point, and a stray `.6` is a typo, not a claim.
|
||||
*/
|
||||
export function parseAmount(raw: string): number | null {
|
||||
const cleaned = raw.replace(/[\s,_]/g, "");
|
||||
if (cleaned === "") return null;
|
||||
const n = Number(cleaned);
|
||||
if (!Number.isFinite(n) || n < 0) return null;
|
||||
return Math.round(n);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the reader is now against a target: `1,900 / 2,100 points`.
|
||||
*
|
||||
* Sets the value rather than adding to it, because the game shows a running
|
||||
* total and that is the number people have in front of them. Enter saves,
|
||||
* Escape cancels, and an empty or unreadable field disables Save rather than
|
||||
* saving a zero nobody typed.
|
||||
*/
|
||||
export function AmountEditor({
|
||||
label,
|
||||
value,
|
||||
target,
|
||||
unit,
|
||||
onSave,
|
||||
onCancel,
|
||||
autoFocus = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: number | undefined;
|
||||
target: number;
|
||||
unit?: string | undefined;
|
||||
onSave: (value: number) => void;
|
||||
/** Absent where the editor is always open, as in the detail sheet. */
|
||||
onCancel?: (() => void) | undefined;
|
||||
autoFocus?: boolean;
|
||||
}) {
|
||||
const [draft, setDraft] = useState(value === undefined ? "" : String(value));
|
||||
const parsed = parseAmount(draft);
|
||||
|
||||
return (
|
||||
<form
|
||||
className="mt-2 flex flex-wrap items-center gap-2 text-xs text-muted"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (parsed !== null) onSave(parsed);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape" && onCancel !== undefined) onCancel();
|
||||
}}
|
||||
>
|
||||
<label className="flex items-center gap-2">
|
||||
<span>{label}</span>
|
||||
<input
|
||||
inputMode="numeric"
|
||||
autoFocus={autoFocus}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
className="tnum w-20 rounded-lg border border-hairline bg-ground px-2 py-1 text-sm text-ink focus:border-faint focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
<span className="tnum">
|
||||
/ {target.toLocaleString("en-US")}
|
||||
{unit ? ` ${unit}` : ""}
|
||||
</span>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={parsed === null}
|
||||
className="rounded-full border border-near/70 bg-near/15 px-3 py-1 text-xs font-medium text-near disabled:opacity-40"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
{onCancel !== undefined && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="text-xs text-faint transition-colors hover:text-muted"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,9 @@ import { isCustomGameId, type DisplayEvent, type LaneId } from "../../shared/cus
|
||||
import type { GameMeta } from "../../shared/games.ts";
|
||||
import type { GameId, Region } from "../../shared/schema.ts";
|
||||
import { goalCount, goalLabel, type GoalStatus } from "../../shared/goal.ts";
|
||||
import { addDays, periodOf, type Period } from "../../shared/period.ts";
|
||||
import type { Task } from "../../shared/task.ts";
|
||||
import { AmountEditor } from "./AmountEditor.tsx";
|
||||
import { formatRemaining } from "../../shared/time.ts";
|
||||
import { DayPip } from "./DayPip.tsx";
|
||||
import { Fireworks } from "./Fireworks.tsx";
|
||||
@@ -33,9 +36,12 @@ import { Fireworks } from "./Fireworks.tsx";
|
||||
* answerable in ten seconds and expires tonight.
|
||||
*/
|
||||
|
||||
/** One thing to tick: a game's standing chore, or one of its repeating events. */
|
||||
/**
|
||||
* One thing to do: a game's standing chore, one of its repeating events, or a
|
||||
* weekly or monthly task the reader set up.
|
||||
*/
|
||||
export interface DailyItem {
|
||||
/** The day-log key — `dailies:<game>` for a chore, the event id otherwise. */
|
||||
/** The day-log key — `dailies:<game>` for a chore, the event or task id otherwise. */
|
||||
key: string;
|
||||
game: LaneId;
|
||||
label: string;
|
||||
@@ -48,8 +54,20 @@ export interface DailyItem {
|
||||
* which has no start because it is a routine rather than an event.
|
||||
*/
|
||||
notBefore: number | null;
|
||||
/** The event's check-in goal, when the reader set one. Chores have none. */
|
||||
/**
|
||||
* The event's check-in goal or the task's status for its period. Chores have
|
||||
* none. An amount here makes the chip open an editor instead of ticking.
|
||||
*/
|
||||
goal?: GoalStatus | undefined;
|
||||
/**
|
||||
* How often it is due. Chores and events are daily and make up the header's
|
||||
* "today" count; tasks are counted by their own period beside it.
|
||||
*/
|
||||
cadence: "day" | "week" | "month";
|
||||
/** A task's period, for finding the one before it in Catch up. */
|
||||
period?: Period | undefined;
|
||||
/** A task's unit, printed after its target. */
|
||||
unit?: string | undefined;
|
||||
}
|
||||
|
||||
/** A game and everything of its that wants ticking today. */
|
||||
@@ -90,6 +108,8 @@ export function dailyGroups(
|
||||
startOf: (event: DisplayEvent) => number,
|
||||
showChores = true,
|
||||
goalFor: (id: string) => GoalStatus | undefined = () => undefined,
|
||||
tasks: readonly Task[] = [],
|
||||
taskStatusFor: (task: Task) => GoalStatus | undefined = () => undefined,
|
||||
): DailyGroup[] {
|
||||
// A lane can arrive through an event without being in `games` — an event on a
|
||||
// game the reader has since switched off, say — and dropping it here would
|
||||
@@ -123,6 +143,7 @@ export function dailyGroups(
|
||||
today,
|
||||
resetsIn,
|
||||
notBefore: null,
|
||||
cadence: "day",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -138,31 +159,80 @@ export function dailyGroups(
|
||||
resetsIn,
|
||||
notBefore: startOf(event),
|
||||
goal: goalFor(event.id),
|
||||
cadence: "day",
|
||||
});
|
||||
}
|
||||
|
||||
// A game's weekly and monthly tasks come last in its group: they are the
|
||||
// least urgent thing in it. Only for lanes the reader is looking at — unlike
|
||||
// an event, a task of a game they switched off is not something they are in
|
||||
// the middle of, and its ticks and numbers wait untouched.
|
||||
if (games.includes(lane)) {
|
||||
for (const task of tasks) {
|
||||
if (task.game !== lane) continue;
|
||||
items.push({
|
||||
key: task.id,
|
||||
game: lane,
|
||||
label: task.title,
|
||||
title: `${game.name} — ${task.title}`,
|
||||
ariaLabel: `${task.title} (${game.name})`,
|
||||
today,
|
||||
resetsIn,
|
||||
notBefore: null,
|
||||
goal: taskStatusFor(task),
|
||||
cadence: task.period.unit,
|
||||
period: task.period,
|
||||
unit: task.measure.kind === "amount" ? task.measure.unit : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (items.length > 0) groups.push({ game: lane, meta: game, items });
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
interface PeriodTally {
|
||||
total: number;
|
||||
complete: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* How much of the strip is finished. An item is complete when ticked today or
|
||||
* when its goal is already met — nothing is ticked on its behalf. `tickedToday`
|
||||
* is kept apart because only a real tick celebrates.
|
||||
* 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.
|
||||
* `tickedToday` is kept apart because only a real tick celebrates. Weekly and
|
||||
* monthly tasks are tallied by their own period: a task due by Sunday is not
|
||||
* "still waiting on you today" every morning.
|
||||
*/
|
||||
export function dailyTally(
|
||||
items: readonly DailyItem[],
|
||||
daysFor: (id: string) => string[],
|
||||
): { total: number; complete: number; tickedToday: number } {
|
||||
): {
|
||||
total: number;
|
||||
complete: number;
|
||||
tickedToday: number;
|
||||
week: PeriodTally;
|
||||
month: PeriodTally;
|
||||
} {
|
||||
let total = 0;
|
||||
let complete = 0;
|
||||
let tickedToday = 0;
|
||||
const week = { total: 0, complete: 0 };
|
||||
const month = { total: 0, complete: 0 };
|
||||
for (const i of items) {
|
||||
const met = i.goal?.met === true;
|
||||
if (i.cadence !== "day") {
|
||||
const tally = i.cadence === "week" ? week : month;
|
||||
tally.total += 1;
|
||||
if (met) tally.complete += 1;
|
||||
continue;
|
||||
}
|
||||
total += 1;
|
||||
const ticked = daysFor(i.key).includes(i.today);
|
||||
if (ticked) tickedToday += 1;
|
||||
if (ticked || i.goal?.met === true) complete += 1;
|
||||
if (ticked || met) complete += 1;
|
||||
}
|
||||
return { total: items.length, complete, tickedToday };
|
||||
return { total, complete, tickedToday, week, month };
|
||||
}
|
||||
|
||||
/** The chip's accessible name: today's tick and the goal are separate facts. */
|
||||
@@ -171,6 +241,8 @@ export function chipLabel(
|
||||
isDone: boolean,
|
||||
goal: GoalStatus | undefined,
|
||||
): string {
|
||||
// A number has no "today": it is wherever the reader last said it was.
|
||||
if (goal?.measure === "amount") return `${base}, ${goalLabel(goal)}`;
|
||||
if (!isDone && goal?.met === true) return `${base}, ${goalLabel(goal)}`;
|
||||
const today = isDone ? "done today" : "not done today";
|
||||
return goal === undefined ? `${base}, ${today}` : `${base}, ${today}, ${goalLabel(goal)}`;
|
||||
@@ -185,6 +257,10 @@ export function Dailies({
|
||||
onToggleDay,
|
||||
showChores,
|
||||
goalFor,
|
||||
tasks = [],
|
||||
taskStatusFor,
|
||||
onSetAmount,
|
||||
amountFor,
|
||||
}: {
|
||||
/** Every lane the reader is looking at, in their own order. */
|
||||
games: LaneId[];
|
||||
@@ -205,6 +281,13 @@ export function Dailies({
|
||||
* completes its item for the header without ticking anything.
|
||||
*/
|
||||
goalFor?: ((id: string) => GoalStatus | undefined) | undefined;
|
||||
/** The reader's weekly and monthly tasks, all of them; the strip picks its lanes'. */
|
||||
tasks?: readonly Task[] | undefined;
|
||||
taskStatusFor?: ((task: Task) => GoalStatus | undefined) | undefined;
|
||||
/** Record a number for an item's period. */
|
||||
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;
|
||||
}) {
|
||||
const gameMeta = useGameMeta();
|
||||
/**
|
||||
@@ -216,6 +299,8 @@ export function Dailies({
|
||||
* the app should work.
|
||||
*/
|
||||
const [catchUp, setCatchUp] = useState(false);
|
||||
/** Which amount chip's editor is open, by item key. One at a time. */
|
||||
const [editing, setEditing] = useState<string | null>(null);
|
||||
|
||||
// Each game rolls on its own server clock, so "today" is asked per game
|
||||
// rather than once for the section — Endfield's European day can still be
|
||||
@@ -229,9 +314,11 @@ export function Dailies({
|
||||
(event) => Date.parse(event.startsAt),
|
||||
showChores,
|
||||
goalFor,
|
||||
tasks,
|
||||
taskStatusFor,
|
||||
);
|
||||
const items = groups.flatMap((group) => group.items);
|
||||
const { total, complete, tickedToday } = dailyTally(items, daysFor);
|
||||
const { total, complete, tickedToday, week, month } = dailyTally(items, daysFor);
|
||||
const allDone = total > 0 && complete === total;
|
||||
|
||||
const [burst, setBurst] = useState(0);
|
||||
@@ -259,12 +346,13 @@ export function Dailies({
|
||||
return () => clearTimeout(id);
|
||||
}, [burst]);
|
||||
|
||||
if (total === 0) return null;
|
||||
if (items.length === 0) return null;
|
||||
|
||||
// With mixed reset clocks there is no single "resets in", so the header
|
||||
// reports the next one to land and says that it is the next one.
|
||||
const soonest = Math.min(...items.map((i) => i.resetsIn));
|
||||
const mixed = new Set(items.map((i) => i.resetsIn)).size > 1;
|
||||
const open = items.find((i) => i.key === editing && i.goal?.measure === "amount");
|
||||
|
||||
return (
|
||||
<section className="relative border-b border-hairline px-4 py-4">
|
||||
@@ -272,7 +360,9 @@ export function Dailies({
|
||||
|
||||
<div className="relative flex items-baseline justify-between gap-3">
|
||||
<h2 className="eyebrow">
|
||||
Today's dailies · {complete}/{total}
|
||||
{total > 0 ? <>Today's dailies · {complete}/{total}</> : "Your tasks"}
|
||||
{week.total > 0 && <> · Weekly {week.complete}/{week.total}</>}
|
||||
{month.total > 0 && <> · Monthly {month.complete}/{month.total}</>}
|
||||
</h2>
|
||||
<p className="tnum text-[0.6875rem] text-faint">
|
||||
{mixed ? "next reset in " : "resets in "}
|
||||
@@ -287,6 +377,8 @@ export function Dailies({
|
||||
region={region}
|
||||
daysFor={daysFor}
|
||||
onToggleDay={onToggleDay}
|
||||
onSetAmount={onSetAmount}
|
||||
amountFor={amountFor}
|
||||
/>
|
||||
) : (
|
||||
/* One wrapping row, with each game's chore and its events adjacent. No
|
||||
@@ -297,28 +389,62 @@ export function Dailies({
|
||||
<ul className="relative mt-2.5 flex flex-wrap gap-1.5">
|
||||
{items.map((item) => (
|
||||
<li key={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)}
|
||||
/>
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* 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 && (
|
||||
<AmountEditor
|
||||
key={`${open.key}:${open.goal.periodStart}`}
|
||||
autoFocus
|
||||
label={`${open.label} this ${open.goal.scope === "event" ? "event" : open.goal.scope}:`}
|
||||
value={amountFor?.(open.key, open.goal.periodStart)}
|
||||
target={open.goal.required}
|
||||
unit={open.unit}
|
||||
onSave={(value) => {
|
||||
onSetAmount(open.key, open.goal!.periodStart, value);
|
||||
setEditing(null);
|
||||
}}
|
||||
onCancel={() => setEditing(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="relative mt-2 flex items-baseline justify-between gap-3">
|
||||
<p className="text-[0.6875rem] leading-relaxed text-faint">
|
||||
{catchUp
|
||||
? `Tick a day you did but didn't record. The last ${CATCH_UP_DAYS} days, and nothing later than today.`
|
||||
: allDone
|
||||
? "All done. Nothing else expires tonight."
|
||||
: `${waiting(total - complete)} still waiting on you 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.`
|
||||
: allDone
|
||||
? "All done. Nothing else expires tonight."
|
||||
: `${waiting(total - complete)} still waiting on you today.`}
|
||||
</p>
|
||||
{/* 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
|
||||
@@ -358,12 +484,16 @@ export function CatchUpPanel({
|
||||
region,
|
||||
daysFor,
|
||||
onToggleDay,
|
||||
onSetAmount,
|
||||
amountFor,
|
||||
}: {
|
||||
groups: DailyGroup[];
|
||||
now: number;
|
||||
region: Region;
|
||||
daysFor: (id: string) => string[];
|
||||
onToggleDay: (id: string, day: string) => void;
|
||||
onSetAmount?: ((key: string, periodStart: string, value: number) => void) | undefined;
|
||||
amountFor?: ((key: string, periodStart: string) => number | undefined) | undefined;
|
||||
}) {
|
||||
return (
|
||||
<div className="relative mt-3 flex flex-col gap-3">
|
||||
@@ -375,7 +505,28 @@ export function CatchUpPanel({
|
||||
>
|
||||
{group.meta.name}
|
||||
</p>
|
||||
{group.items.map((item) => (
|
||||
{group.items.map((item) => {
|
||||
// A number is caught up a period at a time: the one just gone is
|
||||
// the one a reader forgets to record. An event's number is edited
|
||||
// in its sheet, where its whole run is.
|
||||
if (item.goal?.measure === "amount") {
|
||||
if (item.period === undefined || onSetAmount === undefined) return null;
|
||||
const previous = periodOf(item.period, addDays(item.goal.periodStart, -1)).start;
|
||||
return (
|
||||
<div key={item.key} className="mt-1.5">
|
||||
<p className="truncate text-xs text-muted">{item.label}</p>
|
||||
<AmountEditor
|
||||
key={previous}
|
||||
label={`Last ${item.period.unit}:`}
|
||||
value={amountFor?.(item.key, previous)}
|
||||
target={item.goal.required}
|
||||
unit={item.unit}
|
||||
onSave={(value) => onSetAmount(item.key, previous, value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div key={item.key} className="mt-1.5">
|
||||
<p className="truncate text-xs text-muted">{item.label}</p>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
@@ -390,13 +541,67 @@ export function CatchUpPanel({
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A number against a target: `1,900/2,100 wk`, with a hairline under it filling
|
||||
* to the same fraction.
|
||||
*
|
||||
* Not a toggle — there is nothing to tick — so no `aria-pressed`; it opens the
|
||||
* editor under the strip and says so with `aria-expanded`. Met, it takes the
|
||||
* goal-met look a tick chip has: full hue, no wash, since a wash means "done
|
||||
* today" and a number is never that.
|
||||
*/
|
||||
function AmountChip({
|
||||
label,
|
||||
hue,
|
||||
title,
|
||||
ariaLabel,
|
||||
goal,
|
||||
open,
|
||||
onOpen,
|
||||
}: {
|
||||
label: string;
|
||||
hue: string;
|
||||
title: string;
|
||||
ariaLabel: string;
|
||||
goal: GoalStatus;
|
||||
open: boolean;
|
||||
onOpen: () => void;
|
||||
}) {
|
||||
const share = Math.min(1, goal.required === 0 ? 0 : goal.count / goal.required);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
aria-expanded={open}
|
||||
aria-label={chipLabel(ariaLabel, false, goal)}
|
||||
title={title}
|
||||
className="hue-chip relative flex max-w-[15rem] items-center gap-2 overflow-hidden rounded-full border px-3 py-1.5 text-xs font-medium"
|
||||
style={{
|
||||
["--hue" as string]: hue,
|
||||
borderColor: goal.met ? hue : `color-mix(in srgb, ${hue} 20%, transparent)`,
|
||||
color: goal.met ? hue : "var(--color-muted)",
|
||||
background: `color-mix(in srgb, ${hue} 3%, transparent)`,
|
||||
}}
|
||||
>
|
||||
<span className="hue-chip-label truncate">{label}</span>
|
||||
<span className="tnum shrink-0 text-[0.625rem] opacity-70">{goalCount(goal)}</span>
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute bottom-0 left-0 h-[2px]"
|
||||
style={{ width: `${share * 100}%`, background: hue, opacity: goal.met ? 1 : 0.6 }}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One thing to tick off today.
|
||||
*
|
||||
|
||||
+104
-1
@@ -18,6 +18,8 @@ import {
|
||||
import type { GoalStatus } from "../src/shared/goal.ts";
|
||||
import { Welcome } from "../src/client/components/Welcome.tsx";
|
||||
import { chooseGoal, GoalControl, parseDraft } from "../src/client/components/GoalControl.tsx";
|
||||
import { AmountEditor, parseAmount } from "../src/client/components/AmountEditor.tsx";
|
||||
import type { Task } from "../src/shared/task.ts";
|
||||
import { DailyChecklist } from "../src/client/components/DailyChecklist.tsx";
|
||||
import { timelineLanes } from "../src/client/state/lanes.ts";
|
||||
import { GameMetaProvider } from "../src/client/state/gameMeta.tsx";
|
||||
@@ -1198,7 +1200,7 @@ describe("goals in the dailies strip", () => {
|
||||
const [group] = dailyGroups(["genshin"], [ev], NOW, "europe", meta, startOf, false, () =>
|
||||
status({ count: 5, met: true, needed: 0 }),
|
||||
);
|
||||
expect(dailyTally(group!.items, () => [])).toEqual({
|
||||
expect(dailyTally(group!.items, () => [])).toMatchObject({
|
||||
total: 1,
|
||||
complete: 1,
|
||||
tickedToday: 0,
|
||||
@@ -1320,3 +1322,104 @@ describe("GoalControl decisions", () => {
|
||||
expect(parseDraft("53", "week", null)).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe("weekly and monthly tasks in the dailies strip", () => {
|
||||
const NOW = Date.parse("2026-10-15T12:00:00.000Z");
|
||||
const meta = (id: string) => metaFor(id, {});
|
||||
const startOf = (e: { startsAt: string }) => Date.parse(e.startsAt);
|
||||
const AT = "2026-10-01T00:00:00.000Z";
|
||||
const points: 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,
|
||||
};
|
||||
const hidden: Task = { ...monthly, id: "mytask:cccccc3333", game: "hsr", title: "Hidden" };
|
||||
const amountStatus: GoalStatus = {
|
||||
scope: "week", measure: "amount", periodStart: "2026-10-12", required: 2100, count: 1900,
|
||||
met: false, needed: 200, open: 4, shortfall: false,
|
||||
};
|
||||
const monthStatus: GoalStatus = {
|
||||
scope: "month", measure: "days", periodStart: "2026-10-01", required: 1, count: 1,
|
||||
met: true, needed: 0, open: 16, shortfall: false,
|
||||
};
|
||||
const statusOf = (t: Task) => (t.measure.kind === "amount" ? amountStatus : monthStatus);
|
||||
const ev = {
|
||||
id: "z1", game: "zzz", title: "Login", type: "login", summary: null,
|
||||
startsAt: "2026-10-01T00:00:00.000Z", startPrecision: "day", endsAt: null,
|
||||
endPrecision: "unknown", regionScoped: false, regionEnds: null, sourceUrl: "https://example.test",
|
||||
} as never;
|
||||
|
||||
test("a game's tasks follow its chore and events, and a hidden game's stay hidden", () => {
|
||||
const groups = dailyGroups(["zzz"], [ev], NOW, "europe", meta, startOf, true, undefined,
|
||||
[points, monthly, hidden], statusOf);
|
||||
expect(groups.map((g) => g.items.map((i) => i.key))).toEqual([
|
||||
["dailies:zzz", "z1", points.id, monthly.id],
|
||||
]);
|
||||
expect(groups[0]!.items.map((i) => i.cadence)).toEqual(["day", "day", "week", "month"]);
|
||||
});
|
||||
|
||||
test("tasks are counted apart from today's dailies", () => {
|
||||
const [group] = dailyGroups(["zzz"], [], NOW, "europe", meta, startOf, true, undefined,
|
||||
[points, monthly], statusOf);
|
||||
expect(dailyTally(group!.items, () => [])).toEqual({
|
||||
total: 1, complete: 0, tickedToday: 0,
|
||||
week: { total: 1, complete: 0 }, month: { total: 1, complete: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
test("the header shows the period counts, and an amount chip is not a toggle", () => {
|
||||
const html = render(
|
||||
<Dailies games={["zzz"]} events={[]} region="europe" now={NOW} showChores
|
||||
daysFor={() => []} onToggleDay={() => {}} tasks={[points, monthly]}
|
||||
taskStatusFor={statusOf} onSetAmount={() => {}} amountFor={() => undefined} />,
|
||||
);
|
||||
expect(html).toMatch(/Weekly (<!-- -->)?0(<!-- -->)?\/(<!-- -->)?1/);
|
||||
expect(html).toMatch(/Monthly (<!-- -->)?1(<!-- -->)?\/(<!-- -->)?1/);
|
||||
expect(html).toContain("1,900/2,100 wk");
|
||||
expect(html).toMatch(/aria-label="Activity points \(Zenless Zone Zero\), 1,900 of 2,100 this week"/);
|
||||
const chip = html.match(/<button[^>]*Activity points \(Zenless[^>]*>/)![0];
|
||||
expect(chip).not.toContain("aria-pressed");
|
||||
expect(chip).toContain('aria-expanded="false"');
|
||||
});
|
||||
|
||||
test("a strip of only tasks still renders", () => {
|
||||
const html = render(
|
||||
<Dailies games={["zzz"]} events={[]} region="europe" now={NOW} showChores={false}
|
||||
daysFor={() => []} onToggleDay={() => {}} tasks={[points]} taskStatusFor={statusOf}
|
||||
onSetAmount={() => {}} amountFor={() => undefined} />,
|
||||
);
|
||||
expect(html).toContain("Activity points");
|
||||
});
|
||||
|
||||
test("labels for amounts and months", () => {
|
||||
expect(chipLabel("X", false, amountStatus)).toBe("X, 1,900 of 2,100 this week");
|
||||
expect(chipLabel("X", false, monthStatus)).toBe("X, done this month");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AmountEditor", () => {
|
||||
test("shows the value against the target", () => {
|
||||
const html = render(
|
||||
<AmountEditor label="Activity points this week" value={1900} target={2100} unit="points"
|
||||
onSave={() => {}} onCancel={() => {}} />,
|
||||
);
|
||||
expect(html).toContain('value="1900"');
|
||||
expect(html).toContain("/ 2,100 points");
|
||||
expect(html).toContain("Save");
|
||||
});
|
||||
|
||||
test("amounts are whole, non-negative numbers; anything else is mid-edit", () => {
|
||||
expect(parseAmount("1900")).toBe(1900);
|
||||
expect(parseAmount("1,900")).toBe(1900);
|
||||
expect(parseAmount(" 2 100 ")).toBe(2100);
|
||||
expect(parseAmount("1900.6")).toBe(1901);
|
||||
expect(parseAmount("")).toBeNull();
|
||||
expect(parseAmount("abc")).toBeNull();
|
||||
expect(parseAmount("-5")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user