1use anyhow::Result;
2use colored::Colorize;
3use serde::Serialize;
4use std::collections::{HashMap, HashSet};
5use std::fs;
6use std::path::PathBuf;
7
8use crate::models::phase::Phase;
9use crate::models::task::{Task, TaskStatus};
10use crate::storage::Storage;
11
12const VIEWER_STYLES: &str = include_str!("../../assets/viewer.css");
14const VIEWER_SCRIPT: &str = include_str!("../../assets/viewer.js");
15
16#[derive(Debug, Clone, Serialize)]
18struct WaveTask {
19 id: String,
20 title: String,
21 status: String,
22 complexity: u32,
23 dependencies: Vec<String>,
24}
25
26#[derive(Debug, Clone, Serialize)]
27struct Wave {
28 wave: usize,
29 round: usize,
30 tasks: Vec<WaveTask>,
31}
32
33pub fn run(project_root: Option<PathBuf>) -> Result<()> {
34 let storage = Storage::new(project_root);
35
36 if !storage.is_initialized() {
37 anyhow::bail!("SCUD is not initialized. Run: scud init");
38 }
39
40 let tasks_data = storage.load_tasks()?;
42
43 if tasks_data.is_empty() {
44 anyhow::bail!("No tasks found. Run: scud parse-prd <prd-file>");
45 }
46
47 let waves_data = compute_all_waves(&tasks_data);
49
50 let html = generate_viewer_html(&tasks_data, &waves_data);
52
53 let temp_dir = std::env::temp_dir();
55 let timestamp = std::time::SystemTime::now()
56 .duration_since(std::time::UNIX_EPOCH)
57 .unwrap()
58 .as_millis();
59 let temp_file = temp_dir.join(format!("scud-view-{}.html", timestamp));
60
61 fs::write(&temp_file, html)?;
62
63 println!("{}", "Opening SCUD viewer...".green());
64 webbrowser::open(temp_file.to_str().unwrap())?;
65
66 Ok(())
67}
68
69fn compute_all_waves(tasks_data: &HashMap<String, Phase>) -> HashMap<String, Vec<Wave>> {
71 let mut waves_data = HashMap::new();
72
73 for (phase_name, phase) in tasks_data {
74 let waves = compute_waves(&phase.tasks);
75 waves_data.insert(phase_name.clone(), waves);
76 }
77
78 waves_data
79}
80
81fn compute_waves(tasks: &[Task]) -> Vec<Wave> {
83 let actionable: Vec<&Task> = tasks
85 .iter()
86 .filter(|task| {
87 if task.status == TaskStatus::Done
88 || task.status == TaskStatus::Expanded
89 || task.status == TaskStatus::Cancelled
90 {
91 return false;
92 }
93
94 if let Some(ref parent_id) = task.parent_id {
96 let parent_expanded = tasks
97 .iter()
98 .find(|t| &t.id == parent_id)
99 .map(|p| p.is_expanded())
100 .unwrap_or(false);
101 if !parent_expanded {
102 return false;
103 }
104 }
105
106 true
107 })
108 .collect();
109
110 if actionable.is_empty() {
111 return vec![];
112 }
113
114 let task_ids: HashSet<String> = actionable.iter().map(|t| t.id.clone()).collect();
116
117 let mut in_degree: HashMap<String, usize> = HashMap::new();
119 let mut dependents: HashMap<String, Vec<String>> = HashMap::new();
120
121 for task in &actionable {
122 in_degree.entry(task.id.clone()).or_insert(0);
123
124 for dep in &task.dependencies {
125 if task_ids.contains(dep) {
127 *in_degree.entry(task.id.clone()).or_insert(0) += 1;
128 dependents
129 .entry(dep.clone())
130 .or_default()
131 .push(task.id.clone());
132 }
133 }
134 }
135
136 let mut waves: Vec<Wave> = Vec::new();
138 let mut remaining = in_degree.clone();
139 let mut wave_number = 1;
140
141 while !remaining.is_empty() {
142 let ready: Vec<String> = remaining
144 .iter()
145 .filter(|(_, °)| deg == 0)
146 .map(|(id, _)| id.clone())
147 .collect();
148
149 if ready.is_empty() {
150 eprintln!("Warning: Circular dependency detected in tasks");
152 break;
153 }
154
155 for task_id in &ready {
157 remaining.remove(task_id);
158
159 if let Some(deps) = dependents.get(task_id) {
160 for dep_id in deps {
161 if let Some(deg) = remaining.get_mut(dep_id) {
162 *deg = deg.saturating_sub(1);
163 }
164 }
165 }
166 }
167
168 let wave_tasks: Vec<WaveTask> = ready
170 .iter()
171 .filter_map(|task_id| {
172 actionable
173 .iter()
174 .find(|t| &t.id == task_id)
175 .map(|task| WaveTask {
176 id: task_id.clone(),
177 title: task.title.clone(),
178 status: task.status.as_str().to_string(),
179 complexity: task.complexity,
180 dependencies: task.dependencies.clone(),
181 })
182 })
183 .collect();
184
185 waves.push(Wave {
186 wave: wave_number,
187 round: 1,
188 tasks: wave_tasks,
189 });
190
191 wave_number += 1;
192 }
193
194 waves
195}
196
197fn generate_viewer_html(
199 tasks_data: &HashMap<String, Phase>,
200 waves_data: &HashMap<String, Vec<Wave>>,
201) -> String {
202 let tasks_json = serde_json::to_string_pretty(tasks_data).unwrap_or_default();
204 let waves_json = serde_json::to_string_pretty(waves_data).unwrap_or_default();
205
206 format!(
207 r#"<!DOCTYPE html>
208<html lang="en">
209<head>
210 <meta charset="UTF-8">
211 <meta name="viewport" content="width=device-width, initial-scale=1.0">
212 <title>SCUD Task Viewer</title>
213 <script src="https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.min.js"></script>
214 <style>
215{styles}
216 </style>
217</head>
218<body>
219 <header>
220 <h1>SCUD Task Viewer</h1>
221 <nav>
222 <button class="tab-btn active" data-tab="tasks">Tasks</button>
223 <button class="tab-btn" data-tab="waves">Waves</button>
224 <button class="tab-btn" data-tab="diagram">Diagram</button>
225 <button class="tab-btn" data-tab="stats">Stats</button>
226 </nav>
227 </header>
228
229 <div class="layout">
230 <main>
231 <section id="tasks" class="tab-content active">
232 <div class="phase-selector">
233 <label>Phase: </label>
234 <select id="phase-select"></select>
235 </div>
236 <div id="task-list"></div>
237 </section>
238
239 <section id="waves" class="tab-content">
240 <div class="phase-selector">
241 <label>Phase: </label>
242 <select id="waves-phase-select"></select>
243 </div>
244 <div id="waves-list"></div>
245 </section>
246
247 <section id="diagram" class="tab-content">
248 <div class="diagram-controls">
249 <label class="checkbox-label">
250 <input type="checkbox" id="simplified-view" checked> Simplified (hide subtasks)
251 </label>
252 </div>
253 <div id="diagram-phases-container"></div>
254 </section>
255
256 <section id="stats" class="tab-content">
257 <div id="stats-content"></div>
258 </section>
259 </main>
260
261 <aside id="detail-panel" class="detail-panel hidden">
262 <div class="detail-header">
263 <h2 id="detail-title">Task Details</h2>
264 <button id="close-detail" class="close-btn">×</button>
265 </div>
266 <div id="detail-content"></div>
267 </aside>
268 </div>
269
270 <script>
271 const TASKS_DATA = {tasks_json};
272 const WAVES_DATA = {waves_json};
273{script}
274 </script>
275</body>
276</html>"#,
277 styles = VIEWER_STYLES,
278 tasks_json = tasks_json,
279 waves_json = waves_json,
280 script = VIEWER_SCRIPT
281 )
282}