@knotpad/app 0.1.0

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 (342) hide show
  1. package/README.md +167 -0
  2. package/app/(app)/calendar/page.tsx +57 -0
  3. package/app/(app)/error.tsx +35 -0
  4. package/app/(app)/graph/page.tsx +32 -0
  5. package/app/(app)/guide/page.tsx +21 -0
  6. package/app/(app)/kanban/loading.tsx +24 -0
  7. package/app/(app)/kanban/page.tsx +59 -0
  8. package/app/(app)/layout.tsx +122 -0
  9. package/app/(app)/list/loading.tsx +21 -0
  10. package/app/(app)/list/page.tsx +137 -0
  11. package/app/(app)/loading.tsx +18 -0
  12. package/app/(app)/notes/[noteId]/page.tsx +84 -0
  13. package/app/(app)/notes/layout.tsx +30 -0
  14. package/app/(app)/notes/page.tsx +39 -0
  15. package/app/(app)/page.tsx +5 -0
  16. package/app/(app)/settings/agent-token/page.tsx +59 -0
  17. package/app/(app)/settings/backup/page.tsx +49 -0
  18. package/app/(app)/settings/billing/page.tsx +53 -0
  19. package/app/(app)/settings/calendar/page.tsx +41 -0
  20. package/app/(app)/settings/layout.test.tsx +39 -0
  21. package/app/(app)/settings/layout.tsx +71 -0
  22. package/app/(app)/settings/page.tsx +4 -0
  23. package/app/(app)/settings/security/page.tsx +43 -0
  24. package/app/(app)/settings/team/page.tsx +74 -0
  25. package/app/(app)/settings/workspace/page.tsx +27 -0
  26. package/app/(app)/tasks/[taskId]/page.tsx +79 -0
  27. package/app/(auth)/forgot-password/page.tsx +106 -0
  28. package/app/(auth)/guest/page.tsx +56 -0
  29. package/app/(auth)/layout.tsx +13 -0
  30. package/app/(auth)/login/page.tsx +14 -0
  31. package/app/(auth)/register/page.tsx +193 -0
  32. package/app/(auth)/reset-password/page.tsx +138 -0
  33. package/app/api/account/claim/route.tsx +135 -0
  34. package/app/api/admin/backfill-encryption/route.tsx +43 -0
  35. package/app/api/admin/license/route.tsx +42 -0
  36. package/app/api/auth/2fa/route.tsx +148 -0
  37. package/app/api/auth/[...nextauth]/route.tsx +3 -0
  38. package/app/api/auth/change-password/route.tsx +61 -0
  39. package/app/api/auth/check-2fa/route.tsx +19 -0
  40. package/app/api/auth/forgot-password/route.tsx +65 -0
  41. package/app/api/auth/reset-password/route.tsx +52 -0
  42. package/app/api/auth/verify-2fa/route.tsx +88 -0
  43. package/app/api/backup/download/db/route.ts +29 -0
  44. package/app/api/backup/download/notes/route.ts +25 -0
  45. package/app/api/backup/settings/route.ts +92 -0
  46. package/app/api/billing/checkout/route.tsx +81 -0
  47. package/app/api/billing/migrate/route.tsx +163 -0
  48. package/app/api/billing/portal/route.tsx +24 -0
  49. package/app/api/billing/setup-intent/route.tsx +55 -0
  50. package/app/api/billing/status/route.tsx +36 -0
  51. package/app/api/billing/subscribe/route.tsx +85 -0
  52. package/app/api/billing/webhook/route.tsx +199 -0
  53. package/app/api/calendar-feeds/[feedId]/route.tsx +67 -0
  54. package/app/api/calendar-feeds/[feedId]/sync/route.tsx +37 -0
  55. package/app/api/calendar-feeds/events/route.tsx +82 -0
  56. package/app/api/calendar-feeds/route.tsx +52 -0
  57. package/app/api/calendar-feeds/sync-all/route.tsx +34 -0
  58. package/app/api/cron/calendar-feeds/route.tsx +31 -0
  59. package/app/api/cron/stale-tasks/route.tsx +51 -0
  60. package/app/api/cron/sync/route.tsx +34 -0
  61. package/app/api/devices/[deviceId]/route.tsx +25 -0
  62. package/app/api/devices/route.tsx +41 -0
  63. package/app/api/export/route.tsx +40 -0
  64. package/app/api/feedback/route.tsx +54 -0
  65. package/app/api/folders/[folderId]/route.tsx +51 -0
  66. package/app/api/folders/route.tsx +37 -0
  67. package/app/api/graph/route.tsx +242 -0
  68. package/app/api/guest/route.tsx +58 -0
  69. package/app/api/health/route.tsx +10 -0
  70. package/app/api/holidays/countries/route.tsx +14 -0
  71. package/app/api/holidays/route.tsx +49 -0
  72. package/app/api/holidays/states/route.tsx +21 -0
  73. package/app/api/invites/[token]/route.tsx +131 -0
  74. package/app/api/invites/route.tsx +74 -0
  75. package/app/api/mcp/generate-token/route.tsx +55 -0
  76. package/app/api/mcp/revoke-token/[tokenId]/route.tsx +30 -0
  77. package/app/api/mcp/update-alias/[tokenId]/route.tsx +22 -0
  78. package/app/api/notes/[noteId]/export/route.tsx +45 -0
  79. package/app/api/notes/[noteId]/route.tsx +360 -0
  80. package/app/api/notes/route.tsx +112 -0
  81. package/app/api/notifications/route.tsx +44 -0
  82. package/app/api/register/route.tsx +67 -0
  83. package/app/api/restore/route.tsx +148 -0
  84. package/app/api/sync/conflicts/[conflictId]/route.tsx +134 -0
  85. package/app/api/sync/conflicts/route.tsx +48 -0
  86. package/app/api/sync/status/route.tsx +49 -0
  87. package/app/api/sync/trigger/route.tsx +15 -0
  88. package/app/api/tasks/[taskId]/detail/route.tsx +68 -0
  89. package/app/api/tasks/[taskId]/route.tsx +259 -0
  90. package/app/api/tasks/bulk/route.tsx +133 -0
  91. package/app/api/tasks/route.tsx +36 -0
  92. package/app/api/workspace/active/route.tsx +39 -0
  93. package/app/api/workspace/create-team/route.tsx +42 -0
  94. package/app/api/workspace/kanban-statuses/route.tsx +71 -0
  95. package/app/api/workspace/members/[memberId]/route.tsx +69 -0
  96. package/app/api/workspace/route.tsx +24 -0
  97. package/app/download/page.tsx +170 -0
  98. package/app/favicon.ico +0 -0
  99. package/app/generated/prisma/client.d.ts +1 -0
  100. package/app/generated/prisma/client.js +5 -0
  101. package/app/generated/prisma/default.d.ts +1 -0
  102. package/app/generated/prisma/default.js +5 -0
  103. package/app/generated/prisma/edge.d.ts +1 -0
  104. package/app/generated/prisma/edge.js +497 -0
  105. package/app/generated/prisma/index-browser.js +523 -0
  106. package/app/generated/prisma/index.d.ts +46376 -0
  107. package/app/generated/prisma/index.js +497 -0
  108. package/app/generated/prisma/package.json +144 -0
  109. package/app/generated/prisma/query_compiler_fast_bg.js +2 -0
  110. package/app/generated/prisma/query_compiler_fast_bg.wasm +0 -0
  111. package/app/generated/prisma/query_compiler_fast_bg.wasm-base64.js +2 -0
  112. package/app/generated/prisma/runtime/client.d.ts +3386 -0
  113. package/app/generated/prisma/runtime/client.js +86 -0
  114. package/app/generated/prisma/runtime/index-browser.d.ts +90 -0
  115. package/app/generated/prisma/runtime/index-browser.js +6 -0
  116. package/app/generated/prisma/runtime/wasm-compiler-edge.js +76 -0
  117. package/app/generated/prisma/schema.prisma +456 -0
  118. package/app/generated/prisma/wasm-edge-light-loader.mjs +5 -0
  119. package/app/generated/prisma/wasm-worker-loader.mjs +5 -0
  120. package/app/globals.css +54 -0
  121. package/app/invite/[token]/page.tsx +52 -0
  122. package/app/layout.tsx +90 -0
  123. package/app/mcp/route.tsx +430 -0
  124. package/app/opengraph-image.tsx +120 -0
  125. package/app/page.tsx +398 -0
  126. package/app/privacy/page.tsx +69 -0
  127. package/app/robots.tsx +25 -0
  128. package/app/sitemap.tsx +36 -0
  129. package/app/terms/page.tsx +69 -0
  130. package/app/upgrade/page.tsx +75 -0
  131. package/auth.config.ts +33 -0
  132. package/auth.ts +79 -0
  133. package/bin/brief.js +224 -0
  134. package/components/auth/login-form.tsx +302 -0
  135. package/components/auth/password-checklist.tsx +31 -0
  136. package/components/auth/password-input.tsx +36 -0
  137. package/components/auth/switch-account-button.test.tsx +22 -0
  138. package/components/auth/switch-account-button.tsx +19 -0
  139. package/components/auth/two-factor-input.tsx +116 -0
  140. package/components/billing/billing-dashboard.tsx +265 -0
  141. package/components/billing/card-form.tsx +210 -0
  142. package/components/billing/claim-account-form.tsx +99 -0
  143. package/components/branding/app-logo.test.tsx +20 -0
  144. package/components/branding/app-logo.tsx +25 -0
  145. package/components/calendar/calendar-agenda.tsx +150 -0
  146. package/components/calendar/calendar-drag.test.tsx +177 -0
  147. package/components/calendar/calendar-grid.tsx +357 -0
  148. package/components/calendar/calendar-hooks.test.tsx +27 -0
  149. package/components/calendar/calendar-hooks.ts +351 -0
  150. package/components/calendar/calendar-toolbar.test.tsx +68 -0
  151. package/components/calendar/calendar-toolbar.tsx +291 -0
  152. package/components/calendar/calendar-types.ts +148 -0
  153. package/components/calendar/calendar-view.test.tsx +295 -0
  154. package/components/calendar/calendar-view.tsx +307 -0
  155. package/components/calendar/day-detail-popover.tsx +174 -0
  156. package/components/calendar/task-chip.tsx +86 -0
  157. package/components/command/command-palette.test.tsx +33 -0
  158. package/components/command/command-palette.tsx +310 -0
  159. package/components/download-cta.tsx +87 -0
  160. package/components/feedback/feedback-popup.tsx +207 -0
  161. package/components/graph/graph-draw.ts +337 -0
  162. package/components/graph/graph-overlays.tsx +160 -0
  163. package/components/graph/graph-page.test.tsx +131 -0
  164. package/components/graph/graph-page.tsx +263 -0
  165. package/components/graph/graph-types.ts +47 -0
  166. package/components/graph/graph-view.tsx +322 -0
  167. package/components/guide/guide-view.tsx +522 -0
  168. package/components/kanban/kanban-board.test.tsx +128 -0
  169. package/components/kanban/kanban-board.tsx +361 -0
  170. package/components/kanban/kanban-card-menu.tsx +102 -0
  171. package/components/kanban/kanban-card.tsx +227 -0
  172. package/components/kanban/kanban-column.tsx +49 -0
  173. package/components/kanban/kanban-status-context.tsx +28 -0
  174. package/components/landing/calendar-sandbox.test.tsx +15 -0
  175. package/components/landing/calendar-sandbox.tsx +107 -0
  176. package/components/landing/graph-sandbox.test.tsx +27 -0
  177. package/components/landing/graph-sandbox.tsx +80 -0
  178. package/components/landing/kanban-sandbox.test.tsx +24 -0
  179. package/components/landing/kanban-sandbox.tsx +101 -0
  180. package/components/landing/landing-showcase.test.tsx +21 -0
  181. package/components/landing/landing-showcase.tsx +54 -0
  182. package/components/landing/list-sandbox.tsx +86 -0
  183. package/components/landing/mock-workspace.ts +168 -0
  184. package/components/landing/notes-sandbox.test.tsx +14 -0
  185. package/components/landing/notes-sandbox.tsx +88 -0
  186. package/components/layout/app-shell.tsx +83 -0
  187. package/components/layout/backup-scheduler.tsx +122 -0
  188. package/components/layout/bottom-nav.tsx +43 -0
  189. package/components/layout/icon-bar.test.tsx +29 -0
  190. package/components/layout/icon-bar.tsx +118 -0
  191. package/components/layout/mobile-top-bar.tsx +68 -0
  192. package/components/layout/notes-panel-folder.tsx +127 -0
  193. package/components/layout/notes-panel-note-item.tsx +140 -0
  194. package/components/layout/notes-panel-task-tab.tsx +63 -0
  195. package/components/layout/notes-panel-types.ts +44 -0
  196. package/components/layout/notes-panel.tsx +476 -0
  197. package/components/layout/notification-bell.tsx +251 -0
  198. package/components/layout/paywall-screen.tsx +41 -0
  199. package/components/layout/pro-banner.tsx +76 -0
  200. package/components/layout/sw-register.tsx +27 -0
  201. package/components/layout/workspace-switcher.tsx +90 -0
  202. package/components/notes/mobile-bottom-sheet.tsx +99 -0
  203. package/components/notes/note-editor-context-menu.tsx +47 -0
  204. package/components/notes/note-editor-dom.ts +33 -0
  205. package/components/notes/note-editor-dropdowns.tsx +484 -0
  206. package/components/notes/note-editor-hooks.ts +692 -0
  207. package/components/notes/note-editor-keyboard.ts +305 -0
  208. package/components/notes/note-editor-overlay.tsx +90 -0
  209. package/components/notes/note-editor.test.tsx +372 -0
  210. package/components/notes/note-editor.tsx +662 -0
  211. package/components/notes/note-preview-pane.tsx +156 -0
  212. package/components/notes/note-tabs.tsx +120 -0
  213. package/components/notes/note-types.tsx +157 -0
  214. package/components/settings/accept-invite.tsx +108 -0
  215. package/components/settings/agent-token-settings.tsx +369 -0
  216. package/components/settings/backup-restore-settings.test.tsx +25 -0
  217. package/components/settings/backup-restore-settings.tsx +327 -0
  218. package/components/settings/calendar-feeds-settings.tsx +489 -0
  219. package/components/settings/calendar-general-settings.tsx +174 -0
  220. package/components/settings/confirm-danger-action.test.tsx +215 -0
  221. package/components/settings/confirm-danger-action.tsx +65 -0
  222. package/components/settings/security-settings.tsx +252 -0
  223. package/components/settings/settings-guidance.test.tsx +98 -0
  224. package/components/settings/team-settings.tsx +319 -0
  225. package/components/settings/two-factor-auth.tsx +296 -0
  226. package/components/settings/workspace-settings-client.tsx +363 -0
  227. package/components/settings/workspace-settings-form.tsx +73 -0
  228. package/components/sync/conflict-viewer.tsx +247 -0
  229. package/components/sync/sync-indicator.tsx +171 -0
  230. package/components/tasks/snippet-thread.tsx +119 -0
  231. package/components/tasks/status-dot.tsx +47 -0
  232. package/components/tasks/task-badge.tsx +43 -0
  233. package/components/tasks/task-detail.test.tsx +187 -0
  234. package/components/tasks/task-detail.tsx +458 -0
  235. package/components/tasks/task-list-filters.test.tsx +75 -0
  236. package/components/tasks/task-list-filters.tsx +163 -0
  237. package/components/tasks/task-list-types.ts +20 -0
  238. package/components/tasks/task-list.test.tsx +175 -0
  239. package/components/tasks/task-list.tsx +481 -0
  240. package/components/tasks/task-row.tsx +85 -0
  241. package/components/tasks/task-table-row.tsx +259 -0
  242. package/components/ui/skeleton.tsx +3 -0
  243. package/components/ui/toast.test.tsx +42 -0
  244. package/components/ui/toast.tsx +70 -0
  245. package/electron/main.ts +251 -0
  246. package/electron/preload.ts +56 -0
  247. package/instrumentation.tsx +23 -0
  248. package/lib/api-error.ts +50 -0
  249. package/lib/backup/backup-runner.test.ts +32 -0
  250. package/lib/backup/backup-runner.ts +19 -0
  251. package/lib/backup/backup-schedule.test.ts +23 -0
  252. package/lib/backup/backup-schedule.ts +55 -0
  253. package/lib/backup/backup-settings.test.ts +30 -0
  254. package/lib/backup/backup-settings.ts +27 -0
  255. package/lib/backup/export-notes-zip.test.ts +26 -0
  256. package/lib/backup/export-notes-zip.ts +82 -0
  257. package/lib/backup/export-workspace-backup.test.ts +17 -0
  258. package/lib/backup/export-workspace-backup.ts +77 -0
  259. package/lib/backup/restore-workspace-from-export.test.ts +18 -0
  260. package/lib/backup/restore-workspace-from-export.ts +183 -0
  261. package/lib/backup/types.ts +14 -0
  262. package/lib/brand-icons.ts +1 -0
  263. package/lib/calendar-feed-crypto.ts +38 -0
  264. package/lib/calendar-feed.ts +239 -0
  265. package/lib/client/online-status.ts +47 -0
  266. package/lib/conflict-resolver.test.ts +57 -0
  267. package/lib/conflict-resolver.ts +240 -0
  268. package/lib/db-init.ts +79 -0
  269. package/lib/email.ts +159 -0
  270. package/lib/encryption.test.ts +41 -0
  271. package/lib/encryption.ts +98 -0
  272. package/lib/extract-snippet.test.ts +123 -0
  273. package/lib/extract-snippet.ts +69 -0
  274. package/lib/kanban-status.ts +55 -0
  275. package/lib/license.ts +21 -0
  276. package/lib/limits.ts +31 -0
  277. package/lib/mcp-auth.test.ts +58 -0
  278. package/lib/mcp-auth.ts +65 -0
  279. package/lib/mcp-contract.test.ts +25 -0
  280. package/lib/mcp-contract.ts +210 -0
  281. package/lib/mcp-handler.ts +31 -0
  282. package/lib/mcp-url.test.ts +12 -0
  283. package/lib/mcp-url.ts +7 -0
  284. package/lib/mentions.test.ts +45 -0
  285. package/lib/mentions.ts +73 -0
  286. package/lib/note-crypto.ts +108 -0
  287. package/lib/note-sync.ts +201 -0
  288. package/lib/note-title.ts +93 -0
  289. package/lib/prisma.ts +193 -0
  290. package/lib/pro-flush.ts +292 -0
  291. package/lib/rate-limit.ts +57 -0
  292. package/lib/stripe.ts +38 -0
  293. package/lib/sync-worker.ts +388 -0
  294. package/lib/task-parser.test.ts +91 -0
  295. package/lib/task-parser.ts +81 -0
  296. package/lib/task-utils.ts +52 -0
  297. package/lib/use-is-electron.ts +19 -0
  298. package/lib/use-is-mobile.ts +22 -0
  299. package/lib/validation/calendar-feed.ts +31 -0
  300. package/lib/validation/note.ts +27 -0
  301. package/lib/validation/task.ts +26 -0
  302. package/lib/view-preferences.test.ts +54 -0
  303. package/lib/view-preferences.ts +28 -0
  304. package/lib/workspace.ts +66 -0
  305. package/next.config.ts +21 -0
  306. package/package.json +99 -0
  307. package/postcss.config.mjs +7 -0
  308. package/prisma/migrations/20260519021916_init/migration.sql +388 -0
  309. package/prisma/migrations/20260519061113_drop_sync_password/migration.sql +8 -0
  310. package/prisma/migrations/20260520065016_add_task_start_date/migration.sql +2 -0
  311. package/prisma/migrations/20260529010600_remove_encryption_fields/migration.sql +12 -0
  312. package/prisma/migrations/20260529020000_restore_encryption_salt/migration.sql +3 -0
  313. package/prisma/migrations/20260529030000_add_folders/migration.sql +17 -0
  314. package/prisma/migrations/20260605000000_deferred_fixes/migration.sql +31 -0
  315. package/prisma/migrations/20260605020806_add_pending_sync_to_note_and_task/migration.sql +5 -0
  316. package/prisma/migrations/20260605063634_add_stripe_webhook_event_sync_lock/migration.sql +14 -0
  317. package/prisma/migrations/20260605100000_add_prod_indexes/migration.sql +26 -0
  318. package/prisma/migrations/20260608081404_add_kanban_statuses/migration.sql +23 -0
  319. package/prisma/migrations/20260611032723_add_calendar_feeds/migration.sql +43 -0
  320. package/prisma/migrations/20260611040000_add_calendar_feed_color/migration.sql +2 -0
  321. package/prisma/migrations/20260611050000_add_task_priority/migration.sql +14 -0
  322. package/prisma/migrations/20260612060000_add_critical_priority/migration.sql +2 -0
  323. package/prisma/migrations/20260613090000_add_backup_settings/migration.sql +25 -0
  324. package/prisma/migrations/20260614160000_add_feedback/migration.sql +20 -0
  325. package/prisma/migrations/20260614210000_add_2fa/migration.sql +4 -0
  326. package/prisma/migrations/migration_lock.toml +3 -0
  327. package/prisma/schema.prisma +457 -0
  328. package/public/Logo_icon.svg +1 -0
  329. package/public/file.svg +1 -0
  330. package/public/globe.svg +1 -0
  331. package/public/icon-192.png +0 -0
  332. package/public/icon-512.png +0 -0
  333. package/public/icon.svg +4 -0
  334. package/public/icon_dark.svg +1 -0
  335. package/public/knotpad_icon.svg +1 -0
  336. package/public/knotpad_logo_full.svg +1 -0
  337. package/public/manifest.json +14 -0
  338. package/public/next.svg +1 -0
  339. package/public/sw.js +137 -0
  340. package/public/vercel.svg +1 -0
  341. package/public/window.svg +1 -0
  342. package/tsconfig.json +35 -0
@@ -0,0 +1,458 @@
1
+ "use client";
2
+
3
+ import { useState, useEffect } from "react";
4
+ import { useRouter } from "next/navigation";
5
+ import { X, FileText, Clock } from "lucide-react";
6
+ import Link from "next/link";
7
+ import { TaskBadge } from "./task-badge";
8
+ import { useKanbanStatuses } from "@/components/kanban/kanban-status-context";
9
+ import { SnippetThread } from "./snippet-thread";
10
+
11
+ type AuditEntry = {
12
+ id: string;
13
+ action: string;
14
+ detail: string | null;
15
+ createdAt: string;
16
+ user: { id: string; name: string | null } | null;
17
+ };
18
+
19
+ type Reference = { noteId: string; noteTitle: string; snippet: string };
20
+
21
+ type Task = {
22
+ id: string;
23
+ title: string;
24
+ status: string;
25
+ priority?: string | null;
26
+ assigneeType: "HUMAN" | "AGENT";
27
+ claimedBy: string | null;
28
+ claimedByAlias: string | null;
29
+ fileRefs: string[];
30
+ startDate: string | null;
31
+ dueDate: string | null;
32
+ note: { id: string; title: string };
33
+ assignee: { id: string; name: string | null; email: string | null; image: string | null } | null;
34
+ auditLogs: AuditEntry[];
35
+ references: Reference[];
36
+ };
37
+
38
+ type Props = { task: Task; backHref: string };
39
+
40
+ // Format relative time (e.g., "3d ago", "2h ago", "5m ago")
41
+ function formatRelativeTime(date: string | Date): string {
42
+ const now = new Date();
43
+ const then = new Date(date);
44
+ const diffMs = now.getTime() - then.getTime();
45
+ const diffSec = Math.floor(diffMs / 1000);
46
+ const diffMin = Math.floor(diffSec / 60);
47
+ const diffHour = Math.floor(diffMin / 60);
48
+ const diffDay = Math.floor(diffHour / 24);
49
+ const diffWeek = Math.floor(diffDay / 7);
50
+ const diffMonth = Math.floor(diffDay / 30);
51
+ const diffYear = Math.floor(diffDay / 365);
52
+
53
+ if (diffSec < 60) return "just now";
54
+ if (diffMin < 60) return `${diffMin}m ago`;
55
+ if (diffHour < 24) return `${diffHour}h ago`;
56
+ if (diffDay < 7) return `${diffDay}d ago`;
57
+ if (diffWeek < 4) return `${diffWeek}w ago`;
58
+ if (diffMonth < 12) return `${diffMonth}mo ago`;
59
+ return `${diffYear}y ago`;
60
+ }
61
+
62
+ // Format date for date input (YYYY-MM-DD) in local timezone
63
+ function formatDateForInput(date: string | Date | null): string {
64
+ if (!date) return "";
65
+ const d = new Date(date);
66
+ const year = d.getFullYear();
67
+ const month = String(d.getMonth() + 1).padStart(2, "0");
68
+ const day = String(d.getDate()).padStart(2, "0");
69
+ return `${year}-${month}-${day}`;
70
+ }
71
+
72
+ function getBackLabel(backHref: string): string {
73
+ if (backHref.startsWith("/list")) return "Back to List";
74
+ if (backHref.startsWith("/kanban")) return "Back to Kanban";
75
+ if (backHref.startsWith("/calendar")) return "Back to Calendar";
76
+ if (backHref.startsWith("/notes")) return "Back to Note";
77
+ return "Back";
78
+ }
79
+
80
+ export function TaskDetail({ task, backHref }: Props) {
81
+ const router = useRouter();
82
+ const kanbanStatuses = useKanbanStatuses();
83
+ const statusOptions = kanbanStatuses.map((s) => s.key);
84
+ const backLabel = getBackLabel(backHref);
85
+ const [status, setStatus] = useState<string>(task.status);
86
+ const [updating, setUpdating] = useState(false);
87
+ const [error, setError] = useState("");
88
+ const [successMessage, setSuccessMessage] = useState("");
89
+ const [startDate, setStartDate] = useState<string>(formatDateForInput(task.startDate));
90
+ const [savingStart, setSavingStart] = useState(false);
91
+ const [dueDate, setDueDate] = useState<string>(formatDateForInput(task.dueDate));
92
+ const [savingDue, setSavingDue] = useState(false);
93
+ const [showAllActivity, setShowAllActivity] = useState(false);
94
+ const [priority, setPriority] = useState<string>(task.priority ?? "medium");
95
+ const [savingPriority, setSavingPriority] = useState(false);
96
+
97
+ // Sync local state when task prop changes (e.g., after router.refresh())
98
+ useEffect(() => {
99
+ setStatus(task.status);
100
+ setPriority(task.priority ?? "medium");
101
+ setStartDate(formatDateForInput(task.startDate));
102
+ setDueDate(formatDateForInput(task.dueDate));
103
+ }, [task.dueDate, task.priority, task.startDate, task.status]);
104
+
105
+ async function handleStatusChange(newStatus: string) {
106
+ if (newStatus === status) return;
107
+ const prev = status;
108
+ setUpdating(true);
109
+ setStatus(newStatus);
110
+ setError("");
111
+ try {
112
+ const res = await fetch(`/api/tasks/${task.id}`, {
113
+ method: "PATCH",
114
+ headers: { "Content-Type": "application/json" },
115
+ body: JSON.stringify({ status: newStatus }),
116
+ });
117
+ if (!res.ok) throw new Error("patch failed");
118
+ router.refresh();
119
+ } catch {
120
+ setStatus(prev);
121
+ setError("Failed to update status. Please try again.");
122
+ } finally {
123
+ setUpdating(false);
124
+ }
125
+ }
126
+
127
+ async function handleStartDateChange(val: string) {
128
+ const prev = startDate;
129
+ setStartDate(val);
130
+ setSavingStart(true);
131
+ setError("");
132
+ try {
133
+ const res = await fetch(`/api/tasks/${task.id}`, {
134
+ method: "PATCH",
135
+ headers: { "Content-Type": "application/json" },
136
+ body: JSON.stringify({ startDate: val || null }),
137
+ });
138
+ if (!res.ok) throw new Error();
139
+ router.refresh();
140
+ } catch {
141
+ setStartDate(prev);
142
+ setError("Failed to update start date.");
143
+ } finally {
144
+ setSavingStart(false);
145
+ }
146
+ }
147
+
148
+ async function handleDueDateChange(val: string) {
149
+ const prev = dueDate;
150
+ setDueDate(val);
151
+ setSavingDue(true);
152
+ setError("");
153
+ setSuccessMessage("");
154
+ try {
155
+ const res = await fetch(`/api/tasks/${task.id}`, {
156
+ method: "PATCH",
157
+ headers: { "Content-Type": "application/json" },
158
+ body: JSON.stringify({ dueDate: val || null }),
159
+ });
160
+ if (!res.ok) throw new Error();
161
+ setSuccessMessage(val ? "Due date updated." : "Due date cleared.");
162
+ router.refresh();
163
+ } catch {
164
+ setDueDate(prev);
165
+ setError("Failed to update due date.");
166
+ } finally {
167
+ setSavingDue(false);
168
+ }
169
+ }
170
+
171
+ async function handlePriorityChange(newPriority: string) {
172
+ const normalizedPriority = newPriority.toLowerCase();
173
+ if (normalizedPriority === priority) return;
174
+ const prev = priority;
175
+ setPriority(normalizedPriority);
176
+ setSavingPriority(true);
177
+ setError("");
178
+ setSuccessMessage("");
179
+ try {
180
+ const res = await fetch(`/api/tasks/${task.id}`, {
181
+ method: "PATCH",
182
+ headers: { "Content-Type": "application/json" },
183
+ body: JSON.stringify({ priority: normalizedPriority.toUpperCase() }),
184
+ });
185
+ if (!res.ok) throw new Error();
186
+ setSuccessMessage("Priority updated.");
187
+ router.refresh();
188
+ } catch {
189
+ setPriority(prev);
190
+ setError("Failed to update priority.");
191
+ } finally {
192
+ setSavingPriority(false);
193
+ }
194
+ }
195
+
196
+ // Task 2.5.4 - Assignee picker
197
+ const [assigneeType, setAssigneeType] = useState<"HUMAN" | "AGENT">(task.assigneeType);
198
+ const [savingAssignee, setSavingAssignee] = useState(false);
199
+
200
+ async function handleAssigneeChange(type: "HUMAN" | "AGENT") {
201
+ const prev = assigneeType;
202
+ setAssigneeType(type);
203
+ setSavingAssignee(true);
204
+ setError("");
205
+ try {
206
+ const res = await fetch(`/api/tasks/${task.id}`, {
207
+ method: "PATCH",
208
+ headers: { "Content-Type": "application/json" },
209
+ body: JSON.stringify({ assigneeType: type }),
210
+ });
211
+ if (!res.ok) throw new Error();
212
+ router.refresh();
213
+ } catch {
214
+ setAssigneeType(prev);
215
+ setError("Failed to update assignee.");
216
+ } finally {
217
+ setSavingAssignee(false);
218
+ }
219
+ }
220
+
221
+ const displayLogs = showAllActivity ? task.auditLogs : task.auditLogs.slice(0, 5);
222
+ const hasMoreLogs = task.auditLogs.length > 5;
223
+ const summaryChips = [
224
+ dueDate && new Date(dueDate) < new Date() && status !== "DONE" ? "Overdue" : null,
225
+ assigneeType === "AGENT" ? "Agent task" : "Human task",
226
+ `References ${task.references.length}`,
227
+ task.auditLogs[0]?.createdAt ? `Updated ${formatRelativeTime(task.auditLogs[0].createdAt)}` : null,
228
+ ].filter((chip): chip is string => Boolean(chip));
229
+
230
+ const auditSection = task.auditLogs.length > 0 && (
231
+ <div className="space-y-2">
232
+ <div className="flex items-center justify-between">
233
+ <label className="text-xs font-medium text-zinc-500">Activity</label>
234
+ {hasMoreLogs && (
235
+ <button
236
+ onClick={() => setShowAllActivity(!showAllActivity)}
237
+ className="text-xs text-zinc-400 hover:text-zinc-300 transition-colors"
238
+ >
239
+ {showAllActivity ? "Show less" : `Show ${task.auditLogs.length - 5} more`}
240
+ </button>
241
+ )}
242
+ </div>
243
+ <div className="space-y-2">
244
+ {displayLogs.map((log) => (
245
+ <div key={log.id} className="flex items-start gap-2 text-xs">
246
+ <Clock size={11} className="mt-0.5 shrink-0 text-zinc-600" />
247
+ <div className="min-w-0 space-y-1">
248
+ <div className="flex flex-wrap items-center gap-1">
249
+ <span className="font-medium text-zinc-300">{log.user?.name ?? "System"}</span>
250
+ <span className="text-zinc-500">{log.action.replace(/_/g, " ")}</span>
251
+ </div>
252
+ {log.detail && (
253
+ <p className="text-zinc-600 truncate">{log.detail}</p>
254
+ )}
255
+ <p className="text-zinc-700" title={new Date(log.createdAt).toLocaleString()}>
256
+ {formatRelativeTime(log.createdAt)}
257
+ </p>
258
+ </div>
259
+ </div>
260
+ ))}
261
+ </div>
262
+ </div>
263
+ );
264
+
265
+ return (
266
+ <div className="flex h-full flex-col overflow-hidden">
267
+ {/* Header */}
268
+ <div className="flex items-center justify-between border-b border-zinc-800 px-4 py-3 md:px-6">
269
+ <Link
270
+ href={backHref}
271
+ className="flex items-center gap-1.5 text-xs text-zinc-500 hover:text-zinc-300 transition-colors"
272
+ >
273
+ <X size={14} />
274
+ <span>{backLabel}</span>
275
+ </Link>
276
+ <nav className="min-w-0 text-xs text-zinc-600">
277
+ <Link href={`/notes/${task.note.id}`} className="block truncate hover:text-zinc-400 transition-colors">
278
+ {task.note.title}
279
+ </Link>
280
+ </nav>
281
+ </div>
282
+
283
+ {error && (
284
+ <div className="border-b border-red-800/40 bg-red-950/40 px-4 py-2 flex items-center justify-between gap-3 md:px-6">
285
+ <p className="text-sm text-red-300">{error}</p>
286
+ <button onClick={() => setError("")} className="text-red-500 hover:text-red-300 text-lg leading-none shrink-0">×</button>
287
+ </div>
288
+ )}
289
+
290
+ {successMessage && !error && (
291
+ <div className="border-b border-emerald-800/40 bg-emerald-950/40 px-4 py-2 md:px-6">
292
+ <p className="text-sm text-emerald-300">{successMessage}</p>
293
+ </div>
294
+ )}
295
+
296
+ {summaryChips.length > 0 && (
297
+ <div className="flex flex-wrap gap-2 border-b border-zinc-800 px-4 py-2 md:px-6">
298
+ {summaryChips.map((chip) => (
299
+ <span
300
+ key={chip}
301
+ className="rounded-full border border-zinc-800 bg-zinc-900 px-2.5 py-1 text-xs text-zinc-300"
302
+ >
303
+ {chip}
304
+ </span>
305
+ ))}
306
+ </div>
307
+ )}
308
+
309
+ {/* Body */}
310
+ <div className="flex flex-1 flex-col overflow-y-auto md:flex-row md:overflow-hidden md:divide-x md:divide-zinc-800">
311
+ {/* Left — task details */}
312
+ <div className="flex w-full shrink-0 flex-col gap-6 border-b border-zinc-800 p-4 md:w-[380px] md:overflow-y-auto md:border-b-0 md:p-6">
313
+ <h1 className="text-xl font-semibold text-zinc-100 leading-snug">{task.title}</h1>
314
+
315
+ {/* Status */}
316
+ <div className="space-y-1.5">
317
+ <label className="text-xs font-medium text-zinc-500">Status</label>
318
+ <div className="flex flex-wrap gap-1.5">
319
+ {statusOptions.map((s) => (
320
+ <button
321
+ key={s}
322
+ disabled={updating}
323
+ onClick={() => handleStatusChange(s)}
324
+ className={`transition-opacity disabled:opacity-50 ${
325
+ status === s ? "ring-1 ring-white/20 rounded" : "opacity-60 hover:opacity-90"
326
+ }`}
327
+ >
328
+ <TaskBadge status={s} />
329
+ </button>
330
+ ))}
331
+ </div>
332
+ </div>
333
+
334
+ {/* Assignee - Task 2.5.4 */}
335
+ <div className="space-y-1.5">
336
+ <label className="text-xs font-medium text-zinc-500">Assignee</label>
337
+ <select
338
+ value={assigneeType}
339
+ onChange={(e) => handleAssigneeChange(e.target.value as "HUMAN" | "AGENT")}
340
+ disabled={savingAssignee}
341
+ className={`text-sm bg-zinc-800 border border-zinc-700 rounded px-2 py-1 w-full cursor-pointer ${
342
+ savingAssignee ? "opacity-50" : ""
343
+ } ${task.assigneeType === "AGENT" ? "text-blue-400" : "text-zinc-300"}`}
344
+ >
345
+ <option value="HUMAN">Human</option>
346
+ <option value="AGENT">Agent</option>
347
+ </select>
348
+ {savingAssignee && <span className="text-xs text-zinc-500">Saving...</span>}
349
+ </div>
350
+
351
+ <div className="space-y-1.5">
352
+ <label className="text-xs font-medium text-zinc-500">
353
+ Priority {savingPriority && <span className="font-normal text-zinc-600">saving...</span>}
354
+ </label>
355
+ <select
356
+ value={priority}
357
+ onChange={(e) => handlePriorityChange(e.target.value)}
358
+ disabled={savingPriority}
359
+ className={`w-full rounded border border-zinc-700 bg-zinc-800 px-2 py-1 text-sm text-zinc-300 ${
360
+ savingPriority ? "opacity-50" : ""
361
+ }`}
362
+ >
363
+ <option value="low">low</option>
364
+ <option value="medium">medium</option>
365
+ <option value="high">high</option>
366
+ <option value="critical">critical</option>
367
+ </select>
368
+ </div>
369
+
370
+ {/* Source note */}
371
+ <div className="space-y-1.5">
372
+ <label className="text-xs font-medium text-zinc-500">Continue in source note</label>
373
+ <Link
374
+ href={`/notes/${task.note.id}`}
375
+ className="flex items-center gap-1.5 text-sm text-zinc-300 hover:text-white transition-colors"
376
+ >
377
+ <FileText size={13} />
378
+ {task.note.title}
379
+ </Link>
380
+ </div>
381
+
382
+ {/* File refs */}
383
+ {task.fileRefs.length > 0 && (
384
+ <div className="space-y-1.5">
385
+ <label className="text-xs font-medium text-zinc-500">File references</label>
386
+ <div className="flex flex-wrap gap-1.5">
387
+ {task.fileRefs.map((f) => (
388
+ <span
389
+ key={f}
390
+ className="rounded border border-zinc-700 bg-zinc-800 px-2 py-0.5 font-mono text-xs text-emerald-400"
391
+ >
392
+ {f}
393
+ </span>
394
+ ))}
395
+ </div>
396
+ </div>
397
+ )}
398
+
399
+ {/* Start date */}
400
+ <div className="space-y-1.5">
401
+ <label className="text-xs font-medium text-zinc-500">
402
+ Start date {savingStart && <span className="text-zinc-600 font-normal">saving…</span>}
403
+ </label>
404
+ <input
405
+ type="date"
406
+ value={startDate}
407
+ onChange={(e) => handleStartDateChange(e.target.value)}
408
+ className="rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-base text-zinc-300 focus:border-zinc-500 focus:outline-none ring-focus [color-scheme:dark]"
409
+ />
410
+ {startDate && (
411
+ <button
412
+ onClick={() => handleStartDateChange("")}
413
+ className="block text-xs text-zinc-600 hover:text-zinc-400 transition-colors"
414
+ >
415
+ Clear date
416
+ </button>
417
+ )}
418
+ </div>
419
+
420
+ {/* Due date */}
421
+ <div className="space-y-1.5">
422
+ <label className="text-xs font-medium text-zinc-500">
423
+ Due date {savingDue && <span className="text-zinc-600 font-normal">saving…</span>}
424
+ </label>
425
+ <input
426
+ type="date"
427
+ value={dueDate}
428
+ onChange={(e) => handleDueDateChange(e.target.value)}
429
+ className="rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-base text-zinc-300 focus:border-zinc-500 focus:outline-none ring-focus [color-scheme:dark]"
430
+ />
431
+ {dueDate && (
432
+ <button
433
+ onClick={() => handleDueDateChange("")}
434
+ className="block text-xs text-zinc-600 hover:text-zinc-400 transition-colors"
435
+ >
436
+ Clear date
437
+ </button>
438
+ )}
439
+ </div>
440
+
441
+ {/* Audit trail — desktop only here; on mobile it moves below the
442
+ note references (see bottom of this layout). */}
443
+ {auditSection && <div className="hidden md:block">{auditSection}</div>}
444
+ </div>
445
+
446
+ {/* Right — snippet thread */}
447
+ <div className="flex-1 p-4 md:overflow-y-auto md:p-6">
448
+ <SnippetThread references={task.references} taskTitle={task.title} />
449
+ </div>
450
+
451
+ {/* Audit trail — mobile only, after note references */}
452
+ {auditSection && (
453
+ <div className="border-t border-zinc-800 p-4 md:hidden">{auditSection}</div>
454
+ )}
455
+ </div>
456
+ </div>
457
+ );
458
+ }
@@ -0,0 +1,75 @@
1
+ // @vitest-environment jsdom
2
+ import { cleanup, render, screen } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { TaskListFilters } from "@/components/tasks/task-list-filters";
6
+
7
+ const pushMock = vi.fn();
8
+ let pathname = "/list";
9
+ let searchParams = new URLSearchParams();
10
+
11
+ vi.mock("next/navigation", () => ({
12
+ useRouter: () => ({ push: pushMock, refresh: vi.fn() }),
13
+ usePathname: () => pathname,
14
+ useSearchParams: () => searchParams,
15
+ }));
16
+
17
+ beforeEach(() => {
18
+ pushMock.mockReset();
19
+ pathname = "/list";
20
+ searchParams = new URLSearchParams();
21
+ });
22
+
23
+ afterEach(() => {
24
+ cleanup();
25
+ });
26
+
27
+ describe("TaskListFilters", () => {
28
+ it("shows active filter chips and removes only the clicked chip", async () => {
29
+ const user = userEvent.setup();
30
+ searchParams = new URLSearchParams(
31
+ "note=n1&folder=f1&status=DONE&assigneeType=AGENT&overdue=true&hasDueDate=true"
32
+ );
33
+
34
+ render(
35
+ <TaskListFilters
36
+ folders={[{ id: "f1", name: "Product" }]}
37
+ filterFolderId="f1"
38
+ statusFilter="DONE"
39
+ assigneeFilter="AGENT"
40
+ overdueOnly="true"
41
+ hasDueDateFilter="true"
42
+ />
43
+ );
44
+
45
+ expect(screen.getByRole("button", { name: "Folder: Product ×" })).toBeInTheDocument();
46
+ expect(screen.getByRole("button", { name: "Status: DONE ×" })).toBeInTheDocument();
47
+ expect(screen.getByRole("button", { name: "Assignee: AGENT ×" })).toBeInTheDocument();
48
+ expect(screen.getByRole("button", { name: "Overdue ×" })).toBeInTheDocument();
49
+ expect(screen.getByRole("button", { name: "Has due date ×" })).toBeInTheDocument();
50
+
51
+ await user.click(screen.getByRole("button", { name: "Status: DONE ×" }));
52
+
53
+ expect(pushMock).toHaveBeenCalledWith(
54
+ "/list?note=n1&folder=f1&assigneeType=AGENT&overdue=true&hasDueDate=true"
55
+ );
56
+ });
57
+
58
+ it("clears only filter params and preserves unrelated query state", async () => {
59
+ const user = userEvent.setup();
60
+ searchParams = new URLSearchParams("note=n1&folder=f1&status=DONE&overdue=true");
61
+
62
+ render(
63
+ <TaskListFilters
64
+ folders={[{ id: "f1", name: "Product" }]}
65
+ filterFolderId="f1"
66
+ statusFilter="DONE"
67
+ overdueOnly="true"
68
+ />
69
+ );
70
+
71
+ await user.click(screen.getByRole("button", { name: "Clear all" }));
72
+
73
+ expect(pushMock).toHaveBeenCalledWith("/list?note=n1");
74
+ });
75
+ });
@@ -0,0 +1,163 @@
1
+ "use client";
2
+
3
+ import { usePathname, useRouter, useSearchParams } from "next/navigation";
4
+
5
+ type Props = {
6
+ folders: { id: string; name: string }[];
7
+ filterFolderId?: string;
8
+ statusFilter?: string;
9
+ assigneeFilter?: string;
10
+ overdueOnly?: string;
11
+ hasDueDateFilter?: string;
12
+ };
13
+
14
+ export function TaskListFilters({
15
+ folders,
16
+ filterFolderId,
17
+ statusFilter,
18
+ assigneeFilter,
19
+ overdueOnly,
20
+ hasDueDateFilter,
21
+ }: Props) {
22
+ const router = useRouter();
23
+ const pathname = usePathname();
24
+ const searchParams = useSearchParams();
25
+
26
+ function pushParams(params: URLSearchParams) {
27
+ const query = params.toString();
28
+ router.push(query ? `${pathname}?${query}` : pathname);
29
+ }
30
+
31
+ function setParam(key: string, value: string | null) {
32
+ const params = new URLSearchParams(searchParams.toString());
33
+ if (value) params.set(key, value);
34
+ else params.delete(key);
35
+ pushParams(params);
36
+ }
37
+
38
+ function toggleParam(key: string) {
39
+ const params = new URLSearchParams(searchParams.toString());
40
+ if (params.get(key) === "true") params.delete(key);
41
+ else params.set(key, "true");
42
+ pushParams(params);
43
+ }
44
+
45
+ function clearFilters() {
46
+ const params = new URLSearchParams(searchParams.toString());
47
+ for (const key of ["folder", "status", "assigneeType", "overdue", "hasDueDate"]) {
48
+ params.delete(key);
49
+ }
50
+ pushParams(params);
51
+ }
52
+
53
+ const hasActiveFilters = Boolean(
54
+ statusFilter || assigneeFilter || overdueOnly || hasDueDateFilter || filterFolderId
55
+ );
56
+ const activeFilters: { key: string; label: string }[] = [];
57
+
58
+ if (filterFolderId) {
59
+ const folderName = folders.find((folder) => folder.id === filterFolderId)?.name ?? filterFolderId;
60
+ activeFilters.push({ key: "folder", label: `Folder: ${folderName}` });
61
+ }
62
+ if (statusFilter) {
63
+ activeFilters.push({ key: "status", label: `Status: ${statusFilter}` });
64
+ }
65
+ if (assigneeFilter) {
66
+ activeFilters.push({ key: "assigneeType", label: `Assignee: ${assigneeFilter}` });
67
+ }
68
+ if (overdueOnly === "true") {
69
+ activeFilters.push({ key: "overdue", label: "Overdue" });
70
+ }
71
+ if (hasDueDateFilter === "true") {
72
+ activeFilters.push({ key: "hasDueDate", label: "Has due date" });
73
+ }
74
+
75
+ return (
76
+ <div className="flex flex-wrap items-center gap-2 px-3 md:px-6 py-2 border-b border-zinc-800/50 bg-zinc-900/30">
77
+ {/* Folder Filter */}
78
+ {folders.length > 0 && (
79
+ <select
80
+ value={filterFolderId || ""}
81
+ onChange={(e) => setParam("folder", e.target.value || null)}
82
+ className="text-xs bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-zinc-300"
83
+ >
84
+ <option value="">All folders</option>
85
+ {folders.map((f) => (
86
+ <option key={f.id} value={f.id}>{f.name}</option>
87
+ ))}
88
+ </select>
89
+ )}
90
+
91
+ {/* Status Filter */}
92
+ <select
93
+ value={statusFilter || ""}
94
+ onChange={(e) => setParam("status", e.target.value || null)}
95
+ className="text-xs bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-zinc-300"
96
+ >
97
+ <option value="">All statuses</option>
98
+ <option value="TODO,DOING">Active (To Do / Doing)</option>
99
+ <option value="DONE">Done</option>
100
+ <option value="ARCHIVED">Archived</option>
101
+ </select>
102
+
103
+ {/* Assignee Filter */}
104
+ <select
105
+ value={assigneeFilter || ""}
106
+ onChange={(e) => setParam("assigneeType", e.target.value || null)}
107
+ className="text-xs bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-zinc-300"
108
+ >
109
+ <option value="">All assignees</option>
110
+ <option value="HUMAN">Human</option>
111
+ <option value="AGENT">Agent</option>
112
+ </select>
113
+
114
+ {/* Overdue Toggle */}
115
+ <button
116
+ onClick={() => toggleParam("overdue")}
117
+ className={`text-xs px-2 py-1 rounded border transition-colors ${
118
+ overdueOnly === "true"
119
+ ? "bg-red-900/30 border-red-700 text-red-300"
120
+ : "bg-zinc-800 border-zinc-700 text-zinc-400 hover:text-zinc-300"
121
+ }`}
122
+ >
123
+ Overdue only
124
+ </button>
125
+
126
+ {/* Has Due Date Toggle */}
127
+ <button
128
+ onClick={() => toggleParam("hasDueDate")}
129
+ className={`text-xs px-2 py-1 rounded border transition-colors ${
130
+ hasDueDateFilter === "true"
131
+ ? "bg-blue-900/30 border-blue-700 text-blue-300"
132
+ : "bg-zinc-800 border-zinc-700 text-zinc-400 hover:text-zinc-300"
133
+ }`}
134
+ >
135
+ Has due date
136
+ </button>
137
+
138
+ {activeFilters.length > 0 && (
139
+ <div className="flex flex-wrap items-center gap-2">
140
+ {activeFilters.map((filter) => (
141
+ <button
142
+ key={filter.key}
143
+ onClick={() => setParam(filter.key, null)}
144
+ className="rounded-full border border-zinc-700 bg-zinc-800 px-2 py-1 text-xs text-zinc-300"
145
+ >
146
+ {filter.label} ×
147
+ </button>
148
+ ))}
149
+ </div>
150
+ )}
151
+
152
+ {/* Clear Filters */}
153
+ {hasActiveFilters && (
154
+ <button
155
+ onClick={clearFilters}
156
+ className="text-xs text-zinc-500 hover:text-zinc-300 ml-2"
157
+ >
158
+ Clear all
159
+ </button>
160
+ )}
161
+ </div>
162
+ );
163
+ }