@nightkatana/kronosys-app 1.0.0-beta.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 (179) hide show
  1. package/README.md +81 -0
  2. package/app/api/action/route.ts +16 -0
  3. package/app/api/backup/route.ts +84 -0
  4. package/app/api/health/route.ts +22 -0
  5. package/app/api/state/route.ts +27 -0
  6. package/app/apple-icon.png +0 -0
  7. package/app/changelog/page.tsx +122 -0
  8. package/app/globals.css +210 -0
  9. package/app/guide/layout.tsx +11 -0
  10. package/app/guide/page.tsx +278 -0
  11. package/app/icon.png +0 -0
  12. package/app/layout.tsx +77 -0
  13. package/app/licenses/layout.tsx +11 -0
  14. package/app/licenses/page.tsx +246 -0
  15. package/app/manifest.ts +32 -0
  16. package/app/page.tsx +1610 -0
  17. package/app/reporting/page.tsx +2943 -0
  18. package/app/settings/layout.tsx +10 -0
  19. package/app/settings/page.tsx +3518 -0
  20. package/bin/kronosys.mjs +46 -0
  21. package/components/KronosysPackageVersionProvider.tsx +19 -0
  22. package/components/KronosysPayloadProvider.tsx +109 -0
  23. package/components/PwaRegister.tsx +25 -0
  24. package/components/SiteLegalFooter.tsx +21 -0
  25. package/components/ThemeProvider.tsx +78 -0
  26. package/components/dashboard/AppShellLiveSessionDrawer.tsx +394 -0
  27. package/components/dashboard/AppShellRouteNav.tsx +131 -0
  28. package/components/dashboard/AppVersionStamp.tsx +16 -0
  29. package/components/dashboard/DashboardCollapsibleSection.tsx +57 -0
  30. package/components/dashboard/DashboardColumnHintsBanner.tsx +159 -0
  31. package/components/dashboard/DashboardCommandCenter.tsx +470 -0
  32. package/components/dashboard/DashboardLangGateModal.tsx +118 -0
  33. package/components/dashboard/DashboardLoadingOverlay.tsx +42 -0
  34. package/components/dashboard/DashboardSimpleModal.tsx +337 -0
  35. package/components/dashboard/DashboardSuspenseFallback.tsx +52 -0
  36. package/components/dashboard/DashboardToastProvider.tsx +64 -0
  37. package/components/dashboard/DashboardTour.tsx +435 -0
  38. package/components/dashboard/DeferredDescriptionPopoverWrap.tsx +39 -0
  39. package/components/dashboard/DeleteSessionModal.tsx +130 -0
  40. package/components/dashboard/DescriptionTooltipPortaled.tsx +31 -0
  41. package/components/dashboard/GitIdentityQuickSetupModal.tsx +211 -0
  42. package/components/dashboard/HeaderIntegrationBadges.tsx +69 -0
  43. package/components/dashboard/InlineMetricHelpTrigger.tsx +102 -0
  44. package/components/dashboard/IssuePickerModal.tsx +168 -0
  45. package/components/dashboard/KronoFocusPanel.tsx +834 -0
  46. package/components/dashboard/KronosysDatetimePopoverField.tsx +357 -0
  47. package/components/dashboard/KronosysTimePopoverField.tsx +233 -0
  48. package/components/dashboard/LanguageMenu.tsx +123 -0
  49. package/components/dashboard/MongoMirrorSyncLine.tsx +57 -0
  50. package/components/dashboard/NewSessionScopeModal.tsx +410 -0
  51. package/components/dashboard/PageRefreshButton.tsx +130 -0
  52. package/components/dashboard/PlainHelpPopover.tsx +97 -0
  53. package/components/dashboard/ReportingPageToc.tsx +68 -0
  54. package/components/dashboard/ReportingTour.tsx +342 -0
  55. package/components/dashboard/SavedProjectPicker.tsx +92 -0
  56. package/components/dashboard/SavedTagPicker.tsx +115 -0
  57. package/components/dashboard/ScrollToTopFab.tsx +41 -0
  58. package/components/dashboard/SelectedSessionSidebarBlock.tsx +630 -0
  59. package/components/dashboard/SessionEndReasonEditor.tsx +114 -0
  60. package/components/dashboard/SessionListPanel.tsx +320 -0
  61. package/components/dashboard/SessionLocMetricsSection.tsx +128 -0
  62. package/components/dashboard/SettingsTagsProjectsSection.tsx +993 -0
  63. package/components/dashboard/SettingsTour.tsx +332 -0
  64. package/components/dashboard/TagPills.tsx +149 -0
  65. package/components/dashboard/TagsHelpTrigger.tsx +84 -0
  66. package/components/dashboard/TaskFocusPanel.tsx +1261 -0
  67. package/components/dashboard/TaskSessionLiveCard.tsx +832 -0
  68. package/components/dashboard/TaskSubtasksBlock.tsx +748 -0
  69. package/components/dashboard/ThemeToggle.test.tsx +26 -0
  70. package/components/dashboard/ThemeToggle.tsx +36 -0
  71. package/components/dashboard/UserGuideBodyText.tsx +62 -0
  72. package/components/dashboard/WorkspaceGitRepoCard.tsx +191 -0
  73. package/components/dashboard/taskFieldStyles.ts +139 -0
  74. package/components/dashboard/useAnchoredFloatingPortalStyle.ts +71 -0
  75. package/components/dashboard/useDescriptionPopoverAfterMs.ts +220 -0
  76. package/components/dashboard/useKronoFocusLiveSeconds.ts +36 -0
  77. package/components/dashboard/useSmoothStopwatchMs.ts +25 -0
  78. package/lib/appShellHeaderClasses.ts +12 -0
  79. package/lib/backupCsvExport.test.ts +149 -0
  80. package/lib/backupCsvExport.ts +392 -0
  81. package/lib/changelogCopy.ts +34 -0
  82. package/lib/concurrentTaskStartPreference.ts +29 -0
  83. package/lib/dashboardClockFormat.ts +13 -0
  84. package/lib/dashboardColumnChrome.ts +3 -0
  85. package/lib/dashboardColumnHintsStorage.ts +57 -0
  86. package/lib/dashboardCopy.ts +1831 -0
  87. package/lib/dashboardDetachedUrlHintStorage.ts +24 -0
  88. package/lib/dashboardGitIdentityBannerStorage.ts +36 -0
  89. package/lib/dashboardLangStorage.ts +72 -0
  90. package/lib/dashboardQuickSearch.ts +476 -0
  91. package/lib/dashboardQuickSearchQuery.test.ts +63 -0
  92. package/lib/dashboardQuickSearchQuery.ts +179 -0
  93. package/lib/dashboardSessionNav.ts +33 -0
  94. package/lib/dashboardShortcuts.ts +268 -0
  95. package/lib/dashboardTimeZone.ts +91 -0
  96. package/lib/dashboardTourStorage.ts +68 -0
  97. package/lib/dataDir.test.ts +87 -0
  98. package/lib/dataDir.ts +83 -0
  99. package/lib/devDataPreferenceFile.ts +55 -0
  100. package/lib/devDataRuntimeInfo.ts +34 -0
  101. package/lib/formatIsoShort.test.ts +46 -0
  102. package/lib/formatIsoShort.ts +29 -0
  103. package/lib/generatedUserChangelog.ts +34 -0
  104. package/lib/gitlabIssueSearch.ts +8 -0
  105. package/lib/kronoFocusDurationHistory.ts +71 -0
  106. package/lib/kronoFocusRhythm.test.ts +130 -0
  107. package/lib/kronoFocusRhythm.ts +46 -0
  108. package/lib/kronoFocusTimerUrgency.test.ts +74 -0
  109. package/lib/kronoFocusTimerUrgency.ts +24 -0
  110. package/lib/kronosysApi.ts +143 -0
  111. package/lib/legacyEditorPayloadKeys.ts +52 -0
  112. package/lib/legacyKronoFocusStorageKeys.test.ts +29 -0
  113. package/lib/legacyKronoFocusStorageKeys.ts +32 -0
  114. package/lib/licensesCopy.ts +128 -0
  115. package/lib/openPlainTextInNewTab.ts +49 -0
  116. package/lib/readKronosysPackageVersion.ts +10 -0
  117. package/lib/reportingAggregate.test.ts +325 -0
  118. package/lib/reportingAggregate.ts +819 -0
  119. package/lib/reportingDatePresets.ts +41 -0
  120. package/lib/reportingMetricHelp.ts +430 -0
  121. package/lib/reportingNonFinalIndicators.test.ts +157 -0
  122. package/lib/reportingNonFinalIndicators.ts +102 -0
  123. package/lib/reportingStrings.ts +491 -0
  124. package/lib/reportingTagWeekBreakdown.test.ts +141 -0
  125. package/lib/reportingTagWeekBreakdown.ts +181 -0
  126. package/lib/reportingWeekLayout.test.ts +239 -0
  127. package/lib/reportingWeekLayout.ts +313 -0
  128. package/lib/sessionAssiduity.test.ts +25 -0
  129. package/lib/sessionAssiduity.ts +33 -0
  130. package/lib/sessionEndReason.ts +55 -0
  131. package/lib/sessionEndWarnings.test.ts +200 -0
  132. package/lib/sessionEndWarnings.ts +125 -0
  133. package/lib/sessionListMerge.test.ts +101 -0
  134. package/lib/sessionListMerge.ts +70 -0
  135. package/lib/sessionTaskSidebarStats.test.ts +24 -0
  136. package/lib/sessionTaskSidebarStats.ts +54 -0
  137. package/lib/settingsCopy.ts +1276 -0
  138. package/lib/taskParsing.test.ts +153 -0
  139. package/lib/taskParsing.ts +737 -0
  140. package/lib/theme.ts +15 -0
  141. package/lib/translucentButtonClasses.ts +34 -0
  142. package/lib/usageProfile.test.ts +84 -0
  143. package/lib/usageProfile.ts +52 -0
  144. package/lib/userGuideCopy.ts +464 -0
  145. package/lib/workspaceLocDefaults.ts +21 -0
  146. package/next-env.d.ts +6 -0
  147. package/next.config.ts +15 -0
  148. package/package.json +87 -0
  149. package/postcss.config.mjs +12 -0
  150. package/public/apple-icon.png +0 -0
  151. package/public/favicon.ico +0 -0
  152. package/public/file.svg +1 -0
  153. package/public/globe.svg +1 -0
  154. package/public/icon-192.png +0 -0
  155. package/public/icon-512.png +0 -0
  156. package/public/icon.png +0 -0
  157. package/public/next.svg +1 -0
  158. package/public/sw.js +13 -0
  159. package/public/traceback.png +0 -0
  160. package/public/vercel.svg +1 -0
  161. package/public/window.svg +1 -0
  162. package/server/actionDispatch.test.ts +723 -0
  163. package/server/actionDispatch.ts +1476 -0
  164. package/server/actionTaskSession.test.ts +713 -0
  165. package/server/actionTaskSession.ts +717 -0
  166. package/server/db.ts +42 -0
  167. package/server/defaultCfg.ts +87 -0
  168. package/server/gitlabTokenStore.ts +34 -0
  169. package/server/kronoFocusHydrate.test.ts +142 -0
  170. package/server/kronoFocusHydrate.ts +69 -0
  171. package/server/kronoFocusMigrate.test.ts +53 -0
  172. package/server/kronoFocusMigrate.ts +78 -0
  173. package/server/mainTimerHydrate.test.ts +65 -0
  174. package/server/mainTimerHydrate.ts +53 -0
  175. package/server/payloadStore.test.ts +78 -0
  176. package/server/payloadStore.ts +83 -0
  177. package/server/sessionWallHydrate.test.ts +46 -0
  178. package/server/sessionWallHydrate.ts +88 -0
  179. package/tsconfig.json +41 -0
@@ -0,0 +1,748 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import {
5
+ DndContext,
6
+ DragOverlay,
7
+ KeyboardSensor,
8
+ PointerSensor,
9
+ closestCenter,
10
+ useSensor,
11
+ useSensors,
12
+ type DragEndEvent,
13
+ type DragOverEvent,
14
+ type DragStartEvent,
15
+ } from "@dnd-kit/core";
16
+ import {
17
+ SortableContext,
18
+ arrayMove,
19
+ sortableKeyboardCoordinates,
20
+ useSortable,
21
+ verticalListSortingStrategy,
22
+ } from "@dnd-kit/sortable";
23
+ import { CSS } from "@dnd-kit/utilities";
24
+ import { ChevronDown, ChevronUp, GripVertical, Play, Pause, Trash2 } from "lucide-react";
25
+ import { formatStopwatchMs } from "@/lib/taskParsing";
26
+ import type { DashboardStrings } from "@/lib/dashboardCopy";
27
+ import {
28
+ TASK_SUBTASK_NEW_INLINE_CLASS,
29
+ TASK_SUBTASK_ROW_INPUT_CLASS,
30
+ } from "./taskFieldStyles";
31
+ import { useSmoothStopwatchDisplayMs } from "./useSmoothStopwatchMs";
32
+
33
+ type SubtaskDto = { id: string; title: string; done: boolean; durationMs?: number };
34
+
35
+ function SubtaskStopwatchDuration({
36
+ durationMs,
37
+ isTracking,
38
+ className,
39
+ }: Readonly<{
40
+ durationMs: number;
41
+ isTracking: boolean;
42
+ className: string;
43
+ }>) {
44
+ const base = Math.max(0, Math.floor(durationMs));
45
+ const live = useSmoothStopwatchDisplayMs(base, isTracking);
46
+ const text = formatStopwatchMs(live);
47
+ return (
48
+ <span className={className} title={text}>
49
+ {text}
50
+ </span>
51
+ );
52
+ }
53
+
54
+ const SUBTASK_DURATION_CELL_CLASS =
55
+ "min-w-[6.25rem] shrink-0 text-right text-[0.65rem] font-medium leading-none tracking-tight text-zinc-400 dark:text-zinc-500";
56
+
57
+ function SubtaskDurationCell({
58
+ durationMs,
59
+ isTracking,
60
+ enableSubtaskTimer,
61
+ subtaskDone,
62
+ subtasksReadOnly,
63
+ t,
64
+ }: Readonly<{
65
+ durationMs?: number;
66
+ isTracking: boolean;
67
+ enableSubtaskTimer: boolean;
68
+ subtaskDone: boolean;
69
+ subtasksReadOnly: boolean;
70
+ t: DashboardStrings;
71
+ }>) {
72
+ const subMs = Math.max(0, Math.floor(durationMs ?? 0));
73
+ if (subMs > 0 || isTracking) {
74
+ return (
75
+ <SubtaskStopwatchDuration
76
+ durationMs={subMs}
77
+ isTracking={isTracking}
78
+ className={`shrink-0 font-mono tabular-nums text-[0.7rem] tracking-tight ${isTracking ? "font-semibold text-emerald-700 dark:text-emerald-400" : "text-zinc-500"}`}
79
+ />
80
+ );
81
+ }
82
+ if (enableSubtaskTimer) {
83
+ return (
84
+ <span className={SUBTASK_DURATION_CELL_CLASS} title={t.subtaskDurationTooltipNotMeasured}>
85
+ {t.subtaskDurationNotMeasured}
86
+ </span>
87
+ );
88
+ }
89
+ if (subtasksReadOnly && subtaskDone) {
90
+ return (
91
+ <span className={SUBTASK_DURATION_CELL_CLASS} title={t.subtaskDurationTooltipNotShown}>
92
+ {t.subtaskDurationNotShown}
93
+ </span>
94
+ );
95
+ }
96
+ return (
97
+ <span className={SUBTASK_DURATION_CELL_CLASS} title={t.subtaskDurationTooltipNotRecorded}>
98
+ {t.subtaskDurationNotRecorded}
99
+ </span>
100
+ );
101
+ }
102
+
103
+ const SUBTASK_CHECKBOX_CLASS =
104
+ "h-4 w-4 shrink-0 cursor-pointer rounded border-zinc-400 bg-white text-violet-600 accent-violet-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-violet-500/70 dark:border-zinc-500 dark:bg-zinc-900 dark:text-violet-500 dark:accent-violet-500";
105
+
106
+ const SUBTASK_DONE_TITLE_CLASS =
107
+ "text-zinc-400 line-through decoration-zinc-500/[0.35] decoration-[1.5px]";
108
+
109
+ /** Survol : fond et contour violet bien visibles sur toute la ligne. */
110
+ const SUBTASK_ROW_HOVER_CLASS =
111
+ "rounded-md px-1.5 -mx-1.5 py-0.5 -my-0.5 transition-colors duration-150 hover:bg-violet-100 hover:ring-1 hover:ring-violet-400/75 dark:hover:bg-violet-950/65 dark:hover:ring-violet-500/55";
112
+
113
+ function SubtaskDragPreview({
114
+ st,
115
+ ord,
116
+ isTracking,
117
+ enableSubtaskTimer,
118
+ subtasksReadOnly,
119
+ showReorderGutter,
120
+ t,
121
+ }: Readonly<{
122
+ st: SubtaskDto;
123
+ ord: number;
124
+ isTracking: boolean;
125
+ enableSubtaskTimer: boolean;
126
+ subtasksReadOnly: boolean;
127
+ /** Colonne haut / bas (même emplacement qu’en liste, en lecture seule ici). */
128
+ showReorderGutter: boolean;
129
+ t: DashboardStrings;
130
+ }>) {
131
+ return (
132
+ <div className="pointer-events-none flex min-h-10 max-w-[min(100vw-2rem,36rem)] cursor-grabbing items-center gap-1.5 rounded-lg border-2 border-violet-600 bg-white px-2 py-1.5 shadow-xl ring-2 ring-violet-500/30 dark:border-violet-400 dark:bg-zinc-900 dark:ring-violet-400/25 sm:gap-2">
133
+ <GripVertical className="size-4 shrink-0 text-violet-600 dark:text-violet-400" aria-hidden />
134
+ {showReorderGutter ? (
135
+ <span
136
+ className="flex w-[1.125rem] shrink-0 flex-col items-center justify-center gap-0 opacity-40"
137
+ aria-hidden
138
+ >
139
+ <ChevronUp className="size-3.5" strokeWidth={2.5} />
140
+ <ChevronDown className="size-3.5" strokeWidth={2.5} />
141
+ </span>
142
+ ) : null}
143
+ <span className="w-6 shrink-0 text-right font-mono text-[0.7rem] font-semibold tabular-nums text-zinc-500 dark:text-zinc-400">
144
+ {ord}.
145
+ </span>
146
+ <input type="checkbox" readOnly checked={st.done} className={SUBTASK_CHECKBOX_CLASS} tabIndex={-1} />
147
+ <span
148
+ className={`min-w-0 flex-1 truncate text-left text-base font-medium leading-snug ${
149
+ st.done ? SUBTASK_DONE_TITLE_CLASS : "text-zinc-900 dark:text-zinc-100"
150
+ }`}
151
+ >
152
+ {st.title}
153
+ </span>
154
+ <SubtaskDurationCell
155
+ durationMs={st.durationMs}
156
+ isTracking={isTracking}
157
+ enableSubtaskTimer={enableSubtaskTimer}
158
+ subtaskDone={st.done}
159
+ subtasksReadOnly={subtasksReadOnly}
160
+ t={t}
161
+ />
162
+ </div>
163
+ );
164
+ }
165
+
166
+ type SortableRowProps = {
167
+ st: SubtaskDto;
168
+ index: number;
169
+ listLength: number;
170
+ showOrdinal: boolean;
171
+ activeDragId: string | null;
172
+ taskId: string;
173
+ enableSubtaskTimer: boolean;
174
+ activeSubtaskTimerId: string | undefined;
175
+ subtasksReadOnly: boolean;
176
+ t: DashboardStrings;
177
+ editingId: string | null;
178
+ editVal: string;
179
+ setEditingId: (id: string | null) => void;
180
+ setEditVal: (v: string) => void;
181
+ send: (body: Record<string, unknown>) => void;
182
+ dragLabel: string;
183
+ onReorderStep: (fromIndex: number, delta: -1 | 1) => void;
184
+ };
185
+
186
+ function SortableSubtaskRow({
187
+ st,
188
+ index,
189
+ showOrdinal,
190
+ activeDragId,
191
+ taskId,
192
+ enableSubtaskTimer,
193
+ activeSubtaskTimerId,
194
+ subtasksReadOnly,
195
+ t,
196
+ editingId,
197
+ editVal,
198
+ setEditingId,
199
+ setEditVal,
200
+ send,
201
+ dragLabel,
202
+ listLength,
203
+ onReorderStep,
204
+ }: SortableRowProps) {
205
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
206
+ id: st.id,
207
+ });
208
+ const isTracking =
209
+ enableSubtaskTimer && String(activeSubtaskTimerId ?? "").trim() === String(st.id).trim();
210
+ const ord = index + 1;
211
+ const style = {
212
+ transform: CSS.Transform.toString(transform),
213
+ transition,
214
+ };
215
+ const isDimmedSibling = Boolean(activeDragId && activeDragId !== st.id);
216
+
217
+ return (
218
+ <li
219
+ ref={setNodeRef}
220
+ style={style}
221
+ className={`relative z-[1] flex min-h-10 items-center gap-1.5 py-1.5 first:pt-0 sm:gap-2 ${SUBTASK_ROW_HOVER_CLASS} ${
222
+ isDimmedSibling ? "opacity-[0.22] saturate-[0.65]" : ""
223
+ } ${isDragging ? "z-10 opacity-0" : ""}`}
224
+ >
225
+ <button
226
+ type="button"
227
+ className="shrink-0 cursor-grab rounded p-0.5 text-zinc-500 touch-none hover:bg-violet-200/60 hover:text-violet-900 active:cursor-grabbing dark:text-zinc-400 dark:hover:bg-violet-900/50 dark:hover:text-violet-100"
228
+ title={dragLabel}
229
+ aria-label={dragLabel}
230
+ {...attributes}
231
+ {...listeners}
232
+ >
233
+ <GripVertical size={16} strokeWidth={2} aria-hidden />
234
+ </button>
235
+ {!subtasksReadOnly && listLength > 1 ? (
236
+ <span className="flex w-[1.125rem] shrink-0 flex-col items-center justify-center gap-0">
237
+ <button
238
+ type="button"
239
+ disabled={index === 0}
240
+ className="rounded p-0.5 text-zinc-600 hover:bg-violet-100 hover:text-violet-900 disabled:pointer-events-none disabled:opacity-25 dark:text-zinc-400 dark:hover:bg-violet-900/45 dark:hover:text-violet-100"
241
+ title={t.subtaskMoveUp}
242
+ aria-label={t.subtaskMoveUp}
243
+ onClick={() => onReorderStep(index, -1)}
244
+ >
245
+ <ChevronUp size={14} strokeWidth={2.5} aria-hidden />
246
+ </button>
247
+ <button
248
+ type="button"
249
+ disabled={index >= listLength - 1}
250
+ className="rounded p-0.5 text-zinc-600 hover:bg-violet-100 hover:text-violet-900 disabled:pointer-events-none disabled:opacity-25 dark:text-zinc-400 dark:hover:bg-violet-900/45 dark:hover:text-violet-100"
251
+ title={t.subtaskMoveDown}
252
+ aria-label={t.subtaskMoveDown}
253
+ onClick={() => onReorderStep(index, 1)}
254
+ >
255
+ <ChevronDown size={14} strokeWidth={2.5} aria-hidden />
256
+ </button>
257
+ </span>
258
+ ) : null}
259
+ <span
260
+ className="w-6 shrink-0 text-right font-mono text-[0.7rem] font-semibold tabular-nums text-zinc-500 dark:text-zinc-500"
261
+ aria-hidden={!showOrdinal}
262
+ >
263
+ {showOrdinal ? `${ord}.` : ""}
264
+ </span>
265
+ <input
266
+ type="checkbox"
267
+ className={
268
+ subtasksReadOnly
269
+ ? `${SUBTASK_CHECKBOX_CLASS} cursor-not-allowed opacity-50`
270
+ : SUBTASK_CHECKBOX_CLASS
271
+ }
272
+ checked={st.done}
273
+ disabled={subtasksReadOnly}
274
+ onChange={() => {
275
+ if (!subtasksReadOnly) {
276
+ void send({ type: "toggleSubtask", taskId, subtaskId: st.id });
277
+ }
278
+ }}
279
+ title={st.done ? t.subtaskMarkUndone : t.subtaskMarkDone}
280
+ aria-label={st.done ? t.subtaskMarkUndone : t.subtaskMarkDone}
281
+ />
282
+ {editingId === st.id && !subtasksReadOnly ? (
283
+ <input
284
+ autoFocus
285
+ className={`${TASK_SUBTASK_ROW_INPUT_CLASS} min-w-0 flex-1`}
286
+ value={editVal}
287
+ onChange={(e) => setEditVal(e.target.value)}
288
+ onBlur={() => {
289
+ if (editVal.trim() && editVal.trim() !== st.title) {
290
+ void send({
291
+ type: "updateSubtaskTitle",
292
+ taskId,
293
+ subtaskId: st.id,
294
+ title: editVal.trim(),
295
+ });
296
+ }
297
+ setEditingId(null);
298
+ }}
299
+ onKeyDown={(e) => {
300
+ if (e.key === "Enter") {
301
+ (e.target as HTMLInputElement).blur();
302
+ }
303
+ if (e.key === "Escape") {
304
+ setEditingId(null);
305
+ }
306
+ }}
307
+ aria-label={t.subtasksHeading}
308
+ />
309
+ ) : subtasksReadOnly ? (
310
+ <span
311
+ className={`min-w-0 flex-1 truncate text-left text-base font-medium leading-snug ${
312
+ st.done ? SUBTASK_DONE_TITLE_CLASS : "text-zinc-900 dark:text-zinc-200"
313
+ }`}
314
+ >
315
+ {st.title}
316
+ </span>
317
+ ) : (
318
+ <button
319
+ type="button"
320
+ className={`min-w-0 flex-1 truncate text-left text-base font-medium leading-snug outline-none hover:text-violet-800 focus-visible:ring-2 focus-visible:ring-violet-500/50 rounded-sm dark:hover:text-zinc-100 ${
321
+ st.done ? SUBTASK_DONE_TITLE_CLASS : "text-zinc-900 dark:text-zinc-200"
322
+ }`}
323
+ onClick={() => {
324
+ setEditingId(st.id);
325
+ setEditVal(st.title);
326
+ }}
327
+ >
328
+ {st.title}
329
+ </button>
330
+ )}
331
+ <SubtaskDurationCell
332
+ durationMs={st.durationMs}
333
+ isTracking={isTracking}
334
+ enableSubtaskTimer={enableSubtaskTimer}
335
+ subtaskDone={st.done}
336
+ subtasksReadOnly={subtasksReadOnly}
337
+ t={t}
338
+ />
339
+ <div className="flex shrink-0 items-center gap-0.5">
340
+ {enableSubtaskTimer && !st.done && !subtasksReadOnly && (
341
+ <button
342
+ type="button"
343
+ className={`rounded p-1.5 ${isTracking ? "bg-emerald-200 text-emerald-900 dark:bg-emerald-600/30 dark:text-emerald-300" : "text-zinc-600 hover:bg-zinc-200/90 dark:text-zinc-400 dark:hover:bg-zinc-800"}`}
344
+ title={isTracking ? t.subtaskTimerStop : t.subtaskTimerStart}
345
+ aria-label={isTracking ? t.subtaskTimerStop : t.subtaskTimerStart}
346
+ onClick={() =>
347
+ void send({
348
+ type: "setActiveSubtaskTimer",
349
+ taskId,
350
+ subtaskId: isTracking ? null : st.id,
351
+ })
352
+ }
353
+ >
354
+ {isTracking ? <Pause size={14} /> : <Play size={14} />}
355
+ </button>
356
+ )}
357
+ {!subtasksReadOnly ? (
358
+ <button
359
+ type="button"
360
+ className="rounded p-1.5 text-zinc-600 hover:bg-red-100 hover:text-red-800 dark:text-zinc-500 dark:hover:bg-red-950/50 dark:hover:text-red-400"
361
+ title={t.subtaskDelete}
362
+ aria-label={t.subtaskDelete}
363
+ onClick={() => void send({ type: "deleteSubtask", taskId, subtaskId: st.id })}
364
+ >
365
+ <Trash2 size={14} />
366
+ </button>
367
+ ) : null}
368
+ </div>
369
+ </li>
370
+ );
371
+ }
372
+
373
+ /** Ligne statique (pas de DnD) : lecture seule ou une seule sous-tâche. */
374
+ function StaticSubtaskRow({
375
+ st,
376
+ index,
377
+ taskId,
378
+ enableSubtaskTimer,
379
+ activeSubtaskTimerId,
380
+ subtasksReadOnly,
381
+ t,
382
+ editingId,
383
+ editVal,
384
+ setEditingId,
385
+ setEditVal,
386
+ send,
387
+ }: Omit<
388
+ SortableRowProps,
389
+ "activeDragId" | "showOrdinal" | "dragLabel" | "listLength" | "onReorderStep"
390
+ >) {
391
+ const isTracking =
392
+ enableSubtaskTimer && String(activeSubtaskTimerId ?? "").trim() === String(st.id).trim();
393
+ const ord = index + 1;
394
+ return (
395
+ <li
396
+ className={`relative flex min-h-10 items-center gap-1.5 py-1.5 first:pt-0 sm:gap-2 ${SUBTASK_ROW_HOVER_CLASS}`}
397
+ >
398
+ <span className="w-4 shrink-0 sm:w-[1.125rem]" aria-hidden />
399
+ <span
400
+ className="w-6 shrink-0 text-right font-mono text-[0.7rem] font-semibold tabular-nums text-zinc-500 dark:text-zinc-500"
401
+ aria-hidden
402
+ >
403
+ {ord}.
404
+ </span>
405
+ <input
406
+ type="checkbox"
407
+ className={
408
+ subtasksReadOnly
409
+ ? `${SUBTASK_CHECKBOX_CLASS} cursor-not-allowed opacity-50`
410
+ : SUBTASK_CHECKBOX_CLASS
411
+ }
412
+ checked={st.done}
413
+ disabled={subtasksReadOnly}
414
+ onChange={() => {
415
+ if (!subtasksReadOnly) {
416
+ void send({ type: "toggleSubtask", taskId, subtaskId: st.id });
417
+ }
418
+ }}
419
+ title={st.done ? t.subtaskMarkUndone : t.subtaskMarkDone}
420
+ aria-label={st.done ? t.subtaskMarkUndone : t.subtaskMarkDone}
421
+ />
422
+ {editingId === st.id && !subtasksReadOnly ? (
423
+ <input
424
+ autoFocus
425
+ className={`${TASK_SUBTASK_ROW_INPUT_CLASS} min-w-0 flex-1`}
426
+ value={editVal}
427
+ onChange={(e) => setEditVal(e.target.value)}
428
+ onBlur={() => {
429
+ if (editVal.trim() && editVal.trim() !== st.title) {
430
+ void send({
431
+ type: "updateSubtaskTitle",
432
+ taskId,
433
+ subtaskId: st.id,
434
+ title: editVal.trim(),
435
+ });
436
+ }
437
+ setEditingId(null);
438
+ }}
439
+ onKeyDown={(e) => {
440
+ if (e.key === "Enter") {
441
+ (e.target as HTMLInputElement).blur();
442
+ }
443
+ if (e.key === "Escape") {
444
+ setEditingId(null);
445
+ }
446
+ }}
447
+ aria-label={t.subtasksHeading}
448
+ />
449
+ ) : subtasksReadOnly ? (
450
+ <span
451
+ className={`min-w-0 flex-1 truncate text-left text-base font-medium leading-snug ${
452
+ st.done ? SUBTASK_DONE_TITLE_CLASS : "text-zinc-900 dark:text-zinc-200"
453
+ }`}
454
+ >
455
+ {st.title}
456
+ </span>
457
+ ) : (
458
+ <button
459
+ type="button"
460
+ className={`min-w-0 flex-1 truncate text-left text-base font-medium leading-snug outline-none hover:text-violet-800 focus-visible:ring-2 focus-visible:ring-violet-500/50 rounded-sm dark:hover:text-zinc-100 ${
461
+ st.done ? SUBTASK_DONE_TITLE_CLASS : "text-zinc-900 dark:text-zinc-200"
462
+ }`}
463
+ onClick={() => {
464
+ setEditingId(st.id);
465
+ setEditVal(st.title);
466
+ }}
467
+ >
468
+ {st.title}
469
+ </button>
470
+ )}
471
+ <SubtaskDurationCell
472
+ durationMs={st.durationMs}
473
+ isTracking={isTracking}
474
+ enableSubtaskTimer={enableSubtaskTimer}
475
+ subtaskDone={st.done}
476
+ subtasksReadOnly={subtasksReadOnly}
477
+ t={t}
478
+ />
479
+ <div className="flex shrink-0 items-center gap-0.5">
480
+ {enableSubtaskTimer && !st.done && !subtasksReadOnly && (
481
+ <button
482
+ type="button"
483
+ className={`rounded p-1.5 ${isTracking ? "bg-emerald-200 text-emerald-900 dark:bg-emerald-600/30 dark:text-emerald-300" : "text-zinc-600 hover:bg-zinc-200/90 dark:text-zinc-400 dark:hover:bg-zinc-800"}`}
484
+ title={isTracking ? t.subtaskTimerStop : t.subtaskTimerStart}
485
+ aria-label={isTracking ? t.subtaskTimerStop : t.subtaskTimerStart}
486
+ onClick={() =>
487
+ void send({
488
+ type: "setActiveSubtaskTimer",
489
+ taskId,
490
+ subtaskId: isTracking ? null : st.id,
491
+ })
492
+ }
493
+ >
494
+ {isTracking ? <Pause size={14} /> : <Play size={14} />}
495
+ </button>
496
+ )}
497
+ {!subtasksReadOnly ? (
498
+ <button
499
+ type="button"
500
+ className="rounded p-1.5 text-zinc-600 hover:bg-red-100 hover:text-red-800 dark:text-zinc-500 dark:hover:bg-red-950/50 dark:hover:text-red-400"
501
+ title={t.subtaskDelete}
502
+ aria-label={t.subtaskDelete}
503
+ onClick={() => void send({ type: "deleteSubtask", taskId, subtaskId: st.id })}
504
+ >
505
+ <Trash2 size={14} />
506
+ </button>
507
+ ) : null}
508
+ </div>
509
+ </li>
510
+ );
511
+ }
512
+
513
+ export function TaskSubtasksBlock({
514
+ taskId,
515
+ sessionId,
516
+ subtasks,
517
+ enableSubtaskTimer = false,
518
+ activeSubtaskTimerId,
519
+ allowAddSubtasks = false,
520
+ subtasksReadOnly = false,
521
+ t,
522
+ post,
523
+ }: {
524
+ taskId: string;
525
+ sessionId: string | null | undefined;
526
+ subtasks: SubtaskDto[] | undefined;
527
+ enableSubtaskTimer?: boolean;
528
+ activeSubtaskTimerId?: string;
529
+ /** Affiche la ligne « nouvelle sous-tâche » (ex. tâche active en session live, non terminée). */
530
+ allowAddSubtasks?: boolean;
531
+ /** Tâche parente terminée : pas de cocher, renommer, supprimer ni ajouter. */
532
+ subtasksReadOnly?: boolean;
533
+ t: DashboardStrings;
534
+ post: (body: Record<string, unknown>) => Promise<void>;
535
+ }) {
536
+ const [draft, setDraft] = useState("");
537
+ const [editingId, setEditingId] = useState<string | null>(null);
538
+ const [editVal, setEditVal] = useState("");
539
+ const [activeDragId, setActiveDragId] = useState<string | null>(null);
540
+ const [activeSubtask, setActiveSubtask] = useState<SubtaskDto | null>(null);
541
+ const [activeOverlayOrd, setActiveOverlayOrd] = useState(1);
542
+ const list = subtasks ?? [];
543
+
544
+ const sensors = useSensors(
545
+ useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
546
+ useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
547
+ );
548
+
549
+ useEffect(() => {
550
+ if (subtasksReadOnly) {
551
+ setEditingId(null);
552
+ setDraft("");
553
+ }
554
+ }, [subtasksReadOnly]);
555
+
556
+ const send = async (body: Record<string, unknown>) => {
557
+ await post({
558
+ ...body,
559
+ ...(sessionId ? { sessionId } : {}),
560
+ });
561
+ };
562
+
563
+ const hasSubtasks = list.length > 0;
564
+ const showAddRow = allowAddSubtasks && !subtasksReadOnly;
565
+ const canReorder = !subtasksReadOnly && list.length > 1;
566
+ if (!hasSubtasks && !showAddRow) {
567
+ return null;
568
+ }
569
+
570
+ const sortableIds = list.map((s) => s.id);
571
+
572
+ const handleDragStart = (event: DragStartEvent) => {
573
+ const id = String(event.active.id);
574
+ setActiveDragId(id);
575
+ const idx = list.findIndex((s) => s.id === id);
576
+ const st = list[idx];
577
+ if (st) {
578
+ setActiveSubtask(st);
579
+ setActiveOverlayOrd(idx + 1);
580
+ }
581
+ };
582
+
583
+ const handleDragOver = (event: DragOverEvent) => {
584
+ const { over } = event;
585
+ if (!over) {
586
+ return;
587
+ }
588
+ const idx = list.findIndex((s) => s.id === String(over.id));
589
+ if (idx >= 0) {
590
+ setActiveOverlayOrd(idx + 1);
591
+ }
592
+ };
593
+
594
+ const handleDragEnd = (event: DragEndEvent) => {
595
+ const { active, over } = event;
596
+ setActiveDragId(null);
597
+ setActiveSubtask(null);
598
+ if (!over || active.id === over.id) {
599
+ return;
600
+ }
601
+ const oldIndex = list.findIndex((s) => s.id === active.id);
602
+ const newIndex = list.findIndex((s) => s.id === over.id);
603
+ if (oldIndex < 0 || newIndex < 0) {
604
+ return;
605
+ }
606
+ const next = arrayMove(list, oldIndex, newIndex);
607
+ void send({
608
+ type: "reorderSubtasks",
609
+ taskId,
610
+ orderedSubtaskIds: next.map((s) => s.id),
611
+ });
612
+ };
613
+
614
+ const handleDragCancel = () => {
615
+ setActiveDragId(null);
616
+ setActiveSubtask(null);
617
+ };
618
+
619
+ const showOrdinal = !activeDragId;
620
+
621
+ const handleReorderStep = (fromIndex: number, delta: -1 | 1) => {
622
+ const toIndex = fromIndex + delta;
623
+ if (toIndex < 0 || toIndex >= list.length) {
624
+ return;
625
+ }
626
+ const next = arrayMove(list, fromIndex, toIndex);
627
+ void send({
628
+ type: "reorderSubtasks",
629
+ taskId,
630
+ orderedSubtaskIds: next.map((s) => s.id),
631
+ });
632
+ };
633
+
634
+ const listSection = canReorder ? (
635
+ <DndContext
636
+ sensors={sensors}
637
+ collisionDetection={closestCenter}
638
+ onDragStart={handleDragStart}
639
+ onDragOver={handleDragOver}
640
+ onDragEnd={handleDragEnd}
641
+ onDragCancel={handleDragCancel}
642
+ >
643
+ <div
644
+ className={`relative rounded-lg px-0.5 py-0.5 transition-[background-color,box-shadow] duration-200 ${
645
+ activeDragId
646
+ ? "bg-zinc-500/20 shadow-inner ring-1 ring-zinc-500/45 dark:bg-black/55 dark:ring-zinc-600/55"
647
+ : ""
648
+ }`}
649
+ >
650
+ <SortableContext items={sortableIds} strategy={verticalListSortingStrategy}>
651
+ <ul className="divide-y divide-zinc-200 dark:divide-zinc-800/90">
652
+ {list.map((st, index) => (
653
+ <SortableSubtaskRow
654
+ key={st.id}
655
+ st={st}
656
+ index={index}
657
+ showOrdinal={showOrdinal}
658
+ activeDragId={activeDragId}
659
+ taskId={taskId}
660
+ enableSubtaskTimer={enableSubtaskTimer}
661
+ activeSubtaskTimerId={activeSubtaskTimerId}
662
+ subtasksReadOnly={subtasksReadOnly}
663
+ t={t}
664
+ editingId={editingId}
665
+ editVal={editVal}
666
+ setEditingId={setEditingId}
667
+ setEditVal={setEditVal}
668
+ send={send}
669
+ dragLabel={t.subtaskDragReorderHandle}
670
+ listLength={list.length}
671
+ onReorderStep={handleReorderStep}
672
+ />
673
+ ))}
674
+ </ul>
675
+ </SortableContext>
676
+ </div>
677
+ <DragOverlay dropAnimation={{ duration: 220, easing: "cubic-bezier(0.22, 1, 0.36, 1)" }}>
678
+ {activeSubtask ? (
679
+ <SubtaskDragPreview
680
+ st={activeSubtask}
681
+ ord={activeOverlayOrd}
682
+ isTracking={
683
+ enableSubtaskTimer &&
684
+ String(activeSubtaskTimerId ?? "").trim() === String(activeSubtask.id).trim()
685
+ }
686
+ enableSubtaskTimer={enableSubtaskTimer}
687
+ subtasksReadOnly={subtasksReadOnly}
688
+ showReorderGutter
689
+ t={t}
690
+ />
691
+ ) : null}
692
+ </DragOverlay>
693
+ </DndContext>
694
+ ) : (
695
+ <ul className="divide-y divide-zinc-200 dark:divide-zinc-800/90">
696
+ {list.map((st, index) => (
697
+ <StaticSubtaskRow
698
+ key={st.id}
699
+ st={st}
700
+ index={index}
701
+ taskId={taskId}
702
+ enableSubtaskTimer={enableSubtaskTimer}
703
+ activeSubtaskTimerId={activeSubtaskTimerId}
704
+ subtasksReadOnly={subtasksReadOnly}
705
+ t={t}
706
+ editingId={editingId}
707
+ editVal={editVal}
708
+ setEditingId={setEditingId}
709
+ setEditVal={setEditVal}
710
+ send={send}
711
+ />
712
+ ))}
713
+ </ul>
714
+ );
715
+
716
+ return (
717
+ <div className="mt-4 border-l border-zinc-300 pl-3 ml-0.5 dark:border-zinc-700/55">
718
+ {hasSubtasks ? (
719
+ <div className="mb-1.5 text-[0.65rem] font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-500">
720
+ {t.subtasksHeading}
721
+ </div>
722
+ ) : null}
723
+ {hasSubtasks ? listSection : null}
724
+ {showAddRow ? (
725
+ <div
726
+ className={
727
+ hasSubtasks ? "mt-2 border-t border-zinc-200 pt-2 dark:border-zinc-800/80" : undefined
728
+ }
729
+ >
730
+ <input
731
+ className={TASK_SUBTASK_NEW_INLINE_CLASS}
732
+ placeholder={t.addSubtaskPlaceholder}
733
+ value={draft}
734
+ onChange={(e) => setDraft(e.target.value)}
735
+ onKeyDown={(e) => {
736
+ if (e.key === "Enter" && draft.trim()) {
737
+ e.preventDefault();
738
+ void send({ type: "addSubtask", taskId, title: draft.trim() });
739
+ setDraft("");
740
+ }
741
+ }}
742
+ aria-label={t.addSubtaskPlaceholder}
743
+ />
744
+ </div>
745
+ ) : null}
746
+ </div>
747
+ );
748
+ }