use std::collections::HashMap;
use anyhow::Result;
use uuid::Uuid;
use super::store::{ProgressStore, PROJECT_SCOPE_BOOK_ID};
use crate::config::GoalsConfig;
#[derive(Debug, Clone)]
pub struct ProgressSnapshot {
pub project: BookProgress,
pub books: Vec<BookProgress>,
pub status: StatusLadderCounts,
pub streak: StreakStatus,
pub sparkline: Vec<i64>,
pub active_seconds_today: i64,
pub active_seconds_week: i64,
}
impl ProgressSnapshot {
pub fn empty() -> Self {
Self {
project: BookProgress::empty("project"),
books: Vec::new(),
status: StatusLadderCounts::default(),
streak: StreakStatus::default(),
sparkline: Vec::new(),
active_seconds_today: 0,
active_seconds_week: 0,
}
}
}
#[derive(Debug, Clone)]
pub struct BookProgress {
pub label: String,
pub today_words: i64,
pub daily_goal: Option<i64>,
pub total_words: i64,
pub target_words: Option<i64>,
pub required_pace: Option<i64>,
pub days_to_deadline: Option<i64>,
}
impl BookProgress {
pub fn empty(label: &str) -> Self {
Self {
label: label.to_string(),
today_words: 0,
daily_goal: None,
total_words: 0,
target_words: None,
required_pace: None,
days_to_deadline: None,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct StatusLadderCounts {
pub recent: Vec<(String, i64)>,
pub goals: Vec<(String, i64)>,
}
#[derive(Debug, Clone, Default)]
pub struct StreakStatus {
pub days: i64,
pub grace_used: i64,
pub grace_per_week: i64,
}
#[derive(Debug, Clone, Default)]
pub struct LiveTotals {
pub per_book: HashMap<Uuid, i64>,
pub project_total: i64,
pub book_titles: HashMap<Uuid, String>,
pub book_slugs: HashMap<Uuid, String>,
}
pub fn build_snapshot(
store: &ProgressStore,
goals: &GoalsConfig,
live: &LiveTotals,
) -> Result<ProgressSnapshot> {
let today = super::store::today_utc_days();
let project_today = store
.today_words(PROJECT_SCOPE_BOOK_ID, live.project_total)
.unwrap_or(0);
let project = BookProgress {
label: "project".into(),
today_words: project_today,
daily_goal: nonzero(goals.daily_words),
total_words: live.project_total,
target_words: None,
required_pace: None,
days_to_deadline: None,
};
let mut books: Vec<BookProgress> = Vec::new();
for (id, total) in live.per_book.iter() {
let today_w = store.today_words(*id, *total).unwrap_or(0);
let slug = live
.book_slugs
.get(id)
.cloned()
.unwrap_or_default()
.to_ascii_lowercase();
let title = live
.book_titles
.get(id)
.cloned()
.unwrap_or_else(|| slug.clone());
let goal = goals.books.get(&slug);
let target_words = goal.map(|g| g.target_words).filter(|n| *n > 0);
let days_to_deadline = goal
.filter(|g| !g.deadline.is_empty())
.and_then(|g| parse_iso_date_days(&g.deadline))
.map(|d| d - today);
let required_pace = match (target_words, days_to_deadline) {
(Some(t), Some(dd)) => required_pace(*total, t, dd),
_ => None,
};
books.push(BookProgress {
label: title,
today_words: today_w,
daily_goal: nonzero(goals.daily_words),
total_words: *total,
target_words,
required_pace,
days_to_deadline,
});
}
books.sort_by(|a, b| a.label.cmp(&b.label));
let writing_days = store.writing_days_recent(60).unwrap_or_default();
let streak = compute_streak(&writing_days, today, goals.streak_grace_per_week);
let recent = store.status_promotions_recent(7).unwrap_or_default();
let goal_pairs: Vec<(String, i64)> = goals
.status_ladder
.iter()
.map(|(k, v)| (k.to_ascii_lowercase(), *v))
.collect();
let status = StatusLadderCounts {
recent,
goals: goal_pairs,
};
let sparkline = store
.last_n_daily(PROJECT_SCOPE_BOOK_ID, live.project_total, 30)
.unwrap_or_default();
let today_start = today * 86_400;
let now_secs = today_start + 86_400; const ACTIVE_GAP_CAP_SEC: i64 = 300; let active_seconds_today = store
.active_seconds_in_range(today_start, now_secs, ACTIVE_GAP_CAP_SEC)
.unwrap_or(0);
let week_start = (today - 6) * 86_400;
let active_seconds_week = store
.active_seconds_in_range(week_start, now_secs, ACTIVE_GAP_CAP_SEC)
.unwrap_or(0);
Ok(ProgressSnapshot {
project,
books,
status,
streak,
sparkline,
active_seconds_today,
active_seconds_week,
})
}
fn nonzero(n: i64) -> Option<i64> {
if n > 0 { Some(n) } else { None }
}
fn parse_iso_date_days(s: &str) -> Option<i64> {
let parsed = chrono::NaiveDate::parse_from_str(s.trim(), "%Y-%m-%d").ok()?;
let epoch = chrono::NaiveDate::from_ymd_opt(1970, 1, 1)?;
Some(parsed.signed_duration_since(epoch).num_days())
}
pub fn compute_streak(
writing_days_desc: &[i64],
today: i64,
grace_per_week: i64,
) -> StreakStatus {
if writing_days_desc.is_empty() {
return StreakStatus {
days: 0,
grace_used: 0,
grace_per_week,
};
}
let writing: std::collections::HashSet<i64> =
writing_days_desc.iter().copied().collect();
let mut days: i64 = 0;
let mut grace_used_window: i64 = 0;
let mut window: std::collections::VecDeque<bool> =
std::collections::VecDeque::with_capacity(7); let mut d = today;
loop {
let wrote = writing.contains(&d);
let skipped = !wrote;
if window.len() == 7 {
if let Some(old) = window.pop_front() {
if old {
grace_used_window -= 1;
}
}
}
window.push_back(skipped);
if skipped {
grace_used_window += 1;
if grace_used_window > grace_per_week {
break;
}
}
days += 1;
d -= 1;
if days > 1_000 {
break;
}
}
StreakStatus {
days,
grace_used: grace_used_window.max(0),
grace_per_week,
}
}
pub fn required_pace(current: i64, target: i64, days_to_deadline: i64) -> Option<i64> {
if days_to_deadline <= 0 {
let gap = target - current;
if gap > 0 {
Some(gap)
} else {
None
}
} else {
let gap = (target - current).max(0);
Some((gap + days_to_deadline - 1) / days_to_deadline) }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn streak_unbroken() {
let today = 100;
let days = vec![100, 99, 98, 97, 96];
let s = compute_streak(&days, today, 0);
assert_eq!(s.days, 5);
}
#[test]
fn streak_breaks_no_grace() {
let today = 100;
let days = vec![100, 99, 97];
let s = compute_streak(&days, today, 0);
assert_eq!(s.days, 2);
}
#[test]
fn streak_grace_one_per_week() {
let today = 100;
let days = vec![100, 99, 97, 96];
let s = compute_streak(&days, today, 1);
assert_eq!(s.days, 5);
}
#[test]
fn required_pace_simple() {
assert_eq!(required_pace(0, 1000, 10), Some(100));
assert_eq!(required_pace(500, 1000, 5), Some(100));
assert_eq!(required_pace(1500, 1000, 5), Some(0));
}
#[test]
fn required_pace_past_due() {
assert_eq!(required_pace(500, 1000, 0), Some(500));
assert_eq!(required_pace(500, 1000, -3), Some(500));
assert_eq!(required_pace(1000, 1000, -3), None);
}
}