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
+148
View File
@@ -0,0 +1,148 @@
//! Server-side analytics rules (D-133, SPEC §14.5.7): averages, the trailing moving average, the percent difference
//! of the last whole month against the average, and whether a recurring template occurs in a month.
//!
//! The rounding and window rules are the ones S-12 uses on the phone (D-128, `AnalyticsRules` in
//! `:feature:analytics`): division rounds half away from zero, the moving average trails over
//! [`MOVING_AVERAGE_WINDOW`] months and its first points average what exists, and the percent is taken against the
//! rounded average. Pure: no clock, no time zone; the caller picks the months.
use crate::recurring::{Period, RecurrenceError, next_candidate_period};
/// The trailing moving average covers the month and the two before it (D-128); the forecast baseline averages the
/// same number of whole months (D-133).
pub const MOVING_AVERAGE_WINDOW: usize = 3;
/// `numerator / denominator` rounded half away from zero (`3 / 2` is 2, `-3 / 2` is -2); 0 when `denominator` is 0.
pub fn div_half_up(numerator: i64, denominator: i64) -> i64 {
if denominator == 0 {
return 0;
}
let quotient = numerator / denominator;
let remainder = numerator % denominator;
if remainder.unsigned_abs().saturating_mul(2) >= denominator.unsigned_abs() {
let sign = if (numerator < 0) == (denominator < 0) {
1
} else {
-1
};
quotient + sign
} else {
quotient
}
}
/// The average per month of `values` (one per month, empty months as 0), half-up to öre; 0 for no months.
pub fn average(values: &[i64]) -> i64 {
let count = i64::try_from(values.len()).unwrap_or(i64::MAX);
div_half_up(values.iter().sum(), count)
}
/// The trailing moving average of `values` over `window` points, half-up to öre: each point with the `window − 1`
/// before it; the first points average what exists (1, then 2 points). A `window` of 0 counts as 1.
pub fn moving_average(values: &[i64], window: usize) -> Vec<i64> {
let window = window.max(1);
(0..values.len())
.map(|i| average(&values[(i + 1).saturating_sub(window)..=i]))
.collect()
}
/// The last month against the average, in whole percent of the (rounded) average, half-up: positive above, negative
/// below. `None` when the rounded average is 0 (no spending, or less than one öre per month).
pub fn percent_vs_average(last: i64, average: i64) -> Option<i64> {
if average == 0 {
return None;
}
let difference = last - average;
let percent = div_half_up(difference.abs().saturating_mul(100), average.abs());
Some(if difference < 0 { -percent } else { percent })
}
/// Whether a template with this schedule creates an expense for `period`: `start_period ≤ period`, `period ≤
/// end_period` (if set) and `period > last_generated_period` (if set) (SPEC §9.5). Paused and deleted templates are
/// the caller's to leave out.
pub fn occurs_in_period(
start_period: &str,
end_period: Option<&str>,
last_generated_period: Option<&str>,
period: Period,
) -> Result<bool, RecurrenceError> {
let end: Option<Period> = end_period.map(str::parse).transpose()?;
let first = next_candidate_period(start_period, end_period, last_generated_period)?;
Ok(match first {
Some(first) => first <= period && end.is_none_or(|end| period <= end),
None => false,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn period(text: &str) -> Period {
text.parse().unwrap()
}
#[test]
fn half_up_division_matches_the_phone() {
assert_eq!(div_half_up(3, 2), 2);
assert_eq!(div_half_up(4, 3), 1);
assert_eq!(div_half_up(5, 3), 2);
assert_eq!(div_half_up(-3, 2), -2);
assert_eq!(div_half_up(-4, 3), -1);
assert_eq!(div_half_up(0, 7), 0);
assert_eq!(div_half_up(7, 0), 0);
}
#[test]
fn average_counts_empty_months() {
assert_eq!(average(&[100, 200, 0]), 100);
assert_eq!(average(&[1, 2]), 2);
assert_eq!(average(&[50_000, 0, 50_000]), 33_333);
assert_eq!(average(&[]), 0);
assert_eq!(average(&[0, 0, 0]), 0);
}
#[test]
fn moving_average_trails_three_and_shortens_at_the_start() {
assert_eq!(MOVING_AVERAGE_WINDOW, 3);
assert_eq!(
moving_average(&[300, 0, 600, 900, 300], MOVING_AVERAGE_WINDOW),
vec![300, 150, 300, 500, 600]
);
assert_eq!(moving_average(&[1, 2], 3), vec![1, 2]);
assert_eq!(moving_average(&[7], 3), vec![7]);
assert_eq!(moving_average(&[], 3), Vec::<i64>::new());
assert_eq!(moving_average(&[4, 8], 0), vec![4, 8]);
}
#[test]
fn percent_against_the_rounded_average() {
assert_eq!(percent_vs_average(150, 100), Some(50));
assert_eq!(percent_vs_average(50, 100), Some(-50));
assert_eq!(percent_vs_average(100, 100), Some(0));
// 1/3 above: 33.33 % rounds to 33; 2/3 above: 66.67 % rounds to 67; exactly half rounds away from zero.
assert_eq!(percent_vs_average(400, 300), Some(33));
assert_eq!(percent_vs_average(500, 300), Some(67));
assert_eq!(percent_vs_average(1_005, 1_000), Some(1));
assert_eq!(percent_vs_average(995, 1_000), Some(-1));
assert_eq!(percent_vs_average(0, 100), Some(-100));
assert_eq!(percent_vs_average(5, 0), None);
assert_eq!(percent_vs_average(0, 0), None);
}
#[test]
fn occurrence_in_a_period() {
let p = period("2026-11");
assert!(occurs_in_period("2026-01", None, Some("2026-10"), p).unwrap());
assert!(occurs_in_period("2026-11", None, None, p).unwrap());
assert!(!occurs_in_period("2026-12", None, None, p).unwrap());
assert!(occurs_in_period("2026-01", Some("2026-11"), None, p).unwrap());
assert!(!occurs_in_period("2026-01", Some("2026-10"), None, p).unwrap());
assert!(!occurs_in_period("2026-01", None, Some("2026-11"), p).unwrap());
assert!(occurs_in_period("2026-01", None, Some("2026-10"), period("2027-01")).unwrap());
assert_eq!(
occurs_in_period("2026-13", None, None, p),
Err(RecurrenceError::PeriodInvalid)
);
}
}
+7 -1
View File
@@ -1,4 +1,5 @@
//! Kvitt domain rules as pure functions (SPEC §9.1, §9.3, §9.4, §9.5 `next_occurrence_date`, §14.5).
//! Kvitt domain rules as pure functions (SPEC §9.1, §9.3, §9.4, §9.5 `next_occurrence_date`, §14.5, and the
//! server-side analytics rules of §14.5.7).
//!
//! No I/O, no clock, no randomness, no locale. Ids are plain strings (the test vectors use short ids like `"a"`);
//! money is `i64` öre and basis points are `u32`. The Kotlin `:domain` module implements the same functions and both
@@ -8,6 +9,7 @@
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used))]
mod allocation;
mod analytics;
mod balance;
mod recurring;
mod settlement;
@@ -17,6 +19,10 @@ pub use allocation::{
AccountPosition, Allocation, AllocationError, AllocationProposal, over_settled_accounts,
propose_allocation,
};
pub use analytics::{
MOVING_AVERAGE_WINDOW, average, div_half_up, moving_average, occurs_in_period,
percent_vs_average,
};
pub use balance::{BalanceExpense, BalanceSettlement, Balances, compute_balances};
pub use recurring::{
Period, RecurrenceError, RecurrenceSchedule, next_candidate_period, next_occurrence_date,
@@ -60,6 +60,15 @@ impl Period {
}
}
/// The month before, or `None` before 0001-01.
pub fn previous(self) -> Option<Self> {
if self.month == 1 {
Self::new(self.year.checked_sub(1)?, 12)
} else {
Self::new(self.year, self.month - 1)
}
}
/// Number of days in this month (Gregorian leap years).
pub fn days(self) -> u8 {
match self.month {
@@ -184,6 +193,15 @@ mod tests {
next_occurrence_date(s).unwrap()
}
#[test]
fn previous_month() {
let p = |s: &str| s.parse::<Period>().unwrap();
assert_eq!(p("2026-03").previous(), Some(p("2026-02")));
assert_eq!(p("2026-01").previous(), Some(p("2025-12")));
assert_eq!(p("0001-01").previous(), None);
assert_eq!(p("2025-12").next(), Some(p("2026-01")));
}
#[test]
fn never_generated_starts_at_start_period() {
assert_eq!(