@nightkatana/kronosys-app 1.0.0-beta.2 → 1.0.0-beta.21

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 (112) hide show
  1. package/README.md +28 -1
  2. package/app/api/action/route.ts +39 -3
  3. package/app/api/action-logs/route.ts +24 -0
  4. package/app/api/backup/route.ts +1 -1
  5. package/app/api/restore/route.ts +145 -0
  6. package/app/changelog/page.tsx +71 -4
  7. package/app/globals.css +127 -0
  8. package/app/guide/page.tsx +61 -15
  9. package/app/implementation/page.tsx +700 -0
  10. package/app/layout.tsx +14 -3
  11. package/app/licenses/page.tsx +99 -37
  12. package/app/logs/page.tsx +258 -0
  13. package/app/manifest.ts +5 -5
  14. package/app/page.tsx +784 -229
  15. package/app/reporting/page.tsx +1266 -474
  16. package/app/settings/page.tsx +252 -18
  17. package/bin/kronosys.mjs +140 -15
  18. package/components/KronosysPayloadProvider.tsx +2 -0
  19. package/components/RouteTransition.tsx +18 -0
  20. package/components/dashboard/AppShellCommandCenterPlaceholder.tsx +17 -0
  21. package/components/dashboard/AppShellHeaderSessionMeta.tsx +210 -0
  22. package/components/dashboard/AppShellHeaderWallClock.tsx +54 -0
  23. package/components/dashboard/AppShellLiveSessionDrawer.tsx +154 -38
  24. package/components/dashboard/AppShellRouteNav.tsx +323 -48
  25. package/components/dashboard/DashboardPauseBackdrop.tsx +50 -0
  26. package/components/dashboard/DashboardSimpleModal.tsx +168 -25
  27. package/components/dashboard/DashboardTour.tsx +115 -29
  28. package/components/dashboard/GlobalPauseConfirmModal.tsx +183 -0
  29. package/components/dashboard/KronosysDatetimePopoverField.tsx +167 -122
  30. package/components/dashboard/KronosysTimePopoverField.tsx +54 -12
  31. package/components/dashboard/NewSessionScopeModal.tsx +211 -20
  32. package/components/dashboard/PlannedTaskBoundaryConflictWatcher.tsx +275 -0
  33. package/components/dashboard/ReportingTour.tsx +87 -21
  34. package/components/dashboard/SavedProjectPicker.tsx +16 -3
  35. package/components/dashboard/SelectedSessionSidebarBlock.tsx +512 -142
  36. package/components/dashboard/SessionListPanel.tsx +327 -44
  37. package/components/dashboard/SettingsTagsProjectsSection.tsx +1073 -264
  38. package/components/dashboard/SettingsTaskTemplatesSection.tsx +316 -0
  39. package/components/dashboard/SettingsTour.tsx +86 -21
  40. package/components/dashboard/TagPills.tsx +14 -1
  41. package/components/dashboard/TaskFocusPanel.tsx +1081 -478
  42. package/components/dashboard/TaskSessionLiveCard.tsx +650 -135
  43. package/components/dashboard/TaskTimelineGanttModal.tsx +601 -0
  44. package/components/dashboard/taskFieldStyles.ts +20 -4
  45. package/components/dashboard/useReportingInteractionState.ts +80 -0
  46. package/lib/appShellHeaderClasses.ts +13 -0
  47. package/lib/businessRulesMatrix.ts +210 -0
  48. package/lib/copyToClipboard.ts +43 -0
  49. package/lib/dashboardCopy.ts +494 -84
  50. package/lib/dashboardQuickSearch.ts +54 -2
  51. package/lib/dashboardTimeZone.ts +109 -0
  52. package/lib/formatAppShellWallClock.ts +66 -0
  53. package/lib/formatSessionNameTemplate.ts +141 -0
  54. package/lib/generatedUserChangelog.ts +177 -6
  55. package/lib/globalPausePreview.ts +292 -0
  56. package/lib/implementationNotes.ts +1188 -0
  57. package/lib/kronosysApi.ts +6 -0
  58. package/lib/kronosysDashboardModalGates.ts +24 -0
  59. package/lib/plannedBoundaryAttention.ts +9 -0
  60. package/lib/plannedBoundaryConflict.ts +23 -0
  61. package/lib/reportingAggregate.ts +517 -75
  62. package/lib/reportingMetricHelp.ts +8 -0
  63. package/lib/reportingStrings.ts +37 -3
  64. package/lib/sessionListMerge.ts +4 -0
  65. package/lib/sessionTaskSidebarStats.ts +182 -21
  66. package/lib/settingsCopy.ts +178 -4
  67. package/lib/taskParsing.ts +360 -103
  68. package/lib/taskTemplateDraft.ts +135 -0
  69. package/lib/taskTimelineGantt.ts +265 -0
  70. package/lib/temporalDisplayPlanned.ts +71 -0
  71. package/lib/userGuideCopy.ts +121 -47
  72. package/next.config.ts +7 -0
  73. package/package.json +12 -24
  74. package/server/actionDispatch.ts +1000 -77
  75. package/server/actionTaskSession.ts +337 -24
  76. package/server/db.ts +7 -15
  77. package/server/dbSchema.ts +24 -0
  78. package/server/defaultCfg.ts +5 -0
  79. package/server/gitlabTokenStore.ts +0 -12
  80. package/server/liveHistorySync.ts +53 -0
  81. package/server/mainTimerHydrate.ts +38 -2
  82. package/server/payloadStore.ts +33 -11
  83. package/server/sessionWallHydrate.ts +66 -3
  84. package/server/userActionLog.ts +126 -0
  85. package/sonar-project.properties +11 -0
  86. package/tsconfig.json +2 -1
  87. package/components/dashboard/IssuePickerModal.tsx +0 -168
  88. package/components/dashboard/ThemeToggle.test.tsx +0 -26
  89. package/lib/backupCsvExport.test.ts +0 -149
  90. package/lib/dashboardQuickSearchQuery.test.ts +0 -63
  91. package/lib/dataDir.test.ts +0 -87
  92. package/lib/formatIsoShort.test.ts +0 -46
  93. package/lib/kronoFocusRhythm.test.ts +0 -130
  94. package/lib/kronoFocusTimerUrgency.test.ts +0 -74
  95. package/lib/legacyKronoFocusStorageKeys.test.ts +0 -29
  96. package/lib/reportingAggregate.test.ts +0 -325
  97. package/lib/reportingNonFinalIndicators.test.ts +0 -157
  98. package/lib/reportingTagWeekBreakdown.test.ts +0 -141
  99. package/lib/reportingWeekLayout.test.ts +0 -239
  100. package/lib/sessionAssiduity.test.ts +0 -25
  101. package/lib/sessionEndWarnings.test.ts +0 -200
  102. package/lib/sessionListMerge.test.ts +0 -101
  103. package/lib/sessionTaskSidebarStats.test.ts +0 -24
  104. package/lib/taskParsing.test.ts +0 -153
  105. package/lib/usageProfile.test.ts +0 -84
  106. package/server/actionDispatch.test.ts +0 -723
  107. package/server/actionTaskSession.test.ts +0 -713
  108. package/server/kronoFocusHydrate.test.ts +0 -142
  109. package/server/kronoFocusMigrate.test.ts +0 -53
  110. package/server/mainTimerHydrate.test.ts +0 -65
  111. package/server/payloadStore.test.ts +0 -78
  112. package/server/sessionWallHydrate.test.ts +0 -46
@@ -1,13 +1,15 @@
1
1
  "use client";
2
2
 
3
- import { useCallback, useEffect, useRef, useState } from "react";
3
+ import { useCallback, useEffect, useId, useRef, useState } from "react";
4
4
  import {
5
5
  Timer,
6
6
  Play,
7
7
  Pause,
8
8
  CheckCircle2,
9
+ Bookmark,
9
10
  Trash2,
10
- GitCommit,
11
+ Copy,
12
+ Save,
11
13
  } from "lucide-react";
12
14
  import type { DashboardStrings, Lang } from "@/lib/dashboardCopy";
13
15
  import { formatIsoInstantShort } from "@/lib/formatIsoShort";
@@ -20,6 +22,7 @@ import {
20
22
  mergeTagsForDisplay,
21
23
  normalizeTagKey,
22
24
  parseTaskWithAutoTags,
25
+ resolvePersonalProjectForTaskUpdate,
23
26
  resolveProjectForTaskUpdate,
24
27
  taskTitleEditBaseline,
25
28
  taskTitleForDisplay,
@@ -28,6 +31,7 @@ import { TagPills } from "./TagPills";
28
31
  import { tbEmeraldIcon } from "@/lib/translucentButtonClasses";
29
32
  import {
30
33
  PROJECT_CHIP_APPLIED_CLASS,
34
+ PROJECT_CHIP_APPLIED_PERSONAL_CLASS,
31
35
  TASK_ACTIVE_TITLE_EDIT_INPUT_CLASS,
32
36
  TASK_ACTIVE_TITLE_READ_CLASS,
33
37
  } from "./taskFieldStyles";
@@ -74,9 +78,9 @@ const DONE_TITLE_PLAIN_CLASS =
74
78
  const TASK_CARD_SHELL_CLASS =
75
79
  "rounded-xl border border-zinc-200 bg-zinc-100/80 p-5 dark:border-zinc-800 dark:bg-black/20 sm:p-6";
76
80
 
77
- /** Tâches en cours : pas de cadre, intégration dans le flux. */
81
+ /** Même gabarit visuel pour toutes les sections (en cours / pause / terminée) pour fluidifier les animations. */
78
82
  const TASK_CARD_SHELL_PLAIN_CLASS =
79
- "rounded-none border-0 bg-transparent py-4 shadow-none first:pt-2 dark:bg-transparent";
83
+ "rounded-xl border border-zinc-200 bg-zinc-100/80 p-5 dark:border-zinc-800 dark:bg-black/20 sm:p-6";
80
84
 
81
85
  export type TaskSessionLiveCardTask = {
82
86
  id: string;
@@ -87,8 +91,19 @@ export type TaskSessionLiveCardTask = {
87
91
  startTime?: string;
88
92
  /** Fin du suivi ou de l’entrée passée (ISO 8601), si connu. */
89
93
  endTime?: string;
94
+ /** Fin planifiée pour une tâche active (ISO 8601), si connue. */
95
+ scheduledEndAt?: string;
96
+ /** Segments de suivi successifs (play/pause/completed). */
97
+ taskTimerLaps?: Array<{
98
+ startTime?: string;
99
+ endTime?: string;
100
+ durationMs?: number;
101
+ }>;
90
102
  tags?: string[];
91
103
  project?: string | null;
104
+ /** Projet issu d’un jeton `!` (temps personnel). */
105
+ personalProject?: boolean;
106
+ note?: string;
92
107
  subtasks?: Array<{
93
108
  id: string;
94
109
  title: string;
@@ -96,8 +111,13 @@ export type TaskSessionLiveCardTask = {
96
111
  durationMs?: number;
97
112
  }>;
98
113
  activeSubtaskTimerId?: string;
114
+ subtaskTimerStartedAt?: string | number;
115
+ mainTimerSegmentStartedAt?: string | null;
116
+ taskCurrentLapStartedAt?: string | number | null;
99
117
  };
100
118
 
119
+ type DurationAdjustMode = "keep" | "manual" | "from_bounds";
120
+
101
121
  function TaskTimingFieldRow({
102
122
  label,
103
123
  displayValue,
@@ -105,6 +125,8 @@ function TaskTimingFieldRow({
105
125
  draftValue,
106
126
  onDraftChange,
107
127
  onDraftBlur,
128
+ defaultTimeMode,
129
+ alertClassName,
108
130
  lang,
109
131
  t,
110
132
  }: {
@@ -114,6 +136,8 @@ function TaskTimingFieldRow({
114
136
  draftValue: string;
115
137
  onDraftChange: (next: string) => void;
116
138
  onDraftBlur: () => void;
139
+ defaultTimeMode?: "current-hour" | "next-half-hour";
140
+ alertClassName?: string;
117
141
  lang: Lang;
118
142
  t: DashboardStrings;
119
143
  }) {
@@ -121,7 +145,11 @@ function TaskTimingFieldRow({
121
145
  return null;
122
146
  }
123
147
  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">
148
+ <div
149
+ className={`inline-flex min-w-0 items-center gap-2 whitespace-nowrap text-[0.7rem] leading-[1.35] text-zinc-600 dark:text-zinc-400 ${
150
+ alertClassName ?? ""
151
+ }`}
152
+ >
125
153
  <span className="text-[0.65rem] font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-500">
126
154
  {label}
127
155
  </span>
@@ -133,11 +161,12 @@ function TaskTimingFieldRow({
133
161
  onBlur={onDraftBlur}
134
162
  aria-label={label}
135
163
  lang={lang}
164
+ defaultTimeMode={defaultTimeMode}
136
165
  t={t}
137
166
  />
138
167
  </>
139
168
  ) : (
140
- <span className="min-w-0 tabular-nums text-zinc-800 dark:text-zinc-200">
169
+ <span className="inline-flex min-w-0 items-center tabular-nums text-zinc-800 dark:text-zinc-200">
141
170
  {displayValue}
142
171
  </span>
143
172
  )}
@@ -181,12 +210,20 @@ export function TaskSessionLiveCard({
181
210
  kronoFocusIsRunningOrPaused,
182
211
  showKronoFocusTaskActions = true,
183
212
  startKronoFocusFromTask,
213
+ onDuplicateTask,
214
+ onSaveAsTemplate,
215
+ isAlreadyTemplate = false,
184
216
  pausePlayMode,
185
217
  onPausePlay,
186
218
  anchorId,
187
219
  variant = "card",
188
220
  allowTaskStartTimeEdit = true,
189
221
  allowTaskEndTimeEdit = true,
222
+ showTaskActionButtons = true,
223
+ allowAddSubtasks = true,
224
+ showLiveTimerWhenInspecting = false,
225
+ deriveLiveTimerFromStartTime = true,
226
+ temporalPlanned = false,
190
227
  }: {
191
228
  task: TaskSessionLiveCardTask;
192
229
  lang: Lang;
@@ -203,6 +240,11 @@ export function TaskSessionLiveCard({
203
240
  /** Bouton « démarrer le KronoFocus » sur les tâches non terminées (paramètre tableau de bord). */
204
241
  showKronoFocusTaskActions?: boolean;
205
242
  startKronoFocusFromTask: () => void;
243
+ onDuplicateTask: (task: TaskSessionLiveCardTask) => void;
244
+ /** Enregistrer titre / étiquettes / projet comme template de tâche (carte active, en pause ou terminée). */
245
+ onSaveAsTemplate?: (task: TaskSessionLiveCardTask) => void;
246
+ /** Vrai si cette tâche existe déjà dans les templates sauvegardés. */
247
+ isAlreadyTemplate?: boolean;
206
248
  /** `null` : tâche terminée (pas de pause / reprise). */
207
249
  pausePlayMode: "pause" | "resume" | null;
208
250
  onPausePlay: () => void;
@@ -214,14 +256,25 @@ export function TaskSessionLiveCard({
214
256
  allowTaskStartTimeEdit?: boolean;
215
257
  /** Option `dashboardAllowTaskEndTimeEdit` : correction du `endTime` (tâches terminées). */
216
258
  allowTaskEndTimeEdit?: boolean;
259
+ /** Affiche/masque les actions latérales (dupliquer, supprimer, etc.). */
260
+ showTaskActionButtons?: boolean;
261
+ /** Autorise l’ajout de sous-tâches depuis la carte. */
262
+ allowAddSubtasks?: boolean;
263
+ /** Affiche la minuterie live même en mode inspection (ex. reporting). */
264
+ showLiveTimerWhenInspecting?: boolean;
265
+ /** Si `false`, la minuterie reste basée sur `durationMs` (pas de dérive depuis `startTime`). */
266
+ deriveLiveTimerFromStartTime?: boolean;
267
+ /** Affichage « planifié » (début ou fin dans le futur) : style distinct, pas de minuteur live. */
268
+ temporalPlanned?: boolean;
217
269
  }) {
218
- const [shouldCommit, setShouldCommit] = useState(false);
270
+ const timingAdjustRadioGroupId = useId();
219
271
  const [finishOpenSubtasksWarnOpen, setFinishOpenSubtasksWarnOpen] =
220
272
  useState(false);
221
273
  const [dontRemindOpenSubtasksFinish, setDontRemindOpenSubtasksFinish] =
222
274
  useState(false);
223
275
  const [titleEditing, setTitleEditing] = useState(false);
224
276
  const [titleDraft, setTitleDraft] = useState("");
277
+ const [noteDraft, setNoteDraft] = useState("");
225
278
  const [taskStartDraft, setTaskStartDraft] = useState("");
226
279
  const [optimisticLiveBaseMs, setOptimisticLiveBaseMs] = useState<
227
280
  number | null
@@ -233,23 +286,46 @@ export function TaskSessionLiveCard({
233
286
  const [optimisticDoneDurationMin, setOptimisticDoneDurationMin] = useState<
234
287
  number | null
235
288
  >(null);
289
+ const [timingAdjustOpen, setTimingAdjustOpen] = useState(false);
290
+ const [timingAdjustMode, setTimingAdjustMode] =
291
+ useState<DurationAdjustMode>("keep");
292
+ const [manualHours, setManualHours] = useState("0");
293
+ const [manualMinutes, setManualMinutes] = useState("0");
294
+ const [manualSeconds, setManualSeconds] = useState("0");
295
+ const [pendingTimingEdit, setPendingTimingEdit] = useState<{
296
+ kind: "start" | "end";
297
+ nextIso: string;
298
+ startMs: number;
299
+ endMs: number | null;
300
+ } | null>(null);
236
301
  const titleInputRef = useRef<HTMLInputElement>(null);
237
302
  const skipTitleBlurCommitRef = useRef(false);
303
+ const finishAnimationPlayedRef = useRef(false);
304
+ const [finishCelebrate, setFinishCelebrate] = useState(false);
305
+ const [blinkNowMs, setBlinkNowMs] = useState(() => Date.now());
306
+ const [lapsNowMs, setLapsNowMs] = useState(() => Date.now());
238
307
 
239
308
  const mergedTags = mergeTagsForDisplay(task.name, task.tags);
240
309
  const trimmedProject = task.project?.trim() ?? "";
310
+ const isPersonalProject = task.personalProject === true;
241
311
  /** Affichage lecture seule : sans `#tags` ni `@projet` (pastilles en dessous). */
242
312
  const titleDisplay = taskTitleForDisplay(task.name);
243
313
  /** Valeur initiale à l’édition : libellé seul ; on peut toujours saisir `#tag` ou `@projet` dans le champ. */
244
314
  const titleDraftSource = taskTitleEditBaseline(task.name);
245
315
  const isDone = task.isDone === true;
246
- const smoothTaskTimer = !isDone && !isInspecting && pausePlayMode === "pause";
316
+ const taskIsPaused = !isDone && pausePlayMode === "resume";
317
+ const taskIsRunning = !isDone && pausePlayMode === "pause";
318
+ const smoothTaskTimer =
319
+ !temporalPlanned &&
320
+ !isDone &&
321
+ (!isInspecting || showLiveTimerWhenInspecting) &&
322
+ pausePlayMode === "pause";
247
323
  const taskStopwatchBaseMs =
248
324
  optimisticLiveBaseMs !== null
249
325
  ? optimisticLiveBaseMs
250
326
  : derivedLiveBaseMs !== null
251
- ? derivedLiveBaseMs
252
- : Math.max(0, task.durationMs ?? 0);
327
+ ? derivedLiveBaseMs
328
+ : Math.max(0, task.durationMs ?? 0);
253
329
  const liveTaskStopwatchMs = useSmoothStopwatchDisplayMs(
254
330
  taskStopwatchBaseMs,
255
331
  smoothTaskTimer,
@@ -264,10 +340,17 @@ export function TaskSessionLiveCard({
264
340
  use24HourClock,
265
341
  )
266
342
  : null;
343
+ const effectiveEndIso =
344
+ typeof task.endTime === "string" && task.endTime.trim() !== ""
345
+ ? task.endTime
346
+ : typeof task.scheduledEndAt === "string" &&
347
+ task.scheduledEndAt.trim() !== ""
348
+ ? task.scheduledEndAt
349
+ : undefined;
267
350
  const endFmt =
268
- typeof task.endTime === "string"
351
+ typeof effectiveEndIso === "string"
269
352
  ? formatIsoInstantShort(
270
- task.endTime,
353
+ effectiveEndIso,
271
354
  lang,
272
355
  displayTimeZone,
273
356
  use24HourClock,
@@ -282,17 +365,92 @@ export function TaskSessionLiveCard({
282
365
  durationMinResolved !== null ? formatDuration(durationMinResolved) : null;
283
366
  const showDoneTimingRow =
284
367
  isDone && (startFmt !== null || endFmt !== null || durationLabel !== null);
368
+ const persistedTaskTimerLaps = Array.isArray(task.taskTimerLaps)
369
+ ? task.taskTimerLaps
370
+ .map((lap, index) => {
371
+ const startIso =
372
+ typeof lap?.startTime === "string" ? lap.startTime.trim() : "";
373
+ const endIso =
374
+ typeof lap?.endTime === "string" ? lap.endTime.trim() : "";
375
+ if (!startIso || !endIso) {
376
+ return null;
377
+ }
378
+ const durationMsRaw =
379
+ typeof lap?.durationMs === "number" &&
380
+ Number.isFinite(lap.durationMs)
381
+ ? Math.max(0, Math.floor(lap.durationMs))
382
+ : Math.max(0, Date.parse(endIso) - Date.parse(startIso));
383
+ if (durationMsRaw < 1000) {
384
+ return null;
385
+ }
386
+ const startLabel = formatIsoInstantShort(
387
+ startIso,
388
+ lang,
389
+ displayTimeZone,
390
+ use24HourClock,
391
+ );
392
+ const endLabel = formatIsoInstantShort(
393
+ endIso,
394
+ lang,
395
+ displayTimeZone,
396
+ use24HourClock,
397
+ );
398
+ return {
399
+ id: `${task.id}-lap-${index}-${startIso}`,
400
+ startLabel,
401
+ endLabel,
402
+ durationLabel: formatStopwatchMs(durationMsRaw),
403
+ };
404
+ })
405
+ .filter(
406
+ (
407
+ lap,
408
+ ): lap is {
409
+ id: string;
410
+ startLabel: string;
411
+ endLabel: string;
412
+ durationLabel: string;
413
+ } => lap !== null,
414
+ )
415
+ .map((lap, index) => ({ ...lap, index: index + 1 }))
416
+ : [];
417
+ const runningSegmentRaw = task.taskCurrentLapStartedAt;
418
+ const runningSegmentStartMs =
419
+ typeof runningSegmentRaw === "string"
420
+ ? Date.parse(runningSegmentRaw)
421
+ : typeof runningSegmentRaw === "number" &&
422
+ Number.isFinite(runningSegmentRaw)
423
+ ? runningSegmentRaw
424
+ : Number.NaN;
425
+ const runningTaskTimerLap =
426
+ taskIsRunning && Number.isFinite(runningSegmentStartMs)
427
+ ? {
428
+ id: `${task.id}-lap-running-${runningSegmentStartMs}`,
429
+ index: persistedTaskTimerLaps.length + 1,
430
+ startLabel: formatIsoInstantShort(
431
+ new Date(runningSegmentStartMs).toISOString(),
432
+ lang,
433
+ displayTimeZone,
434
+ use24HourClock,
435
+ ),
436
+ endLabel: lang === "fr" ? "en cours..." : "running...",
437
+ durationLabel: formatStopwatchMs(
438
+ Math.max(0, lapsNowMs - runningSegmentStartMs),
439
+ ),
440
+ }
441
+ : null;
442
+ const taskTimerLaps =
443
+ runningTaskTimerLap !== null
444
+ ? [...persistedTaskTimerLaps, runningTaskTimerLap]
445
+ : persistedTaskTimerLaps;
285
446
  const canEditTaskStartTime =
286
- allowTaskStartTimeEdit &&
447
+ (isDone ? true : allowTaskStartTimeEdit) &&
287
448
  typeof task.startTime === "string" &&
288
449
  task.startTime.trim() !== "";
289
450
  const canEditTaskEndTime =
290
- allowTaskEndTimeEdit &&
291
- isDone &&
451
+ (isDone || allowTaskEndTimeEdit) &&
292
452
  typeof task.startTime === "string" &&
293
- task.startTime.trim() !== "" &&
294
- typeof task.endTime === "string" &&
295
- task.endTime.trim() !== "";
453
+ task.startTime.trim() !== "";
296
454
  const doneTimingAria = [
297
455
  startFmt ? `${t.archiveTaskStartLabel} ${startFmt}` : "",
298
456
  endFmt ? `${t.archiveTaskEndLabel} ${endFmt}` : "",
@@ -300,11 +458,75 @@ export function TaskSessionLiveCard({
300
458
  ]
301
459
  .filter(Boolean)
302
460
  .join(", ");
461
+ const scheduledEndMs =
462
+ typeof task.scheduledEndAt === "string" && task.scheduledEndAt.trim() !== ""
463
+ ? Date.parse(task.scheduledEndAt)
464
+ : Number.NaN;
465
+ const taskRemainingMs = Number.isFinite(scheduledEndMs)
466
+ ? scheduledEndMs - blinkNowMs
467
+ : Number.NaN;
468
+ let taskEndAlertClassName = "";
469
+ if (!isDone && Number.isFinite(taskRemainingMs) && taskRemainingMs > 0) {
470
+ if (taskRemainingMs <= 5 * 60_000) {
471
+ taskEndAlertClassName = "kronosys-end-time-alert-fast";
472
+ } else if (taskRemainingMs <= 15 * 60_000) {
473
+ taskEndAlertClassName = "kronosys-end-time-alert-slow";
474
+ }
475
+ }
303
476
 
304
477
  useEffect(() => {
305
478
  setTitleEditing(false);
306
479
  }, [task.id]);
307
480
 
481
+ useEffect(() => {
482
+ const hasScheduledEnd =
483
+ typeof task.scheduledEndAt === "string" &&
484
+ task.scheduledEndAt.trim() !== "";
485
+ if (!hasScheduledEnd || isDone) {
486
+ return;
487
+ }
488
+ const interval = globalThis.setInterval(() => {
489
+ setBlinkNowMs(Date.now());
490
+ }, 1000);
491
+ return () => globalThis.clearInterval(interval);
492
+ }, [task.scheduledEndAt, isDone, task.id]);
493
+
494
+ useEffect(() => {
495
+ if (!taskIsRunning || !Number.isFinite(runningSegmentStartMs)) {
496
+ return;
497
+ }
498
+ const id = globalThis.setInterval(() => {
499
+ setLapsNowMs(Date.now());
500
+ }, 1000);
501
+ return () => globalThis.clearInterval(id);
502
+ }, [taskIsRunning, runningSegmentStartMs, task.id]);
503
+
504
+ useEffect(() => {
505
+ setNoteDraft(typeof task.note === "string" ? task.note : "");
506
+ }, [task.id, task.note]);
507
+
508
+ useEffect(() => {
509
+ if (!isDone) {
510
+ finishAnimationPlayedRef.current = false;
511
+ return;
512
+ }
513
+ if (finishAnimationPlayedRef.current) {
514
+ return;
515
+ }
516
+ const endMs =
517
+ typeof task.endTime === "string" ? Date.parse(task.endTime) : Number.NaN;
518
+ if (!Number.isFinite(endMs)) {
519
+ return;
520
+ }
521
+ if (Math.abs(Date.now() - endMs) > 2600) {
522
+ return;
523
+ }
524
+ finishAnimationPlayedRef.current = true;
525
+ setFinishCelebrate(true);
526
+ const timer = globalThis.setTimeout(() => setFinishCelebrate(false), 750);
527
+ return () => globalThis.clearTimeout(timer);
528
+ }, [task.id, isDone, task.endTime]);
529
+
308
530
  useEffect(() => {
309
531
  const st = typeof task.startTime === "string" ? task.startTime.trim() : "";
310
532
  const parsed = st ? new Date(st) : null;
@@ -316,14 +538,19 @@ export function TaskSessionLiveCard({
316
538
  }, [task.id, task.startTime]);
317
539
 
318
540
  useEffect(() => {
319
- const et = typeof task.endTime === "string" ? task.endTime.trim() : "";
541
+ const et =
542
+ typeof task.endTime === "string" && task.endTime.trim() !== ""
543
+ ? task.endTime.trim()
544
+ : typeof task.scheduledEndAt === "string"
545
+ ? task.scheduledEndAt.trim()
546
+ : "";
320
547
  const parsed = et ? new Date(et) : null;
321
548
  if (!parsed || Number.isNaN(parsed.getTime())) {
322
549
  setTaskEndDraft("");
323
550
  return;
324
551
  }
325
552
  setTaskEndDraft(formatDatetimeLocalValue(parsed));
326
- }, [task.id, task.endTime]);
553
+ }, [task.id, task.endTime, task.scheduledEndAt]);
327
554
 
328
555
  useEffect(() => {
329
556
  setOptimisticLiveBaseMs(null);
@@ -334,20 +561,54 @@ export function TaskSessionLiveCard({
334
561
  }, [task.id, task.durationMs, task.endTime]);
335
562
 
336
563
  useEffect(() => {
337
- if (!smoothTaskTimer) {
564
+ let totalSeconds = Math.max(0, Math.floor((task.durationMs ?? 0) / 1000));
565
+ // Préremplissage lisible : au-delà d'une heure, on neutralise les secondes.
566
+ if (totalSeconds >= 3600) {
567
+ totalSeconds = Math.floor(totalSeconds / 60) * 60;
568
+ }
569
+ const h = Math.floor(totalSeconds / 3600);
570
+ const m = Math.floor((totalSeconds % 3600) / 60);
571
+ const s = totalSeconds % 60;
572
+ setManualHours(String(h));
573
+ setManualMinutes(String(m));
574
+ setManualSeconds(String(s));
575
+ }, [task.id, task.durationMs]);
576
+
577
+ useEffect(() => {
578
+ if (!smoothTaskTimer || !deriveLiveTimerFromStartTime || temporalPlanned) {
338
579
  setDerivedLiveBaseMs(null);
339
580
  return;
340
581
  }
341
- const startMs =
342
- typeof task.startTime === "string"
343
- ? Date.parse(task.startTime)
582
+ const baseDurationMs = Math.max(0, task.durationMs ?? 0);
583
+ const hasSubtaskRunning =
584
+ typeof task.activeSubtaskTimerId === "string" &&
585
+ task.activeSubtaskTimerId.trim() !== "";
586
+ const segmentRaw = hasSubtaskRunning
587
+ ? task.subtaskTimerStartedAt
588
+ : task.mainTimerSegmentStartedAt;
589
+ const segmentStartMs =
590
+ typeof segmentRaw === "string"
591
+ ? Date.parse(segmentRaw)
592
+ : typeof segmentRaw === "number" && Number.isFinite(segmentRaw)
593
+ ? segmentRaw
344
594
  : Number.NaN;
345
- if (!Number.isFinite(startMs)) {
595
+ if (!Number.isFinite(segmentStartMs)) {
346
596
  setDerivedLiveBaseMs(null);
347
597
  return;
348
598
  }
349
- setDerivedLiveBaseMs(Math.max(0, Date.now() - startMs));
350
- }, [smoothTaskTimer, task.startTime, task.id]);
599
+ setDerivedLiveBaseMs(
600
+ baseDurationMs + Math.max(0, Date.now() - segmentStartMs),
601
+ );
602
+ }, [
603
+ deriveLiveTimerFromStartTime,
604
+ smoothTaskTimer,
605
+ task.durationMs,
606
+ task.activeSubtaskTimerId,
607
+ task.subtaskTimerStartedAt,
608
+ task.mainTimerSegmentStartedAt,
609
+ task.id,
610
+ temporalPlanned,
611
+ ]);
351
612
 
352
613
  useEffect(() => {
353
614
  if (titleEditing && titleInputRef.current) {
@@ -375,8 +636,9 @@ export function TaskSessionLiveCard({
375
636
  const nextName = parsed.name.trim() || parsed.tags[0] || trimmed;
376
637
  const mergedTagsNext = mergeTagsForDisplay(trimmed, mergedTags);
377
638
  const projUp = resolveProjectForTaskUpdate(trimmed);
639
+ const persUp = resolvePersonalProjectForTaskUpdate(trimmed);
378
640
  const resolvedProject =
379
- projUp !== undefined ? projUp : (task.project ?? null);
641
+ projUp !== undefined ? projUp : task.project ?? null;
380
642
  const tagsFiltered = filterTaskTagsForProject(
381
643
  mergedTagsNext,
382
644
  resolvedProject,
@@ -393,6 +655,9 @@ export function TaskSessionLiveCard({
393
655
  if (projUp !== undefined) {
394
656
  body.project = projUp;
395
657
  }
658
+ if (persUp !== undefined) {
659
+ body.personalProject = persUp;
660
+ }
396
661
  void post(body);
397
662
  }, [titleDraft, titleDraftSource, task.id, mergedTags, inspectingId, post]);
398
663
 
@@ -406,9 +671,8 @@ export function TaskSessionLiveCard({
406
671
  void post({
407
672
  type: "finishTask",
408
673
  taskId: task.id,
409
- shouldCommit,
410
674
  });
411
- }, [post, task.id, shouldCommit]);
675
+ }, [post, task.id]);
412
676
 
413
677
  const requestFinishTask = useCallback(() => {
414
678
  if (isDone) {
@@ -438,25 +702,26 @@ export function TaskSessionLiveCard({
438
702
  if (nextIso === task.startTime) {
439
703
  return;
440
704
  }
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;
705
+ const endMsRaw =
706
+ typeof task.endTime === "string" ? Date.parse(task.endTime) : Number.NaN;
707
+ if (Number.isFinite(endMsRaw) && nextMs > endMsRaw) {
708
+ return;
451
709
  }
452
- void post(body);
710
+ setPendingTimingEdit({
711
+ kind: "start",
712
+ nextIso,
713
+ startMs: nextMs,
714
+ endMs: Number.isFinite(endMsRaw) ? endMsRaw : null,
715
+ });
716
+ setTimingAdjustMode("keep");
717
+ setTimingAdjustOpen(true);
453
718
  }, [
454
719
  canEditTaskStartTime,
455
720
  taskStartDraft,
456
721
  task.startTime,
457
722
  task.id,
458
723
  inspectingId,
459
- post,
724
+ task.endTime,
460
725
  smoothTaskTimer,
461
726
  ]);
462
727
 
@@ -476,27 +741,76 @@ export function TaskSessionLiveCard({
476
741
  return;
477
742
  }
478
743
  const nextIso = new Date(endMs).toISOString();
479
- if (nextIso === task.endTime) {
744
+ if (nextIso === (task.endTime ?? task.scheduledEndAt)) {
745
+ return;
746
+ }
747
+ setPendingTimingEdit({
748
+ kind: "end",
749
+ nextIso,
750
+ startMs,
751
+ endMs,
752
+ });
753
+ setTimingAdjustMode("keep");
754
+ setTimingAdjustOpen(true);
755
+ }, [
756
+ canEditTaskEndTime,
757
+ taskEndDraft,
758
+ task.startTime,
759
+ task.endTime,
760
+ task.id,
761
+ inspectingId,
762
+ ]);
763
+
764
+ const confirmTimingAdjust = useCallback(() => {
765
+ const edit = pendingTimingEdit;
766
+ if (!edit) {
767
+ setTimingAdjustOpen(false);
480
768
  return;
481
769
  }
482
- setOptimisticDoneDurationMin((endMs - startMs) / 60000);
483
770
  const body: Record<string, unknown> = {
484
- type: "setTaskEndTime",
771
+ type: edit.kind === "start" ? "setTaskStartTime" : "setTaskEndTime",
485
772
  taskId: task.id,
486
- endTime: nextIso,
773
+ durationAdjustMode: timingAdjustMode,
774
+ ...(edit.kind === "start"
775
+ ? { startTime: edit.nextIso }
776
+ : { endTime: edit.nextIso }),
487
777
  };
778
+ if (timingAdjustMode === "manual") {
779
+ const h = Math.max(0, Math.floor(Number(manualHours) || 0));
780
+ const m = Math.max(0, Math.floor(Number(manualMinutes) || 0));
781
+ const s = Math.max(0, Math.floor(Number(manualSeconds) || 0));
782
+ body.manualDurationMs = (h * 3600 + m * 60 + s) * 1000;
783
+ }
488
784
  if (inspectingId) {
489
785
  body.sessionId = inspectingId;
490
786
  }
787
+ if (
788
+ edit.kind === "start" &&
789
+ smoothTaskTimer &&
790
+ timingAdjustMode === "from_bounds"
791
+ ) {
792
+ setOptimisticLiveBaseMs(Math.max(0, Date.now() - edit.startMs));
793
+ }
794
+ if (
795
+ edit.kind === "end" &&
796
+ timingAdjustMode === "from_bounds" &&
797
+ edit.endMs !== null
798
+ ) {
799
+ setOptimisticDoneDurationMin((edit.endMs - edit.startMs) / 60000);
800
+ }
801
+ setTimingAdjustOpen(false);
802
+ setPendingTimingEdit(null);
491
803
  void post(body);
492
804
  }, [
493
- canEditTaskEndTime,
494
- taskEndDraft,
495
- task.startTime,
496
- task.endTime,
497
- task.id,
498
805
  inspectingId,
806
+ manualHours,
807
+ manualMinutes,
808
+ manualSeconds,
809
+ pendingTimingEdit,
499
810
  post,
811
+ smoothTaskTimer,
812
+ task.id,
813
+ timingAdjustMode,
500
814
  ]);
501
815
 
502
816
  const shellBase =
@@ -518,18 +832,54 @@ export function TaskSessionLiveCard({
518
832
  variant === "plain"
519
833
  ? "hover:bg-zinc-200/60 dark:hover:bg-zinc-800/40"
520
834
  : "hover:bg-zinc-200/70 dark:hover:bg-zinc-800/40";
835
+ const titleMainClass = temporalPlanned
836
+ ? variant === "plain"
837
+ ? "text-sky-900 dark:text-sky-100"
838
+ : "text-sky-950 dark:text-sky-50"
839
+ : isDone
840
+ ? titleDoneClass
841
+ : titleActiveClass;
842
+ const templateBtnClass = isAlreadyTemplate
843
+ ? "inline-flex h-10 w-10 items-center justify-center rounded-lg border border-emerald-400/70 bg-emerald-50 text-emerald-700 hover:bg-emerald-100 dark:border-emerald-700/70 dark:bg-emerald-950/40 dark:text-emerald-300 dark:hover:bg-emerald-900/55"
844
+ : "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";
845
+ const templateBtnLabel = isAlreadyTemplate
846
+ ? t.taskAlreadyTemplateBtn
847
+ : t.taskSaveTemplateBtn;
521
848
 
522
849
  return (
523
850
  <>
524
- <div id={anchorId} className={shellClass}>
851
+ <div
852
+ id={anchorId}
853
+ className={[
854
+ shellClass,
855
+ temporalPlanned
856
+ ? "border-sky-400/85 bg-sky-50/80 dark:border-sky-500/55 dark:bg-sky-950/35"
857
+ : "",
858
+ taskIsRunning && !temporalPlanned
859
+ ? "kronosys-task-running-pulse"
860
+ : "",
861
+ taskIsPaused ? "kronosys-task-paused-blink" : "",
862
+ finishCelebrate ? "kronosys-task-finish-celebrate" : "",
863
+ ]
864
+ .filter(Boolean)
865
+ .join(" ")}
866
+ >
525
867
  {trimmedProject ? (
526
868
  <div className="mb-1.5 flex min-w-0 justify-start sm:mb-2">
527
869
  <span
528
- className={`${PROJECT_CHIP_APPLIED_CLASS} inline-flex items-center gap-1 max-w-full truncate pr-1`}
529
- title={formatProjectDisplay(trimmedProject)}
870
+ className={`${
871
+ isPersonalProject
872
+ ? PROJECT_CHIP_APPLIED_PERSONAL_CLASS
873
+ : PROJECT_CHIP_APPLIED_CLASS
874
+ } inline-flex items-center gap-1 max-w-full truncate pr-1`}
875
+ title={formatProjectDisplay(trimmedProject, {
876
+ personal: isPersonalProject,
877
+ })}
530
878
  >
531
879
  <span className="truncate">
532
- {formatProjectDisplay(trimmedProject)}
880
+ {formatProjectDisplay(trimmedProject, {
881
+ personal: isPersonalProject,
882
+ })}
533
883
  </span>
534
884
  <button
535
885
  type="button"
@@ -541,6 +891,7 @@ export function TaskSessionLiveCard({
541
891
  name: task.name,
542
892
  tags: task.tags ?? [],
543
893
  project: null,
894
+ personalProject: false,
544
895
  };
545
896
  if (inspectingId) {
546
897
  body.sessionId = inspectingId;
@@ -600,9 +951,7 @@ export function TaskSessionLiveCard({
600
951
  aria-label={t.activeTaskTitleEditHint}
601
952
  >
602
953
  <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
- }`}
954
+ 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} ${titleMainClass}`}
606
955
  >
607
956
  <span className="whitespace-nowrap">
608
957
  {titleDisplay || "—"}
@@ -611,26 +960,54 @@ export function TaskSessionLiveCard({
611
960
  </div>
612
961
  )}
613
962
  </div>
614
- {!isDone && !isInspecting ? (
963
+ {!isDone &&
964
+ !temporalPlanned &&
965
+ (!isInspecting || showLiveTimerWhenInspecting) ? (
615
966
  <span
616
967
  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"}
968
+ aria-live={smoothTaskTimer ? "polite" : undefined}
618
969
  title={formatStopwatchMs(liveTaskStopwatchMs)}
619
970
  >
620
971
  {formatStopwatchMs(liveTaskStopwatchMs)}
621
972
  </span>
622
973
  ) : 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
- ) : (
974
+ {showTaskActionButtons && isInspecting ? (
975
+ <div className="flex h-10 shrink-0 items-center gap-1">
976
+ <button
977
+ type="button"
978
+ 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"
979
+ title={t.taskDuplicateTooltip}
980
+ aria-label={t.taskDuplicateBtn}
981
+ onClick={() => onDuplicateTask(task)}
982
+ >
983
+ <Copy size={18} />
984
+ </button>
985
+ {onSaveAsTemplate ? (
986
+ <button
987
+ type="button"
988
+ className={templateBtnClass}
989
+ title={templateBtnLabel}
990
+ aria-label={templateBtnLabel}
991
+ onClick={() => onSaveAsTemplate(task)}
992
+ >
993
+ {isAlreadyTemplate ? (
994
+ <Bookmark size={18} />
995
+ ) : (
996
+ <Save size={18} />
997
+ )}
998
+ </button>
999
+ ) : null}
1000
+ <button
1001
+ type="button"
1002
+ 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"
1003
+ title={t.taskDeleteBtn}
1004
+ aria-label={t.taskDeleteBtn}
1005
+ onClick={() => confirmDeleteTask(task.id)}
1006
+ >
1007
+ <Trash2 size={20} />
1008
+ </button>
1009
+ </div>
1010
+ ) : showTaskActionButtons ? (
634
1011
  <div className="flex h-10 shrink-0 items-center gap-1">
635
1012
  {!isDone && showKronoFocusTaskActions ? (
636
1013
  <button
@@ -647,30 +1024,6 @@ export function TaskSessionLiveCard({
647
1024
  <Timer size={20} />
648
1025
  </button>
649
1026
  ) : 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
1027
  {pausePlayMode === null ? (
675
1028
  <div className="h-10 w-10 shrink-0" aria-hidden />
676
1029
  ) : (
@@ -706,6 +1059,30 @@ export function TaskSessionLiveCard({
706
1059
  >
707
1060
  <CheckCircle2 size={20} />
708
1061
  </button>
1062
+ <button
1063
+ type="button"
1064
+ 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"
1065
+ title={t.taskDuplicateTooltip}
1066
+ aria-label={t.taskDuplicateBtn}
1067
+ onClick={() => onDuplicateTask(task)}
1068
+ >
1069
+ <Copy size={18} />
1070
+ </button>
1071
+ {onSaveAsTemplate ? (
1072
+ <button
1073
+ type="button"
1074
+ className={templateBtnClass}
1075
+ title={templateBtnLabel}
1076
+ aria-label={templateBtnLabel}
1077
+ onClick={() => onSaveAsTemplate(task)}
1078
+ >
1079
+ {isAlreadyTemplate ? (
1080
+ <Bookmark size={18} />
1081
+ ) : (
1082
+ <Save size={18} />
1083
+ )}
1084
+ </button>
1085
+ ) : null}
709
1086
  <button
710
1087
  type="button"
711
1088
  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"
@@ -716,11 +1093,11 @@ export function TaskSessionLiveCard({
716
1093
  <Trash2 size={20} />
717
1094
  </button>
718
1095
  </div>
719
- )}
1096
+ ) : null}
720
1097
  </div>
721
1098
 
722
1099
  {showDoneTimingRow || canEditTaskStartTime || canEditTaskEndTime ? (
723
- <div aria-label={doneTimingAria || undefined}>
1100
+ <div className="mt-1.5" aria-label={doneTimingAria || undefined}>
724
1101
  <div className="flex min-w-0 flex-wrap items-center gap-x-4 gap-y-2">
725
1102
  <TaskTimingFieldRow
726
1103
  label={t.archiveTaskStartLabel}
@@ -739,11 +1116,13 @@ export function TaskSessionLiveCard({
739
1116
  draftValue={taskEndDraft}
740
1117
  onDraftChange={setTaskEndDraft}
741
1118
  onDraftBlur={applyTaskEndTimeEdit}
1119
+ defaultTimeMode="next-half-hour"
1120
+ alertClassName={taskEndAlertClassName}
742
1121
  lang={lang}
743
1122
  t={t}
744
1123
  />
745
1124
  {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">
1125
+ <div className="inline-flex min-w-0 items-center gap-2 whitespace-nowrap text-[0.7rem] leading-[1.35] text-zinc-600 dark:text-zinc-400">
747
1126
  <span className="text-[0.65rem] font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-500">
748
1127
  {t.taskTimingDurationLabel}
749
1128
  </span>
@@ -753,40 +1132,99 @@ export function TaskSessionLiveCard({
753
1132
  </div>
754
1133
  ) : null}
755
1134
  </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}
1135
+ <div className="mt-3">
1136
+ <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">
1137
+ {mergedTags.length > 0 ? (
1138
+ <TagPills
1139
+ variant="applied"
1140
+ differentiateProjectScopedTags
1141
+ className="flex min-w-0 max-w-full flex-wrap items-baseline gap-x-1 gap-y-0.5"
1142
+ tags={mergedTags}
1143
+ defaultTagBucketLabel={t.taskTagDefaultBucket}
1144
+ taskProject={task.project ?? null}
1145
+ taskPersonalProject={task.personalProject === true}
1146
+ onRemove={(tagToRemove) => {
1147
+ const nextTags = mergedTags.filter(
1148
+ (t) =>
1149
+ normalizeTagKey(t) !== normalizeTagKey(tagToRemove),
1150
+ );
1151
+ const filteredTags = filterTaskTagsForProject(
1152
+ nextTags,
1153
+ task.project ?? null,
1154
+ );
1155
+ const body: Record<string, unknown> = {
1156
+ type: "updateTask",
1157
+ taskId: task.id,
1158
+ name: task.name,
1159
+ tags: filteredTags,
1160
+ };
1161
+ if (task.project !== undefined) {
1162
+ body.project = task.project;
1163
+ }
1164
+ if (task.personalProject === true) {
1165
+ body.personalProject = true;
1166
+ }
1167
+ if (inspectingId) {
1168
+ body.sessionId = inspectingId;
1169
+ }
1170
+ void post(body);
1171
+ }}
1172
+ />
1173
+ ) : null}
1174
+ </div>
1175
+ </div>
1176
+ </div>
1177
+ ) : null}
1178
+ {taskTimerLaps.length > 0 ? (
1179
+ <div className="mt-3 rounded-lg border border-zinc-200/80 bg-zinc-50/80 p-2.5 dark:border-zinc-700/70 dark:bg-zinc-900/35">
1180
+ <div className="mb-1.5 text-[0.65rem] font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
1181
+ {lang === "fr" ? "Laps de suivi" : "Tracking laps"}
1182
+ </div>
1183
+ <div className="space-y-1">
1184
+ {taskTimerLaps.map((lap) => (
1185
+ <div
1186
+ key={lap.id}
1187
+ className="flex min-w-0 items-center justify-between gap-2 text-[0.72rem] leading-tight text-zinc-700 dark:text-zinc-300"
1188
+ >
1189
+ <span className="shrink-0 tabular-nums text-zinc-500 dark:text-zinc-400">
1190
+ #{lap.index}
1191
+ </span>
1192
+ <span className="min-w-0 flex-1 truncate tabular-nums">
1193
+ {lap.startLabel} - {lap.endLabel}
1194
+ </span>
1195
+ <span className="shrink-0 tabular-nums font-medium text-zinc-900 dark:text-zinc-100">
1196
+ {lap.durationLabel}
1197
+ </span>
1198
+ </div>
1199
+ ))}
1200
+ </div>
789
1201
  </div>
1202
+ ) : null}
1203
+ <div className="mt-3">
1204
+ <textarea
1205
+ className="w-full rounded-lg border border-zinc-300 bg-white/90 px-3 py-2 text-sm text-zinc-800 outline-none transition focus:border-violet-500 dark:border-zinc-700 dark:bg-zinc-900/70 dark:text-zinc-100"
1206
+ placeholder={t.taskNotePlaceholder}
1207
+ value={noteDraft}
1208
+ onChange={(e) => setNoteDraft(e.target.value)}
1209
+ onBlur={() => {
1210
+ if (
1211
+ noteDraft === (typeof task.note === "string" ? task.note : "")
1212
+ ) {
1213
+ return;
1214
+ }
1215
+ const body: Record<string, unknown> = {
1216
+ type: "updateTask",
1217
+ taskId: task.id,
1218
+ note: noteDraft,
1219
+ };
1220
+ if (inspectingId) {
1221
+ body.sessionId = inspectingId;
1222
+ }
1223
+ void post(body);
1224
+ }}
1225
+ rows={3}
1226
+ aria-label={t.taskNoteLabel}
1227
+ />
790
1228
  </div>
791
1229
  <TaskSubtasksBlock
792
1230
  taskId={task.id}
@@ -794,15 +1232,12 @@ export function TaskSessionLiveCard({
794
1232
  subtasks={task.subtasks}
795
1233
  enableSubtaskTimer={!isInspecting && !isDone}
796
1234
  activeSubtaskTimerId={task.activeSubtaskTimerId}
797
- allowAddSubtasks={!isDone}
1235
+ allowAddSubtasks={allowAddSubtasks && !isDone}
798
1236
  subtasksReadOnly={isDone}
799
1237
  t={t}
800
1238
  post={post}
801
1239
  />
802
1240
  </div>
803
- ) : null}
804
- </div>
805
-
806
1241
 
807
1242
  <DashboardConfirmModal
808
1243
  open={finishOpenSubtasksWarnOpen}
@@ -827,6 +1262,86 @@ export function TaskSessionLiveCard({
827
1262
  runFinishTask();
828
1263
  }}
829
1264
  />
1265
+ <DashboardConfirmModal
1266
+ open={timingAdjustOpen}
1267
+ title={t.taskTimingAdjustModalTitle}
1268
+ message={t.taskTimingAdjustModalMessage}
1269
+ cancelLabel={t.dialogCancelBtn}
1270
+ confirmLabel={t.dialogConfirmBtn}
1271
+ onCancel={() => {
1272
+ setTimingAdjustOpen(false);
1273
+ setPendingTimingEdit(null);
1274
+ }}
1275
+ onConfirm={confirmTimingAdjust}
1276
+ extra={
1277
+ <div className="space-y-3 text-sm">
1278
+ <label className="flex items-start gap-2">
1279
+ <input
1280
+ type="radio"
1281
+ name={`kronosys-task-timing-adjust-mode-${timingAdjustRadioGroupId}`}
1282
+ checked={timingAdjustMode === "keep"}
1283
+ onChange={() => setTimingAdjustMode("keep")}
1284
+ />
1285
+ <span>{t.taskTimingAdjustKeepDuration}</span>
1286
+ </label>
1287
+ <label className="flex items-start gap-2">
1288
+ <input
1289
+ type="radio"
1290
+ name={`kronosys-task-timing-adjust-mode-${timingAdjustRadioGroupId}`}
1291
+ checked={timingAdjustMode === "manual"}
1292
+ onChange={() => setTimingAdjustMode("manual")}
1293
+ />
1294
+ <span>{t.taskTimingAdjustManualDuration}</span>
1295
+ </label>
1296
+ {timingAdjustMode === "manual" ? (
1297
+ <div className="grid grid-cols-3 gap-2">
1298
+ <label className="text-xs">
1299
+ {t.taskTimingAdjustHours}
1300
+ <input
1301
+ type="number"
1302
+ min={0}
1303
+ value={manualHours}
1304
+ onChange={(e) => setManualHours(e.target.value)}
1305
+ className="mt-1 w-full rounded border border-zinc-300 px-2 py-1 dark:border-zinc-600 dark:bg-zinc-900"
1306
+ />
1307
+ </label>
1308
+ <label className="text-xs">
1309
+ {t.taskTimingAdjustMinutes}
1310
+ <input
1311
+ type="number"
1312
+ min={0}
1313
+ max={59}
1314
+ value={manualMinutes}
1315
+ onChange={(e) => setManualMinutes(e.target.value)}
1316
+ className="mt-1 w-full rounded border border-zinc-300 px-2 py-1 dark:border-zinc-600 dark:bg-zinc-900"
1317
+ />
1318
+ </label>
1319
+ <label className="text-xs">
1320
+ {t.taskTimingAdjustSeconds}
1321
+ <input
1322
+ type="number"
1323
+ min={0}
1324
+ max={59}
1325
+ value={manualSeconds}
1326
+ onChange={(e) => setManualSeconds(e.target.value)}
1327
+ className="mt-1 w-full rounded border border-zinc-300 px-2 py-1 dark:border-zinc-600 dark:bg-zinc-900"
1328
+ />
1329
+ </label>
1330
+ </div>
1331
+ ) : null}
1332
+ <label className="flex items-start gap-2">
1333
+ <input
1334
+ type="radio"
1335
+ name={`kronosys-task-timing-adjust-mode-${timingAdjustRadioGroupId}`}
1336
+ checked={timingAdjustMode === "from_bounds"}
1337
+ onChange={() => setTimingAdjustMode("from_bounds")}
1338
+ disabled={pendingTimingEdit?.endMs === null}
1339
+ />
1340
+ <span>{t.taskTimingAdjustFromBounds}</span>
1341
+ </label>
1342
+ </div>
1343
+ }
1344
+ />
830
1345
  </>
831
1346
  );
832
1347
  }