AGENTS.md §1's old invariant was half wrong the moment anchored costs lost their slots: occurrenceDate is identity for calendar only. The replacement says what each schedule is and, more usefully, warns against collapsing them — that collapse is exactly the bug where paying twice in September pushed the next charge to November. §2's termination bullet now describes a defensive assert rather than a live guard, because the anchored walk steps forward from a real date and the grid walk never rebases. Also added the anchoring-change-refused-once-charged guard, which was undocumented in AGENTS.md despite having its own test suite and UI-level guard. DECISIONS.md, PRODUCT.md, ARCHITECTURE.md and TODO.md catch up to what shipped: two projection models and the three guards it deleted, a recurring cost charging more than once per cycle and being creatable from the purchase dialog, the anchored walk beside the grid walk, and schema 8 at 730 tests. The 2026-09-03 spec gets a header note pointing at the new spec and nothing else — it stays an accurate record of what was decided then. Demo data seeds two charges in one month on an anchored cost (Car insurance), so the fixed behaviour is the first thing a screenshot shows. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
178 lines
7.9 KiB
TypeScript
Executable File
178 lines
7.9 KiB
TypeScript
Executable File
/**
|
|
* Writes a browser script that seeds a synthetic dataset into IndexedDB, for
|
|
* screenshots and for poking at the app with realistic data.
|
|
*
|
|
* bun scripts/demo-data.ts /tmp/seed.js
|
|
* bun scripts/screenshot.ts http://localhost:3000/month/2026-09 /tmp/shot.png \
|
|
* --eval-file=/tmp/seed.js --reload
|
|
*
|
|
* `--reload` is not optional: the app reads its dataset once at boot, so it has
|
|
* to boot again to see what was written. Chrome also starts from a fresh
|
|
* profile on every run unless you pass --profile, so without this every
|
|
* screenshot shows an app full of zeros.
|
|
*
|
|
* The data is visibly synthetic on purpose. docs/PRODUCT.md: the owner's real budget
|
|
* never leaves their browser, so it is never available as example material, and
|
|
* illustrative data must not pretend to be real.
|
|
*/
|
|
import { createSeedDataset } from "../src/domain/seed.ts";
|
|
import * as actions from "../src/store/actions.ts";
|
|
import { FALLBACK_FX_RATES } from "../src/store/bakedRates.ts";
|
|
|
|
// Visibly synthetic, per docs/PRODUCT.md: the owner's real budget must never be
|
|
// used as example material, and illustrative data must not look real.
|
|
const d = createSeedDataset("2026-01", FALLBACK_FX_RATES);
|
|
const [games, food, social] = d.posts as [typeof d.posts[0], typeof d.posts[0], typeof d.posts[0]];
|
|
const rent = actions.addPost(d, "Rent", "DKK");
|
|
const transport = actions.addPost(d, "Transport", "DKK");
|
|
const savings = actions.addPost(d, "Savings", "DKK");
|
|
|
|
for (let m = 1; m <= 9; m++) {
|
|
const id = `2026-${String(m).padStart(2, "0")}`;
|
|
actions.setIncome(d, id, { amount: m === 7 ? 24500 : 28000, currency: "DKK" });
|
|
}
|
|
|
|
actions.setRuleFrom(d, rent.id, "2026-01", { kind: "fixed", amount: { amount: 8200, currency: "DKK" } });
|
|
actions.setRuleFrom(d, food.id, "2026-01", { kind: "percentOfIncome", percent: 12 });
|
|
actions.setRuleFrom(d, food.id, "2026-06", { kind: "percentOfIncome", percent: 15 });
|
|
actions.setRuleFrom(d, games.id, "2026-01", { kind: "fixed", amount: { amount: 600, currency: "DKK" } });
|
|
actions.setRuleFrom(d, social.id, "2026-01", { kind: "percentOfIncome", percent: 6 });
|
|
actions.setRuleFrom(d, transport.id, "2026-01", { kind: "fixed", amount: { amount: 1100, currency: "DKK" } });
|
|
actions.setRuleFrom(d, savings.id, "2026-03", { kind: "percentOfIncome", percent: 10 });
|
|
actions.setRuleOverride(d, "2026-09", social.id, { kind: "fixed", amount: { amount: 2400, currency: "DKK" } });
|
|
|
|
// Recurring costs: bills that project their own expected charges, shown as a
|
|
// second balance ("Projected") beside the allocation-rule figures above. All
|
|
// three recurrence kinds and both anchorings appear, so the screenshots show
|
|
// every combination at once.
|
|
const wowSub = actions.addRecurringCost(d, {
|
|
name: "World of Warcraft",
|
|
archived: false,
|
|
amount: { amount: 13, currency: "USD" },
|
|
startDate: "2026-06-05",
|
|
recurrence: { kind: "everyNDays", n: 28 },
|
|
anchoring: "calendar",
|
|
splitMode: "percent",
|
|
splits: [{ postId: games.id, value: 100, absorbsRemainder: true }],
|
|
});
|
|
actions.addRecurringCost(d, {
|
|
name: "Rent",
|
|
archived: false,
|
|
amount: { amount: 8200, currency: "DKK" },
|
|
startDate: "2026-06",
|
|
recurrence: { kind: "everyNMonths", n: 1 },
|
|
anchoring: "calendar",
|
|
splitMode: "percent",
|
|
splits: [{ postId: rent.id, value: 100, absorbsRemainder: true }],
|
|
});
|
|
actions.addRecurringCost(d, {
|
|
name: "Veg box",
|
|
archived: false,
|
|
amount: { amount: 175, currency: "DKK" },
|
|
startDate: "2026-06-06",
|
|
recurrence: { kind: "everyNWeeks", n: 2, weekday: 6 },
|
|
anchoring: "calendar",
|
|
splitMode: "percent",
|
|
splits: [{ postId: food.id, value: 100, absorbsRemainder: true }],
|
|
});
|
|
// Rebases on the actual charge date rather than the calendar, so an
|
|
// off-schedule payment moves the whole series — the phone-bill behaviour.
|
|
const carInsurance = actions.addRecurringCost(d, {
|
|
name: "Car insurance",
|
|
archived: false,
|
|
amount: { amount: 349, currency: "DKK" },
|
|
startDate: "2026-08-14",
|
|
recurrence: { kind: "everyNDays", n: 30 },
|
|
anchoring: "lastCharge",
|
|
splitMode: "percent",
|
|
splits: [{ postId: transport.id, value: 100, absorbsRemainder: true }],
|
|
});
|
|
|
|
// One confirmed occurrence, so a screenshot shows both states at once: the
|
|
// expected band lists what is still due, and the fold's two tracks have
|
|
// already reconverged for this one.
|
|
actions.confirmOccurrence(d, wowSub.id, "2026-06-05", { date: "2026-06-05" });
|
|
|
|
// TWO CHARGES IN ONE MONTH on an anchored cost — the reported case. Car
|
|
// insurance rebases on whatever it last actually charged, not on a calendar
|
|
// slot, so charging twice in September must not push the next due date two
|
|
// cycles out to November; it lands one cycle (30 days) after the LATER
|
|
// charge, in October. Confirming these through `confirmOccurrence` carries no
|
|
// `occurrenceDate` (anchored costs have no slots to settle) — only
|
|
// `Purchase.date` matters here.
|
|
actions.confirmOccurrence(d, carInsurance.id, "2026-09-03");
|
|
actions.confirmOccurrence(d, carInsurance.id, "2026-09-19");
|
|
|
|
const buy = (date: string, description: string, amount: number, currency: "DKK" | "EUR" | "USD", postId: string, note?: string) =>
|
|
actions.addPurchase(d, {
|
|
date, description, note,
|
|
total: { amount, currency },
|
|
splitMode: "percent",
|
|
splits: [{ postId, value: 100, absorbsRemainder: true }],
|
|
schedule: null,
|
|
});
|
|
|
|
for (let m = 1; m <= 9; m++) {
|
|
const mm = String(m).padStart(2, "0");
|
|
// The "Rent" recurring cost above starts 2026-06 and is left unconfirmed,
|
|
// so it already projects an expected charge for June onward. An ordinary
|
|
// Rent purchase in those same months would double-charge the post in the
|
|
// screenshots — one from `charges`, one from `expected` — so this ordinary
|
|
// purchase only covers the months before the recurring cost exists.
|
|
if (m < 6) buy(`2026-${mm}-01`, "Rent", 8200, "DKK", rent.id);
|
|
buy(`2026-${mm}-04`, "Groceries", 1180 + m * 37, "DKK", food.id);
|
|
buy(`2026-${mm}-18`, "Groceries", 1420 - m * 12, "DKK", food.id);
|
|
buy(`2026-${mm}-06`, "Commuter pass", 1100, "DKK", transport.id);
|
|
}
|
|
buy("2026-09-03", "Coffee with the team", 184, "DKK", social.id);
|
|
buy("2026-09-07", "Board game night", 465, "DKK", social.id, "split the taxi");
|
|
buy("2026-09-12", "Indie bundle", 27.5, "EUR", games.id, "sale");
|
|
buy("2026-09-15", "Birthday dinner", 1850, "DKK", social.id);
|
|
buy("2026-08-22", "Headphones", 89.99, "USD", games.id);
|
|
|
|
// A split across posts, and a purchase spread over months. Both compose.
|
|
actions.addPurchase(d, {
|
|
date: "2026-09-09", description: "Weekend away", note: "food + social",
|
|
total: { amount: 2600, currency: "DKK" }, splitMode: "percent",
|
|
splits: [
|
|
{ postId: social.id, value: 65, absorbsRemainder: true },
|
|
{ postId: food.id, value: 35, absorbsRemainder: false },
|
|
],
|
|
schedule: null,
|
|
});
|
|
actions.addPurchase(d, {
|
|
date: "2026-08-14", description: "Bike", note: "paid over 4 months",
|
|
total: { amount: 6400, currency: "DKK" }, splitMode: "percent",
|
|
splits: [{ postId: transport.id, value: 100, absorbsRemainder: true }],
|
|
schedule: { slices: [
|
|
{ month: "2026-08", amount: { amount: 1600, currency: "DKK" } },
|
|
{ month: "2026-09", amount: { amount: 1600, currency: "DKK" } },
|
|
{ month: "2026-10", amount: { amount: 1600, currency: "DKK" } },
|
|
{ month: "2026-11", amount: { amount: 1600, currency: "DKK" } },
|
|
] },
|
|
});
|
|
|
|
await Bun.write(
|
|
process.argv[2]!,
|
|
`(async () => {
|
|
const data = ${JSON.stringify(d)};
|
|
await new Promise((resolve, reject) => {
|
|
const open = indexedDB.open("budget2", 1);
|
|
open.onupgradeneeded = () => {
|
|
const db = open.result;
|
|
if (!db.objectStoreNames.contains("state")) db.createObjectStore("state");
|
|
};
|
|
open.onsuccess = () => {
|
|
const db = open.result;
|
|
const tx = db.transaction("state", "readwrite");
|
|
tx.objectStore("state").put(data, "dataset");
|
|
tx.oncomplete = () => { db.close(); resolve(); };
|
|
tx.onerror = () => reject(tx.error);
|
|
};
|
|
open.onerror = () => reject(open.error);
|
|
});
|
|
return "seeded " + data.posts.length + " posts, " + data.purchases.length + " purchases";
|
|
})()`,
|
|
);
|
|
console.log("wrote eval script to", process.argv[2]);
|