@tuturuuu/ui 0.13.0 → 0.16.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 (96) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/biome.json +1 -1
  3. package/package.json +62 -62
  4. package/src/components/ui/custom/__tests__/settings-dialog-shell.test.tsx +12 -0
  5. package/src/components/ui/custom/__tests__/sidebar-context.test.tsx +93 -6
  6. package/src/components/ui/custom/loading-statistic-card.tsx +18 -0
  7. package/src/components/ui/custom/notification-popover-client.tsx +5 -1
  8. package/src/components/ui/custom/settings/keyboard-shortcuts-settings.test.tsx +34 -0
  9. package/src/components/ui/custom/settings/keyboard-shortcuts-settings.tsx +106 -0
  10. package/src/components/ui/custom/settings/sidebar-settings.tsx +18 -6
  11. package/src/components/ui/custom/settings/task-settings.tsx +55 -0
  12. package/src/components/ui/custom/settings-dialog-shell.tsx +18 -12
  13. package/src/components/ui/custom/sidebar-context.tsx +58 -3
  14. package/src/components/ui/custom/sidebar-remote-behavior-bridge.tsx +5 -3
  15. package/src/components/ui/custom/statistic-card.tsx +104 -0
  16. package/src/components/ui/custom/structure.test.tsx +29 -0
  17. package/src/components/ui/custom/structure.tsx +103 -88
  18. package/src/components/ui/finance/invoices/civil-month.ts +224 -0
  19. package/src/components/ui/finance/invoices/components/subscription-invoice-proposal.test.tsx +49 -0
  20. package/src/components/ui/finance/invoices/components/subscription-invoice-proposal.tsx +81 -0
  21. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +8 -2
  22. package/src/components/ui/finance/invoices/new-invoice-page.test.tsx +90 -2
  23. package/src/components/ui/finance/invoices/new-invoice-page.tsx +40 -4
  24. package/src/components/ui/finance/invoices/pending-columns.test.ts +26 -0
  25. package/src/components/ui/finance/invoices/pending-columns.tsx +45 -14
  26. package/src/components/ui/finance/invoices/standard-invoice.test.ts +31 -0
  27. package/src/components/ui/finance/invoices/standard-invoice.tsx +33 -16
  28. package/src/components/ui/finance/invoices/subscription-invoice.test.tsx +331 -0
  29. package/src/components/ui/finance/invoices/subscription-invoice.tsx +53 -28
  30. package/src/components/ui/finance/invoices/utils.test.ts +84 -0
  31. package/src/components/ui/finance/invoices/utils.ts +56 -134
  32. package/src/components/ui/resizable.test.ts +15 -0
  33. package/src/components/ui/resizable.tsx +28 -6
  34. package/src/components/ui/tu-do/boards/boardId/board-column.tsx +4 -0
  35. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/__tests__/bulk-mutations-move.test.tsx +62 -0
  36. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/auto-scroll.test.ts +118 -0
  37. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/auto-scroll.ts +144 -70
  38. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.test.ts +48 -0
  39. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.ts +50 -6
  40. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.tsx +6 -0
  41. package/src/components/ui/tu-do/boards/boardId/kanban.tsx +4 -2
  42. package/src/components/ui/tu-do/boards/boardId/menus/__tests__/task-menus.test.tsx +41 -3
  43. package/src/components/ui/tu-do/boards/boardId/menus/task-due-date-menu.tsx +31 -10
  44. package/src/components/ui/tu-do/boards/boardId/menus/task-priority-menu.tsx +7 -5
  45. package/src/components/ui/tu-do/boards/boardId/menus/task-scheduling-menu.tsx +7 -5
  46. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-archive-visibility.test.ts +44 -0
  47. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-archive-visibility.ts +17 -0
  48. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.test.ts +2 -0
  49. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.ts +7 -0
  50. package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +44 -30
  51. package/src/components/ui/tu-do/my-tasks/__tests__/use-task-context-actions.test.ts +54 -0
  52. package/src/components/ui/tu-do/my-tasks/task-list-with-completion.tsx +43 -1
  53. package/src/components/ui/tu-do/my-tasks/use-task-context-actions.ts +33 -15
  54. package/src/components/ui/tu-do/progress/task-progress-leaderboards-panel.tsx +3 -3
  55. package/src/components/ui/tu-do/progress/task-progress-page.tsx +74 -28
  56. package/src/components/ui/tu-do/progress/task-progress-panels.tsx +12 -10
  57. package/src/components/ui/tu-do/projects/components/project-grid-card.tsx +22 -6
  58. package/src/components/ui/tu-do/projects/components/project-list-item.tsx +24 -8
  59. package/src/components/ui/tu-do/projects/projectId/components/__tests__/tasks-tab-layout.test.tsx +6 -0
  60. package/src/components/ui/tu-do/projects/projectId/components/tasks-tab.tsx +3 -1
  61. package/src/components/ui/tu-do/projects/projectId/task-project-detail-page-client.tsx +11 -3
  62. package/src/components/ui/tu-do/projects/projectId/task-project-detail.tsx +2 -0
  63. package/src/components/ui/tu-do/projects/projectId/types.ts +1 -0
  64. package/src/components/ui/tu-do/projects/task-projects-client.tsx +45 -1
  65. package/src/components/ui/tu-do/projects/types.ts +4 -1
  66. package/src/components/ui/tu-do/providers/task-dialog-provider.tsx +10 -0
  67. package/src/components/ui/tu-do/shared/__tests__/board-client.test.tsx +7 -1
  68. package/src/components/ui/tu-do/shared/__tests__/board-header.test.tsx +87 -2
  69. package/src/components/ui/tu-do/shared/__tests__/board-views.test.tsx +70 -0
  70. package/src/components/ui/tu-do/shared/__tests__/task-board-loading-state.test.tsx +27 -3
  71. package/src/components/ui/tu-do/shared/__tests__/task-dialog-manager.test.tsx +41 -3
  72. package/src/components/ui/tu-do/shared/__tests__/use-progressive-board-loader.test.tsx +54 -0
  73. package/src/components/ui/tu-do/shared/board-header.tsx +20 -0
  74. package/src/components/ui/tu-do/shared/board-views.tsx +33 -3
  75. package/src/components/ui/tu-do/shared/task-board-loading-state.tsx +4 -4
  76. package/src/components/ui/tu-do/shared/task-dialog-manager.tsx +2 -0
  77. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-dialog-header.tsx +7 -0
  78. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/__tests__/use-task-mutations.test.tsx +231 -0
  79. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-mutations.ts +139 -68
  80. package/src/components/ui/tu-do/shared/task-edit-dialog/task-dialog-actions.test.tsx +68 -0
  81. package/src/components/ui/tu-do/shared/task-edit-dialog/task-dialog-actions.tsx +17 -0
  82. package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +45 -23
  83. package/src/components/ui/tu-do/shared/task-quick-create-target-list.ts +20 -0
  84. package/src/components/ui/tu-do/shared/task-row-actions-menu.test.tsx +138 -0
  85. package/src/components/ui/tu-do/shared/task-row-actions-menu.tsx +15 -9
  86. package/src/components/ui/tu-do/templates/client.tsx +97 -78
  87. package/src/components/ui/tu-do/templates/marketplace/client.tsx +171 -135
  88. package/src/components/ui/tu-do/templates/task-templates-hub.tsx +3 -0
  89. package/src/components/ui/tu-do/templates/templateId/client.tsx +24 -9
  90. package/src/components/ui/tu-do/templates/templateId/task-template-detail-page-client.tsx +8 -2
  91. package/src/hooks/__tests__/use-notifications-subscription.test.tsx +34 -0
  92. package/src/hooks/__tests__/use-task-actions.test.tsx +14 -39
  93. package/src/hooks/task-actions-personal-external.ts +12 -6
  94. package/src/hooks/use-notifications.ts +14 -2
  95. package/src/hooks/use-user-config.ts +10 -2
  96. package/vitest.config.ts +4 -0
@@ -42,11 +42,13 @@ type SortOption = 'title-asc' | 'title-desc' | 'created-desc' | 'created-asc';
42
42
  interface Props {
43
43
  wsId: string;
44
44
  templates: BoardTemplate[];
45
+ onOpenTemplate?: (template: BoardTemplate) => void;
45
46
  templatesBasePath?: string;
46
47
  }
47
48
 
48
49
  export default function MarketplaceClient({
49
50
  wsId,
51
+ onOpenTemplate,
50
52
  templates,
51
53
  templatesBasePath = 'templates',
52
54
  }: Props) {
@@ -189,6 +191,7 @@ export default function MarketplaceClient({
189
191
  <FeaturedTemplateCard
190
192
  key={template.id}
191
193
  template={template}
194
+ onOpenTemplate={onOpenTemplate}
192
195
  wsId={wsId}
193
196
  index={idx}
194
197
  templatesBasePath={templatesBasePath}
@@ -220,6 +223,7 @@ export default function MarketplaceClient({
220
223
  <MarketplaceTemplateCard
221
224
  key={template.id}
222
225
  template={template}
226
+ onOpenTemplate={onOpenTemplate}
223
227
  wsId={wsId}
224
228
  templatesBasePath={templatesBasePath}
225
229
  />
@@ -233,176 +237,208 @@ export default function MarketplaceClient({
233
237
 
234
238
  function FeaturedTemplateCard({
235
239
  template,
240
+ onOpenTemplate,
236
241
  wsId,
237
242
  index,
238
243
  templatesBasePath,
239
244
  }: {
240
245
  template: BoardTemplate;
246
+ onOpenTemplate?: (template: BoardTemplate) => void;
241
247
  wsId: string;
242
248
  index: number;
243
249
  templatesBasePath: string;
244
250
  }) {
245
- return (
246
- <Link href={`/${wsId}/${templatesBasePath}/${template.id}`}>
247
- <Card className="group relative h-full overflow-hidden border-border/50 bg-linear-to-br from-background via-background to-muted/30 transition-all hover:border-primary/50 hover:shadow-xl">
248
- <div className="aspect-video w-full overflow-hidden border-b bg-muted/50">
249
- {template.backgroundUrl ? (
250
- <Image
251
- src={template.backgroundUrl}
252
- alt={template.name}
253
- width={600}
254
- height={338}
255
- className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
256
- />
257
- ) : (
258
- <div className="flex h-full w-full items-center justify-center bg-linear-to-br from-dynamic-blue/5 to-dynamic-purple/5">
259
- <KanbanSquare className="h-12 w-12 text-muted-foreground/20" />
260
- </div>
261
- )}
251
+ const card = (
252
+ <Card className="group relative h-full overflow-hidden border-border/50 bg-linear-to-br from-background via-background to-muted/30 transition-all hover:border-primary/50 hover:shadow-xl">
253
+ <div className="aspect-video w-full overflow-hidden border-b bg-muted/50">
254
+ {template.backgroundUrl ? (
255
+ <Image
256
+ src={template.backgroundUrl}
257
+ alt={template.name}
258
+ width={600}
259
+ height={338}
260
+ className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
261
+ />
262
+ ) : (
263
+ <div className="flex h-full w-full items-center justify-center bg-linear-to-br from-dynamic-blue/5 to-dynamic-purple/5">
264
+ <KanbanSquare className="h-12 w-12 text-muted-foreground/20" />
265
+ </div>
266
+ )}
267
+ </div>
268
+
269
+ {/* Gradient overlay on hover */}
270
+ <div
271
+ className={cn(
272
+ 'pointer-events-none absolute inset-0 opacity-0 transition-opacity duration-300 group-hover:opacity-100',
273
+ index === 0
274
+ ? 'bg-linear-to-br from-dynamic-purple/5 via-transparent to-dynamic-blue/5'
275
+ : 'bg-linear-to-br from-dynamic-blue/5 via-transparent to-dynamic-green/5'
276
+ )}
277
+ />
278
+
279
+ <CardHeader className="relative space-y-4 pb-4">
280
+ <div className="flex items-center justify-between">
281
+ <Badge
282
+ variant="secondary"
283
+ className={cn(
284
+ 'bg-background/80 font-semibold backdrop-blur-sm',
285
+ index === 0 ? 'text-dynamic-purple' : 'text-dynamic-blue'
286
+ )}
287
+ >
288
+ ⭐ Featured
289
+ </Badge>
290
+ <span className="text-muted-foreground text-xs">
291
+ {formatDistanceToNow(new Date(template.createdAt), {
292
+ addSuffix: true,
293
+ })}
294
+ </span>
262
295
  </div>
263
296
 
264
- {/* Gradient overlay on hover */}
265
- <div
266
- className={cn(
267
- 'pointer-events-none absolute inset-0 opacity-0 transition-opacity duration-300 group-hover:opacity-100',
268
- index === 0
269
- ? 'bg-linear-to-br from-dynamic-purple/5 via-transparent to-dynamic-blue/5'
270
- : 'bg-linear-to-br from-dynamic-blue/5 via-transparent to-dynamic-green/5'
297
+ <div className="space-y-2">
298
+ <CardTitle className="font-bold text-2xl leading-tight transition-colors group-hover:text-primary">
299
+ {template.name}
300
+ </CardTitle>
301
+ {template.description && (
302
+ <CardDescription className="line-clamp-3 text-base leading-relaxed">
303
+ {template.description}
304
+ </CardDescription>
271
305
  )}
272
- />
306
+ </div>
307
+ </CardHeader>
273
308
 
274
- <CardHeader className="relative space-y-4 pb-4">
275
- <div className="flex items-center justify-between">
276
- <Badge
277
- variant="secondary"
278
- className={cn(
279
- 'bg-background/80 font-semibold backdrop-blur-sm',
280
- index === 0 ? 'text-dynamic-purple' : 'text-dynamic-blue'
281
- )}
282
- >
283
- ⭐ Featured
284
- </Badge>
285
- <span className="text-muted-foreground text-xs">
286
- {formatDistanceToNow(new Date(template.createdAt), {
287
- addSuffix: true,
288
- })}
309
+ <CardContent className="relative">
310
+ <div className="flex flex-wrap items-center gap-4">
311
+ <div className="flex items-center gap-2 rounded-full bg-linear-to-r from-dynamic-blue/10 to-dynamic-cyan/10 px-3 py-1.5 text-sm">
312
+ <KanbanSquare className="h-4 w-4 text-dynamic-blue" />
313
+ <span className="font-medium">
314
+ {template.stats.lists}{' '}
315
+ <span className="text-muted-foreground">lists</span>
289
316
  </span>
290
317
  </div>
291
-
292
- <div className="space-y-2">
293
- <CardTitle className="font-bold text-2xl leading-tight transition-colors group-hover:text-primary">
294
- {template.name}
295
- </CardTitle>
296
- {template.description && (
297
- <CardDescription className="line-clamp-3 text-base leading-relaxed">
298
- {template.description}
299
- </CardDescription>
300
- )}
318
+ <div className="flex items-center gap-2 rounded-full bg-linear-to-r from-dynamic-green/10 to-dynamic-teal/10 px-3 py-1.5 text-sm">
319
+ <ListTodo className="h-4 w-4 text-dynamic-green" />
320
+ <span className="font-medium">
321
+ {template.stats.tasks}{' '}
322
+ <span className="text-muted-foreground">tasks</span>
323
+ </span>
301
324
  </div>
302
- </CardHeader>
303
-
304
- <CardContent className="relative">
305
- <div className="flex flex-wrap items-center gap-4">
306
- <div className="flex items-center gap-2 rounded-full bg-linear-to-r from-dynamic-blue/10 to-dynamic-cyan/10 px-3 py-1.5 text-sm">
307
- <KanbanSquare className="h-4 w-4 text-dynamic-blue" />
325
+ {template.stats.labels > 0 && (
326
+ <div className="flex items-center gap-2 rounded-full bg-linear-to-r from-dynamic-orange/10 to-dynamic-yellow/10 px-3 py-1.5 text-sm">
327
+ <Tags className="h-4 w-4 text-dynamic-orange" />
308
328
  <span className="font-medium">
309
- {template.stats.lists}{' '}
310
- <span className="text-muted-foreground">lists</span>
329
+ {template.stats.labels}{' '}
330
+ <span className="text-muted-foreground">labels</span>
311
331
  </span>
312
332
  </div>
313
- <div className="flex items-center gap-2 rounded-full bg-linear-to-r from-dynamic-green/10 to-dynamic-teal/10 px-3 py-1.5 text-sm">
314
- <ListTodo className="h-4 w-4 text-dynamic-green" />
315
- <span className="font-medium">
316
- {template.stats.tasks}{' '}
317
- <span className="text-muted-foreground">tasks</span>
318
- </span>
319
- </div>
320
- {template.stats.labels > 0 && (
321
- <div className="flex items-center gap-2 rounded-full bg-linear-to-r from-dynamic-orange/10 to-dynamic-yellow/10 px-3 py-1.5 text-sm">
322
- <Tags className="h-4 w-4 text-dynamic-orange" />
323
- <span className="font-medium">
324
- {template.stats.labels}{' '}
325
- <span className="text-muted-foreground">labels</span>
326
- </span>
327
- </div>
328
- )}
329
- </div>
330
- </CardContent>
331
- </Card>
332
- </Link>
333
+ )}
334
+ </div>
335
+ </CardContent>
336
+ </Card>
337
+ );
338
+
339
+ if (onOpenTemplate) {
340
+ return (
341
+ <button
342
+ type="button"
343
+ className="block h-full w-full text-left"
344
+ onClick={() => onOpenTemplate(template)}
345
+ >
346
+ {card}
347
+ </button>
348
+ );
349
+ }
350
+
351
+ return (
352
+ <Link href={`/${wsId}/${templatesBasePath}/${template.id}`}>{card}</Link>
333
353
  );
334
354
  }
335
355
 
336
356
  function MarketplaceTemplateCard({
337
357
  template,
358
+ onOpenTemplate,
338
359
  wsId,
339
360
  templatesBasePath,
340
361
  }: {
341
362
  template: BoardTemplate;
363
+ onOpenTemplate?: (template: BoardTemplate) => void;
342
364
  wsId: string;
343
365
  templatesBasePath: string;
344
366
  }) {
345
- return (
346
- <Link href={`/${wsId}/${templatesBasePath}/${template.id}`}>
347
- <Card className="group h-full overflow-hidden transition-all duration-200 hover:-translate-y-1 hover:border-primary/50 hover:shadow-lg">
348
- <div className="aspect-video w-full overflow-hidden border-b bg-muted/50">
349
- {template.backgroundUrl ? (
350
- <Image
351
- src={template.backgroundUrl}
352
- alt={template.name}
353
- width={400}
354
- height={225}
355
- className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
356
- />
357
- ) : (
358
- <div className="flex h-full w-full items-center justify-center bg-secondary/30">
359
- <KanbanSquare className="h-10 w-10 text-muted-foreground/20" />
360
- </div>
361
- )}
362
- </div>
363
- <CardHeader className="pb-3">
364
- <div className="flex items-start gap-3">
365
- <div className="rounded-lg bg-linear-to-br from-dynamic-purple/10 via-dynamic-pink/5 to-dynamic-blue/10 p-2.5 transition-colors group-hover:from-dynamic-purple/20 group-hover:to-dynamic-blue/20">
366
- <Bookmark className="h-5 w-5 text-dynamic-purple" />
367
- </div>
368
- <div className="min-w-0 flex-1 space-y-1">
369
- <CardTitle className="line-clamp-2 text-base leading-tight transition-colors group-hover:text-dynamic-blue">
370
- {template.name}
371
- </CardTitle>
372
- <span className="text-muted-foreground text-xs">
373
- {formatDistanceToNow(new Date(template.createdAt), {
374
- addSuffix: true,
375
- })}
376
- </span>
377
- </div>
367
+ const card = (
368
+ <Card className="group h-full overflow-hidden transition-all duration-200 hover:-translate-y-1 hover:border-primary/50 hover:shadow-lg">
369
+ <div className="aspect-video w-full overflow-hidden border-b bg-muted/50">
370
+ {template.backgroundUrl ? (
371
+ <Image
372
+ src={template.backgroundUrl}
373
+ alt={template.name}
374
+ width={400}
375
+ height={225}
376
+ className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
377
+ />
378
+ ) : (
379
+ <div className="flex h-full w-full items-center justify-center bg-secondary/30">
380
+ <KanbanSquare className="h-10 w-10 text-muted-foreground/20" />
378
381
  </div>
379
- {template.description && (
380
- <CardDescription className="line-clamp-2 pt-2 text-xs leading-relaxed">
381
- {template.description}
382
- </CardDescription>
383
- )}
384
- </CardHeader>
382
+ )}
383
+ </div>
384
+ <CardHeader className="pb-3">
385
+ <div className="flex items-start gap-3">
386
+ <div className="rounded-lg bg-linear-to-br from-dynamic-purple/10 via-dynamic-pink/5 to-dynamic-blue/10 p-2.5 transition-colors group-hover:from-dynamic-purple/20 group-hover:to-dynamic-blue/20">
387
+ <Bookmark className="h-5 w-5 text-dynamic-purple" />
388
+ </div>
389
+ <div className="min-w-0 flex-1 space-y-1">
390
+ <CardTitle className="line-clamp-2 text-base leading-tight transition-colors group-hover:text-dynamic-blue">
391
+ {template.name}
392
+ </CardTitle>
393
+ <span className="text-muted-foreground text-xs">
394
+ {formatDistanceToNow(new Date(template.createdAt), {
395
+ addSuffix: true,
396
+ })}
397
+ </span>
398
+ </div>
399
+ </div>
400
+ {template.description && (
401
+ <CardDescription className="line-clamp-2 pt-2 text-xs leading-relaxed">
402
+ {template.description}
403
+ </CardDescription>
404
+ )}
405
+ </CardHeader>
385
406
 
386
- <CardContent className="pt-0">
387
- <div className="flex flex-wrap items-center gap-3 border-border/50 border-t pt-3 text-muted-foreground text-xs">
388
- <div className="flex items-center gap-1.5">
389
- <KanbanSquare className="h-3.5 w-3.5 text-dynamic-blue" />
390
- <span>{template.stats.lists}</span>
391
- </div>
407
+ <CardContent className="pt-0">
408
+ <div className="flex flex-wrap items-center gap-3 border-border/50 border-t pt-3 text-muted-foreground text-xs">
409
+ <div className="flex items-center gap-1.5">
410
+ <KanbanSquare className="h-3.5 w-3.5 text-dynamic-blue" />
411
+ <span>{template.stats.lists}</span>
412
+ </div>
413
+ <div className="flex items-center gap-1.5">
414
+ <ListTodo className="h-3.5 w-3.5 text-dynamic-green" />
415
+ <span>{template.stats.tasks}</span>
416
+ </div>
417
+ {template.stats.labels > 0 && (
392
418
  <div className="flex items-center gap-1.5">
393
- <ListTodo className="h-3.5 w-3.5 text-dynamic-green" />
394
- <span>{template.stats.tasks}</span>
419
+ <Tags className="h-3.5 w-3.5 text-dynamic-orange" />
420
+ <span>{template.stats.labels}</span>
395
421
  </div>
396
- {template.stats.labels > 0 && (
397
- <div className="flex items-center gap-1.5">
398
- <Tags className="h-3.5 w-3.5 text-dynamic-orange" />
399
- <span>{template.stats.labels}</span>
400
- </div>
401
- )}
402
- </div>
403
- </CardContent>
404
- </Card>
405
- </Link>
422
+ )}
423
+ </div>
424
+ </CardContent>
425
+ </Card>
426
+ );
427
+
428
+ if (onOpenTemplate) {
429
+ return (
430
+ <button
431
+ type="button"
432
+ className="block h-full w-full text-left"
433
+ onClick={() => onOpenTemplate(template)}
434
+ >
435
+ {card}
436
+ </button>
437
+ );
438
+ }
439
+
440
+ return (
441
+ <Link href={`/${wsId}/${templatesBasePath}/${template.id}`}>{card}</Link>
406
442
  );
407
443
  }
408
444
 
@@ -10,6 +10,7 @@ import type { BoardTemplate } from './types';
10
10
 
11
11
  interface TaskTemplatesHubProps {
12
12
  boardTemplates: BoardTemplate[];
13
+ onOpenBoardTemplate?: (template: BoardTemplate) => void;
13
14
  taskTemplates?: WorkspaceTaskTemplate[];
14
15
  templatesBasePath?: string;
15
16
  wsId: string;
@@ -17,6 +18,7 @@ interface TaskTemplatesHubProps {
17
18
 
18
19
  export function TaskTemplatesHub({
19
20
  boardTemplates,
21
+ onOpenBoardTemplate,
20
22
  taskTemplates = [],
21
23
  templatesBasePath = 'templates',
22
24
  wsId,
@@ -41,6 +43,7 @@ export function TaskTemplatesHub({
41
43
  <TabsContent value="boards">
42
44
  <TemplatesClient
43
45
  initialTemplates={boardTemplates}
46
+ onOpenTemplate={onOpenBoardTemplate}
44
47
  templatesBasePath={templatesBasePath}
45
48
  wsId={wsId}
46
49
  />
@@ -54,6 +54,8 @@ import { UseTemplateDialog } from './use-template-dialog';
54
54
  interface Props {
55
55
  wsId: string;
56
56
  template: BoardTemplateWithContent;
57
+ embedded?: boolean;
58
+ onClose?: () => void;
57
59
  templatesBasePath?: string;
58
60
  }
59
61
 
@@ -221,6 +223,8 @@ function TaskPreviewCard({ task }: { task: TemplateTask }) {
221
223
 
222
224
  export default function TemplateDetailClient({
223
225
  wsId,
226
+ embedded = false,
227
+ onClose,
224
228
  template,
225
229
  templatesBasePath = 'templates',
226
230
  }: Props) {
@@ -248,7 +252,11 @@ export default function TemplateDetailClient({
248
252
  }
249
253
 
250
254
  toast.success(t('detail.delete_success'));
251
- router.push(`/${wsId}/${templatesBasePath}`);
255
+ if (embedded) {
256
+ onClose?.();
257
+ } else {
258
+ router.push(`/${wsId}/${templatesBasePath}`);
259
+ }
252
260
  } catch (error) {
253
261
  console.error('Error deleting template:', error);
254
262
  toast.error(
@@ -269,6 +277,11 @@ export default function TemplateDetailClient({
269
277
  );
270
278
 
271
279
  const handleBack = () => {
280
+ if (embedded) {
281
+ onClose?.();
282
+ return;
283
+ }
284
+
272
285
  // Check if there's history to go back to
273
286
  if (window.history.length > 1) {
274
287
  router.back();
@@ -281,14 +294,16 @@ export default function TemplateDetailClient({
281
294
  return (
282
295
  <div className="space-y-6">
283
296
  {/* Back Button */}
284
- <Button
285
- onClick={handleBack}
286
- variant="link"
287
- className="inline-flex items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground"
288
- >
289
- <ArrowLeft className="h-4 w-4" />
290
- {t('detail.back_to_templates')}
291
- </Button>
297
+ {!embedded && (
298
+ <Button
299
+ onClick={handleBack}
300
+ variant="link"
301
+ className="inline-flex items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground"
302
+ >
303
+ <ArrowLeft className="h-4 w-4" />
304
+ {t('detail.back_to_templates')}
305
+ </Button>
306
+ )}
292
307
 
293
308
  {/* Header */}
294
309
  <div className="grid gap-8 lg:grid-cols-3">
@@ -14,8 +14,10 @@ interface Props {
14
14
  wsId: string;
15
15
  templateId: string;
16
16
  templatesBasePath: string;
17
- initialTemplate: InternalApiWorkspaceTemplate;
18
- initialBackgroundUrl: string | null;
17
+ embedded?: boolean;
18
+ initialTemplate?: InternalApiWorkspaceTemplate;
19
+ initialBackgroundUrl?: string | null;
20
+ onClose?: () => void;
19
21
  }
20
22
 
21
23
  function mapTemplateToDetail(
@@ -74,8 +76,10 @@ export default function TaskTemplateDetailPageClient({
74
76
  wsId,
75
77
  templateId,
76
78
  templatesBasePath,
79
+ embedded = false,
77
80
  initialTemplate,
78
81
  initialBackgroundUrl,
82
+ onClose,
79
83
  }: Props) {
80
84
  const { data: template, error } = useQuery({
81
85
  queryKey: ['workspace-template', wsId, templateId],
@@ -112,6 +116,8 @@ export default function TaskTemplateDetailPageClient({
112
116
  return (
113
117
  <div>
114
118
  <TemplateDetailClient
119
+ embedded={embedded}
120
+ onClose={onClose}
115
121
  wsId={wsId}
116
122
  template={mapTemplateToDetail(template, backgroundUrl ?? null)}
117
123
  templatesBasePath={templatesBasePath}
@@ -8,7 +8,10 @@ import type { ReactNode } from 'react';
8
8
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
9
9
  import {
10
10
  __resetNotificationSubscriptionRegistryForTests,
11
+ UNREAD_COUNT_FALLBACK_INTERVAL_MS,
12
+ useInfiniteNotifications,
11
13
  useNotificationSubscription,
14
+ useUnreadCount,
12
15
  } from '../use-notifications';
13
16
 
14
17
  type RealtimePayload = {
@@ -94,6 +97,37 @@ describe('useNotificationSubscription', () => {
94
97
 
95
98
  afterEach(() => {
96
99
  __resetNotificationSubscriptionRegistryForTests();
100
+ vi.unstubAllGlobals();
101
+ });
102
+
103
+ it('defers notification list requests until the consumer enables them', () => {
104
+ const fetchMock = vi.fn();
105
+ vi.stubGlobal('fetch', fetchMock);
106
+ const queryClient = createQueryClient();
107
+
108
+ renderHook(
109
+ () =>
110
+ useInfiniteNotifications({
111
+ enabled: false,
112
+ unreadOnly: true,
113
+ }),
114
+ { wrapper: createWrapper(queryClient) }
115
+ );
116
+
117
+ expect(fetchMock).not.toHaveBeenCalled();
118
+ });
119
+
120
+ it('uses realtime-first unread counts with a low-frequency fallback', () => {
121
+ const fetchMock = vi.fn();
122
+ vi.stubGlobal('fetch', fetchMock);
123
+ const queryClient = createQueryClient();
124
+
125
+ renderHook(() => useUnreadCount(undefined, { enabled: false }), {
126
+ wrapper: createWrapper(queryClient),
127
+ });
128
+
129
+ expect(fetchMock).not.toHaveBeenCalled();
130
+ expect(UNREAD_COUNT_FALLBACK_INTERVAL_MS).toBe(5 * 60 * 1000);
97
131
  });
98
132
 
99
133
  it('shares one realtime channel across multiple consumers for the same user', async () => {
@@ -286,12 +286,9 @@ describe('useTaskActions', () => {
286
286
  });
287
287
  });
288
288
 
289
- it('moves an external task to the personal and source done lists when checked', async () => {
289
+ it('moves an external task to done through terminal personal placement when checked', async () => {
290
290
  const completedAt = '2026-05-07T03:00:00.000Z';
291
291
  queryClient.setQueryData(['tasks', 'board-1'], [mockExternalTask]);
292
- mockListWorkspaceTaskLists.mockResolvedValueOnce({
293
- lists: mockSourceLists,
294
- });
295
292
  mockUpsertCurrentUserTaskPersonalPlacement.mockResolvedValueOnce({
296
293
  task: {
297
294
  ...mockExternalTask,
@@ -299,14 +296,10 @@ describe('useTaskActions', () => {
299
296
  personal_list_id: 'completion-list',
300
297
  personal_sort_key: 1_500_000,
301
298
  sort_key: 1_500_000,
302
- },
303
- });
304
- mockUpdateWorkspaceTask.mockResolvedValueOnce({
305
- task: {
306
- ...mockExternalTask,
307
- list_id: 'source-done-list',
299
+ source_list_id: 'source-done-list',
300
+ source_list_status: 'done',
308
301
  completed_at: completedAt,
309
- closed_at: null,
302
+ closed_at: completedAt,
310
303
  },
311
304
  });
312
305
 
@@ -329,10 +322,7 @@ describe('useTaskActions', () => {
329
322
  await result.current.handleArchiveToggle();
330
323
  });
331
324
 
332
- expect(mockListWorkspaceTaskLists).toHaveBeenCalledWith(
333
- 'source-ws',
334
- 'source-board'
335
- );
325
+ expect(mockListWorkspaceTaskLists).not.toHaveBeenCalled();
336
326
  expect(mockUpsertCurrentUserTaskPersonalPlacement).toHaveBeenCalledWith(
337
327
  'task-1',
338
328
  expect.objectContaining({
@@ -340,15 +330,10 @@ describe('useTaskActions', () => {
340
330
  personal_list_id: 'completion-list',
341
331
  previous_task_id: null,
342
332
  next_task_id: null,
333
+ terminal_status: 'done',
343
334
  })
344
335
  );
345
- expect(mockUpdateWorkspaceTask).toHaveBeenCalledWith(
346
- 'source-ws',
347
- 'task-1',
348
- {
349
- list_id: 'source-done-list',
350
- }
351
- );
336
+ expect(mockUpdateWorkspaceTask).not.toHaveBeenCalled();
352
337
  expect(queryClient.getQueryData<Task[]>(['tasks', 'board-1'])).toEqual([
353
338
  expect.objectContaining({
354
339
  id: 'task-1',
@@ -356,6 +341,7 @@ describe('useTaskActions', () => {
356
341
  personal_list_id: 'completion-list',
357
342
  source_list_id: 'source-done-list',
358
343
  completed_at: completedAt,
344
+ closed_at: completedAt,
359
345
  _localMutationAt: expect.any(Number),
360
346
  }),
361
347
  ]);
@@ -733,12 +719,9 @@ describe('useTaskActions', () => {
733
719
  expect(setMenuOpen).toHaveBeenCalledWith(false);
734
720
  });
735
721
 
736
- it('moves an external task to the personal and source closed lists', async () => {
722
+ it('moves an external task to closed through terminal personal placement', async () => {
737
723
  const closedAt = '2026-05-07T03:30:00.000Z';
738
724
  queryClient.setQueryData(['tasks', 'board-1'], [mockExternalTask]);
739
- mockListWorkspaceTaskLists.mockResolvedValueOnce({
740
- lists: mockSourceLists,
741
- });
742
725
  mockUpsertCurrentUserTaskPersonalPlacement.mockResolvedValueOnce({
743
726
  task: {
744
727
  ...mockExternalTask,
@@ -746,12 +729,8 @@ describe('useTaskActions', () => {
746
729
  personal_list_id: 'closed-list',
747
730
  personal_sort_key: 1_700_000,
748
731
  sort_key: 1_700_000,
749
- },
750
- });
751
- mockUpdateWorkspaceTask.mockResolvedValueOnce({
752
- task: {
753
- ...mockExternalTask,
754
- list_id: 'source-closed-list',
732
+ source_list_id: 'source-closed-list',
733
+ source_list_status: 'closed',
755
734
  completed_at: null,
756
735
  closed_at: closedAt,
757
736
  },
@@ -781,15 +760,11 @@ describe('useTaskActions', () => {
781
760
  expect.objectContaining({
782
761
  personal_board_id: 'board-1',
783
762
  personal_list_id: 'closed-list',
763
+ terminal_status: 'closed',
784
764
  })
785
765
  );
786
- expect(mockUpdateWorkspaceTask).toHaveBeenCalledWith(
787
- 'source-ws',
788
- 'task-1',
789
- {
790
- list_id: 'source-closed-list',
791
- }
792
- );
766
+ expect(mockListWorkspaceTaskLists).not.toHaveBeenCalled();
767
+ expect(mockUpdateWorkspaceTask).not.toHaveBeenCalled();
793
768
  expect(queryClient.getQueryData<Task[]>(['tasks', 'board-1'])).toEqual([
794
769
  expect.objectContaining({
795
770
  id: 'task-1',