Skip to content

feat: transaction calendar with indefinitely projected schedules - #185

Open
dannywieser wants to merge 2 commits into
mainfrom
claude/calendar-page-transactions-cwlm3u
Open

feat: transaction calendar with indefinitely projected schedules#185
dannywieser wants to merge 2 commits into
mainfrom
claude/calendar-page-transactions-cwlm3u

Conversation

@dannywieser

@dannywieser dannywieser commented Aug 22, 2026

Copy link
Copy Markdown
Owner

Summary

Adds a /calendar page showing a month at a time, with forward/back navigation, where every day block summarises that day's transactions — both logged and scheduled. Scheduled transactions are projected indefinitely into the future.

There was no transaction concept in the repo before this, so the data model is new. It follows the habit-tracker pattern: a new backend service reads from note frontmatter, driven by an optional config block.

How a note becomes a transaction

Any note with a numeric value in the configured amountProperty is a transaction. Adding a recurrence makes it a schedule:

---
amount: -1650.00
date: 2026-01-01
description: Rent
category: housing
recurrence: monthly
---

Every property name is configurable via a new optional transactions block in app.config.json, and every field defaults, so the block can be omitted entirely. folder optionally restricts the scan to one vault directory. amount is signed (negative is money out) and tolerates $1,200.50, (45), and -45. date falls back to the note's own date, so a daily-note vault needs no explicit date. Recurrence accepts named rules (daily, biweekly, monthly, quarterly, yearly, …) and an interval form (every 2 weeks, every 18 months). An unrecognised rule degrades to a one-off rather than failing the scan.

On "projected indefinitely"

There is no horizon constant and nothing is precomputed. The calendar requests a month; recurrences are expanded for exactly that window. Day/week rules seek to the first occurrence arithmetically; month/year rules seek by month distance — so a distant future month costs the same as the current one rather than iterating from the anchor. Month rules clamp to shorter months instead of drifting (Jan 31 → Feb 28 → Mar 31). A single rule is capped at 1000 occurrences per request so an unusually wide from/to can't force an unbounded response; a month request is far below that.

The day cell

Every cell is a fixed height regardless of how much falls on the day, so rows stay uniform. A cell shows the day's logged and scheduled totals (the scheduled one marked with a repeat icon, so a projection is never mistaken for something that happened), then a count of money out and money in rather than a row per transaction.

Hovering a day — or tabbing into it — opens a panel with that day's transactions in full, each linking to its source note. The panel stays mounted rather than appearing on hover, so those links remain tabbable, and it anchors to its own edge on the first and last columns so it never spills off the page.

API

GET /transactions — returns every transaction occurrence in a date window, with the window's totals.

Query parameters (all optional): month=YYYY-MM (what the calendar uses), or from=YYYY-MM-DD&to=YYYY-MM-DD, or neither to default to the month containing today.

status is logged for a note recording a transaction directly and scheduled for a projected occurrence — it says where the entry came from, not whether its date has passed. id is unique per occurrence (<noteId>:<date>) so repeats of one note don't collide; noteId matches the id notes-api gives the same note, so occurrences link back to their source.

Success response: 200

{
  "currency": "USD",
  "from": "2026-08-01",
  "to": "2026-08-31",
  "totals": {
    "expense": -2019.17,
    "income": 5085,
    "logged": 35.31,
    "net": 3065.83,
    "scheduled": 3030.52
  },
  "transactions": [
    {
      "amount": -1650,
      "category": "housing",
      "date": "2026-08-01",
      "description": "Rent",
      "id": "8f3c1d2e-...:2026-08-01",
      "noteId": "8f3c1d2e-...",
      "obsidianUrl": "obsidian://open?vault=notes&file=finance%2Frent",
      "recurrence": "monthly",
      "status": "scheduled"
    },
    {
      "amount": -22.99,
      "category": "books",
      "date": "2026-08-04",
      "description": "Paperback",
      "id": "b1a0f5c7-...:2026-08-04",
      "noteId": "b1a0f5c7-...",
      "obsidianUrl": "obsidian://open?vault=notes&file=finance%2Fpaperback",
      "recurrence": null,
      "status": "logged"
    }
  ]
}

Error response: 400 for a malformed window (bad month, non-ISO or half-supplied from/to, or to before from)

{ "error": "month must be a YYYY-MM value" }

Error response: 500 when the vault can't be scanned

{ "error": "Unable to load transactions" }

GET /health — same shape as the other services: 200 {"status":"ok"}, or 503 {"status":"error","error":"..."} when config can't be resolved or (in obsidian mode) the vault isn't readable.

Changes by package

  • apps/transaction-tracker (new, port 3006) — the service above. Supports both obsidian and bear note sources, same dispatch as habit-tracker.
  • packages/app-config — optional transactions block; DEFAULT_TRANSACTIONS_CONFIG fills every unset field.
  • packages/markdownFILE_ID_NAMESPACE moved here from notes-api (which re-exports it) so both services scanning the same vault derive identical note ids. That's what makes the occurrence → note links work.
  • packages/utiladdMonthsToDate (day-clamping) and monthsBetween. toISODateString's timezone argument is now optional, defaulting to the runtime's, which is what a browser needs for "today".
  • packages/servicesTransactionsResponse types and useTransactionsQuery.
  • apps/web/calendar and /calendar/:month. The month lives in the route so months are linkable and the back button steps through them. buildCalendarWeeks derives each day's logged/scheduled totals and money-out/in counts, so a cell summarises itself without walking its transactions. Adds positiveText/negativeText palette tokens to all 8 themes: there was no red token, and successText is a foreground-on-green colour, so reusing it would have been wrong.
  • apps/demo-data — generates finance notes and snapshots the occurrences.
  • Infra — compose service, Dockerfile, nginx route, vite proxy, publish matrix.

Verification

npm run verify passes — 59/59 tasks (lint, typecheck, build, test). 103 tests on the service, plus web and services coverage.

Smoke-tested against the real service over the generated demo vault:

  • 2026-08 returns interleaved logged and scheduled entries with correct totals.
  • 2027-02, 2031-06, 2099-07, 2150-12 all resolve, scheduled-only, with biweekly staying in phase (paycheque count varies by month as it should).
  • 2150-12 responded in 35ms vs 27ms for the current month — confirming the arithmetic seek, not iteration.
  • 400s and the explicit from/to form behave as documented.

Also driven in Chromium in demo mode:

  • All 42 cells measure exactly one distinct height (96px).
  • Every populated cell was hovered and its detail panel checked against the viewport — none overflow.
  • An earlier round caught a bug the unit tests could not: /calendar crashed because I'd used formatDate, which returns "2026.08.22 (Sat)" rather than ISO. The component test mocks its util, so it was blind to it. Fixed via the optional-timezone toISODateString, with a regression test asserting the date passed into the grid builder matches the ISO pattern.

Notes for review

  • The data model is the main thing to check. No transaction concept existed, so I chose this shape to match the codebase. If transactions should come from somewhere else, that's the piece to redirect.
  • Demo mode is inherently limited. A static snapshot can't project on demand, so it captures a fixed window (12 months back, 24 forward) and the hook narrows it per month. Paging beyond that shows empty months in the demo only; the live app has no such limit. Documented in apps/demo-data/README.md.

Checklist

  • I added a .changeset/*.md entry for any user-visible change, or I confirm this PR contains only docs, tests, or CI changes and no changeset is needed.

claude added 2 commits August 22, 2026 12:26
Adds a month-at-a-time calendar page listing each day's transactions,
both logged and scheduled, backed by a new transaction-tracker service.

- apps/transaction-tracker: GET /transactions?month= (or ?from=&to=)
  scans note frontmatter for transactions and returns the occurrences in
  the requested window plus in/out/net totals. Notes with a recurrence
  become schedules; their occurrences are expanded on demand for whatever
  window is asked for, so projection has no end horizon. Day/week rules
  seek to the first occurrence arithmetically and month/year rules seek by
  month distance, so a distant future month costs the same as the current
  one. Month rules clamp to shorter months instead of drifting.
- packages/app-config: optional `transactions` block naming the
  frontmatter properties, currency, and an optional folder to scan; every
  field defaults, so the block can be omitted.
- packages/markdown: FILE_ID_NAMESPACE moved here from notes-api so both
  services derive the same note ids and occurrences can link to notes.
- packages/util: addMonthsToDate (clamping) and monthsBetween;
  toISODateString's timezone argument is now optional, defaulting to the
  runtime's for browser use.
- packages/services: TransactionsResponse types and useTransactionsQuery,
  with a demo-mode path that narrows the static snapshot to one month.
- apps/web: /calendar and /calendar/:month, with the month in the route so
  months are linkable and the back button steps through them. Adds
  positiveText/negativeText palette tokens for money in and out.
- apps/demo-data: generates finance notes and snapshots a bounded window
  of occurrences, since a static demo cannot project on demand.
- Docker, nginx, vite proxy and the publish matrix wire up the service.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PLNimtv3ZC5ZtoLQpMhzNS
Day cells grew to fit their transactions, so rows were all different
heights. Each cell is now a fixed height and summarises the day instead
of listing it.

- The cell shows the day's logged and scheduled totals (the scheduled one
  marked with a repeat icon), then a count of money out and money in.
- The transactions themselves move to TransactionDayDetail, a panel shown
  on hover or keyboard focus. It stays mounted so the entry links remain
  tabbable, and anchors to its own edge on the first and last columns so
  it doesn't spill off the page.
- buildCalendarWeeks now derives loggedTotal, scheduledTotal, expenseCount
  and incomeCount per day, replacing the single net total, so a cell can
  summarise itself without walking its transactions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PLNimtv3ZC5ZtoLQpMhzNS
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants