Skip to content

Commit eff7fb7

Browse files
feat(tasks): per-project task lists with deadlines + a cross-project task board
The user runs 50+ projects and had only one free-text note per project (rolled up by the "Next" view) — no checklist, no done state, no deadline. Now each project has a structured task list, and the "Next" view becomes a cross-project task board grouped by due date. - New pure src/shared/tasks.ts (TDD, 17 tests): Todo type, classifyDue (local-calendar-day overdue/today/week/later/none — DST-safe), groupTasksByDue (incomplete-only, fixed order, intra-group sort), taskCounts (card badge), immutable add/toggle/edit/setDue/remove reducers, sanitizeTodos (drops junk, caps text + list length). - StoreEntry.todos (default []), sanitized on read + write; getTodos/setTodos mirror setNote. ProjectViewModel.todos flows through buildProjectList so listProjects() carries todos. New project:setTodos IPC (sanitizes) + preload + global.d.ts. - Next view = task board: add (project picker + text + optional date), check off, inline text edit, a due chip that opens a date picker, delete, and open. Grouped 지연/오늘/이번 주/나중/날짜 없음 with a header count + overdue total. Supersedes the note/cue rollup (nextItems.ts removed; the resume cue still shows on each card). - Deck card / list row: a compact read-only badge "done/total · overdue" (hidden when no tasks, red when overdue) → click jumps to the board. Todos are in projectSignature so the badge updates on the in-place refresh. - Locale keys x4 (157/locale); date-only deadlines (no clock time). Scope: brainstorm chose "planning + deadlines" of three directions (a scheduling calendar and per-project info management deferred to their own specs). QA: 358 tests (+16 net); a compiled store->group end-to-end check green; Playwright harness 0 console/page errors across all views x4 locales incl. the new task board. Deferred: drag-reorder, priority, recurring, reminders, calendar view, subtasks, tags. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 3fe1222 commit eff7fb7

24 files changed

Lines changed: 541 additions & 112 deletions

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ See every repo's state at a glance — git status, how long it's been neglected,
1111
![License](https://img.shields.io/badge/license-MIT-blue)
1212
![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-0078D6)
1313
![Built with Electron](https://img.shields.io/badge/Electron-31-47848F)
14-
![Tests](https://img.shields.io/badge/tests-342%20passing-3fb950)
14+
![Tests](https://img.shields.io/badge/tests-358%20passing-3fb950)
1515
![CI](https://github.com/writingdeveloper/devdeck/actions/workflows/ci.yml/badge.svg)
1616

1717
<img src="docs/demo/demo.gif" width="820" alt="DevDeck demo" />

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "devdeck",
3-
"version": "1.12.2",
3+
"version": "1.13.0",
44
"description": "Project command deck — at-a-glance state + claude -c resume",
55
"main": "dist/main/main.js",
66
"type": "commonjs",

qa/screenshot.mjs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,14 @@ await win.waitForTimeout(400);
114114
await shot('titlebar-maximized');
115115
await win.evaluate(() => window.devdeck.windowControls.toggleMaximize());
116116

117+
// Next task board: navigate + capture (empty-state render path — add form + no open tasks in the
118+
// isolated QA profile). Guards the tasks.ts wiring renders without console/page errors.
119+
await showView('next');
120+
await win.waitForSelector('#view-next .tk-add, #view-next .empty', { timeout: 5000 }).catch(() => {});
121+
await shot('next-tasks');
122+
const nextAdd = await win.evaluate(() => !!document.querySelector('#view-next .tk-add'));
123+
console.log('next task-board add form present:', nextAdd);
124+
117125
// Cockpit view: navigate and capture the empty state (no PTY spawned in the harness)
118126
await showView('cockpit');
119127
await win.waitForSelector('#ck-empty', { timeout: 5000 }).catch(() => {});

src/main/ipc.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import type { WtTab } from '../shared/wtArgs';
2222
import { scanUsage } from './usageScan';
2323
import { listClaudeProjectDirs } from './usageProjectsScan';
2424
import { classifyUsageProjects } from '../shared/usageProjects';
25+
import { sanitizeTodos } from '../shared/tasks';
2526
import { getUsageWindows, readClaudeCredentials, fetchUsageApi, type CacheEntry } from './claudeUsage';
2627
import type { PersistedSession } from '../shared/cockpitPersist';
2728
import { readClaudeSessionMeta } from './sessionMeta';
@@ -86,6 +87,10 @@ export function registerIpc(cfg: IpcConfig): void {
8687
ipcMain.handle('project:setNote', (_e, path: string, note: string) => {
8788
cfg.store.setNote(path, String(note).slice(0, 10000));
8889
});
90+
ipcMain.handle('project:setTodos', (_e, path: string, todos: unknown) => {
91+
// store.setTodos sanitizes (drops junk, caps text + list length), so an untrusted array is safe.
92+
cfg.store.setTodos(path, sanitizeTodos(todos));
93+
});
8994
ipcMain.handle('project:setPinned', (_e, path: string, pinned: boolean) => {
9095
cfg.store.setPinned(path, pinned);
9196
});

src/main/projects.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ function deps(over: Partial<BuildDeps>): BuildDeps {
2424
}),
2525
sessions: () => [],
2626
resumeCue: () => null,
27-
getEntry: () => ({ note: '', pinned: false, hidden: false, lastOpened: null }),
27+
getEntry: () => ({ note: '', pinned: false, hidden: false, lastOpened: null, todos: [] }),
2828
...over,
2929
};
3030
}
@@ -40,7 +40,7 @@ describe('buildProjectList', () => {
4040
it('includes hidden projects in output (renderer filters them)', async () => {
4141
const list = await buildProjectList(deps({
4242
getEntry: (path) => ({
43-
note: '', pinned: false, hidden: path.endsWith('old'), lastOpened: null,
43+
note: '', pinned: false, hidden: path.endsWith('old'), lastOpened: null, todos: [],
4444
}),
4545
}));
4646
expect(list.map((p) => p.name)).toEqual(['fresh', 'old']);
@@ -51,7 +51,7 @@ describe('buildProjectList', () => {
5151
it('floats pinned projects to the top regardless of activity', async () => {
5252
const list = await buildProjectList(deps({
5353
getEntry: (path) => ({
54-
note: '', pinned: path.endsWith('old'), hidden: false, lastOpened: null,
54+
note: '', pinned: path.endsWith('old'), hidden: false, lastOpened: null, todos: [],
5555
}),
5656
}));
5757
expect(list[0].name).toBe('old');

src/main/projects.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ export async function buildProjectList(deps: BuildDeps): Promise<ProjectViewMode
4747
lastOpened: entry.lastOpened,
4848
resumeCue: cueText ? ({ kind: 'lastMessage', text: cueText } satisfies ResumeCue) : null,
4949
repoUrl: git.repoUrl,
50+
todos: entry.todos,
5051
};
5152
}),
5253
);

src/main/store.test.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,19 @@ describe('Store', () => {
1717
it('returns a default entry for an unknown project', () => {
1818
const s = new Store(file);
1919
expect(s.get('C:\\g\\x')).toEqual({
20-
note: '', pinned: false, hidden: false, lastOpened: null,
20+
note: '', pinned: false, hidden: false, lastOpened: null, todos: [],
2121
});
2222
});
2323

24+
it('persists todos across instances and sanitizes junk on read', () => {
25+
const s1 = new Store(file);
26+
const good = { id: 'a', text: 'ship v2', done: false, due: '2026-07-04', createdAt: '2026-07-01T00:00:00Z' };
27+
s1.setTodos('C:\\g\\x', [good, { id: '', text: 'bad' } as never]); // bad entry dropped on write
28+
const s2 = new Store(file);
29+
expect(s2.getTodos('C:\\g\\x')).toEqual([good]);
30+
expect(s2.get('C:\\g\\x').todos).toEqual([good]); // also exposed on the full entry
31+
});
32+
2433
it('persists a note across instances', () => {
2534
const s1 = new Store(file);
2635
s1.setNote('C:\\g\\x', '다음: Task1');

src/main/store.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,15 @@ import { readFileSync, writeFileSync, existsSync, renameSync } from 'node:fs';
22
import { resolve } from 'node:path';
33
import type { StoreEntry, Folder } from '../shared/types';
44
import { sanitizePersistedList, type PersistedSession } from '../shared/cockpitPersist';
5+
import { sanitizeTodos, type Todo } from '../shared/tasks';
56

67
interface StateFile {
78
projects: Record<string, StoreEntry>;
89
settings?: { language?: string; baseDir?: string; folders?: Folder[]; thresholds?: { freshDays: number; warnDays: number; neglectedDays: number }; agent?: string; openAtLogin?: boolean; viewMode?: 'cards' | 'list'; cockpitSessions?: PersistedSession[]; trayAlert?: 'off' | 'attention' | 'all' };
910
}
1011

1112
const EMPTY: StoreEntry = {
12-
note: '', pinned: false, hidden: false, lastOpened: null,
13+
note: '', pinned: false, hidden: false, lastOpened: null, todos: [],
1314
};
1415

1516
export class Store {
@@ -40,7 +41,8 @@ export class Store {
4041
}
4142

4243
get(path: string): StoreEntry {
43-
return { ...EMPTY, ...this.state.projects[path] };
44+
const e = { ...EMPTY, ...this.state.projects[path] };
45+
return { ...e, todos: sanitizeTodos(e.todos) }; // never hand out unvalidated on-disk todos
4446
}
4547

4648
private mutate(path: string, patch: Partial<StoreEntry>): void {
@@ -99,6 +101,8 @@ export class Store {
99101

100102

101103
setNote(path: string, note: string): void { this.mutate(path, { note }); }
104+
getTodos(path: string): Todo[] { return this.get(path).todos; }
105+
setTodos(path: string, todos: Todo[]): void { this.mutate(path, { todos: sanitizeTodos(todos) }); }
102106
setPinned(path: string, pinned: boolean): void { this.mutate(path, { pinned }); }
103107
setHidden(path: string, hidden: boolean): void { this.mutate(path, { hidden }); }
104108
setLastOpened(path: string, iso: string): void { this.mutate(path, { lastOpened: iso }); }

src/preload/preload.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { contextBridge, ipcRenderer } from 'electron';
33
contextBridge.exposeInMainWorld('devdeck', {
44
listProjects: () => ipcRenderer.invoke('projects:list'),
55
setNote: (path: string, note: string) => ipcRenderer.invoke('project:setNote', path, note),
6+
setTodos: (path: string, todos: unknown) => ipcRenderer.invoke('project:setTodos', path, todos),
67
setPinned: (path: string, pinned: boolean) => ipcRenderer.invoke('project:setPinned', path, pinned),
78
setHidden: (path: string, hidden: boolean) => ipcRenderer.invoke('project:setHidden', path, hidden),
89
open: (items: { path: string; sessionId: string | null }[]) => ipcRenderer.invoke('projects:open', items),

src/renderer/global.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ declare global {
55
devdeck: {
66
listProjects(): Promise<ProjectViewModel[]>;
77
setNote(path: string, note: string): Promise<void>;
8+
setTodos(path: string, todos: import('../shared/tasks').Todo[]): Promise<void>;
89
setPinned(path: string, pinned: boolean): Promise<void>;
910
setHidden(path: string, hidden: boolean): Promise<void>;
1011
open(items: { path: string; sessionId: string | null }[]): Promise<void>;

0 commit comments

Comments
 (0)