Skip to content

Commit 83efbfe

Browse files
committed
feat: added colors to graph
1 parent e4fbff4 commit 83efbfe

12 files changed

Lines changed: 298 additions & 74 deletions

File tree

taskin-api/api/dependencies.py

Lines changed: 61 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from calendar import c
12
from fastapi import APIRouter, Depends
23
from sqlalchemy.orm import Session
34
from models import OneOffTodoDependency, TaskStatus, get_db, Category, Todo, TodoDependency, OneOffTodo
@@ -9,9 +10,6 @@
910
)
1011

1112

12-
13-
14-
1513
router = APIRouter()
1614

1715

@@ -33,6 +31,7 @@ def get_dependency_graph(db: Session = Depends(get_db)):
3331

3432
nodes: list[DependencyNode] = []
3533
edges: list[DependencyEdge] = []
34+
nid_categories: dict[int, str] = {}
3635

3736
nid = 0
3837

@@ -48,6 +47,7 @@ def get_dependency_graph(db: Session = Depends(get_db)):
4847
node_type=NodeType.todo,
4948
)
5049
)
50+
nid_categories[nid] = todo.category.name if todo.category else "Uncategorised"
5151
todo_id_map[todo.id] = nid
5252
nid += 1
5353
for category in categories:
@@ -82,7 +82,7 @@ def get_dependency_graph(db: Session = Depends(get_db)):
8282

8383
start_node_nid = nid + 1
8484
end_node_nid = nid + 2
85-
85+
oneoff_start_nid = nid + 3
8686
nodes.append(
8787
DependencyNode(
8888
id=start_node_nid,
@@ -98,11 +98,23 @@ def get_dependency_graph(db: Session = Depends(get_db)):
9898
)
9999
)
100100

101+
nodes.append(
102+
DependencyNode(
103+
id=one_off_cat_nid,
104+
title="All One-Off Todos",
105+
node_type=NodeType.category,
106+
)
107+
)
101108
for todo in todos:
102109
downstream_deps = db.query(TodoDependency).filter(TodoDependency.depends_on_todo_id == todo.id).all()
103-
has_category_sub_dep = False # if another todo depends on the current todo
110+
has_category_sub_dep = False # if another todo depends on the current todo
104111
upstream_deps_count = db.query(TodoDependency).filter(TodoDependency.todo_id == todo.id).count()
105-
depends_on_all_oneoffs = db.query(TodoDependency).filter(TodoDependency.todo_id == todo.id, TodoDependency.depends_on_all_oneoffs == 1).count() > 0
112+
depends_on_all_oneoffs = (
113+
db.query(TodoDependency)
114+
.filter(TodoDependency.todo_id == todo.id, TodoDependency.depends_on_all_oneoffs == 1)
115+
.count()
116+
> 0
117+
)
106118
if depends_on_all_oneoffs:
107119
edges.append(
108120
DependencyEdge(
@@ -163,15 +175,47 @@ def get_dependency_graph(db: Session = Depends(get_db)):
163175
)
164176
)
165177

178+
downstream_deps = db.query(TodoDependency).filter(TodoDependency.depends_on_all_oneoffs == 1).count()
179+
upstream_deps = db.query(OneOffTodoDependency).all()
180+
181+
def connect_upstream_deps(dest_nid: int):
182+
if len(upstream_deps) == 0:
183+
edges.append(
184+
DependencyEdge(
185+
from_node_id=start_node_nid,
186+
to_node_id=dest_nid,
187+
)
188+
)
189+
else:
190+
for dep in upstream_deps:
191+
if dep.depends_on_todo_id is not None:
192+
edges.append(
193+
DependencyEdge(
194+
from_node_id=todo_id_map[dep.depends_on_todo_id],
195+
to_node_id=dest_nid,
196+
)
197+
)
198+
if dep.depends_on_category_id is not None:
199+
edges.append(
200+
DependencyEdge(
201+
from_node_id=category_id_map[dep.depends_on_category_id],
202+
to_node_id=dest_nid,
203+
)
204+
)
205+
166206
if len(oneoffs) == 0:
167207
# Connect all one-off todos control node to end node directly
168-
edges.append(
169-
DependencyEdge(
170-
from_node_id=start_node_nid,
171-
to_node_id=one_off_cat_nid,
208+
connect_upstream_deps(one_off_cat_nid)
209+
else:
210+
nodes.append(
211+
DependencyNode(
212+
id=oneoff_start_nid,
213+
title="One-Off Todos Start",
214+
node_type=NodeType.control,
172215
)
173216
)
174-
downstream_deps = db.query(TodoDependency).filter(TodoDependency.depends_on_all_oneoffs == 1).count()
217+
connect_upstream_deps(oneoff_start_nid)
218+
175219
if downstream_deps == 0:
176220
edges.append(
177221
DependencyEdge(
@@ -180,29 +224,12 @@ def get_dependency_graph(db: Session = Depends(get_db)):
180224
)
181225
)
182226
for oneoff in oneoffs:
183-
upstream_deps = db.query(OneOffTodoDependency).all()
184-
if len(upstream_deps) == 0:
185-
edges.append(
186-
DependencyEdge(
187-
from_node_id=start_node_nid,
188-
to_node_id=oneoff_id_map[oneoff.id],
189-
)
227+
edges.append(
228+
DependencyEdge(
229+
from_node_id=oneoff_start_nid,
230+
to_node_id=oneoff_id_map[oneoff.id],
190231
)
191-
for dep in upstream_deps:
192-
if dep.depends_on_todo_id is not None:
193-
edges.append(
194-
DependencyEdge(
195-
from_node_id=todo_id_map[dep.depends_on_todo_id],
196-
to_node_id=oneoff_id_map[oneoff.id],
197-
)
198-
)
199-
if dep.depends_on_category_id is not None:
200-
edges.append(
201-
DependencyEdge(
202-
from_node_id=category_id_map[dep.depends_on_category_id],
203-
to_node_id=oneoff_id_map[oneoff.id],
204-
)
205-
)
232+
)
206233
edges.append(
207234
DependencyEdge(
208235
from_node_id=oneoff_id_map[oneoff.id],
@@ -213,4 +240,5 @@ def get_dependency_graph(db: Session = Depends(get_db)):
213240
return DependencyGraph(
214241
nodes=nodes,
215242
edges=edges,
243+
node_category_map=nid_categories,
216244
)

taskin-api/api/oneoffs.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ def update_oneoff_status(oneoff_id: int, status: TaskStatus, db: Session = Depen
100100
return item
101101

102102

103-
@router.get("/oneoff-todos/recommended", response_model=List[OneOffTodoResponse])
103+
@router.get("/recommended-oneoffs", response_model=List[OneOffTodoResponse])
104104
def get_recommended_oneoff_todos(db: Session = Depends(get_db)):
105105
"""Get recommended one-off todos."""
106106
# Query all todos that are dependencies for one-off tasks

taskin-api/db_init.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ def sync_dependencies(db: Session, config: AppConfig):
127127
# Clear existing dependencies
128128
db.query(TodoDependency).delete()
129129
db.query(TodoDependencyComputed).delete()
130+
db.query(OneOffTodoDependency).delete()
130131
db.query(OneOffTodoDependencyComputed).delete()
131132
# Build todo lookup maps
132133
todo_id_map: dict[str, int] = {} # (category_name, todo_title) -> todo object

taskin-api/schemas.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from enum import Enum
22
from pydantic import BaseModel, Field, ConfigDict
3-
from typing import Optional, List
3+
from typing import Literal, Optional, List
44
from datetime import datetime
55
from models import TaskStatus
66

@@ -32,6 +32,7 @@ class TodoResponse(ORMModel):
3232
position: int = 0
3333
cumulative_in_progress_seconds: float = 0
3434

35+
3536
class CategoryWithTodos(CategoryResponse):
3637
"""Schema for category with todos"""
3738

@@ -66,12 +67,14 @@ class OneOffTodoResponse(ORMModel):
6667
description: Optional[str]
6768
status: TaskStatus
6869

70+
6971
class NodeType(Enum):
7072
todo = "todo"
7173
category = "category"
7274
oneoff = "oneoff"
7375
control = "control"
7476

77+
7578
# Dependency graph schemas
7679
class DependencyNode(BaseModel):
7780
"""A node in the dependency graph representing a todo or category"""
@@ -93,6 +96,7 @@ class DependencyGraph(BaseModel):
9396

9497
nodes: List[DependencyNode]
9598
edges: List[DependencyEdge]
99+
node_category_map: dict[int, str | Literal["Uncategorised"]]
96100

97101

98102
# Statistics/Report schemas

ui/src/App.tsx

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ function App() {
3737
const [error, setError] = useState<string | null>(null);
3838
const [pendingQueue, setPendingQueue] = useState<Array<{ id: number; status: TaskStatus }>>([]);
3939
const [oneOffs, setOneOffs] = useState<OneOffTodo[]>([]);
40+
const [recommendedOneOffs, setRecommendedOneOffs] = useState<OneOffTodo[]>([]);
4041
const [newOneTitle, setNewOneTitle] = useState('');
4142
const [newOneDesc, setNewOneDesc] = useState('');
4243
// Inline edit state for One-offs
@@ -102,6 +103,7 @@ function App() {
102103
const LS_RECOMMENDED = 'taskin_recommended';
103104
const LS_PENDING = 'taskin_pending_queue';
104105
const LS_ONEOFFS = 'taskin_oneoffs';
106+
const LS_REC_ONEOFFS = 'taskin_rec_oneoffs';
105107
const LS_ONEOFFS_PENDING = 'taskin_oneoffs_pending';
106108

107109
const saveCache = (cats: CategoryWithTodos[], rec: TodoWithCategory[]) => {
@@ -194,17 +196,22 @@ function App() {
194196
if (initial) setIsLoading(true);
195197
setError(null);
196198
try {
197-
const [categoriesData, recommendedData, oneOffData] = await Promise.all([
199+
const [categoriesData, recommendedData, oneOffData, recOneOffData] = await Promise.all([
198200
api.getCategories(),
199201
api.getRecommendedTodos(),
200202
api.getOneOffTodos(),
203+
api.getRecommendedOneOffTodos(),
201204
]);
202205
setCategories(categoriesData);
203206
setRecommendedTodos(recommendedData);
204207
setOneOffs(Array.isArray(oneOffData) ? oneOffData : []);
208+
setRecommendedOneOffs(Array.isArray(recOneOffData) ? recOneOffData : []);
205209
// recOneOffData is used only in Recommended tab rendering; keep separately if needed later
206210
saveCache(categoriesData, recommendedData);
207-
try { localStorage.setItem(LS_ONEOFFS, JSON.stringify(Array.isArray(oneOffData) ? oneOffData : [])); } catch { }
211+
try {
212+
localStorage.setItem(LS_ONEOFFS, JSON.stringify(Array.isArray(oneOffData) ? oneOffData : []));
213+
localStorage.setItem(LS_REC_ONEOFFS, JSON.stringify(Array.isArray(recOneOffData) ? recOneOffData : []));
214+
} catch { }
208215
} catch (err) {
209216
if (initial) setError('Failed to load data. Make sure the API is running.');
210217
console.error(err);
@@ -254,6 +261,7 @@ function App() {
254261
const cachedRec = localStorage.getItem(LS_RECOMMENDED);
255262
const cachedPending = localStorage.getItem(LS_PENDING);
256263
const cachedOneOffs = localStorage.getItem(LS_ONEOFFS);
264+
const cachedRecOneOffs = localStorage.getItem(LS_REC_ONEOFFS);
257265
const cachedOneOffPending = localStorage.getItem(LS_ONEOFFS_PENDING);
258266
if (cachedCats && cachedRec) {
259267
setCategories(JSON.parse(cachedCats));
@@ -268,6 +276,12 @@ function App() {
268276
setOneOffs(Array.isArray(parsed) ? parsed : []);
269277
} catch { setOneOffs([]); }
270278
}
279+
if (cachedRecOneOffs) {
280+
try {
281+
const parsed = JSON.parse(cachedRecOneOffs);
282+
setRecommendedOneOffs(Array.isArray(parsed) ? parsed : []);
283+
} catch { setRecommendedOneOffs([]); }
284+
}
271285
if (cachedPending) {
272286
setPendingQueue(JSON.parse(cachedPending));
273287
}
@@ -646,7 +660,7 @@ function App() {
646660
>
647661
<Sparkles className="w-4 h-4" />
648662
<span>Recommended</span>
649-
<span> ({recommendedTodos.length + oneOffs.filter(o => o.status === 'incomplete').length})</span>
663+
<span> ({recommendedTodos.length + recommendedOneOffs.length})</span>
650664
</button>
651665
<button
652666
onClick={() => { navigate('/oneoff'); setNavOpen(false); }}
@@ -693,7 +707,7 @@ function App() {
693707
className={`inline-flex items-center gap-2 px-3 py-1.5 text-sm rounded-md ${currentTab === 'recommended' ? 'bg-accent text-accent-foreground' : 'hover:bg-accent hover:text-accent-foreground'}`}
694708
>
695709
<Sparkles className="w-4 h-4" />
696-
Recommended ({recommendedTodos.length + oneOffs.filter(o => o.status === 'incomplete').length})
710+
Recommended ({recommendedTodos.length + recommendedOneOffs.length})
697711
</button>
698712
</NavigationMenuItem>
699713
<NavigationMenuItem>
@@ -748,7 +762,7 @@ function App() {
748762
>
749763
<Routes>
750764
<Route path="/all" element={<AllTasksPage categories={categories} summary={summary as any} onStatusChange={handleStatusChange} />} />
751-
<Route path="/recommended" element={<RecommendedPage todos={recommendedTodos} oneOffs={oneOffs.filter(o => o.status === 'incomplete')} onStatusChange={handleStatusChange} onOneOffStatusChange={handleOneOffStatusChange} />} />
765+
<Route path="/recommended" element={<RecommendedPage todos={recommendedTodos} oneOffs={recommendedOneOffs} onStatusChange={handleStatusChange} onOneOffStatusChange={handleOneOffStatusChange} />} />
752766
<Route path="/graph" element={<GraphPage />} />
753767
<Route path="/reports" element={<ReportsPage />} />
754768
<Route path="/oneoff" element={<OneOffsPage

ui/src/api.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ export const api = {
4444
},
4545

4646
async getRecommendedOneOffTodos(): Promise<OneOffTodo[]> {
47-
const response = await fetch(`${API_BASE}/oneoff-todos/recommended`);
47+
const response = await fetch(`${API_BASE}/recommended-oneoffs`);
4848
if (!response.ok) throw new Error('Failed to fetch recommended one-off todos');
4949
return response.json();
5050
},

ui/src/components/MermaidGraphView.tsx

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
22
import mermaid from 'mermaid';
33
import { api } from '../api';
44
import { DependencyGraph } from '../types';
5+
import { generateCategoryColorsUnique, baseHueForName } from '../lib/utils';
56
import { Card } from './ui/card';
67
import { RefreshCw } from 'lucide-react';
78
import svgPanZoom from 'svg-pan-zoom';
@@ -58,6 +59,8 @@ export default function MermaidGraphView() {
5859
const todoNodeIds: string[] = [];
5960
const oneoffNodeIds: string[] = [];
6061
const controlNodeIds: string[] = [];
62+
// Keep category labels by node id for grouping/coloring
63+
const categoryLabels: Record<string, string> = {};
6164

6265
// Define nodes based on node_type from API
6366
for (const n of graph.nodes) {
@@ -69,6 +72,7 @@ export default function MermaidGraphView() {
6972
// Rounded rect for categories
7073
lines.push(`${nid}("${label}")`);
7174
categoryNodeIds.push(nid);
75+
categoryLabels[nid] = n.title;
7276
break;
7377
case 'oneoff':
7478
// Stadium shape for one-offs
@@ -88,10 +92,52 @@ export default function MermaidGraphView() {
8892
}
8993
}
9094

91-
// Styles
92-
if (categoryNodeIds.length > 0) {
93-
lines.push('classDef category fill:#10b981,stroke:#0b3b2e,color:#0b3b2e');
94-
lines.push(`class ${categoryNodeIds.join(',')} category;`);
95+
// Styles: build per-category color classes using node_category_map, with fallback
96+
const byCategory: Record<string, { categories: string[]; todos: string[] }> = {};
97+
const categoryNameByNode: Record<string, string> = {};
98+
if (graph.node_category_map) {
99+
for (const [idStr, name] of Object.entries(graph.node_category_map)) {
100+
const nid = nodeIdTodo(Number(idStr));
101+
categoryNameByNode[nid] = name || 'Uncategorised';
102+
}
103+
}
104+
// Build a quick reverse edge map to find nearest category parents for todos
105+
const edgesByTo: Record<string, string[]> = {};
106+
for (const e of graph.edges) {
107+
const fromId = nodeIdTodo(e.from_node_id);
108+
const toId = nodeIdTodo(e.to_node_id);
109+
(edgesByTo[toId] ||= []).push(fromId);
110+
}
111+
// Group categories by their own titles
112+
for (const nid of categoryNodeIds) {
113+
const name = categoryLabels[nid] || 'Uncategorised';
114+
(byCategory[name] ||= { categories: [], todos: [] }).categories.push(nid);
115+
}
116+
// Group todos by provided map or nearest category parent
117+
for (const nid of todoNodeIds) {
118+
let name = categoryNameByNode[nid];
119+
if (!name) {
120+
const parents = edgesByTo[nid] || [];
121+
const catParent = parents.find(p => categoryLabels[p]);
122+
if (catParent) name = categoryLabels[catParent];
123+
}
124+
if (!name) name = 'Uncategorised';
125+
(byCategory[name] ||= { categories: [], todos: [] }).todos.push(nid);
126+
}
127+
// Define classDefs and assign classes
128+
const usedHues: number[] = [];
129+
// Sort names by their base hue to keep palette stable across renders
130+
const entries = Object.entries(byCategory).sort((a, b) => baseHueForName(a[0]) - baseHueForName(b[0]));
131+
for (const [name, group] of entries) {
132+
const { hue, ...colors } = generateCategoryColorsUnique(name, usedHues, 26);
133+
usedHues.push(hue);
134+
const safe = name.replace(/[^a-zA-Z0-9_]/g, '_');
135+
const catClass = `cat_${safe}`;
136+
const todoClass = `todo_${safe}`;
137+
lines.push(`classDef ${catClass} fill:${colors.category.fill},stroke:${colors.category.stroke},color:${colors.category.text}`);
138+
lines.push(`classDef ${todoClass} fill:${colors.todo.fill},stroke:${colors.todo.stroke},color:${colors.todo.text}`);
139+
if (group.categories.length > 0) lines.push(`class ${group.categories.join(',')} ${catClass};`);
140+
if (group.todos.length > 0) lines.push(`class ${group.todos.join(',')} ${todoClass};`);
95141
}
96142
if (oneoffNodeIds.length > 0) {
97143
lines.push('classDef oneoff fill:#f59e0b,stroke:#7c3d00,color:#111');

0 commit comments

Comments
 (0)