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,117 @@
//! Analytics responses (D-133, SPEC §14.3.4 E-34 and E-35, rules in §14.5.7): read-only, computed per request.
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
use super::{DateSchema, OreSchema, PeriodSchema, UuidSchema, int, nullable_int};
/// What a sum adds up (AC-14.4): `household` the full expense amounts, whoever paid; `my_share` the calling user's
/// shares.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum AnalyticsMeasure {
Household,
MyShare,
}
/// One whole month of a [`Trend`].
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct TrendPoint {
#[schema(value_type = PeriodSchema)]
pub month: String,
#[schema(value_type = OreSchema)]
pub amount_ore: i64,
/// Trailing over 3 months, the first points over what exists (D-128).
#[schema(value_type = OreSchema)]
pub moving_average_ore: i64,
}
/// Spending over the last whole months, for the household or one category.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct Trend {
/// Sum over the whole months.
#[schema(value_type = OreSchema)]
pub total_ore: i64,
/// `total_ore` ÷ months, half-up to öre (empty months count).
#[schema(value_type = OreSchema)]
pub average_ore: i64,
/// The last whole month (the month before the current one).
#[schema(value_type = OreSchema)]
pub last_month_ore: i64,
/// `last_month_ore` against `average_ore` in whole percent of the average, half-up; null when the average is 0.
#[schema(schema_with = nullable_int, required = true)]
pub last_month_vs_average_pct: Option<i64>,
/// The current month from its first day to today (Europe/Stockholm), not part of the whole months.
#[schema(value_type = OreSchema)]
pub current_month_ore: i64,
/// One point per whole month, oldest first, empty months as zero.
pub points: Vec<TrendPoint>,
}
/// A category's [`Trend`].
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct CategoryTrend {
#[schema(value_type = UuidSchema)]
pub category_id: Uuid,
pub trend: Trend,
}
/// E-34 response.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct TrendsResponse {
pub measure: AnalyticsMeasure,
/// Today in Europe/Stockholm.
#[schema(value_type = DateSchema)]
pub today: String,
#[schema(value_type = PeriodSchema)]
pub current_month: String,
/// The number of whole months in every series.
#[schema(schema_with = int)]
pub months: i64,
pub household: Trend,
/// Every category with spending in the whole months or the current month, archived and deleted categories
/// included; by `total_ore` descending, then `category_id`.
pub categories: Vec<CategoryTrend>,
}
/// One forecast month of an account.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct ForecastMonth {
#[schema(value_type = PeriodSchema)]
pub month: String,
/// The account's active recurring templates due in the month.
#[schema(value_type = OreSchema)]
pub recurring_ore: i64,
/// Average per month of the last 3 whole months, recurring-generated expenses excluded.
#[schema(value_type = OreSchema)]
pub baseline_ore: i64,
/// `recurring_ore + baseline_ore`.
#[schema(value_type = OreSchema)]
pub forecast_ore: i64,
}
/// One account's forecast.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct AccountForecast {
#[schema(value_type = UuidSchema)]
pub account_id: Uuid,
/// The current month from its first day to today (Europe/Stockholm), recurring-generated expenses included.
#[schema(value_type = OreSchema)]
pub current_month_ore: i64,
/// The two months after the current one, in order.
pub months: Vec<ForecastMonth>,
}
/// E-35 response.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct ForecastResponse {
pub measure: AnalyticsMeasure,
/// Today in Europe/Stockholm.
#[schema(value_type = DateSchema)]
pub today: String,
#[schema(value_type = PeriodSchema)]
pub current_month: String,
/// Every non-deleted account, archived included, by `created_at`, then `account_id`.
pub accounts: Vec<AccountForecast>,
}
@@ -6,6 +6,7 @@
//! Request bodies ignore unknown fields (serde's default).
pub mod account;
pub mod analytics;
pub mod audit;
pub mod category;
pub mod device;
@@ -17,6 +18,10 @@ pub mod sync;
pub mod user;
pub use account::{Account, CreateAccountRequest, PatchAccountRequest, Share};
pub use analytics::{
AccountForecast, AnalyticsMeasure, CategoryTrend, ForecastMonth, ForecastResponse, Trend,
TrendPoint, TrendsResponse,
};
pub use audit::{AuditAction, AuditChange, AuditEntry, AuditSource, EntityType};
pub use category::{Category, CreateCategoryRequest, DefaultCategoryKey, PatchCategoryRequest};
pub use device::{Device, DeviceList, PushTokenRequest};
@@ -191,6 +196,25 @@ primitive_schema!(
.default(Some(serde_json::json!(512)))
);
primitive_schema!(
/// The `measure` query parameter of E-34 and E-35 (AC-14.4): default `household`.
AnalyticsMeasureParamSchema,
"AnalyticsMeasureParam",
ObjectBuilder::new()
.schema_type(SchemaType::Type(Type::String))
.enum_values(Some(["household", "my_share"]))
.default(Some(serde_json::json!("household")))
);
primitive_schema!(
/// The `months` query parameter of E-34: 1 to 24, default 6.
AnalyticsMonthsSchema,
"AnalyticsMonths",
ObjectBuilder::new()
.schema_type(SchemaType::Type(Type::Integer))
.default(Some(serde_json::json!(6)))
);
primitive_schema!(
/// The `limit` query parameter of E-31: default 500.
SyncLimitSchema,