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
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:
@@ -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 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user