Files
budget2.0/scripts/demo-data.ts
T
Lucas WintherandClaude Opus 5.5 933027cb1b Merge main into the anchored model
Main moved on by 27 commits since this branch forked, including three schema
steps of its own (budget folders, placement, nested bills). Resolutions worth
knowing:

- The anchored-model migration becomes 10 -> 11; main's 7 -> 10 run first.
- Main's nextUnconfirmedOccurrenceOnOrAfter walked the calendar grid for
  every cost, which would have shown the slot-model "next due" for an
  anchored bill in the budget tree. Both walks are now generators and the
  lookup dispatches on anchoring, like occurrencesOf.
- A folder's "Bills due" summed only occurrences, and an anchored bill's
  charges are never occurrences, so a charged phone plan read as 0 due.
  budgetTree now adds an anchored bill's charges in the month.
- Main's RecurringSection (with RecurringSplitEditor) supersedes this
  branch's split editor; the anchoring rules are ported onto it: start-date
  validation via startDateNeedsDay, month precision hidden for lastCharge,
  the anchoring control locked once a cost has charges.
- PurchaseDialog keeps main's placement control. The "Recurring cost" select
  replaces main's "Unlink from bill" checkbox, since "None" does the same.
  A bill created from the dialog nests under its post like one from Settings.
- Dropped main's "bounded projection ignores invalid confirmations" test: it
  exercised slot confirmations on a lastCharge cost, which no longer exist.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-30 16:51:17 +02:00

188 lines
8.6 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 housing = actions.addBudgetFolder(d, "Housing", null);
const utilities = actions.addBudgetFolder(d, "Small bills", housing.id);
rent.folderId = housing.id;
const smallBills = actions.addPost(d, "Household bills", "DKK");
smallBills.folderId = utilities.id;
for (const name of ["Internet", "Phone"]) actions.addRecurringCost(d, { name, folderId: utilities.id, archived: false, amount: { amount: name === "Internet" ? 249 : 129, currency: "DKK" }, startDate: "2026-09", recurrence: { kind: "everyNMonths", n: 1 }, anchoring: "calendar", splitMode: "percent", splits: [{ postId: smallBills.id, value: 100, absorbsRemainder: true }] });
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",
folderId: null,
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",
folderId: housing.id,
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",
folderId: null,
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",
folderId: null,
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]);