- Foldable left panel (user profile) and right panel (task details) - Clicking a task in the list or graph node selects it and shows details - Both views (task list + graph) always mounted via absolute inset-0 for correct canvas dimensions; tabs toggle visibility with opacity - Graph node selection animation: other nodes repel outward (charge -600), then selected node smoothly slides to center (500ms cubic ease-out), then charge restores to -120 and graph stabilizes - Graph re-fits on tab switch and panel resize via ResizeObserver - Fix UUID string IDs throughout (backend returns UUIDs, not integers) - Add TaskDetailPanel, UserPanel components - Add CLAUDE.md project documentation Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
118 lines
3.3 KiB
Rust
118 lines
3.3 KiB
Rust
use rand::{Rng, SeedableRng};
|
|
use rand::rngs::StdRng;
|
|
|
|
use crate::models::{GraphData, GraphEdge, GraphNode, Task};
|
|
|
|
/// Build graph data from a list of tasks.
|
|
/// Nodes = tasks, edges = ~30% of all pairs, deterministic seed from task IDs.
|
|
pub fn build_graph(tasks: &[Task]) -> GraphData {
|
|
let nodes: Vec<GraphNode> = tasks
|
|
.iter()
|
|
.map(|t| GraphNode {
|
|
id: t.id.clone(),
|
|
label: t.title.clone(),
|
|
completed: t.completed,
|
|
})
|
|
.collect();
|
|
|
|
let mut edges = Vec::new();
|
|
|
|
if tasks.len() < 2 {
|
|
return GraphData { nodes, edges };
|
|
}
|
|
|
|
// Derive a deterministic seed from all task IDs concatenated
|
|
let seed_str: String = tasks.iter().map(|t| t.id.as_str()).collect::<Vec<_>>().join("");
|
|
let seed: u64 = seed_str
|
|
.bytes()
|
|
.fold(0u64, |acc, b| acc.wrapping_mul(31).wrapping_add(b as u64));
|
|
|
|
let mut rng = StdRng::seed_from_u64(seed);
|
|
|
|
for i in 0..tasks.len() {
|
|
for j in (i + 1)..tasks.len() {
|
|
// ~30% chance of an edge
|
|
if rng.gen::<f64>() < 0.30 {
|
|
let weight = rng.gen_range(0.1f32..1.0f32);
|
|
edges.push(GraphEdge {
|
|
source: tasks[i].id.clone(),
|
|
target: tasks[j].id.clone(),
|
|
weight,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
GraphData { nodes, edges }
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn make_tasks(n: usize) -> Vec<Task> {
|
|
(0..n)
|
|
.map(|i| Task {
|
|
id: format!("task-{i:04}"),
|
|
title: format!("Task {i}"),
|
|
description: None,
|
|
completed: i % 2 == 0,
|
|
created_at: i as i64,
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
#[test]
|
|
fn test_graph_node_count_matches_tasks() {
|
|
let tasks = make_tasks(10);
|
|
let graph = build_graph(&tasks);
|
|
assert_eq!(graph.nodes.len(), 10);
|
|
}
|
|
|
|
#[test]
|
|
fn test_graph_edge_count_reasonable() {
|
|
let tasks = make_tasks(10);
|
|
let graph = build_graph(&tasks);
|
|
// 10 tasks => 45 possible pairs. ~30% => roughly 5..20 edges
|
|
assert!(graph.edges.len() <= 45);
|
|
// At least some edges should exist with 10 tasks
|
|
// (statistically overwhelmingly likely, but we just check <= max)
|
|
}
|
|
|
|
#[test]
|
|
fn test_graph_deterministic() {
|
|
let tasks = make_tasks(8);
|
|
let g1 = build_graph(&tasks);
|
|
let g2 = build_graph(&tasks);
|
|
assert_eq!(g1.edges.len(), g2.edges.len());
|
|
for (e1, e2) in g1.edges.iter().zip(g2.edges.iter()) {
|
|
assert_eq!(e1.source, e2.source);
|
|
assert_eq!(e1.target, e2.target);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_empty_tasks() {
|
|
let graph = build_graph(&[]);
|
|
assert!(graph.nodes.is_empty());
|
|
assert!(graph.edges.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_single_task_no_edges() {
|
|
let tasks = make_tasks(1);
|
|
let graph = build_graph(&tasks);
|
|
assert_eq!(graph.nodes.len(), 1);
|
|
assert!(graph.edges.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_edge_weights_in_range() {
|
|
let tasks = make_tasks(10);
|
|
let graph = build_graph(&tasks);
|
|
for edge in &graph.edges {
|
|
assert!(edge.weight >= 0.1 && edge.weight < 1.0);
|
|
}
|
|
}
|
|
}
|