Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
bd74ee4
fix(api): cap unbounded meter-group/tenant duplicate-check queries
Shinitaii Jul 27, 2026
59cd532
fix(api): allow cursor pagination combined with meterGroupId/propertyId
Shinitaii Jul 27, 2026
28967a5
fix(ui): surface network/auth errors instead of silently logging out
Shinitaii Jul 27, 2026
b6f775f
fix(ui): sign out via Firebase auth and redirect to login
Shinitaii Jul 27, 2026
c388134
feat(api): compute rate-EMA per meter group and estimate pending bill…
Shinitaii Jul 27, 2026
fe4e6df
feat(ui): show rate-EMA pending estimates on the billings page
Shinitaii Jul 27, 2026
0d502df
docs: document rate-EMA pipeline and refresh feature status
Shinitaii Jul 27, 2026
0c4ce23
chore: ignore api/functions/scripts/ migration scripts directory
Shinitaii Jul 27, 2026
cb62196
feat(mobile): add toast, confirm-sheet, and auth-notice a11y infrastr…
Shinitaii Jul 28, 2026
05b9e4e
fix(mobile): wire a11y toast/confirm/focus-management into screens
Shinitaii Jul 28, 2026
e3a20d1
feat(ui): add accessible confirm-dialog, toast, and nav-counts infras…
Shinitaii Jul 28, 2026
2c1b71f
fix(ui): wire a11y toast/confirm dialogs into layout, sidebar, and CR…
Shinitaii Jul 28, 2026
4abcc18
fix(a11y): modal/confirm dialog infra — focus trap, opacity, re-entrancy
Shinitaii Jul 28, 2026
f10e213
fix(ui): crud batch-delete robustness + properties fetch/error fixes
Shinitaii Jul 28, 2026
1b0284d
fix(ui): billings page cluster — cycle state, discovery races, toasts
Shinitaii Jul 28, 2026
75ab0ca
fix(ui): readings page cluster — OCR races, timestamp, error scoping
Shinitaii Jul 28, 2026
0b8bdad
refactor(ui): extract property meter-group-entry helpers and form fields
Shinitaii Jul 28, 2026
be2f529
fix(ui): layout/nav badge staleness, breadcrumbs, shared layout vars
Shinitaii Jul 28, 2026
b1bad2e
refactor(ui): types cleanup — wire contract, dead code, request aliases
Shinitaii Jul 28, 2026
535805e
refactor(ui): factor the 6 CRUD API modules onto a shared factory
Shinitaii Jul 28, 2026
d84a23a
fix(mobile): property filter self-reset, a11y, error/api consistency
Shinitaii Jul 28, 2026
4720106
chore: lint and audit changes
Shinitaii Jul 28, 2026
5df4a3a
chore: remove devDependencies on audit check
Shinitaii Jul 28, 2026
85e3924
chore: fix ui lint
Shinitaii Jul 28, 2026
2fd20bc
chore: fix ui prettier
Shinitaii Jul 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci-api.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ jobs:
# Fails the build on high/critical vulnerabilities; moderate (and below)
# are still printed above but only reported, not fatal.
- name: Security audit
run: npm audit --audit-level=high
run: npm audit --omit=dev --audit-level=high

- name: Lint
run: npm run lint
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/ci-ui.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ jobs:
run: npx svelte-kit sync

- name: Security audit
run: npm audit --audit-level=high
run: npm audit --omit=dev --audit-level=high

- name: Lint
run: npm run lint
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ npm-debug.log*
decisions/
.claude/

# Scripts for migrations, do not remove.
api/functions/scripts/

# Personal Claude Code hook config (machine-specific absolute paths)
.claude/settings.local.json
Expand Down
4 changes: 1 addition & 3 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
{
{
"files.exclude": {
"**/.git": true,
"**/.DS_Store": true,
"**/node_modules": true,
"**/.svelte-kit": true,
"**/dist/**": true,
"**/package-lock.json": true,
},
"files.watcherExclude": {
".git/objects/**": true,
Expand All @@ -17,7 +16,6 @@
"**/node_modules/**": true,
"**/.svelte-kit/**": true,
"**/dist/**": true,
"**/package-lock.json": true
},
"search.exclude": {
"**/node_modules": true,
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ Each page/component is organized by:
- ✅ Tenants (CRUD, batch; dynamic sorting)
- ✅ Readings (CRUD, batch; auto-billing on single create; anomaly guard; meter rollback prevention; utility extraction)
- ✅ Billings (CRUD, batch; normally auto-created; meter rollback prevention)
- ✅ Billing Cycles (CRUD, batch, validation; editable via `PATCH /:id` for rate/consumption/date corrections; version-aware consumption (handles N meter resets cumulatively via `calculateTrueReading`/`resolveVersionsSource` in `reading.util.ts`); `POST /ocr` bill photo extraction; dynamic sorting)
- ✅ Billing Cycles (CRUD, batch, validation; editable via `PATCH /:id` for rate/consumption/date corrections; version-aware consumption (handles N meter resets cumulatively via `calculateTrueReading`/`resolveVersionsSource` in `reading.util.ts`); `POST /ocr` bill photo extraction; dynamic sorting; rate-EMA — `rate_ema` per meter group recomputed on cycle create/update, feeds `Billing.estimated_cost`, see `api/functions/CLAUDE.md` → "Billing Cycles" for detail and `decisions/20260724_billing-cost-estimation-ml-finding.md` for the methodology)
- ✅ Auth (Firebase Auth: sign up, login, logout)
- ✅ Image Extraction (`POST /image-extraction/readings` + `POST /image-extraction/billings` — vision OCR via the user's configured `llm-config` vision provider, Groq or Ollama Cloud only; no Gemini)
- ✅ Reports (`GET /reports/summary`, `/consumption`, `/billing-trends`, `/collection-status`)
Expand Down
19 changes: 10 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,11 +84,12 @@ Starts API, UI, and the mobile web preview in watch mode, each in its own contai
| Tenants | ✅ Complete — CRUD, batch |
| Readings | ✅ Complete — auto-billing on create, anomaly guard, meter rollback prevention |
| Billings | ✅ Complete — normally auto-created; manual escape hatch available |
| Billing Cycles | ✅ Complete — validation (version-aware, handles N meter resets), OCR autofill via Gemini, editable for rate/consumption/date corrections |
| Image Extraction | ✅ Complete — `POST /image-extraction/readings` + `/billings` (Gemini Vision) |
| Reports | ✅ Complete — summary, consumption, billing trends, collection status |
| Bills | ⚠️ Partial stub — `POST /bills/ocr` exists; no full service layer |
| Users | ⚠️ Partial stub — `POST /users` for role management |
| Billing Cycles | ✅ Complete — validation (version-aware, handles N meter resets), OCR autofill via the configured vision provider, editable for rate/consumption/date corrections, rate-EMA (`rate_ema` per meter group, feeds `Billing.estimated_cost` — see below) |
| Image Extraction | ✅ Complete — `POST /image-extraction/readings` + `/billings` (Groq or Ollama Cloud vision provider only, configured via Settings → LLM Provider; no Gemini) |
| Reports | ✅ Complete — summary, consumption, billing trends, collection status (+ combined `GET /reports`) |
| Bills | ✅ Complete — `POST /bills/ocr` is a thin pass-through to Image Extraction's billing OCR |
| Users | ✅ Complete — `POST /users` creates Auth + Firestore profile server-side; account creation is currently disabled by a single-tenant feature flag (`ACCOUNT_CREATION_DISABLED`) |
| Rate-EMA cost estimation | ✅ Complete — per-meter-group EMA of `billing_rate` (`RATE_EMA_GAMMA_BY_UTILITY_TYPE = {water: 0.15, electricity: 0.01}`), estimates a bill's cost from known consumption before the official rate arrives; see `decisions/20260724_billing-cost-estimation-ml-finding.md` |

All DELETE endpoints use soft-delete (no hard removal). `PATCH /:id/restore` reverses it.

Expand All @@ -101,10 +102,10 @@ All DELETE endpoints use soft-delete (no hard removal). `PATCH /:id/restore` rev
| Properties | ✅ List + detail tabs, archive/restore |
| Tenants | ✅ Searchable list, archive/restore |
| Readings | ✅ Batch form + OCR suggest, archive/restore |
| Billings | ✅ Cycle-centric, OCR autofill, cycle edit modal (rate/consumption/dates), archive/restore |
| Reports | 🚧 StubAPI ready, UI not built |
| Bills / OCR | 🚧 StubAPI ready, UI not built |
| Settings | 🚧 Partial — payment + user management tabs scaffolded |
| Billings | ✅ Cycle-centric, OCR autofill, cycle edit modal (rate/consumption/dates), archive/restore, "Pending Estimates" panel (rate-EMA `estimated_cost` for billings awaiting their official cycle/rate) |
| Reports | ✅ Completefilters, summary stat cards, consumption/billing-trends charts, collection-status cards, per-property table |
| Bills / OCR | ✅ Complete3-step wizard (upload → review/map → submit) |
| Settings | ✅ Payment + LLM Provider (chat/vision + Clear Cache) complete; user management form built but disabled by feature flag (single-tenant) |

### Mobile (Android)
| Screen | Status |
Expand Down
55 changes: 55 additions & 0 deletions api/firestore.indexes.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,28 @@
],
"density": "SPARSE_ALL"
},
{
"collectionGroup": "billing_cycles",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "is_deleted", "order": "ASCENDING" },
{ "fieldPath": "created_at", "order": "DESCENDING" },
{ "fieldPath": "billing_start_date", "order": "DESCENDING" },
{ "fieldPath": "__name__", "order": "DESCENDING" }
],
"density": "SPARSE_ALL"
},
{
"collectionGroup": "billing_cycles",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "is_deleted", "order": "ASCENDING" },
{ "fieldPath": "meter_group_id", "order": "ASCENDING" },
{ "fieldPath": "billing_start_date", "order": "DESCENDING" },
{ "fieldPath": "__name__", "order": "DESCENDING" }
],
"density": "SPARSE_ALL"
},
{
"collectionGroup": "billings",
"queryScope": "COLLECTION",
Expand Down Expand Up @@ -64,6 +86,28 @@
],
"density": "SPARSE_ALL"
},
{
"collectionGroup": "billings",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "is_deleted", "order": "ASCENDING" },
{ "fieldPath": "meter_group_id", "order": "ASCENDING" },
{ "fieldPath": "created_at", "order": "DESCENDING" },
{ "fieldPath": "__name__", "order": "DESCENDING" }
],
"density": "SPARSE_ALL"
},
{
"collectionGroup": "billings",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "is_deleted", "order": "ASCENDING" },
{ "fieldPath": "meter_group_id", "order": "ASCENDING" },
{ "fieldPath": "billing_period_date", "order": "DESCENDING" },
{ "fieldPath": "__name__", "order": "DESCENDING" }
],
"density": "SPARSE_ALL"
},
{
"collectionGroup": "meter_groups",
"queryScope": "COLLECTION",
Expand Down Expand Up @@ -106,6 +150,17 @@
],
"density": "SPARSE_ALL"
},
{
"collectionGroup": "properties",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "is_deleted", "order": "ASCENDING" },
{ "fieldPath": "main_meter_group_ids", "arrayConfig": "CONTAINS" },
{ "fieldPath": "created_at", "order": "DESCENDING" },
{ "fieldPath": "__name__", "order": "DESCENDING" }
],
"density": "SPARSE_ALL"
},
{
"collectionGroup": "readings",
"queryScope": "COLLECTION",
Expand Down
102 changes: 98 additions & 4 deletions api/functions/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,15 @@ Represents buildings/units that consume utilities.
- Enforces max-tenant-count per property
- **Cascade delete**: `DELETE /:id` soft-deletes the property + all readings for this property + all billings for this property (atomic transaction)
- **Cascade restore**: `PATCH /:id/restore` restores the property + all its soft-deleted readings + billings (atomic transaction)
- `Property.main_meter_group_ids: string[]` — derived/denormalized from `meter_groups` (the
meter_group_ids this property is the main meter for), kept in sync at every create/update
(single + batch) that touches `meter_groups`. Lets `property.validator.ts` and
`billing-cycle.service.ts`'s `injectMainMeterBilling` run a targeted `$arrayContains` /
`$arrayContainsAny` query for main-meter uniqueness/lookup instead of scanning the whole
`properties` collection — same denormalization idiom as `Billing.meter_group_id` (see
`decisions/20260719_billing-meter-group-denormalization.md`). Backfilled for pre-existing
records via `scripts/backfill-property-main-meter.ts`. See
`decisions/20260726_data-retrieval-layer-modularization.md`.

---

Expand Down Expand Up @@ -348,6 +357,62 @@ Represents billing periods with validation and rate calculation.
- Returns 404 if the user has no `vision_model` configured in `llm-config` (no fallback), 422 if the vision model cannot extract the data or any numeric field is invalid
- Requires `admin` or `landlord` role

**Rate-EMA cost estimation** (`billing-cycle.util.ts` / `billing-cycle.service.ts`):
- `computeRateEmaChain(cycles, gamma)` (`billing-cycle.util.ts`) computes a chronological
exponential moving average of `billing_rate` per meter group, seeded by the first cycle's own
rate. `gamma` is resolved per utility type via `RATE_EMA_GAMMA_BY_UTILITY_TYPE = {water: 0.15,
electricity: 0.01}` — values chosen by backtest, see
`decisions/20260724_billing-cost-estimation-ml-finding.md` for the full methodology (rejected
OLS regression, rejected seasonal-naive/sibling-consumption/total-intake forecasting attempts).
- `recomputeRateEmaForMeterGroup()` (`billing-cycle.service.ts`) runs after every cycle
create/batch-create/update, looks up the meter group's gamma, writes the result onto
`BillingCycle.rate_ema`, then calls `billingService.recomputePendingEstimates()` for the same
meter group so any still-pending `Billing.estimated_cost` picks up the new rate_ema too.
Since EMA is causal (`ema[i]` only depends on `ema[i-1]`, never on anything after it), the
common single-cycle create/update path passes an `anchorDate` (the earliest
`billing_start_date` the operation could have affected) so this only fetches the nearest
earlier cycle (to seed the chain) plus cycles on/after the anchor — not the meter group's
entire history. Falls back to a full-history recompute (bounded to 1000 cycles) when no clean
anchor applies: a batch update touching several cycles at once, or cleaning up the *old*
meter group's chain after a cycle's `meter_group_id` changed. This bounded-range fetch relies
on a `billing_cycles` composite index on `(meter_group_id, is_deleted, billing_start_date)` —
see `api/firestore.indexes.json`. See
`decisions/20260726_billing-cycle-ema-recompute-scaling.md` for why this changed (a live
`POST /billing-cycles` was taking 9+ seconds) and what else was fixed alongside it
(`CachedRepository`'s batch cache writes were also fully sequential). The seed-plus-affected
fetch itself goes through the shared `fetchSeedAndAffected<T>()` in
`src/utils/anchor-fetch.util.ts` — a generic "nearest-before + on/after-anchor" bounded read
over any per-user `CachedRepository`, reused by `recomputePendingEstimates()` below rather than
each reimplementing the same shape.
- `Billing.estimated_cost` (`billing.model.ts`) is set at auto-billing time as
`consumption × latest closed cycle's rate_ema` (`billing.service.ts`), then **kept live**:
`recomputePendingEstimates()` recomputes it for every billing with no closed `BillingCycle`
referencing it yet, whenever that meter group's `rate_ema` chain changes — a new cycle, a
`PATCH /billing-cycles/:id` rate correction, or a `rate_ema` backfill. Once a cycle picks up a
billing (referenced in its `billing_ids`), the billing is no longer "pending" and its estimate
stops being touched. See `decisions/20260724_billing-cost-estimation-ml-finding.md`'s "frozen
snapshot" follow-up for the history of this gap and why it was closed this way rather than via
a separate compute pipeline. Like the cycle-chain recompute above, this is anchor-bounded via
`fetchSeedAndAffected()` on `Billing.billing_period_date` (`billing_period_date >= anchorDate`)
instead of scanning every billing in the meter group — the caller
(`recomputeRateEmaForMeterGroup`) only passes an `anchorDate` through when a cycle already
existed before it (i.e. the bounded cycle fetch found a seed cycle); on a meter group's
*very first* cycle ever, it's omitted and this falls back to a full scan (bounded to 1000
billings), since older still-pending billings that were stuck at `estimated_cost: null` (no
rate_ema existed at all yet) would otherwise be wrongly excluded by an anchor that only looks
forward. Relies on a `billings` composite index on
`(is_deleted, meter_group_id, billing_period_date)` — see `api/firestore.indexes.json`.
- `scripts/backfill-rate-ema.ts` — one-time idempotent migration for `BillingCycle` documents that
predate `rate_ema`, or that need re-backfilling after a gamma change (`--dry-run` writes a
report, `--apply` writes for real; safe to re-run). Since it writes `BillingCycle` documents
directly via raw Firestore batches (bypassing `recomputeRateEmaForMeterGroup`), it also runs its
own second pass that mirrors `recomputePendingEstimates()` against raw Firestore, so a rerun
after a gamma change fixes pending `Billing.estimated_cost` values too, not just `rate_ema`.
- `scripts/rate-ema-backtest.js`, `regression-coefficients.js`, `seasonal-naive-backtest.js`,
`sibling-consumption-backtest.js`, `total-intake-backtest.js` are one-off analysis/backtest
scripts that produced this design — not part of the runtime app, kept for reproducibility. Full
detail in the decision doc above; not duplicated here.

---

### Image Extraction (`/image-extraction` — protected)
Expand Down Expand Up @@ -379,11 +444,15 @@ Read-only analytics endpoints for billing summaries and trends. Accepts optional

| Method | Path | Purpose |
|--------|------|---------|
| GET | `/reports` | Combined fetch — summary + consumption + billing-trends + collection-status from one shared join. Used by the Reports UI page (single HTTP call instead of four) |
| GET | `/reports/summary` | Key billing metrics: total revenue, collection rate, payment status breakdown |
| GET | `/reports/consumption` | Consumption breakdown by month and by property |
| GET | `/reports/billing-trends` | Billing amounts (billed, collected, pending, overdue) grouped by month |
| GET | `/reports/collection-status` | Billing counts and amounts grouped by payment status |

The four granular endpoints remain available for other callers (e.g. the chatbot's tool-calling)
even though the Reports UI page only calls the combined `GET /reports`.

**Query params** (all optional, all endpoints):
- `startDate` / `endDate` — ISO 8601 filter on billing cycle dates
- `meterGroupId` — filter by specific meter group
Expand Down Expand Up @@ -417,12 +486,10 @@ Restricted to the `admin` role (`requireRole("admin")` in `chatbot.route.ts`)

### Stub & Incomplete Features

The following feature folders exist but are **not fully implemented**:

| Folder | Status | Notes |
|--------|--------|-------|
| `bills/` | ⚠️ Partial | `POST /bills/ocr` — OCR via `llm-config` `vision_model`; no model/service/repository. Functionally overlaps with `image-extraction/billings` |
| `user/` | ⚠️ Partial | `POST /users` — create user record; no model/service/repository. Auth covered by `auth/` |
| `bills/` | ✅ Complete (intentionally thin) | `POST /bills/ocr` — no model/service/repository by design; delegates to `ImageExtractionService.extractBillingFromImage()` rather than duplicating it. Not a stub — it's a deliberate pass-through |
| `user/` | ✅ Complete | `POST /users` — creates both the Firebase Auth account and Firestore profile server-side via the Admin SDK in one call; no model/service/repository since it's a single-purpose admin action, not CRUD. Account creation is currently disabled client-side via the UI's `ACCOUNT_CREATION_DISABLED` flag (single-tenant), not an API limitation |
| `audit/` | ❌ Stub | `audit.model.ts` only — not mounted |

---
Expand Down Expand Up @@ -650,6 +717,9 @@ api/functions/src/
├── pagination.util.ts → PaginatedResult<T>, cursor-based pagination
├── error.util.ts → AppError, handleValidationError()
├── logger.util.ts → Pino logger instance
├── cascade-delete.util.ts → Cross-cutting cascade delete/restore/purge (meter-group, property, reading)
├── list-cache.util.ts → Per-user list-cache primitives (loadAll, paginate, listAppendMany/listUpdateMany/listRemoveMany — batched to avoid races from concurrent single-item calls on the same cache key)
├── anchor-fetch.util.ts → fetchSeedAndAffected<T>() — shared bounded "seed nearest-before + affected on/after anchor" read, see "Rate-EMA cost estimation" below
└── ... (sanitize, firestore conversion)
```

Expand Down Expand Up @@ -860,6 +930,30 @@ Methods:
hard-deleted in a transaction. Public `DELETE /:id` endpoints map to `softDelete`, per the
soft-delete-only decision (D1).

`SearchOptions.filters`' `RangeFilter` supports `$gte`/`$lte`/`$gt`/`$lt` and, as of
`decisions/20260726_data-retrieval-layer-modularization.md`, `$arrayContains` /
`$arrayContainsAny` (native Firestore `array-contains` / `array-contains-any`; the latter throws
if given more than 10 values, matching this codebase's existing batch-size cap).

### CachedRepository<T>
Wraps `Repository<T>` with a two-tier cache (per-item + per-user list). Located in
`src/lib/cached-repository.lib.ts`; every feature constructs one per-request via a local
`repoFor(userId)` helper.

- `search(options)` / `searchAll(options)` — serve active-item reads from the user's cached full
list, filtered/paginated in memory. **Only equality filters work here** —
`applyFilters()` throws on any range- or array-shaped filter value, since the in-memory list
cache can't express them. This is the right tool for simple paginated list endpoints, where
loading the whole collection once and slicing in memory is the point.
- `searchDirect(options: SearchOptions<T>)` → `PaginatedResult<T>` — thin pass-through to
`this.repo.search(options)`, bypassing the list cache entirely. **The sanctioned tool for
correctness-critical or narrowly-scoped reads** (recompute functions, validators) where loading
a user's entire collection into memory would be wasteful or wrong — supports the full range and
array-contains filter set `Repository.search()` does. Prefer this over `search()`/`searchAll()`
any time the caller isn't serving a simple paginated list endpoint; don't reach around
`CachedRepository` to the raw `Repository`/`Firestore` for this — that was the one-off shape
this method replaced. See `decisions/20260726_data-retrieval-layer-modularization.md`.

### AppError
Custom error class with HTTP status. Located in `src/utils/error.util.ts`.

Expand Down
Loading
Loading