Add side panels, task selection, graph animation, and project docs

- 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>
This commit is contained in:
Alvis
2026-04-08 11:23:06 +00:00
parent 5c7edd4bbc
commit f1d51b8cc8
23998 changed files with 3242708 additions and 0 deletions

117
backend/src/graph.rs Normal file
View File

@@ -0,0 +1,117 @@
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);
}
}
}