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,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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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!(
|
||||
|
||||
Reference in New Issue
Block a user