@tuturuuu/ui 0.9.0 → 0.11.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 (123) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/package.json +6 -5
  3. package/src/components/ui/custom/__tests__/settings-dialog-search.test.ts +78 -0
  4. package/src/components/ui/custom/__tests__/settings-dialog-shell-compile-graph.test.ts +76 -0
  5. package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +27 -1
  6. package/src/components/ui/custom/nav-link.test.tsx +165 -0
  7. package/src/components/ui/custom/nav-link.tsx +69 -11
  8. package/src/components/ui/custom/navigation.tsx +1 -0
  9. package/src/components/ui/custom/settings/task-settings.tsx +104 -0
  10. package/src/components/ui/custom/settings-dialog-search-loader.d.ts +5 -0
  11. package/src/components/ui/custom/settings-dialog-search-loader.js +3 -0
  12. package/src/components/ui/custom/settings-dialog-search.ts +75 -0
  13. package/src/components/ui/custom/settings-dialog-shell.tsx +63 -27
  14. package/src/components/ui/custom/workspace-select-helpers.ts +23 -0
  15. package/src/components/ui/custom/workspace-select.tsx +17 -16
  16. package/src/components/ui/finance/invoices/components/subscription-attendance-summary.tsx +19 -5
  17. package/src/components/ui/finance/invoices/components/subscription-prepaid-controls.test.tsx +41 -0
  18. package/src/components/ui/finance/invoices/components/subscription-prepaid-controls.tsx +93 -0
  19. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +82 -70
  20. package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-content.ts +50 -25
  21. package/src/components/ui/finance/invoices/hooks.ts +11 -2
  22. package/src/components/ui/finance/invoices/internal-api.ts +5 -0
  23. package/src/components/ui/finance/invoices/subscription-invoice.tsx +92 -31
  24. package/src/components/ui/finance/invoices/utils.test.ts +82 -0
  25. package/src/components/ui/finance/invoices/utils.ts +240 -7
  26. package/src/components/ui/tu-do/boards/__tests__/board-share-dialog.test.tsx +16 -0
  27. package/src/components/ui/tu-do/boards/__tests__/task-board-form.test.tsx +12 -0
  28. package/src/components/ui/tu-do/boards/board-share-dialog.tsx +4 -328
  29. package/src/components/ui/tu-do/boards/board-share-settings-panel.tsx +351 -0
  30. package/src/components/ui/tu-do/boards/boardId/board-column.tsx +50 -37
  31. package/src/components/ui/tu-do/boards/boardId/enhanced-task-list.tsx +7 -0
  32. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-operation-types.ts +3 -3
  33. package/src/components/ui/tu-do/boards/boardId/kanban/data/use-bulk-resources.ts +59 -5
  34. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/drag-preview.tsx +20 -1
  35. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.test.tsx +263 -21
  36. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.tsx +133 -14
  37. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-deadline-panels.tsx +112 -54
  38. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-skeleton.tsx +8 -2
  39. package/src/components/ui/tu-do/boards/boardId/kanban.tsx +29 -14
  40. package/src/components/ui/tu-do/boards/boardId/task-board-server-page.test.tsx +24 -1
  41. package/src/components/ui/tu-do/boards/boardId/task-board-server-page.tsx +7 -0
  42. package/src/components/ui/tu-do/boards/boardId/task-card/measured-task-card.tsx +7 -1
  43. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.test.ts +20 -0
  44. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.ts +10 -0
  45. package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +80 -8
  46. package/src/components/ui/tu-do/boards/boardId/task-list.tsx +15 -0
  47. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-grid.tsx +9 -0
  48. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-task-row.tsx +9 -0
  49. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-toolbar.tsx +9 -0
  50. package/src/components/ui/tu-do/boards/boardId/timeline-board.tsx +35 -3
  51. package/src/components/ui/tu-do/boards/form.tsx +1 -1
  52. package/src/components/ui/tu-do/hooks/__tests__/useTaskLabelManagement.test.tsx +48 -0
  53. package/src/components/ui/tu-do/hooks/__tests__/useTaskProjectManagement.test.tsx +144 -0
  54. package/src/components/ui/tu-do/hooks/useTaskDialog.ts +7 -0
  55. package/src/components/ui/tu-do/hooks/useTaskLabelManagement.ts +115 -106
  56. package/src/components/ui/tu-do/hooks/useTaskProjectManagement.ts +115 -122
  57. package/src/components/ui/tu-do/progress/task-progress-import-panel.tsx +60 -0
  58. package/src/components/ui/tu-do/progress/task-progress-leaderboards-panel.tsx +156 -0
  59. package/src/components/ui/tu-do/progress/task-progress-page.tsx +348 -0
  60. package/src/components/ui/tu-do/progress/task-progress-panels.tsx +301 -0
  61. package/src/components/ui/tu-do/providers/task-dialog-provider.tsx +26 -0
  62. package/src/components/ui/tu-do/shared/__tests__/assignee-select.test.tsx +81 -10
  63. package/src/components/ui/tu-do/shared/__tests__/board-client.test.tsx +123 -1
  64. package/src/components/ui/tu-do/shared/__tests__/board-header.test.tsx +38 -0
  65. package/src/components/ui/tu-do/shared/__tests__/board-switcher.test.tsx +128 -7
  66. package/src/components/ui/tu-do/shared/__tests__/board-views.test.tsx +222 -9
  67. package/src/components/ui/tu-do/shared/__tests__/task-board-loading-state.test.tsx +58 -0
  68. package/src/components/ui/tu-do/shared/__tests__/task-cache-patches.test.ts +147 -0
  69. package/src/components/ui/tu-do/shared/__tests__/use-progressive-board-loader.test.tsx +3 -0
  70. package/src/components/ui/tu-do/shared/assignee-select.tsx +77 -26
  71. package/src/components/ui/tu-do/shared/board-client.tsx +16 -4
  72. package/src/components/ui/tu-do/shared/board-header.tsx +8 -1
  73. package/src/components/ui/tu-do/shared/board-switcher.tsx +70 -38
  74. package/src/components/ui/tu-do/shared/board-user-presence-avatars.tsx +18 -12
  75. package/src/components/ui/tu-do/shared/board-views.tsx +49 -69
  76. package/src/components/ui/tu-do/shared/list-view.tsx +21 -3
  77. package/src/components/ui/tu-do/shared/task-board-loading-state.tsx +58 -4
  78. package/src/components/ui/tu-do/shared/task-cache-patches.ts +394 -0
  79. package/src/components/ui/tu-do/shared/task-dialog-manager.tsx +21 -1
  80. package/src/components/ui/tu-do/shared/task-edit-dialog/components/quick-settings-popover.tsx +5 -1
  81. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-description-editor.tsx +3 -0
  82. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-dialog-header.tsx +25 -2
  83. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-list-selector.tsx +7 -1
  84. package/src/components/ui/tu-do/shared/task-edit-dialog/description-versions.test.ts +97 -0
  85. package/src/components/ui/tu-do/shared/task-edit-dialog/description-versions.ts +210 -0
  86. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-data.ts +79 -10
  87. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-mutations.ts +76 -77
  88. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-relationships.test.tsx +63 -0
  89. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-relationships.ts +78 -69
  90. package/src/components/ui/tu-do/shared/task-edit-dialog/personal-overrides-section.tsx +28 -8
  91. package/src/components/ui/tu-do/shared/task-edit-dialog/relationships/dependencies-section.tsx +14 -3
  92. package/src/components/ui/tu-do/shared/task-edit-dialog/relationships/parent-section.tsx +6 -1
  93. package/src/components/ui/tu-do/shared/task-edit-dialog/relationships/related-section.tsx +6 -1
  94. package/src/components/ui/tu-do/shared/task-edit-dialog/relationships/subtasks-section.tsx +6 -1
  95. package/src/components/ui/tu-do/shared/task-edit-dialog/relationships/types/task-relationships.types.ts +8 -0
  96. package/src/components/ui/tu-do/shared/task-edit-dialog/task-activity-section.tsx +56 -8
  97. package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-change-dialog.test.tsx +63 -0
  98. package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-change-dialog.tsx +218 -0
  99. package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-restore-banner.tsx +83 -0
  100. package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-version-restore-dialog.test.tsx +120 -0
  101. package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-version-restore-dialog.tsx +180 -0
  102. package/src/components/ui/tu-do/shared/task-edit-dialog/task-dialog-actions.tsx +8 -1
  103. package/src/components/ui/tu-do/shared/task-edit-dialog/task-properties-section.test.tsx +150 -0
  104. package/src/components/ui/tu-do/shared/task-edit-dialog/task-properties-section.tsx +61 -35
  105. package/src/components/ui/tu-do/shared/task-edit-dialog/task-relationships-properties.tsx +44 -2
  106. package/src/components/ui/tu-do/shared/task-edit-dialog/utils.test.ts +100 -0
  107. package/src/components/ui/tu-do/shared/task-edit-dialog/utils.ts +109 -0
  108. package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +390 -34
  109. package/src/components/ui/tu-do/shared/task-row-actions-menu.tsx +11 -0
  110. package/src/components/ui/tu-do/shared/use-progressive-board-loader.ts +2 -0
  111. package/src/components/ui/tu-do/templates/task-template-api.ts +142 -0
  112. package/src/components/ui/tu-do/templates/task-template-card.tsx +118 -0
  113. package/src/components/ui/tu-do/templates/task-template-client.tsx +258 -0
  114. package/src/components/ui/tu-do/templates/task-template-dialogs.test.tsx +167 -0
  115. package/src/components/ui/tu-do/templates/task-template-dialogs.tsx +376 -0
  116. package/src/components/ui/tu-do/templates/task-templates-hub.test.tsx +114 -0
  117. package/src/components/ui/tu-do/templates/task-templates-hub.tsx +50 -0
  118. package/src/components/ui/tu-do/templates/task-templates-page.tsx +6 -11
  119. package/src/hooks/__tests__/useBoardPresence.test.tsx +191 -0
  120. package/src/hooks/__tests__/useBoardRealtime.test.tsx +24 -144
  121. package/src/hooks/useBoardPresence.ts +364 -0
  122. package/src/hooks/useBoardRealtimeEventHandler.ts +34 -90
  123. package/src/lib/workspace-actions.ts +2 -6
@@ -0,0 +1,60 @@
1
+ import type { UseMutationResult } from '@tanstack/react-query';
2
+ import { BarChart3, Upload } from '@tuturuuu/icons';
3
+ import { Button } from '@tuturuuu/ui/button';
4
+ import { Card, CardContent, CardHeader, CardTitle } from '@tuturuuu/ui/card';
5
+ import { Textarea } from '@tuturuuu/ui/textarea';
6
+ import type { useTranslations } from 'next-intl';
7
+
8
+ type Translate = ReturnType<typeof useTranslations>;
9
+
10
+ export function ImportPanel({
11
+ importMutation,
12
+ importPreviewCount,
13
+ importText,
14
+ setImportText,
15
+ t,
16
+ }: {
17
+ importMutation: UseMutationResult<any, unknown, boolean>;
18
+ importPreviewCount: number;
19
+ importText: string;
20
+ setImportText: (value: string) => void;
21
+ t: Translate;
22
+ }) {
23
+ return (
24
+ <Card>
25
+ <CardHeader>
26
+ <CardTitle>{t('import.title')}</CardTitle>
27
+ </CardHeader>
28
+ <CardContent className="space-y-4">
29
+ <Textarea
30
+ className="min-h-56 font-mono text-sm"
31
+ onChange={(event) => setImportText(event.target.value)}
32
+ placeholder={t('import.placeholder')}
33
+ value={importText}
34
+ />
35
+ <div className="flex flex-wrap items-center justify-between gap-3">
36
+ <div className="text-muted-foreground text-sm">
37
+ {t('import.preview_count', { count: importPreviewCount })}
38
+ </div>
39
+ <div className="flex gap-2">
40
+ <Button
41
+ disabled={!importText.trim() || importMutation.isPending}
42
+ onClick={() => importMutation.mutate(false)}
43
+ variant="outline"
44
+ >
45
+ <BarChart3 className="mr-2 h-4 w-4" />
46
+ {t('actions.preview')}
47
+ </Button>
48
+ <Button
49
+ disabled={!importText.trim() || importMutation.isPending}
50
+ onClick={() => importMutation.mutate(true)}
51
+ >
52
+ <Upload className="mr-2 h-4 w-4" />
53
+ {t('actions.commit_import')}
54
+ </Button>
55
+ </div>
56
+ </div>
57
+ </CardContent>
58
+ </Card>
59
+ );
60
+ }
@@ -0,0 +1,156 @@
1
+ import type { UseMutationResult } from '@tanstack/react-query';
2
+ import { Trophy } from '@tuturuuu/icons';
3
+ import type { TaskProgressMetric } from '@tuturuuu/internal-api';
4
+ import { Badge } from '@tuturuuu/ui/badge';
5
+ import { Button } from '@tuturuuu/ui/button';
6
+ import { Card, CardContent, CardHeader, CardTitle } from '@tuturuuu/ui/card';
7
+ import { Input } from '@tuturuuu/ui/input';
8
+ import type { useTranslations } from 'next-intl';
9
+
10
+ type Translate = ReturnType<typeof useTranslations>;
11
+ const today = () => new Date().toISOString().slice(0, 10);
12
+
13
+ function MetricSelect({
14
+ metrics,
15
+ selectedMetric,
16
+ }: {
17
+ metrics: TaskProgressMetric[];
18
+ selectedMetric: TaskProgressMetric | null;
19
+ }) {
20
+ return (
21
+ <select
22
+ className="h-10 rounded-md border bg-background px-3 text-sm"
23
+ defaultValue={selectedMetric?.id}
24
+ name="metric_id"
25
+ required
26
+ >
27
+ {metrics.map((metric) => (
28
+ <option key={metric.id} value={metric.id}>
29
+ {metric.name}
30
+ </option>
31
+ ))}
32
+ </select>
33
+ );
34
+ }
35
+
36
+ export function LeaderboardsPanel(props: {
37
+ createLeaderboardMutation: UseMutationResult<any, unknown, FormData>;
38
+ createTeamMutation: UseMutationResult<
39
+ any,
40
+ unknown,
41
+ { formData: FormData; leaderboardId: string }
42
+ >;
43
+ leaderboards: any[];
44
+ metrics: TaskProgressMetric[];
45
+ selectedMetric: TaskProgressMetric | null;
46
+ t: Translate;
47
+ }) {
48
+ const {
49
+ createLeaderboardMutation,
50
+ createTeamMutation,
51
+ leaderboards,
52
+ metrics,
53
+ selectedMetric,
54
+ t,
55
+ } = props;
56
+
57
+ return (
58
+ <div className="grid gap-4 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
59
+ <Card>
60
+ <CardHeader>
61
+ <CardTitle>{t('leaderboards.create_leaderboard')}</CardTitle>
62
+ </CardHeader>
63
+ <CardContent>
64
+ <form
65
+ className="grid gap-3"
66
+ onSubmit={(event) => {
67
+ event.preventDefault();
68
+ createLeaderboardMutation.mutate(
69
+ new FormData(event.currentTarget)
70
+ );
71
+ event.currentTarget.reset();
72
+ }}
73
+ >
74
+ <Input
75
+ name="name"
76
+ placeholder={t('fields.leaderboard_name')}
77
+ required
78
+ />
79
+ <MetricSelect metrics={metrics} selectedMetric={selectedMetric} />
80
+ <Input defaultValue={today()} name="period_start" type="date" />
81
+ <Input name="period_end" type="date" />
82
+ <Button
83
+ disabled={!selectedMetric || createLeaderboardMutation.isPending}
84
+ >
85
+ <Trophy className="mr-2 h-4 w-4" />
86
+ {t('actions.add_leaderboard')}
87
+ </Button>
88
+ </form>
89
+ </CardContent>
90
+ </Card>
91
+ <div className="grid gap-3">
92
+ {leaderboards.length === 0 ? (
93
+ <Card>
94
+ <CardContent className="py-8 text-sm">
95
+ {t('empty.leaderboards')}
96
+ </CardContent>
97
+ </Card>
98
+ ) : (
99
+ leaderboards.map((leaderboard) => (
100
+ <Card key={leaderboard.id}>
101
+ <CardContent className="space-y-4 py-4">
102
+ <div className="flex items-start justify-between gap-3">
103
+ <div>
104
+ <div className="font-semibold">{leaderboard.name}</div>
105
+ <div className="text-muted-foreground text-sm">
106
+ {leaderboard.metric?.name} · {leaderboard.join_code}
107
+ </div>
108
+ </div>
109
+ <Badge>{leaderboard.rankings?.length ?? 0}</Badge>
110
+ </div>
111
+ <div className="space-y-2">
112
+ {(leaderboard.rankings ?? [])
113
+ .slice(0, 5)
114
+ .map((member: any) => (
115
+ <div
116
+ className="flex items-center justify-between rounded-md border p-2"
117
+ key={member.id}
118
+ >
119
+ <span>
120
+ #{member.rank} {member.display_name || member.user_id}
121
+ </span>
122
+ <strong>
123
+ {Number(member.value ?? 0).toLocaleString()}
124
+ </strong>
125
+ </div>
126
+ ))}
127
+ </div>
128
+ <form
129
+ className="grid gap-2 border-t pt-3 sm:grid-cols-[1fr_7rem_auto]"
130
+ onSubmit={(event) => {
131
+ event.preventDefault();
132
+ createTeamMutation.mutate({
133
+ formData: new FormData(event.currentTarget),
134
+ leaderboardId: leaderboard.id,
135
+ });
136
+ event.currentTarget.reset();
137
+ }}
138
+ >
139
+ <Input
140
+ name="name"
141
+ placeholder={t('fields.team_name')}
142
+ required
143
+ />
144
+ <Input name="color" placeholder={t('fields.color')} />
145
+ <Button size="sm" variant="outline">
146
+ {t('actions.add_team')}
147
+ </Button>
148
+ </form>
149
+ </CardContent>
150
+ </Card>
151
+ ))
152
+ )}
153
+ </div>
154
+ </div>
155
+ );
156
+ }
@@ -0,0 +1,348 @@
1
+ 'use client';
2
+
3
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
4
+ import { BarChart3, Flag, Target, TrendingUp, Trophy } from '@tuturuuu/icons';
5
+ import {
6
+ createTaskLeaderboard,
7
+ createTaskLeaderboardTeam,
8
+ createTaskProgressEntry,
9
+ createTaskProgressGoal,
10
+ createTaskProgressMetric,
11
+ getTaskProgressStats,
12
+ importTaskProgressEntries,
13
+ isTaskProgressSchemaUnavailable,
14
+ listTaskLeaderboards,
15
+ listTaskProgressEntries,
16
+ listTaskProgressGoals,
17
+ listTaskProgressMetrics,
18
+ type TaskProgressMetric,
19
+ } from '@tuturuuu/internal-api';
20
+ import { Button } from '@tuturuuu/ui/button';
21
+ import { Card, CardContent } from '@tuturuuu/ui/card';
22
+ import { toast } from '@tuturuuu/ui/sonner';
23
+ import Link from 'next/link';
24
+ import { useTranslations } from 'next-intl';
25
+ import { useMemo, useState } from 'react';
26
+ import { ImportPanel } from './task-progress-import-panel';
27
+ import { LeaderboardsPanel } from './task-progress-leaderboards-panel';
28
+ import {
29
+ GoalsPanel,
30
+ ProgressPanel,
31
+ StatsPanel,
32
+ SummaryCard,
33
+ } from './task-progress-panels';
34
+
35
+ export type TaskProgressView =
36
+ | 'progress'
37
+ | 'goals'
38
+ | 'stats'
39
+ | 'leaderboards'
40
+ | 'import';
41
+
42
+ interface TaskProgressPageProps {
43
+ routeWsId: string;
44
+ view: TaskProgressView;
45
+ wsId: string;
46
+ }
47
+
48
+ const today = () => new Date().toISOString().slice(0, 10);
49
+
50
+ function metricOption(metrics: TaskProgressMetric[]) {
51
+ return metrics.find((metric) => metric.is_default) ?? metrics[0] ?? null;
52
+ }
53
+
54
+ function parseImportRows(text: string, metrics: TaskProgressMetric[]) {
55
+ const metricByName = new Map(
56
+ metrics.map((metric) => [metric.name.trim().toLowerCase(), metric])
57
+ );
58
+ const fallbackMetric = metricOption(metrics);
59
+
60
+ return text
61
+ .split(/\r?\n/u)
62
+ .map((line) => line.trim())
63
+ .filter(Boolean)
64
+ .map((line) => {
65
+ const [entry_date, rawValue, metricName, rawTags, note] = line
66
+ .split(',')
67
+ .map((part) => part.trim());
68
+ const metric =
69
+ (metricName ? metricByName.get(metricName.toLowerCase()) : null) ??
70
+ fallbackMetric;
71
+
72
+ if (!metric) throw new Error('missing_metric');
73
+
74
+ return {
75
+ entry_date: entry_date || today(),
76
+ metric_id: metric.id,
77
+ value: Number(rawValue || 0),
78
+ tags: rawTags
79
+ ? rawTags
80
+ .split('|')
81
+ .map((tag) => tag.trim())
82
+ .filter(Boolean)
83
+ : [],
84
+ note: note || null,
85
+ };
86
+ });
87
+ }
88
+
89
+ export function TaskProgressPage({
90
+ routeWsId,
91
+ view,
92
+ wsId,
93
+ }: TaskProgressPageProps) {
94
+ const t = useTranslations('task-progress');
95
+ const queryClient = useQueryClient();
96
+ const [importText, setImportText] = useState('');
97
+ const [importPreviewCount, setImportPreviewCount] = useState(0);
98
+ const queryRoot = ['task-progress', wsId];
99
+
100
+ const metricsQuery = useQuery({
101
+ queryKey: [...queryRoot, 'metrics'],
102
+ queryFn: () => listTaskProgressMetrics(wsId),
103
+ });
104
+ const metrics =
105
+ metricsQuery.data?.ok === true ? metricsQuery.data.metrics : [];
106
+ const selectedMetric = useMemo(() => metricOption(metrics), [metrics]);
107
+
108
+ const entriesQuery = useQuery({
109
+ queryKey: [...queryRoot, 'entries', selectedMetric?.id],
110
+ queryFn: () =>
111
+ listTaskProgressEntries(wsId, {
112
+ metric_id: selectedMetric?.id,
113
+ pageSize: 25,
114
+ }),
115
+ enabled: metrics.length > 0,
116
+ });
117
+ const goalsQuery = useQuery({
118
+ queryKey: [...queryRoot, 'goals'],
119
+ queryFn: () => listTaskProgressGoals(wsId, { status: 'active' }),
120
+ });
121
+ const statsQuery = useQuery({
122
+ queryKey: [...queryRoot, 'stats', selectedMetric?.id],
123
+ queryFn: () =>
124
+ getTaskProgressStats(wsId, { metric_id: selectedMetric?.id }),
125
+ enabled: metrics.length > 0,
126
+ });
127
+ const leaderboardsQuery = useQuery({
128
+ queryKey: [...queryRoot, 'leaderboards'],
129
+ queryFn: () => listTaskLeaderboards(wsId, { status: 'active' }),
130
+ });
131
+
132
+ const invalidateProgress = () =>
133
+ queryClient.invalidateQueries({ queryKey: queryRoot });
134
+
135
+ const createMetricMutation = useMutation({
136
+ mutationFn: (formData: FormData) =>
137
+ createTaskProgressMetric(wsId, {
138
+ name: String(formData.get('name') ?? ''),
139
+ unit_label: String(formData.get('unit_label') ?? ''),
140
+ unit_kind: 'custom',
141
+ }),
142
+ onSuccess: () => {
143
+ toast.success(t('toast.metric_created'));
144
+ invalidateProgress();
145
+ },
146
+ });
147
+ const createEntryMutation = useMutation({
148
+ mutationFn: (formData: FormData) =>
149
+ createTaskProgressEntry(wsId, {
150
+ metric_id: String(formData.get('metric_id') ?? ''),
151
+ entry_date: String(formData.get('entry_date') ?? today()),
152
+ value: Number(formData.get('value') ?? 0),
153
+ tags: String(formData.get('tags') ?? '')
154
+ .split(',')
155
+ .map((tag) => tag.trim())
156
+ .filter(Boolean),
157
+ note: String(formData.get('note') ?? '') || null,
158
+ }),
159
+ onSuccess: () => {
160
+ toast.success(t('toast.entry_created'));
161
+ invalidateProgress();
162
+ },
163
+ });
164
+ const createGoalMutation = useMutation({
165
+ mutationFn: (formData: FormData) =>
166
+ createTaskProgressGoal(wsId, {
167
+ metric_id: String(formData.get('metric_id') ?? ''),
168
+ name: String(formData.get('name') ?? ''),
169
+ target_value: Number(formData.get('target_value') ?? 0),
170
+ period_start: String(formData.get('period_start') ?? today()),
171
+ period_end: String(formData.get('period_end') || '') || null,
172
+ goal_type:
173
+ String(formData.get('goal_type')) === 'habit' ? 'habit' : 'target',
174
+ }),
175
+ onSuccess: () => {
176
+ toast.success(t('toast.goal_created'));
177
+ invalidateProgress();
178
+ },
179
+ });
180
+ const createLeaderboardMutation = useMutation({
181
+ mutationFn: (formData: FormData) =>
182
+ createTaskLeaderboard(wsId, {
183
+ metric_id: String(formData.get('metric_id') ?? ''),
184
+ name: String(formData.get('name') ?? ''),
185
+ period_start: String(formData.get('period_start') ?? today()),
186
+ period_end: String(formData.get('period_end') || '') || null,
187
+ }),
188
+ onSuccess: () => {
189
+ toast.success(t('toast.leaderboard_created'));
190
+ invalidateProgress();
191
+ },
192
+ });
193
+ const createTeamMutation = useMutation({
194
+ mutationFn: ({
195
+ formData,
196
+ leaderboardId,
197
+ }: {
198
+ formData: FormData;
199
+ leaderboardId: string;
200
+ }) =>
201
+ createTaskLeaderboardTeam(wsId, leaderboardId, {
202
+ name: String(formData.get('name') ?? ''),
203
+ color: String(formData.get('color') || '') || null,
204
+ }),
205
+ onSuccess: () => {
206
+ toast.success(t('toast.team_created'));
207
+ invalidateProgress();
208
+ },
209
+ });
210
+ const importMutation = useMutation({
211
+ mutationFn: (commit: boolean) =>
212
+ importTaskProgressEntries(wsId, {
213
+ commit,
214
+ entries: parseImportRows(importText, metrics),
215
+ }),
216
+ onSuccess: (response) => {
217
+ if (response.ok) {
218
+ setImportPreviewCount(response.summary.entriesCount);
219
+ toast.success(
220
+ response.committed
221
+ ? t('toast.import_committed')
222
+ : t('toast.import_previewed')
223
+ );
224
+ }
225
+ invalidateProgress();
226
+ },
227
+ onError: () => toast.error(t('toast.import_failed')),
228
+ });
229
+
230
+ const hasPendingSchema =
231
+ isTaskProgressSchemaUnavailable(metricsQuery.data) ||
232
+ isTaskProgressSchemaUnavailable(statsQuery.data);
233
+ const entries = entriesQuery.data?.ok ? entriesQuery.data.entries : [];
234
+ const goals = goalsQuery.data?.ok ? goalsQuery.data.goals : [];
235
+ const stats = statsQuery.data?.ok ? statsQuery.data : null;
236
+ const leaderboards = leaderboardsQuery.data?.ok
237
+ ? leaderboardsQuery.data.leaderboards
238
+ : [];
239
+
240
+ return (
241
+ <div className="flex flex-col gap-6 p-4 md:p-6">
242
+ <div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
243
+ <div className="space-y-2">
244
+ <div className="flex items-center gap-3">
245
+ <div className="flex h-10 w-10 items-center justify-center rounded-lg bg-dynamic-blue/10 ring-1 ring-dynamic-blue/20">
246
+ <TrendingUp className="h-5 w-5 text-dynamic-blue" />
247
+ </div>
248
+ <div>
249
+ <h1 className="font-bold text-2xl tracking-tight">
250
+ {t(`views.${view}.title`)}
251
+ </h1>
252
+ <p className="text-muted-foreground text-sm">
253
+ {t(`views.${view}.description`)}
254
+ </p>
255
+ </div>
256
+ </div>
257
+ </div>
258
+ <div className="flex flex-wrap gap-2">
259
+ {(
260
+ ['progress', 'goals', 'stats', 'leaderboards', 'import'] as const
261
+ ).map((tab) => (
262
+ <Button
263
+ key={tab}
264
+ asChild
265
+ size="sm"
266
+ variant={tab === view ? 'default' : 'outline'}
267
+ >
268
+ <Link href={`/${routeWsId}/tasks/${tab}`}>
269
+ {t(`tabs.${tab}`)}
270
+ </Link>
271
+ </Button>
272
+ ))}
273
+ </div>
274
+ </div>
275
+
276
+ {hasPendingSchema ? (
277
+ <Card>
278
+ <CardContent className="py-8 text-sm">
279
+ {t('schema_unavailable')}
280
+ </CardContent>
281
+ </Card>
282
+ ) : null}
283
+
284
+ <div className="grid gap-4 md:grid-cols-4">
285
+ <SummaryCard
286
+ icon={<BarChart3 className="h-4 w-4" />}
287
+ label={t('summary.total')}
288
+ value={stats?.summary.total ?? 0}
289
+ />
290
+ <SummaryCard
291
+ icon={<Flag className="h-4 w-4" />}
292
+ label={t('summary.entries')}
293
+ value={stats?.summary.entriesCount ?? entries.length}
294
+ />
295
+ <SummaryCard
296
+ icon={<Target className="h-4 w-4" />}
297
+ label={t('summary.goals')}
298
+ value={goals.length}
299
+ />
300
+ <SummaryCard
301
+ icon={<Trophy className="h-4 w-4" />}
302
+ label={t('summary.streak')}
303
+ value={stats?.summary.currentStreak ?? 0}
304
+ />
305
+ </div>
306
+
307
+ {view === 'progress' ? (
308
+ <ProgressPanel
309
+ createEntryMutation={createEntryMutation}
310
+ createMetricMutation={createMetricMutation}
311
+ entries={entries}
312
+ metrics={metrics}
313
+ selectedMetric={selectedMetric}
314
+ t={t}
315
+ />
316
+ ) : null}
317
+ {view === 'goals' ? (
318
+ <GoalsPanel
319
+ createGoalMutation={createGoalMutation}
320
+ goals={goals}
321
+ metrics={metrics}
322
+ selectedMetric={selectedMetric}
323
+ t={t}
324
+ />
325
+ ) : null}
326
+ {view === 'stats' ? <StatsPanel stats={stats} t={t} /> : null}
327
+ {view === 'leaderboards' ? (
328
+ <LeaderboardsPanel
329
+ createLeaderboardMutation={createLeaderboardMutation}
330
+ createTeamMutation={createTeamMutation}
331
+ leaderboards={leaderboards}
332
+ metrics={metrics}
333
+ selectedMetric={selectedMetric}
334
+ t={t}
335
+ />
336
+ ) : null}
337
+ {view === 'import' ? (
338
+ <ImportPanel
339
+ importMutation={importMutation}
340
+ importPreviewCount={importPreviewCount}
341
+ importText={importText}
342
+ setImportText={setImportText}
343
+ t={t}
344
+ />
345
+ ) : null}
346
+ </div>
347
+ );
348
+ }