Files
kvitt/server/crates/kvitt-domain/src/recurring.rs
T
jacobh2 50a0d6792a
Build Kvitt images / build (push) Failing after 1m20s
Server: web client under /app/ (D-132), analytics E-34 trends and E-35 forecast (D-133)
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
2026-10-21 20:54:21 +02:00

324 lines
10 KiB
Rust

//! Recurring periods and the `next_occurrence_date` of a template (SPEC §9.5).
//!
//! Pure calendar arithmetic on `YYYY-MM` periods and `YYYY-MM-DD` dates; no clock and no time zone. The occurrence
//! *instant* (the clamped date at 06:00 Europe/Stockholm) is the server's concern; this module only fixes which
//! period and which calendar date.
use std::fmt;
use std::str::FromStr;
use serde::{Deserialize, Serialize};
/// A month, `YYYY-MM`. Orders chronologically.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Period {
year: u16,
month: u8,
}
/// Why a recurrence input is malformed.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RecurrenceError {
/// A period that is not `YYYY-MM` with month 01 to 12 (years 0001 to 9999).
PeriodInvalid,
/// `day_of_month` outside 1 to 31 (SPEC §9.6).
DayOfMonthOutOfRange,
}
impl fmt::Display for RecurrenceError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::PeriodInvalid => "period_invalid",
Self::DayOfMonthOutOfRange => "day_of_month_out_of_range",
})
}
}
impl std::error::Error for RecurrenceError {}
impl Period {
/// A period, if `month` is 1 to 12 and `year` 1 to 9999.
pub fn new(year: u16, month: u8) -> Option<Self> {
((1..=9999).contains(&year) && (1..=12).contains(&month)).then_some(Self { year, month })
}
pub fn year(self) -> u16 {
self.year
}
pub fn month(self) -> u8 {
self.month
}
/// The following month, or `None` after 9999-12.
pub fn next(self) -> Option<Self> {
if self.month == 12 {
Self::new(self.year.checked_add(1)?, 1)
} else {
Self::new(self.year, self.month + 1)
}
}
/// 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 {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
_ if is_leap(self.year) => 29,
_ => 28,
}
}
/// The occurrence date of `day_of_month` in this month, clamped to its last day (`31` in April is `30`), as
/// `YYYY-MM-DD`.
pub fn clamped_date(self, day_of_month: u8) -> Result<String, RecurrenceError> {
if !(1..=31).contains(&day_of_month) {
return Err(RecurrenceError::DayOfMonthOutOfRange);
}
Ok(format!("{self}-{:02}", day_of_month.min(self.days())))
}
}
impl fmt::Display for Period {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{:04}-{:02}", self.year, self.month)
}
}
impl FromStr for Period {
type Err = RecurrenceError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let bytes = s.as_bytes();
let digits = |r: std::ops::Range<usize>| bytes[r].iter().all(u8::is_ascii_digit);
if bytes.len() != 7 || bytes[4] != b'-' || !digits(0..4) || !digits(5..7) {
return Err(RecurrenceError::PeriodInvalid);
}
let year: u16 = s[0..4]
.parse()
.map_err(|_| RecurrenceError::PeriodInvalid)?;
let month: u8 = s[5..7]
.parse()
.map_err(|_| RecurrenceError::PeriodInvalid)?;
Self::new(year, month).ok_or(RecurrenceError::PeriodInvalid)
}
}
fn is_leap(year: u16) -> bool {
(year.is_multiple_of(4) && !year.is_multiple_of(100)) || year.is_multiple_of(400)
}
/// The template fields `next_occurrence_date` depends on.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RecurrenceSchedule {
pub day_of_month: u8,
pub start_period: String,
pub end_period: Option<String>,
pub last_generated_period: Option<String>,
pub paused: bool,
pub deleted: bool,
}
/// The first period that may still be generated: the month after `last_generated_period`, but not before
/// `start_period`; `None` when that is after `end_period` (SPEC §9.5). Paused and deleted are not considered here.
pub fn next_candidate_period(
start_period: &str,
end_period: Option<&str>,
last_generated_period: Option<&str>,
) -> Result<Option<Period>, RecurrenceError> {
let start: Period = start_period.parse()?;
let end: Option<Period> = end_period.map(str::parse).transpose()?;
let after_last: Option<Period> = match last_generated_period {
Some(last) => match last.parse::<Period>()?.next() {
Some(p) => Some(p),
None => return Ok(None),
},
None => None,
};
let candidate = after_last.map_or(start, |p| p.max(start));
Ok(match end {
Some(end) if candidate > end => None,
_ => Some(candidate),
})
}
/// `next_occurrence_date` of the RecurringTemplate wire form (SPEC §9.5): the clamped date of the first candidate
/// period after `last_generated_period` that is ≥ `start_period` and ≤ `end_period`; `None` when paused, deleted or
/// past `end_period`.
pub fn next_occurrence_date(
schedule: &RecurrenceSchedule,
) -> Result<Option<String>, RecurrenceError> {
if !(1..=31).contains(&schedule.day_of_month) {
return Err(RecurrenceError::DayOfMonthOutOfRange);
}
let candidate = next_candidate_period(
&schedule.start_period,
schedule.end_period.as_deref(),
schedule.last_generated_period.as_deref(),
)?;
if schedule.paused || schedule.deleted {
return Ok(None);
}
candidate
.map(|p| p.clamped_date(schedule.day_of_month))
.transpose()
}
#[cfg(test)]
mod tests {
use super::*;
fn schedule(day: u8, start: &str, end: Option<&str>, last: Option<&str>) -> RecurrenceSchedule {
RecurrenceSchedule {
day_of_month: day,
start_period: start.to_owned(),
end_period: end.map(str::to_owned),
last_generated_period: last.map(str::to_owned),
paused: false,
deleted: false,
}
}
fn next(s: &RecurrenceSchedule) -> Option<String> {
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!(
next(&schedule(25, "2026-11", None, None)).as_deref(),
Some("2026-11-25")
);
}
#[test]
fn after_last_generated_period() {
assert_eq!(
next(&schedule(1, "2026-01", None, Some("2026-10"))).as_deref(),
Some("2026-11-01")
);
}
#[test]
fn year_boundary() {
assert_eq!(
next(&schedule(15, "2026-01", None, Some("2026-12"))).as_deref(),
Some("2027-01-15")
);
}
#[test]
fn start_period_moved_past_last_generated() {
// Changing start_period never regenerates past periods; a later start wins over last + 1.
assert_eq!(
next(&schedule(10, "2027-03", None, Some("2026-10"))).as_deref(),
Some("2027-03-10")
);
}
#[test]
fn clamped_to_month_end() {
assert_eq!(
next(&schedule(31, "2026-04", None, None)).as_deref(),
Some("2026-04-30")
);
assert_eq!(
next(&schedule(31, "2027-02", None, None)).as_deref(),
Some("2027-02-28")
);
assert_eq!(
next(&schedule(30, "2028-02", None, None)).as_deref(),
Some("2028-02-29")
);
assert_eq!(
next(&schedule(29, "2100-02", None, None)).as_deref(),
Some("2100-02-28")
);
assert_eq!(
next(&schedule(29, "2000-02", None, None)).as_deref(),
Some("2000-02-29")
);
}
#[test]
fn end_period_inclusive_and_past() {
assert_eq!(
next(&schedule(5, "2026-01", Some("2026-11"), Some("2026-10"))).as_deref(),
Some("2026-11-05")
);
assert_eq!(
next(&schedule(5, "2026-01", Some("2026-11"), Some("2026-11"))),
None
);
assert_eq!(next(&schedule(5, "2026-12", Some("2026-11"), None)), None);
}
#[test]
fn paused_or_deleted_is_none() {
let mut s = schedule(5, "2026-01", None, None);
s.paused = true;
assert_eq!(next(&s), None);
s.paused = false;
s.deleted = true;
assert_eq!(next(&s), None);
}
#[test]
fn malformed_input() {
let bad = |s: RecurrenceSchedule, e| assert_eq!(next_occurrence_date(&s), Err(e));
bad(
schedule(0, "2026-01", None, None),
RecurrenceError::DayOfMonthOutOfRange,
);
bad(
schedule(32, "2026-01", None, None),
RecurrenceError::DayOfMonthOutOfRange,
);
bad(
schedule(1, "2026-13", None, None),
RecurrenceError::PeriodInvalid,
);
bad(
schedule(1, "2026-1", None, None),
RecurrenceError::PeriodInvalid,
);
bad(
schedule(1, "2026-01", Some("x"), None),
RecurrenceError::PeriodInvalid,
);
bad(
schedule(1, "2026-01", None, Some("2026-00")),
RecurrenceError::PeriodInvalid,
);
}
#[test]
fn period_round_trip_and_order() {
let p: Period = "2026-10".parse().unwrap();
assert_eq!(p.to_string(), "2026-10");
assert!(p < "2026-11".parse().unwrap());
assert!(p > "2025-12".parse().unwrap());
assert_eq!(p.next().map(|n| n.to_string()).as_deref(), Some("2026-11"));
assert_eq!("9999-12".parse::<Period>().unwrap().next(), None);
}
}