use serde::Serialize;
use crate::calendar::{Planned, Tier, Week};
#[derive(Debug, Clone, Serialize)]
pub struct Shipped {
pub project: String,
pub version: String,
pub day: String,
pub on_release_day: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct Queued {
pub project: String,
pub version: String,
pub title: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ReleaseDay {
pub week: Week,
pub friday: String,
pub shipped: Vec<Shipped>,
pub queued: Vec<Queued>,
}
impl ReleaseDay {
pub fn early(&self) -> usize {
self.shipped.iter().filter(|s| !s.on_release_day).count()
}
pub fn over_the_slot(&self) -> usize {
self.shipped.len().saturating_sub(1)
}
}
#[derive(Debug, Clone, Serialize)]
pub struct Day {
pub day: String,
pub on_release_day: bool,
pub projects: Vec<DayProject>,
pub releases: usize,
}
#[derive(Debug, Clone, Serialize)]
pub struct DayProject {
pub project: String,
pub versions: Vec<String>,
}
impl DayProject {
pub fn summary(&self) -> String {
match self.versions.len() {
0 => String::new(),
1..=3 => self.versions.join(" "),
n => format!(
"{}..{} ({n})",
self.versions.first().map(String::as_str).unwrap_or(""),
self.versions.last().map(String::as_str).unwrap_or("")
),
}
}
}
impl ReleaseDay {
pub fn days(&self) -> Vec<Day> {
let mut days: Vec<Day> = Vec::new();
for shipped in &self.shipped {
let day = match days.iter_mut().find(|d| d.day == shipped.day) {
Some(day) => day,
None => {
days.push(Day {
day: shipped.day.clone(),
on_release_day: shipped.on_release_day,
projects: Vec::new(),
releases: 0,
});
days.last_mut().expect("just pushed")
}
};
day.releases += 1;
match day.projects.iter_mut().find(|p| p.project == shipped.project) {
Some(project) => project.versions.push(shipped.version.clone()),
None => day.projects.push(DayProject {
project: shipped.project.clone(),
versions: vec![shipped.version.clone()],
}),
}
}
days
}
}
pub fn release_day(week: Week, versions: &[Planned]) -> ReleaseDay {
let friday = week.friday().to_string();
let mut shipped = Vec::new();
let mut queued = Vec::new();
for version in versions {
match (version.shipped, version.planned) {
(Some(shipped_week), _) if shipped_week == week => {
let day = version.shipped_at.as_deref().and_then(|s| s.split(['T', ' ']).next()).unwrap_or("").to_string();
let on_release_day = day == friday;
shipped.push(Shipped {
project: version.project.clone(),
version: version.version.clone(),
day,
on_release_day,
});
}
(None, Some(planned)) if planned == week => queued.push(Queued {
project: version.project.clone(),
version: version.version.clone(),
title: version.title.clone(),
}),
_ => {}
}
}
shipped.sort_by(|a, b| {
a.day
.cmp(&b.day)
.then_with(|| a.project.cmp(&b.project))
.then_with(|| crate::db::version_order(&a.version).cmp(&crate::db::version_order(&b.version)))
});
queued.sort_by(|a, b| {
a.project
.cmp(&b.project)
.then_with(|| crate::db::version_order(&a.version).cmp(&crate::db::version_order(&b.version)))
});
ReleaseDay { week, friday, shipped, queued }
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum Signal {
MissedCycle,
WithoutFocus,
SecondStart,
}
#[derive(Debug, Clone, Serialize)]
pub struct Raised {
pub project: String,
pub tier: Tier,
pub signal: Signal,
pub weeks: Option<i64>,
pub alongside: Option<String>,
}
#[derive(Debug, Clone)]
pub struct Standing {
pub project: String,
pub tier: Tier,
pub rhythm_weeks: Option<u32>,
pub last_shipped: Option<Week>,
pub last_touched: Option<Week>,
pub has_first_release: bool,
}
const FOCUS_WEEKS: i64 = 6;
pub fn signals(standings: &[Standing], now: Week) -> Vec<Raised> {
let mut out = Vec::new();
for standing in standings {
match standing.tier {
Tier::A => {
let Some(rhythm) = standing.rhythm_weeks.filter(|r| *r > 0) else {
continue;
};
let allowed = i64::from(rhythm) * 2;
let weeks = weeks_since(standing.last_shipped, now, allowed + 1);
if weeks > allowed {
out.push(Raised {
project: standing.project.clone(),
tier: Tier::A,
signal: Signal::MissedCycle,
weeks: Some(weeks),
alongside: None,
});
}
}
Tier::B => {
let weeks = weeks_since(standing.last_touched, now, FOCUS_WEEKS + 1);
if weeks > FOCUS_WEEKS {
out.push(Raised {
project: standing.project.clone(),
tier: Tier::B,
signal: Signal::WithoutFocus,
weeks: Some(weeks),
alongside: None,
});
}
}
Tier::C | Tier::Out => {}
}
}
let mut started: Vec<&Standing> = standings
.iter()
.filter(|s| s.tier == Tier::C && !s.has_first_release && s.last_touched.is_some())
.collect();
if started.len() > 1 {
started.sort_by(|a, b| a.last_touched.cmp(&b.last_touched).then_with(|| a.project.cmp(&b.project)));
let first = started[0].project.clone();
for standing in started.iter().skip(1) {
out.push(Raised {
project: standing.project.clone(),
tier: Tier::C,
signal: Signal::SecondStart,
weeks: None,
alongside: Some(first.clone()),
});
}
}
out.sort_by(|a, b| a.tier.cmp(&b.tier).then_with(|| b.weeks.cmp(&a.weeks)).then_with(|| a.project.cmp(&b.project)));
out
}
fn weeks_since(week: Option<Week>, now: Week, never: i64) -> i64 {
week.map(|w| w.until(now)).unwrap_or(never)
}
#[cfg(test)]
mod tests {
use super::*;
fn week(text: &str) -> Week {
Week::parse(text).unwrap()
}
fn version(project: &str, name: &str, planned: Option<&str>, shipped_at: Option<&str>) -> Planned {
Planned {
project: project.to_string(),
version: name.to_string(),
title: None,
planned: planned.map(week),
shipped: shipped_at.and_then(Week::of_recorded),
shipped_at: shipped_at.map(String::from),
}
}
#[test]
fn a_tag_on_the_friday_is_the_rule_being_kept() {
let day = release_day(week("2026-W37"), &[version("alpha", "v0.1.0", Some("2026-W37"), Some("2026-09-11T09:00:00Z"))]);
assert_eq!(day.shipped.len(), 1);
assert!(day.shipped[0].on_release_day);
assert_eq!(day.early(), 0);
assert_eq!(day.over_the_slot(), 0);
}
#[test]
fn a_tag_earlier_in_the_week_is_one_that_should_have_queued() {
let day = release_day(week("2026-W37"), &[version("alpha", "v0.1.0", Some("2026-W37"), Some("2026-09-08T09:00:00Z"))]);
assert!(!day.shipped[0].on_release_day);
assert_eq!(day.early(), 1);
}
#[test]
fn a_week_past_its_one_slot_says_by_how_much() {
let day = release_day(
week("2026-W37"),
&[
version("alpha", "v0.1.0", None, Some("2026-09-08T09:00:00Z")),
version("alpha", "v0.2.0", None, Some("2026-09-09T09:00:00Z")),
version("beta", "v1.0.0", None, Some("2026-09-11T09:00:00Z")),
],
);
assert_eq!(day.shipped.len(), 3);
assert_eq!(day.over_the_slot(), 2);
assert_eq!(day.early(), 2);
assert_eq!(day.shipped[0].version, "v0.1.0");
assert_eq!(day.shipped[2].version, "v1.0.0");
}
#[test]
fn a_busy_week_folds_into_days_rather_than_a_line_each() {
let mut versions = Vec::new();
for n in 1..=9 {
versions.push(version("alpha", &format!("v0.{n}.0"), None, Some("2026-09-08T09:00:00Z")));
}
versions.push(version("beta", "v1.0.0", None, Some("2026-09-08T09:00:00Z")));
versions.push(version("beta", "v1.1.0", None, Some("2026-09-11T09:00:00Z")));
let days = release_day(week("2026-W37"), &versions).days();
assert_eq!(days.len(), 2, "{days:?}");
assert_eq!(days[0].day, "2026-09-08");
assert!(!days[0].on_release_day);
assert_eq!(days[0].releases, 10);
assert_eq!(days[0].projects.len(), 2);
assert_eq!(days[0].projects[0].summary(), "v0.1.0..v0.9.0 (9)");
assert_eq!(days[0].projects[1].summary(), "v1.0.0");
assert!(days[1].on_release_day);
}
#[test]
fn a_span_runs_from_the_lowest_version_to_the_highest() {
let versions = vec![
version("alpha", "v0.10.0", None, Some("2026-09-08T09:00:00Z")),
version("alpha", "v0.2.0", None, Some("2026-09-08T09:00:00Z")),
version("alpha", "v0.9.0", None, Some("2026-09-08T09:00:00Z")),
version("alpha", "v0.1.0", None, Some("2026-09-08T09:00:00Z")),
];
let days = release_day(week("2026-W37"), &versions).days();
assert_eq!(days[0].projects[0].summary(), "v0.1.0..v0.10.0 (4)");
}
#[test]
fn a_planned_version_without_a_tag_is_the_queue() {
let day = release_day(
week("2026-W37"),
&[
version("alpha", "v0.1.0", Some("2026-W37"), None),
version("beta", "v0.2.0", Some("2026-W38"), None),
version("gamma", "v0.3.0", Some("2026-W37"), Some("2026-09-11T09:00:00Z")),
],
);
let queued: Vec<&str> = day.queued.iter().map(|q| q.version.as_str()).collect();
assert_eq!(queued, vec!["v0.1.0"]);
assert_eq!(day.shipped.len(), 1);
}
fn standing(project: &str, tier: Tier, rhythm: Option<u32>, shipped: Option<&str>, touched: Option<&str>, first: bool) -> Standing {
Standing {
project: project.to_string(),
tier,
rhythm_weeks: rhythm,
last_shipped: shipped.map(week),
last_touched: touched.map(week),
has_first_release: first,
}
}
#[test]
fn tier_a_fires_on_the_second_missed_cycle_not_the_first() {
let now = week("2026-W40");
let one_missed = signals(&[standing("alpha", Tier::A, Some(2), Some("2026-W36"), None, true)], now);
assert!(one_missed.is_empty(), "{one_missed:?}");
let two_missed = signals(&[standing("alpha", Tier::A, Some(2), Some("2026-W35"), None, true)], now);
assert_eq!(two_missed.len(), 1);
assert_eq!(two_missed[0].signal, Signal::MissedCycle);
assert_eq!(two_missed[0].weeks, Some(5));
}
#[test]
fn a_carrying_product_that_never_shipped_is_behind_from_the_start() {
let now = week("2026-W40");
let raised = signals(&[standing("alpha", Tier::A, Some(2), None, None, false)], now);
assert_eq!(raised.len(), 1);
assert_eq!(raised[0].signal, Signal::MissedCycle);
}
#[test]
fn tier_b_is_measured_by_the_last_turn_not_the_last_tag() {
let now = week("2026-W40");
let inside = signals(&[standing("beta", Tier::B, Some(4), Some("2026-W10"), Some("2026-W34"), true)], now);
assert!(inside.is_empty(), "{inside:?}");
let outside = signals(&[standing("beta", Tier::B, Some(4), Some("2026-W10"), Some("2026-W33"), true)], now);
assert_eq!(outside.len(), 1);
assert_eq!(outside[0].signal, Signal::WithoutFocus);
assert_eq!(outside[0].weeks, Some(7));
}
#[test]
fn tier_c_names_the_second_start_and_the_one_it_should_have_waited_for() {
let now = week("2026-W40");
let raised = signals(
&[
standing("first", Tier::C, Some(6), None, Some("2026-W32"), false),
standing("second", Tier::C, Some(6), None, Some("2026-W35"), false),
],
now,
);
let out_of_turn: Vec<&Raised> = raised.iter().filter(|r| r.signal == Signal::SecondStart).collect();
assert_eq!(out_of_turn.len(), 1, "{raised:?}");
assert_eq!(out_of_turn[0].project, "second");
assert_eq!(out_of_turn[0].alongside.as_deref(), Some("first"));
}
#[test]
fn one_declared_product_at_a_time_raises_nothing() {
let now = week("2026-W40");
let raised = signals(
&[
standing("first", Tier::C, Some(6), None, Some("2026-W32"), false),
standing("second", Tier::C, Some(6), Some("2026-W36"), Some("2026-W36"), true),
],
now,
);
assert!(raised.iter().all(|r| r.signal != Signal::SecondStart), "{raised:?}");
}
#[test]
fn a_declared_product_nobody_has_started_is_not_a_second_start() {
let now = week("2026-W40");
let raised = signals(
&[
standing("first", Tier::C, Some(6), None, Some("2026-W32"), false),
standing("second", Tier::C, Some(6), None, None, false),
],
now,
);
assert!(raised.is_empty(), "{raised:?}");
}
#[test]
fn a_project_out_of_the_rotation_raises_nothing() {
let now = week("2026-W40");
let raised = signals(&[standing("aside", Tier::Out, None, None, None, false)], now);
assert!(raised.is_empty(), "{raised:?}");
}
#[test]
fn the_worst_tier_is_listed_first() {
let now = week("2026-W40");
let raised = signals(
&[
standing("beta", Tier::B, Some(4), None, Some("2026-W20"), true),
standing("alpha", Tier::A, Some(2), Some("2026-W20"), None, true),
],
now,
);
assert_eq!(raised[0].project, "alpha");
assert_eq!(raised[0].tier, Tier::A);
}
}