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,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,
|
||||
|
||||
Reference in New Issue
Block a user