fix(recurring): never offer a coming-up row that can't be confirmed coherently
Critical (X1): confirming a "Coming up" row records TODAY against a future slot, and occurrencesOf throws — permanently, since the purchase persists — if that pairing can't advance the series. Reproduced with the demo's own Car insurance (everyNDays 30, lastCharge, from 2026-08-14): viewing 2026-09 and confirming its coming-up row (slot 2026-10-13, recorded 2026-09-04) bricks every month route, yearView, summaryView and PostYearRoute. `expectedGroups` now omits a coming-up row outright (never disabled-with-an-explanation) unless `wouldAdvancePast` says recording it today would leave the walk able to advance past that slot. Important (X3): a cost with an outstanding occurrence in the displayed month or any earlier foldable month never gets a coming-up row either — that was the double-counting path into X1 (Car insurance listed under both "Expected" 2026-09-13 and "Coming up" 2026-10-13; confirming the later while the earlier stayed pending would double-count the bill). Settle the earlier bill first. Important (X2): extracted the this-month-vs-coming-up recorded-date decision into `recordedDateFor(group, today)`, previously inlined as `row(occurrence, null)` vs `row(occurrence, today)` with nothing testing that the two aren't swapped. Tested directly. Minor: the "Coming up" label is now an `<h3>` (matching MonthRoute's own group-heading convention) instead of a `<p>`, so assistive tech gets the section divider. New tests in ExpectedBand.test.ts cover all of the above, including a regression built from the exact reported shape: confirming everything expectedGroups offers as "coming up" and then folding must never throw. Pre-fix, that reproduction throws: Recurring cost "Car insurance" did not advance past 2026-10-13 (produced 2026-10-04). Purchase <id> claims that slot but is dated 2026-09-04, which does not move the series past 2026-10-13 — correct its date so the series advances.
This commit is contained in:
1 parent
17f9a44b65
commit
1281362388
2 files changed
+259
-23
No files matched your search
@@ -1,5 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { expectedGroups, resolveExpectedAmount } from "./ExpectedBand.tsx";
|
||||
import { expectedGroups, recordedDateFor, resolveExpectedAmount } from "./ExpectedBand.tsx";
|
||||
import { occurrencesByMonth, wouldAdvancePast } from "../../domain/occurrences.ts";
|
||||
import { confirmOccurrence } from "../../store/actions.ts";
|
||||
import { createSeedDataset } from "../../domain/seed.ts";
|
||||
import type { Dataset, Purchase, RecurringCost } from "../../domain/types.ts";
|
||||
|
||||
@@ -38,6 +40,20 @@ describe("resolveExpectedAmount", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// X2: the load-bearing decision behind the phone-bill behaviour is which date
|
||||
// a Confirm click records. Nothing else in the suite would catch the two
|
||||
// cases being swapped — this test must FAIL if `recordedDateFor`'s two arms
|
||||
// are exchanged (verified by hand: swapping them and re-running does fail).
|
||||
describe("recordedDateFor", () => {
|
||||
test("a this-month ('pending') row records at the slot's own date — null, confirmOccurrence's default", () => {
|
||||
expect(recordedDateFor("pending", "2026-09-04")).toBeNull();
|
||||
});
|
||||
|
||||
test("a 'coming up' row records at today — paying early is the whole point", () => {
|
||||
expect(recordedDateFor("comingUp", "2026-09-04")).toBe("2026-09-04");
|
||||
});
|
||||
});
|
||||
|
||||
function baseDataset(): Dataset {
|
||||
const data = createSeedDataset("2026-02");
|
||||
data.posts[0]!.id = "housing";
|
||||
@@ -73,12 +89,16 @@ function confirmed(recurringId: string, occurrenceDate: string, id: string): Pur
|
||||
};
|
||||
}
|
||||
|
||||
// A fixed "today" for tests where its exact value doesn't matter (any
|
||||
// calendar-anchored cost advances regardless of it).
|
||||
const TODAY = "2026-02-15";
|
||||
|
||||
// Gap 1 (spec §5): the band offers confirmation on the next pending
|
||||
// occurrence, not only ones landing in the displayed month.
|
||||
describe("expectedGroups (Coming up)", () => {
|
||||
test("a dataset with no recurring costs shows nothing — the component must return null", () => {
|
||||
const data = baseDataset();
|
||||
const groups = expectedGroups(data, "2026-02");
|
||||
const groups = expectedGroups(data, "2026-02", TODAY);
|
||||
expect(groups.pending).toEqual([]);
|
||||
expect(groups.overdue).toBe(0);
|
||||
expect(groups.comingUp).toEqual([]);
|
||||
@@ -93,7 +113,7 @@ describe("expectedGroups (Coming up)", () => {
|
||||
data.recurring = [cost({ id: "phone", anchoring: "lastCharge" })];
|
||||
data.purchases = [confirmed("phone", "2026-02", "p1")]; // this month's is done
|
||||
|
||||
const groups = expectedGroups(data, "2026-02");
|
||||
const groups = expectedGroups(data, "2026-02", TODAY);
|
||||
expect(groups.pending).toEqual([]);
|
||||
expect(groups.comingUp.length).toBe(1);
|
||||
expect(groups.comingUp[0]!.recurringId).toBe("phone");
|
||||
@@ -103,7 +123,11 @@ describe("expectedGroups (Coming up)", () => {
|
||||
test("at most one row per cost, even when several future occurrences fall within the horizon", () => {
|
||||
// A weekly cost produces many unconfirmed occurrences across the next
|
||||
// several months. Only the soonest one past the displayed month may
|
||||
// appear — never a list of everything the future holds.
|
||||
// appear — never a list of everything the future holds. February's own
|
||||
// occurrences are confirmed first so X3 doesn't suppress the row this
|
||||
// test exists to check (a cost with an outstanding slot this month must
|
||||
// settle it before "coming up" offers a later one — covered separately
|
||||
// below).
|
||||
const data = baseDataset();
|
||||
data.recurring = [
|
||||
cost({
|
||||
@@ -112,10 +136,15 @@ describe("expectedGroups (Coming up)", () => {
|
||||
recurrence: { kind: "everyNDays", n: 7 },
|
||||
}),
|
||||
];
|
||||
data.purchases = [
|
||||
confirmed("gym", "2026-02-02", "p1"),
|
||||
confirmed("gym", "2026-02-09", "p2"),
|
||||
confirmed("gym", "2026-02-16", "p3"),
|
||||
confirmed("gym", "2026-02-23", "p4"),
|
||||
];
|
||||
|
||||
const groups = expectedGroups(data, "2026-02");
|
||||
// Four Monday-ish occurrences fall in February itself.
|
||||
expect(groups.pending.length).toBe(4);
|
||||
const groups = expectedGroups(data, "2026-02", TODAY);
|
||||
expect(groups.pending).toEqual([]);
|
||||
// Many more unconfirmed occurrences exist in March, April, ... within the
|
||||
// horizon — but comingUp holds exactly one.
|
||||
const forThisCost = groups.comingUp.filter((o) => o.recurringId === "gym");
|
||||
@@ -136,10 +165,10 @@ describe("expectedGroups (Coming up)", () => {
|
||||
];
|
||||
data.purchases = [confirmed("insurance", "2026-02", "p1")];
|
||||
|
||||
expect(expectedGroups(data, "2026-02").comingUp).toEqual([]); // default (12) horizon
|
||||
expect(expectedGroups(data, "2026-02", TODAY).comingUp).toEqual([]); // default (12) horizon
|
||||
// Raising the horizon far enough reveals the same occurrence, proving the
|
||||
// emptiness above is the bound, not a bug elsewhere.
|
||||
const wider = expectedGroups(data, "2026-02", 16).comingUp;
|
||||
const wider = expectedGroups(data, "2026-02", TODAY, 16).comingUp;
|
||||
expect(wider.length).toBe(1);
|
||||
expect(wider[0]!.date).toBe("2027-05");
|
||||
});
|
||||
@@ -152,7 +181,7 @@ describe("expectedGroups (Coming up)", () => {
|
||||
data.recurring = [cost({ id: "rent", endedFrom: "2026-04" })];
|
||||
data.purchases = [confirmed("rent", "2026-02", "p1"), confirmed("rent", "2026-03", "p2")];
|
||||
|
||||
const groups = expectedGroups(data, "2026-02");
|
||||
const groups = expectedGroups(data, "2026-02", TODAY);
|
||||
expect(groups.pending).toEqual([]);
|
||||
expect(groups.comingUp).toEqual([]);
|
||||
});
|
||||
@@ -162,7 +191,162 @@ describe("expectedGroups (Coming up)", () => {
|
||||
data.settings.foldStartMonth = "2026-01";
|
||||
data.recurring = [cost({ id: "rent", startDate: "2026-01" })]; // Jan unconfirmed
|
||||
|
||||
const groups = expectedGroups(data, "2026-02");
|
||||
const groups = expectedGroups(data, "2026-02", TODAY);
|
||||
expect(groups.overdue).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// X3: a cost must never appear in both "Expected" and "Coming up" at once —
|
||||
// that is the path into X1 (confirming the later row while the earlier one
|
||||
// is still outstanding writes a real purchase AND leaves the earlier
|
||||
// projection standing, double-counting the bill).
|
||||
describe("expectedGroups — X3 (a cost with an outstanding slot never also shows a coming-up row)", () => {
|
||||
test("this month's slot still pending suppresses the coming-up row for the SAME cost", () => {
|
||||
const data = baseDataset();
|
||||
data.recurring = [
|
||||
cost({
|
||||
id: "car",
|
||||
name: "Car insurance",
|
||||
amount: { amount: 349, currency: "DKK" },
|
||||
startDate: "2026-08-14",
|
||||
recurrence: { kind: "everyNDays", n: 30 },
|
||||
anchoring: "lastCharge",
|
||||
}),
|
||||
];
|
||||
// Matches the reported demo shape exactly: no purchases at all, viewed
|
||||
// 2026-09. 2026-09-13 is pending; 2026-10-13 would otherwise be the
|
||||
// soonest future occurrence.
|
||||
data.settings.foldStartMonth = "2026-01";
|
||||
|
||||
const groups = expectedGroups(data, "2026-09", "2026-09-04");
|
||||
expect(groups.pending.map((o) => o.date)).toEqual(["2026-09-13"]);
|
||||
expect(groups.comingUp).toEqual([]); // suppressed — settle September first
|
||||
});
|
||||
|
||||
test("once the pending slot is settled, a coming-up row reappears — for a pairing that is itself safe", () => {
|
||||
// A monthly lastCharge cost, unlike the 30-day Car insurance above, is
|
||||
// always safe to confirm early (X1 never suppresses `everyNMonths`), so
|
||||
// this isolates X3's own effect: suppressed while this month is
|
||||
// outstanding, offered again once it is settled.
|
||||
const data = baseDataset();
|
||||
data.recurring = [cost({ id: "phone", anchoring: "lastCharge" })];
|
||||
|
||||
let groups = expectedGroups(data, "2026-02", TODAY);
|
||||
expect(groups.pending.map((o) => o.date)).toEqual(["2026-02"]);
|
||||
expect(groups.comingUp).toEqual([]);
|
||||
|
||||
confirmOccurrence(data, "phone", "2026-02", { date: "2026-02" });
|
||||
groups = expectedGroups(data, "2026-02", TODAY);
|
||||
expect(groups.pending).toEqual([]);
|
||||
expect(groups.comingUp.map((o) => o.date)).toEqual(["2026-03"]);
|
||||
});
|
||||
|
||||
test("an outstanding occurrence in an earlier FOLDABLE month also suppresses the row", () => {
|
||||
const data = baseDataset();
|
||||
data.settings.foldStartMonth = "2026-01";
|
||||
data.recurring = [
|
||||
cost({ id: "rent", startDate: "2026-01", endedFrom: "2026-04" }),
|
||||
];
|
||||
// January is unconfirmed; February (displayed month) and March are
|
||||
// confirmed, so nothing is `pending` and the only reason a coming-up row
|
||||
// would be suppressed is the outstanding January slot.
|
||||
data.purchases = [confirmed("rent", "2026-02", "p1"), confirmed("rent", "2026-03", "p2")];
|
||||
|
||||
const groups = expectedGroups(data, "2026-02", TODAY);
|
||||
expect(groups.pending).toEqual([]);
|
||||
expect(groups.comingUp).toEqual([]); // January is still outstanding
|
||||
});
|
||||
});
|
||||
|
||||
// X1: a coming-up row confirms at today's date rather than its own slot, and
|
||||
// `occurrencesOf` throws — permanently, since the purchase persists — if that
|
||||
// pairing cannot advance the series. The band must never OFFER such a row.
|
||||
describe("expectedGroups — X1 (a coming-up row that cannot be confirmed coherently is never offered)", () => {
|
||||
test("wouldAdvancePast identifies the exact reported pairing as unsafe", () => {
|
||||
const car = cost({
|
||||
id: "car",
|
||||
name: "Car insurance",
|
||||
recurrence: { kind: "everyNDays", n: 30 },
|
||||
anchoring: "lastCharge",
|
||||
});
|
||||
// 39 days between "today" and the slot — more than one 30-day cycle.
|
||||
expect(wouldAdvancePast(car, "2026-10-13", "2026-09-04")).toBe(false);
|
||||
// A slot close enough to today (a few days, the "paid early" case) is fine.
|
||||
expect(wouldAdvancePast(car, "2026-09-13", "2026-09-04")).toBe(true);
|
||||
});
|
||||
|
||||
test("stays suppressed even once X3 no longer applies — settling this month doesn't make a too-early payment safe", () => {
|
||||
// Same Car insurance shape, but September's slot is settled ON TIME
|
||||
// first, so X3 no longer has anything to suppress. The October row is
|
||||
// still 39 days from `today` — more than one 30-day cycle — so X1 alone
|
||||
// must keep it off the list.
|
||||
const data = baseDataset();
|
||||
data.recurring = [
|
||||
cost({
|
||||
id: "car",
|
||||
name: "Car insurance",
|
||||
amount: { amount: 349, currency: "DKK" },
|
||||
startDate: "2026-08-14",
|
||||
recurrence: { kind: "everyNDays", n: 30 },
|
||||
anchoring: "lastCharge",
|
||||
}),
|
||||
];
|
||||
confirmOccurrence(data, "car", "2026-09-13", { date: "2026-09-13" });
|
||||
|
||||
const groups = expectedGroups(data, "2026-09", "2026-09-04");
|
||||
expect(groups.pending).toEqual([]);
|
||||
expect(groups.comingUp).toEqual([]);
|
||||
});
|
||||
|
||||
test("confirming an unsafe pairing directly still throws — the domain guard is untouched", () => {
|
||||
const data = baseDataset();
|
||||
data.recurring = [
|
||||
cost({
|
||||
id: "car",
|
||||
name: "Car insurance",
|
||||
amount: { amount: 349, currency: "DKK" },
|
||||
startDate: "2026-08-14",
|
||||
recurrence: { kind: "everyNDays", n: 30 },
|
||||
anchoring: "lastCharge",
|
||||
}),
|
||||
];
|
||||
confirmOccurrence(data, "car", "2026-10-13", { date: "2026-09-04" });
|
||||
expect(() => occurrencesByMonth(data, "2026-10")).toThrow(
|
||||
/did not advance past 2026-10-13 \(produced 2026-10-04\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test("regression: whatever expectedGroups offers as 'coming up', confirming it all today and folding never throws (demo Car insurance shape)", () => {
|
||||
const data = baseDataset();
|
||||
data.recurring = [
|
||||
cost({
|
||||
id: "car",
|
||||
name: "Car insurance",
|
||||
amount: { amount: 349, currency: "DKK" },
|
||||
startDate: "2026-08-14",
|
||||
recurrence: { kind: "everyNDays", n: 30 },
|
||||
anchoring: "lastCharge",
|
||||
}),
|
||||
cost({
|
||||
id: "phone",
|
||||
name: "Phone",
|
||||
startDate: "2026-01",
|
||||
recurrence: { kind: "everyNMonths", n: 1 },
|
||||
anchoring: "lastCharge",
|
||||
}),
|
||||
];
|
||||
data.purchases = [confirmed("phone", "2026-02", "p0")]; // settled through Feb, unlike Car
|
||||
const today = "2026-09-04";
|
||||
|
||||
const groups = expectedGroups(data, "2026-09", today);
|
||||
// Car insurance is suppressed entirely (X3: 2026-09-13 is still pending),
|
||||
// and even confirming everything else offered must never break the fold.
|
||||
expect(groups.comingUp.some((o) => o.recurringId === "car")).toBe(false);
|
||||
|
||||
for (const occurrence of groups.comingUp) {
|
||||
confirmOccurrence(data, occurrence.recurringId, occurrence.date, { date: today });
|
||||
}
|
||||
|
||||
expect(() => occurrencesByMonth(data, "2026-10")).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,11 @@ import { useDataset } from "../hooks/useDataset.ts";
|
||||
import { useMutate } from "../hooks/useMutate.ts";
|
||||
import { today } from "../../store/index.ts";
|
||||
import { confirmOccurrence } from "../../store/actions.ts";
|
||||
import { occurrencesByMonth, type Occurrence } from "../../domain/occurrences.ts";
|
||||
import {
|
||||
occurrencesByMonth,
|
||||
wouldAdvancePast,
|
||||
type Occurrence,
|
||||
} from "../../domain/occurrences.ts";
|
||||
import { addMonths, compareMonths } from "../../domain/months.ts";
|
||||
import { parseMoneyInput, type CurrencyOption, type ParsedMoney } from "../moneyInput.ts";
|
||||
import { Section } from "./Section.tsx";
|
||||
@@ -33,6 +37,24 @@ export function resolveExpectedAmount(
|
||||
return { parsed, invalid: parsed === null };
|
||||
}
|
||||
|
||||
export type ExpectedRowGroup = "pending" | "comingUp";
|
||||
|
||||
/**
|
||||
* What a Confirm click should record, given which group the row is in. A
|
||||
* this-month row confirms at the slot's own date — `null`, `confirmOccurrence`'s
|
||||
* default — and a coming-up row confirms at `today` instead, because it is
|
||||
* being paid early on purpose (see `ExpectedBand`'s doc comment).
|
||||
*
|
||||
* This one branch (previously inlined as `row(occurrence, null)` versus
|
||||
* `row(occurrence, today)`) is the whole of the phone-bill behaviour, and
|
||||
* nothing else in the suite would catch the two being swapped — a DOM test
|
||||
* isn't needed for that, a direct one is. Exported for `ExpectedBand.test.ts`
|
||||
* (X2).
|
||||
*/
|
||||
export function recordedDateFor(group: ExpectedRowGroup, today: IsoDate): IsoDate | null {
|
||||
return group === "comingUp" ? today : null;
|
||||
}
|
||||
|
||||
export interface ExpectedGroups {
|
||||
/** This month's unconfirmed occurrences. */
|
||||
pending: Occurrence[];
|
||||
@@ -57,11 +79,31 @@ export interface ExpectedGroups {
|
||||
* enough to catch an annual `everyNMonths` cost — and is a band-local concern.
|
||||
* `foldBalances`'s own `upToMonth` bound is untouched.
|
||||
*
|
||||
* `today` is what a "Coming up" row would record if confirmed (see `row` in
|
||||
* `ExpectedBand` below) — passed in rather than read from a clock, because
|
||||
* this function is exercised directly by tests with no DOM.
|
||||
*
|
||||
* Two rules keep a "coming up" row from ever being offered when confirming it
|
||||
* cannot go well (X1, X3):
|
||||
*
|
||||
* - A cost with an unresolved occurrence in `monthId` or any earlier
|
||||
* FOLDABLE month (same bound `overdue` uses) is skipped entirely. Settle
|
||||
* the earlier bill first — offering a later one while an earlier sits
|
||||
* outstanding would let confirming the later one leave the earlier
|
||||
* projection standing, double-counting the bill.
|
||||
* - Otherwise, the row is offered only if recording it at `today` would
|
||||
* leave `occurrencesOf`'s walk able to advance past that slot
|
||||
* (`wouldAdvancePast`). A bill confirmed today for a slot far enough in
|
||||
* the future can rebase the series backwards, which is exactly what
|
||||
* `occurrencesOf` throws on — so a row that would do that must never be
|
||||
* offered in the first place, not offered-and-disabled.
|
||||
*
|
||||
* Exported for `ExpectedBand.test.ts`.
|
||||
*/
|
||||
export function expectedGroups(
|
||||
dataset: Dataset,
|
||||
monthId: MonthId,
|
||||
today: IsoDate,
|
||||
horizonMonths = 12,
|
||||
): ExpectedGroups {
|
||||
const byMonth = occurrencesByMonth(dataset, addMonths(monthId, horizonMonths));
|
||||
@@ -69,6 +111,9 @@ export function expectedGroups(
|
||||
|
||||
let overdue = 0;
|
||||
const seen = new Set<string>();
|
||||
// Costs with an outstanding slot at or before `monthId` — X3: none of
|
||||
// these may get a "coming up" row until that slot is settled.
|
||||
const unresolved = new Set<string>(pending.map((o) => o.recurringId));
|
||||
const comingUp: Occurrence[] = [];
|
||||
|
||||
for (const month of [...byMonth.keys()].sort(compareMonths)) {
|
||||
@@ -81,9 +126,15 @@ export function expectedGroups(
|
||||
// foldStartMonth: foldBalances never folds those, so an occurrence out
|
||||
// there never held the projected balance down in the first place, and
|
||||
// counting it here would make the sentence below claim a gap that isn't
|
||||
// in the number the owner is looking at.
|
||||
// in the number the owner is looking at. The same bound applies to X3's
|
||||
// suppression, for the same reason — a slot the fold never tracked was
|
||||
// never "outstanding" in any number the owner can see.
|
||||
if (compareMonths(month, dataset.settings.foldStartMonth) < 0) continue;
|
||||
overdue += occurrences.filter((o) => !o.confirmedBy).length;
|
||||
for (const o of occurrences) {
|
||||
if (o.confirmedBy) continue;
|
||||
overdue += 1;
|
||||
unresolved.add(o.recurringId);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -95,6 +146,9 @@ export function expectedGroups(
|
||||
if (occurrence.confirmedBy) continue;
|
||||
if (seen.has(occurrence.recurringId)) continue;
|
||||
seen.add(occurrence.recurringId);
|
||||
if (unresolved.has(occurrence.recurringId)) continue; // X3
|
||||
const cost = dataset.recurring.find((c) => c.id === occurrence.recurringId);
|
||||
if (!cost || !wouldAdvancePast(cost, occurrence.date, today)) continue; // X1
|
||||
comingUp.push(occurrence);
|
||||
}
|
||||
}
|
||||
@@ -124,8 +178,8 @@ export function ExpectedBand({ monthId }: { monthId: MonthId }) {
|
||||
const [amounts, setAmounts] = useState<Record<string, string>>({});
|
||||
|
||||
const { pending, overdue, comingUp } = useMemo(
|
||||
() => expectedGroups(dataset, monthId),
|
||||
[dataset, monthId],
|
||||
() => expectedGroups(dataset, monthId, today),
|
||||
[dataset, monthId, today],
|
||||
);
|
||||
|
||||
if (pending.length === 0 && overdue === 0 && comingUp.length === 0) return null;
|
||||
@@ -135,9 +189,7 @@ export function ExpectedBand({ monthId }: { monthId: MonthId }) {
|
||||
|
||||
/**
|
||||
* One row, shared by "Expected" and "Coming up" — they differ only in the
|
||||
* subtitle and in what date confirming records. `recordedDate` is `null`
|
||||
* for a this-month row (confirms at the slot's own date, `confirmOccurrence`'s
|
||||
* default) and `today` for a coming-up row (confirms early, on purpose).
|
||||
* subtitle and in what date confirming records, per `recordedDateFor`.
|
||||
*/
|
||||
function row(occurrence: Occurrence, recordedDate: IsoDate | null) {
|
||||
const key = `${occurrence.recurringId}:${occurrence.date}`;
|
||||
@@ -222,17 +274,17 @@ export function ExpectedBand({ monthId }: { monthId: MonthId }) {
|
||||
>
|
||||
{pending.length > 0 && (
|
||||
<ul className="divide-y divide-budget-rule">
|
||||
{pending.map((occurrence) => row(occurrence, null))}
|
||||
{pending.map((occurrence) => row(occurrence, recordedDateFor("pending", today)))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{comingUp.length > 0 && (
|
||||
<div className={pending.length > 0 ? "mt-4" : ""}>
|
||||
<p className="mb-1 text-[0.6875rem] font-medium uppercase tracking-wider text-budget-ink-muted">
|
||||
<h3 className="mb-1 text-[0.6875rem] font-medium uppercase tracking-wider text-budget-ink-muted">
|
||||
Coming up
|
||||
</p>
|
||||
</h3>
|
||||
<ul className="divide-y divide-budget-rule">
|
||||
{comingUp.map((occurrence) => row(occurrence, today))}
|
||||
{comingUp.map((occurrence) => row(occurrence, recordedDateFor("comingUp", today)))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user