diff --git a/README.md b/README.md index b199dfd5..b60c3071 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ runtime dependencies. harvest, drawn from deliberately through the year. ``` -2073 tests · 0 runtime dependencies · 57 migrations · 209 routes +2076 tests · 0 runtime dependencies · 57 migrations · 209 routes ``` ![The budget screen: the month grid, Ready to Assign, credit-card payment envelopes, and the in-app digest](docs/screenshots/budget.png) diff --git a/src/app.ts b/src/app.ts index 98877975..c96fe8db 100644 --- a/src/app.ts +++ b/src/app.ts @@ -2770,9 +2770,15 @@ export function buildApp(deps: AppDeps): { router: Router; middleware: ((ctx: Re */ const accounts = listAccounts(db, { viewerMemberId: viewer(ctx) }) .filter((a) => !DERIVED_VALUE_SUBTYPES.has(a.subtype)); + // The reader's last-used account. The household's could be somebody + // else's private one, which is not in the list, so nothing was preselected. + const lastUsedHidden = hiddenTransactionSql("t", viewer(ctx)); const lastUsed = queryOne<{ account_id: string }>( db, - `SELECT account_id FROM transactions WHERE deleted_at IS NULL ORDER BY created_at DESC LIMIT 1`, + `SELECT t.account_id FROM transactions t + WHERE t.deleted_at IS NULL AND NOT ${lastUsedHidden.sql} + ORDER BY t.created_at DESC LIMIT 1`, + ...lastUsedHidden.params, ); return render( @@ -4484,6 +4490,13 @@ export function buildApp(deps: AppDeps): { router: Router; middleware: ((ctx: Re * else, from the one place it is written down. */ const reviewHidden = hiddenTransactionSql("t", viewer(ctx)); + /* + * And the envelope a payee "usually" goes to is the reader's usual, as it + * is on /add. Counted over the household, a payee Ravi files privately + * more often than Priya files it anywhere came out as his envelope, which + * the page rightly would not name, so Priya got no suggestion at all. + */ + const usualHidden = hiddenTransactionSql("prev", viewer(ctx)); const reviewHiddenAccount = hiddenAccountSql("a", viewer(ctx)); const unfundedCards = [...view.categories.values()] @@ -4523,7 +4536,7 @@ export function buildApp(deps: AppDeps): { router: Router; middleware: ((ctx: Re (SELECT prev.category_id FROM transactions prev WHERE prev.payee_id = t.payee_id AND prev.category_id IS NOT NULL - AND prev.deleted_at IS NULL + AND prev.deleted_at IS NULL AND NOT ${usualHidden.sql} GROUP BY prev.category_id ORDER BY COUNT(*) DESC, MAX(prev.date) DESC LIMIT 1) AS usual_category_id @@ -4534,7 +4547,7 @@ export function buildApp(deps: AppDeps): { router: Router; middleware: ((ctx: Re AND t.transfer_pair_id IS NULL AND a.kind != 'tracking' AND t.amount < 0 AND NOT ${reviewHidden.sql} ORDER BY t.date DESC LIMIT ?`, - ...reviewHidden.params, UNCATEGORISED_PAGE, + ...usualHidden.params, ...reviewHidden.params, UNCATEGORISED_PAGE, ), uncategorisedTotal: queryOne<{ n: number }>( db, @@ -8457,6 +8470,7 @@ export function buildApp(deps: AppDeps): { router: Router; middleware: ((ctx: Re const requestFailures24h = countRequestFailures(db); const recentFailures = requestFailures24h > 0 ? recentRequestFailures(db, 3) : []; const queue = reviewCount(db, viewerMemberId); + const visibleCount = hiddenTransactionSql("t", viewerMemberId); return [ { @@ -8507,7 +8521,14 @@ export function buildApp(deps: AppDeps): { router: Router; middleware: ((ctx: Re { name: "Database", state: "healthy", - reason: `Connected. ${queryOne<{ n: number }>(db, `SELECT COUNT(*) AS n FROM transactions WHERE deleted_at IS NULL`)?.n ?? 0} transactions, ` + + // 15 · The transactions the reader can see: a household-wide count + // moved whenever somebody spent privately. + reason: `Connected. ${queryOne<{ n: number }>( + db, + `SELECT COUNT(*) AS n FROM transactions t + WHERE t.deleted_at IS NULL AND NOT ${visibleCount.sql}`, + ...visibleCount.params, + )?.n ?? 0} transactions, ` + `${queryOne<{ n: number }>(db, `SELECT COUNT(*) AS n FROM events`)?.n ?? 0} events.`, }, { diff --git a/src/web/viewer-defaults.test.ts b/src/web/viewer-defaults.test.ts new file mode 100644 index 00000000..cf9f1a10 --- /dev/null +++ b/src/web/viewer-defaults.test.ts @@ -0,0 +1,141 @@ +/** + * 15 · The small defaults a screen works out for you, worked out as you. + * + * Three places read the whole household to pick something for the reader: the + * envelope Review offers to file a payee as, the account /add starts on, and the + * transaction count on /health. None of them printed another member's private + * data — each was filtered on the way out — but each chose from it, so the + * reader got a worse answer than their own history gave: no suggestion, no + * preselected account, a count that moved when somebody else spent privately. + */ + +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; +import { freshDb, seedMember, startTestApp } from "./harness.test-data.ts"; +import type { Actor } from "../core/events.ts"; +import { rupees, type Paise } from "../core/money.ts"; +import { todayIST, addDays } from "../core/dates.ts"; +import { createAccount } from "../domain/accounts.ts"; +import { createGroup, createCategory } from "../domain/budget.ts"; +import { createTransaction } from "../domain/transactions.ts"; +import { ensurePersonalBudget } from "../domain/budgets.ts"; +import { execute, queryOne } from "../db/db.ts"; + +const RAVI = "m-ravi"; +const PRIYA = "m-priya"; +const ravi: Actor = { memberId: RAVI, source: "ui" }; +const priya: Actor = { memberId: PRIYA, source: "ui" }; + +function household() { + const db = freshDb(); + seedMember(db, RAVI, "Ravi"); + seedMember(db, PRIYA, "Priya"); + const joint = createAccount(db, ravi, { + name: "Joint current", kind: "budget", subtype: "savings", + openingDate: "2026-01-01", openingBalance: rupees(2_00_000), + }); + const wallet = createAccount(db, priya, { + name: "Priya's wallet", kind: "budget", subtype: "wallet", + openingDate: "2026-01-01", openingBalance: rupees(10_000), + }); + const everyday = createGroup(db, ravi, "Everyday"); + const eatingOut = createCategory(db, ravi, { groupId: everyday.id, name: "Eating out" }); + + const his = ensurePersonalBudget(db, RAVI, "Ravi"); + const hisAccount = createAccount(db, ravi, { + name: "Ravi private savings", kind: "budget", subtype: "savings", + openingDate: "2026-01-01", openingBalance: rupees(3_00_000), + holderMemberId: RAVI, visibility: "private", budgetId: his.id, + }); + const hisGroup = createGroup(db, ravi, "Mine", "normal", his.id); + const treats = createCategory(db, ravi, { groupId: hisGroup.id, name: "Ravi treats" }); + return { db, joint, wallet, eatingOut, hisAccount, treats }; +} + +describe("15 · defaults a screen picks for the reader are picked from what the reader sees", () => { + test("Review's 'File as' suggestion comes from the reader's own filings", async () => { + const { db, joint, eatingOut, hisAccount, treats } = household(); + const day = addDays(todayIST(), -10); + // Priya has filed Cafe Coffee Day to Eating out once. + createTransaction(db, priya, { + accountId: joint.id, amount: -rupees(200) as Paise, date: day, + categoryId: eatingOut.id, payeeName: "Cafe Coffee Day", ownerMemberId: PRIYA, + }); + // Ravi, privately, three times to his own envelope: the household-wide + // favourite, and one Priya cannot see. + for (let i = 0; i < 3; i++) { + createTransaction(db, ravi, { + accountId: hisAccount.id, amount: -rupees(250) as Paise, date: day, + categoryId: treats.id, payeeName: "Cafe Coffee Day", ownerMemberId: RAVI, + }); + } + // An unfiled one on the joint account waits in Priya's queue. + const unfiled = createTransaction(db, priya, { + accountId: joint.id, amount: -rupees(180) as Paise, date: todayIST(), + categoryId: eatingOut.id, payeeName: "Cafe Coffee Day", ownerMemberId: PRIYA, + }); + execute(db, `UPDATE transactions SET category_id = NULL WHERE id = ?`, unfiled.id); + + const app = await startTestApp(db, { memberId: PRIYA }); + try { + const body = await (await app.get("/review")).text(); + assert.ok(!body.includes("Ravi treats"), "Review named Ravi's private envelope"); + assert.match(body, /File as\s+Eating out/, "Priya's own history was ignored in favour of Ravi's"); + assert.deepEqual(app.failures, []); + } finally { + await app.close(); + } + }); + + test("/add starts on the reader's last-used account, not somebody else's private one", async () => { + const { db, wallet, hisAccount, treats, eatingOut } = household(); + createTransaction(db, priya, { + accountId: wallet.id, amount: -rupees(90) as Paise, date: todayIST(), + categoryId: eatingOut.id, payeeName: "Chai", ownerMemberId: PRIYA, + }); + // Entered after Priya's, so it is the household's most recent. Stamped a + // minute later so the order does not rest on two inserts in one millisecond. + const his = createTransaction(db, ravi, { + accountId: hisAccount.id, amount: -rupees(500) as Paise, date: todayIST(), + categoryId: treats.id, payeeName: "Bookshop", ownerMemberId: RAVI, + }); + execute(db, `UPDATE transactions SET created_at = '2999-01-01T00:00:00.000+05:30' WHERE id = ?`, his.id); + + const app = await startTestApp(db, { memberId: PRIYA }); + try { + const body = await (await app.get("/add")).text(); + assert.match( + body, new RegExp(`