@nightkatana/kronosys-app 1.0.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. package/README.md +81 -0
  2. package/app/api/action/route.ts +16 -0
  3. package/app/api/backup/route.ts +84 -0
  4. package/app/api/health/route.ts +22 -0
  5. package/app/api/state/route.ts +27 -0
  6. package/app/apple-icon.png +0 -0
  7. package/app/changelog/page.tsx +122 -0
  8. package/app/globals.css +210 -0
  9. package/app/guide/layout.tsx +11 -0
  10. package/app/guide/page.tsx +278 -0
  11. package/app/icon.png +0 -0
  12. package/app/layout.tsx +77 -0
  13. package/app/licenses/layout.tsx +11 -0
  14. package/app/licenses/page.tsx +246 -0
  15. package/app/manifest.ts +32 -0
  16. package/app/page.tsx +1610 -0
  17. package/app/reporting/page.tsx +2943 -0
  18. package/app/settings/layout.tsx +10 -0
  19. package/app/settings/page.tsx +3518 -0
  20. package/bin/kronosys.mjs +46 -0
  21. package/components/KronosysPackageVersionProvider.tsx +19 -0
  22. package/components/KronosysPayloadProvider.tsx +109 -0
  23. package/components/PwaRegister.tsx +25 -0
  24. package/components/SiteLegalFooter.tsx +21 -0
  25. package/components/ThemeProvider.tsx +78 -0
  26. package/components/dashboard/AppShellLiveSessionDrawer.tsx +394 -0
  27. package/components/dashboard/AppShellRouteNav.tsx +131 -0
  28. package/components/dashboard/AppVersionStamp.tsx +16 -0
  29. package/components/dashboard/DashboardCollapsibleSection.tsx +57 -0
  30. package/components/dashboard/DashboardColumnHintsBanner.tsx +159 -0
  31. package/components/dashboard/DashboardCommandCenter.tsx +470 -0
  32. package/components/dashboard/DashboardLangGateModal.tsx +118 -0
  33. package/components/dashboard/DashboardLoadingOverlay.tsx +42 -0
  34. package/components/dashboard/DashboardSimpleModal.tsx +337 -0
  35. package/components/dashboard/DashboardSuspenseFallback.tsx +52 -0
  36. package/components/dashboard/DashboardToastProvider.tsx +64 -0
  37. package/components/dashboard/DashboardTour.tsx +435 -0
  38. package/components/dashboard/DeferredDescriptionPopoverWrap.tsx +39 -0
  39. package/components/dashboard/DeleteSessionModal.tsx +130 -0
  40. package/components/dashboard/DescriptionTooltipPortaled.tsx +31 -0
  41. package/components/dashboard/GitIdentityQuickSetupModal.tsx +211 -0
  42. package/components/dashboard/HeaderIntegrationBadges.tsx +69 -0
  43. package/components/dashboard/InlineMetricHelpTrigger.tsx +102 -0
  44. package/components/dashboard/IssuePickerModal.tsx +168 -0
  45. package/components/dashboard/KronoFocusPanel.tsx +834 -0
  46. package/components/dashboard/KronosysDatetimePopoverField.tsx +357 -0
  47. package/components/dashboard/KronosysTimePopoverField.tsx +233 -0
  48. package/components/dashboard/LanguageMenu.tsx +123 -0
  49. package/components/dashboard/MongoMirrorSyncLine.tsx +57 -0
  50. package/components/dashboard/NewSessionScopeModal.tsx +410 -0
  51. package/components/dashboard/PageRefreshButton.tsx +130 -0
  52. package/components/dashboard/PlainHelpPopover.tsx +97 -0
  53. package/components/dashboard/ReportingPageToc.tsx +68 -0
  54. package/components/dashboard/ReportingTour.tsx +342 -0
  55. package/components/dashboard/SavedProjectPicker.tsx +92 -0
  56. package/components/dashboard/SavedTagPicker.tsx +115 -0
  57. package/components/dashboard/ScrollToTopFab.tsx +41 -0
  58. package/components/dashboard/SelectedSessionSidebarBlock.tsx +630 -0
  59. package/components/dashboard/SessionEndReasonEditor.tsx +114 -0
  60. package/components/dashboard/SessionListPanel.tsx +320 -0
  61. package/components/dashboard/SessionLocMetricsSection.tsx +128 -0
  62. package/components/dashboard/SettingsTagsProjectsSection.tsx +993 -0
  63. package/components/dashboard/SettingsTour.tsx +332 -0
  64. package/components/dashboard/TagPills.tsx +149 -0
  65. package/components/dashboard/TagsHelpTrigger.tsx +84 -0
  66. package/components/dashboard/TaskFocusPanel.tsx +1261 -0
  67. package/components/dashboard/TaskSessionLiveCard.tsx +832 -0
  68. package/components/dashboard/TaskSubtasksBlock.tsx +748 -0
  69. package/components/dashboard/ThemeToggle.test.tsx +26 -0
  70. package/components/dashboard/ThemeToggle.tsx +36 -0
  71. package/components/dashboard/UserGuideBodyText.tsx +62 -0
  72. package/components/dashboard/WorkspaceGitRepoCard.tsx +191 -0
  73. package/components/dashboard/taskFieldStyles.ts +139 -0
  74. package/components/dashboard/useAnchoredFloatingPortalStyle.ts +71 -0
  75. package/components/dashboard/useDescriptionPopoverAfterMs.ts +220 -0
  76. package/components/dashboard/useKronoFocusLiveSeconds.ts +36 -0
  77. package/components/dashboard/useSmoothStopwatchMs.ts +25 -0
  78. package/lib/appShellHeaderClasses.ts +12 -0
  79. package/lib/backupCsvExport.test.ts +149 -0
  80. package/lib/backupCsvExport.ts +392 -0
  81. package/lib/changelogCopy.ts +34 -0
  82. package/lib/concurrentTaskStartPreference.ts +29 -0
  83. package/lib/dashboardClockFormat.ts +13 -0
  84. package/lib/dashboardColumnChrome.ts +3 -0
  85. package/lib/dashboardColumnHintsStorage.ts +57 -0
  86. package/lib/dashboardCopy.ts +1831 -0
  87. package/lib/dashboardDetachedUrlHintStorage.ts +24 -0
  88. package/lib/dashboardGitIdentityBannerStorage.ts +36 -0
  89. package/lib/dashboardLangStorage.ts +72 -0
  90. package/lib/dashboardQuickSearch.ts +476 -0
  91. package/lib/dashboardQuickSearchQuery.test.ts +63 -0
  92. package/lib/dashboardQuickSearchQuery.ts +179 -0
  93. package/lib/dashboardSessionNav.ts +33 -0
  94. package/lib/dashboardShortcuts.ts +268 -0
  95. package/lib/dashboardTimeZone.ts +91 -0
  96. package/lib/dashboardTourStorage.ts +68 -0
  97. package/lib/dataDir.test.ts +87 -0
  98. package/lib/dataDir.ts +83 -0
  99. package/lib/devDataPreferenceFile.ts +55 -0
  100. package/lib/devDataRuntimeInfo.ts +34 -0
  101. package/lib/formatIsoShort.test.ts +46 -0
  102. package/lib/formatIsoShort.ts +29 -0
  103. package/lib/generatedUserChangelog.ts +34 -0
  104. package/lib/gitlabIssueSearch.ts +8 -0
  105. package/lib/kronoFocusDurationHistory.ts +71 -0
  106. package/lib/kronoFocusRhythm.test.ts +130 -0
  107. package/lib/kronoFocusRhythm.ts +46 -0
  108. package/lib/kronoFocusTimerUrgency.test.ts +74 -0
  109. package/lib/kronoFocusTimerUrgency.ts +24 -0
  110. package/lib/kronosysApi.ts +143 -0
  111. package/lib/legacyEditorPayloadKeys.ts +52 -0
  112. package/lib/legacyKronoFocusStorageKeys.test.ts +29 -0
  113. package/lib/legacyKronoFocusStorageKeys.ts +32 -0
  114. package/lib/licensesCopy.ts +128 -0
  115. package/lib/openPlainTextInNewTab.ts +49 -0
  116. package/lib/readKronosysPackageVersion.ts +10 -0
  117. package/lib/reportingAggregate.test.ts +325 -0
  118. package/lib/reportingAggregate.ts +819 -0
  119. package/lib/reportingDatePresets.ts +41 -0
  120. package/lib/reportingMetricHelp.ts +430 -0
  121. package/lib/reportingNonFinalIndicators.test.ts +157 -0
  122. package/lib/reportingNonFinalIndicators.ts +102 -0
  123. package/lib/reportingStrings.ts +491 -0
  124. package/lib/reportingTagWeekBreakdown.test.ts +141 -0
  125. package/lib/reportingTagWeekBreakdown.ts +181 -0
  126. package/lib/reportingWeekLayout.test.ts +239 -0
  127. package/lib/reportingWeekLayout.ts +313 -0
  128. package/lib/sessionAssiduity.test.ts +25 -0
  129. package/lib/sessionAssiduity.ts +33 -0
  130. package/lib/sessionEndReason.ts +55 -0
  131. package/lib/sessionEndWarnings.test.ts +200 -0
  132. package/lib/sessionEndWarnings.ts +125 -0
  133. package/lib/sessionListMerge.test.ts +101 -0
  134. package/lib/sessionListMerge.ts +70 -0
  135. package/lib/sessionTaskSidebarStats.test.ts +24 -0
  136. package/lib/sessionTaskSidebarStats.ts +54 -0
  137. package/lib/settingsCopy.ts +1276 -0
  138. package/lib/taskParsing.test.ts +153 -0
  139. package/lib/taskParsing.ts +737 -0
  140. package/lib/theme.ts +15 -0
  141. package/lib/translucentButtonClasses.ts +34 -0
  142. package/lib/usageProfile.test.ts +84 -0
  143. package/lib/usageProfile.ts +52 -0
  144. package/lib/userGuideCopy.ts +464 -0
  145. package/lib/workspaceLocDefaults.ts +21 -0
  146. package/next-env.d.ts +6 -0
  147. package/next.config.ts +15 -0
  148. package/package.json +87 -0
  149. package/postcss.config.mjs +12 -0
  150. package/public/apple-icon.png +0 -0
  151. package/public/favicon.ico +0 -0
  152. package/public/file.svg +1 -0
  153. package/public/globe.svg +1 -0
  154. package/public/icon-192.png +0 -0
  155. package/public/icon-512.png +0 -0
  156. package/public/icon.png +0 -0
  157. package/public/next.svg +1 -0
  158. package/public/sw.js +13 -0
  159. package/public/traceback.png +0 -0
  160. package/public/vercel.svg +1 -0
  161. package/public/window.svg +1 -0
  162. package/server/actionDispatch.test.ts +723 -0
  163. package/server/actionDispatch.ts +1476 -0
  164. package/server/actionTaskSession.test.ts +713 -0
  165. package/server/actionTaskSession.ts +717 -0
  166. package/server/db.ts +42 -0
  167. package/server/defaultCfg.ts +87 -0
  168. package/server/gitlabTokenStore.ts +34 -0
  169. package/server/kronoFocusHydrate.test.ts +142 -0
  170. package/server/kronoFocusHydrate.ts +69 -0
  171. package/server/kronoFocusMigrate.test.ts +53 -0
  172. package/server/kronoFocusMigrate.ts +78 -0
  173. package/server/mainTimerHydrate.test.ts +65 -0
  174. package/server/mainTimerHydrate.ts +53 -0
  175. package/server/payloadStore.test.ts +78 -0
  176. package/server/payloadStore.ts +83 -0
  177. package/server/sessionWallHydrate.test.ts +46 -0
  178. package/server/sessionWallHydrate.ts +88 -0
  179. package/tsconfig.json +41 -0
@@ -0,0 +1,630 @@
1
+ "use client";
2
+
3
+ import type { RefObject } from "react";
4
+ import { useEffect, useMemo, useState } from "react";
5
+ import { CheckCircle2, Pencil } from "lucide-react";
6
+ import { InlineMetricHelpTrigger } from "@/components/dashboard/InlineMetricHelpTrigger";
7
+ import { SessionLocMetricsSection } from "@/components/dashboard/SessionLocMetricsSection";
8
+ import { WorkspaceGitRepoCard } from "@/components/dashboard/WorkspaceGitRepoCard";
9
+ import { formatDuration, formatWallDurationMs } from "@/lib/taskParsing";
10
+ import { tbEmeraldIcon } from "@/lib/translucentButtonClasses";
11
+ import type { DashboardStrings, Lang } from "@/lib/dashboardCopy";
12
+ import type { GitRepoStatisticsPayload } from "@/lib/kronosysApi";
13
+ import { formatSessionEndReasonLine } from "@/lib/sessionEndReason";
14
+ import { SessionEndReasonEditor } from "@/components/dashboard/SessionEndReasonEditor";
15
+ import { countSessionTasksForSidebar } from "@/lib/sessionTaskSidebarStats";
16
+ import { useSmoothStopwatchDisplayMs } from "@/components/dashboard/useSmoothStopwatchMs";
17
+ import {
18
+ KronosysDatetimePopoverField,
19
+ formatDatetimeLocalValue,
20
+ } from "@/components/dashboard/KronosysDatetimePopoverField";
21
+ import { formatIsoInstantShort } from "@/lib/formatIsoShort";
22
+ import { DEFAULT_DASHBOARD_TIME_ZONE } from "@/lib/dashboardTimeZone";
23
+
24
+ type SessionMetricsShape = {
25
+ sessionId?: string;
26
+ sessionName?: string;
27
+ archived?: boolean;
28
+ /** Session en pause (collecte) : la durée murale ne progresse pas tant que la session est en pause. */
29
+ isPaused?: boolean;
30
+ /** Horodatage immuable de création de la session ; repli : `startAt` pour les anciennes données. */
31
+ createdAt?: string | null;
32
+ /** Début officiel de la session (ISO 8601), aligné sur l’historique / la liste. */
33
+ startAt?: string | null;
34
+ endAt?: string | null;
35
+ sessionEndReasonKind?: string;
36
+ sessionEndReasonNote?: string;
37
+ sessionDurationMinutes?: number;
38
+ codingMinutesSession?: number;
39
+ activeMinutes?: number;
40
+ totalEvents?: number;
41
+ tasks?: Array<{
42
+ id?: string;
43
+ isDone?: boolean;
44
+ manualTaskTimerPaused?: boolean;
45
+ }>;
46
+ activeTasks?: Array<{
47
+ id?: string;
48
+ isDone?: boolean;
49
+ manualTaskTimerPaused?: boolean;
50
+ }>;
51
+ activeTask?: {
52
+ id?: string;
53
+ isDone?: boolean;
54
+ manualTaskTimerPaused?: boolean;
55
+ } | null;
56
+ linesWrittenTotal?: number;
57
+ linesWrittenHuman?: number;
58
+ linesWrittenAi?: number;
59
+ locByLanguage?: Array<[string, number]>;
60
+ codingSignalsByLanguage?: Array<[string, number]>;
61
+ };
62
+
63
+ export function SelectedSessionSidebarBlock({
64
+ lang,
65
+ t,
66
+ sessionCurrent,
67
+ columnArchiveId,
68
+ sessionName,
69
+ setSessionName,
70
+ sessionNameInputRef,
71
+ sessionNameFieldActiveRef,
72
+ sessionNameRowFocused,
73
+ setSessionNameRowFocused,
74
+ post,
75
+ headerSessionLabel,
76
+ headerSessionDuration,
77
+ headerSessionStart,
78
+ displayTimeZone = DEFAULT_DASHBOARD_TIME_ZONE,
79
+ use24HourClock = true,
80
+ headerCoding,
81
+ headerActive,
82
+ headerTasks,
83
+ archivedBadge,
84
+ trackCodeMetrics,
85
+ showIdeCodeTimingMetrics,
86
+ gitStats,
87
+ liveSessionId,
88
+ onEndLiveSession,
89
+ sessionDurationAlertThresholdMinutes,
90
+ allowSessionStartTimeEdit = true,
91
+ allowSessionEndTimeEdit = true,
92
+ }: {
93
+ lang: Lang;
94
+ t: DashboardStrings;
95
+ sessionCurrent: SessionMetricsShape | undefined;
96
+ columnArchiveId: string | null;
97
+ sessionName: string;
98
+ setSessionName: (v: string) => void;
99
+ sessionNameInputRef: RefObject<HTMLInputElement | null>;
100
+ sessionNameFieldActiveRef: RefObject<boolean>;
101
+ sessionNameRowFocused: boolean;
102
+ setSessionNameRowFocused: (v: boolean) => void;
103
+ post: (body: Record<string, unknown>) => Promise<unknown>;
104
+ headerSessionLabel: string;
105
+ headerSessionDuration: string;
106
+ headerSessionStart: string;
107
+ /** Fuseau IANA pour l’affichage de `startAt` (paramètres tableau de bord). */
108
+ displayTimeZone?: string;
109
+ /** Format 12 h / 24 h pour `startAt` (paramètres tableau de bord). */
110
+ use24HourClock?: boolean;
111
+ headerCoding: string;
112
+ headerActive: string;
113
+ headerTasks: string;
114
+ archivedBadge: string;
115
+ trackCodeMetrics: boolean;
116
+ /** Temps de codage / temps actif : afficher seulement en profil développeur avec métriques code. */
117
+ showIdeCodeTimingMetrics: boolean;
118
+ gitStats: GitRepoStatisticsPayload | undefined;
119
+ /** Identifiant de la session en cours ; sert à n’afficher le bouton de fin que sur la session live. */
120
+ liveSessionId?: string;
121
+ /** Terminer la session en cours (même flux que la liste des sessions). */
122
+ onEndLiveSession?: () => void;
123
+ /** Minutes murales : au-delà de ce seuil, la durée de session s’affiche en alerte (rouge, clignotement lent). */
124
+ sessionDurationAlertThresholdMinutes: number;
125
+ /** Option `dashboardAllowSessionStartTimeEdit` : correction du début de session. */
126
+ allowSessionStartTimeEdit?: boolean;
127
+ /** Option `dashboardAllowSessionEndTimeEdit` : correction de la fin de session (terminée). */
128
+ allowSessionEndTimeEdit?: boolean;
129
+ }) {
130
+ const liveSid =
131
+ typeof sessionCurrent?.sessionId === "string"
132
+ ? sessionCurrent.sessionId.trim()
133
+ : "";
134
+ const hasSessionContext = columnArchiveId !== null || liveSid !== "";
135
+ const liveSidNorm =
136
+ typeof liveSessionId === "string" ? liveSessionId.trim() : "";
137
+ const showEndLiveSession =
138
+ typeof onEndLiveSession === "function" &&
139
+ columnArchiveId === null &&
140
+ liveSidNorm !== "" &&
141
+ liveSid === liveSidNorm &&
142
+ sessionCurrent?.archived !== true;
143
+ const [optimisticSessionWallMinutes, setOptimisticSessionWallMinutes] =
144
+ useState<number | null>(null);
145
+ const [derivedSessionWallMinutes, setDerivedSessionWallMinutes] = useState<
146
+ number | null
147
+ >(null);
148
+ const [sessionEndDraft, setSessionEndDraft] = useState("");
149
+
150
+ const sessionWallMinutes =
151
+ optimisticSessionWallMinutes !== null
152
+ ? optimisticSessionWallMinutes
153
+ : derivedSessionWallMinutes !== null
154
+ ? derivedSessionWallMinutes
155
+ : (sessionCurrent?.sessionDurationMinutes ?? 0);
156
+ const sessionEnded =
157
+ typeof sessionCurrent?.endAt === "string" &&
158
+ sessionCurrent.endAt.trim() !== "";
159
+ const wallClockMsBase = useMemo(
160
+ () => Math.max(0, Math.floor(sessionWallMinutes * 60_000)),
161
+ [sessionWallMinutes],
162
+ );
163
+ /** Affichage fluide à la seconde (ou mieux) pour la session live affichée, entre deux rafraîchissements API. */
164
+ const smoothSessionWall =
165
+ columnArchiveId === null &&
166
+ liveSidNorm !== "" &&
167
+ liveSid === liveSidNorm &&
168
+ sessionCurrent?.archived !== true &&
169
+ !sessionEnded &&
170
+ sessionCurrent?.isPaused !== true;
171
+ const sessionWallDisplayMs = useSmoothStopwatchDisplayMs(
172
+ wallClockMsBase,
173
+ smoothSessionWall,
174
+ );
175
+ const sessionWallDisplayMinutes = sessionWallDisplayMs / 60_000;
176
+ const threshold = Math.max(1, sessionDurationAlertThresholdMinutes);
177
+ const sessionDurationOverThreshold = sessionWallDisplayMinutes >= threshold;
178
+ const taskCounts = countSessionTasksForSidebar(sessionCurrent);
179
+ const endReasonLine = sessionCurrent
180
+ ? formatSessionEndReasonLine(
181
+ t,
182
+ sessionCurrent.sessionEndReasonKind,
183
+ sessionCurrent.sessionEndReasonNote,
184
+ )
185
+ : null;
186
+ const inspectingArchive = columnArchiveId !== null;
187
+ const showSessionEndReason =
188
+ Boolean(endReasonLine) && (sessionEnded || inspectingArchive);
189
+ const inspectingLiveRunning =
190
+ columnArchiveId !== null &&
191
+ liveSidNorm !== "" &&
192
+ columnArchiveId === liveSidNorm &&
193
+ liveSid === liveSidNorm &&
194
+ !sessionEnded;
195
+ let targetSessionEndReasonId = "";
196
+ if (typeof columnArchiveId === "string" && columnArchiveId.trim() !== "") {
197
+ targetSessionEndReasonId = columnArchiveId.trim();
198
+ } else if (typeof sessionCurrent?.sessionId === "string") {
199
+ targetSessionEndReasonId = sessionCurrent.sessionId.trim();
200
+ }
201
+ const canEditSessionEndReason =
202
+ hasSessionContext &&
203
+ targetSessionEndReasonId !== "" &&
204
+ !inspectingLiveRunning &&
205
+ (sessionEnded || inspectingArchive);
206
+ const canEditSessionStartTime =
207
+ allowSessionStartTimeEdit &&
208
+ hasSessionContext &&
209
+ typeof sessionCurrent?.startAt === "string" &&
210
+ sessionCurrent.startAt.trim() !== "";
211
+ const canEditSessionEndTime =
212
+ allowSessionEndTimeEdit &&
213
+ hasSessionContext &&
214
+ sessionEnded &&
215
+ typeof sessionCurrent?.startAt === "string" &&
216
+ sessionCurrent.startAt.trim() !== "" &&
217
+ typeof sessionCurrent?.endAt === "string" &&
218
+ sessionCurrent.endAt.trim() !== "";
219
+ const [sessionStartDraft, setSessionStartDraft] = useState("");
220
+
221
+ useEffect(() => {
222
+ const raw =
223
+ typeof sessionCurrent?.startAt === "string"
224
+ ? sessionCurrent.startAt.trim()
225
+ : "";
226
+ const parsed = raw ? new Date(raw) : null;
227
+ if (!parsed || Number.isNaN(parsed.getTime())) {
228
+ setSessionStartDraft("");
229
+ return;
230
+ }
231
+ setSessionStartDraft(formatDatetimeLocalValue(parsed));
232
+ }, [sessionCurrent?.sessionId, sessionCurrent?.startAt]);
233
+
234
+ useEffect(() => {
235
+ const raw =
236
+ typeof sessionCurrent?.endAt === "string"
237
+ ? sessionCurrent.endAt.trim()
238
+ : "";
239
+ const parsed = raw ? new Date(raw) : null;
240
+ if (!parsed || Number.isNaN(parsed.getTime())) {
241
+ setSessionEndDraft("");
242
+ return;
243
+ }
244
+ setSessionEndDraft(formatDatetimeLocalValue(parsed));
245
+ }, [sessionCurrent?.sessionId, sessionCurrent?.endAt]);
246
+
247
+ useEffect(() => {
248
+ setOptimisticSessionWallMinutes(null);
249
+ }, [
250
+ sessionCurrent?.sessionId,
251
+ sessionCurrent?.sessionDurationMinutes,
252
+ sessionCurrent?.endAt,
253
+ ]);
254
+
255
+ useEffect(() => {
256
+ const startMs =
257
+ typeof sessionCurrent?.startAt === "string"
258
+ ? Date.parse(sessionCurrent.startAt)
259
+ : Number.NaN;
260
+ if (!Number.isFinite(startMs)) {
261
+ setDerivedSessionWallMinutes(null);
262
+ return;
263
+ }
264
+ const endMs =
265
+ typeof sessionCurrent?.endAt === "string"
266
+ ? Date.parse(sessionCurrent.endAt)
267
+ : Number.NaN;
268
+ if (Number.isFinite(endMs) && endMs >= startMs) {
269
+ setDerivedSessionWallMinutes((endMs - startMs) / 60000);
270
+ return;
271
+ }
272
+ if (smoothSessionWall) {
273
+ setDerivedSessionWallMinutes(Math.max(0, (Date.now() - startMs) / 60000));
274
+ return;
275
+ }
276
+ setDerivedSessionWallMinutes(null);
277
+ }, [sessionCurrent?.startAt, sessionCurrent?.endAt, smoothSessionWall]);
278
+
279
+ const applySessionStartTimeEdit = () => {
280
+ if (!canEditSessionStartTime) {
281
+ return;
282
+ }
283
+ const startMs = Date.parse(sessionStartDraft);
284
+ if (!Number.isFinite(startMs)) {
285
+ return;
286
+ }
287
+ const startAt = new Date(startMs).toISOString();
288
+ if (startAt === sessionCurrent?.startAt) {
289
+ return;
290
+ }
291
+ if (smoothSessionWall) {
292
+ setOptimisticSessionWallMinutes(
293
+ Math.max(0, (Date.now() - startMs) / 60000),
294
+ );
295
+ }
296
+ void post({
297
+ type: "setSessionStartTime",
298
+ sessionId: targetSessionEndReasonId,
299
+ startAt,
300
+ });
301
+ };
302
+
303
+ const applySessionEndTimeEdit = () => {
304
+ if (!canEditSessionEndTime || !sessionCurrent) {
305
+ return;
306
+ }
307
+ const endMs = Date.parse(sessionEndDraft);
308
+ if (!Number.isFinite(endMs)) {
309
+ return;
310
+ }
311
+ const startMs = Date.parse(String(sessionCurrent.startAt));
312
+ if (!Number.isFinite(startMs) || endMs < startMs) {
313
+ return;
314
+ }
315
+ const endAt = new Date(endMs).toISOString();
316
+ if (endAt === sessionCurrent.endAt) {
317
+ return;
318
+ }
319
+ setOptimisticSessionWallMinutes(Math.max(0, (endMs - startMs) / 60000));
320
+ void post({
321
+ type: "setSessionEndTime",
322
+ sessionId: targetSessionEndReasonId,
323
+ endAt,
324
+ });
325
+ };
326
+
327
+ const sessionStartFormatted = useMemo(() => {
328
+ const raw = typeof sessionCurrent?.startAt === "string" ? sessionCurrent.startAt.trim() : "";
329
+ if (!raw) {
330
+ return null;
331
+ }
332
+ return formatIsoInstantShort(raw, lang, displayTimeZone, use24HourClock);
333
+ }, [sessionCurrent?.startAt, displayTimeZone, lang, use24HourClock]);
334
+ const sessionCreatedFormatted = useMemo(() => {
335
+ const raw =
336
+ typeof sessionCurrent?.createdAt === "string" && sessionCurrent.createdAt.trim() !== ""
337
+ ? sessionCurrent.createdAt.trim()
338
+ : typeof sessionCurrent?.startAt === "string"
339
+ ? sessionCurrent.startAt.trim()
340
+ : "";
341
+ if (!raw) {
342
+ return null;
343
+ }
344
+ return formatIsoInstantShort(raw, lang, displayTimeZone, use24HourClock);
345
+ }, [sessionCurrent?.createdAt, sessionCurrent?.startAt, displayTimeZone, lang, use24HourClock]);
346
+ const sessionNameFallback =
347
+ liveSid !== ""
348
+ ? sessionCreatedFormatted
349
+ ? `${liveSid.slice(0, 8)} · ${sessionCreatedFormatted}`
350
+ : liveSid.slice(0, 8)
351
+ : lang === "fr"
352
+ ? "Nom de la session"
353
+ : "Session name";
354
+
355
+ return (
356
+ <div className="flex min-w-0 flex-col gap-5 rounded-xl border border-zinc-200 bg-white/90 p-5 text-center shadow-sm sm:p-6 dark:border-zinc-800 dark:bg-zinc-800/40 dark:shadow-none">
357
+ <div>
358
+ <h2 className="text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
359
+ {t.selectedSessionSidebarTitle}
360
+ </h2>
361
+ {hasSessionContext ? (
362
+ <div className="mt-2 flex min-w-0 flex-wrap items-center justify-center gap-x-2 gap-y-1">
363
+ <div className="flex min-w-0 max-w-full flex-1 basis-48 items-center justify-center gap-1.5 sm:max-w-md">
364
+ <input
365
+ ref={sessionNameInputRef}
366
+ id="kronosys-session-name-sidebar"
367
+ aria-label={headerSessionLabel}
368
+ className="min-w-0 flex-1 border-0 border-b border-zinc-300/90 bg-transparent py-1 text-sm font-medium text-zinc-800 outline-none transition placeholder:text-zinc-400 hover:border-zinc-400 focus:border-violet-500/80 focus:ring-0 dark:border-zinc-600/80 dark:text-zinc-100 dark:placeholder:text-zinc-500 dark:hover:border-zinc-500"
369
+ placeholder={sessionNameFallback}
370
+ value={sessionName}
371
+ onChange={(e) => setSessionName(e.target.value)}
372
+ onFocus={() => {
373
+ sessionNameFieldActiveRef.current = true;
374
+ setSessionNameRowFocused(true);
375
+ }}
376
+ onBlur={async (e) => {
377
+ const name = e.currentTarget.value;
378
+ try {
379
+ if (columnArchiveId) {
380
+ await post({
381
+ type: "setSessionName",
382
+ name,
383
+ sessionId: columnArchiveId,
384
+ });
385
+ } else {
386
+ await post({ type: "setSessionName", name });
387
+ }
388
+ } finally {
389
+ sessionNameFieldActiveRef.current = false;
390
+ setSessionNameRowFocused(false);
391
+ }
392
+ }}
393
+ />
394
+ {!sessionNameRowFocused ? (
395
+ <button
396
+ type="button"
397
+ className="shrink-0 rounded-md p-1 text-violet-600 transition hover:bg-violet-100 dark:text-violet-400/95 dark:hover:bg-violet-950/35 dark:hover:text-violet-300"
398
+ aria-label={
399
+ lang === "fr"
400
+ ? "Modifier le nom de la session"
401
+ : "Edit session name"
402
+ }
403
+ onMouseDown={(e) => {
404
+ e.preventDefault();
405
+ const el = sessionNameInputRef.current;
406
+ if (!el) {
407
+ return;
408
+ }
409
+ el.focus();
410
+ queueMicrotask(() => el.select());
411
+ }}
412
+ >
413
+ <Pencil size={16} strokeWidth={2.25} aria-hidden />
414
+ </button>
415
+ ) : null}
416
+ </div>
417
+ {sessionCurrent?.archived ? (
418
+ <span className="shrink-0 rounded border border-zinc-300 px-1.5 py-0.5 text-[0.65rem] font-semibold uppercase tracking-wide text-zinc-500 dark:border-zinc-600 dark:text-zinc-400">
419
+ {archivedBadge}
420
+ </span>
421
+ ) : null}
422
+ </div>
423
+ ) : (
424
+ <p className="mt-3 text-sm leading-relaxed text-zinc-600 dark:text-zinc-400">
425
+ {t.selectedSessionIdleHint}
426
+ </p>
427
+ )}
428
+ {hasSessionContext && sessionStartFormatted ? (
429
+ <p className="mt-2 flex flex-wrap items-center justify-center gap-x-1.5 gap-y-0.5 text-center text-[0.7rem] leading-tight text-zinc-600 dark:text-zinc-400">
430
+ <span className="font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-500">
431
+ {headerSessionStart}
432
+ </span>
433
+ <span className="font-mono font-medium text-zinc-800 dark:text-zinc-200">{sessionStartFormatted}</span>
434
+ </p>
435
+ ) : null}
436
+ </div>
437
+
438
+ {hasSessionContext ? (
439
+ <section className="grid justify-items-center gap-3 rounded-lg border border-zinc-200 bg-zinc-50/80 p-3 text-center sm:grid-cols-2 dark:border-zinc-700/80 dark:bg-zinc-900/35">
440
+ <div className="w-full max-w-[11rem] sm:max-w-none">
441
+ <div className="flex min-h-5 items-center justify-center gap-0.5">
442
+ <span className="text-[0.65rem] uppercase text-zinc-500 dark:text-zinc-500">
443
+ {headerSessionDuration}
444
+ </span>
445
+ <InlineMetricHelpTrigger
446
+ ariaLabel={t.statsMetricSessionDurationHelpAria}
447
+ body={t.statsMetricSessionDurationHelpBody}
448
+ />
449
+ </div>
450
+ <div
451
+ className={`text-xl font-semibold tabular-nums ${
452
+ sessionDurationOverThreshold
453
+ ? "kronosys-session-duration-alert"
454
+ : "text-zinc-900 dark:text-zinc-100"
455
+ }`}
456
+ title={
457
+ sessionDurationOverThreshold
458
+ ? lang === "fr"
459
+ ? `Durée murale au seuil d’alerte ou au-delà (${Math.round(threshold / 60)} h — Paramètres)`
460
+ : `Wall-clock duration at or past alert threshold (${Math.round(threshold / 60)} h — Settings)`
461
+ : undefined
462
+ }
463
+ >
464
+ {smoothSessionWall
465
+ ? formatWallDurationMs(sessionWallDisplayMs)
466
+ : formatDuration(sessionWallDisplayMinutes)}
467
+ </div>
468
+ </div>
469
+ {showIdeCodeTimingMetrics ? (
470
+ <>
471
+ <div className="w-full max-w-[11rem] sm:max-w-none">
472
+ <div className="flex min-h-5 items-center justify-center gap-0.5">
473
+ <span className="text-[0.65rem] uppercase text-zinc-500 dark:text-zinc-500">
474
+ {headerCoding}
475
+ </span>
476
+ <InlineMetricHelpTrigger
477
+ ariaLabel={t.statsMetricCodingTimeHelpAria}
478
+ body={t.statsMetricCodingTimeHelpBody}
479
+ />
480
+ </div>
481
+ <div className="text-xl font-semibold tabular-nums text-zinc-900 dark:text-zinc-100">
482
+ {formatDuration(sessionCurrent?.codingMinutesSession ?? 0)}
483
+ </div>
484
+ </div>
485
+ <div className="w-full max-w-[11rem] sm:max-w-none">
486
+ <div className="flex min-h-5 items-center justify-center gap-0.5">
487
+ <span className="text-[0.65rem] uppercase text-zinc-500 dark:text-zinc-500">
488
+ {headerActive}
489
+ </span>
490
+ <InlineMetricHelpTrigger
491
+ ariaLabel={t.statsMetricActiveTimeHelpAria}
492
+ body={t.statsMetricActiveTimeHelpBody}
493
+ />
494
+ </div>
495
+ <div className="text-xl font-semibold tabular-nums text-zinc-900 dark:text-zinc-100">
496
+ {formatDuration(sessionCurrent?.activeMinutes ?? 0)}
497
+ </div>
498
+ </div>
499
+ </>
500
+ ) : null}
501
+ <div className="w-full max-w-[13rem] sm:max-w-none">
502
+ <div className="flex min-h-5 items-center justify-center gap-0.5">
503
+ <span className="text-[0.65rem] uppercase text-zinc-500 dark:text-zinc-500">
504
+ {headerTasks}
505
+ </span>
506
+ <InlineMetricHelpTrigger
507
+ ariaLabel={t.statsMetricTasksHelpAria}
508
+ body={t.statsMetricTasksHelpBody}
509
+ align="end"
510
+ />
511
+ </div>
512
+ <div className="mx-auto mt-1 w-full max-w-[12.5rem] space-y-1 text-left text-[0.7rem] leading-tight text-zinc-800 dark:text-zinc-200">
513
+ <div className="flex items-baseline justify-between gap-2">
514
+ <span className="min-w-0 shrink text-zinc-500 dark:text-zinc-400">
515
+ {t.statsTasksRowRunning}
516
+ </span>
517
+ <span className="shrink-0 tabular-nums font-semibold text-zinc-900 dark:text-zinc-100">
518
+ {taskCounts.running}
519
+ </span>
520
+ </div>
521
+ <div className="flex items-baseline justify-between gap-2">
522
+ <span className="min-w-0 shrink text-zinc-500 dark:text-zinc-400">
523
+ {t.statsTasksRowPausedList}
524
+ </span>
525
+ <span className="shrink-0 tabular-nums font-semibold text-zinc-900 dark:text-zinc-100">
526
+ {taskCounts.pausedList}
527
+ </span>
528
+ </div>
529
+ <div className="flex items-baseline justify-between gap-2">
530
+ <span className="min-w-0 shrink text-zinc-500 dark:text-zinc-400">
531
+ {t.statsTasksRowCompleted}
532
+ </span>
533
+ <span className="shrink-0 tabular-nums font-semibold text-zinc-900 dark:text-zinc-100">
534
+ {taskCounts.completed}
535
+ </span>
536
+ </div>
537
+ </div>
538
+ </div>
539
+ </section>
540
+ ) : null}
541
+
542
+ {canEditSessionStartTime ? (
543
+ <div className="rounded-lg border border-zinc-200 bg-zinc-50/90 px-3 py-2.5 text-left text-zinc-700 dark:border-zinc-700/80 dark:bg-zinc-900/40 dark:text-zinc-300">
544
+ <div className="text-[0.65rem] font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-500">
545
+ {t.sessionStartTimeEditSectionTitle}
546
+ </div>
547
+ <div className="mt-2 flex min-w-0 flex-wrap items-center gap-2">
548
+ <KronosysDatetimePopoverField
549
+ value={sessionStartDraft}
550
+ onChange={setSessionStartDraft}
551
+ onBlur={applySessionStartTimeEdit}
552
+ aria-label={t.sessionStartTimeEditSectionTitle}
553
+ lang={lang}
554
+ t={t}
555
+ />
556
+ </div>
557
+ </div>
558
+ ) : null}
559
+
560
+ {canEditSessionEndTime ? (
561
+ <div className="rounded-lg border border-zinc-200 bg-zinc-50/90 px-3 py-2.5 text-left text-zinc-700 dark:border-zinc-700/80 dark:bg-zinc-900/40 dark:text-zinc-300">
562
+ <div className="text-[0.65rem] font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-500">
563
+ {t.sessionEndTimeEditSectionTitle}
564
+ </div>
565
+ <div className="mt-2 flex min-w-0 flex-wrap items-center gap-2">
566
+ <KronosysDatetimePopoverField
567
+ value={sessionEndDraft}
568
+ onChange={setSessionEndDraft}
569
+ onBlur={applySessionEndTimeEdit}
570
+ aria-label={t.sessionEndTimeEditSectionTitle}
571
+ lang={lang}
572
+ t={t}
573
+ />
574
+ </div>
575
+ </div>
576
+ ) : null}
577
+
578
+ {hasSessionContext && canEditSessionEndReason ? (
579
+ <div className="rounded-lg border border-zinc-200 bg-zinc-50/90 px-3 py-2.5 text-left text-zinc-700 dark:border-zinc-700/80 dark:bg-zinc-900/40 dark:text-zinc-300">
580
+ <div className="text-[0.65rem] font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-500">
581
+ {t.selectedSessionEndReasonTitle}
582
+ </div>
583
+ <div className="mt-2">
584
+ <SessionEndReasonEditor
585
+ t={t}
586
+ radioGroupName="kronosys-session-end-reason-edit-sidebar"
587
+ sessionId={targetSessionEndReasonId}
588
+ initialKind={sessionCurrent?.sessionEndReasonKind}
589
+ initialNote={sessionCurrent?.sessionEndReasonNote}
590
+ post={post}
591
+ />
592
+ </div>
593
+ </div>
594
+ ) : hasSessionContext && showSessionEndReason && endReasonLine ? (
595
+ <div className="rounded-lg border border-zinc-200 bg-zinc-50/90 px-3 py-2.5 text-left text-[0.75rem] leading-snug text-zinc-700 dark:border-zinc-700/80 dark:bg-zinc-900/40 dark:text-zinc-300">
596
+ <div className="text-[0.65rem] font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-500">
597
+ {t.selectedSessionEndReasonTitle}
598
+ </div>
599
+ <p className="mt-1 whitespace-pre-wrap">{endReasonLine}</p>
600
+ </div>
601
+ ) : null}
602
+
603
+ {hasSessionContext && trackCodeMetrics ? (
604
+ <div className="text-left">
605
+ <SessionLocMetricsSection session={sessionCurrent ?? {}} t={t} />
606
+ </div>
607
+ ) : null}
608
+
609
+ {trackCodeMetrics ? (
610
+ <div className="text-left">
611
+ <WorkspaceGitRepoCard git={gitStats} t={t} lang={lang} />
612
+ </div>
613
+ ) : null}
614
+
615
+ {showEndLiveSession ? (
616
+ <div className="flex justify-end">
617
+ <button
618
+ type="button"
619
+ className={tbEmeraldIcon}
620
+ aria-label={t.sessionEndLiveAria}
621
+ title={`${t.sessionEndLiveSidebarBtn} — ${t.sessionEndLiveTitle}`}
622
+ onClick={() => onEndLiveSession?.()}
623
+ >
624
+ <CheckCircle2 size={20} aria-hidden />
625
+ </button>
626
+ </div>
627
+ ) : null}
628
+ </div>
629
+ );
630
+ }