@knotpad/app 0.1.5 → 0.1.6

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 (340) hide show
  1. package/app/(app)/calendar/page.tsx +57 -0
  2. package/app/(app)/error.tsx +35 -0
  3. package/app/(app)/graph/page.tsx +32 -0
  4. package/app/(app)/guide/page.tsx +21 -0
  5. package/app/(app)/kanban/loading.tsx +24 -0
  6. package/app/(app)/kanban/page.tsx +59 -0
  7. package/app/(app)/layout.tsx +122 -0
  8. package/app/(app)/list/loading.tsx +21 -0
  9. package/app/(app)/list/page.tsx +137 -0
  10. package/app/(app)/loading.tsx +18 -0
  11. package/app/(app)/notes/[noteId]/page.tsx +84 -0
  12. package/app/(app)/notes/layout.tsx +30 -0
  13. package/app/(app)/notes/page.tsx +39 -0
  14. package/app/(app)/page.tsx +5 -0
  15. package/app/(app)/settings/agent-token/page.tsx +59 -0
  16. package/app/(app)/settings/backup/page.tsx +49 -0
  17. package/app/(app)/settings/billing/page.tsx +53 -0
  18. package/app/(app)/settings/calendar/page.tsx +41 -0
  19. package/app/(app)/settings/layout.test.tsx +39 -0
  20. package/app/(app)/settings/layout.tsx +71 -0
  21. package/app/(app)/settings/page.tsx +4 -0
  22. package/app/(app)/settings/security/page.tsx +43 -0
  23. package/app/(app)/settings/team/page.tsx +74 -0
  24. package/app/(app)/settings/workspace/page.tsx +27 -0
  25. package/app/(app)/tasks/[taskId]/page.tsx +79 -0
  26. package/app/(auth)/forgot-password/page.tsx +106 -0
  27. package/app/(auth)/guest/page.tsx +56 -0
  28. package/app/(auth)/layout.tsx +13 -0
  29. package/app/(auth)/login/page.tsx +14 -0
  30. package/app/(auth)/register/page.tsx +193 -0
  31. package/app/(auth)/reset-password/page.tsx +138 -0
  32. package/app/api/account/claim/route.tsx +135 -0
  33. package/app/api/admin/backfill-encryption/route.tsx +43 -0
  34. package/app/api/admin/license/route.tsx +42 -0
  35. package/app/api/auth/2fa/route.tsx +148 -0
  36. package/app/api/auth/[...nextauth]/route.tsx +3 -0
  37. package/app/api/auth/change-password/route.tsx +61 -0
  38. package/app/api/auth/check-2fa/route.tsx +19 -0
  39. package/app/api/auth/forgot-password/route.tsx +65 -0
  40. package/app/api/auth/reset-password/route.tsx +52 -0
  41. package/app/api/auth/verify-2fa/route.tsx +88 -0
  42. package/app/api/backup/download/db/route.ts +29 -0
  43. package/app/api/backup/download/notes/route.ts +25 -0
  44. package/app/api/backup/settings/route.ts +92 -0
  45. package/app/api/billing/checkout/route.tsx +81 -0
  46. package/app/api/billing/migrate/route.tsx +163 -0
  47. package/app/api/billing/portal/route.tsx +24 -0
  48. package/app/api/billing/setup-intent/route.tsx +55 -0
  49. package/app/api/billing/status/route.tsx +36 -0
  50. package/app/api/billing/subscribe/route.tsx +85 -0
  51. package/app/api/billing/webhook/route.tsx +199 -0
  52. package/app/api/calendar-feeds/[feedId]/route.tsx +67 -0
  53. package/app/api/calendar-feeds/[feedId]/sync/route.tsx +37 -0
  54. package/app/api/calendar-feeds/events/route.tsx +82 -0
  55. package/app/api/calendar-feeds/route.tsx +52 -0
  56. package/app/api/calendar-feeds/sync-all/route.tsx +34 -0
  57. package/app/api/cron/calendar-feeds/route.tsx +31 -0
  58. package/app/api/cron/stale-tasks/route.tsx +51 -0
  59. package/app/api/cron/sync/route.tsx +34 -0
  60. package/app/api/devices/[deviceId]/route.tsx +25 -0
  61. package/app/api/devices/route.tsx +41 -0
  62. package/app/api/export/route.tsx +40 -0
  63. package/app/api/feedback/route.tsx +54 -0
  64. package/app/api/folders/[folderId]/route.tsx +51 -0
  65. package/app/api/folders/route.tsx +37 -0
  66. package/app/api/graph/route.tsx +242 -0
  67. package/app/api/guest/route.tsx +58 -0
  68. package/app/api/health/route.tsx +10 -0
  69. package/app/api/holidays/countries/route.tsx +14 -0
  70. package/app/api/holidays/route.tsx +49 -0
  71. package/app/api/holidays/states/route.tsx +21 -0
  72. package/app/api/invites/[token]/route.tsx +131 -0
  73. package/app/api/invites/route.tsx +74 -0
  74. package/app/api/mcp/generate-token/route.tsx +55 -0
  75. package/app/api/mcp/revoke-token/[tokenId]/route.tsx +30 -0
  76. package/app/api/mcp/update-alias/[tokenId]/route.tsx +22 -0
  77. package/app/api/notes/[noteId]/export/route.tsx +45 -0
  78. package/app/api/notes/[noteId]/route.tsx +360 -0
  79. package/app/api/notes/route.tsx +112 -0
  80. package/app/api/notifications/route.tsx +44 -0
  81. package/app/api/register/route.tsx +67 -0
  82. package/app/api/restore/route.tsx +148 -0
  83. package/app/api/sync/conflicts/[conflictId]/route.tsx +134 -0
  84. package/app/api/sync/conflicts/route.tsx +48 -0
  85. package/app/api/sync/status/route.tsx +49 -0
  86. package/app/api/sync/trigger/route.tsx +15 -0
  87. package/app/api/tasks/[taskId]/detail/route.tsx +68 -0
  88. package/app/api/tasks/[taskId]/route.tsx +259 -0
  89. package/app/api/tasks/bulk/route.tsx +133 -0
  90. package/app/api/tasks/route.tsx +36 -0
  91. package/app/api/workspace/active/route.tsx +39 -0
  92. package/app/api/workspace/create-team/route.tsx +42 -0
  93. package/app/api/workspace/kanban-statuses/route.tsx +71 -0
  94. package/app/api/workspace/members/[memberId]/route.tsx +69 -0
  95. package/app/api/workspace/route.tsx +24 -0
  96. package/app/download/page.tsx +170 -0
  97. package/app/favicon.ico +0 -0
  98. package/app/generated/prisma/client.d.ts +1 -0
  99. package/app/generated/prisma/client.js +5 -0
  100. package/app/generated/prisma/default.d.ts +1 -0
  101. package/app/generated/prisma/default.js +5 -0
  102. package/app/generated/prisma/edge.d.ts +1 -0
  103. package/app/generated/prisma/edge.js +497 -0
  104. package/app/generated/prisma/index-browser.js +523 -0
  105. package/app/generated/prisma/index.d.ts +46376 -0
  106. package/app/generated/prisma/index.js +497 -0
  107. package/app/generated/prisma/package.json +144 -0
  108. package/app/generated/prisma/query_compiler_fast_bg.js +2 -0
  109. package/app/generated/prisma/query_compiler_fast_bg.wasm +0 -0
  110. package/app/generated/prisma/query_compiler_fast_bg.wasm-base64.js +2 -0
  111. package/app/generated/prisma/runtime/client.d.ts +3386 -0
  112. package/app/generated/prisma/runtime/client.js +86 -0
  113. package/app/generated/prisma/runtime/index-browser.d.ts +90 -0
  114. package/app/generated/prisma/runtime/index-browser.js +6 -0
  115. package/app/generated/prisma/runtime/wasm-compiler-edge.js +76 -0
  116. package/app/generated/prisma/schema.prisma +456 -0
  117. package/app/generated/prisma/wasm-edge-light-loader.mjs +5 -0
  118. package/app/generated/prisma/wasm-worker-loader.mjs +5 -0
  119. package/app/globals.css +54 -0
  120. package/app/invite/[token]/page.tsx +52 -0
  121. package/app/layout.tsx +90 -0
  122. package/app/mcp/route.tsx +430 -0
  123. package/app/opengraph-image.tsx +120 -0
  124. package/app/page.tsx +398 -0
  125. package/app/privacy/page.tsx +69 -0
  126. package/app/robots.tsx +25 -0
  127. package/app/sitemap.tsx +36 -0
  128. package/app/terms/page.tsx +69 -0
  129. package/app/upgrade/page.tsx +75 -0
  130. package/auth.config.ts +33 -0
  131. package/auth.ts +79 -0
  132. package/bin/brief.js +224 -0
  133. package/components/auth/login-form.tsx +302 -0
  134. package/components/auth/password-checklist.tsx +31 -0
  135. package/components/auth/password-input.tsx +36 -0
  136. package/components/auth/switch-account-button.test.tsx +22 -0
  137. package/components/auth/switch-account-button.tsx +19 -0
  138. package/components/auth/two-factor-input.tsx +116 -0
  139. package/components/billing/billing-dashboard.tsx +265 -0
  140. package/components/billing/card-form.tsx +210 -0
  141. package/components/billing/claim-account-form.tsx +99 -0
  142. package/components/branding/app-logo.test.tsx +20 -0
  143. package/components/branding/app-logo.tsx +25 -0
  144. package/components/calendar/calendar-agenda.tsx +150 -0
  145. package/components/calendar/calendar-drag.test.tsx +177 -0
  146. package/components/calendar/calendar-grid.tsx +357 -0
  147. package/components/calendar/calendar-hooks.test.tsx +27 -0
  148. package/components/calendar/calendar-hooks.ts +351 -0
  149. package/components/calendar/calendar-toolbar.test.tsx +68 -0
  150. package/components/calendar/calendar-toolbar.tsx +291 -0
  151. package/components/calendar/calendar-types.ts +148 -0
  152. package/components/calendar/calendar-view.test.tsx +295 -0
  153. package/components/calendar/calendar-view.tsx +307 -0
  154. package/components/calendar/day-detail-popover.tsx +174 -0
  155. package/components/calendar/task-chip.tsx +86 -0
  156. package/components/command/command-palette.test.tsx +33 -0
  157. package/components/command/command-palette.tsx +310 -0
  158. package/components/download-cta.tsx +87 -0
  159. package/components/feedback/feedback-popup.tsx +207 -0
  160. package/components/graph/graph-draw.ts +337 -0
  161. package/components/graph/graph-overlays.tsx +160 -0
  162. package/components/graph/graph-page.test.tsx +131 -0
  163. package/components/graph/graph-page.tsx +263 -0
  164. package/components/graph/graph-types.ts +47 -0
  165. package/components/graph/graph-view.tsx +322 -0
  166. package/components/guide/guide-view.tsx +522 -0
  167. package/components/kanban/kanban-board.test.tsx +128 -0
  168. package/components/kanban/kanban-board.tsx +361 -0
  169. package/components/kanban/kanban-card-menu.tsx +102 -0
  170. package/components/kanban/kanban-card.tsx +227 -0
  171. package/components/kanban/kanban-column.tsx +49 -0
  172. package/components/kanban/kanban-status-context.tsx +28 -0
  173. package/components/landing/calendar-sandbox.test.tsx +15 -0
  174. package/components/landing/calendar-sandbox.tsx +107 -0
  175. package/components/landing/graph-sandbox.test.tsx +27 -0
  176. package/components/landing/graph-sandbox.tsx +80 -0
  177. package/components/landing/kanban-sandbox.test.tsx +24 -0
  178. package/components/landing/kanban-sandbox.tsx +101 -0
  179. package/components/landing/landing-showcase.test.tsx +21 -0
  180. package/components/landing/landing-showcase.tsx +54 -0
  181. package/components/landing/list-sandbox.tsx +86 -0
  182. package/components/landing/mock-workspace.ts +168 -0
  183. package/components/landing/notes-sandbox.test.tsx +14 -0
  184. package/components/landing/notes-sandbox.tsx +88 -0
  185. package/components/layout/app-shell.tsx +83 -0
  186. package/components/layout/backup-scheduler.tsx +122 -0
  187. package/components/layout/bottom-nav.tsx +43 -0
  188. package/components/layout/icon-bar.test.tsx +29 -0
  189. package/components/layout/icon-bar.tsx +118 -0
  190. package/components/layout/mobile-top-bar.tsx +68 -0
  191. package/components/layout/notes-panel-folder.tsx +127 -0
  192. package/components/layout/notes-panel-note-item.tsx +140 -0
  193. package/components/layout/notes-panel-task-tab.tsx +63 -0
  194. package/components/layout/notes-panel-types.ts +44 -0
  195. package/components/layout/notes-panel.tsx +476 -0
  196. package/components/layout/notification-bell.tsx +251 -0
  197. package/components/layout/paywall-screen.tsx +41 -0
  198. package/components/layout/pro-banner.tsx +76 -0
  199. package/components/layout/sw-register.tsx +27 -0
  200. package/components/layout/workspace-switcher.tsx +90 -0
  201. package/components/notes/mobile-bottom-sheet.tsx +99 -0
  202. package/components/notes/note-editor-context-menu.tsx +47 -0
  203. package/components/notes/note-editor-dom.ts +33 -0
  204. package/components/notes/note-editor-dropdowns.tsx +484 -0
  205. package/components/notes/note-editor-hooks.ts +692 -0
  206. package/components/notes/note-editor-keyboard.ts +305 -0
  207. package/components/notes/note-editor-overlay.tsx +90 -0
  208. package/components/notes/note-editor.test.tsx +372 -0
  209. package/components/notes/note-editor.tsx +662 -0
  210. package/components/notes/note-preview-pane.tsx +156 -0
  211. package/components/notes/note-tabs.tsx +120 -0
  212. package/components/notes/note-types.tsx +157 -0
  213. package/components/settings/accept-invite.tsx +108 -0
  214. package/components/settings/agent-token-settings.tsx +369 -0
  215. package/components/settings/backup-restore-settings.test.tsx +25 -0
  216. package/components/settings/backup-restore-settings.tsx +327 -0
  217. package/components/settings/calendar-feeds-settings.tsx +489 -0
  218. package/components/settings/calendar-general-settings.tsx +174 -0
  219. package/components/settings/confirm-danger-action.test.tsx +215 -0
  220. package/components/settings/confirm-danger-action.tsx +65 -0
  221. package/components/settings/security-settings.tsx +252 -0
  222. package/components/settings/settings-guidance.test.tsx +98 -0
  223. package/components/settings/team-settings.tsx +319 -0
  224. package/components/settings/two-factor-auth.tsx +296 -0
  225. package/components/settings/workspace-settings-client.tsx +363 -0
  226. package/components/settings/workspace-settings-form.tsx +73 -0
  227. package/components/sync/conflict-viewer.tsx +247 -0
  228. package/components/sync/sync-indicator.tsx +171 -0
  229. package/components/tasks/snippet-thread.tsx +119 -0
  230. package/components/tasks/status-dot.tsx +47 -0
  231. package/components/tasks/task-badge.tsx +43 -0
  232. package/components/tasks/task-detail.test.tsx +187 -0
  233. package/components/tasks/task-detail.tsx +458 -0
  234. package/components/tasks/task-list-filters.test.tsx +75 -0
  235. package/components/tasks/task-list-filters.tsx +163 -0
  236. package/components/tasks/task-list-types.ts +20 -0
  237. package/components/tasks/task-list.test.tsx +175 -0
  238. package/components/tasks/task-list.tsx +481 -0
  239. package/components/tasks/task-row.tsx +85 -0
  240. package/components/tasks/task-table-row.tsx +259 -0
  241. package/components/ui/skeleton.tsx +3 -0
  242. package/components/ui/toast.test.tsx +42 -0
  243. package/components/ui/toast.tsx +70 -0
  244. package/instrumentation.tsx +23 -0
  245. package/lib/api-error.ts +50 -0
  246. package/lib/backup/backup-runner.test.ts +32 -0
  247. package/lib/backup/backup-runner.ts +19 -0
  248. package/lib/backup/backup-schedule.test.ts +23 -0
  249. package/lib/backup/backup-schedule.ts +55 -0
  250. package/lib/backup/backup-settings.test.ts +30 -0
  251. package/lib/backup/backup-settings.ts +27 -0
  252. package/lib/backup/export-notes-zip.test.ts +26 -0
  253. package/lib/backup/export-notes-zip.ts +82 -0
  254. package/lib/backup/export-workspace-backup.test.ts +17 -0
  255. package/lib/backup/export-workspace-backup.ts +77 -0
  256. package/lib/backup/restore-workspace-from-export.test.ts +18 -0
  257. package/lib/backup/restore-workspace-from-export.ts +183 -0
  258. package/lib/backup/types.ts +14 -0
  259. package/lib/brand-icons.ts +1 -0
  260. package/lib/calendar-feed-crypto.ts +38 -0
  261. package/lib/calendar-feed.ts +239 -0
  262. package/lib/client/online-status.ts +47 -0
  263. package/lib/conflict-resolver.test.ts +57 -0
  264. package/lib/conflict-resolver.ts +240 -0
  265. package/lib/db-init.ts +79 -0
  266. package/lib/email.ts +159 -0
  267. package/lib/encryption.test.ts +41 -0
  268. package/lib/encryption.ts +98 -0
  269. package/lib/extract-snippet.test.ts +123 -0
  270. package/lib/extract-snippet.ts +69 -0
  271. package/lib/kanban-status.ts +55 -0
  272. package/lib/license.ts +21 -0
  273. package/lib/limits.ts +31 -0
  274. package/lib/mcp-auth.test.ts +58 -0
  275. package/lib/mcp-auth.ts +65 -0
  276. package/lib/mcp-contract.test.ts +25 -0
  277. package/lib/mcp-contract.ts +210 -0
  278. package/lib/mcp-handler.ts +31 -0
  279. package/lib/mcp-url.test.ts +12 -0
  280. package/lib/mcp-url.ts +7 -0
  281. package/lib/mentions.test.ts +45 -0
  282. package/lib/mentions.ts +73 -0
  283. package/lib/note-crypto.ts +108 -0
  284. package/lib/note-sync.ts +201 -0
  285. package/lib/note-title.ts +93 -0
  286. package/lib/prisma.ts +193 -0
  287. package/lib/pro-flush.ts +292 -0
  288. package/lib/rate-limit.ts +57 -0
  289. package/lib/stripe.ts +38 -0
  290. package/lib/sync-worker.ts +388 -0
  291. package/lib/task-parser.test.ts +91 -0
  292. package/lib/task-parser.ts +81 -0
  293. package/lib/task-utils.ts +52 -0
  294. package/lib/use-is-electron.ts +19 -0
  295. package/lib/use-is-mobile.ts +22 -0
  296. package/lib/validation/calendar-feed.ts +31 -0
  297. package/lib/validation/note.ts +27 -0
  298. package/lib/validation/task.ts +26 -0
  299. package/lib/view-preferences.test.ts +54 -0
  300. package/lib/view-preferences.ts +28 -0
  301. package/lib/workspace.ts +66 -0
  302. package/next.config.ts +21 -0
  303. package/package.json +49 -3
  304. package/postcss.config.mjs +7 -0
  305. package/prisma/migrations/20260519021916_init/migration.sql +388 -0
  306. package/prisma/migrations/20260519061113_drop_sync_password/migration.sql +8 -0
  307. package/prisma/migrations/20260520065016_add_task_start_date/migration.sql +2 -0
  308. package/prisma/migrations/20260529010600_remove_encryption_fields/migration.sql +12 -0
  309. package/prisma/migrations/20260529020000_restore_encryption_salt/migration.sql +3 -0
  310. package/prisma/migrations/20260529030000_add_folders/migration.sql +17 -0
  311. package/prisma/migrations/20260605000000_deferred_fixes/migration.sql +31 -0
  312. package/prisma/migrations/20260605020806_add_pending_sync_to_note_and_task/migration.sql +5 -0
  313. package/prisma/migrations/20260605063634_add_stripe_webhook_event_sync_lock/migration.sql +14 -0
  314. package/prisma/migrations/20260605100000_add_prod_indexes/migration.sql +26 -0
  315. package/prisma/migrations/20260608081404_add_kanban_statuses/migration.sql +23 -0
  316. package/prisma/migrations/20260611032723_add_calendar_feeds/migration.sql +43 -0
  317. package/prisma/migrations/20260611040000_add_calendar_feed_color/migration.sql +2 -0
  318. package/prisma/migrations/20260611050000_add_task_priority/migration.sql +14 -0
  319. package/prisma/migrations/20260612060000_add_critical_priority/migration.sql +2 -0
  320. package/prisma/migrations/20260613090000_add_backup_settings/migration.sql +25 -0
  321. package/prisma/migrations/20260614160000_add_feedback/migration.sql +20 -0
  322. package/prisma/migrations/20260614210000_add_2fa/migration.sql +4 -0
  323. package/prisma/migrations/migration_lock.toml +3 -0
  324. package/prisma/schema.prisma +457 -0
  325. package/public/Logo_icon.svg +1 -0
  326. package/public/file.svg +1 -0
  327. package/public/globe.svg +1 -0
  328. package/public/icon-192.png +0 -0
  329. package/public/icon-512.png +0 -0
  330. package/public/icon.svg +4 -0
  331. package/public/icon_dark.svg +1 -0
  332. package/public/knotpad_icon.svg +1 -0
  333. package/public/knotpad_logo_full.svg +1 -0
  334. package/public/manifest.json +14 -0
  335. package/public/next.svg +1 -0
  336. package/public/sw.js +137 -0
  337. package/public/vercel.svg +1 -0
  338. package/public/window.svg +1 -0
  339. package/tsconfig.json +35 -0
  340. package/brief.js +0 -311
@@ -0,0 +1,85 @@
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import { useKanbanStatuses } from "@/components/kanban/kanban-status-context";
5
+ import type { Task } from "./task-list-types";
6
+
7
+ export function TaskRow({
8
+ task,
9
+ status,
10
+ overdue,
11
+ updating,
12
+ detailHref,
13
+ onStatusChange,
14
+ }: {
15
+ task: Task;
16
+ status: string;
17
+ overdue: boolean;
18
+ updating: boolean;
19
+ detailHref: string;
20
+ onStatusChange: (id: string, s: string) => void;
21
+ }) {
22
+ const kanbanStatuses = useKanbanStatuses();
23
+
24
+ const assigneeLabel =
25
+ task.assigneeType === "AGENT"
26
+ ? task.claimedByAlias
27
+ ? `@agent:${task.claimedByAlias}`
28
+ : "@agent"
29
+ : task.assignee?.name
30
+ ? `@${task.assignee.name}`
31
+ : null;
32
+
33
+ const dueDateStr = task.dueDate
34
+ ? new Date(task.dueDate).toLocaleDateString(undefined, { month: "short", day: "numeric" })
35
+ : null;
36
+
37
+ return (
38
+ <div className={`flex items-start gap-3 rounded-md px-3 py-2.5 transition-colors group ${overdue ? "bg-red-950/20 hover:bg-red-950/30" : "hover:bg-zinc-800/50"}`}>
39
+ <div className="flex-1 min-w-0">
40
+ <Link href={detailHref} className="block group-hover:text-white transition-colors">
41
+ <p className={`text-sm truncate ${overdue ? "text-red-300" : "text-zinc-200"}`}>
42
+ {overdue && <span className="mr-1.5 text-red-400">!</span>}
43
+ {task.title}
44
+ </p>
45
+ <div className="mt-0.5 flex items-center gap-2 text-xs text-zinc-600">
46
+ <span className="truncate">{task.note.title}</span>
47
+ {dueDateStr && (
48
+ <>
49
+ <span>·</span>
50
+ <span className={overdue ? "text-red-400 font-medium" : "text-zinc-500"}>
51
+ {overdue ? "Due " : ""}{dueDateStr}
52
+ </span>
53
+ </>
54
+ )}
55
+ {assigneeLabel && (
56
+ <>
57
+ <span>·</span>
58
+ <span className={task.assigneeType === "AGENT" ? "text-blue-500" : "text-zinc-500"}>
59
+ {assigneeLabel}
60
+ </span>
61
+ </>
62
+ )}
63
+ </div>
64
+ </Link>
65
+ </div>
66
+
67
+ <select
68
+ value={status}
69
+ disabled={updating}
70
+ onChange={(e) => onStatusChange(task.id, e.target.value)}
71
+ className={`rounded border px-2 py-1 text-xs focus:outline-none ring-focus disabled:opacity-50 cursor-pointer ${
72
+ overdue
73
+ ? "border-red-800/60 bg-red-950/40 text-red-300"
74
+ : "border-zinc-700 bg-zinc-900 text-zinc-400"
75
+ }`}
76
+ >
77
+ {kanbanStatuses.map((s) => (
78
+ <option key={s.key} value={s.key}>
79
+ {s.label}
80
+ </option>
81
+ ))}
82
+ </select>
83
+ </div>
84
+ );
85
+ }
@@ -0,0 +1,259 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import Link from "next/link";
5
+ import type { Task } from "./task-list-types";
6
+
7
+ export function TaskTableRow({
8
+ task,
9
+ status,
10
+ overdue,
11
+ updating,
12
+ onStatusChange,
13
+ isSelected,
14
+ onSelect,
15
+ kanbanStatuses,
16
+ detailHref,
17
+ onUpdateDueDate,
18
+ onUpdateAssignee,
19
+ }: {
20
+ task: Task;
21
+ status: string;
22
+ overdue: boolean;
23
+ updating: boolean;
24
+ onStatusChange: (taskId: string, newStatus: string) => void;
25
+ isSelected: boolean;
26
+ onSelect: () => void;
27
+ kanbanStatuses: { key: string; label: string; color?: string }[];
28
+ detailHref: string;
29
+ onUpdateDueDate?: (taskId: string, dueDate: string | null) => void;
30
+ onUpdateAssignee?: (taskId: string, assigneeType: "HUMAN" | "AGENT", assigneeId?: string) => void;
31
+ }) {
32
+ // Format relative time (e.g., "2h ago", "3d ago")
33
+ function formatRelativeTime(date: string | Date | undefined): string {
34
+ if (!date) return "-";
35
+ const then = new Date(date);
36
+ const now = new Date();
37
+ const diffMs = now.getTime() - then.getTime();
38
+ const diffMins = Math.floor(diffMs / 60000);
39
+ const diffHours = Math.floor(diffMs / 3600000);
40
+ const diffDays = Math.floor(diffMs / 86400000);
41
+
42
+ if (diffMins < 60) return `${diffMins}m ago`;
43
+ if (diffHours < 24) return `${diffHours}h ago`;
44
+ if (diffDays < 7) return `${diffDays}d ago`;
45
+ return then.toLocaleDateString();
46
+ }
47
+
48
+ // Get priority badge color
49
+ function getPriorityColor(priority?: string | null): string {
50
+ switch (priority?.toLowerCase()) {
51
+ case "critical": return "text-red-500 font-semibold";
52
+ case "high": return "text-red-400";
53
+ case "medium": return "text-yellow-400";
54
+ case "low": return "text-green-400";
55
+ default: return "text-zinc-500";
56
+ }
57
+ }
58
+
59
+ // Get assignee label
60
+ // Inline edit states
61
+ const [editingDueDate, setEditingDueDate] = useState(false);
62
+ const [editingAssignee, setEditingAssignee] = useState(false);
63
+ const [tempDueDate, setTempDueDate] = useState<string>("");
64
+ const [tempAssignee, setTempAssignee] = useState<string>("");
65
+ const [saving, setSaving] = useState(false);
66
+
67
+ const assigneeLabel =
68
+ task.assigneeType === "AGENT"
69
+ ? task.claimedByAlias
70
+ ? `@agent:${task.claimedByAlias}`
71
+ : "@agent"
72
+ : task.assignee?.name
73
+ ? `@${task.assignee.name}`
74
+ : task.assignee?.email
75
+ ? `@${task.assignee.email.split("@")[0]}`
76
+ : "-";
77
+
78
+ const rowClassName = isSelected
79
+ ? `ring-1 ring-inset ring-blue-900/30 ${overdue ? "bg-red-950/10" : "bg-blue-950/20"}`
80
+ : overdue
81
+ ? "bg-red-950/10 hover:bg-red-950/20"
82
+ : "hover:bg-zinc-800/30";
83
+
84
+ return (
85
+ <tr className={rowClassName}>
86
+ {/* Checkbox */}
87
+ <td className="px-3 py-2">
88
+ <input
89
+ type="checkbox"
90
+ checked={isSelected}
91
+ onChange={onSelect}
92
+ className="rounded border-zinc-600 bg-zinc-800 text-blue-600"
93
+ />
94
+ </td>
95
+
96
+ {/* Title */}
97
+ <td className="px-4 py-2">
98
+ <div className="flex items-center gap-2">
99
+ <Link
100
+ href={detailHref}
101
+ className="block truncate text-left text-sm text-zinc-200 hover:text-zinc-100"
102
+ title={task.title}
103
+ >
104
+ {task.title}
105
+ </Link>
106
+ {overdue && <span className="text-xs text-red-400">overdue</span>}
107
+ </div>
108
+ </td>
109
+
110
+ {/* Status dropdown */}
111
+ <td className="px-4 py-2">
112
+ <select
113
+ value={status}
114
+ disabled={updating}
115
+ onChange={(e) => onStatusChange(task.id, e.target.value)}
116
+ className={`text-xs rounded border px-2 py-1 bg-zinc-900 border-zinc-700 text-zinc-300 ${
117
+ updating ? "opacity-50" : ""
118
+ }`}
119
+ >
120
+ {kanbanStatuses.map((s) => (
121
+ <option key={s.key} value={s.key}>
122
+ {s.label}
123
+ </option>
124
+ ))}
125
+ </select>
126
+ </td>
127
+
128
+ {/* Due date - inline editable */}
129
+ <td className="px-4 py-2">
130
+ {editingDueDate ? (
131
+ <div className="flex items-center gap-1">
132
+ <input
133
+ type="date"
134
+ value={tempDueDate}
135
+ onChange={(e) => setTempDueDate(e.target.value)}
136
+ className="text-xs bg-zinc-800 border border-zinc-700 rounded px-1 py-0.5 text-zinc-300 w-28"
137
+ autoFocus
138
+ />
139
+ <button
140
+ onClick={async () => {
141
+ setSaving(true);
142
+ await onUpdateDueDate?.(task.id, tempDueDate || null);
143
+ setSaving(false);
144
+ setEditingDueDate(false);
145
+ }}
146
+ disabled={saving}
147
+ className="text-xs text-green-500 hover:text-green-400"
148
+ >
149
+
150
+ </button>
151
+ <button
152
+ onClick={() => setEditingDueDate(false)}
153
+ className="text-xs text-red-500 hover:text-red-400"
154
+ >
155
+
156
+ </button>
157
+ </div>
158
+ ) : (
159
+ <button
160
+ onClick={() => {
161
+ setTempDueDate(task.dueDate ? new Date(task.dueDate).toISOString().split("T")[0] : "");
162
+ setEditingDueDate(true);
163
+ }}
164
+ className={`text-xs ${overdue ? "text-red-400 font-medium" : "text-zinc-400"} hover:text-zinc-200 transition-colors text-left`}
165
+ >
166
+ {task.dueDate
167
+ ? new Date(task.dueDate).toLocaleDateString(undefined, { month: "short", day: "numeric" })
168
+ : "-"}
169
+ </button>
170
+ )}
171
+ </td>
172
+
173
+ {/* Priority */}
174
+ <td className="px-4 py-2">
175
+ <span className={`text-xs ${getPriorityColor(task.priority)}`}>
176
+ {task.priority || "-"}
177
+ </span>
178
+ </td>
179
+
180
+ {/* Assignee - inline editable */}
181
+ <td className="px-4 py-2">
182
+ {editingAssignee ? (
183
+ <div className="flex items-center gap-1">
184
+ <select
185
+ value={tempAssignee}
186
+ onChange={(e) => setTempAssignee(e.target.value)}
187
+ className="text-xs bg-zinc-800 border border-zinc-700 rounded px-1 py-0.5 text-zinc-300"
188
+ autoFocus
189
+ >
190
+ <option value="">Unassigned</option>
191
+ <option value="AGENT">@agent</option>
192
+ <option value="HUMAN">Human</option>
193
+ </select>
194
+ <button
195
+ onClick={async () => {
196
+ setSaving(true);
197
+ // "" = unassigned (HUMAN with no assignee), "AGENT" = agent task,
198
+ // "HUMAN:<id>" = assigned to specific human
199
+ const assigneeType = tempAssignee === "" ? "HUMAN" : tempAssignee.split(":")[0] as "HUMAN" | "AGENT";
200
+ const assigneeId = tempAssignee.includes(":") ? tempAssignee.split(":")[1] : null;
201
+ await onUpdateAssignee?.(task.id, assigneeType, assigneeId ?? undefined);
202
+ setSaving(false);
203
+ setEditingAssignee(false);
204
+ }}
205
+ disabled={saving}
206
+ className="text-xs text-green-500 hover:text-green-400"
207
+ >
208
+
209
+ </button>
210
+ <button
211
+ onClick={() => setEditingAssignee(false)}
212
+ className="text-xs text-red-500 hover:text-red-400"
213
+ >
214
+
215
+ </button>
216
+ </div>
217
+ ) : (
218
+ <button
219
+ onClick={() => {
220
+ const currentAssignee = task.assigneeType === "AGENT"
221
+ ? task.claimedByAlias
222
+ ? `AGENT:${task.claimedBy}`
223
+ : "AGENT"
224
+ : task.assignee?.id
225
+ ? `HUMAN:${task.assignee.id}`
226
+ : "";
227
+ setTempAssignee(currentAssignee);
228
+ setEditingAssignee(true);
229
+ }}
230
+ className={`text-xs ${task.assigneeType === "AGENT" ? "text-blue-400" : "text-zinc-400"} hover:text-zinc-200 transition-colors text-left`}
231
+ >
232
+ {assigneeLabel}
233
+ </button>
234
+ )}
235
+ </td>
236
+
237
+ {/* Note/Folder */}
238
+ <td className="px-4 py-2">
239
+ <Link
240
+ href={`/notes/${task.note.id}`}
241
+ className="block text-xs text-zinc-500 hover:text-zinc-300 truncate max-w-[150px]"
242
+ title={task.note.title}
243
+ >
244
+ {task.note.folder && (
245
+ <span className="text-zinc-600">{task.note.folder.name} / </span>
246
+ )}
247
+ {task.note.title}
248
+ </Link>
249
+ </td>
250
+
251
+ {/* Updated */}
252
+ <td className="px-4 py-2">
253
+ <span className="text-xs text-zinc-500">
254
+ {formatRelativeTime(task.updatedAt)}
255
+ </span>
256
+ </td>
257
+ </tr>
258
+ );
259
+ }
@@ -0,0 +1,3 @@
1
+ export function Skeleton({ className = "" }: { className?: string }) {
2
+ return <div className={`animate-pulse rounded bg-zinc-800/70 ${className}`} />;
3
+ }
@@ -0,0 +1,42 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect } from "vitest";
3
+ import { render, screen, waitFor } from "@testing-library/react";
4
+ import userEvent from "@testing-library/user-event";
5
+ import { ToastProvider, useToast } from "./toast";
6
+
7
+ function Trigger() {
8
+ const toast = useToast();
9
+ return <button onClick={() => toast("Saved!", "success")}>fire</button>;
10
+ }
11
+
12
+ describe("ToastProvider", () => {
13
+ it("shows a toast when triggered, then dismisses it", async () => {
14
+ const user = userEvent.setup();
15
+ render(
16
+ <ToastProvider>
17
+ <Trigger />
18
+ </ToastProvider>
19
+ );
20
+
21
+ // Nothing shown initially.
22
+ expect(screen.queryByText("Saved!")).toBeNull();
23
+
24
+ // Firing a toast renders its message.
25
+ await user.click(screen.getByText("fire"));
26
+ expect(await screen.findByText("Saved!")).toBeInTheDocument();
27
+
28
+ // Clicking dismiss removes it.
29
+ await user.click(screen.getByLabelText("Dismiss"));
30
+ await waitFor(() => expect(screen.queryByText("Saved!")).toBeNull());
31
+ });
32
+
33
+ it("is a no-op outside a provider (default context)", () => {
34
+ // useToast falls back to a noop so consumers never crash if unwrapped.
35
+ function Lone() {
36
+ const toast = useToast();
37
+ return <button onClick={() => toast("x")}>ok</button>;
38
+ }
39
+ render(<Lone />);
40
+ expect(screen.getByText("ok")).toBeInTheDocument();
41
+ });
42
+ });
@@ -0,0 +1,70 @@
1
+ "use client";
2
+
3
+ import {
4
+ createContext,
5
+ useCallback,
6
+ useContext,
7
+ useState,
8
+ } from "react";
9
+ import { Check, AlertTriangle, Info, X } from "lucide-react";
10
+
11
+ type Variant = "success" | "error" | "info";
12
+ type Toast = { id: number; message: string; variant: Variant };
13
+
14
+ const ToastCtx = createContext<(message: string, variant?: Variant) => void>(
15
+ () => {}
16
+ );
17
+
18
+ export function useToast() {
19
+ return useContext(ToastCtx);
20
+ }
21
+
22
+ const VARIANT: Record<Variant, { ring: string; icon: React.ReactNode }> = {
23
+ success: { ring: "border-emerald-700/60", icon: <Check size={15} className="text-emerald-400" /> },
24
+ error: { ring: "border-red-700/60", icon: <AlertTriangle size={15} className="text-red-400" /> },
25
+ info: { ring: "border-zinc-700", icon: <Info size={15} className="text-zinc-400" /> },
26
+ };
27
+
28
+ export function ToastProvider({ children }: { children: React.ReactNode }) {
29
+ const [toasts, setToasts] = useState<Toast[]>([]);
30
+
31
+ const toast = useCallback((message: string, variant: Variant = "info") => {
32
+ const id = Date.now() + Math.random();
33
+ setToasts((t) => [...t, { id, message, variant }]);
34
+ setTimeout(() => {
35
+ setToasts((t) => t.filter((x) => x.id !== id));
36
+ }, 4000);
37
+ }, []);
38
+
39
+ const dismiss = (id: number) => setToasts((t) => t.filter((x) => x.id !== id));
40
+
41
+ return (
42
+ <ToastCtx.Provider value={toast}>
43
+ {children}
44
+ {/* Sits above the mobile bottom-nav (bottom-20) and bottom-right on desktop. */}
45
+ <div
46
+ className="pointer-events-none fixed inset-x-0 bottom-20 z-[200] flex flex-col items-center gap-2 px-4 md:inset-x-auto md:right-4 md:bottom-4 md:items-end"
47
+ role="region"
48
+ aria-live="polite"
49
+ aria-label="Notifications"
50
+ >
51
+ {toasts.map((t) => (
52
+ <div
53
+ key={t.id}
54
+ className={`pointer-events-auto flex w-full max-w-sm items-center gap-2.5 rounded-lg border bg-zinc-900 px-3 py-2.5 shadow-xl ${VARIANT[t.variant].ring}`}
55
+ >
56
+ <span className="shrink-0">{VARIANT[t.variant].icon}</span>
57
+ <span className="flex-1 text-sm text-zinc-200">{t.message}</span>
58
+ <button
59
+ onClick={() => dismiss(t.id)}
60
+ aria-label="Dismiss"
61
+ className="shrink-0 text-zinc-600 transition-colors hover:text-zinc-300"
62
+ >
63
+ <X size={14} />
64
+ </button>
65
+ </div>
66
+ ))}
67
+ </div>
68
+ </ToastCtx.Provider>
69
+ );
70
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Next.js instrumentation hook — runs once when the server starts,
3
+ * before any request handlers.
4
+ *
5
+ * Local mode (IS_CLOUD=false):
6
+ * 1. Initialise PGlite (async dynamic import + file open)
7
+ * 2. Apply any pending SQL migrations to the local DB
8
+ *
9
+ * Cloud mode (IS_CLOUD=true):
10
+ * No-op. Prisma clients are synchronously initialised at module load,
11
+ * and migrations are applied to Neon by the deploy pipeline.
12
+ */
13
+ export async function register() {
14
+ if (process.env.NEXT_RUNTIME !== "nodejs") return;
15
+ if (process.env.IS_CLOUD === "true") return;
16
+
17
+ const { initLocalPrisma } = await import("./lib/prisma");
18
+ await initLocalPrisma();
19
+
20
+ const g = global as never as Record<string, unknown>;
21
+ const { runLocalMigrations } = await import("./lib/db-init");
22
+ await runLocalMigrations(g.briefPglite as Parameters<typeof runLocalMigrations>[0]);
23
+ }
@@ -0,0 +1,50 @@
1
+ import { NextResponse } from "next/server";
2
+ import type { z } from "zod";
3
+
4
+ /**
5
+ * Standard JSON error shape: `{ error, code? }`. Use across all route handlers
6
+ * so clients can rely on a single contract.
7
+ */
8
+ export function apiError(message: string, status = 400, code?: string) {
9
+ return NextResponse.json(
10
+ code ? { error: message, code } : { error: message },
11
+ { status }
12
+ );
13
+ }
14
+
15
+ export const unauthorized = () => apiError("Unauthorized", 401);
16
+ export const noWorkspace = () => apiError("No workspace", 404);
17
+ export const notFound = (message = "Not found") => apiError(message, 404);
18
+
19
+ type ParseOk<T> = { data: T; response?: undefined };
20
+ type ParseErr = { data?: undefined; response: NextResponse };
21
+
22
+ /**
23
+ * Parse + validate a JSON request body against a zod schema.
24
+ * On failure returns `{ response }` (a ready-to-return 400) so handlers can do:
25
+ *
26
+ * const parsed = await parseJson(req, schema);
27
+ * if (parsed.response) return parsed.response;
28
+ * const { ... } = parsed.data;
29
+ */
30
+ export async function parseJson<T extends z.ZodTypeAny>(
31
+ req: Request,
32
+ schema: T
33
+ ): Promise<ParseOk<z.infer<T>> | ParseErr> {
34
+ let raw: unknown;
35
+ try {
36
+ raw = await req.json();
37
+ } catch {
38
+ return { response: apiError("Invalid JSON body", 400, "INVALID_JSON") };
39
+ }
40
+ const result = schema.safeParse(raw);
41
+ if (!result.success) {
42
+ const first = result.error.issues[0];
43
+ const path = first?.path.join(".");
44
+ const message = first
45
+ ? `${path ? path + ": " : ""}${first.message}`
46
+ : "Invalid request";
47
+ return { response: apiError(message, 400, "VALIDATION") };
48
+ }
49
+ return { data: result.data };
50
+ }
@@ -0,0 +1,32 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { shouldRunScheduledBackup } from "@/lib/backup/backup-runner";
3
+
4
+ describe("shouldRunScheduledBackup", () => {
5
+ it("runs only when the schedule is enabled and due", () => {
6
+ expect(
7
+ shouldRunScheduledBackup({
8
+ isElectron: true,
9
+ isCloudWorkspace: false,
10
+ scheduleEnabled: true,
11
+ destinationPath: "C:/Backups",
12
+ cadence: "DAILY",
13
+ lastBackupAt: new Date("2026-06-10T08:00:00.000Z"),
14
+ now: new Date("2026-06-11T08:01:00.000Z"),
15
+ })
16
+ ).toBe(true);
17
+ });
18
+
19
+ it("skips cloud workspaces", () => {
20
+ expect(
21
+ shouldRunScheduledBackup({
22
+ isElectron: true,
23
+ isCloudWorkspace: true,
24
+ scheduleEnabled: true,
25
+ destinationPath: "C:/Backups",
26
+ cadence: "DAILY",
27
+ lastBackupAt: null,
28
+ now: new Date("2026-06-13T00:00:00.000Z"),
29
+ })
30
+ ).toBe(false);
31
+ });
32
+ });
@@ -0,0 +1,19 @@
1
+ import { isBackupDue } from "@/lib/backup/backup-schedule";
2
+ import type { BackupCadence } from "@/lib/backup/types";
3
+
4
+ export function shouldRunScheduledBackup(input: {
5
+ isElectron: boolean;
6
+ isCloudWorkspace: boolean;
7
+ scheduleEnabled: boolean;
8
+ destinationPath: string | null;
9
+ cadence: BackupCadence;
10
+ lastBackupAt: Date | null;
11
+ now?: Date;
12
+ }) {
13
+ if (!input.isElectron) return false;
14
+ if (input.isCloudWorkspace) return false;
15
+ if (!input.scheduleEnabled) return false;
16
+ if (!input.destinationPath) return false;
17
+
18
+ return isBackupDue(input.cadence, input.lastBackupAt, input.now ?? new Date());
19
+ }
@@ -0,0 +1,23 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { getNextBackupDueAt, isBackupDue } from "@/lib/backup/backup-schedule";
3
+
4
+ describe("backup schedule cadence", () => {
5
+ it("marks a daily backup due after 24 hours", () => {
6
+ const lastBackupAt = new Date("2026-06-10T08:00:00.000Z");
7
+ const now = new Date("2026-06-11T08:01:00.000Z");
8
+
9
+ expect(isBackupDue("DAILY", lastBackupAt, now)).toBe(true);
10
+ });
11
+
12
+ it("returns the next monthly due date from the last success time", () => {
13
+ const lastBackupAt = new Date("2026-01-31T09:00:00.000Z");
14
+
15
+ expect(getNextBackupDueAt("MONTHLY", lastBackupAt)?.toISOString()).toBe(
16
+ "2026-02-28T09:00:00.000Z"
17
+ );
18
+ });
19
+
20
+ it("treats a missing last backup as immediately due", () => {
21
+ expect(isBackupDue("WEEKLY", null, new Date("2026-06-13T00:00:00.000Z"))).toBe(true);
22
+ });
23
+ });
@@ -0,0 +1,55 @@
1
+ import type { BackupCadence } from "@/lib/backup/types";
2
+
3
+ function getDaysInMonth(year: number, monthIndex: number) {
4
+ return new Date(Date.UTC(year, monthIndex + 1, 0)).getUTCDate();
5
+ }
6
+
7
+ function addMonthsClamped(date: Date, monthsToAdd: number) {
8
+ const year = date.getUTCFullYear();
9
+ const monthIndex = date.getUTCMonth();
10
+ const day = date.getUTCDate();
11
+ const nextMonthIndex = monthIndex + monthsToAdd;
12
+ const targetYear = year + Math.floor(nextMonthIndex / 12);
13
+ const normalizedMonthIndex = ((nextMonthIndex % 12) + 12) % 12;
14
+ const targetDay = Math.min(day, getDaysInMonth(targetYear, normalizedMonthIndex));
15
+
16
+ return new Date(
17
+ Date.UTC(
18
+ targetYear,
19
+ normalizedMonthIndex,
20
+ targetDay,
21
+ date.getUTCHours(),
22
+ date.getUTCMinutes(),
23
+ date.getUTCSeconds(),
24
+ date.getUTCMilliseconds()
25
+ )
26
+ );
27
+ }
28
+
29
+ export function getNextBackupDueAt(
30
+ cadence: BackupCadence,
31
+ lastBackupAt: Date | null
32
+ ): Date | null {
33
+ if (!lastBackupAt) return null;
34
+
35
+ if (cadence === "DAILY") {
36
+ return new Date(lastBackupAt.getTime() + 24 * 60 * 60 * 1000);
37
+ }
38
+
39
+ if (cadence === "WEEKLY") {
40
+ return new Date(lastBackupAt.getTime() + 7 * 24 * 60 * 60 * 1000);
41
+ }
42
+
43
+ return addMonthsClamped(lastBackupAt, 1);
44
+ }
45
+
46
+ export function isBackupDue(
47
+ cadence: BackupCadence,
48
+ lastBackupAt: Date | null,
49
+ now = new Date()
50
+ ): boolean {
51
+ if (!lastBackupAt) return true;
52
+
53
+ const nextDueAt = getNextBackupDueAt(cadence, lastBackupAt);
54
+ return nextDueAt !== null && now >= nextDueAt;
55
+ }