Server: web client under /app/ (D-132), analytics E-34 trends and E-35 forecast (D-133)
Build Kvitt images / build (push) Failing after 1m20s

Part A (D-132): KVITT_WEB_DIR (default /app) served under /app/ by a small handler: /app redirects to
/app/, app routes fall back to index.html, a missing assets/* file is 404, assets cached immutable,
index.html no-store, the app's own CSP plus nosniff, DENY and no-referrer. Unsafe paths never reach the
file system. /app paths count against the unauthenticated per-IP limit. Without the directory every /app
path stays 404 not_found. server/Dockerfile gains a Node 24 LTS stage (pinned by digest) building web/,
passed through the Rust stage so npm and rustc never run at once on the Pi 3; the build context is the
repository root with an allowlist .dockerignore (server/.dockerignore removed); workflow, build-images.sh
and infra/dev/build.sh follow.

Part B (D-133): GET /analytics/trends (E-34) and GET /analytics/forecast (E-35), authenticated, read-only,
SQL month buckets; rules (half-up average, trailing 3-month moving average, percent against the rounded
average, template occurrence per month) in kvitt-domain::analytics. OpenAPI, examples, contracts-check
and tests updated (35 operations).

Cargo.lock: rustls-webpki 0.103.16 and webpki-root-certs 1.0.10 (RUSTSEC-2026-0382, -0383) so that
cargo audit passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
2026-10-21 20:52:46 +02:00
parent 7bddc50a53
commit 50a0d6792a
32 changed files with 2247 additions and 48 deletions
@@ -0,0 +1,50 @@
{
"measure": "household",
"today": "2026-10-17",
"current_month": "2026-10",
"months": 3,
"household": {
"total_ore": 153000,
"average_ore": 51000,
"last_month_ore": 78000,
"last_month_vs_average_pct": 53,
"current_month_ore": 12000,
"points": [
{ "month": "2026-07", "amount_ore": 40000, "moving_average_ore": 40000 },
{ "month": "2026-08", "amount_ore": 35000, "moving_average_ore": 37500 },
{ "month": "2026-09", "amount_ore": 78000, "moving_average_ore": 51000 }
]
},
"categories": [
{
"category_id": "01920000-0000-7000-8000-000000000001",
"trend": {
"total_ore": 93000,
"average_ore": 31000,
"last_month_ore": 63000,
"last_month_vs_average_pct": 103,
"current_month_ore": 12000,
"points": [
{ "month": "2026-07", "amount_ore": 30000, "moving_average_ore": 30000 },
{ "month": "2026-08", "amount_ore": 0, "moving_average_ore": 15000 },
{ "month": "2026-09", "amount_ore": 63000, "moving_average_ore": 31000 }
]
}
},
{
"category_id": "01920000-0000-7000-8000-000000000003",
"trend": {
"total_ore": 60000,
"average_ore": 20000,
"last_month_ore": 15000,
"last_month_vs_average_pct": -25,
"current_month_ore": 0,
"points": [
{ "month": "2026-07", "amount_ore": 10000, "moving_average_ore": 10000 },
{ "month": "2026-08", "amount_ore": 35000, "moving_average_ore": 22500 },
{ "month": "2026-09", "amount_ore": 15000, "moving_average_ore": 20000 }
]
}
}
]
}
@@ -0,0 +1,31 @@
{
"measure": "household",
"today": "2026-10-17",
"current_month": "2026-10",
"accounts": [
{
"account_id": "0192a003-0000-7000-8000-000000000001",
"current_month_ore": 12000,
"months": [
{ "month": "2026-11", "recurring_ore": 4900, "baseline_ore": 40000, "forecast_ore": 44900 },
{ "month": "2026-12", "recurring_ore": 0, "baseline_ore": 40000, "forecast_ore": 40000 }
]
},
{
"account_id": "0192a003-0000-7000-8000-000000000002",
"current_month_ore": 0,
"months": [
{ "month": "2026-11", "recurring_ore": 15000, "baseline_ore": 1000, "forecast_ore": 16000 },
{ "month": "2026-12", "recurring_ore": 15000, "baseline_ore": 1000, "forecast_ore": 16000 }
]
},
{
"account_id": "0192a003-0000-7000-8000-000000000003",
"current_month_ore": 0,
"months": [
{ "month": "2026-11", "recurring_ore": 0, "baseline_ore": 0, "forecast_ore": 0 },
{ "month": "2026-12", "recurring_ore": 0, "baseline_ore": 0, "forecast_ore": 0 }
]
}
]
}
+217
View File
@@ -1364,6 +1364,109 @@ paths:
'503':
$ref: '#/components/responses/Unavailable'
/analytics/trends:
get:
operationId: getAnalyticsTrends
summary: E-34 Spending trends and category averages
description: |
D-133, SPEC §14.5.7. The last `months` whole calendar months before the current one (Europe/Stockholm), for
the household and per category: each month's spend, the trailing 3-month moving average (the first months
average what exists, D-128), the total, the average per month (half-up to öre, empty months count), the last
whole month against the average in whole percent (half-up; null when the average is 0), and the current month
so far. Non-deleted expenses only; settlements never count; archived and deleted categories count.
parameters:
- name: months
in: query
required: false
description: Whole months, 1 to 24 (`invalid_request` otherwise).
schema:
type: integer
default: 6
- name: measure
in: query
required: false
description: '`household` (full amounts, whoever paid) or `my_share` (the calling user''s shares), AC-14.4; other values are `invalid_request`.'
schema:
type: string
enum: [household, my_share]
default: household
- $ref: '#/components/parameters/Protocol'
- $ref: '#/components/parameters/AppVersion'
- $ref: '#/components/parameters/AppVersionName'
- $ref: '#/components/parameters/AcceptLanguage'
responses:
'200':
description: The trends.
headers:
X-Kvitt-Server-Version:
$ref: '#/components/headers/ServerVersion'
content:
application/json; charset=utf-8:
schema:
$ref: '#/components/schemas/TrendsResponse'
'400':
$ref: '#/components/responses/BadRequest'
description: '`invalid_request` (bad `months` or `measure`, with `field`).'
'401':
$ref: '#/components/responses/Unauthorized'
'426':
$ref: '#/components/responses/ProtocolUnsupported'
'429':
$ref: '#/components/responses/RateLimited'
'500':
$ref: '#/components/responses/Internal'
'503':
$ref: '#/components/responses/Unavailable'
/analytics/forecast:
get:
operationId: getAnalyticsForecast
summary: E-35 Spending forecast per account
description: |
D-133, SPEC §14.5.7. For every non-deleted account (archived included) and each of the two calendar months
after the current one (Europe/Stockholm): `recurring_ore` = the amounts of the account's non-paused,
non-deleted recurring templates due in that month (one per template and month, within `start_period` and
`end_period`); `baseline_ore` = the account's spend over the last 3 whole months (the window of D-128's moving
average), expenses generated from a recurring template excluded, divided by 3 (half-up to öre; months without
spend count, so a new account has a lower baseline); `forecast_ore = recurring_ore + baseline_ore`. `current_month_ore` is the current month so
far, generated expenses included. `my_share` uses the shares a generation would write today.
parameters:
- name: measure
in: query
required: false
description: '`household` (full amounts, whoever paid) or `my_share` (the calling user''s shares), AC-14.4; other values are `invalid_request`.'
schema:
type: string
enum: [household, my_share]
default: household
- $ref: '#/components/parameters/Protocol'
- $ref: '#/components/parameters/AppVersion'
- $ref: '#/components/parameters/AppVersionName'
- $ref: '#/components/parameters/AcceptLanguage'
responses:
'200':
description: The forecast.
headers:
X-Kvitt-Server-Version:
$ref: '#/components/headers/ServerVersion'
content:
application/json; charset=utf-8:
schema:
$ref: '#/components/schemas/ForecastResponse'
'400':
$ref: '#/components/responses/BadRequest'
description: '`invalid_request` (bad `months` or `measure`, with `field`).'
'401':
$ref: '#/components/responses/Unauthorized'
'426':
$ref: '#/components/responses/ProtocolUnsupported'
'429':
$ref: '#/components/responses/RateLimited'
'500':
$ref: '#/components/responses/Internal'
'503':
$ref: '#/components/responses/Unavailable'
components:
securitySchemes:
device:
@@ -1630,6 +1733,10 @@ components:
SplitMode:
type: string
enum: [default, ratio, exact, full]
AnalyticsMeasure:
type: string
description: AC-14.4. `household` sums full expense amounts, whoever paid; `my_share` the calling user's shares.
enum: [household, my_share]
EntityType:
type: string
enum: [expense, settlement]
@@ -2318,6 +2425,116 @@ components:
items:
$ref: '#/components/schemas/Uuid'
# E-34 and E-35 (D-133, SPEC §14.5.7)
TrendPoint:
type: object
required: [month, amount_ore, moving_average_ore]
properties:
month:
$ref: '#/components/schemas/Period'
amount_ore:
$ref: '#/components/schemas/Ore'
moving_average_ore:
$ref: '#/components/schemas/Ore'
description: Trailing over 3 months; the first points average what exists (D-128).
Trend:
type: object
required: [total_ore, average_ore, last_month_ore, last_month_vs_average_pct, current_month_ore, points]
properties:
total_ore:
$ref: '#/components/schemas/Ore'
description: Sum over the whole months.
average_ore:
$ref: '#/components/schemas/Ore'
description: '`total_ore` ÷ months, half-up to öre (empty months count).'
last_month_ore:
$ref: '#/components/schemas/Ore'
description: The last whole month (the month before the current one).
last_month_vs_average_pct:
type: [integer, 'null']
description: '`last_month_ore` against `average_ore`, whole percent of the average, half-up; null when the average is 0.'
current_month_ore:
$ref: '#/components/schemas/Ore'
description: The current month from its first day to today (Europe/Stockholm); not one of the whole months.
points:
type: array
description: One point per whole month, oldest first, empty months as zero.
items:
$ref: '#/components/schemas/TrendPoint'
CategoryTrend:
type: object
required: [category_id, trend]
properties:
category_id:
$ref: '#/components/schemas/Uuid'
trend:
$ref: '#/components/schemas/Trend'
TrendsResponse:
type: object
required: [measure, today, current_month, months, household, categories]
properties:
measure:
$ref: '#/components/schemas/AnalyticsMeasure'
today:
$ref: '#/components/schemas/Date'
description: Today in Europe/Stockholm.
current_month:
$ref: '#/components/schemas/Period'
months:
type: integer
description: The number of whole months in every series.
household:
$ref: '#/components/schemas/Trend'
categories:
type: array
description: Every category with spending in the whole months or the current month (archived and deleted
included), by `total_ore` descending, then `category_id`.
items:
$ref: '#/components/schemas/CategoryTrend'
ForecastMonth:
type: object
required: [month, recurring_ore, baseline_ore, forecast_ore]
properties:
month:
$ref: '#/components/schemas/Period'
recurring_ore:
$ref: '#/components/schemas/Ore'
baseline_ore:
$ref: '#/components/schemas/Ore'
forecast_ore:
$ref: '#/components/schemas/Ore'
description: '`recurring_ore + baseline_ore`.'
AccountForecast:
type: object
required: [account_id, current_month_ore, months]
properties:
account_id:
$ref: '#/components/schemas/Uuid'
current_month_ore:
$ref: '#/components/schemas/Ore'
description: The current month so far, recurring-generated expenses included.
months:
type: array
description: The two months after the current one, in order.
items:
$ref: '#/components/schemas/ForecastMonth'
ForecastResponse:
type: object
required: [measure, today, current_month, accounts]
properties:
measure:
$ref: '#/components/schemas/AnalyticsMeasure'
today:
$ref: '#/components/schemas/Date'
description: Today in Europe/Stockholm.
current_month:
$ref: '#/components/schemas/Period'
accounts:
type: array
description: Every non-deleted account, archived included, by `created_at`, then `account_id`.
items:
$ref: '#/components/schemas/AccountForecast'
# ---- sync (SPEC §14.4) ----
SyncPullResponse:
type: object