@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
package/app/page.tsx ADDED
@@ -0,0 +1,1610 @@
1
+ "use client";
2
+
3
+ import {
4
+ Suspense,
5
+ useCallback,
6
+ useEffect,
7
+ useLayoutEffect,
8
+ useMemo,
9
+ useRef,
10
+ useState,
11
+ } from "react";
12
+ import { usePathname, useRouter, useSearchParams } from "next/navigation";
13
+ import { Clock, FolderOpen, Globe, Plus, User, X } from "lucide-react";
14
+ import { HeaderIntegrationBadges } from "@/components/dashboard/HeaderIntegrationBadges";
15
+ import {
16
+ postKronosysAction,
17
+ type GitRepoStatisticsPayload,
18
+ type KronosysUpdatePayload,
19
+ type WorkspaceCodeSnapshotPayload,
20
+ } from "@/lib/kronosysApi";
21
+ import { useKronosysPayload } from "@/components/KronosysPayloadProvider";
22
+ import { appShellHeaderClassName } from "@/lib/appShellHeaderClasses";
23
+ import { dashboardColumnTitleRowClassName } from "@/lib/dashboardColumnChrome";
24
+ import { AppVersionStamp } from "@/components/dashboard/AppVersionStamp";
25
+ import { dashboardStrings, type Lang } from "@/lib/dashboardCopy";
26
+ import {
27
+ readDashboardLangUserChosen,
28
+ readStoredDashboardLang,
29
+ writeDashboardLangUserChosen,
30
+ writeStoredDashboardLang,
31
+ } from "@/lib/dashboardLangStorage";
32
+ import {
33
+ readGitIdentityBannerDismissed,
34
+ writeGitIdentityBannerDismissed,
35
+ } from "@/lib/dashboardGitIdentityBannerStorage";
36
+ import {
37
+ readDashboardDetachedUrlHintDismissed,
38
+ writeDashboardDetachedUrlHintDismissed,
39
+ } from "@/lib/dashboardDetachedUrlHintStorage";
40
+ import { settingsCopy } from "@/lib/settingsCopy";
41
+ import { reportingNav } from "@/lib/reportingStrings";
42
+ import {
43
+ pathnameWithUpdatedSessionQuery,
44
+ withDashboardSessionParam,
45
+ } from "@/lib/dashboardSessionNav";
46
+ import type { EndLiveSessionWarningFlags } from "@/lib/sessionEndWarnings";
47
+ import {
48
+ formatEndLiveSessionModalIntro,
49
+ getEndLiveSessionWarningFlags,
50
+ } from "@/lib/sessionEndWarnings";
51
+ import { KronoFocusPanel } from "@/components/dashboard/KronoFocusPanel";
52
+ import { TaskFocusPanel } from "@/components/dashboard/TaskFocusPanel";
53
+ import { DeleteSessionModal } from "@/components/dashboard/DeleteSessionModal";
54
+ import {
55
+ DashboardAlertModal,
56
+ DashboardConfirmModal,
57
+ } from "@/components/dashboard/DashboardSimpleModal";
58
+ import {
59
+ SessionListPanel,
60
+ type SessionListEntry,
61
+ } from "@/components/dashboard/SessionListPanel";
62
+ import { SettingsTagsProjectsSection } from "@/components/dashboard/SettingsTagsProjectsSection";
63
+ import { InlineMetricHelpTrigger } from "@/components/dashboard/InlineMetricHelpTrigger";
64
+ import { LanguageMenu } from "@/components/dashboard/LanguageMenu";
65
+ import { SelectedSessionSidebarBlock } from "@/components/dashboard/SelectedSessionSidebarBlock";
66
+ import { ThemeToggle } from "@/components/dashboard/ThemeToggle";
67
+ import { PageRefreshButton } from "@/components/dashboard/PageRefreshButton";
68
+ import { AppShellRouteNav } from "@/components/dashboard/AppShellRouteNav";
69
+ import { DashboardColumnHintsBanner } from "@/components/dashboard/DashboardColumnHintsBanner";
70
+ import {
71
+ DashboardCommandCenter,
72
+ type DashboardCommandHandlers,
73
+ } from "@/components/dashboard/DashboardCommandCenter";
74
+ import { DashboardLoadingOverlay } from "@/components/dashboard/DashboardLoadingOverlay";
75
+ import { DashboardSuspenseFallback } from "@/components/dashboard/DashboardSuspenseFallback";
76
+ import { DashboardLangGateModal } from "@/components/dashboard/DashboardLangGateModal";
77
+ import { GitIdentityQuickSetupModal } from "@/components/dashboard/GitIdentityQuickSetupModal";
78
+ import { DashboardTour } from "@/components/dashboard/DashboardTour";
79
+ import { isDashboardTourCompleted } from "@/lib/dashboardTourStorage";
80
+ import { workspaceFolderPathStrings } from "@/lib/legacyEditorPayloadKeys";
81
+ import { mergeLiveSessionIntoHistory } from "@/lib/sessionListMerge";
82
+ import {
83
+ showIdeLinkedCodeTimingMetrics,
84
+ showWorkspaceFoldersEmptyMessage,
85
+ trackCodeMetricsFromCfg,
86
+ } from "@/lib/usageProfile";
87
+ import { buildDashboardQuickSearchItems } from "@/lib/dashboardQuickSearch";
88
+ import { readDashboardUse24HourClockFromCfg } from "@/lib/dashboardClockFormat";
89
+ import { readDashboardTimeZoneFromCfg } from "@/lib/dashboardTimeZone";
90
+ import { NewSessionScopeModal } from "@/components/dashboard/NewSessionScopeModal";
91
+ import { useKronosysPackageVersion } from "@/components/KronosysPackageVersionProvider";
92
+
93
+ type LiveTaskShape = {
94
+ id: string;
95
+ name?: string;
96
+ isDone?: boolean;
97
+ manualTaskTimerPaused?: boolean;
98
+ subtasks?: Array<{ done?: boolean }>;
99
+ };
100
+
101
+ type LiveShape = {
102
+ sessionId?: string;
103
+ sessionName?: string;
104
+ archived?: boolean;
105
+ endAt?: string | null;
106
+ sessionEndReasonKind?: string;
107
+ sessionEndReasonNote?: string;
108
+ /** Avertissement lié au périmètre de session (max horloge, calendrier, etc.). */
109
+ sessionScopeNotice?: { kind: "alert" | "info"; message: string } | null;
110
+ /** Session en pause (collecte / minuteurs). */
111
+ isPaused?: boolean;
112
+ /** Horodatage immuable de création de la session (ISO 8601). */
113
+ createdAt?: string | null;
114
+ /** Instant de création / début officiel de la session (ISO 8601). */
115
+ startAt?: string | null;
116
+ /** Temps écoulé (horloge murale) depuis le premier événement tracé dans la session. */
117
+ sessionDurationMinutes?: number;
118
+ codingMinutesSession?: number;
119
+ activeMinutes?: number;
120
+ totalEvents?: number;
121
+ language?: string;
122
+ activeTasks?: LiveTaskShape[];
123
+ activeTask?: LiveTaskShape | null;
124
+ tasks?: LiveTaskShape[];
125
+ kronoFocus?: {
126
+ mode: "work" | "break" | "longBreak";
127
+ status: "idle" | "running" | "paused";
128
+ timeLeftSeconds: number;
129
+ /** Horodatage fin de phase (ms) — décompte côté API serveur. */
130
+ kronoFocusDeadlineAtMs?: number;
131
+ workDurationSeconds?: number;
132
+ shortBreakDurationSeconds?: number;
133
+ longBreakDurationSeconds?: number;
134
+ linkedTaskId?: string;
135
+ linkedTaskName?: string;
136
+ };
137
+ linesWrittenTotal?: number;
138
+ linesWrittenHuman?: number;
139
+ linesWrittenAi?: number;
140
+ locByLanguage?: Array<[string, number]>;
141
+ codingSignalsByLanguage?: Array<[string, number]>;
142
+ };
143
+
144
+ type UrlSessionResolution =
145
+ | { mode: "none" }
146
+ | { mode: "loading" }
147
+ | { mode: "ok"; id: string }
148
+ | { mode: "invalid" };
149
+
150
+ function resolveUrlSession(
151
+ urlParam: string | null,
152
+ payload: KronosysUpdatePayload | null,
153
+ live: LiveShape | undefined,
154
+ history: SessionListEntry[],
155
+ historyArchived: SessionListEntry[],
156
+ ): UrlSessionResolution {
157
+ if (!urlParam) {
158
+ return { mode: "none" };
159
+ }
160
+ if (!payload) {
161
+ return { mode: "loading" };
162
+ }
163
+ const liveSid =
164
+ typeof live?.sessionId === "string" ? live.sessionId.trim() : "";
165
+ if (
166
+ (liveSid !== "" && urlParam === liveSid) ||
167
+ history.some((s) => s.sessionId === urlParam) ||
168
+ historyArchived.some((s) => s.sessionId === urlParam)
169
+ ) {
170
+ return { mode: "ok", id: urlParam };
171
+ }
172
+ return { mode: "invalid" };
173
+ }
174
+
175
+ function DashboardHome() {
176
+ const searchParams = useSearchParams();
177
+ const pathname = usePathname();
178
+ const router = useRouter();
179
+ const urlParam = searchParams.get("session");
180
+ const { payload, error, refresh } = useKronosysPayload();
181
+ const [sessionName, setSessionName] = useState("");
182
+ /** Évite d’écraser la saisie à chaque poll API (refresh toutes les 2 s). */
183
+ const sessionNameFieldActiveRef = useRef(false);
184
+ const sessionNameInputRef = useRef<HTMLInputElement>(null);
185
+ /** Session à répercuter sur les `router.push` (command center) ; lu au moment du clic. */
186
+ const dashboardNavSessionRef = useRef<string | undefined>(undefined);
187
+ const [sessionNameRowFocused, setSessionNameRowFocused] = useState(false);
188
+ const liveSessionIdSyncedRef = useRef<string | undefined>(undefined);
189
+ const viewedSessionForNameRef = useRef<string | undefined>(undefined);
190
+ const [deleteSessionId, setDeleteSessionId] = useState<string | null>(null);
191
+ const [mongoPushBusyId, setMongoPushBusyId] = useState<string | null>(null);
192
+ const [homeDialogAlert, setHomeDialogAlert] = useState<string | null>(null);
193
+ const [archiveConfirmSessionId, setArchiveConfirmSessionId] = useState<
194
+ string | null
195
+ >(null);
196
+ const [archiveDismissChecked, setArchiveDismissChecked] = useState(false);
197
+ const [endLiveSessionConfirmFlags, setEndLiveSessionConfirmFlags] =
198
+ useState<EndLiveSessionWarningFlags | null>(null);
199
+ const [endSessionReasonKind, setEndSessionReasonKind] = useState<string>("");
200
+ const [endSessionReasonNote, setEndSessionReasonNote] = useState("");
201
+ const [tourOpen, setTourOpen] = useState(false);
202
+ const [newSessionModalOpen, setNewSessionModalOpen] = useState(false);
203
+ const [gitBannerDismissed, setGitBannerDismissed] = useState(false);
204
+ const [gitIdentitySetupModalOpen, setGitIdentitySetupModalOpen] =
205
+ useState(false);
206
+ const [detachedUrlHintDismissed, setDetachedUrlHintDismissed] =
207
+ useState(false);
208
+ const [updateChangelogModalOpen, setUpdateChangelogModalOpen] =
209
+ useState(false);
210
+ const packageVersion = useKronosysPackageVersion();
211
+
212
+ useEffect(() => {
213
+ setGitBannerDismissed(readGitIdentityBannerDismissed());
214
+ }, []);
215
+
216
+ useEffect(() => {
217
+ setDetachedUrlHintDismissed(readDashboardDetachedUrlHintDismissed());
218
+ }, []);
219
+
220
+ useEffect(() => {
221
+ if (typeof globalThis.window === "undefined") {
222
+ return;
223
+ }
224
+ const key = "kronosys_last_seen_package_version_v1";
225
+ try {
226
+ const previous = globalThis.localStorage.getItem(key);
227
+ if (!previous) {
228
+ globalThis.localStorage.setItem(key, packageVersion);
229
+ return;
230
+ }
231
+ if (previous !== packageVersion) {
232
+ setUpdateChangelogModalOpen(true);
233
+ }
234
+ } catch {
235
+ // ignore localStorage unavailability
236
+ }
237
+ }, [packageVersion]);
238
+
239
+ useEffect(() => {
240
+ if (archiveConfirmSessionId !== null) {
241
+ setArchiveDismissChecked(false);
242
+ }
243
+ }, [archiveConfirmSessionId]);
244
+
245
+ const live = payload?.current as LiveShape | undefined;
246
+ const historyArchived = (payload?.historyArchived ||
247
+ []) as SessionListEntry[];
248
+ const history = useMemo(() => {
249
+ if (!payload) {
250
+ return [] as SessionListEntry[];
251
+ }
252
+ const raw = (payload.history || []) as SessionListEntry[];
253
+ return mergeLiveSessionIntoHistory(raw, live);
254
+ }, [payload, live]);
255
+ const urlResolution = resolveUrlSession(
256
+ urlParam,
257
+ payload,
258
+ live,
259
+ history,
260
+ historyArchived,
261
+ );
262
+ const sessionQueryMode = Boolean(urlParam);
263
+ const isDetachedUrlTab = urlResolution.mode === "ok";
264
+ const liveSidForUrlHint =
265
+ typeof live?.sessionId === "string" ? live.sessionId.trim() : "";
266
+ const urlSessionFocusId =
267
+ urlResolution.mode === "ok" ? urlResolution.id.trim() : "";
268
+ const detachedUrlHintExplainsArchive =
269
+ isDetachedUrlTab &&
270
+ urlSessionFocusId !== "" &&
271
+ urlSessionFocusId !== liveSidForUrlHint;
272
+ const showDetachedUrlHintBanner =
273
+ detachedUrlHintExplainsArchive && !detachedUrlHintDismissed;
274
+
275
+ const inspectingId = payload?.inspectingSessionId as
276
+ | string
277
+ | null
278
+ | undefined;
279
+ const columnArchiveId =
280
+ isDetachedUrlTab && urlResolution.id !== live?.sessionId
281
+ ? urlResolution.id
282
+ : (inspectingId ?? null);
283
+ const viewingSession = columnArchiveId
284
+ ? ((history.find((s) => s.sessionId === columnArchiveId) ||
285
+ historyArchived.find((s) => s.sessionId === columnArchiveId)) as
286
+ | LiveShape
287
+ | undefined)
288
+ : undefined;
289
+ const sessionCurrent = (viewingSession ?? live) as LiveShape | undefined;
290
+ const isInspecting = Boolean(columnArchiveId);
291
+
292
+ useEffect(() => {
293
+ if (!payload) {
294
+ return;
295
+ }
296
+ if (sessionNameFieldActiveRef.current) {
297
+ return;
298
+ }
299
+
300
+ const liveCur = payload.current as LiveShape | undefined;
301
+ const mergedHistory = mergeLiveSessionIntoHistory(
302
+ (payload.history || []) as SessionListEntry[],
303
+ liveCur,
304
+ );
305
+ const histArch = (payload.historyArchived || []) as SessionListEntry[];
306
+
307
+ if (columnArchiveId) {
308
+ const vs =
309
+ mergedHistory.find((s) => s.sessionId === columnArchiveId) ||
310
+ histArch.find((s) => s.sessionId === columnArchiveId);
311
+ const serverName = vs?.sessionName;
312
+ if (columnArchiveId !== viewedSessionForNameRef.current) {
313
+ viewedSessionForNameRef.current = columnArchiveId;
314
+ sessionNameFieldActiveRef.current = false;
315
+ setSessionName(typeof serverName === "string" ? serverName : "");
316
+ return;
317
+ }
318
+ if (typeof serverName === "string") {
319
+ setSessionName(serverName);
320
+ }
321
+ return;
322
+ }
323
+
324
+ viewedSessionForNameRef.current = undefined;
325
+ const sid = liveCur?.sessionId;
326
+ const serverName = liveCur?.sessionName;
327
+
328
+ if (sid !== liveSessionIdSyncedRef.current) {
329
+ liveSessionIdSyncedRef.current = sid;
330
+ sessionNameFieldActiveRef.current = false;
331
+ setSessionName(typeof serverName === "string" ? serverName : "");
332
+ return;
333
+ }
334
+
335
+ if (typeof serverName === "string") {
336
+ setSessionName(serverName);
337
+ }
338
+ }, [payload, columnArchiveId]);
339
+
340
+ const hasPayload = Boolean(payload);
341
+ const serverLang: Lang = live?.language === "fr" ? "fr" : "en";
342
+ /** Langue affichée : état local mis à jour par le sélecteur initial, le menu ou la synchro post-visite. */
343
+ const [uiLang, setUiLang] = useState<Lang>("en");
344
+ const [langGateOpen, setLangGateOpen] = useState(false);
345
+
346
+ useLayoutEffect(() => {
347
+ if (typeof window === "undefined") {
348
+ return;
349
+ }
350
+ const userChosen = readDashboardLangUserChosen();
351
+ const tourDone = isDashboardTourCompleted();
352
+ if (!userChosen && !tourDone) {
353
+ setLangGateOpen(true);
354
+ return;
355
+ }
356
+ const stored = readStoredDashboardLang();
357
+ if (stored) {
358
+ setUiLang(stored);
359
+ }
360
+ }, []);
361
+
362
+ const lang: Lang = uiLang;
363
+ const dt = dashboardStrings(lang);
364
+ const tagProjSettingsCopy = settingsCopy(lang);
365
+ const nav = reportingNav(lang);
366
+ const handleManualRefresh = useCallback(async () => {
367
+ return await refresh({ routerInvalidate: true });
368
+ }, [refresh]);
369
+
370
+ /** Pendant la barrière de langue, aligner l’arrière-plan sur la langue du navigateur sans écrire le stockage. */
371
+ useEffect(() => {
372
+ if (!hasPayload || !langGateOpen) {
373
+ return;
374
+ }
375
+ setUiLang(serverLang);
376
+ }, [hasPayload, serverLang, langGateOpen]);
377
+
378
+ /** Après la visite guidée, conserver la langue choisie pour le tableau de bord. */
379
+ useEffect(() => {
380
+ if (!hasPayload) {
381
+ return;
382
+ }
383
+ if (!isDashboardTourCompleted()) {
384
+ return;
385
+ }
386
+ writeStoredDashboardLang(serverLang);
387
+ setUiLang(serverLang);
388
+ }, [hasPayload, serverLang]);
389
+
390
+ const stripTourReplayParam = useCallback(() => {
391
+ if (searchParams.get("tour") !== "replay") {
392
+ return;
393
+ }
394
+ const next = new URLSearchParams(searchParams.toString());
395
+ next.delete("tour");
396
+ const q = next.toString();
397
+ router.replace(q ? `${pathname}?${q}` : pathname);
398
+ }, [pathname, router, searchParams]);
399
+
400
+ useEffect(() => {
401
+ if (!hasPayload) {
402
+ return;
403
+ }
404
+ if (langGateOpen) {
405
+ return;
406
+ }
407
+ if (searchParams.get("tour") === "replay") {
408
+ setTourOpen(true);
409
+ stripTourReplayParam();
410
+ return;
411
+ }
412
+ if (!isDashboardTourCompleted()) {
413
+ setTourOpen(true);
414
+ }
415
+ }, [hasPayload, langGateOpen, searchParams, stripTourReplayParam]);
416
+
417
+ useEffect(() => {
418
+ const sid =
419
+ typeof live?.sessionId === "string" ? live.sessionId.trim() : "";
420
+ if (!sid || live?.archived === true) {
421
+ return;
422
+ }
423
+ const t = dashboardStrings(lang);
424
+ const onBeforeUnload = (e: BeforeUnloadEvent) => {
425
+ e.preventDefault();
426
+ e.returnValue = t.beforeUnloadLiveSessionMessage;
427
+ };
428
+ window.addEventListener("beforeunload", onBeforeUnload);
429
+ return () => window.removeEventListener("beforeunload", onBeforeUnload);
430
+ }, [lang, live?.sessionId, live?.archived]);
431
+
432
+ const header =
433
+ lang === "fr"
434
+ ? {
435
+ title: "Kronosys",
436
+ apiError:
437
+ "Impossible de charger l’état Kronosys (API /api/state). Vérifiez que le serveur Next tourne et les permissions du fichier SQLite.",
438
+ session: "Nom de la session",
439
+ sessionDuration: "Durée de session",
440
+ sessionStart: "Début de session",
441
+ coding: "Temps de codage",
442
+ active: "Temps actif",
443
+ tasks: "Tâches",
444
+ newSession: "Nouvelle session",
445
+ en: "English",
446
+ fr: "Français",
447
+ }
448
+ : {
449
+ title: "Kronosys",
450
+ apiError:
451
+ "Cannot load Kronosys state (/api/state). Ensure the Next server is running and the SQLite data directory is writable.",
452
+ session: "Session name",
453
+ sessionDuration: "Session duration",
454
+ sessionStart: "Session start",
455
+ coding: "Coding time",
456
+ active: "Active time",
457
+ tasks: "Tasks",
458
+ newSession: "New session",
459
+ en: "English",
460
+ fr: "Français",
461
+ };
462
+
463
+ const post = useCallback(
464
+ async (body: Record<string, unknown>) => {
465
+ const res = await postKronosysAction(body);
466
+ await refresh();
467
+ const op = res.result?.sessionOp;
468
+ if (op && !op.ok) {
469
+ if (op.message) {
470
+ setHomeDialogAlert(op.message);
471
+ }
472
+ if (typeof op.revertedName === "string") {
473
+ setSessionName(op.revertedName);
474
+ }
475
+ }
476
+ return res;
477
+ },
478
+ [refresh],
479
+ );
480
+
481
+ const completeLangGate = useCallback(
482
+ (next: Lang) => {
483
+ writeStoredDashboardLang(next);
484
+ writeDashboardLangUserChosen();
485
+ setUiLang(next);
486
+ setLangGateOpen(false);
487
+ void post({ type: "setLanguage", lang: next });
488
+ },
489
+ [post],
490
+ );
491
+
492
+ const handlePushSessionToMongo = useCallback(
493
+ async (sessionId: string) => {
494
+ setMongoPushBusyId(sessionId);
495
+ try {
496
+ const res = await postKronosysAction({
497
+ type: "pushSessionToMongo",
498
+ sessionId,
499
+ });
500
+ await refresh();
501
+ const p = res.result?.pushSessionToMongo;
502
+ if (p && !p.ok) {
503
+ const msg =
504
+ p.error === "disabled"
505
+ ? dt.sessionMongoPushFailedDisabled
506
+ : p.error === "not_found"
507
+ ? dt.sessionMongoPushFailedNotFound
508
+ : p.error === "uri_incomplete"
509
+ ? dt.sessionMongoPushFailedUri
510
+ : dt.sessionMongoPushFailedMongo;
511
+ setHomeDialogAlert(msg);
512
+ }
513
+ } catch {
514
+ setHomeDialogAlert(dt.sessionMongoPushFailedMongo);
515
+ } finally {
516
+ setMongoPushBusyId(null);
517
+ }
518
+ },
519
+ [dt, refresh],
520
+ );
521
+
522
+ const deleteMoveTargets =
523
+ deleteSessionId !== null
524
+ ? history
525
+ .filter((s) => s.sessionId !== deleteSessionId)
526
+ .map((s) => ({
527
+ id: s.sessionId,
528
+ label: s.sessionName?.trim() || s.sessionId.slice(0, 8),
529
+ }))
530
+ : [];
531
+
532
+ const confirmArchiveSession = useCallback(
533
+ (sessionId: string) => {
534
+ if (payload?.dismissArchiveSessionConfirm === true) {
535
+ void post({ type: "archiveSession", sessionId, archived: true });
536
+ return;
537
+ }
538
+ setArchiveConfirmSessionId(sessionId);
539
+ },
540
+ [payload?.dismissArchiveSessionConfirm, post],
541
+ );
542
+
543
+ const handleDeleteConfirm = useCallback(
544
+ async (opts: { moveTasksToSessionId?: string }) => {
545
+ if (!deleteSessionId) {
546
+ return;
547
+ }
548
+ await post({
549
+ type: "deleteHistorySession",
550
+ sessionId: deleteSessionId,
551
+ moveTasksToSessionId: opts.moveTasksToSessionId,
552
+ });
553
+ setDeleteSessionId(null);
554
+ },
555
+ [deleteSessionId, post],
556
+ );
557
+
558
+ const postVoid = useCallback(
559
+ async (body: Record<string, unknown>) => {
560
+ await post(body);
561
+ },
562
+ [post],
563
+ );
564
+
565
+ const handleRequestEndLiveSession = useCallback(() => {
566
+ const flags = getEndLiveSessionWarningFlags(live);
567
+ setEndSessionReasonKind("");
568
+ setEndSessionReasonNote("");
569
+ setEndLiveSessionConfirmFlags(flags);
570
+ }, [live]);
571
+
572
+ const canEndLiveSessionShortcut =
573
+ typeof live?.sessionId === "string" &&
574
+ live.sessionId.trim() !== "" &&
575
+ live.archived !== true;
576
+
577
+ const commandHandlers = useMemo(
578
+ (): DashboardCommandHandlers => ({
579
+ newSession: () => setNewSessionModalOpen(true),
580
+ refresh: () => void handleManualRefresh(),
581
+ openReporting: () =>
582
+ void router.push(
583
+ withDashboardSessionParam(
584
+ "/reporting",
585
+ dashboardNavSessionRef.current,
586
+ ),
587
+ ),
588
+ openSettings: () =>
589
+ void router.push(
590
+ withDashboardSessionParam(
591
+ "/settings",
592
+ dashboardNavSessionRef.current,
593
+ ),
594
+ ),
595
+ openUserGuide: () =>
596
+ void router.push(
597
+ withDashboardSessionParam("/guide", dashboardNavSessionRef.current),
598
+ ),
599
+ focusSessions: () =>
600
+ document.getElementById("dashboard-col-sessions")?.scrollIntoView({
601
+ behavior: "smooth",
602
+ block: "start",
603
+ }),
604
+ focusTasks: () =>
605
+ document.getElementById("dashboard-col-tasks")?.scrollIntoView({
606
+ behavior: "smooth",
607
+ block: "start",
608
+ }),
609
+ focusTags: () =>
610
+ document.getElementById("dashboard-col-tags")?.scrollIntoView({
611
+ behavior: "smooth",
612
+ block: "start",
613
+ }),
614
+ toggleLang: () =>
615
+ void post({ type: "setLanguage", lang: lang === "fr" ? "en" : "fr" }),
616
+ endLiveSession: canEndLiveSessionShortcut
617
+ ? () => void handleRequestEndLiveSession()
618
+ : undefined,
619
+ }),
620
+ [
621
+ canEndLiveSessionShortcut,
622
+ handleManualRefresh,
623
+ handleRequestEndLiveSession,
624
+ lang,
625
+ post,
626
+ router,
627
+ ],
628
+ );
629
+
630
+ const cancelEndLiveSessionConfirm = useCallback(() => {
631
+ setEndLiveSessionConfirmFlags(null);
632
+ setEndSessionReasonKind("");
633
+ setEndSessionReasonNote("");
634
+ }, []);
635
+
636
+ const confirmEndLiveSession = useCallback(() => {
637
+ const kind = endSessionReasonKind;
638
+ const note = endSessionReasonNote.trim();
639
+ setEndLiveSessionConfirmFlags(null);
640
+ setEndSessionReasonKind("");
641
+ setEndSessionReasonNote("");
642
+ void post({
643
+ type: "endLiveSession",
644
+ ...(kind === "planned" ||
645
+ kind === "early" ||
646
+ kind === "overrun" ||
647
+ kind === "other"
648
+ ? { sessionEndReasonKind: kind }
649
+ : {}),
650
+ ...(note.length > 0 ? { sessionEndReasonNote: note } : {}),
651
+ });
652
+ }, [post, endSessionReasonKind, endSessionReasonNote]);
653
+
654
+ const handleSelectSession = useCallback(
655
+ async (sessionId: string) => {
656
+ if (sessionQueryMode) {
657
+ const liveSid =
658
+ typeof live?.sessionId === "string" ? live.sessionId.trim() : "";
659
+ const pickLive = sessionId === liveSid;
660
+ router.replace(
661
+ pathnameWithUpdatedSessionQuery(
662
+ pathname,
663
+ searchParams.toString(),
664
+ pickLive ? null : sessionId,
665
+ ),
666
+ );
667
+ if (pickLive) {
668
+ await postKronosysAction({ type: "inspectSession", sessionId: null });
669
+ await postKronosysAction({ type: "setPaused", paused: false });
670
+ }
671
+ await refresh();
672
+ return;
673
+ }
674
+ if (sessionId === live?.sessionId) {
675
+ await postKronosysAction({ type: "inspectSession", sessionId: null });
676
+ await postKronosysAction({ type: "setPaused", paused: false });
677
+ } else {
678
+ await postKronosysAction({ type: "inspectSession", sessionId });
679
+ }
680
+ await refresh();
681
+ },
682
+ [
683
+ live?.sessionId,
684
+ pathname,
685
+ refresh,
686
+ router,
687
+ searchParams,
688
+ sessionQueryMode,
689
+ ],
690
+ );
691
+
692
+ const scrollToSessionInList = useCallback((sessionId: string) => {
693
+ document.getElementById("dashboard-col-sessions")?.scrollIntoView({
694
+ behavior: "smooth",
695
+ block: "start",
696
+ });
697
+ globalThis.requestAnimationFrame(() => {
698
+ document.getElementById(`kronosys-session-${sessionId}`)?.scrollIntoView({
699
+ behavior: "smooth",
700
+ block: "nearest",
701
+ });
702
+ });
703
+ }, []);
704
+
705
+ const focusTasksColumnForSearch = useCallback(() => {
706
+ document.getElementById("dashboard-col-tasks")?.scrollIntoView({
707
+ behavior: "smooth",
708
+ block: "start",
709
+ });
710
+ }, []);
711
+
712
+ const scrollToTaskInPanel = useCallback((taskId: string) => {
713
+ const el =
714
+ document.getElementById(`kronosys-active-task-${taskId}`) ??
715
+ document.getElementById(`kronosys-task-${taskId}`);
716
+ el?.scrollIntoView({ behavior: "smooth", block: "nearest" });
717
+ }, []);
718
+
719
+ const sessionDurationAlertThresholdMinutes = useMemo(() => {
720
+ const raw = (payload?.cfg as Record<string, unknown> | undefined)
721
+ ?.dashboardSessionDurationAlertHours;
722
+ const h = typeof raw === "number" && Number.isFinite(raw) ? raw : 24;
723
+ const clamped = Math.min(Math.max(h, 1), 8760);
724
+ return Math.round(clamped * 60);
725
+ }, [payload?.cfg]);
726
+
727
+ const dashboardShowKronoFocusInHeader = useMemo(() => {
728
+ const raw = (payload?.cfg as Record<string, unknown> | undefined)
729
+ ?.dashboardShowKronoFocusInHeader;
730
+ return raw !== false;
731
+ }, [payload?.cfg]);
732
+
733
+ const dashboardShowKronoFocusInTaskOps = useMemo(() => {
734
+ const raw = (payload?.cfg as Record<string, unknown> | undefined)
735
+ ?.dashboardShowKronoFocusInTaskOps;
736
+ return raw !== false;
737
+ }, [payload?.cfg]);
738
+
739
+ const dashboardAllowTaskStartTimeEdit = useMemo(() => {
740
+ const raw = (payload?.cfg as Record<string, unknown> | undefined)
741
+ ?.dashboardAllowTaskStartTimeEdit;
742
+ return raw !== false;
743
+ }, [payload?.cfg]);
744
+
745
+ const dashboardAllowSessionStartTimeEdit = useMemo(() => {
746
+ const raw = (payload?.cfg as Record<string, unknown> | undefined)
747
+ ?.dashboardAllowSessionStartTimeEdit;
748
+ return raw !== false;
749
+ }, [payload?.cfg]);
750
+
751
+ const dashboardAllowTaskEndTimeEdit = useMemo(() => {
752
+ const raw = (payload?.cfg as Record<string, unknown> | undefined)
753
+ ?.dashboardAllowTaskEndTimeEdit;
754
+ return raw !== false;
755
+ }, [payload?.cfg]);
756
+
757
+ const dashboardAllowSessionEndTimeEdit = useMemo(() => {
758
+ const raw = (payload?.cfg as Record<string, unknown> | undefined)
759
+ ?.dashboardAllowSessionEndTimeEdit;
760
+ return raw !== false;
761
+ }, [payload?.cfg]);
762
+
763
+ const dashboardDisplayTimeZone = useMemo(
764
+ () => readDashboardTimeZoneFromCfg(payload?.cfg as Record<string, unknown> | undefined),
765
+ [payload?.cfg]
766
+ );
767
+ const dashboardUse24HourClock = useMemo(
768
+ () => readDashboardUse24HourClockFromCfg(payload?.cfg as Record<string, unknown> | undefined),
769
+ [payload?.cfg]
770
+ );
771
+
772
+ const quickSearchItems = useMemo(
773
+ () =>
774
+ buildDashboardQuickSearchItems({
775
+ history,
776
+ historyArchived,
777
+ sessionCurrent: sessionCurrent as
778
+ | Record<string, unknown>
779
+ | null
780
+ | undefined,
781
+ dt,
782
+ liveSearchContext:
783
+ live &&
784
+ typeof live.sessionId === "string" &&
785
+ live.sessionId.trim() !== ""
786
+ ? {
787
+ sessionId: live.sessionId.trim(),
788
+ sessionPaused: live.isPaused === true,
789
+ }
790
+ : null,
791
+ onSelectSession: (id) => {
792
+ void handleSelectSession(id);
793
+ },
794
+ scrollToSession: scrollToSessionInList,
795
+ focusTasksColumn: focusTasksColumnForSearch,
796
+ scrollToTask: scrollToTaskInPanel,
797
+ }),
798
+ [
799
+ dt,
800
+ focusTasksColumnForSearch,
801
+ handleSelectSession,
802
+ history,
803
+ historyArchived,
804
+ live,
805
+ scrollToSessionInList,
806
+ scrollToTaskInPanel,
807
+ sessionCurrent,
808
+ ],
809
+ );
810
+
811
+ const handleResumeActiveSession = useCallback(async () => {
812
+ if (sessionQueryMode) {
813
+ router.replace(
814
+ pathnameWithUpdatedSessionQuery(
815
+ pathname,
816
+ searchParams.toString(),
817
+ null,
818
+ ),
819
+ );
820
+ }
821
+ await postKronosysAction({ type: "inspectSession", sessionId: null });
822
+ await postKronosysAction({ type: "setPaused", paused: false });
823
+ await refresh();
824
+ }, [pathname, refresh, router, searchParams, sessionQueryMode]);
825
+
826
+ const openSessionInNewTab = useCallback(
827
+ (sessionId: string) => {
828
+ const url = `${globalThis.location.origin}${pathname}?session=${encodeURIComponent(sessionId)}`;
829
+ globalThis.open(url, "_blank", "noopener,noreferrer");
830
+ },
831
+ [pathname],
832
+ );
833
+
834
+ const selectedSessionId =
835
+ urlResolution.mode === "ok"
836
+ ? urlResolution.id
837
+ : urlResolution.mode === "loading" && urlParam
838
+ ? urlParam
839
+ : urlResolution.mode === "invalid"
840
+ ? live?.sessionId
841
+ : (inspectingId ?? live?.sessionId);
842
+
843
+ const trimmedSelectedSessionId =
844
+ typeof selectedSessionId === "string" ? selectedSessionId.trim() : "";
845
+ dashboardNavSessionRef.current =
846
+ trimmedSelectedSessionId !== "" ? trimmedSelectedSessionId : undefined;
847
+
848
+ const gitIdentity = payload?.gitIdentity;
849
+ const gitUserName =
850
+ typeof gitIdentity?.gitUserName === "string"
851
+ ? gitIdentity.gitUserName.trim()
852
+ : "";
853
+ const gitUserEmail =
854
+ typeof gitIdentity?.gitUserEmail === "string"
855
+ ? gitIdentity.gitUserEmail.trim()
856
+ : "";
857
+ const gitAccountLogin =
858
+ typeof gitIdentity?.gitAccountLogin === "string"
859
+ ? gitIdentity.gitAccountLogin.trim()
860
+ : "";
861
+ const gitContextLine = [
862
+ gitUserName || null,
863
+ gitUserEmail || null,
864
+ gitAccountLogin || null,
865
+ ]
866
+ .filter(Boolean)
867
+ .join(" · ");
868
+ const hasGitIdentityConfigured = Boolean(
869
+ gitUserName || gitUserEmail || gitAccountLogin,
870
+ );
871
+ const showGitIdentityBanner =
872
+ Boolean(payload) && !hasGitIdentityConfigured && !gitBannerDismissed;
873
+
874
+ const dismissGitIdentityBanner = useCallback(() => {
875
+ writeGitIdentityBannerDismissed();
876
+ setGitBannerDismissed(true);
877
+ }, []);
878
+
879
+ const dismissDetachedUrlHint = useCallback(() => {
880
+ writeDashboardDetachedUrlHintDismissed();
881
+ setDetachedUrlHintDismissed(true);
882
+ }, []);
883
+
884
+ const acknowledgePackageVersion = useCallback(() => {
885
+ if (typeof globalThis.window === "undefined") {
886
+ return;
887
+ }
888
+ try {
889
+ globalThis.localStorage.setItem(
890
+ "kronosys_last_seen_package_version_v1",
891
+ packageVersion,
892
+ );
893
+ } catch {
894
+ // ignore localStorage unavailability
895
+ }
896
+ }, [packageVersion]);
897
+
898
+ /** Après effacement des données ou retrait de la clé localStorage, resynchroniser si l’identité Git est encore vide. */
899
+ useEffect(() => {
900
+ if (!payload || hasGitIdentityConfigured) {
901
+ return;
902
+ }
903
+ setGitBannerDismissed(readGitIdentityBannerDismissed());
904
+ }, [
905
+ payload,
906
+ hasGitIdentityConfigured,
907
+ gitUserName,
908
+ gitUserEmail,
909
+ gitAccountLogin,
910
+ ]);
911
+
912
+ const cfg = payload?.cfg as Record<string, unknown> | undefined;
913
+ const trackCodeMetrics = trackCodeMetricsFromCfg(cfg);
914
+ const mongoEnabled = cfg?.mongodbEnabled === true;
915
+ const mongoRemoteStatus = payload?.remoteStatus as
916
+ | "connected"
917
+ | "failed"
918
+ | "pending"
919
+ | undefined;
920
+ const mongoConnected = mongoEnabled && mongoRemoteStatus === "connected";
921
+ const localPersistenceDriver =
922
+ typeof cfg?.localPersistenceDriver === "string" &&
923
+ cfg.localPersistenceDriver.trim().toLowerCase() === "json"
924
+ ? "json"
925
+ : "sqlite";
926
+ const mongoPushEnabled = mongoConnected;
927
+
928
+ /** Chemins workspace : payload, puis cfg, puis instantané LOC. */
929
+ const resolvedWorkspaceRoots = useMemo(() => {
930
+ const top = workspaceFolderPathStrings(payload);
931
+ if (top.length > 0) {
932
+ return top;
933
+ }
934
+ const cfgObj = payload?.cfg as Record<string, unknown> | undefined;
935
+ const fromCfg = workspaceFolderPathStrings(cfgObj);
936
+ if (fromCfg.length > 0) {
937
+ return fromCfg;
938
+ }
939
+ const snap = payload?.workspaceCodeSnapshot as
940
+ | WorkspaceCodeSnapshotPayload
941
+ | undefined;
942
+ if (snap?.ok === true) {
943
+ const w = snap.workspaceFolder?.trim();
944
+ if (w) {
945
+ return [w];
946
+ }
947
+ }
948
+ return [];
949
+ }, [payload]);
950
+
951
+ const gitStats = payload?.gitStats as GitRepoStatisticsPayload | undefined;
952
+
953
+ const showWorkspaceFoldersEmpty = showWorkspaceFoldersEmptyMessage(
954
+ payload,
955
+ resolvedWorkspaceRoots.length,
956
+ );
957
+ const showIdeCodeTimingMetrics = showIdeLinkedCodeTimingMetrics(payload, cfg);
958
+
959
+ const showHeaderUserRow =
960
+ Boolean(payload) &&
961
+ Boolean(
962
+ gitContextLine ||
963
+ showWorkspaceFoldersEmpty ||
964
+ resolvedWorkspaceRoots.length > 0 ||
965
+ cfg,
966
+ );
967
+ const headerClockShort = dashboardUse24HourClock
968
+ ? dt.headerClockFormat24Short
969
+ : dt.headerClockFormat12Short;
970
+ const headerDisplayPrefsTitle = dt.headerDisplayRegionTitle
971
+ .replace("{timeZone}", dashboardDisplayTimeZone)
972
+ .replace("{clock}", headerClockShort);
973
+
974
+ return (
975
+ <div className="min-h-screen bg-zinc-100 text-zinc-900 dark:bg-zinc-900 dark:text-zinc-100">
976
+ <header className={appShellHeaderClassName}>
977
+ <div className="mb-3 flex w-full flex-col gap-3 sm:mb-4 sm:flex-row sm:items-start sm:justify-between sm:gap-6">
978
+ <div id="dashboard-tour-anchor-intro" className="min-w-0 shrink-0">
979
+ <div className="flex min-w-0 flex-col gap-1">
980
+ <h1 className="text-xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-100">
981
+ {header.title}
982
+ </h1>
983
+ <p className="flex flex-wrap items-center gap-x-2 text-xs font-medium leading-snug text-zinc-500 dark:text-zinc-400">
984
+ <span>{dt.brandTagline}</span>
985
+ <span
986
+ className="text-zinc-400/70 dark:text-zinc-600"
987
+ aria-hidden
988
+ >
989
+ ·
990
+ </span>
991
+ <AppVersionStamp ariaLabelTemplate={dt.appVersionAriaLabel} />
992
+ </p>
993
+ </div>
994
+ </div>
995
+ <div
996
+ id="dashboard-tour-anchor-user-storage"
997
+ className="min-w-0 shrink-0 sm:max-w-[min(100%,36rem)] sm:justify-self-end"
998
+ >
999
+ {showHeaderUserRow ? (
1000
+ <div className="flex min-w-0 flex-col items-stretch gap-1.5 text-xs text-zinc-600 sm:items-end sm:text-right dark:text-zinc-400">
1001
+ {gitContextLine ? (
1002
+ <p
1003
+ className="flex max-w-full items-center gap-x-2 sm:justify-end"
1004
+ title={gitContextLine}
1005
+ >
1006
+ <User
1007
+ className="shrink-0 text-zinc-500 dark:text-zinc-500"
1008
+ size={14}
1009
+ aria-hidden
1010
+ />
1011
+ <span className="min-w-0 max-w-[min(100%,48rem)] truncate font-medium text-zinc-800 dark:text-zinc-300">
1012
+ {gitContextLine}
1013
+ </span>
1014
+ </p>
1015
+ ) : null}
1016
+ <p className="flex max-w-full flex-wrap items-center gap-x-1.5 sm:justify-end" title={headerDisplayPrefsTitle}>
1017
+ <Globe className="shrink-0 text-zinc-500 dark:text-zinc-500" size={14} aria-hidden />
1018
+ <span className="min-w-0 max-w-[min(100%,48rem)] break-all font-mono text-[0.7rem] font-medium text-zinc-800 dark:text-zinc-300">
1019
+ {dashboardDisplayTimeZone}
1020
+ </span>
1021
+ <span className="text-zinc-400/70 dark:text-zinc-600" aria-hidden>
1022
+ ·
1023
+ </span>
1024
+ <Clock className="shrink-0 text-zinc-500 dark:text-zinc-500" size={14} aria-hidden />
1025
+ <span className="shrink-0 font-medium text-zinc-800 dark:text-zinc-300">{headerClockShort}</span>
1026
+ </p>
1027
+ {showWorkspaceFoldersEmpty ? (
1028
+ <p className="flex max-w-full items-start gap-x-2 sm:justify-end">
1029
+ <FolderOpen
1030
+ className="mt-0.5 shrink-0 text-zinc-500 dark:text-zinc-500"
1031
+ size={14}
1032
+ aria-hidden
1033
+ />
1034
+ <span className="min-w-0 max-w-[min(100%,48rem)] break-words font-medium text-zinc-800 sm:text-right dark:text-zinc-300">
1035
+ {dt.workspaceFoldersEmpty ?? "—"}
1036
+ </span>
1037
+ </p>
1038
+ ) : resolvedWorkspaceRoots.length > 0 ? (
1039
+ resolvedWorkspaceRoots.map((p) => (
1040
+ <p
1041
+ key={p}
1042
+ className="flex max-w-full items-start gap-x-2 sm:justify-end"
1043
+ >
1044
+ <FolderOpen
1045
+ className="mt-0.5 shrink-0 text-zinc-500 dark:text-zinc-500"
1046
+ size={14}
1047
+ aria-hidden
1048
+ />
1049
+ <span className="min-w-0 max-w-[min(100%,48rem)] break-all font-medium text-zinc-800 sm:text-right dark:text-zinc-300">
1050
+ {p}
1051
+ </span>
1052
+ </p>
1053
+ ))
1054
+ ) : null}
1055
+ {cfg ? (
1056
+ <div className="flex w-full min-w-0 sm:justify-end">
1057
+ <HeaderIntegrationBadges
1058
+ t={dt}
1059
+ localPersistenceDriver={localPersistenceDriver}
1060
+ mongoConnected={mongoConnected}
1061
+ mongoEnabled={mongoEnabled}
1062
+ />
1063
+ </div>
1064
+ ) : null}
1065
+ </div>
1066
+ ) : null}
1067
+ </div>
1068
+ </div>
1069
+ <div className="grid w-full grid-cols-1 gap-3 xl:grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] xl:items-center xl:gap-x-6 xl:gap-y-0">
1070
+ <div className="flex flex-wrap items-center justify-between gap-4 xl:contents">
1071
+ <div
1072
+ className="hidden min-w-0 xl:col-start-1 xl:row-start-1 xl:block"
1073
+ aria-hidden
1074
+ />
1075
+ <div
1076
+ id="dashboard-tour-anchor-app-toolbar"
1077
+ className="flex h-10 shrink-0 flex-wrap items-center justify-end gap-1.5 xl:col-start-3 xl:row-start-1 xl:justify-self-end"
1078
+ >
1079
+ <DashboardCommandCenter
1080
+ dt={dt}
1081
+ handlers={commandHandlers}
1082
+ searchItems={quickSearchItems}
1083
+ toolbarDomId="dashboard-tour-command-center"
1084
+ />
1085
+ <AppShellRouteNav
1086
+ current="dashboard"
1087
+ labels={nav}
1088
+ navAriaLabel={dt.appShellRouteNavAria}
1089
+ dashboardSessionId={selectedSessionId}
1090
+ />
1091
+ <ThemeToggle lang={lang} />
1092
+ <PageRefreshButton
1093
+ title={dt.pageRefreshTitle}
1094
+ ariaLabel={dt.pageRefreshAriaLabel}
1095
+ inlineMessages={{
1096
+ loading: dt.pageRefreshProgressLabel,
1097
+ success: dt.pageRefreshDoneToast,
1098
+ error: dt.pageRefreshFailedToast,
1099
+ }}
1100
+ onRefresh={handleManualRefresh}
1101
+ />
1102
+ <LanguageMenu
1103
+ lang={lang}
1104
+ labelEn={header.en}
1105
+ labelFr={header.fr}
1106
+ menuHeading={lang === "fr" ? "Langue" : "Language"}
1107
+ triggerAriaLabel={
1108
+ lang === "fr" ? "Langue de l’interface" : "Interface language"
1109
+ }
1110
+ onSelect={(next) => {
1111
+ writeStoredDashboardLang(next);
1112
+ writeDashboardLangUserChosen();
1113
+ setUiLang(next);
1114
+ void post({ type: "setLanguage", lang: next });
1115
+ }}
1116
+ />
1117
+ </div>
1118
+ </div>
1119
+ {payload && live && dashboardShowKronoFocusInHeader ? (
1120
+ <div
1121
+ id="dashboard-tour-anchor-kronoFocus-header"
1122
+ className="min-w-0 w-full xl:col-start-2 xl:row-start-1 xl:flex xl:h-10 xl:w-max xl:max-w-[min(100vw-10rem,52rem)] xl:items-center xl:justify-self-center"
1123
+ >
1124
+ <KronoFocusPanel
1125
+ variant="headerBar"
1126
+ kronoFocus={live.kronoFocus}
1127
+ liveActiveTaskIds={
1128
+ Array.isArray(live.activeTasks) && live.activeTasks.length > 0
1129
+ ? live.activeTasks
1130
+ .map((t) => t.id)
1131
+ .filter(
1132
+ (id): id is string =>
1133
+ typeof id === "string" && id.length > 0,
1134
+ )
1135
+ : live.activeTask?.id
1136
+ ? [live.activeTask.id]
1137
+ : undefined
1138
+ }
1139
+ t={dt}
1140
+ post={postVoid}
1141
+ viewingArchive={isInspecting}
1142
+ />
1143
+ </div>
1144
+ ) : null}
1145
+ </div>
1146
+ </header>
1147
+
1148
+ <div className="mx-auto w-full max-w-[1920px] px-5 pt-5 pb-8 sm:px-8 sm:pt-6 sm:pb-10 lg:px-12 lg:pt-7 lg:pb-11 xl:px-14 xl:pt-7 xl:pb-12">
1149
+ {error && (
1150
+ <div
1151
+ className="mb-8 rounded-lg border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-900 dark:border-red-900/60 dark:bg-red-950/40 dark:text-red-100"
1152
+ role="alert"
1153
+ >
1154
+ <strong className="block text-red-800 dark:text-red-200">
1155
+ API
1156
+ </strong>
1157
+ {header.apiError}
1158
+ <pre className="mt-2 overflow-x-auto text-xs text-red-800/90 dark:text-red-200/80">
1159
+ {error}
1160
+ </pre>
1161
+ </div>
1162
+ )}
1163
+
1164
+ {urlResolution.mode === "invalid" && payload && (
1165
+ <div
1166
+ className="mb-6 rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-950 dark:border-amber-800/60 dark:bg-amber-950/35 dark:text-amber-100"
1167
+ role="status"
1168
+ >
1169
+ {dt.detachedSessionInvalidUrl}
1170
+ </div>
1171
+ )}
1172
+
1173
+ {showDetachedUrlHintBanner ? (
1174
+ <div
1175
+ className="mb-6 flex gap-3 rounded-lg border border-zinc-200 bg-zinc-50 px-4 py-3 text-sm text-zinc-700 dark:border-zinc-700 dark:bg-zinc-900/60 dark:text-zinc-300"
1176
+ role="note"
1177
+ >
1178
+ <p className="min-w-0 flex-1 leading-snug">
1179
+ {dt.detachedSessionUrlHint}
1180
+ </p>
1181
+ <button
1182
+ type="button"
1183
+ className="inline-flex size-8 shrink-0 items-center justify-center rounded-md border border-zinc-300 text-zinc-600 hover:bg-zinc-200/90 dark:border-zinc-600 dark:text-zinc-300 dark:hover:bg-zinc-800/80"
1184
+ onClick={dismissDetachedUrlHint}
1185
+ title={dt.detachedSessionUrlHintDismiss}
1186
+ aria-label={dt.detachedSessionUrlHintDismiss}
1187
+ >
1188
+ <X size={16} strokeWidth={2} aria-hidden />
1189
+ </button>
1190
+ </div>
1191
+ ) : null}
1192
+
1193
+ {live?.sessionScopeNotice &&
1194
+ live.archived !== true &&
1195
+ typeof live.sessionId === "string" &&
1196
+ live.sessionId.trim() !== "" ? (
1197
+ <div
1198
+ className={`mb-6 rounded-lg border px-4 py-3 text-sm ${
1199
+ live.sessionScopeNotice.kind === "alert"
1200
+ ? "border-red-400/70 bg-red-950/40 text-red-100"
1201
+ : "border-amber-400/70 bg-amber-950/35 text-amber-100"
1202
+ }`}
1203
+ role="status"
1204
+ aria-label={dt.sessionScopeNoticeAria}
1205
+ >
1206
+ {live.sessionScopeNotice.message}
1207
+ </div>
1208
+ ) : null}
1209
+
1210
+ {payload && (
1211
+ <>
1212
+ <div id="dashboard-tour-anchor-column-hints">
1213
+ <DashboardColumnHintsBanner dt={dt} />
1214
+ </div>
1215
+ {showGitIdentityBanner ? (
1216
+ <div id="dashboard-tour-anchor-git-identity-banner">
1217
+ <div
1218
+ className="mb-6 flex flex-col gap-3 rounded-lg border border-violet-300/80 bg-violet-50/90 px-4 py-3 text-sm text-violet-950 dark:border-violet-700/50 dark:bg-violet-950/35 dark:text-violet-100"
1219
+ role="region"
1220
+ aria-label={dt.gitIdentityBannerAria}
1221
+ >
1222
+ <p className="leading-snug">{dt.gitIdentityBannerBody}</p>
1223
+ <div className="flex flex-wrap items-center gap-2">
1224
+ <button
1225
+ type="button"
1226
+ className="inline-flex rounded-lg bg-violet-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-violet-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-violet-400 focus-visible:ring-offset-2 focus-visible:ring-offset-violet-50 dark:focus-visible:ring-offset-violet-950"
1227
+ onClick={() => setGitIdentitySetupModalOpen(true)}
1228
+ >
1229
+ {dt.gitIdentityBannerConfigure}
1230
+ </button>
1231
+ <button
1232
+ type="button"
1233
+ className="rounded-lg border border-violet-400/70 px-3 py-1.5 text-sm text-violet-900 hover:bg-violet-100/80 dark:border-violet-600/60 dark:text-violet-100 dark:hover:bg-violet-900/40"
1234
+ onClick={dismissGitIdentityBanner}
1235
+ >
1236
+ {dt.gitIdentityBannerDismiss}
1237
+ </button>
1238
+ </div>
1239
+ </div>
1240
+ </div>
1241
+ ) : null}
1242
+ <div className="grid w-full grid-cols-1 gap-8 xl:grid-cols-[minmax(0,1fr)_minmax(0,2.25fr)_minmax(0,1fr)] xl:items-start xl:gap-x-6 2xl:gap-x-10">
1243
+ <div
1244
+ id="dashboard-col-sessions"
1245
+ className="flex min-w-0 flex-col xl:sticky xl:top-8 xl:max-h-[calc(100vh-6rem)] xl:overflow-y-auto xl:pb-6 xl:pr-3 [scrollbar-gutter:stable] 2xl:pr-4"
1246
+ >
1247
+ <div className="w-full min-w-0">
1248
+ <div className="mx-auto flex w-full max-w-md flex-col gap-8 sm:max-w-lg xl:mx-0 xl:max-w-none">
1249
+ <div
1250
+ className={`min-w-0 ${dashboardColumnTitleRowClassName}`}
1251
+ >
1252
+ <h2 className="text-lg font-semibold text-zinc-900 dark:text-zinc-100">
1253
+ {dt.sessionsColumnTitle}
1254
+ </h2>
1255
+ <InlineMetricHelpTrigger
1256
+ ariaLabel={dt.sessionsColumnHelpAria}
1257
+ body={dt.sessionsColumnHelpBody}
1258
+ preserveLineBreaks
1259
+ />
1260
+ <button
1261
+ type="button"
1262
+ className="inline-flex size-10 shrink-0 cursor-pointer items-center justify-center rounded-lg border border-zinc-300 bg-white text-zinc-700 shadow-sm transition hover:border-violet-400/70 hover:bg-violet-50 hover:text-violet-800 active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-500/50 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:border-zinc-600 dark:bg-zinc-800/80 dark:text-zinc-200 dark:hover:border-violet-500/50 dark:hover:bg-violet-950/40 dark:hover:text-violet-200 dark:focus-visible:ring-violet-400/45 dark:focus-visible:ring-offset-zinc-900"
1263
+ title={header.newSession}
1264
+ aria-label={header.newSession}
1265
+ onClick={() => setNewSessionModalOpen(true)}
1266
+ >
1267
+ <Plus
1268
+ size={20}
1269
+ strokeWidth={2.25}
1270
+ className="shrink-0"
1271
+ aria-hidden
1272
+ />
1273
+ </button>
1274
+ </div>
1275
+
1276
+ <SelectedSessionSidebarBlock
1277
+ lang={lang}
1278
+ t={dt}
1279
+ sessionCurrent={sessionCurrent}
1280
+ columnArchiveId={columnArchiveId}
1281
+ sessionName={sessionName}
1282
+ setSessionName={setSessionName}
1283
+ sessionNameInputRef={sessionNameInputRef}
1284
+ sessionNameFieldActiveRef={sessionNameFieldActiveRef}
1285
+ sessionNameRowFocused={sessionNameRowFocused}
1286
+ setSessionNameRowFocused={setSessionNameRowFocused}
1287
+ post={post}
1288
+ headerSessionLabel={header.session}
1289
+ headerSessionDuration={header.sessionDuration}
1290
+ headerSessionStart={header.sessionStart}
1291
+ displayTimeZone={dashboardDisplayTimeZone}
1292
+ use24HourClock={dashboardUse24HourClock}
1293
+ headerCoding={header.coding}
1294
+ headerActive={header.active}
1295
+ headerTasks={header.tasks}
1296
+ archivedBadge={dt.archivedBadge}
1297
+ trackCodeMetrics={trackCodeMetrics}
1298
+ showIdeCodeTimingMetrics={showIdeCodeTimingMetrics}
1299
+ gitStats={gitStats}
1300
+ liveSessionId={live?.sessionId}
1301
+ onEndLiveSession={() =>
1302
+ void handleRequestEndLiveSession()
1303
+ }
1304
+ sessionDurationAlertThresholdMinutes={
1305
+ sessionDurationAlertThresholdMinutes
1306
+ }
1307
+ allowSessionStartTimeEdit={
1308
+ dashboardAllowSessionStartTimeEdit
1309
+ }
1310
+ allowSessionEndTimeEdit={dashboardAllowSessionEndTimeEdit}
1311
+ />
1312
+
1313
+ <SessionListPanel
1314
+ sessions={history}
1315
+ lang={lang}
1316
+ displayTimeZone={dashboardDisplayTimeZone}
1317
+ use24HourClock={dashboardUse24HourClock}
1318
+ liveSessionId={live?.sessionId}
1319
+ selectedSessionId={selectedSessionId}
1320
+ t={dt}
1321
+ onSelectSession={(id) => void handleSelectSession(id)}
1322
+ onOpenSessionInNewTab={openSessionInNewTab}
1323
+ onEndLiveSession={() =>
1324
+ void handleRequestEndLiveSession()
1325
+ }
1326
+ onArchiveSession={confirmArchiveSession}
1327
+ onDeleteSession={(id) => setDeleteSessionId(id)}
1328
+ onOpenArchives={() =>
1329
+ void router.push(
1330
+ withDashboardSessionParam(
1331
+ "/settings#settings-archived-sessions",
1332
+ selectedSessionId,
1333
+ ),
1334
+ )
1335
+ }
1336
+ archivedCount={historyArchived.length}
1337
+ mongoPushEnabled={mongoPushEnabled}
1338
+ onPushSessionToMongo={(id) =>
1339
+ void handlePushSessionToMongo(id)
1340
+ }
1341
+ pushingSessionId={mongoPushBusyId}
1342
+ sessionDurationAlertThresholdMinutes={
1343
+ sessionDurationAlertThresholdMinutes
1344
+ }
1345
+ />
1346
+ </div>
1347
+ </div>
1348
+ </div>
1349
+
1350
+ <div
1351
+ id="dashboard-col-tasks"
1352
+ className="min-w-0 space-y-8 border-zinc-200 xl:border-x xl:border-zinc-200/90 xl:px-5 2xl:px-8 dark:border-zinc-700/80"
1353
+ >
1354
+ <div className={`min-w-0 ${dashboardColumnTitleRowClassName}`}>
1355
+ <h2 className="text-lg font-semibold text-zinc-900 dark:text-zinc-100">
1356
+ {dt.rightColumnTitle}
1357
+ </h2>
1358
+ <InlineMetricHelpTrigger
1359
+ ariaLabel={dt.tasksColumnSubtitleHelpAria}
1360
+ body={dt.tasksColumnSubtitle}
1361
+ />
1362
+ </div>
1363
+
1364
+ <TaskFocusPanel
1365
+ payload={payload}
1366
+ lang={lang}
1367
+ t={dt}
1368
+ refresh={refresh}
1369
+ urlFocusedSessionId={
1370
+ isDetachedUrlTab ? urlResolution.id : undefined
1371
+ }
1372
+ liveSessionId={live?.sessionId}
1373
+ displayTimeZone={dashboardDisplayTimeZone}
1374
+ use24HourClock={dashboardUse24HourClock}
1375
+ onResumeActiveSession={handleResumeActiveSession}
1376
+ showKronoFocusInTaskOps={dashboardShowKronoFocusInTaskOps}
1377
+ allowTaskStartTimeEdit={dashboardAllowTaskStartTimeEdit}
1378
+ allowTaskEndTimeEdit={dashboardAllowTaskEndTimeEdit}
1379
+ />
1380
+ </div>
1381
+
1382
+ <aside
1383
+ id="dashboard-col-tags"
1384
+ className="flex min-w-0 flex-col xl:sticky xl:top-8 xl:max-h-[calc(100vh-6rem)] xl:overflow-y-auto xl:pb-6 xl:pl-3 [scrollbar-gutter:stable] 2xl:pl-4"
1385
+ aria-labelledby="dashboard-tags-projects-heading"
1386
+ >
1387
+ <div className={`min-w-0 ${dashboardColumnTitleRowClassName}`}>
1388
+ <h2
1389
+ id="dashboard-tags-projects-heading"
1390
+ className="text-lg font-semibold text-zinc-900 dark:text-zinc-100"
1391
+ >
1392
+ {dt.tagsProjectsColumnTitle}
1393
+ </h2>
1394
+ <InlineMetricHelpTrigger
1395
+ ariaLabel={dt.tagsProjectsColumnHelpAria}
1396
+ body={`${dt.tagsProjectsColumnIntro}\n\n${dt.tagsProjectsColumnHelpBody}`}
1397
+ preserveLineBreaks
1398
+ panelClassName="w-[min(calc(100vw-2rem),24rem)]"
1399
+ />
1400
+ </div>
1401
+ <div className="mt-5 min-w-0">
1402
+ <SettingsTagsProjectsSection
1403
+ variant="dashboard"
1404
+ s={tagProjSettingsCopy}
1405
+ lang={lang}
1406
+ payload={payload}
1407
+ saving={false}
1408
+ refresh={refresh}
1409
+ />
1410
+ </div>
1411
+ </aside>
1412
+ </div>
1413
+ </>
1414
+ )}
1415
+
1416
+ {!payload && !error ? (
1417
+ <DashboardLoadingOverlay
1418
+ ariaLabel={dt.dashboardLoadingAriaLabel}
1419
+ message={dt.dashboardLoadingMessage}
1420
+ />
1421
+ ) : null}
1422
+
1423
+ <NewSessionScopeModal
1424
+ open={newSessionModalOpen}
1425
+ lang={lang}
1426
+ t={dt}
1427
+ onCancel={() => setNewSessionModalOpen(false)}
1428
+ onConfirm={(sessionScope) => {
1429
+ setNewSessionModalOpen(false);
1430
+ void post({ type: "newSession", sessionScope });
1431
+ }}
1432
+ />
1433
+ <DashboardAlertModal
1434
+ open={homeDialogAlert !== null}
1435
+ message={homeDialogAlert ?? ""}
1436
+ okLabel={dt.dialogOkBtn}
1437
+ onClose={() => setHomeDialogAlert(null)}
1438
+ />
1439
+ <DashboardConfirmModal
1440
+ open={updateChangelogModalOpen}
1441
+ title={lang === "fr" ? "Mise à jour détectée" : "Update detected"}
1442
+ message={
1443
+ lang === "fr"
1444
+ ? `Kronosys a été mis à jour vers la version v${packageVersion}. Voulez-vous ouvrir le CHANGELOG usager maintenant?`
1445
+ : `Kronosys was updated to version v${packageVersion}. Do you want to open the user-facing CHANGELOG now?`
1446
+ }
1447
+ cancelLabel={lang === "fr" ? "Plus tard" : "Later"}
1448
+ confirmLabel={
1449
+ lang === "fr" ? "Ouvrir le CHANGELOG" : "Open CHANGELOG"
1450
+ }
1451
+ onCancel={() => {
1452
+ acknowledgePackageVersion();
1453
+ setUpdateChangelogModalOpen(false);
1454
+ }}
1455
+ onConfirm={() => {
1456
+ acknowledgePackageVersion();
1457
+ setUpdateChangelogModalOpen(false);
1458
+ void router.push(
1459
+ withDashboardSessionParam(
1460
+ "/changelog",
1461
+ dashboardNavSessionRef.current,
1462
+ ),
1463
+ );
1464
+ }}
1465
+ />
1466
+ <DashboardConfirmModal
1467
+ open={endLiveSessionConfirmFlags !== null}
1468
+ title={dt.sessionEndLiveConfirmTitle}
1469
+ message={
1470
+ endLiveSessionConfirmFlags
1471
+ ? formatEndLiveSessionModalIntro(endLiveSessionConfirmFlags, dt)
1472
+ : ""
1473
+ }
1474
+ cancelLabel={dt.dialogCancelBtn}
1475
+ confirmLabel={dt.sessionEndLiveConfirmBtn}
1476
+ onCancel={cancelEndLiveSessionConfirm}
1477
+ onConfirm={confirmEndLiveSession}
1478
+ extra={
1479
+ endLiveSessionConfirmFlags ? (
1480
+ <div className="space-y-3 text-left text-sm text-zinc-700 dark:text-zinc-300">
1481
+ <p className="text-[0.8rem] leading-snug text-zinc-600 dark:text-zinc-400">
1482
+ {dt.sessionEndReasonModalHint}
1483
+ </p>
1484
+ <fieldset className="space-y-2 border-0 p-0">
1485
+ <legend className="text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-500">
1486
+ {dt.sessionEndReasonFieldsetLegend}
1487
+ </legend>
1488
+ {(
1489
+ [
1490
+ ["", dt.sessionEndReasonSkip],
1491
+ ["planned", dt.sessionEndReasonPlanned],
1492
+ ["early", dt.sessionEndReasonEarly],
1493
+ ["overrun", dt.sessionEndReasonOverrun],
1494
+ ["other", dt.sessionEndReasonOther],
1495
+ ] as const
1496
+ ).map(([value, label]) => (
1497
+ <label
1498
+ key={value || "skip"}
1499
+ className="flex cursor-pointer items-start gap-2 rounded-md py-0.5 pr-1 hover:bg-zinc-100/80 dark:hover:bg-zinc-800/50"
1500
+ >
1501
+ <input
1502
+ type="radio"
1503
+ name="kronosys-session-end-reason"
1504
+ className="mt-1 size-4 shrink-0 border-zinc-300 text-violet-600 focus:ring-violet-500/50 dark:border-zinc-600"
1505
+ checked={endSessionReasonKind === value}
1506
+ onChange={() => setEndSessionReasonKind(value)}
1507
+ />
1508
+ <span className="leading-snug">{label}</span>
1509
+ </label>
1510
+ ))}
1511
+ </fieldset>
1512
+ <label className="block">
1513
+ <span className="sr-only">{dt.sessionEndReasonNoteAria}</span>
1514
+ <textarea
1515
+ className="mt-1 w-full min-h-[4rem] resize-y rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 placeholder:text-zinc-400 focus:border-violet-500/70 focus:outline-none focus:ring-2 focus:ring-violet-500/25 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100 dark:placeholder:text-zinc-500"
1516
+ rows={3}
1517
+ maxLength={500}
1518
+ value={endSessionReasonNote}
1519
+ onChange={(e) => setEndSessionReasonNote(e.target.value)}
1520
+ placeholder={dt.sessionEndReasonNotePlaceholder}
1521
+ aria-label={dt.sessionEndReasonNoteAria}
1522
+ />
1523
+ </label>
1524
+ </div>
1525
+ ) : null
1526
+ }
1527
+ />
1528
+ <DashboardConfirmModal
1529
+ open={archiveConfirmSessionId !== null}
1530
+ message={dt.sessionArchiveConfirm}
1531
+ cancelLabel={dt.dialogCancelBtn}
1532
+ confirmLabel={dt.dialogConfirmBtn}
1533
+ dismissCheckbox={{
1534
+ label: dt.sessionArchiveDontShowAgain,
1535
+ checked: archiveDismissChecked,
1536
+ onChange: setArchiveDismissChecked,
1537
+ }}
1538
+ onCancel={() => {
1539
+ setArchiveConfirmSessionId(null);
1540
+ setArchiveDismissChecked(false);
1541
+ }}
1542
+ onConfirm={async () => {
1543
+ const id = archiveConfirmSessionId;
1544
+ const saveDismiss = archiveDismissChecked;
1545
+ setArchiveConfirmSessionId(null);
1546
+ setArchiveDismissChecked(false);
1547
+ if (id) {
1548
+ await post({
1549
+ type: "archiveSession",
1550
+ sessionId: id,
1551
+ archived: true,
1552
+ ...(saveDismiss ? { dismissArchiveConfirm: true } : {}),
1553
+ });
1554
+ }
1555
+ }}
1556
+ />
1557
+ <DeleteSessionModal
1558
+ open={deleteSessionId !== null}
1559
+ sessionLabel={
1560
+ deleteSessionId
1561
+ ? history
1562
+ .find((s) => s.sessionId === deleteSessionId)
1563
+ ?.sessionName?.trim() || deleteSessionId.slice(0, 8)
1564
+ : ""
1565
+ }
1566
+ moveTargets={deleteMoveTargets}
1567
+ t={dt}
1568
+ onClose={() => setDeleteSessionId(null)}
1569
+ onConfirm={handleDeleteConfirm}
1570
+ />
1571
+ <DashboardLangGateModal
1572
+ open={langGateOpen}
1573
+ onSelect={completeLangGate}
1574
+ />
1575
+ <GitIdentityQuickSetupModal
1576
+ open={gitIdentitySetupModalOpen}
1577
+ onClose={() => setGitIdentitySetupModalOpen(false)}
1578
+ dt={dt}
1579
+ s={tagProjSettingsCopy}
1580
+ initialGitIdentity={payload?.gitIdentity}
1581
+ onSave={async (fields) => {
1582
+ await post({
1583
+ type: "setGitIdentity",
1584
+ gitUserName: fields.gitUserName,
1585
+ gitUserEmail: fields.gitUserEmail,
1586
+ gitAccountLogin: fields.gitAccountLogin,
1587
+ });
1588
+ }}
1589
+ />
1590
+ <DashboardTour
1591
+ open={tourOpen}
1592
+ onOpenChange={setTourOpen}
1593
+ dt={dt}
1594
+ kronoFocusTourStep={Boolean(
1595
+ payload && live && dashboardShowKronoFocusInHeader,
1596
+ )}
1597
+ gitIdentityBannerTourStep={showGitIdentityBanner}
1598
+ />
1599
+ </div>
1600
+ </div>
1601
+ );
1602
+ }
1603
+
1604
+ export default function Page() {
1605
+ return (
1606
+ <Suspense fallback={<DashboardSuspenseFallback />}>
1607
+ <DashboardHome />
1608
+ </Suspense>
1609
+ );
1610
+ }