@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.
- package/README.md +28 -1
- package/app/api/action/route.ts +39 -3
- package/app/api/action-logs/route.ts +24 -0
- package/app/api/backup/route.ts +1 -1
- package/app/api/restore/route.ts +145 -0
- package/app/changelog/page.tsx +71 -4
- package/app/globals.css +127 -0
- package/app/guide/page.tsx +61 -15
- package/app/implementation/page.tsx +700 -0
- package/app/layout.tsx +14 -3
- package/app/licenses/page.tsx +99 -37
- package/app/logs/page.tsx +258 -0
- package/app/manifest.ts +5 -5
- package/app/page.tsx +784 -229
- package/app/reporting/page.tsx +1266 -474
- package/app/settings/page.tsx +252 -18
- package/bin/kronosys.mjs +140 -15
- package/components/KronosysPayloadProvider.tsx +2 -0
- package/components/RouteTransition.tsx +18 -0
- package/components/dashboard/AppShellCommandCenterPlaceholder.tsx +17 -0
- package/components/dashboard/AppShellHeaderSessionMeta.tsx +210 -0
- package/components/dashboard/AppShellHeaderWallClock.tsx +54 -0
- package/components/dashboard/AppShellLiveSessionDrawer.tsx +154 -38
- package/components/dashboard/AppShellRouteNav.tsx +323 -48
- package/components/dashboard/DashboardPauseBackdrop.tsx +50 -0
- package/components/dashboard/DashboardSimpleModal.tsx +168 -25
- package/components/dashboard/DashboardTour.tsx +115 -29
- package/components/dashboard/GlobalPauseConfirmModal.tsx +183 -0
- package/components/dashboard/KronosysDatetimePopoverField.tsx +167 -122
- package/components/dashboard/KronosysTimePopoverField.tsx +54 -12
- package/components/dashboard/NewSessionScopeModal.tsx +211 -20
- package/components/dashboard/PlannedTaskBoundaryConflictWatcher.tsx +275 -0
- package/components/dashboard/ReportingTour.tsx +87 -21
- package/components/dashboard/SavedProjectPicker.tsx +16 -3
- package/components/dashboard/SelectedSessionSidebarBlock.tsx +512 -142
- package/components/dashboard/SessionListPanel.tsx +327 -44
- package/components/dashboard/SettingsTagsProjectsSection.tsx +1073 -264
- package/components/dashboard/SettingsTaskTemplatesSection.tsx +316 -0
- package/components/dashboard/SettingsTour.tsx +86 -21
- package/components/dashboard/TagPills.tsx +14 -1
- package/components/dashboard/TaskFocusPanel.tsx +1081 -478
- package/components/dashboard/TaskSessionLiveCard.tsx +650 -135
- package/components/dashboard/TaskTimelineGanttModal.tsx +601 -0
- package/components/dashboard/taskFieldStyles.ts +20 -4
- package/components/dashboard/useReportingInteractionState.ts +80 -0
- package/lib/appShellHeaderClasses.ts +13 -0
- package/lib/businessRulesMatrix.ts +210 -0
- package/lib/copyToClipboard.ts +43 -0
- package/lib/dashboardCopy.ts +494 -84
- package/lib/dashboardQuickSearch.ts +54 -2
- package/lib/dashboardTimeZone.ts +109 -0
- package/lib/formatAppShellWallClock.ts +66 -0
- package/lib/formatSessionNameTemplate.ts +141 -0
- package/lib/generatedUserChangelog.ts +177 -6
- package/lib/globalPausePreview.ts +292 -0
- package/lib/implementationNotes.ts +1188 -0
- package/lib/kronosysApi.ts +6 -0
- package/lib/kronosysDashboardModalGates.ts +24 -0
- package/lib/plannedBoundaryAttention.ts +9 -0
- package/lib/plannedBoundaryConflict.ts +23 -0
- package/lib/reportingAggregate.ts +517 -75
- package/lib/reportingMetricHelp.ts +8 -0
- package/lib/reportingStrings.ts +37 -3
- package/lib/sessionListMerge.ts +4 -0
- package/lib/sessionTaskSidebarStats.ts +182 -21
- package/lib/settingsCopy.ts +178 -4
- package/lib/taskParsing.ts +360 -103
- package/lib/taskTemplateDraft.ts +135 -0
- package/lib/taskTimelineGantt.ts +265 -0
- package/lib/temporalDisplayPlanned.ts +71 -0
- package/lib/userGuideCopy.ts +121 -47
- package/next.config.ts +7 -0
- package/package.json +12 -24
- package/server/actionDispatch.ts +1000 -77
- package/server/actionTaskSession.ts +337 -24
- package/server/db.ts +7 -15
- package/server/dbSchema.ts +24 -0
- package/server/defaultCfg.ts +5 -0
- package/server/gitlabTokenStore.ts +0 -12
- package/server/liveHistorySync.ts +53 -0
- package/server/mainTimerHydrate.ts +38 -2
- package/server/payloadStore.ts +33 -11
- package/server/sessionWallHydrate.ts +66 -3
- package/server/userActionLog.ts +126 -0
- package/sonar-project.properties +11 -0
- package/tsconfig.json +2 -1
- package/components/dashboard/IssuePickerModal.tsx +0 -168
- package/components/dashboard/ThemeToggle.test.tsx +0 -26
- package/lib/backupCsvExport.test.ts +0 -149
- package/lib/dashboardQuickSearchQuery.test.ts +0 -63
- package/lib/dataDir.test.ts +0 -87
- package/lib/formatIsoShort.test.ts +0 -46
- package/lib/kronoFocusRhythm.test.ts +0 -130
- package/lib/kronoFocusTimerUrgency.test.ts +0 -74
- package/lib/legacyKronoFocusStorageKeys.test.ts +0 -29
- package/lib/reportingAggregate.test.ts +0 -325
- package/lib/reportingNonFinalIndicators.test.ts +0 -157
- package/lib/reportingTagWeekBreakdown.test.ts +0 -141
- package/lib/reportingWeekLayout.test.ts +0 -239
- package/lib/sessionAssiduity.test.ts +0 -25
- package/lib/sessionEndWarnings.test.ts +0 -200
- package/lib/sessionListMerge.test.ts +0 -101
- package/lib/sessionTaskSidebarStats.test.ts +0 -24
- package/lib/taskParsing.test.ts +0 -153
- package/lib/usageProfile.test.ts +0 -84
- package/server/actionDispatch.test.ts +0 -723
- package/server/actionTaskSession.test.ts +0 -713
- package/server/kronoFocusHydrate.test.ts +0 -142
- package/server/kronoFocusMigrate.test.ts +0 -53
- package/server/mainTimerHydrate.test.ts +0 -65
- package/server/payloadStore.test.ts +0 -78
- 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
|
-
|
|
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
|
-
/**
|
|
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-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
252
|
-
|
|
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
|
|
351
|
+
typeof effectiveEndIso === "string"
|
|
269
352
|
? formatIsoInstantShort(
|
|
270
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
342
|
-
|
|
343
|
-
|
|
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(
|
|
595
|
+
if (!Number.isFinite(segmentStartMs)) {
|
|
346
596
|
setDerivedLiveBaseMs(null);
|
|
347
597
|
return;
|
|
348
598
|
}
|
|
349
|
-
setDerivedLiveBaseMs(
|
|
350
|
-
|
|
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 :
|
|
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
|
|
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
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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={`${
|
|
529
|
-
|
|
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 &&
|
|
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" :
|
|
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
|
-
<
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
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="
|
|
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
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
(
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
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
|
}
|