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:
Lucas Winther
2026-10-13 13:44:57 +02:00
co-authored by Claude Opus 5.5
parent e97e8b002f
commit 595ae55f4a
4 changed files with 442 additions and 28 deletions
+15
View File
@@ -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>
+91
View File
@@ -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>
);
}
+232 -27
View File
@@ -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
View File
@@ -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();
});
});