Skip to main content

board_watch/
board.rs

1//! The client's own view of the agent's state.
2//!
3//! Deliberately *not* the server's type. A front-end team is handed a JSON
4//! shape, not a crate, and writes the struct it wants to render from — so this
5//! is a second, independent declaration of the same wire contract, and the
6//! integration tests are what keep the two honest about each other.
7//!
8//! It carries only what the view draws. `nextId` is on the wire and absent
9//! here, which is the case worth having: `Session` deserializes state into `S`
10//! by value, so a client that models less than the agent publishes has to keep
11//! working, and a client that models it *wrongly* has to say so.
12
13use serde::{Deserialize, Serialize};
14
15/// One item on the board.
16#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(rename_all = "camelCase")]
18pub struct Task {
19    /// Stable within a thread.
20    pub id: u32,
21    /// What the user asked for.
22    pub title: String,
23    /// Minutes, once somebody has estimated it.
24    #[serde(default)]
25    pub estimate_minutes: Option<u32>,
26    /// Whether it is finished.
27    #[serde(default)]
28    pub done: bool,
29}
30
31impl Task {
32    /// How the task reads on one line, checkbox included.
33    pub fn line(&self) -> String {
34        let mark = if self.done { "x" } else { " " };
35        let estimate = match self.estimate_minutes {
36            Some(minutes) => format!(" · {minutes}m"),
37            None => String::new(),
38        };
39        format!("[{mark}] #{} {}{estimate}", self.id, self.title)
40    }
41}
42
43/// The board as this client models it.
44#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(rename_all = "camelCase")]
46pub struct Board {
47    /// Every task, in the order the agent published them.
48    #[serde(default)]
49    pub tasks: Vec<Task>,
50}
51
52impl Board {
53    /// How many tasks are not done.
54    pub fn open(&self) -> usize {
55        self.tasks.iter().filter(|task| !task.done).count()
56    }
57
58    /// How many tasks are done.
59    pub fn done(&self) -> usize {
60        self.tasks.iter().filter(|task| task.done).count()
61    }
62
63    /// The one-line status the watcher prints on every state event.
64    pub fn summary(&self) -> String {
65        if self.tasks.is_empty() {
66            return "empty".to_owned();
67        }
68        format!("{} open · {} done", self.open(), self.done())
69    }
70}
71
72#[cfg(test)]
73mod tests {
74    use super::*;
75    use serde_json::json;
76
77    /// The contract this client is written against, as the agent publishes it.
78    #[test]
79    fn state_the_agent_publishes_deserializes_into_the_view_model() {
80        let published = json!({
81            "tasks": [
82                {"id": 1, "title": "draft the agenda", "done": false},
83                {"id": 2, "title": "book the room", "estimateMinutes": 45, "done": true},
84            ],
85            // Modelled by the agent, not by this client. Extra keys are ignored
86            // rather than fatal, which is what lets the two evolve apart.
87            "nextId": 2,
88        });
89
90        let board: Board = serde_json::from_value(published).expect("the published shape");
91        assert_eq!(board.summary(), "1 open · 1 done");
92        assert_eq!(board.tasks[1].line(), "[x] #2 book the room · 45m");
93    }
94
95    #[test]
96    fn an_empty_state_object_is_an_empty_board() {
97        let board: Board = serde_json::from_value(json!({})).expect("the empty shape");
98        assert_eq!(board, Board::default());
99        assert_eq!(board.summary(), "empty");
100    }
101}