@knotpad/app 0.1.4 → 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,150 @@
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import { CalendarDays } from "lucide-react";
5
+ import { useKanbanStatuses } from "@/components/kanban/kanban-status-context";
6
+ import type { CalTask, HolidayEntry, BusyBlockEntry } from "./calendar-types";
7
+ import { getStatusDot, feedBg, feedText, countryFlag } from "./calendar-types";
8
+
9
+ export function CalendarAgenda({
10
+ agendaDayKeys,
11
+ agendaByDay,
12
+ today,
13
+ holidaysEnabled,
14
+ holidays,
15
+ holidayCountry = "MY",
16
+ busyEnabled,
17
+ busyBlocks,
18
+ unscheduled,
19
+ kanbanStatuses: _kanbanStatuses,
20
+ isOverdue,
21
+ taskHref,
22
+ busyLimit,
23
+ onShowDayDetail,
24
+ }: {
25
+ agendaDayKeys: string[];
26
+ agendaByDay: Record<string, CalTask[]>;
27
+ today: string;
28
+ holidaysEnabled: boolean;
29
+ holidays: Record<string, HolidayEntry[]>;
30
+ holidayCountry?: string;
31
+ busyEnabled: boolean;
32
+ busyBlocks: Record<string, BusyBlockEntry[]>;
33
+ unscheduled: CalTask[];
34
+ kanbanStatuses: { key: string; color: string }[];
35
+ isOverdue: (task: CalTask) => boolean;
36
+ taskHref: (taskId: string) => string;
37
+ busyLimit: number;
38
+ onShowDayDetail: (dateKey: string, kind: "busy" | "tasks" | "holidays", anchorRect: DOMRect | null) => void;
39
+ }) {
40
+ const kanbanStatuses = useKanbanStatuses();
41
+
42
+ return (
43
+ <div className="flex flex-1 flex-col overflow-y-auto md:hidden">
44
+ {/* Unscheduled tasks (mobile) - moved to top for visibility */}
45
+ {unscheduled.length > 0 && (
46
+ <div className="border-b border-zinc-800 px-4 py-3">
47
+ <div className="mb-2 flex items-center gap-2">
48
+ <CalendarDays size={13} className="text-zinc-600" />
49
+ <span className="text-xs font-medium text-zinc-500">No due date ({unscheduled.length})</span>
50
+ </div>
51
+ <div className="flex flex-col">
52
+ {unscheduled.map((task) => (
53
+ <Link
54
+ key={task.id}
55
+ href={taskHref(task.id)}
56
+ className="flex items-center gap-2.5 py-2 transition-colors active:bg-zinc-800/60"
57
+ >
58
+ <span className={`h-2 w-2 shrink-0 rounded-full ${getStatusDot(kanbanStatuses, task.status)}`} />
59
+ <span className="flex-1 truncate text-sm text-zinc-300">{task.title}</span>
60
+ <span className="shrink-0 truncate text-xs text-zinc-600 max-w-[90px]">{task.noteTitle}</span>
61
+ </Link>
62
+ ))}
63
+ </div>
64
+ </div>
65
+ )}
66
+
67
+ {agendaDayKeys.length === 0 && unscheduled.length === 0 ? (
68
+ <div className="flex flex-1 flex-col items-center justify-center gap-2 p-10 text-center">
69
+ <CalendarDays size={20} className="text-zinc-700" />
70
+ <p className="text-sm text-zinc-500">Nothing scheduled this month.</p>
71
+ <p className="text-xs text-zinc-700">Set a due date on a task to see it here.</p>
72
+ </div>
73
+ ) : (
74
+ agendaDayKeys.map((key) => {
75
+ const d = new Date(key + "T00:00:00");
76
+ const isToday = key === today;
77
+ const dayTasks = agendaByDay[key] ?? [];
78
+ const dayHolidays = holidaysEnabled ? holidays[key] ?? [] : [];
79
+ return (
80
+ <div key={key} className="border-b border-zinc-800/60">
81
+ <div className="sticky top-0 z-10 flex items-baseline gap-2 bg-zinc-950/95 px-4 py-2 backdrop-blur">
82
+ <span className={`text-sm font-semibold ${isToday ? "text-blue-400" : "text-zinc-300"}`}>
83
+ {d.getDate()}
84
+ </span>
85
+ <span className="text-xs text-zinc-500">
86
+ {d.toLocaleDateString(undefined, { weekday: "long" })}
87
+ </span>
88
+ {isToday && (
89
+ <span className="rounded-full bg-blue-500/20 px-2 py-0.5 text-[10px] font-medium text-blue-300">
90
+ Today
91
+ </span>
92
+ )}
93
+ </div>
94
+ <div className="flex flex-col pb-1">
95
+ {dayHolidays.map((h) => (
96
+ <div
97
+ key={h.name}
98
+ className="mx-3 mb-1 flex items-center gap-1.5 rounded border border-amber-800/40 bg-amber-950/40 px-2.5 py-1.5 text-xs text-amber-300"
99
+ >
100
+ <span className="shrink-0">{countryFlag(holidayCountry)}</span>
101
+ <span className="truncate">{h.name}{h.is_subject_to_change ? " *" : ""}</span>
102
+ </div>
103
+ ))}
104
+ {busyEnabled && (busyBlocks[key] ?? []).slice(0, busyLimit).map((b, i) => (
105
+ <div
106
+ key={`${b.title}-${i}`}
107
+ className={`mx-3 mb-1 flex items-center gap-1.5 rounded border px-2.5 py-1.5 text-xs ${feedBg(b.feedColor)} ${feedText(b.feedColor)}`}
108
+ >
109
+ <span className="shrink-0 truncate">
110
+ {b.allDay ? b.title : `${new Date(b.start).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })} ${b.title}`}
111
+ </span>
112
+ <span className="shrink-0 truncate opacity-60">· {b.feedLabel}</span>
113
+ </div>
114
+ ))}
115
+ {busyEnabled && (busyBlocks[key]?.length ?? 0) > busyLimit && (
116
+ <button
117
+ type="button"
118
+ onClick={() => onShowDayDetail(key, "busy", null)}
119
+ className="mx-3 mb-1 text-left text-xs text-zinc-500 hover:text-zinc-300 transition-colors"
120
+ >
121
+ +{(busyBlocks[key]?.length ?? 0) - busyLimit} more events
122
+ </button>
123
+ )}
124
+ {dayTasks.map((task) => {
125
+ const overdue = isOverdue(task);
126
+ return (
127
+ <Link
128
+ key={task.id}
129
+ href={taskHref(task.id)}
130
+ className="flex items-center gap-2.5 px-4 py-2.5 transition-colors active:bg-zinc-800/60"
131
+ >
132
+ <span
133
+ className={`h-2 w-2 shrink-0 rounded-full ${overdue ? "bg-red-400" : getStatusDot(kanbanStatuses, task.status)}`}
134
+ />
135
+ <span className={`flex-1 truncate text-sm ${overdue ? "text-red-300" : "text-zinc-200"}`}>
136
+ {task.title}
137
+ </span>
138
+ {overdue && <span className="shrink-0 text-xs font-medium text-red-400">Overdue</span>}
139
+ <span className="shrink-0 truncate text-xs text-zinc-600 max-w-[90px]">{task.noteTitle}</span>
140
+ </Link>
141
+ );
142
+ })}
143
+ </div>
144
+ </div>
145
+ );
146
+ })
147
+ )}
148
+ </div>
149
+ );
150
+ }
@@ -0,0 +1,177 @@
1
+ // @vitest-environment jsdom
2
+ import { cleanup, render, screen, fireEvent, waitFor } from "@testing-library/react";
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+ import { CalendarView } from "@/components/calendar/calendar-view";
5
+
6
+ const pushMock = vi.fn();
7
+ const refreshMock = vi.fn();
8
+
9
+ vi.mock("next/navigation", () => ({
10
+ useRouter: () => ({ push: pushMock, refresh: refreshMock }),
11
+ }));
12
+
13
+ vi.mock("@/components/kanban/kanban-status-context", () => ({
14
+ useKanbanStatuses: () => [{ key: "OPEN", color: "border-sky-700" }],
15
+ }));
16
+
17
+ // Use the REAL useCalendarDrag, mock only the data-fetching hooks.
18
+ vi.mock("@/components/calendar/calendar-hooks", async (importActual) => {
19
+ const actual = await importActual<typeof import("@/components/calendar/calendar-hooks")>();
20
+ return {
21
+ ...actual,
22
+ useHolidays: () => ({
23
+ holidayModuleEnabled: false,
24
+ holidaysVisible: false,
25
+ holidayCountry: "MY",
26
+ holidays: {},
27
+ holidaysLoading: false,
28
+ holidayError: false,
29
+ toggleHolidayVisibility: vi.fn(),
30
+ }),
31
+ useCalendarFeeds: () => ({
32
+ feeds: [],
33
+ visibleFeedIds: new Set<string>(),
34
+ busyEnabled: false,
35
+ busyLoading: false,
36
+ busyDropdownOpen: false,
37
+ setBusyDropdownOpen: vi.fn(),
38
+ syncingFeedId: null,
39
+ syncingAll: false,
40
+ toggleFeedVisibility: vi.fn(),
41
+ syncFeed: vi.fn(),
42
+ syncAllFeeds: vi.fn(),
43
+ toggleBusyBlocks: vi.fn(),
44
+ busyBlocks: {},
45
+ }),
46
+ useBusyLimit: () => ({ busyLimit: 2, setBusyLimit: vi.fn() }),
47
+ };
48
+ });
49
+
50
+ let fetchMock: ReturnType<typeof vi.fn>;
51
+
52
+ beforeEach(() => {
53
+ pushMock.mockReset();
54
+ refreshMock.mockReset();
55
+ fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) });
56
+ vi.stubGlobal("fetch", fetchMock);
57
+ });
58
+
59
+ afterEach(() => {
60
+ window.localStorage.clear();
61
+ vi.unstubAllGlobals();
62
+ cleanup();
63
+ });
64
+
65
+ function makeDataTransfer() {
66
+ return { setData: vi.fn(), getData: vi.fn(), effectAllowed: "", dropEffect: "" };
67
+ }
68
+
69
+ const rangedTask = {
70
+ id: "t-range",
71
+ title: "Ranged task",
72
+ status: "OPEN",
73
+ startDate: "2026-06-12",
74
+ dueDate: "2026-06-14",
75
+ noteId: "n1",
76
+ noteTitle: "Launch",
77
+ assigneeName: null,
78
+ assigneeType: "HUMAN",
79
+ claimedByAlias: null,
80
+ };
81
+
82
+ describe("CalendarView edge drag", () => {
83
+ it("dragging the END handle and dropping on a day updates dueDate", async () => {
84
+ render(<CalendarView initialMonth="2026-06" tasks={[rangedTask]} />);
85
+
86
+ const endHandle = screen.getByLabelText("Resize end for Ranged task");
87
+ const dt = makeDataTransfer();
88
+
89
+ fireEvent.dragStart(endHandle, { dataTransfer: dt });
90
+
91
+ // Drop on the cell for June 20.
92
+ const targetCell = screen.getByText("20");
93
+ fireEvent.dragOver(targetCell, { dataTransfer: dt });
94
+ fireEvent.drop(targetCell, { dataTransfer: dt });
95
+
96
+ await waitFor(() => {
97
+ expect(fetchMock).toHaveBeenCalledWith(
98
+ "/api/tasks/t-range",
99
+ expect.objectContaining({
100
+ method: "PATCH",
101
+ body: JSON.stringify({ startDate: "2026-06-12", dueDate: "2026-06-20" }),
102
+ })
103
+ );
104
+ });
105
+ });
106
+
107
+ it("dragging the START handle and dropping on a day updates startDate", async () => {
108
+ render(<CalendarView initialMonth="2026-06" tasks={[rangedTask]} />);
109
+
110
+ const startHandle = screen.getByLabelText("Resize start for Ranged task");
111
+ const dt = makeDataTransfer();
112
+
113
+ fireEvent.dragStart(startHandle, { dataTransfer: dt });
114
+
115
+ const targetCell = screen.getByText("10");
116
+ fireEvent.dragOver(targetCell, { dataTransfer: dt });
117
+ fireEvent.drop(targetCell, { dataTransfer: dt });
118
+
119
+ await waitFor(() => {
120
+ expect(fetchMock).toHaveBeenCalledWith(
121
+ "/api/tasks/t-range",
122
+ expect.objectContaining({
123
+ method: "PATCH",
124
+ body: JSON.stringify({ startDate: "2026-06-10", dueDate: "2026-06-14" }),
125
+ })
126
+ );
127
+ });
128
+ });
129
+
130
+ it("resizing a single-day task by its END handle creates a start..due range", async () => {
131
+ // A task with only a due date renders as a point chip with resize handles.
132
+ const pointTask = { ...rangedTask, id: "t-point", title: "Point task", startDate: null, dueDate: "2026-06-14" };
133
+ render(<CalendarView initialMonth="2026-06" tasks={[pointTask]} />);
134
+
135
+ const endHandle = screen.getByLabelText("Resize end for Point task");
136
+ const dt = makeDataTransfer();
137
+
138
+ fireEvent.dragStart(endHandle, { dataTransfer: dt });
139
+ const targetCell = screen.getByText("16");
140
+ fireEvent.dragOver(targetCell, { dataTransfer: dt });
141
+ fireEvent.drop(targetCell, { dataTransfer: dt });
142
+
143
+ // Anchors the existing day (14) as start and the dropped day (16) as due.
144
+ await waitFor(() => {
145
+ expect(fetchMock).toHaveBeenCalledWith(
146
+ "/api/tasks/t-point",
147
+ expect.objectContaining({
148
+ method: "PATCH",
149
+ body: JSON.stringify({ startDate: "2026-06-14", dueDate: "2026-06-16" }),
150
+ })
151
+ );
152
+ });
153
+ });
154
+
155
+ it("resizing a single-day task by its START handle anchors the due date", async () => {
156
+ const pointTask = { ...rangedTask, id: "t-point2", title: "Point two", startDate: null, dueDate: "2026-06-14" };
157
+ render(<CalendarView initialMonth="2026-06" tasks={[pointTask]} />);
158
+
159
+ const startHandle = screen.getByLabelText("Resize start for Point two");
160
+ const dt = makeDataTransfer();
161
+
162
+ fireEvent.dragStart(startHandle, { dataTransfer: dt });
163
+ const targetCell = screen.getByText("11");
164
+ fireEvent.dragOver(targetCell, { dataTransfer: dt });
165
+ fireEvent.drop(targetCell, { dataTransfer: dt });
166
+
167
+ await waitFor(() => {
168
+ expect(fetchMock).toHaveBeenCalledWith(
169
+ "/api/tasks/t-point2",
170
+ expect.objectContaining({
171
+ method: "PATCH",
172
+ body: JSON.stringify({ startDate: "2026-06-11", dueDate: "2026-06-14" }),
173
+ })
174
+ );
175
+ });
176
+ });
177
+ });
@@ -0,0 +1,357 @@
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import { CalendarDays } from "lucide-react";
5
+ import { useKanbanStatuses } from "@/components/kanban/kanban-status-context";
6
+ import type { CalTask, HolidayEntry, BusyBlockEntry } from "./calendar-types";
7
+ import { getStatusColor, feedBg, feedText, effectiveDates, countryFlag } from "./calendar-types";
8
+ import { TaskChip } from "./task-chip";
9
+
10
+ export function CalendarGrid({
11
+ weeks,
12
+ today,
13
+ scheduled,
14
+ unscheduled,
15
+ pointByDate,
16
+ optimistic,
17
+ holidays,
18
+ holidayCountry = "MY",
19
+ orderedDays = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"],
20
+ busyEnabled,
21
+ busyBlocks,
22
+ saving,
23
+ dragOverDate,
24
+ isOverdue,
25
+ taskHref,
26
+ handleDragStart,
27
+ handleDragOver,
28
+ handleDrop,
29
+ handleDragEnd,
30
+ pointTaskEdge,
31
+ reschedule,
32
+ dragTaskId,
33
+ busyLimit,
34
+ onShowDayDetail,
35
+ }: {
36
+ weeks: (string | null)[][];
37
+ today: string;
38
+ scheduled: CalTask[];
39
+ unscheduled: CalTask[];
40
+ pointByDate: Record<string, CalTask[]>;
41
+ optimistic: Record<string, { startDate?: string | null; dueDate?: string | null }>;
42
+ holidays: Record<string, HolidayEntry[]>;
43
+ holidayCountry?: string;
44
+ orderedDays?: string[];
45
+ busyEnabled: boolean;
46
+ busyBlocks: Record<string, BusyBlockEntry[]>;
47
+ saving: string | null;
48
+ dragOverDate: string | null;
49
+ isOverdue: (task: CalTask) => boolean;
50
+ taskHref: (taskId: string) => string;
51
+ handleDragStart: (e: React.DragEvent, taskId: string, edge: "start" | "end" | "both") => void;
52
+ handleDragOver: (e: React.DragEvent, dateKey: string | null) => void;
53
+ handleDrop: (e: React.DragEvent, dateKey: string | null) => void;
54
+ handleDragEnd: () => void;
55
+ pointTaskEdge: (task: CalTask) => "start" | "end" | "both";
56
+ reschedule: (taskId: string, edge: "start" | "end" | "both", newDate: string | null) => void;
57
+ dragTaskId: string | null;
58
+ busyLimit: number;
59
+ onShowDayDetail: (dateKey: string, kind: "busy" | "tasks" | "holidays", anchorRect: DOMRect | null) => void;
60
+ }) {
61
+ const kanbanStatuses = useKanbanStatuses();
62
+
63
+ return (
64
+ <div className="hidden flex-1 flex-col overflow-y-auto md:flex">
65
+ {/* Day-of-week headers */}
66
+ <div className="grid grid-cols-7 border-b border-zinc-800">
67
+ {orderedDays.map((d) => (
68
+ <div key={d} className="py-2 text-center text-[11px] font-medium text-zinc-600">{d}</div>
69
+ ))}
70
+ </div>
71
+
72
+ {/* Calendar weeks */}
73
+ {weeks.map((week, wi) => {
74
+ const spans = scheduled
75
+ .map((task) => {
76
+ const { start, end } = effectiveDates(task, optimistic);
77
+ if (!start || !end) return null;
78
+ if (start === end) return null;
79
+ const weekDays = week.filter(Boolean) as string[];
80
+ if (!weekDays.length) return null;
81
+ const weekStart = weekDays[0];
82
+ const weekEnd = weekDays[weekDays.length - 1];
83
+ if (end < weekStart || start > weekEnd) return null;
84
+
85
+ const clippedStart = start < weekStart ? weekStart : start;
86
+ const clippedEnd = end > weekEnd ? weekEnd : end;
87
+
88
+ const colStart = week.indexOf(clippedStart);
89
+ const colEnd = week.indexOf(clippedEnd);
90
+ if (colStart === -1 || colEnd === -1) return null;
91
+
92
+ return {
93
+ task,
94
+ colStart,
95
+ colEnd,
96
+ isRealStart: clippedStart === start,
97
+ isRealEnd: clippedEnd === end,
98
+ };
99
+ })
100
+ .filter(Boolean) as {
101
+ task: CalTask;
102
+ colStart: number;
103
+ colEnd: number;
104
+ isRealStart: boolean;
105
+ isRealEnd: boolean;
106
+ }[];
107
+
108
+ type SpanWithRow = (typeof spans)[0] & { row: number };
109
+ const spansWithRow: SpanWithRow[] = [];
110
+ for (const s of spans) {
111
+ const rowBlocked: boolean[] = [];
112
+ for (const prev of spansWithRow) {
113
+ if (prev.colStart <= s.colEnd && prev.colEnd >= s.colStart) {
114
+ rowBlocked[prev.row] = true;
115
+ }
116
+ }
117
+ const row = rowBlocked.findIndex((v) => !v);
118
+ spansWithRow.push({ ...s, row: row === -1 ? rowBlocked.length : row });
119
+ }
120
+ const maxRow = spansWithRow.reduce((m, s) => Math.max(m, s.row), -1);
121
+ const barsHeight = (maxRow + 1) * 24;
122
+
123
+ return (
124
+ <div key={wi} className="relative border-b border-zinc-800/40">
125
+ {spansWithRow.length > 0 && (
126
+ <div
127
+ className="absolute inset-x-0 top-0 z-10 grid grid-cols-7"
128
+ style={{ height: `${barsHeight}px`, gridTemplateRows: `repeat(${maxRow + 1}, 24px)` }}
129
+ onDragOver={(e) => {
130
+ const rect = e.currentTarget.getBoundingClientRect();
131
+ const colIndex = Math.min(6, Math.max(0, Math.floor((e.clientX - rect.left) / (rect.width / 7))));
132
+ const dateKey = week[colIndex];
133
+ if (dateKey) handleDragOver(e, dateKey);
134
+ else e.preventDefault();
135
+ }}
136
+ onDrop={(e) => {
137
+ const rect = e.currentTarget.getBoundingClientRect();
138
+ const colIndex = Math.min(6, Math.max(0, Math.floor((e.clientX - rect.left) / (rect.width / 7))));
139
+ const dateKey = week[colIndex];
140
+ // Dropping on a day outside this month (null cell) should
141
+ // cancel rather than clear the task's dates.
142
+ if (dateKey) handleDrop(e, dateKey);
143
+ else handleDragEnd();
144
+ }}
145
+ >
146
+ {spansWithRow.map(({ task, colStart, colEnd, isRealStart, isRealEnd, row }) => {
147
+ const color = getStatusColor(kanbanStatuses, task.status);
148
+ const span = colEnd - colStart + 1;
149
+ return (
150
+ <div
151
+ key={task.id}
152
+ style={{
153
+ gridColumn: `${colStart + 1} / span ${span}`,
154
+ gridRow: row + 1,
155
+ }}
156
+ className={`mx-0.5 my-0.5 flex items-center overflow-hidden rounded border text-[10px] text-white ${
157
+ isOverdue(task) ? "border-red-700/70 bg-red-800" : "border-black/20"
158
+ } ${isOverdue(task) ? "" : color}`}
159
+ >
160
+ {isRealStart && (
161
+ <span
162
+ draggable
163
+ onDragStart={(e) => { e.stopPropagation(); handleDragStart(e, task.id, "start"); }}
164
+ onDragEnd={handleDragEnd}
165
+ aria-label={`Resize start for ${task.title}`}
166
+ className="flex w-5 min-w-[20px] shrink-0 cursor-ew-resize items-center justify-center self-stretch border-r border-white/15 bg-zinc-950/45 hover:bg-zinc-950/70"
167
+ title="Drag to change start date"
168
+ >
169
+ <span className="rounded-sm border border-white/25 bg-white/10 px-1 text-[8px] font-semibold leading-4 text-white/80">
170
+ &lt;
171
+ </span>
172
+ </span>
173
+ )}
174
+ <div
175
+ draggable
176
+ onDragStart={(e) => { e.stopPropagation(); handleDragStart(e, task.id, "both"); }}
177
+ onDragEnd={handleDragEnd}
178
+ className="flex min-w-0 flex-1 cursor-grab items-center gap-1 px-1 active:cursor-grabbing"
179
+ title={`${task.title} · ${task.noteTitle}`}
180
+ >
181
+ <Link
182
+ href={taskHref(task.id)}
183
+ draggable={false}
184
+ onClick={(e) => e.stopPropagation()}
185
+ className="min-w-0 flex-1 truncate"
186
+ >
187
+ {isRealStart ? task.title : task.title}
188
+ </Link>
189
+ </div>
190
+ {isRealEnd && (
191
+ <span
192
+ draggable
193
+ onDragStart={(e) => { e.stopPropagation(); handleDragStart(e, task.id, "end"); }}
194
+ onDragEnd={handleDragEnd}
195
+ aria-label={`Resize end for ${task.title}`}
196
+ className="flex w-5 min-w-[20px] shrink-0 cursor-ew-resize items-center justify-center self-stretch border-l border-white/15 bg-zinc-950/45 hover:bg-zinc-950/70"
197
+ title="Drag to change end date"
198
+ >
199
+ <span className="rounded-sm border border-white/25 bg-white/10 px-1 text-[8px] font-semibold leading-4 text-white/80">
200
+ &gt;
201
+ </span>
202
+ </span>
203
+ )}
204
+ </div>
205
+ );
206
+ })}
207
+ </div>
208
+ )}
209
+
210
+ <div className="grid grid-cols-7" style={{ gridAutoRows: "minmax(80px, 1fr)", paddingTop: `${barsHeight}px` }}>
211
+ {week.map((key, di) => {
212
+ const isToday = key === today;
213
+ const isDragOver = dragOverDate === key;
214
+ const isCurrentMonth = key !== null;
215
+ const dayTasks = key ? (pointByDate[key] ?? []) : [];
216
+
217
+ return (
218
+ <div
219
+ key={di}
220
+ onDragOver={(e) => isCurrentMonth && handleDragOver(e, key)}
221
+ onDrop={(e) => isCurrentMonth && handleDrop(e, key)}
222
+ className={`min-h-[80px] border-r border-zinc-800/60 p-1.5 transition-colors ${
223
+ !isCurrentMonth
224
+ ? "bg-zinc-900/20"
225
+ : isDragOver
226
+ ? "bg-zinc-700/20 ring-1 ring-inset ring-zinc-500/50"
227
+ : ""
228
+ }`}
229
+ >
230
+ {key && (
231
+ <>
232
+ <div
233
+ className={`mb-1 flex h-5 w-5 items-center justify-center rounded-full text-[11px] font-medium ${
234
+ isToday ? "bg-blue-500 text-white" : "text-zinc-600"
235
+ }`}
236
+ >
237
+ {new Date(key + "T00:00:00").getDate()}
238
+ </div>
239
+ {(holidays[key] ?? []).slice(0, 3).map((h) => (
240
+ <div
241
+ key={h.name}
242
+ title={h.name + (h.is_subject_to_change ? " · Date subject to change" : "")}
243
+ className="mb-0.5 flex items-center gap-1 rounded border border-amber-800/40 bg-amber-950/50 px-1 py-0.5 text-[10px] leading-tight text-amber-300"
244
+ >
245
+ <span className="shrink-0 text-[9px]">{countryFlag(holidayCountry)}</span>
246
+ <span className="truncate">{h.name}{h.is_subject_to_change ? " *" : ""}</span>
247
+ </div>
248
+ ))}
249
+ {(holidays[key] ?? []).length > 3 && (
250
+ <button
251
+ type="button"
252
+ onClick={(e) => onShowDayDetail(key, "holidays", e.currentTarget.getBoundingClientRect())}
253
+ className="block w-full text-left text-[10px] text-amber-500 pl-1 hover:text-amber-400 transition-colors mb-0.5"
254
+ >
255
+ +{(holidays[key] ?? []).length - 3} holidays
256
+ </button>
257
+ )}
258
+
259
+ {busyEnabled && (busyBlocks[key] ?? []).slice(0, busyLimit).map((b, i) => (
260
+ <div
261
+ key={`${b.title}-${i}`}
262
+ title={`${b.title} · ${b.feedLabel}`}
263
+ className={`mb-0.5 truncate rounded border px-1 py-0.5 text-[10px] leading-tight ${feedBg(b.feedColor)} ${feedText(b.feedColor)}`}
264
+ >
265
+ {b.allDay ? b.title : `${new Date(b.start).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })} ${b.title}`}
266
+ </div>
267
+ ))}
268
+ {busyEnabled && (busyBlocks[key]?.length ?? 0) > busyLimit && (
269
+ <button
270
+ type="button"
271
+ onClick={(e) => onShowDayDetail(key, "busy", e.currentTarget.getBoundingClientRect())}
272
+ className="block w-full text-left text-[10px] text-zinc-600 pl-1 hover:text-zinc-400 transition-colors"
273
+ >
274
+ +{(busyBlocks[key]?.length ?? 0) - busyLimit} busy
275
+ </button>
276
+ )}
277
+
278
+ <div className="space-y-0.5">
279
+ {dayTasks.slice(0, 3).map((task) => (
280
+ <TaskChip
281
+ key={task.id}
282
+ task={task}
283
+ href={taskHref(task.id)}
284
+ isSaving={saving === task.id}
285
+ isOverdue={isOverdue(task)}
286
+ resizable
287
+ onDragStart={(e) => handleDragStart(e, task.id, pointTaskEdge(task))}
288
+ onEdgeDragStart={(e, edge) => handleDragStart(e, task.id, edge)}
289
+ onDragEnd={handleDragEnd}
290
+ />
291
+ ))}
292
+ {dayTasks.length > 3 && (
293
+ <button
294
+ type="button"
295
+ onClick={(e) => onShowDayDetail(key, "tasks", e.currentTarget.getBoundingClientRect())}
296
+ className="block w-full text-left text-[10px] text-zinc-600 pl-1 hover:text-zinc-400 transition-colors"
297
+ >
298
+ +{dayTasks.length - 3} more
299
+ </button>
300
+ )}
301
+ </div>
302
+ </>
303
+ )}
304
+ </div>
305
+ );
306
+ })}
307
+ </div>
308
+ </div>
309
+ );
310
+ })}
311
+
312
+ {/* Unscheduled tasks */}
313
+ <div
314
+ className={`border-t border-zinc-800 px-4 transition-colors ${
315
+ dragOverDate === "__unscheduled__"
316
+ ? "bg-zinc-700/20 py-3 ring-1 ring-inset ring-zinc-500/60"
317
+ : unscheduled.length === 0
318
+ ? "bg-zinc-950/30 py-2"
319
+ : "py-3"
320
+ }`}
321
+ onDragOver={(e) => handleDragOver(e, "__unscheduled__")}
322
+ onDrop={(e) => {
323
+ e.preventDefault();
324
+ if (dragTaskId) reschedule(dragTaskId, "both", null);
325
+ }}
326
+ >
327
+ <div className="mb-2 flex items-center gap-2">
328
+ <CalendarDays size={13} className="text-zinc-600" />
329
+ <span className="text-xs font-medium text-zinc-500">
330
+ No due date ({unscheduled.length})
331
+ </span>
332
+ <span className="text-[10px] text-zinc-700">drag here to clear date</span>
333
+ </div>
334
+ {unscheduled.length > 0 ? (
335
+ <div className="flex flex-wrap gap-1.5">
336
+ {unscheduled.map((task) => (
337
+ <TaskChip
338
+ key={task.id}
339
+ task={task}
340
+ href={taskHref(task.id)}
341
+ isSaving={saving === task.id}
342
+ isOverdue={false}
343
+ onDragStart={(e) => handleDragStart(e, task.id, "both")}
344
+ onDragEnd={handleDragEnd}
345
+ compact
346
+ />
347
+ ))}
348
+ </div>
349
+ ) : (
350
+ <p className="text-[11px] text-zinc-600">
351
+ Drop a task here or use Unassign to clear its dates.
352
+ </p>
353
+ )}
354
+ </div>
355
+ </div>
356
+ );
357
+ }