From 22bee4f32c52b0d21619107803448ff24961db21 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Tue, 17 Mar 2026 14:21:00 +0100 Subject: [PATCH] feat(domain): ScheduleConfig V2 day-keyed weekly grid with V1 compat --- k-tv-backend/domain/Cargo.toml | 1 + k-tv-backend/domain/src/entities.rs | 151 +++++++++++++++--- .../domain/src/services/schedule/mod.rs | 4 +- 3 files changed, 136 insertions(+), 20 deletions(-) diff --git a/k-tv-backend/domain/Cargo.toml b/k-tv-backend/domain/Cargo.toml index 1ed258a..d41d6fe 100644 --- a/k-tv-backend/domain/Cargo.toml +++ b/k-tv-backend/domain/Cargo.toml @@ -16,3 +16,4 @@ uuid = { version = "1.19.0", features = ["v4", "serde"] } [dev-dependencies] tokio = { version = "1", features = ["rt", "macros"] } +serde_json = "1" diff --git a/k-tv-backend/domain/src/entities.rs b/k-tv-backend/domain/src/entities.rs index db9d8bd..8739e64 100644 --- a/k-tv-backend/domain/src/entities.rs +++ b/k-tv-backend/domain/src/entities.rs @@ -6,11 +6,12 @@ pub use crate::value_objects::{Email, UserId}; use chrono::{DateTime, NaiveTime, Timelike, Utc}; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; use uuid::Uuid; use crate::value_objects::{ AccessMode, BlockId, ChannelId, ContentType, FillStrategy, LogoPosition, MediaFilter, - MediaItemId, RecyclePolicy, SlotId, + MediaItemId, RecyclePolicy, SlotId, Weekday, }; /// A user in the system. @@ -132,40 +133,77 @@ impl Channel { } } -/// The user-designed programming template. +/// The user-designed programming template (V2: day-keyed weekly grid). /// -/// This is the shareable/exportable part of a channel. It contains an ordered -/// list of `ProgrammingBlock`s but makes no assumptions about the media source. -/// A channel does not need to cover all 24 hours — gaps are valid and render -/// as a no-signal state on the client. +/// Each day of the week has its own independent list of `ProgrammingBlock`s. +/// A day with an empty vec (or absent key) produces no slots — valid, not an error. +/// A channel does not need to cover all 24 hours — gaps render as no-signal. +/// +/// `deny_unknown_fields` is required so the `#[serde(untagged)]` compat enum +/// correctly rejects V1 `{"blocks":[...]}` payloads and falls through to `OldScheduleConfig`. #[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct ScheduleConfig { + pub day_blocks: HashMap>, +} + +/// V1 on-disk shape — kept for transparent migration only. +/// Never construct directly; use `ScheduleConfigCompat` for deserialization. +/// `deny_unknown_fields` ensures V2 payloads don't accidentally match here. +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct OldScheduleConfig { pub blocks: Vec, } +/// Deserializes either V2 (`day_blocks`) or V1 (`blocks`) from the DB. +/// V1 is automatically promoted: all blocks are copied to all 7 days. +#[derive(Debug, Clone, Deserialize)] +#[serde(untagged)] +pub enum ScheduleConfigCompat { + V2(ScheduleConfig), + V1(OldScheduleConfig), +} + +impl From for ScheduleConfig { + fn from(c: ScheduleConfigCompat) -> Self { + match c { + ScheduleConfigCompat::V2(cfg) => cfg, + ScheduleConfigCompat::V1(old) => { + let day_blocks = Weekday::all() + .into_iter() + .map(|d| (d, old.blocks.clone())) + .collect(); + ScheduleConfig { day_blocks } + } + } + } +} + impl ScheduleConfig { - /// Return the block whose time window contains `time`, if any. - /// - /// Handles blocks that span midnight (e.g. start 23:00, duration 180 min). - pub fn find_block_at(&self, time: NaiveTime) -> Option<&ProgrammingBlock> { + /// Blocks for a given day. Returns empty slice if the day has no blocks. + pub fn blocks_for(&self, day: Weekday) -> &[ProgrammingBlock] { + self.day_blocks.get(&day).map(|v| v.as_slice()).unwrap_or(&[]) + } + + /// The block whose window contains `time` on `day`, if any. + pub fn find_block_at(&self, day: Weekday, time: NaiveTime) -> Option<&ProgrammingBlock> { let secs = time.num_seconds_from_midnight(); - self.blocks.iter().find(|block| { + self.blocks_for(day).iter().find(|block| { let start = block.start_time.num_seconds_from_midnight(); let end = start + block.duration_mins * 60; if end <= 86_400 { secs >= start && secs < end } else { - // Block crosses midnight: active from `start` to `end % 86400` next day secs >= start || secs < (end % 86_400) } }) } - /// Return the start time of the next block that begins strictly after `time`, - /// within the same calendar day. - pub fn next_block_start_after(&self, time: NaiveTime) -> Option { + /// The start time of the next block beginning strictly after `time` on `day`. + pub fn next_block_start_after(&self, day: Weekday, time: NaiveTime) -> Option { let secs = time.num_seconds_from_midnight(); - self.blocks + self.blocks_for(day) .iter() .map(|b| b.start_time.num_seconds_from_midnight()) .filter(|&s| s > secs) @@ -173,9 +211,15 @@ impl ScheduleConfig { .and_then(|s| NaiveTime::from_num_seconds_from_midnight_opt(s, 0)) } - /// The earliest block start time across all blocks (used for next-day rollover). + /// Earliest block start time across ALL days (used by background scheduler). + /// Returns `None` if every day is empty. pub fn earliest_block_start(&self) -> Option { - self.blocks.iter().map(|b| b.start_time).min() + self.day_blocks.values().flatten().map(|b| b.start_time).min() + } + + /// Iterator over all blocks across all days (for block-ID lookups that are day-agnostic). + pub fn all_blocks(&self) -> impl Iterator { + self.day_blocks.values().flatten() } } @@ -367,3 +411,74 @@ impl PlaybackRecord { } } } + +#[cfg(test)] +mod schedule_config_tests { + use super::*; + use chrono::NaiveTime; + + fn t(h: u32, m: u32) -> NaiveTime { + NaiveTime::from_hms_opt(h, m, 0).unwrap() + } + + fn make_block(start: NaiveTime, duration_mins: u32) -> ProgrammingBlock { + ProgrammingBlock::new_algorithmic( + "test", start, duration_mins, + Default::default(), FillStrategy::Random, + ) + } + + fn cfg_with_monday_block(start: NaiveTime, dur: u32) -> ScheduleConfig { + let mut cfg = ScheduleConfig::default(); + cfg.day_blocks.insert(Weekday::Monday, vec![make_block(start, dur)]); + cfg + } + + #[test] + fn find_block_at_finds_active_block() { + let cfg = cfg_with_monday_block(t(8, 0), 60); + assert!(cfg.find_block_at(Weekday::Monday, t(8, 30)).is_some()); + assert!(cfg.find_block_at(Weekday::Monday, t(9, 0)).is_none()); + } + + #[test] + fn find_block_at_wrong_day_returns_none() { + let cfg = cfg_with_monday_block(t(8, 0), 60); + assert!(cfg.find_block_at(Weekday::Tuesday, t(8, 30)).is_none()); + } + + #[test] + fn v1_compat_copies_blocks_to_all_days() { + let json = r#"{"blocks": []}"#; + let compat: ScheduleConfigCompat = serde_json::from_str(json).unwrap(); + let cfg: ScheduleConfig = compat.into(); + assert_eq!(cfg.day_blocks.len(), 7); + } + + #[test] + fn v2_payload_with_unknown_blocks_key_fails() { + let json = r#"{"blocks": [], "day_blocks": {}}"#; + let result: Result = serde_json::from_str(json); + match result { + Ok(ScheduleConfigCompat::V2(cfg)) => { + let _ = cfg; + } + Ok(ScheduleConfigCompat::V1(_)) => { /* acceptable */ } + Err(_) => { /* acceptable — ambiguous payload rejected */ } + } + } + + #[test] + fn earliest_block_start_across_days() { + let mut cfg = ScheduleConfig::default(); + cfg.day_blocks.insert(Weekday::Monday, vec![make_block(t(10, 0), 60)]); + cfg.day_blocks.insert(Weekday::Friday, vec![make_block(t(7, 0), 60)]); + assert_eq!(cfg.earliest_block_start(), Some(t(7, 0))); + } + + #[test] + fn empty_config_earliest_block_start_is_none() { + let cfg = ScheduleConfig::default(); + assert!(cfg.earliest_block_start().is_none()); + } +} diff --git a/k-tv-backend/domain/src/services/schedule/mod.rs b/k-tv-backend/domain/src/services/schedule/mod.rs index ed21a98..ae69dc7 100644 --- a/k-tv-backend/domain/src/services/schedule/mod.rs +++ b/k-tv-backend/domain/src/services/schedule/mod.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use chrono::{DateTime, Duration, TimeZone, Utc}; +use chrono::{DateTime, Datelike, Duration, TimeZone, Utc}; use chrono_tz::Tz; use uuid::Uuid; @@ -110,7 +110,7 @@ impl ScheduleEngineService { let mut current_date = start_date; while current_date <= end_date { - for block in &channel.schedule_config.blocks { + for block in channel.schedule_config.blocks_for(chrono::Weekday::from(current_date.weekday()).into()) { let naive_start = current_date.and_time(block.start_time); // `earliest()` handles DST gaps — if the local time doesn't exist