swarm / office/src/tasks.rs

raw file 9541 bytes
//! 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)
}