local-kanban 1.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/CHANGELOG.md +381 -0
  2. package/LICENSE +21 -0
  3. package/README.md +144 -0
  4. package/README.ru.md +144 -0
  5. package/bin/kb +306 -0
  6. package/bin/local-kanban +217 -0
  7. package/ecosystem.example.cjs +25 -0
  8. package/package.json +52 -0
  9. package/public/app.js +18 -0
  10. package/public/boot-fallback.js +3 -0
  11. package/public/drawer-sync.js +14 -0
  12. package/public/favicon-180.png +0 -0
  13. package/public/favicon-32.png +0 -0
  14. package/public/favicon-64.png +0 -0
  15. package/public/favicon.ico +0 -0
  16. package/public/favicon.svg +6 -0
  17. package/public/icon-192.png +0 -0
  18. package/public/icon-512.png +0 -0
  19. package/public/icon-maskable-192.png +0 -0
  20. package/public/icon-maskable-512.png +0 -0
  21. package/public/index.html +138 -0
  22. package/public/js/board.js +698 -0
  23. package/public/js/chaos.js +247 -0
  24. package/public/js/core.js +821 -0
  25. package/public/js/dash.js +114 -0
  26. package/public/js/drawer.js +919 -0
  27. package/public/js/horizon.js +140 -0
  28. package/public/js/init.js +208 -0
  29. package/public/js/project.js +338 -0
  30. package/public/js/settings.js +905 -0
  31. package/public/js/sidebar.js +640 -0
  32. package/public/js/sse.js +386 -0
  33. package/public/manifest.webmanifest +32 -0
  34. package/public/offline.html +39 -0
  35. package/public/style.css +1247 -0
  36. package/public/sw.js +31 -0
  37. package/scripts/raycast/open-kanban.sh +18 -0
  38. package/scripts/update.js +43 -0
  39. package/skills/deploy/SKILL.md +70 -0
  40. package/skills/kanban/SKILL.md +217 -0
  41. package/src/backup.js +111 -0
  42. package/src/bus.js +10 -0
  43. package/src/config.js +40 -0
  44. package/src/db.js +321 -0
  45. package/src/error-handler.js +11 -0
  46. package/src/file-snapshot.js +22 -0
  47. package/src/origin-guard.js +32 -0
  48. package/src/pm2-services.js +31 -0
  49. package/src/query-params.js +17 -0
  50. package/src/repo-base.js +23 -0
  51. package/src/restore.js +153 -0
  52. package/src/routes/dashboard.js +132 -0
  53. package/src/routes/events.js +30 -0
  54. package/src/routes/horizons.js +66 -0
  55. package/src/routes/projects.js +532 -0
  56. package/src/routes/skills.js +116 -0
  57. package/src/routes/system.js +285 -0
  58. package/src/routes/tasks.js +477 -0
  59. package/src/schema.sql +135 -0
  60. package/src/server.js +100 -0
  61. package/src/skills.js +119 -0
  62. package/src/sync/github.js +265 -0
  63. package/src/sync/worker.js +136 -0
  64. package/src/task-shape.js +29 -0
@@ -0,0 +1,132 @@
1
+ import { db } from '../db.js';
2
+ import { bus } from '../bus.js';
3
+
4
+ const noclaudeFilter = (alias = '', only = false) => `${alias}labels ${only ? '' : 'NOT '}LIKE '%"noclaude"%'`;
5
+ const LIVE_TASKS = (alias = '') => `${alias}project_id IN (SELECT id FROM projects WHERE archived = 0)`;
6
+
7
+ const SINCE_LOCAL_MIDNIGHT = "datetime('now','localtime','start of day', ?, 'utc')";
8
+ const sinceArgFor = (days) => `-${days - 1} days`;
9
+
10
+ export default async function dashboardRoutes(app) {
11
+ app.get('/api/stats', () => {
12
+ const r = db.prepare(`
13
+ SELECT COUNT(*) AS n, COALESCE(SUM(work_seconds), 0) AS sec
14
+ FROM tasks WHERE status = 'done' AND done_at >= ${SINCE_LOCAL_MIDNIGHT}
15
+ AND ${LIVE_TASKS()} AND ${noclaudeFilter()}
16
+ `).get(sinceArgFor(7));
17
+ return { week_done: r.n, week_seconds: r.sec };
18
+ });
19
+
20
+ const RANGE_DAYS = { week: 7, month: 30, half: 182, year: 365 };
21
+
22
+ const DASH_TTL = 2000;
23
+ const dashCache = new Map(); // range -> { at, data }
24
+ bus.on('event', () => dashCache.clear());
25
+
26
+ app.get('/api/dashboard', (req) => {
27
+ const range = Object.hasOwn(RANGE_DAYS, req.query.range) ? req.query.range : 'week';
28
+ const hit = dashCache.get(range);
29
+ if (hit && Date.now() - hit.at < DASH_TTL) return hit.data;
30
+ const LIVE_EVENTS = (alias = '') => `${alias}task_id IN (SELECT id FROM tasks WHERE ${LIVE_TASKS()})`;
31
+ const LIVE = LIVE_TASKS();
32
+ const NOT_NOCLAUDE = noclaudeFilter();
33
+ const IS_NOCLAUDE = noclaudeFilter('', true);
34
+ const sinceArg = sinceArgFor(RANGE_DAYS[range]);
35
+ const period = db.prepare(`
36
+ SELECT COUNT(*) AS n, COALESCE(SUM(work_seconds), 0) AS sec,
37
+ AVG((julianday(done_at) - julianday(created_at)) * 24) AS cyc
38
+ FROM tasks WHERE status = 'done' AND ${LIVE} AND ${NOT_NOCLAUDE} AND done_at >= datetime('now','localtime','start of day', ?, 'utc')
39
+ `).get(sinceArg);
40
+ const active = db.prepare(`SELECT COUNT(*) AS n FROM tasks WHERE status IN ('todo','prep','doing','deploy','review') AND ${LIVE} AND ${NOT_NOCLAUDE}`).get().n;
41
+ const doneTotal = db.prepare(`SELECT COUNT(*) AS n FROM tasks WHERE status = 'done' AND ${LIVE} AND ${NOT_NOCLAUDE}`).get().n;
42
+ const manualPeriod = db.prepare(`SELECT COUNT(*) AS n FROM tasks WHERE status = 'done' AND ${LIVE} AND ${IS_NOCLAUDE} AND done_at >= datetime('now','localtime','start of day', ?, 'utc')`).get(sinceArg).n;
43
+ const manualTotal = db.prepare(`SELECT COUNT(*) AS n FROM tasks WHERE status = 'done' AND ${LIVE} AND ${IS_NOCLAUDE}`).get().n;
44
+ const todoReady = db.prepare(`SELECT COUNT(*) AS n FROM tasks WHERE status = 'todo' AND ${LIVE}`).get().n;
45
+ const backlog = db.prepare(`SELECT COUNT(*) AS n FROM tasks WHERE status = 'backlog' AND ${LIVE}`).get().n;
46
+
47
+ const waitSel = `
48
+ SELECT (p.prefix || '-' || t.task_no) AS key, t.title, p.name AS project,
49
+ t.priority, t.blocked_reason AS reason,
50
+ (julianday('now') - julianday(t.updated_at)) * 24 AS idle_h
51
+ FROM tasks t JOIN projects p ON p.id = t.project_id AND p.archived = 0`;
52
+ const review = db.prepare(`${waitSel} WHERE t.status = 'review' ORDER BY t.priority DESC, t.updated_at DESC LIMIT 40`).all()
53
+ .map((t) => ({ key: t.key, title: t.title, project: t.project, priority: t.priority }));
54
+ const blocked = db.prepare(`${waitSel} WHERE t.blocked = 1 AND t.status NOT IN ('done','cancelled') ORDER BY t.updated_at DESC LIMIT 40`).all()
55
+ .map((t) => ({ key: t.key, title: t.title, project: t.project, reason: t.reason }));
56
+ const stale = db.prepare(`${waitSel} WHERE t.status IN ('doing','deploy') AND t.updated_at <= datetime('now','-2 hours') ORDER BY t.updated_at ASC LIMIT 40`).all()
57
+ .map((t) => ({ key: t.key, title: t.title, project: t.project, hours: Math.floor(t.idle_h) }));
58
+
59
+ const STALE_DAYS = 14;
60
+ const NEXT_FRESH_DAYS = 60;
61
+ const activeRows = db.prepare(`
62
+ SELECT (p.prefix || '-' || t.task_no) AS key, t.title, p.name AS project, t.status, t.priority,
63
+ (t.description IS NULL OR trim(t.description) = '') AS no_desc,
64
+ CAST(julianday('now') - julianday(t.updated_at) AS INTEGER) AS idle_days,
65
+ CAST(julianday('now') - julianday(t.created_at) AS INTEGER) AS age_days
66
+ FROM tasks t JOIN projects p ON p.id = t.project_id AND p.archived = 0
67
+ WHERE t.status IN ('backlog','todo')
68
+ ORDER BY t.priority DESC, t.updated_at DESC`).all();
69
+ const planRow = (t) => ({ key: t.key, title: t.title, project: t.project, status: t.status, priority: t.priority, idle_days: t.idle_days });
70
+
71
+ const FUNNEL_ST = ['backlog', 'todo', 'prep', 'doing', 'deploy', 'review'];
72
+ const funMap = Object.fromEntries(db.prepare(
73
+ `SELECT status, COUNT(*) n FROM tasks WHERE status IN ('backlog','todo','prep','doing','deploy','review')
74
+ AND ${LIVE_TASKS()} GROUP BY status`).all().map((r) => [r.status, r.n]));
75
+ const funnel = FUNNEL_ST.map((s) => ({ status: s, n: funMap[s] || 0 }));
76
+
77
+ const nextScore = (t) => t.priority * 100 + Math.min(t.age_days, NEXT_FRESH_DAYS);
78
+ const next = activeRows.filter((t) => !t.no_desc && t.priority > 0)
79
+ .sort((a, b) => nextScore(b) - nextScore(a)).slice(0, 6).map(planRow);
80
+
81
+ const matrix = { hot: [], queue: [], decide: [], idea: [] };
82
+ for (const t of activeRows) {
83
+ const important = t.priority >= 2;
84
+ const stale = t.idle_days >= STALE_DAYS;
85
+ const q = important ? (stale ? 'hot' : 'queue') : (stale ? 'decide' : 'idea');
86
+ if (matrix[q].length < 30) matrix[q].push(planRow(t));
87
+ }
88
+
89
+ const health = {
90
+ abandoned: activeRows.filter((t) => t.idle_days >= STALE_DAYS).slice(0, 20).map((t) => ({ ...planRow(t), days: t.idle_days })),
91
+ no_desc: activeRows.filter((t) => t.no_desc).slice(0, 20).map(planRow),
92
+ no_priority: activeRows.filter((t) => t.priority === 0).slice(0, 20).map(planRow),
93
+ };
94
+
95
+ const planning = { funnel, next, matrix, health };
96
+
97
+ const heatDays = db.prepare(`SELECT strftime('%Y-%m-%d', created_at, 'localtime') AS d, COUNT(DISTINCT task_id) AS n FROM task_events WHERE ${LIVE_EVENTS()} AND created_at >= datetime('now','localtime','start of day', ?, 'utc') GROUP BY d ORDER BY d`).all(sinceArg);
98
+ const heatTime = db.prepare(`SELECT CAST(strftime('%w', created_at, 'localtime') AS INTEGER) AS w, CAST(strftime('%H', created_at, 'localtime') AS INTEGER) AS h, COUNT(DISTINCT task_id) AS n FROM task_events WHERE ${LIVE_EVENTS()} AND created_at >= datetime('now','localtime','start of day', ?, 'utc') GROUP BY w, h`).all(sinceArg);
99
+ const donePerDay = db.prepare(`SELECT strftime('%Y-%m-%d', done_at, 'localtime') AS d, COUNT(*) AS n FROM tasks WHERE ${LIVE} AND done_at IS NOT NULL AND done_at >= datetime('now','localtime','start of day', ?, 'utc') GROUP BY d ORDER BY d`).all(sinceArg);
100
+ const topProjects = db.prepare("SELECT p.name, COUNT(*) AS n FROM tasks t JOIN projects p ON p.id = t.project_id WHERE p.archived = 0 AND t.status = 'done' AND t.done_at >= datetime('now','localtime','start of day', ?, 'utc') GROUP BY p.id ORDER BY n DESC, p.name LIMIT 6").all(sinceArg);
101
+
102
+ const recent = db.prepare(`
103
+ SELECT e.status, e.created_at, (p.prefix || '-' || t.task_no) AS key, t.title, p.name AS project
104
+ FROM task_events e
105
+ JOIN (SELECT task_id, MAX(id) AS mid FROM task_events GROUP BY task_id) last ON last.mid = e.id
106
+ JOIN tasks t ON t.id = e.task_id JOIN projects p ON p.id = t.project_id AND p.archived = 0
107
+ ORDER BY e.id DESC LIMIT 30
108
+ `).all();
109
+
110
+ const peak = db.prepare("SELECT CAST(strftime('%H', created_at, 'localtime') AS INTEGER) AS h, COUNT(*) AS n FROM task_events GROUP BY h ORDER BY n DESC LIMIT 1").get();
111
+ const since = db.prepare("SELECT strftime('%Y-%m-%d', MIN(created_at), 'localtime') AS d FROM task_events").get().d;
112
+ const rangeStart = db.prepare("SELECT strftime('%Y-%m-%d', 'now', 'localtime', 'start of day', ?) AS d").get(sinceArg).d;
113
+ const rangeSince = since && since > rangeStart ? since : rangeStart;
114
+
115
+ const out = {
116
+ range,
117
+ summary: { done_period: period.n, active, work_seconds_period: period.sec, cycle_hours_period: period.cyc, done_total: doneTotal, manual_period: manualPeriod, manual_total: manualTotal },
118
+ queue: { todo_ready: todoReady, backlog },
119
+ waiting: { review, blocked, stale },
120
+ planning,
121
+ heat_days: heatDays,
122
+ heat_time: heatTime,
123
+ done_per_day: donePerDay,
124
+ top_projects: topProjects,
125
+ recent,
126
+ range_since: rangeSince,
127
+ fun: { done_total: doneTotal, peak_hour: peak ? peak.h : null, since },
128
+ };
129
+ dashCache.set(range, { at: Date.now(), data: out });
130
+ return out;
131
+ });
132
+ }
@@ -0,0 +1,30 @@
1
+ import { bus, SSE_MAX } from '../bus.js';
2
+
3
+ let sseClients = 0;
4
+ export const sseCount = () => sseClients;
5
+
6
+ export default async function eventRoutes(app) {
7
+ app.get('/api/events', (req, reply) => {
8
+ if (sseClients >= SSE_MAX) return reply.code(503).send({ error: 'too many SSE connections' });
9
+ sseClients++;
10
+ reply.raw.writeHead(200, {
11
+ 'Content-Type': 'text/event-stream',
12
+ 'Cache-Control': 'no-cache',
13
+ Connection: 'keep-alive',
14
+ });
15
+ reply.raw.write('\n');
16
+
17
+ const onEvent = (ev) => {
18
+ reply.raw.write(`data: ${JSON.stringify(ev)}\n\n`);
19
+ };
20
+ bus.on('event', onEvent);
21
+
22
+ const heartbeat = setInterval(() => reply.raw.write(`data: ${JSON.stringify({ type: 'ping' })}\n\n`), 30000);
23
+
24
+ req.raw.on('close', () => {
25
+ sseClients--;
26
+ clearInterval(heartbeat);
27
+ bus.off('event', onEvent);
28
+ });
29
+ });
30
+ }
@@ -0,0 +1,66 @@
1
+ import { db } from '../db.js';
2
+ import { scalarParam } from '../query-params.js';
3
+
4
+ const SCALES = ['life', 'years', 'months', 'weeks', 'days'];
5
+
6
+ function badParent(value, selfId) {
7
+ if (value === undefined || value === null || value === '') return null;
8
+ const id = Number(value);
9
+ if (!Number.isInteger(id) || id <= 0) return 'parent_id: expected the id of an existing goal';
10
+ if (selfId && id === selfId) return 'parent_id: a goal cannot be its own parent';
11
+ if (!db.prepare('SELECT 1 FROM horizon_goals WHERE id = ?').get(id)) return 'parent_id: no goal with this id';
12
+ return null;
13
+ }
14
+
15
+ function shape(g) {
16
+ return { id: g.id, scale: g.scale, period: g.period, text: g.text, done: !!g.done, position: g.position, parent_id: g.parent_id };
17
+ }
18
+
19
+ export default async function horizonRoutes(app) {
20
+ app.get('/api/horizons', (req, reply) => {
21
+ const parsed = scalarParam(req.query?.scale, 'scale');
22
+ if (parsed.error) return reply.code(400).send({ error: parsed.error });
23
+ const scale = parsed.value;
24
+ const rows = scale
25
+ ? db.prepare('SELECT * FROM horizon_goals WHERE scale = ? ORDER BY position, id').all(scale)
26
+ : db.prepare('SELECT * FROM horizon_goals ORDER BY position, id').all();
27
+ return rows.map(shape);
28
+ });
29
+
30
+ app.post('/api/horizons', (req, reply) => {
31
+ const { scale, period, text = '', parent_id = null } = req.body || {};
32
+ if (!SCALES.includes(scale)) return reply.code(400).send({ error: 'bad scale' });
33
+ if (!period || typeof period !== 'string') return reply.code(400).send({ error: 'period required' });
34
+ const parentErr = badParent(parent_id, null);
35
+ if (parentErr) return reply.code(400).send({ error: parentErr });
36
+ const mx = db.prepare('SELECT COALESCE(MAX(position),0) AS m FROM horizon_goals WHERE scale=? AND period=?').get(scale, period).m;
37
+ const r = db.prepare('INSERT INTO horizon_goals (scale, period, text, position, parent_id) VALUES (?,?,?,?,?)')
38
+ .run(scale, period, String(text), mx + 1, parent_id || null);
39
+ return reply.code(201).send(shape(db.prepare('SELECT * FROM horizon_goals WHERE id=?').get(r.lastInsertRowid)));
40
+ });
41
+
42
+ app.patch('/api/horizons/:id', (req, reply) => {
43
+ const g = db.prepare('SELECT * FROM horizon_goals WHERE id=?').get(req.params.id);
44
+ if (!g) return reply.code(404).send({ error: 'goal not found' });
45
+ const b = req.body || {};
46
+ const sets = [], vals = [];
47
+ if (b.text !== undefined) { sets.push('text = ?'); vals.push(String(b.text)); }
48
+ if (b.done !== undefined) { sets.push('done = ?'); vals.push(b.done ? 1 : 0); }
49
+ if (b.position !== undefined) { const p = Number(b.position); if (Number.isFinite(p)) { sets.push('position = ?'); vals.push(p); } }
50
+ if (b.period !== undefined && typeof b.period === 'string') { sets.push('period = ?'); vals.push(b.period); }
51
+ if (b.parent_id !== undefined) {
52
+ const parentErr = badParent(b.parent_id, g.id);
53
+ if (parentErr) return reply.code(400).send({ error: parentErr });
54
+ sets.push('parent_id = ?'); vals.push(b.parent_id || null);
55
+ }
56
+ if (sets.length) db.prepare(`UPDATE horizon_goals SET ${sets.join(', ')} WHERE id=?`).run(...vals, g.id);
57
+ return shape(db.prepare('SELECT * FROM horizon_goals WHERE id=?').get(g.id));
58
+ });
59
+
60
+ app.delete('/api/horizons/:id', (req, reply) => {
61
+ const g = db.prepare('SELECT * FROM horizon_goals WHERE id=?').get(req.params.id);
62
+ if (!g) return reply.code(404).send({ error: 'goal not found' });
63
+ db.prepare('DELETE FROM horizon_goals WHERE id=?').run(g.id);
64
+ return reply.code(204).send();
65
+ });
66
+ }