1use ag_ui::Tool;
11use ag_ui_a2ui::message::Component;
12use ag_ui_a2ui::toolkit::ops::SurfaceSpec;
13use serde::{Deserialize, Serialize};
14use serde_json::{Value, json};
15
16pub const SURFACE_ID: &str = "task-board";
18
19pub const ADD_TASK: &str = "add_task";
21pub const COMPLETE_TASK: &str = "complete_task";
23pub const ESTIMATE: &str = "estimate";
25pub const CLEAR_BOARD: &str = "clear_board";
27
28#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(rename_all = "camelCase")]
31pub struct Task {
32 pub id: u32,
35 pub title: String,
37 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub estimate_minutes: Option<u32>,
40 pub done: bool,
42}
43
44impl Task {
45 pub fn label(&self) -> String {
50 let estimate = match self.estimate_minutes {
51 Some(minutes) => format!(" · {minutes}m"),
52 None => String::new(),
53 };
54 format!("#{} {}{estimate}", self.id, self.title)
55 }
56}
57
58#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
65#[serde(rename_all = "camelCase")]
66pub struct Board {
67 #[serde(default)]
69 pub tasks: Vec<Task>,
70 #[serde(default)]
72 pub next_id: u32,
73}
74
75impl Board {
76 pub fn add(&mut self, title: impl Into<String>) -> &Task {
78 self.next_id += 1;
79 self.tasks.push(Task {
80 id: self.next_id,
81 title: title.into(),
82 estimate_minutes: None,
83 done: false,
84 });
85 self.tasks.last().expect("just pushed")
86 }
87
88 pub fn find(&self, needle: &str) -> Option<&Task> {
93 self.index_of(needle).map(|index| &self.tasks[index])
94 }
95
96 fn index_of(&self, needle: &str) -> Option<usize> {
97 let needle = needle.trim();
98 if let Ok(id) = needle.parse::<u32>() {
99 if let Some(index) = self.tasks.iter().position(|task| task.id == id) {
100 return Some(index);
101 }
102 }
103 let lowered = needle.to_lowercase();
104 if lowered.is_empty() {
105 return None;
106 }
107 self.tasks
108 .iter()
109 .position(|task| task.title.to_lowercase().contains(&lowered))
110 }
111
112 pub fn complete(&mut self, needle: &str) -> Option<&Task> {
114 let index = self.index_of(needle)?;
115 self.tasks[index].done = true;
116 Some(&self.tasks[index])
117 }
118
119 pub fn estimate(&mut self, needle: &str, minutes: u32) -> Option<&Task> {
121 let index = self.index_of(needle)?;
122 self.tasks[index].estimate_minutes = Some(minutes);
123 Some(&self.tasks[index])
124 }
125
126 pub fn clear(&mut self) -> usize {
129 let removed = self.tasks.len();
130 self.tasks.clear();
131 removed
132 }
133
134 pub fn open(&self) -> usize {
136 self.tasks.iter().filter(|task| !task.done).count()
137 }
138
139 pub fn done(&self) -> usize {
141 self.tasks.iter().filter(|task| task.done).count()
142 }
143
144 pub fn remaining_minutes(&self) -> u32 {
146 self.tasks
147 .iter()
148 .filter(|task| !task.done)
149 .filter_map(|task| task.estimate_minutes)
150 .sum()
151 }
152
153 pub fn summary(&self) -> String {
155 if self.tasks.is_empty() {
156 return "nothing on the board".to_owned();
157 }
158 let mut summary = format!("{} open · {} done", self.open(), self.done());
159 let minutes = self.remaining_minutes();
160 if minutes > 0 {
161 summary.push_str(&format!(" · {minutes}m to go"));
162 }
163 summary
164 }
165}
166
167pub fn tools() -> Vec<Tool> {
173 vec![
174 Tool::new(
175 ADD_TASK,
176 "Add one task to the board.",
177 json!({
178 "type": "object",
179 "properties": {
180 "title": {"type": "string", "description": "What has to be done."},
181 },
182 "required": ["title"],
183 }),
184 ),
185 Tool::new(
186 COMPLETE_TASK,
187 "Mark a task done, by id or by a piece of its title.",
188 json!({
189 "type": "object",
190 "properties": {
191 "task": {"type": "string", "description": "Task id, or part of the title."},
192 },
193 "required": ["task"],
194 }),
195 ),
196 Tool::new(
197 ESTIMATE,
198 "Put a minute estimate on a task.",
199 json!({
200 "type": "object",
201 "properties": {
202 "task": {"type": "string", "description": "Task id, or part of the title."},
203 "minutes": {"type": "integer", "minimum": 1},
204 },
205 "required": ["task", "minutes"],
206 }),
207 ),
208 Tool::new(
209 CLEAR_BOARD,
210 "Remove every task. Destructive: ask the human first.",
211 json!({"type": "object", "properties": {}}),
212 ),
213 ]
214}
215
216pub fn surface(board: &Board) -> SurfaceSpec {
223 SurfaceSpec::new(SURFACE_ID)
224 .with_components(vec![
225 Component::new("root", "Card").with("child", json!("body")),
226 Component::new("body", "Column").with("children", json!(["heading", "status", "list"])),
227 Component::new("heading", "Text")
228 .with("text", json!({"path": "/title"}))
229 .with("variant", json!("h2")),
230 Component::new("status", "Text")
231 .with("text", json!({"path": "/summary"}))
232 .with("variant", json!("caption")),
233 Component::new("list", "List")
234 .with("children", json!({"componentId": "task", "path": "/tasks"})),
237 Component::new("task", "CheckBox")
238 .with("label", json!({"path": "label"}))
240 .with("value", json!({"path": "done"})),
241 ])
242 .with_data_model(data_model(board))
243}
244
245pub fn data_model(board: &Board) -> Value {
247 json!({
248 "title": "Workshop board",
249 "summary": board.summary(),
250 "tasks": board
251 .tasks
252 .iter()
253 .map(|task| json!({"label": task.label(), "done": task.done}))
254 .collect::<Vec<_>>(),
255 })
256}
257
258#[cfg(test)]
259mod tests {
260 use super::*;
261 use ag_ui_a2ui::catalog::Catalog;
262 use ag_ui_a2ui::validate::Validator;
263
264 fn board() -> Board {
265 let mut board = Board::default();
266 board.add("draft the agenda");
267 board.add("book the room");
268 board.estimate("2", 45).expect("task 2 exists");
269 board.complete("agenda").expect("matched by title");
270 board
271 }
272
273 #[test]
274 fn tasks_are_found_by_id_and_by_title() {
275 let board = board();
276 assert_eq!(board.find("1").map(|task| task.id), Some(1));
277 assert_eq!(board.find("BOOK").map(|task| task.id), Some(2));
278 assert_eq!(board.find("nothing like this"), None);
279 }
280
281 #[test]
282 fn a_cleared_board_does_not_reuse_ids() {
283 let mut board = board();
284 assert_eq!(board.clear(), 2);
285 assert_eq!(board.add("start over").id, 3);
286 }
287
288 #[test]
289 fn the_summary_counts_only_the_work_that_is_left() {
290 let board = board();
291 assert_eq!(board.summary(), "1 open · 1 done · 45m to go");
292 assert_eq!(Board::default().summary(), "nothing on the board");
293 }
294
295 #[test]
298 fn the_surface_validates_against_the_basic_catalog() {
299 let board = board();
300 let spec = surface(&board);
301 let model = spec.data_model.clone().expect("a data model");
302
303 let report =
304 Validator::new(&Catalog::basic()).validate_surface(&spec.components, Some(&model));
305 assert!(report.is_valid(), "{:?}", report.errors);
306 assert!(report.unreachable.is_empty(), "{:?}", report.unreachable);
307 }
308}