//! 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<String>,
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<u32>,
/// Employee pushback: "this needs 3 days, due in 1" etc.
pub concern: Option<String>,
pub notes: Vec<String>,
/// Acceptance criteria: submission allowed only when all hold on disk.
/// Empty → legacy counter mode (progress ticks per work action).
#[serde(default)]
pub acceptance: Vec<Criterion>,
}
#[derive(Serialize, Deserialize, Resource)]
pub struct TaskBoard {
pub tasks: Vec<Task>,
/// 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<String, String>,
}
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::<TaskBoard>(&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<u32> {
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<String> {
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<String> =
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::<Vec<_>>()
.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::<Vec<_>>()
.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<u32> {
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<String> {
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<Item = &'a str>, given: &str) -> Option<String> {
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)
}