@tuturuuu/ui 0.9.0 → 0.10.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 (94) hide show
  1. package/CHANGELOG.md +29 -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/tu-do/boards/__tests__/board-share-dialog.test.tsx +16 -0
  17. package/src/components/ui/tu-do/boards/__tests__/task-board-form.test.tsx +12 -0
  18. package/src/components/ui/tu-do/boards/board-share-dialog.tsx +4 -328
  19. package/src/components/ui/tu-do/boards/board-share-settings-panel.tsx +351 -0
  20. package/src/components/ui/tu-do/boards/boardId/board-column.tsx +50 -37
  21. package/src/components/ui/tu-do/boards/boardId/enhanced-task-list.tsx +7 -0
  22. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-operation-types.ts +3 -3
  23. package/src/components/ui/tu-do/boards/boardId/kanban/data/use-bulk-resources.ts +59 -5
  24. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/drag-preview.tsx +20 -1
  25. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.test.tsx +263 -21
  26. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.tsx +133 -14
  27. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-deadline-panels.tsx +112 -54
  28. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-skeleton.tsx +8 -2
  29. package/src/components/ui/tu-do/boards/boardId/kanban.tsx +29 -14
  30. package/src/components/ui/tu-do/boards/boardId/task-board-server-page.test.tsx +24 -1
  31. package/src/components/ui/tu-do/boards/boardId/task-board-server-page.tsx +7 -0
  32. package/src/components/ui/tu-do/boards/boardId/task-card/measured-task-card.tsx +7 -1
  33. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.test.ts +20 -0
  34. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.ts +10 -0
  35. package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +80 -8
  36. package/src/components/ui/tu-do/boards/boardId/task-list.tsx +15 -0
  37. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-grid.tsx +9 -0
  38. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-task-row.tsx +9 -0
  39. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-toolbar.tsx +9 -0
  40. package/src/components/ui/tu-do/boards/boardId/timeline-board.tsx +35 -3
  41. package/src/components/ui/tu-do/boards/form.tsx +1 -1
  42. package/src/components/ui/tu-do/hooks/__tests__/useTaskLabelManagement.test.tsx +48 -0
  43. package/src/components/ui/tu-do/hooks/__tests__/useTaskProjectManagement.test.tsx +144 -0
  44. package/src/components/ui/tu-do/hooks/useTaskDialog.ts +7 -0
  45. package/src/components/ui/tu-do/hooks/useTaskLabelManagement.ts +115 -106
  46. package/src/components/ui/tu-do/hooks/useTaskProjectManagement.ts +115 -122
  47. package/src/components/ui/tu-do/progress/task-progress-import-panel.tsx +60 -0
  48. package/src/components/ui/tu-do/progress/task-progress-leaderboards-panel.tsx +156 -0
  49. package/src/components/ui/tu-do/progress/task-progress-page.tsx +348 -0
  50. package/src/components/ui/tu-do/progress/task-progress-panels.tsx +301 -0
  51. package/src/components/ui/tu-do/providers/task-dialog-provider.tsx +26 -0
  52. package/src/components/ui/tu-do/shared/__tests__/assignee-select.test.tsx +81 -10
  53. package/src/components/ui/tu-do/shared/__tests__/board-client.test.tsx +116 -1
  54. package/src/components/ui/tu-do/shared/__tests__/board-header.test.tsx +38 -0
  55. package/src/components/ui/tu-do/shared/__tests__/board-switcher.test.tsx +128 -7
  56. package/src/components/ui/tu-do/shared/__tests__/board-views.test.tsx +222 -9
  57. package/src/components/ui/tu-do/shared/__tests__/task-board-loading-state.test.tsx +21 -0
  58. package/src/components/ui/tu-do/shared/__tests__/task-cache-patches.test.ts +147 -0
  59. package/src/components/ui/tu-do/shared/__tests__/use-progressive-board-loader.test.tsx +3 -0
  60. package/src/components/ui/tu-do/shared/assignee-select.tsx +77 -26
  61. package/src/components/ui/tu-do/shared/board-client.tsx +14 -4
  62. package/src/components/ui/tu-do/shared/board-header.tsx +8 -1
  63. package/src/components/ui/tu-do/shared/board-switcher.tsx +70 -38
  64. package/src/components/ui/tu-do/shared/board-user-presence-avatars.tsx +18 -12
  65. package/src/components/ui/tu-do/shared/board-views.tsx +49 -69
  66. package/src/components/ui/tu-do/shared/list-view.tsx +21 -3
  67. package/src/components/ui/tu-do/shared/task-board-loading-state.tsx +4 -4
  68. package/src/components/ui/tu-do/shared/task-cache-patches.ts +394 -0
  69. package/src/components/ui/tu-do/shared/task-dialog-manager.tsx +21 -1
  70. package/src/components/ui/tu-do/shared/task-edit-dialog/components/quick-settings-popover.tsx +5 -1
  71. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-dialog-header.tsx +25 -2
  72. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-list-selector.tsx +7 -1
  73. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-data.ts +79 -10
  74. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-mutations.ts +76 -77
  75. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-relationships.test.tsx +63 -0
  76. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-relationships.ts +78 -69
  77. package/src/components/ui/tu-do/shared/task-edit-dialog/personal-overrides-section.tsx +28 -8
  78. package/src/components/ui/tu-do/shared/task-edit-dialog/relationships/dependencies-section.tsx +14 -3
  79. package/src/components/ui/tu-do/shared/task-edit-dialog/relationships/parent-section.tsx +6 -1
  80. package/src/components/ui/tu-do/shared/task-edit-dialog/relationships/related-section.tsx +6 -1
  81. package/src/components/ui/tu-do/shared/task-edit-dialog/relationships/subtasks-section.tsx +6 -1
  82. package/src/components/ui/tu-do/shared/task-edit-dialog/relationships/types/task-relationships.types.ts +8 -0
  83. package/src/components/ui/tu-do/shared/task-edit-dialog/task-dialog-actions.tsx +8 -1
  84. package/src/components/ui/tu-do/shared/task-edit-dialog/task-properties-section.test.tsx +150 -0
  85. package/src/components/ui/tu-do/shared/task-edit-dialog/task-properties-section.tsx +61 -35
  86. package/src/components/ui/tu-do/shared/task-edit-dialog/task-relationships-properties.tsx +44 -2
  87. package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +9 -0
  88. package/src/components/ui/tu-do/shared/task-row-actions-menu.tsx +11 -0
  89. package/src/components/ui/tu-do/shared/use-progressive-board-loader.ts +2 -0
  90. package/src/hooks/__tests__/useBoardPresence.test.tsx +191 -0
  91. package/src/hooks/__tests__/useBoardRealtime.test.tsx +24 -144
  92. package/src/hooks/useBoardPresence.ts +364 -0
  93. package/src/hooks/useBoardRealtimeEventHandler.ts +34 -90
  94. 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
+ }