//! Task board: the shared work registry (work/tasks.json), plus the //! assignment/progress mechanics the manager and employees act on. use bevy::prelude::Resource; use serde::{Deserialize, Serialize}; use std::path::Path; use crate::company::{slug, CompanyCfg}; use crate::sim::DAY_START; #[derive(Serialize, Deserialize, Clone, PartialEq)] pub enum TaskStatus { Backlog, Assigned, InProgress, InReview, Done, ChangesRequested, } impl TaskStatus { pub fn label(&self) -> &'static str { match self { TaskStatus::Backlog => "backlog", TaskStatus::Assigned => "assigned", TaskStatus::InProgress => "in progress", TaskStatus::InReview => "in review", TaskStatus::Done => "done", TaskStatus::ChangesRequested => "changes requested", } } } #[derive(Serialize, Deserialize, Clone)] pub struct Criterion { /// Path relative to work/project/. Formats: /// "a/b.md" exact file must exist (and ≥ min_lines lines) /// "dir/" that subtree must contain at least one file /// "dir/**" that subtree must contain ≥ min_files files pub file: String, #[serde(default)] pub min_lines: u32, #[serde(default)] pub min_files: u32, } #[derive(Serialize, Deserialize, Clone)] pub struct Task { pub id: String, pub title: String, pub description: String, pub area: String, pub effort_days: u32, pub assignee: Option, pub status: TaskStatus, /// Work units done; effort_days * 100 = done. pub progress: u32, /// Calendar day due (1-based). Manager sets this when assigning. pub due_day: Option, /// Employee pushback: "this needs 3 days, due in 1" etc. pub concern: Option, pub notes: Vec, /// Acceptance criteria: submission allowed only when all hold on disk. /// Empty → legacy counter mode (progress ticks per work action). #[serde(default)] pub acceptance: Vec, } #[derive(Serialize, Deserialize, Resource)] pub struct TaskBoard { pub tasks: Vec, /// Day counter for due dates (set by sim clock system). pub day: u32, /// Sim clock (minutes since day-1 00:00) at last save — reloads the sim /// exactly where the last session left off. #[serde(default = "day_start_f")] pub sim_minutes: f32, /// repo path (lowercase) → person who wrote it, for build-gate mail. #[serde(default)] pub file_owners: std::collections::BTreeMap, } fn day_start_f() -> f32 { DAY_START } impl Default for TaskBoard { fn default() -> Self { Self { tasks: Vec::new(), day: 1, sim_minutes: DAY_START, file_owners: Default::default() } } } impl TaskBoard { pub fn from_backlog(cfg: &CompanyCfg) -> Self { Self { day: 1, sim_minutes: DAY_START, file_owners: Default::default(), tasks: cfg .backlog .iter() .map(|b| Task { id: b.id.clone(), title: b.title.clone(), description: b.description.clone(), area: b.area.clone(), effort_days: b.effort_days, assignee: None, status: TaskStatus::Backlog, progress: 0, due_day: None, concern: None, notes: Vec::new(), acceptance: b.acceptance.clone(), }) .collect(), } } pub fn load_or_init(path: &Path, cfg: &CompanyCfg) -> Self { if let Ok(text) = std::fs::read_to_string(path) { if let Ok(board) = serde_json::from_str::(&text) { return board; } } let board = Self::from_backlog(cfg); board.save(path); board } pub fn save(&self, path: &Path) { if let Ok(text) = serde_json::to_string_pretty(self) { std::fs::write(path, text).ok(); } } pub fn task_mut(&mut self, id: &str) -> Option<&mut Task> { self.tasks.iter_mut().find(|t| t.id.eq_ignore_ascii_case(id)) } /// Fraction of the task's acceptance criteria satisfied, as a percent. /// `None` → no criteria → legacy counter mode. pub fn criteria_progress(&self, task: &Task, project: &std::path::Path) -> Option { criteria_progress_of(task, project) } /// Check names still unmet (for "can't submit yet" nudges). pub fn unmet_criteria(&self, task: &Task, project: &std::path::Path) -> Vec { unmet_criteria_of(task, project) } /// Compact board text for prompts. pub fn summary(&self) -> String { let mut out = String::new(); for t in &self.tasks { let who = t.assignee.as_deref().unwrap_or("unassigned"); let due = t .due_day .map(|d| format!(" due day {d}")) .unwrap_or_default(); let concern = t .concern .as_ref() .map(|c| format!("\n ⚠ raised concern: {c}")) .unwrap_or_default(); out.push_str(&format!( "- [{}] {} \"{}\" ({}% done, {}){}\n", t.id, who, t.title, t.progress, t.status.label(), due, )); out.push_str(&concern); if !t.acceptance.is_empty() { let files: Vec = t.acceptance.iter().map(|c| c.file.clone()).collect(); out.push_str(&format!( " Submit only once these exist on disk: {}\n", files.join(", ") )); } } out } /// One line for prompts about a person's own work. pub fn my_work(&self, me: &str) -> String { let mine: Vec<&Task> = self .tasks .iter() .filter(|t| t.assignee.as_deref() == Some(me)) .collect(); if mine.is_empty() { return " (no assigned work — ask the manager for a task)".into(); } mine.iter() .map(|t| { let crit = if t.acceptance.is_empty() { String::new() } else { format!( "\n done-for-realsies when on disk: {}", t.acceptance .iter() .map(|c| c.file.clone()) .collect::>() .join(", ") ) }; format!( " [{}] \"{}\" — {}%, {}{}{}", t.id, t.title, t.progress, t.status.label(), t.due_day .map(|d| format!(", due day {d}")) .unwrap_or_default(), crit ) }) .collect::>() .join("\n") } } /// Free-standing progress helpers (used by round.rs apply paths too). pub fn criteria_progress_of(task: &Task, project: &std::path::Path) -> Option { if task.acceptance.is_empty() { return None; } let met = task .acceptance .iter() .filter(|c| criterion_met(c, project)) .count(); Some(((met * 100) / task.acceptance.len()) as u32) } pub fn unmet_criteria_of(task: &Task, project: &std::path::Path) -> Vec { task.acceptance .iter() .filter(|c| !criterion_met(c, project)) .map(|c| c.file.clone()) .collect() } /// Does this criterion hold right now? (read-only, never panics) fn criterion_met(c: &Criterion, project: &std::path::Path) -> bool { if c.file.ends_with("/**") || c.file.ends_with("/*") { let base = c.file.trim_end_matches('*').trim_end_matches('/'); return count_files(&project.join(base)) >= c.min_files.max(1); } if c.file.ends_with('/') { return count_files(&project.join(c.file.trim_end_matches('/'))) >= 1; } let full = project.join(&c.file); match std::fs::read_to_string(&full) { Ok(text) => c.min_lines == 0 || text.lines().count() >= c.min_lines as usize, Err(_) => false, } } fn count_files(dir: &std::path::Path) -> u32 { fn walk(dir: &std::path::Path, out: &mut u32) { if let Ok(entries) = std::fs::read_dir(dir) { for e in entries.flatten() { let p = e.path(); if p.is_dir() { walk(&p, out); } else { *out += 1; } } } } let mut n = 0; walk(dir, &mut n); n } /// Assignee matching: model may send any case / partial name. pub fn find_person_name<'a>(names: impl Iterator, given: &str) -> Option { let g = given.trim().to_lowercase(); names .filter(|n| { let n = n.to_lowercase(); n == g || n.contains(&g) || g.contains(&n.split_whitespace().next().unwrap_or("")) }) .next() .map(|s| s.to_string()) } pub fn person_dir_name(name: &str) -> String { slug(name) }