@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,832 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useRef, useState } from "react";
4
+ import {
5
+ Timer,
6
+ Play,
7
+ Pause,
8
+ CheckCircle2,
9
+ Trash2,
10
+ GitCommit,
11
+ } from "lucide-react";
12
+ import type { DashboardStrings, Lang } from "@/lib/dashboardCopy";
13
+ import { formatIsoInstantShort } from "@/lib/formatIsoShort";
14
+ import { DEFAULT_DASHBOARD_TIME_ZONE } from "@/lib/dashboardTimeZone";
15
+ import {
16
+ filterTaskTagsForProject,
17
+ formatDuration,
18
+ formatStopwatchMs,
19
+ formatProjectDisplay,
20
+ mergeTagsForDisplay,
21
+ normalizeTagKey,
22
+ parseTaskWithAutoTags,
23
+ resolveProjectForTaskUpdate,
24
+ taskTitleEditBaseline,
25
+ taskTitleForDisplay,
26
+ } from "@/lib/taskParsing";
27
+ import { TagPills } from "./TagPills";
28
+ import { tbEmeraldIcon } from "@/lib/translucentButtonClasses";
29
+ import {
30
+ PROJECT_CHIP_APPLIED_CLASS,
31
+ TASK_ACTIVE_TITLE_EDIT_INPUT_CLASS,
32
+ TASK_ACTIVE_TITLE_READ_CLASS,
33
+ } from "./taskFieldStyles";
34
+ import { TaskSubtasksBlock } from "./TaskSubtasksBlock";
35
+ import { DashboardConfirmModal } from "./DashboardSimpleModal";
36
+ import { useSmoothStopwatchDisplayMs } from "./useSmoothStopwatchMs";
37
+ import {
38
+ KronosysDatetimePopoverField,
39
+ formatDatetimeLocalValue,
40
+ } from "./KronosysDatetimePopoverField";
41
+
42
+ const FINISH_TASK_SUBTASKS_WARN_STORAGE_KEY =
43
+ "kronosys_finish_task_subtasks_warn_dismiss_v1";
44
+
45
+ function readFinishSubtasksWarnDismissed(): boolean {
46
+ try {
47
+ return (
48
+ globalThis.localStorage?.getItem(
49
+ FINISH_TASK_SUBTASKS_WARN_STORAGE_KEY,
50
+ ) === "1"
51
+ );
52
+ } catch {
53
+ return false;
54
+ }
55
+ }
56
+
57
+ function persistFinishSubtasksWarnDismissed(): void {
58
+ try {
59
+ globalThis.localStorage?.setItem(
60
+ FINISH_TASK_SUBTASKS_WARN_STORAGE_KEY,
61
+ "1",
62
+ );
63
+ } catch {
64
+ /* quota / navigation privée */
65
+ }
66
+ }
67
+
68
+ const DONE_TITLE_READ_CLASS =
69
+ "text-zinc-500 line-through decoration-zinc-400/[0.35] decoration-[1.5px] dark:text-zinc-300 dark:decoration-zinc-500/[0.35]";
70
+
71
+ const DONE_TITLE_PLAIN_CLASS =
72
+ "text-zinc-500 line-through decoration-zinc-400/[0.35] decoration-[1.5px] dark:text-zinc-400 dark:decoration-zinc-500/[0.35]";
73
+
74
+ const TASK_CARD_SHELL_CLASS =
75
+ "rounded-xl border border-zinc-200 bg-zinc-100/80 p-5 dark:border-zinc-800 dark:bg-black/20 sm:p-6";
76
+
77
+ /** Tâches en cours : pas de cadre, intégration dans le flux. */
78
+ const TASK_CARD_SHELL_PLAIN_CLASS =
79
+ "rounded-none border-0 bg-transparent py-4 shadow-none first:pt-2 dark:bg-transparent";
80
+
81
+ export type TaskSessionLiveCardTask = {
82
+ id: string;
83
+ name: string;
84
+ durationMs: number;
85
+ isDone: boolean;
86
+ /** Début du suivi ou de l’entrée passée (ISO 8601), si connu. */
87
+ startTime?: string;
88
+ /** Fin du suivi ou de l’entrée passée (ISO 8601), si connu. */
89
+ endTime?: string;
90
+ tags?: string[];
91
+ project?: string | null;
92
+ subtasks?: Array<{
93
+ id: string;
94
+ title: string;
95
+ done: boolean;
96
+ durationMs?: number;
97
+ }>;
98
+ activeSubtaskTimerId?: string;
99
+ };
100
+
101
+ function TaskTimingFieldRow({
102
+ label,
103
+ displayValue,
104
+ editable,
105
+ draftValue,
106
+ onDraftChange,
107
+ onDraftBlur,
108
+ lang,
109
+ t,
110
+ }: {
111
+ label: string;
112
+ displayValue: string | null;
113
+ editable: boolean;
114
+ draftValue: string;
115
+ onDraftChange: (next: string) => void;
116
+ onDraftBlur: () => void;
117
+ lang: Lang;
118
+ t: DashboardStrings;
119
+ }) {
120
+ if (!editable && !displayValue) {
121
+ return null;
122
+ }
123
+ return (
124
+ <div className="mt-2 flex min-w-0 flex-wrap items-center gap-2 text-[0.7rem] leading-snug text-zinc-600 dark:text-zinc-400">
125
+ <span className="text-[0.65rem] font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-500">
126
+ {label}
127
+ </span>
128
+ {editable ? (
129
+ <>
130
+ <KronosysDatetimePopoverField
131
+ value={draftValue}
132
+ onChange={onDraftChange}
133
+ onBlur={onDraftBlur}
134
+ aria-label={label}
135
+ lang={lang}
136
+ t={t}
137
+ />
138
+ </>
139
+ ) : (
140
+ <span className="min-w-0 tabular-nums text-zinc-800 dark:text-zinc-200">
141
+ {displayValue}
142
+ </span>
143
+ )}
144
+ </div>
145
+ );
146
+ }
147
+
148
+ function resolvedDoneTaskDurationMinutes(
149
+ task: TaskSessionLiveCardTask,
150
+ ): number | null {
151
+ const ms = task.durationMs;
152
+ if (typeof ms === "number" && Number.isFinite(ms) && ms > 0) {
153
+ return ms / 60000;
154
+ }
155
+ const st =
156
+ typeof task.startTime === "string"
157
+ ? new Date(task.startTime).getTime()
158
+ : Number.NaN;
159
+ const en =
160
+ typeof task.endTime === "string"
161
+ ? new Date(task.endTime).getTime()
162
+ : Number.NaN;
163
+ if (Number.isFinite(st) && Number.isFinite(en) && en >= st) {
164
+ return (en - st) / 60000;
165
+ }
166
+ return null;
167
+ }
168
+
169
+ export function TaskSessionLiveCard({
170
+ task,
171
+ lang,
172
+ displayTimeZone = DEFAULT_DASHBOARD_TIME_ZONE,
173
+ use24HourClock = true,
174
+ isInspecting,
175
+ inspectingId,
176
+ knownTags,
177
+ knownProjects,
178
+ post,
179
+ t,
180
+ confirmDeleteTask,
181
+ kronoFocusIsRunningOrPaused,
182
+ showKronoFocusTaskActions = true,
183
+ startKronoFocusFromTask,
184
+ pausePlayMode,
185
+ onPausePlay,
186
+ anchorId,
187
+ variant = "card",
188
+ allowTaskStartTimeEdit = true,
189
+ allowTaskEndTimeEdit = true,
190
+ }: {
191
+ task: TaskSessionLiveCardTask;
192
+ lang: Lang;
193
+ displayTimeZone?: string;
194
+ use24HourClock?: boolean;
195
+ isInspecting: boolean;
196
+ inspectingId: string | null;
197
+ knownTags: string[];
198
+ knownProjects: string[];
199
+ post: (body: Record<string, unknown>) => Promise<void>;
200
+ t: DashboardStrings;
201
+ confirmDeleteTask: (taskId: string) => void;
202
+ kronoFocusIsRunningOrPaused: boolean;
203
+ /** Bouton « démarrer le KronoFocus » sur les tâches non terminées (paramètre tableau de bord). */
204
+ showKronoFocusTaskActions?: boolean;
205
+ startKronoFocusFromTask: () => void;
206
+ /** `null` : tâche terminée (pas de pause / reprise). */
207
+ pausePlayMode: "pause" | "resume" | null;
208
+ onPausePlay: () => void;
209
+ /** Ex. `kronosys-active-task-<id>` pour ancre KronoFocus. */
210
+ anchorId?: string;
211
+ /** `plain` : sans bordure ni fond (ex. tâches au minuteur). */
212
+ variant?: "card" | "plain";
213
+ /** Option `dashboardAllowTaskStartTimeEdit` : correction du `startTime`. */
214
+ allowTaskStartTimeEdit?: boolean;
215
+ /** Option `dashboardAllowTaskEndTimeEdit` : correction du `endTime` (tâches terminées). */
216
+ allowTaskEndTimeEdit?: boolean;
217
+ }) {
218
+ const [shouldCommit, setShouldCommit] = useState(false);
219
+ const [finishOpenSubtasksWarnOpen, setFinishOpenSubtasksWarnOpen] =
220
+ useState(false);
221
+ const [dontRemindOpenSubtasksFinish, setDontRemindOpenSubtasksFinish] =
222
+ useState(false);
223
+ const [titleEditing, setTitleEditing] = useState(false);
224
+ const [titleDraft, setTitleDraft] = useState("");
225
+ const [taskStartDraft, setTaskStartDraft] = useState("");
226
+ const [optimisticLiveBaseMs, setOptimisticLiveBaseMs] = useState<
227
+ number | null
228
+ >(null);
229
+ const [derivedLiveBaseMs, setDerivedLiveBaseMs] = useState<number | null>(
230
+ null,
231
+ );
232
+ const [taskEndDraft, setTaskEndDraft] = useState("");
233
+ const [optimisticDoneDurationMin, setOptimisticDoneDurationMin] = useState<
234
+ number | null
235
+ >(null);
236
+ const titleInputRef = useRef<HTMLInputElement>(null);
237
+ const skipTitleBlurCommitRef = useRef(false);
238
+
239
+ const mergedTags = mergeTagsForDisplay(task.name, task.tags);
240
+ const trimmedProject = task.project?.trim() ?? "";
241
+ /** Affichage lecture seule : sans `#tags` ni `@projet` (pastilles en dessous). */
242
+ const titleDisplay = taskTitleForDisplay(task.name);
243
+ /** Valeur initiale à l’édition : libellé seul ; on peut toujours saisir `#tag` ou `@projet` dans le champ. */
244
+ const titleDraftSource = taskTitleEditBaseline(task.name);
245
+ const isDone = task.isDone === true;
246
+ const smoothTaskTimer = !isDone && !isInspecting && pausePlayMode === "pause";
247
+ const taskStopwatchBaseMs =
248
+ optimisticLiveBaseMs !== null
249
+ ? optimisticLiveBaseMs
250
+ : derivedLiveBaseMs !== null
251
+ ? derivedLiveBaseMs
252
+ : Math.max(0, task.durationMs ?? 0);
253
+ const liveTaskStopwatchMs = useSmoothStopwatchDisplayMs(
254
+ taskStopwatchBaseMs,
255
+ smoothTaskTimer,
256
+ );
257
+
258
+ const startFmt =
259
+ typeof task.startTime === "string"
260
+ ? formatIsoInstantShort(
261
+ task.startTime,
262
+ lang,
263
+ displayTimeZone,
264
+ use24HourClock,
265
+ )
266
+ : null;
267
+ const endFmt =
268
+ typeof task.endTime === "string"
269
+ ? formatIsoInstantShort(
270
+ task.endTime,
271
+ lang,
272
+ displayTimeZone,
273
+ use24HourClock,
274
+ )
275
+ : null;
276
+ const durationMinResolved = isDone
277
+ ? optimisticDoneDurationMin !== null
278
+ ? optimisticDoneDurationMin
279
+ : resolvedDoneTaskDurationMinutes(task)
280
+ : null;
281
+ const durationLabel =
282
+ durationMinResolved !== null ? formatDuration(durationMinResolved) : null;
283
+ const showDoneTimingRow =
284
+ isDone && (startFmt !== null || endFmt !== null || durationLabel !== null);
285
+ const canEditTaskStartTime =
286
+ allowTaskStartTimeEdit &&
287
+ typeof task.startTime === "string" &&
288
+ task.startTime.trim() !== "";
289
+ const canEditTaskEndTime =
290
+ allowTaskEndTimeEdit &&
291
+ isDone &&
292
+ typeof task.startTime === "string" &&
293
+ task.startTime.trim() !== "" &&
294
+ typeof task.endTime === "string" &&
295
+ task.endTime.trim() !== "";
296
+ const doneTimingAria = [
297
+ startFmt ? `${t.archiveTaskStartLabel} ${startFmt}` : "",
298
+ endFmt ? `${t.archiveTaskEndLabel} ${endFmt}` : "",
299
+ durationLabel ? `${t.taskTimingDurationLabel} ${durationLabel}` : "",
300
+ ]
301
+ .filter(Boolean)
302
+ .join(", ");
303
+
304
+ useEffect(() => {
305
+ setTitleEditing(false);
306
+ }, [task.id]);
307
+
308
+ useEffect(() => {
309
+ const st = typeof task.startTime === "string" ? task.startTime.trim() : "";
310
+ const parsed = st ? new Date(st) : null;
311
+ if (!parsed || Number.isNaN(parsed.getTime())) {
312
+ setTaskStartDraft("");
313
+ return;
314
+ }
315
+ setTaskStartDraft(formatDatetimeLocalValue(parsed));
316
+ }, [task.id, task.startTime]);
317
+
318
+ useEffect(() => {
319
+ const et = typeof task.endTime === "string" ? task.endTime.trim() : "";
320
+ const parsed = et ? new Date(et) : null;
321
+ if (!parsed || Number.isNaN(parsed.getTime())) {
322
+ setTaskEndDraft("");
323
+ return;
324
+ }
325
+ setTaskEndDraft(formatDatetimeLocalValue(parsed));
326
+ }, [task.id, task.endTime]);
327
+
328
+ useEffect(() => {
329
+ setOptimisticLiveBaseMs(null);
330
+ }, [task.id, task.durationMs]);
331
+
332
+ useEffect(() => {
333
+ setOptimisticDoneDurationMin(null);
334
+ }, [task.id, task.durationMs, task.endTime]);
335
+
336
+ useEffect(() => {
337
+ if (!smoothTaskTimer) {
338
+ setDerivedLiveBaseMs(null);
339
+ return;
340
+ }
341
+ const startMs =
342
+ typeof task.startTime === "string"
343
+ ? Date.parse(task.startTime)
344
+ : Number.NaN;
345
+ if (!Number.isFinite(startMs)) {
346
+ setDerivedLiveBaseMs(null);
347
+ return;
348
+ }
349
+ setDerivedLiveBaseMs(Math.max(0, Date.now() - startMs));
350
+ }, [smoothTaskTimer, task.startTime, task.id]);
351
+
352
+ useEffect(() => {
353
+ if (titleEditing && titleInputRef.current) {
354
+ const el = titleInputRef.current;
355
+ el.focus();
356
+ el.select();
357
+ }
358
+ }, [titleEditing]);
359
+
360
+ const beginEditTitle = useCallback(() => {
361
+ setTitleDraft(titleDraftSource);
362
+ setTitleEditing(true);
363
+ }, [titleDraftSource]);
364
+
365
+ const commitTitleEdit = useCallback(() => {
366
+ const trimmed = titleDraft.trim();
367
+ setTitleEditing(false);
368
+ if (trimmed === "") {
369
+ return;
370
+ }
371
+ if (trimmed === titleDraftSource) {
372
+ return;
373
+ }
374
+ const parsed = parseTaskWithAutoTags(trimmed);
375
+ const nextName = parsed.name.trim() || parsed.tags[0] || trimmed;
376
+ const mergedTagsNext = mergeTagsForDisplay(trimmed, mergedTags);
377
+ const projUp = resolveProjectForTaskUpdate(trimmed);
378
+ const resolvedProject =
379
+ projUp !== undefined ? projUp : (task.project ?? null);
380
+ const tagsFiltered = filterTaskTagsForProject(
381
+ mergedTagsNext,
382
+ resolvedProject,
383
+ );
384
+ const body: Record<string, unknown> = {
385
+ type: "updateTask",
386
+ taskId: task.id,
387
+ name: nextName,
388
+ tags: tagsFiltered,
389
+ };
390
+ if (inspectingId) {
391
+ body.sessionId = inspectingId;
392
+ }
393
+ if (projUp !== undefined) {
394
+ body.project = projUp;
395
+ }
396
+ void post(body);
397
+ }, [titleDraft, titleDraftSource, task.id, mergedTags, inspectingId, post]);
398
+
399
+ const cancelTitleEdit = useCallback(() => {
400
+ skipTitleBlurCommitRef.current = true;
401
+ setTitleDraft(titleDraftSource);
402
+ setTitleEditing(false);
403
+ }, [titleDraftSource]);
404
+
405
+ const runFinishTask = useCallback(() => {
406
+ void post({
407
+ type: "finishTask",
408
+ taskId: task.id,
409
+ shouldCommit,
410
+ });
411
+ }, [post, task.id, shouldCommit]);
412
+
413
+ const requestFinishTask = useCallback(() => {
414
+ if (isDone) {
415
+ return;
416
+ }
417
+ const subs = task.subtasks;
418
+ const openCount = Array.isArray(subs)
419
+ ? subs.filter((s) => !s.done).length
420
+ : 0;
421
+ if (openCount > 0 && !readFinishSubtasksWarnDismissed()) {
422
+ setDontRemindOpenSubtasksFinish(false);
423
+ setFinishOpenSubtasksWarnOpen(true);
424
+ return;
425
+ }
426
+ runFinishTask();
427
+ }, [isDone, task.subtasks, runFinishTask]);
428
+
429
+ const applyTaskStartTimeEdit = useCallback(() => {
430
+ if (!canEditTaskStartTime) {
431
+ return;
432
+ }
433
+ const nextMs = Date.parse(taskStartDraft);
434
+ if (!Number.isFinite(nextMs)) {
435
+ return;
436
+ }
437
+ const nextIso = new Date(nextMs).toISOString();
438
+ if (nextIso === task.startTime) {
439
+ return;
440
+ }
441
+ if (smoothTaskTimer) {
442
+ setOptimisticLiveBaseMs(Math.max(0, Date.now() - nextMs));
443
+ }
444
+ const body: Record<string, unknown> = {
445
+ type: "setTaskStartTime",
446
+ taskId: task.id,
447
+ startTime: nextIso,
448
+ };
449
+ if (inspectingId) {
450
+ body.sessionId = inspectingId;
451
+ }
452
+ void post(body);
453
+ }, [
454
+ canEditTaskStartTime,
455
+ taskStartDraft,
456
+ task.startTime,
457
+ task.id,
458
+ inspectingId,
459
+ post,
460
+ smoothTaskTimer,
461
+ ]);
462
+
463
+ const applyTaskEndTimeEdit = useCallback(() => {
464
+ if (!canEditTaskEndTime) {
465
+ return;
466
+ }
467
+ const endMs = Date.parse(taskEndDraft);
468
+ if (!Number.isFinite(endMs)) {
469
+ return;
470
+ }
471
+ const startMs =
472
+ typeof task.startTime === "string"
473
+ ? Date.parse(task.startTime)
474
+ : Number.NaN;
475
+ if (!Number.isFinite(startMs) || endMs < startMs) {
476
+ return;
477
+ }
478
+ const nextIso = new Date(endMs).toISOString();
479
+ if (nextIso === task.endTime) {
480
+ return;
481
+ }
482
+ setOptimisticDoneDurationMin((endMs - startMs) / 60000);
483
+ const body: Record<string, unknown> = {
484
+ type: "setTaskEndTime",
485
+ taskId: task.id,
486
+ endTime: nextIso,
487
+ };
488
+ if (inspectingId) {
489
+ body.sessionId = inspectingId;
490
+ }
491
+ void post(body);
492
+ }, [
493
+ canEditTaskEndTime,
494
+ taskEndDraft,
495
+ task.startTime,
496
+ task.endTime,
497
+ task.id,
498
+ inspectingId,
499
+ post,
500
+ ]);
501
+
502
+ const shellBase =
503
+ variant === "plain" ? TASK_CARD_SHELL_PLAIN_CLASS : TASK_CARD_SHELL_CLASS;
504
+ const shellClass = anchorId ? `scroll-mt-24 ${shellBase}` : shellBase;
505
+
506
+ const titleUnderline =
507
+ variant === "plain"
508
+ ? "border-b border-zinc-300 dark:border-zinc-600"
509
+ : "border-b border-zinc-300 bg-transparent dark:border-zinc-600";
510
+ /** Carte claire : pas de `text-white` (illisible sur zinc-100). */
511
+ const titleActiveClass =
512
+ variant === "plain"
513
+ ? "text-zinc-900 dark:text-zinc-100"
514
+ : "text-zinc-950 dark:text-zinc-50";
515
+ const titleDoneClass =
516
+ variant === "plain" ? DONE_TITLE_PLAIN_CLASS : DONE_TITLE_READ_CLASS;
517
+ const titleHitClass =
518
+ variant === "plain"
519
+ ? "hover:bg-zinc-200/60 dark:hover:bg-zinc-800/40"
520
+ : "hover:bg-zinc-200/70 dark:hover:bg-zinc-800/40";
521
+
522
+ return (
523
+ <>
524
+ <div id={anchorId} className={shellClass}>
525
+ {trimmedProject ? (
526
+ <div className="mb-1.5 flex min-w-0 justify-start sm:mb-2">
527
+ <span
528
+ className={`${PROJECT_CHIP_APPLIED_CLASS} inline-flex items-center gap-1 max-w-full truncate pr-1`}
529
+ title={formatProjectDisplay(trimmedProject)}
530
+ >
531
+ <span className="truncate">
532
+ {formatProjectDisplay(trimmedProject)}
533
+ </span>
534
+ <button
535
+ type="button"
536
+ className="flex h-4 w-4 shrink-0 items-center justify-center rounded-full hover:bg-black/10 dark:hover:bg-white/10"
537
+ onClick={() => {
538
+ const body: Record<string, unknown> = {
539
+ type: "updateTask",
540
+ taskId: task.id,
541
+ name: task.name,
542
+ tags: task.tags ?? [],
543
+ project: null,
544
+ };
545
+ if (inspectingId) {
546
+ body.sessionId = inspectingId;
547
+ }
548
+ void post(body);
549
+ }}
550
+ >
551
+ <span className="text-[0.65rem] font-bold leading-none">×</span>
552
+ </button>
553
+ </span>
554
+ </div>
555
+ ) : null}
556
+ <div className="flex min-h-10 items-center gap-2 sm:gap-3">
557
+ <div
558
+ className={`flex h-10 min-w-0 flex-1 bg-transparent ${titleUnderline}`}
559
+ >
560
+ {titleEditing ? (
561
+ <input
562
+ ref={titleInputRef}
563
+ type="text"
564
+ className={TASK_ACTIVE_TITLE_EDIT_INPUT_CLASS}
565
+ value={titleDraft}
566
+ onChange={(e) => setTitleDraft(e.target.value)}
567
+ onBlur={() => {
568
+ if (skipTitleBlurCommitRef.current) {
569
+ skipTitleBlurCommitRef.current = false;
570
+ return;
571
+ }
572
+ commitTitleEdit();
573
+ }}
574
+ onKeyDown={(e) => {
575
+ if (e.key === "Enter") {
576
+ e.preventDefault();
577
+ skipTitleBlurCommitRef.current = true;
578
+ commitTitleEdit();
579
+ }
580
+ if (e.key === "Escape") {
581
+ e.preventDefault();
582
+ cancelTitleEdit();
583
+ }
584
+ }}
585
+ aria-label={t.activeTaskTitleInputAria}
586
+ />
587
+ ) : (
588
+ <div
589
+ role="button"
590
+ tabIndex={0}
591
+ className={`flex h-full min-h-10 min-w-0 flex-1 cursor-pointer items-center rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-zinc-500/40 ${titleHitClass}`}
592
+ onClick={() => beginEditTitle()}
593
+ onKeyDown={(e) => {
594
+ if (e.key === "Enter" || e.key === " ") {
595
+ e.preventDefault();
596
+ beginEditTitle();
597
+ }
598
+ }}
599
+ title={t.activeTaskTitleEditHint}
600
+ aria-label={t.activeTaskTitleEditHint}
601
+ >
602
+ <div
603
+ className={`flex h-full min-h-0 min-w-0 flex-1 flex-nowrap items-center overflow-x-auto overflow-y-hidden ${TASK_ACTIVE_TITLE_READ_CLASS} ${
604
+ isDone ? titleDoneClass : titleActiveClass
605
+ }`}
606
+ >
607
+ <span className="whitespace-nowrap">
608
+ {titleDisplay || "—"}
609
+ </span>
610
+ </div>
611
+ </div>
612
+ )}
613
+ </div>
614
+ {!isDone && !isInspecting ? (
615
+ <span
616
+ className="shrink-0 self-center font-mono text-sm font-semibold tabular-nums tracking-tight text-emerald-700 dark:text-emerald-400"
617
+ aria-live={smoothTaskTimer ? "polite" : "off"}
618
+ title={formatStopwatchMs(liveTaskStopwatchMs)}
619
+ >
620
+ {formatStopwatchMs(liveTaskStopwatchMs)}
621
+ </span>
622
+ ) : null}
623
+ {isInspecting ? (
624
+ <button
625
+ type="button"
626
+ className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-zinc-300 text-zinc-600 hover:border-red-600/60 hover:bg-red-50 hover:text-red-800 dark:border-zinc-600 dark:text-zinc-400 dark:hover:border-red-800/70 dark:hover:bg-red-950/45 dark:hover:text-red-300"
627
+ title={t.taskDeleteBtn}
628
+ aria-label={t.taskDeleteBtn}
629
+ onClick={() => confirmDeleteTask(task.id)}
630
+ >
631
+ <Trash2 size={20} />
632
+ </button>
633
+ ) : (
634
+ <div className="flex h-10 shrink-0 items-center gap-1">
635
+ {!isDone && showKronoFocusTaskActions ? (
636
+ <button
637
+ type="button"
638
+ className={`inline-flex h-10 w-10 items-center justify-center rounded-lg ${
639
+ kronoFocusIsRunningOrPaused
640
+ ? "border border-violet-500/55 bg-violet-100 text-violet-900 hover:bg-violet-200/90 dark:border-violet-500/55 dark:bg-violet-950/45 dark:text-violet-200 dark:hover:bg-violet-900/55"
641
+ : "border border-violet-500/45 bg-violet-50 text-violet-900 hover:bg-violet-100/90 dark:border-violet-600/45 dark:bg-violet-950/30 dark:text-violet-200 dark:hover:bg-violet-900/40"
642
+ }`}
643
+ title={t.startKronoFocusFromActiveTask}
644
+ aria-label={t.startKronoFocusFromActiveTask}
645
+ onClick={() => void startKronoFocusFromTask()}
646
+ >
647
+ <Timer size={20} />
648
+ </button>
649
+ ) : null}
650
+ <button
651
+ type="button"
652
+ role="switch"
653
+ disabled={isDone}
654
+ aria-checked={shouldCommit ? "true" : "false"}
655
+ aria-label={
656
+ shouldCommit
657
+ ? t.commitOnFinishToggleAriaOn
658
+ : t.commitOnFinishToggleAriaOff
659
+ }
660
+ title={t.commitOnFinishHint}
661
+ onClick={() => {
662
+ if (!isDone) {
663
+ setShouldCommit((v) => !v);
664
+ }
665
+ }}
666
+ className={
667
+ shouldCommit
668
+ ? tbEmeraldIcon
669
+ : "inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-zinc-300 bg-zinc-100/80 text-zinc-600 transition-colors hover:bg-zinc-200/80 disabled:cursor-not-allowed disabled:opacity-40 dark:border-zinc-600 dark:bg-zinc-900/40 dark:text-zinc-500 dark:hover:bg-zinc-800/50 [&_svg]:shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-500/30 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-zinc-900"
670
+ }
671
+ >
672
+ <GitCommit size={18} strokeWidth={2} aria-hidden />
673
+ </button>
674
+ {pausePlayMode === null ? (
675
+ <div className="h-10 w-10 shrink-0" aria-hidden />
676
+ ) : (
677
+ <button
678
+ type="button"
679
+ className="inline-flex h-10 w-10 items-center justify-center rounded-lg border border-zinc-300 bg-white text-zinc-700 hover:bg-zinc-100 dark:border-zinc-600 dark:bg-transparent dark:text-zinc-300 dark:hover:bg-zinc-800"
680
+ title={
681
+ pausePlayMode === "pause" ? t.pauseTaskBtn : t.resumeTaskBtn
682
+ }
683
+ aria-label={
684
+ pausePlayMode === "pause" ? t.pauseTaskBtn : t.resumeTaskBtn
685
+ }
686
+ onClick={onPausePlay}
687
+ >
688
+ {pausePlayMode === "pause" ? (
689
+ <Pause size={20} />
690
+ ) : (
691
+ <Play size={20} className="ml-px" />
692
+ )}
693
+ </button>
694
+ )}
695
+ <button
696
+ type="button"
697
+ disabled={isDone}
698
+ className={tbEmeraldIcon}
699
+ title={t.finishTaskBtn}
700
+ aria-label={t.finishTaskBtn}
701
+ onClick={() => {
702
+ if (!isDone) {
703
+ requestFinishTask();
704
+ }
705
+ }}
706
+ >
707
+ <CheckCircle2 size={20} />
708
+ </button>
709
+ <button
710
+ type="button"
711
+ className="inline-flex h-10 w-10 items-center justify-center rounded-lg border border-zinc-300 text-zinc-600 hover:border-red-600/60 hover:bg-red-50 hover:text-red-800 dark:border-zinc-600 dark:text-zinc-400 dark:hover:border-red-800/70 dark:hover:bg-red-950/45 dark:hover:text-red-300"
712
+ title={t.taskDeleteBtn}
713
+ aria-label={t.taskDeleteBtn}
714
+ onClick={() => confirmDeleteTask(task.id)}
715
+ >
716
+ <Trash2 size={20} />
717
+ </button>
718
+ </div>
719
+ )}
720
+ </div>
721
+
722
+ {showDoneTimingRow || canEditTaskStartTime || canEditTaskEndTime ? (
723
+ <div aria-label={doneTimingAria || undefined}>
724
+ <div className="flex min-w-0 flex-wrap items-center gap-x-4 gap-y-2">
725
+ <TaskTimingFieldRow
726
+ label={t.archiveTaskStartLabel}
727
+ displayValue={startFmt}
728
+ editable={canEditTaskStartTime}
729
+ draftValue={taskStartDraft}
730
+ onDraftChange={setTaskStartDraft}
731
+ onDraftBlur={applyTaskStartTimeEdit}
732
+ lang={lang}
733
+ t={t}
734
+ />
735
+ <TaskTimingFieldRow
736
+ label={t.archiveTaskEndLabel}
737
+ displayValue={endFmt}
738
+ editable={canEditTaskEndTime}
739
+ draftValue={taskEndDraft}
740
+ onDraftChange={setTaskEndDraft}
741
+ onDraftBlur={applyTaskEndTimeEdit}
742
+ lang={lang}
743
+ t={t}
744
+ />
745
+ {durationLabel ? (
746
+ <div className="mt-2 flex min-w-0 flex-wrap items-center gap-2 text-[0.7rem] leading-snug text-zinc-600 dark:text-zinc-400 sm:mt-0">
747
+ <span className="text-[0.65rem] font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-500">
748
+ {t.taskTimingDurationLabel}
749
+ </span>
750
+ <span className="tabular-nums text-zinc-800 dark:text-zinc-200">
751
+ {durationLabel}
752
+ </span>
753
+ </div>
754
+ ) : null}
755
+ </div>
756
+ <div className="mt-3">
757
+ <div className="-mx-0.5 flex w-full min-w-0 flex-wrap items-start gap-x-2 gap-y-1.5 px-0.5 pb-0.5">
758
+ {mergedTags.length > 0 ? (
759
+ <TagPills
760
+ variant="applied"
761
+ differentiateProjectScopedTags
762
+ className="flex min-w-0 max-w-full flex-wrap items-baseline gap-x-1 gap-y-0.5"
763
+ tags={mergedTags}
764
+ defaultTagBucketLabel={t.taskTagDefaultBucket}
765
+ onRemove={(tagToRemove) => {
766
+ const nextTags = mergedTags.filter(
767
+ (t) => normalizeTagKey(t) !== normalizeTagKey(tagToRemove),
768
+ );
769
+ const filteredTags = filterTaskTagsForProject(
770
+ nextTags,
771
+ task.project ?? null,
772
+ );
773
+ const body: Record<string, unknown> = {
774
+ type: "updateTask",
775
+ taskId: task.id,
776
+ name: task.name,
777
+ tags: filteredTags,
778
+ };
779
+ if (task.project !== undefined) {
780
+ body.project = task.project;
781
+ }
782
+ if (inspectingId) {
783
+ body.sessionId = inspectingId;
784
+ }
785
+ void post(body);
786
+ }}
787
+ />
788
+ ) : null}
789
+ </div>
790
+ </div>
791
+ <TaskSubtasksBlock
792
+ taskId={task.id}
793
+ sessionId={inspectingId}
794
+ subtasks={task.subtasks}
795
+ enableSubtaskTimer={!isInspecting && !isDone}
796
+ activeSubtaskTimerId={task.activeSubtaskTimerId}
797
+ allowAddSubtasks={!isDone}
798
+ subtasksReadOnly={isDone}
799
+ t={t}
800
+ post={post}
801
+ />
802
+ </div>
803
+ ) : null}
804
+ </div>
805
+
806
+
807
+ <DashboardConfirmModal
808
+ open={finishOpenSubtasksWarnOpen}
809
+ title={t.finishTaskOpenSubtasksWarnTitle}
810
+ message={t.finishTaskOpenSubtasksWarnBody}
811
+ cancelLabel={t.dialogCancelBtn}
812
+ confirmLabel={t.finishTaskBtn}
813
+ dismissCheckbox={{
814
+ label: t.sessionArchiveDontShowAgain,
815
+ checked: dontRemindOpenSubtasksFinish,
816
+ onChange: setDontRemindOpenSubtasksFinish,
817
+ }}
818
+ onCancel={() => {
819
+ setFinishOpenSubtasksWarnOpen(false);
820
+ setDontRemindOpenSubtasksFinish(false);
821
+ }}
822
+ onConfirm={() => {
823
+ if (dontRemindOpenSubtasksFinish) {
824
+ persistFinishSubtasksWarnDismissed();
825
+ }
826
+ setFinishOpenSubtasksWarnOpen(false);
827
+ runFinishTask();
828
+ }}
829
+ />
830
+ </>
831
+ );
832
+ }