@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,3518 @@
1
+ "use client";
2
+
3
+ import {
4
+ Suspense,
5
+ useCallback,
6
+ useEffect,
7
+ useId,
8
+ useMemo,
9
+ useRef,
10
+ useState,
11
+ } from "react";
12
+ import Link from "next/link";
13
+ import { useRouter, useSearchParams } from "next/navigation";
14
+ import {
15
+ postKronosysAction,
16
+ type WorkspaceCodeSnapshotPayload,
17
+ } from "@/lib/kronosysApi";
18
+ import { useKronosysPayload } from "@/components/KronosysPayloadProvider";
19
+ import { AppVersionStamp } from "@/components/dashboard/AppVersionStamp";
20
+ import {
21
+ dashboardStrings,
22
+ sessionTaskCountNoun,
23
+ type Lang,
24
+ } from "@/lib/dashboardCopy";
25
+ import type { SessionListEntry } from "@/components/dashboard/SessionListPanel";
26
+ import {
27
+ MongoMirrorSyncLine,
28
+ type MongoMirrorSyncPayload,
29
+ } from "@/components/dashboard/MongoMirrorSyncLine";
30
+ import {
31
+ DashboardAlertModal,
32
+ DashboardConfirmModal,
33
+ } from "@/components/dashboard/DashboardSimpleModal";
34
+ import { reportingNav } from "@/lib/reportingStrings";
35
+ import { settingsCopy, type SettingsCopy } from "@/lib/settingsCopy";
36
+ import {
37
+ appShellHeaderClassName,
38
+ appShellHeaderToolRowClassName,
39
+ } from "@/lib/appShellHeaderClasses";
40
+ import { workspaceFolderPathStrings } from "@/lib/legacyEditorPayloadKeys";
41
+ import { showWorkspaceFoldersEmptyMessage } from "@/lib/usageProfile";
42
+ import { readDashboardUse24HourClockFromCfg } from "@/lib/dashboardClockFormat";
43
+ import {
44
+ DASHBOARD_TIME_ZONE_SELECT_OPTIONS,
45
+ isValidIanaTimeZone,
46
+ readDashboardTimeZoneFromCfg,
47
+ } from "@/lib/dashboardTimeZone";
48
+ import { DEFAULT_WORKSPACE_LOC_EXCLUDED_DIRECTORY_NAMES } from "@/lib/workspaceLocDefaults";
49
+ import { LanguageMenu } from "@/components/dashboard/LanguageMenu";
50
+ import { ScrollToTopFab } from "@/components/dashboard/ScrollToTopFab";
51
+ import { ThemeToggle } from "@/components/dashboard/ThemeToggle";
52
+ import { PageRefreshButton } from "@/components/dashboard/PageRefreshButton";
53
+ import { AppShellRouteNav } from "@/components/dashboard/AppShellRouteNav";
54
+ import { SettingsTagsProjectsSection } from "@/components/dashboard/SettingsTagsProjectsSection";
55
+ import { SettingsTour } from "@/components/dashboard/SettingsTour";
56
+ import { resetGitIdentityBannerDismissed } from "@/lib/dashboardGitIdentityBannerStorage";
57
+ import { resetDashboardLangPreference } from "@/lib/dashboardLangStorage";
58
+ import {
59
+ isSettingsTourCompleted,
60
+ resetDashboardTour,
61
+ resetSettingsTour,
62
+ } from "@/lib/dashboardTourStorage";
63
+ import {
64
+ writeDashboardColumnHintsClosed,
65
+ writeDashboardColumnHintsDismissed,
66
+ } from "@/lib/dashboardColumnHintsStorage";
67
+ import { KronosysTimePopoverField } from "@/components/dashboard/KronosysTimePopoverField";
68
+ import { Search } from "lucide-react";
69
+ import { withDashboardSessionParam } from "@/lib/dashboardSessionNav";
70
+
71
+ type LiveShape = { language?: string };
72
+
73
+ type SettingsForm = {
74
+ usageProfile: string;
75
+ enabled: boolean;
76
+ autoStart: boolean;
77
+ showWelcomeOnStartup: boolean;
78
+ anonymizePaths: boolean;
79
+ flushIntervalSeconds: number;
80
+ heartbeatIntervalSeconds: number;
81
+ maxBufferedEvents: number;
82
+ historyStoragePath: string;
83
+ localPersistenceDriver: string;
84
+ historyPartition: string;
85
+ sessionExportFormat: string;
86
+ csvDelimiter: string;
87
+ sessionStartMode: string;
88
+ autoGitSync: boolean;
89
+ gitRemoteUrl: string;
90
+ /** Origine GitLab API (vide = GitLab.com après résolution). */
91
+ gitlabApiBaseUrl: string;
92
+ mongodbEnabled: boolean;
93
+ mongodbDatabaseName: string;
94
+ mongodbCollectionName: string;
95
+ mongodbHost: string;
96
+ mongodbPort: number;
97
+ mongodbUsername: string;
98
+ mongodbAuthSource: string;
99
+ localApiPort: number;
100
+ dashboardWebUrl: string;
101
+ /** Fuseau IANA pour dates tableau de bord et rapports. */
102
+ dashboardDisplayTimeZone: string;
103
+ /** `true` : affichage 24 h ; `false` : 12 h (AM/PM). */
104
+ dashboardUse24HourClock: boolean;
105
+ /** Seuil d’alerte (heures) pour la durée murale affichée dans le tableau de bord. */
106
+ dashboardSessionDurationAlertHours: number;
107
+ dashboardShowKronoFocusInHeader: boolean;
108
+ dashboardShowKronoFocusInTaskOps: boolean;
109
+ /** Lorsque `false`, les tâches sans # ne reçoivent pas l’étiquette réservée `default`. */
110
+ taskDefaultTagBucketEnabled: boolean;
111
+ dashboardAllowTaskStartTimeEdit: boolean;
112
+ dashboardAllowSessionStartTimeEdit: boolean;
113
+ dashboardAllowTaskEndTimeEdit: boolean;
114
+ dashboardAllowSessionEndTimeEdit: boolean;
115
+ /** `next dev` : partager le coffre `v4` avec la production (sinon `v4-dev`). */
116
+ developmentUseProductionData: boolean;
117
+ workspaceLocExcludedDirsText: string;
118
+ workspaceLocExcludedPathPatternsText: string;
119
+ scheduleEnabled: boolean;
120
+ scheduleDays: number[];
121
+ scheduleStartTime: string;
122
+ scheduleEndTime: string;
123
+ scheduleCreateDailySession: boolean;
124
+ scheduleTransferTasks: boolean;
125
+ schedulePauseTasks: boolean;
126
+ plannedSessions: {
127
+ id: string;
128
+ name: string;
129
+ days: number[];
130
+ startTime: string;
131
+ endTime: string;
132
+ createSession: boolean;
133
+ transferTasks: boolean;
134
+ pauseTasks: boolean;
135
+ tagsText: string;
136
+ project: string;
137
+ }[];
138
+ };
139
+
140
+ function cfgToForm(cfg: Record<string, unknown> | undefined): SettingsForm {
141
+ const num = (v: unknown, fallback: number) =>
142
+ typeof v === "number" && Number.isFinite(v) ? v : fallback;
143
+ const bool = (v: unknown, fallback: boolean) =>
144
+ typeof v === "boolean" ? v : fallback;
145
+ const str = (v: unknown, fallback: string) =>
146
+ typeof v === "string" ? v : fallback;
147
+ const part = str(cfg?.historyPartition, "daily");
148
+ const partition = ["daily", "weekly", "monthly"].includes(part)
149
+ ? part
150
+ : "daily";
151
+ const fmtRaw = str(cfg?.sessionExportFormat, "json");
152
+ const fmt = ["json", "csv", "jiraTable", "sap"].includes(fmtRaw)
153
+ ? fmtRaw
154
+ : "json";
155
+ const modeRaw = str(cfg?.sessionStartMode, "continue");
156
+ const mode = ["continue", "new"].includes(modeRaw) ? modeRaw : "continue";
157
+ const profileRaw = str(cfg?.usageProfile, "manager");
158
+ const usageProfile = profileRaw === "developer" ? "developer" : "manager";
159
+ const locDirsLines = (() => {
160
+ const v = cfg?.workspaceLocExcludedDirectoryNames;
161
+ if (!Array.isArray(v)) {
162
+ return [...DEFAULT_WORKSPACE_LOC_EXCLUDED_DIRECTORY_NAMES].join("\n");
163
+ }
164
+ return v
165
+ .map((x) => String(x).trim())
166
+ .filter(Boolean)
167
+ .join("\n");
168
+ })();
169
+ const locPatsLines = (() => {
170
+ const v = cfg?.workspaceLocExcludedPathPatterns;
171
+ if (!Array.isArray(v)) {
172
+ return "";
173
+ }
174
+ return v
175
+ .map((x) => String(x).trim())
176
+ .filter(Boolean)
177
+ .join("\n");
178
+ })();
179
+ const scheduleDaysRaw = cfg?.scheduleDays;
180
+ const scheduleDays = Array.isArray(scheduleDaysRaw)
181
+ ? (scheduleDaysRaw.filter((x) => typeof x === "number") as number[])
182
+ : [1, 2, 3, 4, 5];
183
+ const rawPlannedSessions = Array.isArray(cfg?.plannedSessions)
184
+ ? cfg.plannedSessions
185
+ : [];
186
+ const plannedSessions = rawPlannedSessions.map((psRaw: unknown) => {
187
+ const ps =
188
+ typeof psRaw === "object" && psRaw !== null
189
+ ? (psRaw as Record<string, unknown>)
190
+ : {};
191
+ return {
192
+ id: str(ps.id, Math.random().toString(36).slice(2, 11)),
193
+ name: str(ps.name, ""),
194
+ days: Array.isArray(ps.days)
195
+ ? ps.days.filter((x: unknown) => typeof x === "number")
196
+ : [],
197
+ startTime: str(ps.startTime, "10:00"),
198
+ endTime: str(ps.endTime, "11:00"),
199
+ createSession: bool(ps.createSession, true),
200
+ transferTasks: bool(ps.transferTasks, true),
201
+ pauseTasks: bool(ps.pauseTasks, true),
202
+ tagsText: Array.isArray(ps.tags) ? ps.tags.join(", ") : "",
203
+ project: str(ps.project, ""),
204
+ };
205
+ });
206
+ return {
207
+ usageProfile,
208
+ enabled: bool(cfg?.enabled, true),
209
+ autoStart: bool(cfg?.autoStart, true),
210
+ showWelcomeOnStartup: bool(cfg?.showWelcomeOnStartup, true),
211
+ anonymizePaths: bool(cfg?.anonymizePaths, false),
212
+ flushIntervalSeconds: num(cfg?.flushIntervalSeconds, 15),
213
+ heartbeatIntervalSeconds: num(cfg?.heartbeatIntervalSeconds, 120),
214
+ maxBufferedEvents: num(cfg?.maxBufferedEvents, 500),
215
+ historyStoragePath: str(cfg?.historyStoragePath, ""),
216
+ localPersistenceDriver:
217
+ str(cfg?.localPersistenceDriver, "sqlite") === "json" ? "json" : "sqlite",
218
+ historyPartition: partition,
219
+ sessionExportFormat: fmt,
220
+ csvDelimiter: str(cfg?.csvDelimiter, ","),
221
+ sessionStartMode: mode,
222
+ autoGitSync: bool(cfg?.autoGitSync, false),
223
+ gitRemoteUrl: str(cfg?.gitRemoteUrl, ""),
224
+ gitlabApiBaseUrl: str(cfg?.gitlabApiBaseUrl, ""),
225
+ mongodbEnabled: bool(cfg?.mongodbEnabled, false),
226
+ mongodbDatabaseName: str(cfg?.mongodbDatabaseName, "kronosys"),
227
+ mongodbCollectionName: str(cfg?.mongodbCollectionName, "sessions"),
228
+ mongodbHost: str(cfg?.mongodbHost, "127.0.0.1"),
229
+ mongodbPort: num(cfg?.mongodbPort, 27017),
230
+ mongodbUsername: str(cfg?.mongodbUsername, ""),
231
+ mongodbAuthSource: str(cfg?.mongodbAuthSource, "admin"),
232
+ localApiPort: num(cfg?.localApiPort, 5566),
233
+ dashboardWebUrl: str(cfg?.dashboardWebUrl, "http://kronosys:5555"),
234
+ dashboardDisplayTimeZone: readDashboardTimeZoneFromCfg(cfg),
235
+ dashboardUse24HourClock: readDashboardUse24HourClockFromCfg(cfg),
236
+ dashboardSessionDurationAlertHours: Math.min(
237
+ Math.max(num(cfg?.dashboardSessionDurationAlertHours, 24), 1),
238
+ 8760,
239
+ ),
240
+ dashboardShowKronoFocusInHeader: bool(
241
+ cfg?.dashboardShowKronoFocusInHeader,
242
+ true,
243
+ ),
244
+ dashboardShowKronoFocusInTaskOps: bool(
245
+ cfg?.dashboardShowKronoFocusInTaskOps,
246
+ true,
247
+ ),
248
+ taskDefaultTagBucketEnabled: bool(cfg?.taskDefaultTagBucketEnabled, true),
249
+ dashboardAllowTaskStartTimeEdit: bool(
250
+ cfg?.dashboardAllowTaskStartTimeEdit,
251
+ true,
252
+ ),
253
+ dashboardAllowSessionStartTimeEdit: bool(
254
+ cfg?.dashboardAllowSessionStartTimeEdit,
255
+ true,
256
+ ),
257
+ dashboardAllowTaskEndTimeEdit: bool(
258
+ cfg?.dashboardAllowTaskEndTimeEdit,
259
+ true,
260
+ ),
261
+ dashboardAllowSessionEndTimeEdit: bool(
262
+ cfg?.dashboardAllowSessionEndTimeEdit,
263
+ true,
264
+ ),
265
+ developmentUseProductionData: bool(
266
+ cfg?.developmentUseProductionData,
267
+ false,
268
+ ),
269
+ workspaceLocExcludedDirsText: locDirsLines,
270
+ workspaceLocExcludedPathPatternsText: locPatsLines,
271
+ scheduleEnabled: bool(cfg?.scheduleEnabled, false),
272
+ scheduleDays,
273
+ scheduleStartTime: str(cfg?.scheduleStartTime, "09:00"),
274
+ scheduleEndTime: str(cfg?.scheduleEndTime, "17:00"),
275
+ scheduleCreateDailySession: bool(cfg?.scheduleCreateDailySession, true),
276
+ scheduleTransferTasks: bool(cfg?.scheduleTransferTasks, true),
277
+ schedulePauseTasks: bool(cfg?.schedulePauseTasks, true),
278
+ plannedSessions,
279
+ };
280
+ }
281
+
282
+ function inputClass(disabled?: boolean) {
283
+ return `w-full max-w-md rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 outline-none ring-violet-500/30 focus:ring-2 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100 ${
284
+ disabled ? "opacity-50" : ""
285
+ }`;
286
+ }
287
+
288
+ function textareaClass(disabled?: boolean) {
289
+ return `w-full max-w-xl min-h-[9rem] rounded-lg border border-zinc-300 bg-white px-3 py-2 font-mono text-xs leading-relaxed text-zinc-900 outline-none ring-violet-500/30 focus:ring-2 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100 ${
290
+ disabled ? "opacity-50" : ""
291
+ }`;
292
+ }
293
+
294
+ function splitLinesToWorkspaceLocDirs(text: string): string[] {
295
+ return [
296
+ ...new Set(
297
+ text
298
+ .split("\n")
299
+ .map((l) => l.trim())
300
+ .filter(
301
+ (s) =>
302
+ s.length > 0 &&
303
+ s.length <= 256 &&
304
+ !s.includes("/") &&
305
+ !s.includes("\\"),
306
+ ),
307
+ ),
308
+ ].slice(0, 500);
309
+ }
310
+
311
+ function splitLinesToPathPatterns(text: string): string[] {
312
+ return [
313
+ ...new Set(
314
+ text
315
+ .split("\n")
316
+ .map((l) => l.trim().replace(/\\/g, "/"))
317
+ .filter((s) => s.length > 0 && s.length <= 512),
318
+ ),
319
+ ].slice(0, 200);
320
+ }
321
+
322
+ function Field({
323
+ label,
324
+ description,
325
+ children,
326
+ }: {
327
+ label: string;
328
+ description: string;
329
+ children: React.ReactNode;
330
+ }) {
331
+ const uid = useId();
332
+ const labelId = `${uid}-label`;
333
+ const descId = `${uid}-desc`;
334
+ return (
335
+ <div
336
+ role="group"
337
+ aria-labelledby={labelId}
338
+ aria-describedby={descId}
339
+ className="flex flex-col gap-1.5 sm:flex-row sm:items-start sm:gap-6"
340
+ >
341
+ <div className="min-w-0 shrink-0 sm:w-52">
342
+ <div
343
+ id={labelId}
344
+ className="text-sm font-medium text-zinc-800 dark:text-zinc-200"
345
+ >
346
+ {label}
347
+ </div>
348
+ <p
349
+ id={descId}
350
+ className="mt-0.5 text-xs leading-snug text-zinc-600 dark:text-zinc-500"
351
+ >
352
+ {description}
353
+ </p>
354
+ </div>
355
+ <div className="min-w-0 flex-1">{children}</div>
356
+ </div>
357
+ );
358
+ }
359
+
360
+ function Toggle({
361
+ checked,
362
+ onChange,
363
+ disabled,
364
+ ariaLabel,
365
+ }: {
366
+ checked: boolean;
367
+ onChange: (v: boolean) => void;
368
+ disabled?: boolean;
369
+ ariaLabel: string;
370
+ }) {
371
+ return (
372
+ <button
373
+ type="button"
374
+ role="switch"
375
+ aria-checked={checked ? "true" : "false"}
376
+ aria-label={ariaLabel}
377
+ disabled={disabled}
378
+ className={`relative h-7 w-12 shrink-0 rounded-full transition ${
379
+ checked ? "bg-violet-600" : "bg-zinc-700"
380
+ } ${disabled ? "opacity-50" : ""}`}
381
+ onClick={() => onChange(!checked)}
382
+ >
383
+ <span
384
+ className={`absolute top-0.5 left-0.5 h-6 w-6 rounded-full bg-white transition ${
385
+ checked ? "translate-x-5" : "translate-x-0"
386
+ }`}
387
+ />
388
+ </button>
389
+ );
390
+ }
391
+
392
+ function SettingsCheckbox({
393
+ checked,
394
+ onChange,
395
+ disabled,
396
+ ariaLabel,
397
+ }: {
398
+ checked: boolean;
399
+ onChange: (v: boolean) => void;
400
+ disabled?: boolean;
401
+ ariaLabel: string;
402
+ }) {
403
+ return (
404
+ <input
405
+ type="checkbox"
406
+ checked={checked}
407
+ disabled={disabled}
408
+ onChange={(e) => onChange(e.target.checked)}
409
+ aria-label={ariaLabel}
410
+ className={`h-4 w-4 shrink-0 rounded border border-zinc-400 bg-white text-violet-600 accent-violet-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-violet-500/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-zinc-600 dark:bg-zinc-950 dark:focus-visible:ring-offset-zinc-950 ${
411
+ disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer"
412
+ }`}
413
+ />
414
+ );
415
+ }
416
+
417
+ const SETTINGS_SECTION_SCROLL =
418
+ "scroll-mt-24 space-y-6 border-b border-zinc-200 pb-10 last:border-b-0 last:pb-4 dark:border-zinc-800";
419
+
420
+ const ARCHIVED_SESSIONS_PAGE_SIZE = 10;
421
+
422
+ function archivedTaskCount(s: SessionListEntry): number {
423
+ const listed = s.tasks?.length ?? 0;
424
+ const nActive =
425
+ Array.isArray(s.activeTasks) && s.activeTasks.length > 0
426
+ ? s.activeTasks.length
427
+ : s.activeTask
428
+ ? 1
429
+ : 0;
430
+ return listed + nActive;
431
+ }
432
+
433
+ function tocMatch(filter: string, ...parts: string[]): boolean {
434
+ const q = filter.trim().toLowerCase();
435
+ if (!q) return true;
436
+ return parts.join(" ").toLowerCase().includes(q);
437
+ }
438
+
439
+ function SettingsToc({
440
+ s,
441
+ filter,
442
+ showDevDataInGeneral,
443
+ }: {
444
+ s: SettingsCopy;
445
+ filter: string;
446
+ showDevDataInGeneral: boolean;
447
+ }) {
448
+ const linkClass =
449
+ "rounded text-zinc-700 underline-offset-2 hover:text-violet-700 hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-violet-500/80 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-zinc-300 dark:hover:text-violet-300 dark:focus-visible:ring-offset-zinc-950";
450
+ const m = (...parts: string[]) => tocMatch(filter, ...parts);
451
+
452
+ const showUsage = m(s.sectionUsageProfile, "settings-usage-profile");
453
+ const showGeneral = m(
454
+ s.sectionGeneral,
455
+ "settings-general",
456
+ "général",
457
+ "general",
458
+ "v4",
459
+ "v4-dev",
460
+ "next",
461
+ "dev",
462
+ "sqlite",
463
+ "données",
464
+ "data",
465
+ "développement",
466
+ "development",
467
+ );
468
+ const showSchedule = m(
469
+ s.sectionSchedule,
470
+ "settings-schedule",
471
+ "schedule",
472
+ "horaire",
473
+ "travail",
474
+ "weekday",
475
+ "automatique",
476
+ );
477
+ const showPlannedSessions = m(
478
+ s.sectionPlannedSessions,
479
+ "settings-planned-sessions",
480
+ "planned",
481
+ "planifi",
482
+ "récurrent",
483
+ "recurring",
484
+ );
485
+ const showPrivacy = m(s.sectionPrivacy, "settings-privacy");
486
+ const showKronoFocus = m(
487
+ s.sectionKronoFocus,
488
+ "settings-kronoFocus",
489
+ "kronoFocus",
490
+ );
491
+ const showTaskTags = m(
492
+ s.sectionTaskTags,
493
+ "settings-task-tags",
494
+ "task",
495
+ "tag",
496
+ "étiquette",
497
+ "default",
498
+ "défaut",
499
+ "bucket",
500
+ );
501
+ const showCollection = m(
502
+ s.sectionCollection,
503
+ "settings-collection",
504
+ "buffer",
505
+ );
506
+ const showHistory = m(s.sectionHistory, "settings-history");
507
+ const showTagsGlobal =
508
+ m(s.sectionTagsProjects, "settings-tags-projects") ||
509
+ m(s.tocSubTagsGlobal, "settings-tags-global");
510
+ const showTagsProjects =
511
+ m(s.sectionTagsProjects, "settings-tags-projects") ||
512
+ m(s.tocSubTagsProjects, "settings-tags-saved-projects");
513
+ const showTagsByProject =
514
+ m(s.sectionTagsProjects, "settings-tags-projects") ||
515
+ m(s.tocSubTagsByProject, "settings-tags-by-project");
516
+ const showTagsBlock = showTagsGlobal || showTagsProjects || showTagsByProject;
517
+ const showDanger = m(s.sectionDangerZone, "settings-danger-zone");
518
+ const showArchived = m(
519
+ s.sectionArchivedSessions,
520
+ "settings-archived-sessions",
521
+ "archived",
522
+ );
523
+ const showExport = m(s.sectionExport, "settings-export");
524
+ const showGit =
525
+ m(s.sectionGit, "settings-git", "gitlab") ||
526
+ m(
527
+ s.tocSubGitIdentity,
528
+ "settings-git-identity",
529
+ "author",
530
+ "identity",
531
+ "forge",
532
+ );
533
+ const showMongoConn =
534
+ m(s.sectionMongo, "settings-mongo", "mongodb") ||
535
+ m(s.sectionMongoConnection, "settings-mongo-connection");
536
+ const showMongoBlock = showMongoConn;
537
+ const showWorkspaceLoc = m(
538
+ s.sectionWorkspaceLoc,
539
+ "settings-workspace-loc",
540
+ "snapshot",
541
+ "reporting",
542
+ );
543
+ const showWebTour =
544
+ m(s.sectionWeb, "settings-web", "dashboard", "api") ||
545
+ m(s.tocSubDashboardTour, "settings-dashboard-tour") ||
546
+ m(s.tocSubReportingTour, "settings-reporting-tour") ||
547
+ m(
548
+ s.tocSubDashboardColumnHints,
549
+ "settings-dashboard-column-hints",
550
+ "column",
551
+ "colonnes",
552
+ "three",
553
+ "trois",
554
+ ) ||
555
+ m(
556
+ s.dashboardDisplayTimeZone,
557
+ "fuseau",
558
+ "timezone",
559
+ "time zone",
560
+ "iana",
561
+ "horaire",
562
+ ) ||
563
+ m(
564
+ s.dashboardClockFormat,
565
+ "12h",
566
+ "12 h",
567
+ "24h",
568
+ "24 h",
569
+ "am/pm",
570
+ "horloge",
571
+ "clock format",
572
+ "heure",
573
+ );
574
+ const showWebDuration =
575
+ m(s.sectionWeb, "settings-web", "dashboard", "api") ||
576
+ m(
577
+ s.tocSubSessionDurationAlert,
578
+ "settings-session-duration-alert",
579
+ "duration",
580
+ );
581
+ const showWebBlock = showWebTour || showWebDuration;
582
+ const showLicenses = m(
583
+ s.licensesPageLink,
584
+ "/licenses",
585
+ "license",
586
+ "licence",
587
+ "third-party",
588
+ "notices",
589
+ "logiciels",
590
+ );
591
+
592
+ const anyVisible =
593
+ showUsage ||
594
+ showGeneral ||
595
+ showSchedule ||
596
+ showPlannedSessions ||
597
+ showPrivacy ||
598
+ showKronoFocus ||
599
+ showTaskTags ||
600
+ showCollection ||
601
+ showHistory ||
602
+ showTagsBlock ||
603
+ showDanger ||
604
+ showArchived ||
605
+ showExport ||
606
+ showGit ||
607
+ showMongoBlock ||
608
+ showWorkspaceLoc ||
609
+ showWebBlock ||
610
+ showLicenses;
611
+
612
+ const subListClass =
613
+ "mt-1.5 ml-2 space-y-1 border-l border-zinc-700 pl-3 dark:border-zinc-600";
614
+
615
+ return (
616
+ <nav
617
+ aria-label={s.tocNavAriaLabel}
618
+ className="rounded-xl border border-zinc-200 bg-zinc-50/90 p-4 dark:border-zinc-800 dark:bg-zinc-900/40"
619
+ >
620
+ <p className="mb-3 text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-500">
621
+ {s.tocHeading}
622
+ </p>
623
+ {filter.trim() && !anyVisible ? (
624
+ <p className="text-sm text-zinc-600 dark:text-zinc-400" role="status">
625
+ {s.tocSearchNoResults}
626
+ </p>
627
+ ) : (
628
+ <ul className="space-y-2 text-sm">
629
+ {showUsage ? (
630
+ <li>
631
+ <a href="#settings-usage-profile" className={linkClass}>
632
+ {s.sectionUsageProfile}
633
+ </a>
634
+ </li>
635
+ ) : null}
636
+ {showGeneral ? (
637
+ <li>
638
+ <a href="#settings-general" className={linkClass}>
639
+ {s.sectionGeneral}
640
+ </a>
641
+ {showDevDataInGeneral ? (
642
+ <ul className={subListClass}>
643
+ <li>
644
+ <a href="#settings-dev-data" className={linkClass}>
645
+ {s.devDataBlockTitle}
646
+ </a>
647
+ </li>
648
+ </ul>
649
+ ) : null}
650
+ </li>
651
+ ) : null}
652
+ {showSchedule ? (
653
+ <li>
654
+ <a href="#settings-schedule" className={linkClass}>
655
+ {s.sectionSchedule}
656
+ </a>
657
+ </li>
658
+ ) : null}
659
+ {showPlannedSessions ? (
660
+ <li>
661
+ <a href="#settings-planned-sessions" className={linkClass}>
662
+ {s.sectionPlannedSessions}
663
+ </a>
664
+ </li>
665
+ ) : null}
666
+ {showPrivacy ? (
667
+ <li>
668
+ <a href="#settings-privacy" className={linkClass}>
669
+ {s.sectionPrivacy}
670
+ </a>
671
+ </li>
672
+ ) : null}
673
+ {showKronoFocus ? (
674
+ <li>
675
+ <a href="#settings-kronoFocus" className={linkClass}>
676
+ {s.sectionKronoFocus}
677
+ </a>
678
+ </li>
679
+ ) : null}
680
+ {showTaskTags ? (
681
+ <li>
682
+ <a href="#settings-task-tags" className={linkClass}>
683
+ {s.sectionTaskTags}
684
+ </a>
685
+ </li>
686
+ ) : null}
687
+ {showCollection ? (
688
+ <li>
689
+ <a href="#settings-collection" className={linkClass}>
690
+ {s.sectionCollection}
691
+ </a>
692
+ </li>
693
+ ) : null}
694
+ {showHistory ? (
695
+ <li>
696
+ <a href="#settings-history" className={linkClass}>
697
+ {s.sectionHistory}
698
+ </a>
699
+ </li>
700
+ ) : null}
701
+ {showExport ? (
702
+ <li>
703
+ <a href="#settings-export" className={linkClass}>
704
+ {s.sectionExport}
705
+ </a>
706
+ </li>
707
+ ) : null}
708
+ {showTagsBlock ? (
709
+ <li>
710
+ <a href="#settings-tags-projects" className={linkClass}>
711
+ {s.sectionTagsProjects}
712
+ </a>
713
+ {showTagsGlobal || showTagsProjects || showTagsByProject ? (
714
+ <ul className={subListClass}>
715
+ {showTagsGlobal ? (
716
+ <li>
717
+ <a href="#settings-tags-global" className={linkClass}>
718
+ {s.tocSubTagsGlobal}
719
+ </a>
720
+ </li>
721
+ ) : null}
722
+ {showTagsProjects ? (
723
+ <li>
724
+ <a
725
+ href="#settings-tags-saved-projects"
726
+ className={linkClass}
727
+ >
728
+ {s.tocSubTagsProjects}
729
+ </a>
730
+ </li>
731
+ ) : null}
732
+ {showTagsByProject ? (
733
+ <li>
734
+ <a href="#settings-tags-by-project" className={linkClass}>
735
+ {s.tocSubTagsByProject}
736
+ </a>
737
+ </li>
738
+ ) : null}
739
+ </ul>
740
+ ) : null}
741
+ </li>
742
+ ) : null}
743
+ {showGit ? (
744
+ <li>
745
+ <a href="#settings-git" className={linkClass}>
746
+ {s.sectionGit}
747
+ </a>
748
+ <ul className="mt-1.5 ml-2 space-y-1 border-l border-zinc-700 pl-3">
749
+ <li>
750
+ <a href="#settings-git-identity" className={linkClass}>
751
+ {s.tocSubGitIdentity}
752
+ </a>
753
+ </li>
754
+ </ul>
755
+ </li>
756
+ ) : null}
757
+ {showMongoBlock ? (
758
+ <li>
759
+ <a href="#settings-mongo" className={linkClass}>
760
+ {s.sectionMongo}
761
+ </a>
762
+ {showMongoConn ? (
763
+ <ul className="mt-1.5 ml-2 space-y-1 border-l border-zinc-700 pl-3">
764
+ <li>
765
+ <a href="#settings-mongo-connection" className={linkClass}>
766
+ {s.sectionMongoConnection}
767
+ </a>
768
+ </li>
769
+ </ul>
770
+ ) : null}
771
+ </li>
772
+ ) : null}
773
+ {showWorkspaceLoc ? (
774
+ <li>
775
+ <a href="#settings-workspace-loc" className={linkClass}>
776
+ {s.sectionWorkspaceLoc}
777
+ </a>
778
+ </li>
779
+ ) : null}
780
+ {showWebBlock ? (
781
+ <li>
782
+ <a href="#settings-web" className={linkClass}>
783
+ {s.sectionWeb}
784
+ </a>
785
+ {showWebTour || showWebDuration ? (
786
+ <ul className={subListClass}>
787
+ {showWebTour ? (
788
+ <>
789
+ <li>
790
+ <a
791
+ href="#settings-dashboard-tour"
792
+ className={linkClass}
793
+ >
794
+ {s.tocSubDashboardTour}
795
+ </a>
796
+ </li>
797
+ <li>
798
+ <a
799
+ href="#settings-dashboard-column-hints"
800
+ className={linkClass}
801
+ >
802
+ {s.tocSubDashboardColumnHints}
803
+ </a>
804
+ </li>
805
+ <li>
806
+ <a
807
+ href="#settings-reporting-tour"
808
+ className={linkClass}
809
+ >
810
+ {s.tocSubReportingTour}
811
+ </a>
812
+ </li>
813
+ </>
814
+ ) : null}
815
+ {showWebDuration ? (
816
+ <li>
817
+ <a
818
+ href="#settings-session-duration-alert"
819
+ className={linkClass}
820
+ >
821
+ {s.tocSubSessionDurationAlert}
822
+ </a>
823
+ </li>
824
+ ) : null}
825
+ </ul>
826
+ ) : null}
827
+ </li>
828
+ ) : null}
829
+ {showArchived ? (
830
+ <li>
831
+ <a href="#settings-archived-sessions" className={linkClass}>
832
+ {s.sectionArchivedSessions}
833
+ </a>
834
+ </li>
835
+ ) : null}
836
+ {showDanger ? (
837
+ <li>
838
+ <a href="#settings-danger-zone" className={linkClass}>
839
+ {s.sectionDangerZone}
840
+ </a>
841
+ </li>
842
+ ) : null}
843
+ {showLicenses ? (
844
+ <li>
845
+ <Link href="/licenses" className={linkClass}>
846
+ {s.licensesPageLink}
847
+ </Link>
848
+ </li>
849
+ ) : null}
850
+ </ul>
851
+ )}
852
+ </nav>
853
+ );
854
+ }
855
+
856
+ function SettingsPageContent() {
857
+ const glabSuggestDescId = useId();
858
+ const tocSearchFieldId = useId();
859
+ const {
860
+ payload,
861
+ error,
862
+ refresh: refreshKronosys,
863
+ getLatestPayload,
864
+ } = useKronosysPayload();
865
+ const [form, setForm] = useState<SettingsForm | null>(null);
866
+ const [saving, setSaving] = useState(false);
867
+ const [settingsAck, setSettingsAck] = useState<"save" | "reset" | null>(null);
868
+ const [resetDefaultsConfirmOpen, setResetDefaultsConfirmOpen] =
869
+ useState(false);
870
+ const [resetDefaultsBusy, setResetDefaultsBusy] = useState(false);
871
+ const [mongoUriDraft, setMongoUriDraft] = useState("");
872
+ const [mongoUriSaving, setMongoUriSaving] = useState(false);
873
+ const [mongoPasswordDraft, setMongoPasswordDraft] = useState("");
874
+ const [mongoPasswordSaving, setMongoPasswordSaving] = useState(false);
875
+ const [mongoTestBusy, setMongoTestBusy] = useState(false);
876
+ const [glabRepoBusy, setGlabRepoBusy] = useState(false);
877
+ const [gitlabTokenDraft, setGitlabTokenDraft] = useState("");
878
+ const [gitlabTokenSaving, setGitlabTokenSaving] = useState(false);
879
+ const [gitlabTestBusy, setGitlabTestBusy] = useState(false);
880
+ const [gitlabTestFeedback, setGitlabTestFeedback] = useState<{
881
+ tone: "ok" | "bad" | "warn";
882
+ text: string;
883
+ } | null>(null);
884
+ const [gitIdName, setGitIdName] = useState("");
885
+ const [gitIdEmail, setGitIdEmail] = useState("");
886
+ const [gitIdLogin, setGitIdLogin] = useState("");
887
+ const [gitIdentitySaving, setGitIdentitySaving] = useState(false);
888
+ const [gitIdentitySavedFlash, setGitIdentitySavedFlash] = useState(false);
889
+ const [columnHintsRestoreFlash, setColumnHintsRestoreFlash] = useState(false);
890
+ const [mongoTestFeedback, setMongoTestFeedback] = useState<{
891
+ tone: "ok" | "bad" | "warn";
892
+ text: string;
893
+ } | null>(null);
894
+ const [mongoResyncBusy, setMongoResyncBusy] = useState(false);
895
+ const [settingsDialogAlert, setSettingsDialogAlert] = useState<string | null>(
896
+ null,
897
+ );
898
+ const [mongoResyncConfirmOpen, setMongoResyncConfirmOpen] = useState(false);
899
+ const [archivedSessionsPage, setArchivedSessionsPage] = useState(0);
900
+ const [archivedRestoreBusyId, setArchivedRestoreBusyId] = useState<
901
+ string | null
902
+ >(null);
903
+ const [clearHistoryConfirmOpen, setClearHistoryConfirmOpen] = useState(false);
904
+ const [clearHistoryBusy, setClearHistoryBusy] = useState(false);
905
+ const [settingsTocFilter, setSettingsTocFilter] = useState("");
906
+ const [settingsTourOpen, setSettingsTourOpen] = useState(false);
907
+
908
+ useEffect(() => {
909
+ if (payload && !isSettingsTourCompleted()) {
910
+ setSettingsTourOpen(true);
911
+ }
912
+ }, [payload]);
913
+
914
+ const router = useRouter();
915
+ const searchParams = useSearchParams();
916
+ const dashboardSessionNavId = searchParams.get("session");
917
+
918
+ const refresh = useCallback(
919
+ async (options?: {
920
+ preserveForm?: boolean;
921
+ routerInvalidate?: boolean;
922
+ }): Promise<boolean> => {
923
+ const ok = await refreshKronosys({
924
+ routerInvalidate: options?.routerInvalidate === true,
925
+ });
926
+ if (ok && options?.preserveForm !== true) {
927
+ const p = getLatestPayload();
928
+ if (p) {
929
+ setForm(cfgToForm(p.cfg as Record<string, unknown> | undefined));
930
+ }
931
+ }
932
+ return ok;
933
+ },
934
+ [getLatestPayload, refreshKronosys],
935
+ );
936
+
937
+ const settingsFormBootstrappedRef = useRef(false);
938
+ useEffect(() => {
939
+ if (!payload || settingsFormBootstrappedRef.current) {
940
+ return;
941
+ }
942
+ settingsFormBootstrappedRef.current = true;
943
+ setForm(cfgToForm(payload.cfg as Record<string, unknown> | undefined));
944
+ }, [payload]);
945
+
946
+ useEffect(() => {
947
+ if (gitIdentitySaving) {
948
+ return;
949
+ }
950
+ const g = payload?.gitIdentity;
951
+ setGitIdName(typeof g?.gitUserName === "string" ? g.gitUserName : "");
952
+ setGitIdEmail(typeof g?.gitUserEmail === "string" ? g.gitUserEmail : "");
953
+ setGitIdLogin(
954
+ typeof g?.gitAccountLogin === "string" ? g.gitAccountLogin : "",
955
+ );
956
+ }, [payload?.gitIdentity, gitIdentitySaving]);
957
+
958
+ const saveGitIdentity = useCallback(async () => {
959
+ setGitIdentitySaving(true);
960
+ try {
961
+ await postKronosysAction({
962
+ type: "setGitIdentity",
963
+ gitUserName: gitIdName,
964
+ gitUserEmail: gitIdEmail,
965
+ gitAccountLogin: gitIdLogin,
966
+ });
967
+ await refresh({ preserveForm: true });
968
+ setGitIdentitySavedFlash(true);
969
+ window.setTimeout(() => setGitIdentitySavedFlash(false), 3500);
970
+ } catch (e: unknown) {
971
+ setSettingsDialogAlert(e instanceof Error ? e.message : String(e));
972
+ } finally {
973
+ setGitIdentitySaving(false);
974
+ }
975
+ }, [gitIdEmail, gitIdLogin, gitIdName, refresh]);
976
+
977
+ const live = payload?.current as LiveShape | undefined;
978
+ const lang: Lang = live?.language === "fr" ? "fr" : "en";
979
+ const nav = reportingNav(lang);
980
+ const s: SettingsCopy = settingsCopy(lang);
981
+ const ddr = payload?.devDataRuntime;
982
+ const showDevDataInGeneral = ddr?.isNextDevelopment === true;
983
+ const formLocked = saving || resetDefaultsBusy;
984
+ const dt = dashboardStrings(lang);
985
+ const handleManualRefresh = useCallback(async () => {
986
+ return await refresh({ routerInvalidate: true, preserveForm: true });
987
+ }, [refresh]);
988
+
989
+ const restoreDashboardColumnHintsPreference = useCallback(() => {
990
+ writeDashboardColumnHintsDismissed(false);
991
+ writeDashboardColumnHintsClosed(false);
992
+ setColumnHintsRestoreFlash(true);
993
+ window.setTimeout(() => setColumnHintsRestoreFlash(false), 3500);
994
+ }, []);
995
+
996
+ const dangerClearHistoryModalExtra = useMemo(
997
+ () => (
998
+ <div
999
+ className={`space-y-3 rounded-lg border border-zinc-200 bg-zinc-50/90 p-3 text-zinc-800 dark:border-zinc-600 dark:bg-zinc-900/50 dark:text-zinc-200 ${
1000
+ clearHistoryBusy ? "pointer-events-none opacity-50" : ""
1001
+ }`}
1002
+ >
1003
+ <p className="text-xs font-medium leading-snug">
1004
+ {s.dangerClearHistoryBackupIntro}
1005
+ </p>
1006
+ <ul className="space-y-2.5 text-sm">
1007
+ <li>
1008
+ <a
1009
+ href="/api/backup?format=json"
1010
+ download
1011
+ className="font-medium text-violet-700 underline decoration-violet-400 underline-offset-2 hover:text-violet-600 dark:text-violet-300 dark:hover:text-violet-200"
1012
+ >
1013
+ {s.dangerClearHistoryBackupJson}
1014
+ </a>
1015
+ <p className="mt-0.5 text-[11px] leading-relaxed text-zinc-500 dark:text-zinc-400">
1016
+ {s.dangerClearHistoryBackupJsonHint}
1017
+ </p>
1018
+ </li>
1019
+ <li>
1020
+ <a
1021
+ href="/api/backup?format=csv"
1022
+ download
1023
+ className="font-medium text-violet-700 underline decoration-violet-400 underline-offset-2 hover:text-violet-600 dark:text-violet-300 dark:hover:text-violet-200"
1024
+ >
1025
+ {s.dangerClearHistoryBackupCsvZip}
1026
+ </a>
1027
+ <p className="mt-0.5 text-[11px] leading-relaxed text-zinc-500 dark:text-zinc-400">
1028
+ {s.dangerClearHistoryBackupCsvZipHint}
1029
+ </p>
1030
+ </li>
1031
+ <li>
1032
+ <a
1033
+ href="/api/backup?format=sqlite"
1034
+ download
1035
+ className="font-medium text-violet-700 underline decoration-violet-400 underline-offset-2 hover:text-violet-600 dark:text-violet-300 dark:hover:text-violet-200"
1036
+ >
1037
+ {s.dangerClearHistoryBackupSqlite}
1038
+ </a>
1039
+ <p className="mt-0.5 text-[11px] leading-relaxed text-zinc-500 dark:text-zinc-400">
1040
+ {s.dangerClearHistoryBackupSqliteHint}
1041
+ </p>
1042
+ </li>
1043
+ </ul>
1044
+ </div>
1045
+ ),
1046
+ [s, clearHistoryBusy],
1047
+ );
1048
+ const historyArchivedSorted = useMemo(() => {
1049
+ const raw = (payload?.historyArchived || []) as SessionListEntry[];
1050
+ return [...raw].sort((a, b) => {
1051
+ const ta = Date.parse(a.savedAt || a.startAt || "") || 0;
1052
+ const tb = Date.parse(b.savedAt || b.startAt || "") || 0;
1053
+ return tb - ta;
1054
+ });
1055
+ }, [payload?.historyArchived]);
1056
+ const archivedTotal = historyArchivedSorted.length;
1057
+ const maxArchivedPage = Math.max(
1058
+ 0,
1059
+ Math.ceil(archivedTotal / ARCHIVED_SESSIONS_PAGE_SIZE) - 1,
1060
+ );
1061
+ const archivedPage = Math.min(archivedSessionsPage, maxArchivedPage);
1062
+ const archivedSliceStart = archivedPage * ARCHIVED_SESSIONS_PAGE_SIZE;
1063
+ const archivedPageRows = historyArchivedSorted.slice(
1064
+ archivedSliceStart,
1065
+ archivedSliceStart + ARCHIVED_SESSIONS_PAGE_SIZE,
1066
+ );
1067
+ const archivedRangeTo =
1068
+ archivedTotal === 0
1069
+ ? 0
1070
+ : Math.min(archivedSliceStart + archivedPageRows.length, archivedTotal);
1071
+
1072
+ useEffect(() => {
1073
+ setArchivedSessionsPage((p) => Math.min(p, maxArchivedPage));
1074
+ }, [maxArchivedPage]);
1075
+
1076
+ useEffect(() => {
1077
+ if (!form || !payload) {
1078
+ return;
1079
+ }
1080
+ if (
1081
+ typeof window === "undefined" ||
1082
+ window.location.hash !== "#settings-archived-sessions"
1083
+ ) {
1084
+ return;
1085
+ }
1086
+ const el = document.getElementById("settings-archived-sessions");
1087
+ if (!el) {
1088
+ return;
1089
+ }
1090
+ const t = window.setTimeout(() => {
1091
+ el.scrollIntoView({ behavior: "smooth", block: "start" });
1092
+ }, 80);
1093
+ return () => window.clearTimeout(t);
1094
+ }, [form, payload]);
1095
+
1096
+ const cfgRaw = payload?.cfg as Record<string, unknown> | undefined;
1097
+ const mongoUriConfigured = cfgRaw?.mongodbUriConfigured === true;
1098
+ const mongoManualUriConfigured = cfgRaw?.mongodbManualUriConfigured === true;
1099
+ const mongoPasswordConfigured = cfgRaw?.mongodbPasswordConfigured === true;
1100
+ const mongoRemoteStatus = payload?.remoteStatus as
1101
+ | "connected"
1102
+ | "failed"
1103
+ | "pending"
1104
+ | undefined;
1105
+ const mongoMirrorSavedOn = cfgRaw?.mongodbEnabled === true;
1106
+ const resolvedWorkspaceRoots = useMemo(() => {
1107
+ const top = workspaceFolderPathStrings(payload);
1108
+ if (top.length > 0) {
1109
+ return top;
1110
+ }
1111
+ const cfgObj = payload?.cfg as Record<string, unknown> | undefined;
1112
+ const fromCfg = workspaceFolderPathStrings(cfgObj);
1113
+ if (fromCfg.length > 0) {
1114
+ return fromCfg;
1115
+ }
1116
+ const snap = payload?.workspaceCodeSnapshot as
1117
+ | WorkspaceCodeSnapshotPayload
1118
+ | undefined;
1119
+ if (snap?.ok === true) {
1120
+ const w = snap.workspaceFolder?.trim();
1121
+ if (w) {
1122
+ return [w];
1123
+ }
1124
+ }
1125
+ return [];
1126
+ }, [payload]);
1127
+ const showWorkspaceFoldersEmpty = showWorkspaceFoldersEmptyMessage(
1128
+ payload,
1129
+ resolvedWorkspaceRoots.length,
1130
+ );
1131
+ const suggestGlabRemoteRepo = cfgRaw?.suggestGlabRemoteRepo === true;
1132
+ const gitlabTokenStoredFlag = cfgRaw?.gitlabTokenStored === true;
1133
+ const gitlabTokenFromEnvFlag = cfgRaw?.gitlabTokenFromEnv === true;
1134
+ const gitlabApiVerifiedFlag = cfgRaw?.gitlabApiVerified === true;
1135
+ const gitlabCanAttemptTest =
1136
+ gitlabTokenDraft.trim().length > 0 || gitlabTokenFromEnvFlag;
1137
+ const gitlabTokenStatusText = gitlabTokenStoredFlag
1138
+ ? s.gitlabTokenStoredYes
1139
+ : gitlabTokenFromEnvFlag
1140
+ ? s.gitlabTokenFromEnvYes
1141
+ : s.gitlabTokenNone;
1142
+
1143
+ const headerApiError =
1144
+ lang === "fr"
1145
+ ? "Impossible de joindre l’API locale Kronosys (127.0.0.1:5566 par défaut). Vérifiez que le serveur tourne."
1146
+ : "Cannot reach the local Kronosys API (default 127.0.0.1:5566). Make sure the server is running.";
1147
+
1148
+ const postLang = async (next: Lang) => {
1149
+ await postKronosysAction({ type: "setLanguage", lang: next });
1150
+ await refresh();
1151
+ };
1152
+
1153
+ const onSave = async () => {
1154
+ if (!form) {
1155
+ return;
1156
+ }
1157
+ setSaving(true);
1158
+ setSettingsAck(null);
1159
+ try {
1160
+ const uriTrim = mongoUriDraft.trim();
1161
+ if (uriTrim.length > 0) {
1162
+ const uriRes = await postKronosysAction({
1163
+ type: "setMongoDbConnectionUri",
1164
+ uri: uriTrim,
1165
+ });
1166
+ const uriErr = uriRes.result?.settingsError;
1167
+ if (uriErr) {
1168
+ setSettingsDialogAlert(
1169
+ typeof uriErr === "string" ? uriErr : String(uriErr),
1170
+ );
1171
+ return;
1172
+ }
1173
+ }
1174
+ const passTrim = mongoPasswordDraft.trim();
1175
+ if (passTrim.length > 0) {
1176
+ const passRes = await postKronosysAction({
1177
+ type: "setMongoDbPassword",
1178
+ password: passTrim,
1179
+ });
1180
+ const passErr = passRes.result?.settingsError;
1181
+ if (passErr) {
1182
+ setSettingsDialogAlert(
1183
+ typeof passErr === "string" ? passErr : String(passErr),
1184
+ );
1185
+ return;
1186
+ }
1187
+ }
1188
+ const gitlabTokTrim = gitlabTokenDraft.trim();
1189
+ if (gitlabTokTrim.length > 0) {
1190
+ const tokRes = await postKronosysAction({
1191
+ type: "setGitlabToken",
1192
+ token: gitlabTokTrim,
1193
+ });
1194
+ const tokErr = tokRes.result?.settingsError;
1195
+ if (tokErr) {
1196
+ setSettingsDialogAlert(
1197
+ typeof tokErr === "string" ? tokErr : String(tokErr),
1198
+ );
1199
+ return;
1200
+ }
1201
+ }
1202
+ const {
1203
+ workspaceLocExcludedDirsText,
1204
+ workspaceLocExcludedPathPatternsText,
1205
+ plannedSessions,
1206
+ ...formRest
1207
+ } = form;
1208
+ const res = await postKronosysAction({
1209
+ type: "updateKronosysSettings",
1210
+ settings: {
1211
+ ...formRest,
1212
+ workspaceLocExcludedDirectoryNames: splitLinesToWorkspaceLocDirs(
1213
+ workspaceLocExcludedDirsText,
1214
+ ),
1215
+ workspaceLocExcludedPathPatterns: splitLinesToPathPatterns(
1216
+ workspaceLocExcludedPathPatternsText,
1217
+ ),
1218
+ plannedSessions: plannedSessions.map((ps) => {
1219
+ const { tagsText, ...rest } = ps;
1220
+ return {
1221
+ ...rest,
1222
+ tags: tagsText
1223
+ .split(",")
1224
+ .map((t) => t.trim())
1225
+ .filter(Boolean),
1226
+ };
1227
+ }),
1228
+ },
1229
+ });
1230
+ const err = res.result?.settingsError;
1231
+ if (err) {
1232
+ setSettingsDialogAlert(typeof err === "string" ? err : String(err));
1233
+ return;
1234
+ }
1235
+ setMongoUriDraft("");
1236
+ setMongoPasswordDraft("");
1237
+ setGitlabTokenDraft("");
1238
+ setSettingsAck("save");
1239
+ await refresh();
1240
+ setTimeout(() => setSettingsAck(null), 4000);
1241
+ } finally {
1242
+ setSaving(false);
1243
+ }
1244
+ };
1245
+
1246
+ const pickStorage = async () => {
1247
+ await postKronosysAction({ type: "pickStoragePath" });
1248
+ await refresh();
1249
+ };
1250
+
1251
+ const saveGitlabToken = async (token: string) => {
1252
+ const t = token.trim();
1253
+ if (t.length === 0) {
1254
+ return;
1255
+ }
1256
+ setGitlabTokenSaving(true);
1257
+ try {
1258
+ const res = await postKronosysAction({
1259
+ type: "setGitlabToken",
1260
+ token: t,
1261
+ });
1262
+ const err = res.result?.settingsError;
1263
+ if (err) {
1264
+ setSettingsDialogAlert(typeof err === "string" ? err : String(err));
1265
+ return;
1266
+ }
1267
+ setGitlabTokenDraft("");
1268
+ setGitlabTestFeedback(null);
1269
+ await refresh({ preserveForm: true });
1270
+ } finally {
1271
+ setGitlabTokenSaving(false);
1272
+ }
1273
+ };
1274
+
1275
+ const clearGitlabTokenSetting = async () => {
1276
+ setGitlabTokenSaving(true);
1277
+ try {
1278
+ const res = await postKronosysAction({
1279
+ type: "setGitlabToken",
1280
+ token: "",
1281
+ });
1282
+ const err = res.result?.settingsError;
1283
+ if (err) {
1284
+ setSettingsDialogAlert(typeof err === "string" ? err : String(err));
1285
+ return;
1286
+ }
1287
+ setGitlabTokenDraft("");
1288
+ setGitlabTestFeedback(null);
1289
+ await refresh({ preserveForm: true });
1290
+ } finally {
1291
+ setGitlabTokenSaving(false);
1292
+ }
1293
+ };
1294
+
1295
+ const testGitlabConnection = async (copy: SettingsCopy) => {
1296
+ setGitlabTestBusy(true);
1297
+ setGitlabTestFeedback(null);
1298
+ try {
1299
+ const res = await postKronosysAction({
1300
+ type: "testGitlabConnection",
1301
+ token: gitlabTokenDraft.trim(),
1302
+ lang,
1303
+ gitlabApiBaseUrl: form?.gitlabApiBaseUrl?.trim() ?? "",
1304
+ });
1305
+ const gt = res.result?.gitlabConnectionTest;
1306
+ await refresh({ preserveForm: true });
1307
+ if (gt?.outcome === "connected") {
1308
+ setGitlabTestFeedback({ tone: "ok", text: copy.gitlabTestOk });
1309
+ } else if (gt?.outcome === "failed") {
1310
+ if (gt.reason === "no_token") {
1311
+ setGitlabTestFeedback({ tone: "bad", text: copy.gitlabTestNoToken });
1312
+ } else {
1313
+ const extra =
1314
+ typeof gt.message === "string" && gt.message.trim()
1315
+ ? ` (${gt.message.trim()})`
1316
+ : "";
1317
+ setGitlabTestFeedback({
1318
+ tone: "bad",
1319
+ text: `${copy.gitlabTestFail}${extra}`,
1320
+ });
1321
+ }
1322
+ } else {
1323
+ setGitlabTestFeedback({
1324
+ tone: "bad",
1325
+ text: copy.gitlabTestErrorGeneric,
1326
+ });
1327
+ }
1328
+ } catch {
1329
+ setGitlabTestFeedback({ tone: "bad", text: copy.gitlabTestErrorGeneric });
1330
+ } finally {
1331
+ setGitlabTestBusy(false);
1332
+ }
1333
+ };
1334
+
1335
+ const createGlabRemoteWithGlab = async () => {
1336
+ setGlabRepoBusy(true);
1337
+ try {
1338
+ const res = await postKronosysAction({ type: "createGlabRemoteRepo" });
1339
+ const cr = res.result?.glabRepoCreate;
1340
+ if (cr && cr.ok === false && cr.error) {
1341
+ setSettingsDialogAlert(cr.error);
1342
+ return;
1343
+ }
1344
+ await refresh();
1345
+ } finally {
1346
+ setGlabRepoBusy(false);
1347
+ }
1348
+ };
1349
+
1350
+ const saveMongoUri = async (uri: string) => {
1351
+ setMongoUriSaving(true);
1352
+ try {
1353
+ const res = await postKronosysAction({
1354
+ type: "setMongoDbConnectionUri",
1355
+ uri,
1356
+ });
1357
+ const err = res.result?.settingsError;
1358
+ if (err) {
1359
+ setSettingsDialogAlert(typeof err === "string" ? err : String(err));
1360
+ return;
1361
+ }
1362
+ setMongoUriDraft("");
1363
+ await refresh({ preserveForm: true });
1364
+ } finally {
1365
+ setMongoUriSaving(false);
1366
+ }
1367
+ };
1368
+
1369
+ const saveMongoPassword = async (password: string) => {
1370
+ setMongoPasswordSaving(true);
1371
+ try {
1372
+ const res = await postKronosysAction({
1373
+ type: "setMongoDbPassword",
1374
+ password,
1375
+ });
1376
+ const err = res.result?.settingsError;
1377
+ if (err) {
1378
+ setSettingsDialogAlert(typeof err === "string" ? err : String(err));
1379
+ return;
1380
+ }
1381
+ setMongoPasswordDraft("");
1382
+ await refresh({ preserveForm: true });
1383
+ } finally {
1384
+ setMongoPasswordSaving(false);
1385
+ }
1386
+ };
1387
+
1388
+ const testMongoConnection = async (
1389
+ copy: SettingsCopy,
1390
+ draft: SettingsForm,
1391
+ ) => {
1392
+ setMongoTestBusy(true);
1393
+ setMongoTestFeedback(null);
1394
+ try {
1395
+ const res = await postKronosysAction({
1396
+ type: "testMongoDbConnection",
1397
+ settings: {
1398
+ mongodbEnabled: draft.mongodbEnabled,
1399
+ mongodbDatabaseName: draft.mongodbDatabaseName,
1400
+ mongodbCollectionName: draft.mongodbCollectionName,
1401
+ mongodbHost: draft.mongodbHost,
1402
+ mongodbPort: draft.mongodbPort,
1403
+ mongodbUsername: draft.mongodbUsername,
1404
+ mongodbAuthSource: draft.mongodbAuthSource,
1405
+ },
1406
+ });
1407
+ const outcome = res.result?.mongoConnectionTest?.outcome;
1408
+ await refresh({ preserveForm: true });
1409
+ if (outcome === "connected") {
1410
+ setMongoTestFeedback({ tone: "ok", text: copy.mongoTestOk });
1411
+ } else if (outcome === "failed") {
1412
+ setMongoTestFeedback({ tone: "bad", text: copy.mongoTestFail });
1413
+ } else if (outcome === "disabled") {
1414
+ setMongoTestFeedback({ tone: "warn", text: copy.mongoTestDisabled });
1415
+ }
1416
+ } catch {
1417
+ setMongoTestFeedback({ tone: "bad", text: copy.mongoTestErrorGeneric });
1418
+ } finally {
1419
+ setMongoTestBusy(false);
1420
+ }
1421
+ };
1422
+
1423
+ const openMongoResyncConfirm = useCallback(() => {
1424
+ setMongoResyncConfirmOpen(true);
1425
+ }, []);
1426
+
1427
+ const executeMongoResync = useCallback(async () => {
1428
+ setMongoResyncConfirmOpen(false);
1429
+ const copy = settingsCopy(lang);
1430
+ setMongoResyncBusy(true);
1431
+ try {
1432
+ const res = await postKronosysAction({ type: "resyncMongoMirror" });
1433
+ const mr = res.result?.mongoResync;
1434
+ await refresh({ preserveForm: true });
1435
+ if (mr && mr.ok === true) {
1436
+ setSettingsDialogAlert(
1437
+ copy.mongoResyncDone
1438
+ .replace("{upserted}", String(mr.upserted))
1439
+ .replace("{failed}", String(mr.failed)),
1440
+ );
1441
+ } else if (mr && mr.ok === false) {
1442
+ if (mr.reason === "disabled") {
1443
+ setSettingsDialogAlert(copy.mongoResyncDisabled);
1444
+ } else if (mr.reason === "unsupported") {
1445
+ setSettingsDialogAlert(copy.mongoResyncUnsupported);
1446
+ } else if (mr.reason === "failed") {
1447
+ setSettingsDialogAlert(
1448
+ copy.mongoResyncErrorDetail.replace("{detail}", String(mr.message)),
1449
+ );
1450
+ } else {
1451
+ setSettingsDialogAlert(copy.mongoResyncErrorGeneric);
1452
+ }
1453
+ } else {
1454
+ setSettingsDialogAlert(copy.mongoResyncErrorGeneric);
1455
+ }
1456
+ } catch (e: unknown) {
1457
+ const detail = e instanceof Error ? e.message : String(e);
1458
+ setSettingsDialogAlert(
1459
+ copy.mongoResyncErrorDetail.replace("{detail}", detail),
1460
+ );
1461
+ } finally {
1462
+ setMongoResyncBusy(false);
1463
+ }
1464
+ }, [lang, refresh]);
1465
+
1466
+ const update = <K extends keyof SettingsForm>(
1467
+ key: K,
1468
+ value: SettingsForm[K],
1469
+ ) => {
1470
+ setForm((prev) => (prev ? { ...prev, [key]: value } : prev));
1471
+ };
1472
+
1473
+ const restoreArchivedSession = async (sessionId: string) => {
1474
+ setArchivedRestoreBusyId(sessionId);
1475
+ try {
1476
+ const res = await postKronosysAction({
1477
+ type: "archiveSession",
1478
+ sessionId,
1479
+ archived: false,
1480
+ });
1481
+ const op = res.result?.sessionOp;
1482
+ if (op && !op.ok && op.message) {
1483
+ setSettingsDialogAlert(
1484
+ typeof op.message === "string" ? op.message : String(op.message),
1485
+ );
1486
+ }
1487
+ await refresh({ preserveForm: true });
1488
+ } finally {
1489
+ setArchivedRestoreBusyId(null);
1490
+ }
1491
+ };
1492
+
1493
+ const clearHistory = useCallback(async () => {
1494
+ setClearHistoryBusy(true);
1495
+ try {
1496
+ await postKronosysAction({ type: "clearHistory" });
1497
+ resetDashboardTour();
1498
+ resetDashboardLangPreference();
1499
+ resetGitIdentityBannerDismissed();
1500
+ await refresh({ preserveForm: true });
1501
+ setClearHistoryConfirmOpen(false);
1502
+ router.push("/?tour=replay");
1503
+ } catch (e: unknown) {
1504
+ setSettingsDialogAlert(e instanceof Error ? e.message : String(e));
1505
+ } finally {
1506
+ setClearHistoryBusy(false);
1507
+ }
1508
+ }, [refresh, router]);
1509
+
1510
+ const applyResetToDefaults = useCallback(async () => {
1511
+ setResetDefaultsBusy(true);
1512
+ setSettingsAck(null);
1513
+ try {
1514
+ await postKronosysAction({ type: "resetKronosysSettings" });
1515
+ resetGitIdentityBannerDismissed();
1516
+ setMongoUriDraft("");
1517
+ setMongoPasswordDraft("");
1518
+ setGitlabTokenDraft("");
1519
+ setSettingsAck("reset");
1520
+ await refresh();
1521
+ setTimeout(() => setSettingsAck(null), 4000);
1522
+ } catch (e: unknown) {
1523
+ setSettingsDialogAlert(e instanceof Error ? e.message : String(e));
1524
+ } finally {
1525
+ setResetDefaultsBusy(false);
1526
+ }
1527
+ }, [refresh]);
1528
+
1529
+ return (
1530
+ <div className="min-h-screen bg-zinc-100 text-zinc-900 dark:bg-zinc-900 dark:text-zinc-100">
1531
+ <header className={appShellHeaderClassName}>
1532
+ <div className={appShellHeaderToolRowClassName}>
1533
+ <div className="flex min-w-0 flex-col gap-1">
1534
+ <Link
1535
+ href={withDashboardSessionParam("/", dashboardSessionNavId)}
1536
+ className="w-fit text-xl font-semibold tracking-tight text-zinc-900 hover:text-violet-700 dark:text-zinc-100 dark:hover:text-violet-300"
1537
+ >
1538
+ Kronosys
1539
+ </Link>
1540
+ <p className="flex flex-wrap items-center gap-x-2 text-xs font-medium leading-snug text-zinc-500 dark:text-zinc-400">
1541
+ <span>{dt.brandTagline}</span>
1542
+ <span className="text-zinc-400/70 dark:text-zinc-600" aria-hidden>
1543
+ ·
1544
+ </span>
1545
+ <AppVersionStamp ariaLabelTemplate={dt.appVersionAriaLabel} />
1546
+ </p>
1547
+ </div>
1548
+ <div className="flex flex-wrap items-center gap-1.5">
1549
+ <AppShellRouteNav
1550
+ current="settings"
1551
+ labels={nav}
1552
+ navAriaLabel={dt.appShellRouteNavAria}
1553
+ dashboardSessionId={dashboardSessionNavId}
1554
+ />
1555
+ <ThemeToggle lang={lang} />
1556
+ <PageRefreshButton
1557
+ title={dt.pageRefreshTitle}
1558
+ ariaLabel={dt.pageRefreshAriaLabel}
1559
+ inlineMessages={{
1560
+ loading: dt.pageRefreshProgressLabel,
1561
+ success: dt.pageRefreshDoneToast,
1562
+ error: dt.pageRefreshFailedToast,
1563
+ }}
1564
+ onRefresh={handleManualRefresh}
1565
+ />
1566
+ <LanguageMenu
1567
+ lang={lang}
1568
+ labelEn="English"
1569
+ labelFr="Français"
1570
+ menuHeading={lang === "fr" ? "Langue" : "Language"}
1571
+ triggerAriaLabel={
1572
+ lang === "fr" ? "Langue de l’interface" : "Interface language"
1573
+ }
1574
+ onSelect={(next) => void postLang(next)}
1575
+ />
1576
+ </div>
1577
+ </div>
1578
+ </header>
1579
+
1580
+ <main className="mx-auto w-full max-w-[1920px] px-5 py-8 sm:px-8 lg:px-10 xl:px-12 2xl:px-14">
1581
+ <div id="settings-tour-anchor-intro" className="scroll-mt-32">
1582
+ <h1 className="text-2xl font-semibold text-zinc-900 dark:text-zinc-50">
1583
+ {s.title}
1584
+ </h1>
1585
+ <p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
1586
+ {s.subtitle}
1587
+ </p>
1588
+ </div>
1589
+ <p className="mt-2 text-xs text-zinc-500 dark:text-zinc-500">
1590
+ {s.openIdeSettings}
1591
+ </p>
1592
+
1593
+ {error && (
1594
+ <div
1595
+ className="mt-6 rounded-lg border border-red-900/60 bg-red-950/40 px-4 py-3 text-sm text-red-100"
1596
+ role="alert"
1597
+ >
1598
+ <strong className="block text-red-200">API</strong>
1599
+ {headerApiError}
1600
+ <pre className="mt-2 overflow-x-auto text-xs text-red-200/80">
1601
+ {error}
1602
+ </pre>
1603
+ </div>
1604
+ )}
1605
+
1606
+ {!form && !error && (
1607
+ <p className="mt-8 text-sm text-zinc-500">{s.loading}</p>
1608
+ )}
1609
+
1610
+ {form ? (
1611
+ <div className="mt-8 lg:grid lg:grid-cols-[minmax(0,13rem)_minmax(0,1fr)] lg:items-start lg:gap-10">
1612
+ <aside className="mb-8 lg:sticky lg:top-6 lg:mb-0 lg:self-start">
1613
+ <div className="mb-4">
1614
+ <label htmlFor={tocSearchFieldId} className="sr-only">
1615
+ {s.tocSearchAriaLabel}
1616
+ </label>
1617
+ <div className="relative">
1618
+ <Search
1619
+ className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-zinc-400 dark:text-zinc-500"
1620
+ aria-hidden
1621
+ />
1622
+ <input
1623
+ id={tocSearchFieldId}
1624
+ type="search"
1625
+ value={settingsTocFilter}
1626
+ onChange={(e) => setSettingsTocFilter(e.target.value)}
1627
+ placeholder={s.tocSearchPlaceholder}
1628
+ className="w-full rounded-lg border border-zinc-300 bg-white py-2 pl-9 pr-3 text-sm text-zinc-900 outline-none ring-violet-500/30 placeholder:text-zinc-400 focus:ring-2 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100 dark:placeholder:text-zinc-500"
1629
+ />
1630
+ </div>
1631
+ </div>
1632
+ <SettingsToc
1633
+ s={s}
1634
+ filter={settingsTocFilter}
1635
+ showDevDataInGeneral={showDevDataInGeneral}
1636
+ />
1637
+ </aside>
1638
+ <div className="min-w-0 space-y-10">
1639
+ <div className="flex flex-wrap items-center gap-3">
1640
+ <button
1641
+ type="button"
1642
+ disabled={formLocked}
1643
+ className="rounded-lg bg-violet-600 px-4 py-2 text-sm font-medium text-white hover:bg-violet-500 disabled:opacity-50"
1644
+ onClick={() => void onSave()}
1645
+ >
1646
+ {saving ? s.saving : s.save}
1647
+ </button>
1648
+ <button
1649
+ type="button"
1650
+ disabled={formLocked}
1651
+ className="rounded-lg border border-zinc-600 px-4 py-2 text-sm text-zinc-300 hover:bg-zinc-800 disabled:opacity-50"
1652
+ onClick={() => void refresh()}
1653
+ >
1654
+ {s.reload}
1655
+ </button>
1656
+ <button
1657
+ type="button"
1658
+ disabled={formLocked}
1659
+ className="rounded-lg border border-zinc-500 px-4 py-2 text-sm text-zinc-700 hover:bg-zinc-200 disabled:opacity-50 dark:border-zinc-600 dark:text-zinc-200 dark:hover:bg-zinc-800"
1660
+ onClick={() => setResetDefaultsConfirmOpen(true)}
1661
+ >
1662
+ {resetDefaultsBusy ? s.resettingDefaults : s.resetToDefaults}
1663
+ </button>
1664
+ {settingsAck === "save" ? (
1665
+ <span className="text-sm text-emerald-400">{s.savedOk}</span>
1666
+ ) : settingsAck === "reset" ? (
1667
+ <span className="text-sm text-emerald-400">
1668
+ {s.resetDefaultsDone}
1669
+ </span>
1670
+ ) : null}
1671
+ </div>
1672
+
1673
+ <section
1674
+ id="settings-usage-profile"
1675
+ className={SETTINGS_SECTION_SCROLL}
1676
+ >
1677
+ <h2 className="text-xs font-semibold uppercase tracking-wide text-zinc-500">
1678
+ {s.sectionUsageProfile}
1679
+ </h2>
1680
+ <Field label={s.usageProfile} description={s.usageProfileDesc}>
1681
+ <select
1682
+ className={inputClass(formLocked)}
1683
+ value={form.usageProfile}
1684
+ onChange={(e) => update("usageProfile", e.target.value)}
1685
+ disabled={formLocked}
1686
+ aria-label={s.usageProfile}
1687
+ >
1688
+ <option value="developer" disabled>
1689
+ {s.profileDeveloper}{" "}
1690
+ {lang === "fr" ? "(Désactivé)" : "(Disabled)"}
1691
+ </option>
1692
+ <option value="manager">{s.profileManager}</option>
1693
+ </select>
1694
+ </Field>
1695
+ </section>
1696
+
1697
+ <section
1698
+ id="settings-general"
1699
+ className={SETTINGS_SECTION_SCROLL}
1700
+ >
1701
+ <h2 className="text-xs font-semibold uppercase tracking-wide text-zinc-500">
1702
+ {s.sectionGeneral}
1703
+ </h2>
1704
+ <Field label={s.enabled} description={s.enabledDesc}>
1705
+ <Toggle
1706
+ checked={form.enabled}
1707
+ onChange={(v) => update("enabled", v)}
1708
+ disabled={formLocked}
1709
+ ariaLabel={s.enabled}
1710
+ />
1711
+ </Field>
1712
+ <Field label={s.autoStart} description={s.autoStartDesc}>
1713
+ <Toggle
1714
+ checked={form.autoStart}
1715
+ onChange={(v) => update("autoStart", v)}
1716
+ disabled={formLocked}
1717
+ ariaLabel={s.autoStart}
1718
+ />
1719
+ </Field>
1720
+ <Field label={s.showWelcome} description={s.showWelcomeDesc}>
1721
+ <Toggle
1722
+ checked={form.showWelcomeOnStartup}
1723
+ onChange={(v) => update("showWelcomeOnStartup", v)}
1724
+ disabled={formLocked}
1725
+ ariaLabel={s.showWelcome}
1726
+ />
1727
+ </Field>
1728
+ {showDevDataInGeneral && ddr ? (
1729
+ <div
1730
+ id="settings-dev-data"
1731
+ className="scroll-mt-24 space-y-4 border-t border-zinc-200 pt-6 dark:border-zinc-800"
1732
+ >
1733
+ <h3 className="text-sm font-semibold text-zinc-800 dark:text-zinc-200">
1734
+ {s.devDataBlockTitle}
1735
+ </h3>
1736
+ <p className="text-sm leading-relaxed text-zinc-600 dark:text-zinc-400">
1737
+ {s.devDataIntro}
1738
+ </p>
1739
+ {ddr.traceDataDirOverride ? (
1740
+ <p
1741
+ className="text-sm text-amber-700 dark:text-amber-300/90"
1742
+ role="status"
1743
+ >
1744
+ {s.devDataTraceOverride}
1745
+ </p>
1746
+ ) : null}
1747
+ {ddr.envForcesProductionData &&
1748
+ !ddr.traceDataDirOverride ? (
1749
+ <p
1750
+ className="text-sm text-amber-700 dark:text-amber-300/90"
1751
+ role="status"
1752
+ >
1753
+ {s.devDataEnvOverride}
1754
+ </p>
1755
+ ) : null}
1756
+ {ddr.dataDirectoryResolutionMismatch ? (
1757
+ <p
1758
+ className="text-sm text-amber-700 dark:text-amber-300/90"
1759
+ role="status"
1760
+ >
1761
+ {s.devDataRestartHint}
1762
+ </p>
1763
+ ) : null}
1764
+ <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-6">
1765
+ <div className="min-w-0 sm:max-w-md">
1766
+ <div className="text-sm font-medium text-zinc-800 dark:text-zinc-200">
1767
+ {s.devDataUseProdLabel}
1768
+ </div>
1769
+ <p className="mt-0.5 text-xs leading-snug text-zinc-600 dark:text-zinc-500">
1770
+ {s.devDataUseProdDesc}
1771
+ </p>
1772
+ </div>
1773
+ <Toggle
1774
+ checked={
1775
+ ddr.envForcesProductionData ||
1776
+ form.developmentUseProductionData
1777
+ }
1778
+ onChange={(v) =>
1779
+ update("developmentUseProductionData", v)
1780
+ }
1781
+ disabled={
1782
+ formLocked ||
1783
+ ddr.traceDataDirOverride ||
1784
+ ddr.envForcesProductionData
1785
+ }
1786
+ ariaLabel={s.devDataUseProdLabel}
1787
+ />
1788
+ </div>
1789
+ <div className="space-y-2 rounded-lg border border-zinc-200 bg-zinc-50/80 p-3 text-xs text-zinc-700 dark:border-zinc-700 dark:bg-zinc-900/50 dark:text-zinc-300">
1790
+ <div>
1791
+ <div className="mb-0.5 font-medium text-zinc-500 dark:text-zinc-500">
1792
+ {s.devDataActivePath}
1793
+ </div>
1794
+ <div className="break-all font-mono text-[0.7rem] leading-relaxed">
1795
+ {ddr.activeDataDirectory}
1796
+ </div>
1797
+ </div>
1798
+ <div>
1799
+ <div className="mb-0.5 font-medium text-zinc-500 dark:text-zinc-500">
1800
+ {s.devDataPreferredPath}
1801
+ </div>
1802
+ <div className="break-all font-mono text-[0.7rem] leading-relaxed">
1803
+ {ddr.preferredDataDirectory}
1804
+ </div>
1805
+ </div>
1806
+ <div>
1807
+ <div className="mb-0.5 font-medium text-zinc-500 dark:text-zinc-500">
1808
+ {s.devDataProdPath}
1809
+ </div>
1810
+ <div className="break-all font-mono text-[0.7rem] leading-relaxed">
1811
+ {ddr.productionDataDirectory}
1812
+ </div>
1813
+ </div>
1814
+ <div>
1815
+ <div className="mb-0.5 font-medium text-zinc-500 dark:text-zinc-500">
1816
+ {s.devDataPrefsFile}
1817
+ </div>
1818
+ <div className="break-all font-mono text-[0.7rem] leading-relaxed">
1819
+ {ddr.preferencesFilePath}
1820
+ </div>
1821
+ </div>
1822
+ </div>
1823
+ </div>
1824
+ ) : null}
1825
+ </section>
1826
+
1827
+ <section
1828
+ id="settings-schedule"
1829
+ className={SETTINGS_SECTION_SCROLL}
1830
+ >
1831
+ <h2 className="text-xs font-semibold uppercase tracking-wide text-zinc-500">
1832
+ {s.sectionSchedule}
1833
+ </h2>
1834
+ <Field
1835
+ label={s.scheduleEnabled}
1836
+ description={s.scheduleEnabledDesc}
1837
+ >
1838
+ <Toggle
1839
+ checked={form.scheduleEnabled}
1840
+ onChange={(v) => update("scheduleEnabled", v)}
1841
+ disabled={formLocked}
1842
+ ariaLabel={s.scheduleEnabled}
1843
+ />
1844
+ </Field>
1845
+ {form.scheduleEnabled && (
1846
+ <>
1847
+ <Field
1848
+ label={s.scheduleDays}
1849
+ description={s.scheduleDaysDesc}
1850
+ >
1851
+ <div className="flex flex-wrap items-center gap-2">
1852
+ {[1, 2, 3, 4, 5, 6, 7].map((day) => {
1853
+ const isSelected = form.scheduleDays.includes(day);
1854
+ const label =
1855
+ lang === "fr"
1856
+ ? ["L", "M", "M", "J", "V", "S", "D"][day - 1]
1857
+ : ["M", "T", "W", "T", "F", "S", "S"][day - 1];
1858
+ return (
1859
+ <button
1860
+ key={day}
1861
+ type="button"
1862
+ disabled={formLocked}
1863
+ onClick={() => {
1864
+ const newDays = isSelected
1865
+ ? form.scheduleDays.filter((d) => d !== day)
1866
+ : [...form.scheduleDays, day].sort(
1867
+ (a, b) => a - b,
1868
+ );
1869
+ update("scheduleDays", newDays);
1870
+ }}
1871
+ className={`flex h-8 w-8 items-center justify-center rounded-md border text-sm font-medium transition-colors ${
1872
+ isSelected
1873
+ ? "border-violet-600 bg-violet-600 text-white hover:bg-violet-500"
1874
+ : "border-zinc-300 bg-white text-zinc-700 hover:bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300 dark:hover:bg-zinc-800"
1875
+ }`}
1876
+ >
1877
+ {label}
1878
+ </button>
1879
+ );
1880
+ })}
1881
+ </div>
1882
+ </Field>
1883
+ <div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
1884
+ <Field label={s.scheduleStartTime} description="">
1885
+ <KronosysTimePopoverField
1886
+ value={form.scheduleStartTime}
1887
+ onChange={(v) => update("scheduleStartTime", v)}
1888
+ disabled={formLocked}
1889
+ aria-label={s.scheduleStartTime}
1890
+ lang={lang}
1891
+ use24HourClock={form.dashboardUse24HourClock}
1892
+ t={s}
1893
+ />
1894
+ </Field>
1895
+ <Field label={s.scheduleEndTime} description="">
1896
+ <KronosysTimePopoverField
1897
+ value={form.scheduleEndTime}
1898
+ onChange={(v) => update("scheduleEndTime", v)}
1899
+ disabled={formLocked}
1900
+ aria-label={s.scheduleEndTime}
1901
+ lang={lang}
1902
+ use24HourClock={form.dashboardUse24HourClock}
1903
+ t={s}
1904
+ />
1905
+ </Field>
1906
+ </div>
1907
+ <Field
1908
+ label={s.scheduleCreateDailySession}
1909
+ description={s.scheduleCreateDailySessionDesc}
1910
+ >
1911
+ <Toggle
1912
+ checked={form.scheduleCreateDailySession}
1913
+ onChange={(v) =>
1914
+ update("scheduleCreateDailySession", v)
1915
+ }
1916
+ disabled={formLocked}
1917
+ ariaLabel={s.scheduleCreateDailySession}
1918
+ />
1919
+ </Field>
1920
+ <Field
1921
+ label={s.scheduleTransferTasks}
1922
+ description={s.scheduleTransferTasksDesc}
1923
+ >
1924
+ <Toggle
1925
+ checked={form.scheduleTransferTasks}
1926
+ onChange={(v) => update("scheduleTransferTasks", v)}
1927
+ disabled={formLocked}
1928
+ ariaLabel={s.scheduleTransferTasks}
1929
+ />
1930
+ </Field>
1931
+ <Field
1932
+ label={s.schedulePauseTasks}
1933
+ description={s.schedulePauseTasksDesc}
1934
+ >
1935
+ <Toggle
1936
+ checked={form.schedulePauseTasks}
1937
+ onChange={(v) => update("schedulePauseTasks", v)}
1938
+ disabled={formLocked}
1939
+ ariaLabel={s.schedulePauseTasks}
1940
+ />
1941
+ </Field>
1942
+ </>
1943
+ )}
1944
+ </section>
1945
+
1946
+ <section
1947
+ id="settings-planned-sessions"
1948
+ className={SETTINGS_SECTION_SCROLL}
1949
+ >
1950
+ <h2 className="text-xs font-semibold uppercase tracking-wide text-zinc-500">
1951
+ {s.sectionPlannedSessions}
1952
+ </h2>
1953
+ <p className="mt-1 text-xs leading-relaxed text-zinc-500">
1954
+ {s.plannedSessionsIntro}
1955
+ </p>
1956
+
1957
+ <div className="mt-6 flex flex-col gap-4">
1958
+ {form.plannedSessions.map((ps, idx) => (
1959
+ <div
1960
+ key={ps.id}
1961
+ className="rounded-lg border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900"
1962
+ >
1963
+ <div className="mb-4 flex items-center justify-between">
1964
+ <h3 className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
1965
+ {s.plannedSessionName} {idx + 1}
1966
+ </h3>
1967
+ <button
1968
+ type="button"
1969
+ disabled={formLocked}
1970
+ onClick={() => {
1971
+ update(
1972
+ "plannedSessions",
1973
+ form.plannedSessions.filter((_, i) => i !== idx),
1974
+ );
1975
+ }}
1976
+ className="text-xs text-red-600 hover:text-red-500 disabled:opacity-50 dark:text-red-400"
1977
+ >
1978
+ {s.plannedSessionRemove}
1979
+ </button>
1980
+ </div>
1981
+
1982
+ <div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
1983
+ <Field label={s.plannedSessionName} description="">
1984
+ <input
1985
+ type="text"
1986
+ className={inputClass(formLocked)}
1987
+ value={ps.name}
1988
+ onChange={(e) => {
1989
+ const copy = [...form.plannedSessions];
1990
+ copy[idx] = {
1991
+ ...copy[idx],
1992
+ name: e.target.value,
1993
+ };
1994
+ update("plannedSessions", copy);
1995
+ }}
1996
+ disabled={formLocked}
1997
+ />
1998
+ </Field>
1999
+ <Field label={s.scheduleDays} description="">
2000
+ <div className="flex flex-wrap items-center gap-2">
2001
+ {[1, 2, 3, 4, 5, 6, 7].map((day) => {
2002
+ const isSelected = ps.days.includes(day);
2003
+ const label =
2004
+ lang === "fr"
2005
+ ? ["L", "M", "M", "J", "V", "S", "D"][day - 1]
2006
+ : ["M", "T", "W", "T", "F", "S", "S"][
2007
+ day - 1
2008
+ ];
2009
+ return (
2010
+ <button
2011
+ key={day}
2012
+ type="button"
2013
+ disabled={formLocked}
2014
+ onClick={() => {
2015
+ const newDays = isSelected
2016
+ ? ps.days.filter((d) => d !== day)
2017
+ : [...ps.days, day].sort((a, b) => a - b);
2018
+ const copy = [...form.plannedSessions];
2019
+ copy[idx] = { ...copy[idx], days: newDays };
2020
+ update("plannedSessions", copy);
2021
+ }}
2022
+ className={`flex h-7 w-7 items-center justify-center rounded border text-xs font-medium transition-colors ${
2023
+ isSelected
2024
+ ? "border-violet-600 bg-violet-600 text-white hover:bg-violet-500"
2025
+ : "border-zinc-300 bg-white text-zinc-700 hover:bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300 dark:hover:bg-zinc-800"
2026
+ }`}
2027
+ >
2028
+ {label}
2029
+ </button>
2030
+ );
2031
+ })}
2032
+ </div>
2033
+ </Field>
2034
+ <Field label={s.scheduleStartTime} description="">
2035
+ <KronosysTimePopoverField
2036
+ value={ps.startTime}
2037
+ onChange={(v) => {
2038
+ const copy = [...form.plannedSessions];
2039
+ copy[idx] = { ...copy[idx], startTime: v };
2040
+ update("plannedSessions", copy);
2041
+ }}
2042
+ disabled={formLocked}
2043
+ aria-label={s.scheduleStartTime}
2044
+ lang={lang}
2045
+ use24HourClock={form.dashboardUse24HourClock}
2046
+ t={s}
2047
+ />
2048
+ </Field>
2049
+ <Field label={s.scheduleEndTime} description="">
2050
+ <KronosysTimePopoverField
2051
+ value={ps.endTime}
2052
+ onChange={(v) => {
2053
+ const copy = [...form.plannedSessions];
2054
+ copy[idx] = { ...copy[idx], endTime: v };
2055
+ update("plannedSessions", copy);
2056
+ }}
2057
+ disabled={formLocked}
2058
+ aria-label={s.scheduleEndTime}
2059
+ lang={lang}
2060
+ use24HourClock={form.dashboardUse24HourClock}
2061
+ t={s}
2062
+ />
2063
+ </Field>
2064
+ <Field label={s.plannedSessionProject} description="">
2065
+ <input
2066
+ type="text"
2067
+ className={inputClass(formLocked)}
2068
+ value={ps.project}
2069
+ onChange={(e) => {
2070
+ const copy = [...form.plannedSessions];
2071
+ copy[idx] = {
2072
+ ...copy[idx],
2073
+ project: e.target.value,
2074
+ };
2075
+ update("plannedSessions", copy);
2076
+ }}
2077
+ disabled={formLocked}
2078
+ placeholder="e.g. Deep work"
2079
+ />
2080
+ </Field>
2081
+ <Field label={s.plannedSessionTags} description="">
2082
+ <input
2083
+ type="text"
2084
+ className={inputClass(formLocked)}
2085
+ value={ps.tagsText}
2086
+ onChange={(e) => {
2087
+ const copy = [...form.plannedSessions];
2088
+ copy[idx] = {
2089
+ ...copy[idx],
2090
+ tagsText: e.target.value,
2091
+ };
2092
+ update("plannedSessions", copy);
2093
+ }}
2094
+ disabled={formLocked}
2095
+ placeholder="e.g. meeting, frontend"
2096
+ />
2097
+ </Field>
2098
+ </div>
2099
+
2100
+ <div className="mt-6 flex flex-col gap-4 border-t border-zinc-100 pt-4 dark:border-zinc-800">
2101
+ <Field
2102
+ label={s.scheduleCreateDailySession}
2103
+ description=""
2104
+ >
2105
+ <Toggle
2106
+ checked={ps.createSession}
2107
+ onChange={(v) => {
2108
+ const copy = [...form.plannedSessions];
2109
+ copy[idx] = { ...copy[idx], createSession: v };
2110
+ update("plannedSessions", copy);
2111
+ }}
2112
+ disabled={formLocked}
2113
+ ariaLabel={s.scheduleCreateDailySession}
2114
+ />
2115
+ </Field>
2116
+ <Field label={s.scheduleTransferTasks} description="">
2117
+ <Toggle
2118
+ checked={ps.transferTasks}
2119
+ onChange={(v) => {
2120
+ const copy = [...form.plannedSessions];
2121
+ copy[idx] = { ...copy[idx], transferTasks: v };
2122
+ update("plannedSessions", copy);
2123
+ }}
2124
+ disabled={formLocked}
2125
+ ariaLabel={s.scheduleTransferTasks}
2126
+ />
2127
+ </Field>
2128
+ <Field label={s.schedulePauseTasks} description="">
2129
+ <Toggle
2130
+ checked={ps.pauseTasks}
2131
+ onChange={(v) => {
2132
+ const copy = [...form.plannedSessions];
2133
+ copy[idx] = { ...copy[idx], pauseTasks: v };
2134
+ update("plannedSessions", copy);
2135
+ }}
2136
+ disabled={formLocked}
2137
+ ariaLabel={s.schedulePauseTasks}
2138
+ />
2139
+ </Field>
2140
+ </div>
2141
+ </div>
2142
+ ))}
2143
+
2144
+ <button
2145
+ type="button"
2146
+ onClick={() => {
2147
+ update("plannedSessions", [
2148
+ ...form.plannedSessions,
2149
+ {
2150
+ id: Math.random().toString(36).slice(2, 11),
2151
+ name: "",
2152
+ days: [1, 2, 3, 4, 5],
2153
+ startTime: "10:00",
2154
+ endTime: "11:00",
2155
+ createSession: true,
2156
+ transferTasks: true,
2157
+ pauseTasks: true,
2158
+ tagsText: "",
2159
+ project: "",
2160
+ },
2161
+ ]);
2162
+ }}
2163
+ disabled={formLocked}
2164
+ className="w-fit rounded-lg border border-dashed border-zinc-300 px-4 py-2 text-sm font-medium text-zinc-600 hover:border-violet-500 hover:text-violet-600 disabled:opacity-50 dark:border-zinc-700 dark:text-zinc-400 dark:hover:border-violet-500 dark:hover:text-violet-400"
2165
+ >
2166
+ + {s.plannedSessionAdd}
2167
+ </button>
2168
+ </div>
2169
+ </section>
2170
+
2171
+ <section
2172
+ id="settings-privacy"
2173
+ className={SETTINGS_SECTION_SCROLL}
2174
+ >
2175
+ <h2 className="text-xs font-semibold uppercase tracking-wide text-zinc-500">
2176
+ {s.sectionPrivacy}
2177
+ </h2>
2178
+ <Field
2179
+ label={s.anonymizePaths}
2180
+ description={s.anonymizePathsDesc}
2181
+ >
2182
+ <Toggle
2183
+ checked={form.anonymizePaths}
2184
+ onChange={(v) => update("anonymizePaths", v)}
2185
+ disabled={formLocked}
2186
+ ariaLabel={s.anonymizePaths}
2187
+ />
2188
+ </Field>
2189
+ </section>
2190
+
2191
+ <section
2192
+ id="settings-kronoFocus"
2193
+ className={SETTINGS_SECTION_SCROLL}
2194
+ >
2195
+ <h2 className="text-xs font-semibold uppercase tracking-wide text-zinc-500">
2196
+ {s.sectionKronoFocus}
2197
+ </h2>
2198
+ <Field
2199
+ label={s.kronoFocusShowInHeader}
2200
+ description={s.kronoFocusShowInHeaderDesc}
2201
+ >
2202
+ <SettingsCheckbox
2203
+ checked={form.dashboardShowKronoFocusInHeader}
2204
+ onChange={(v) =>
2205
+ update("dashboardShowKronoFocusInHeader", v)
2206
+ }
2207
+ disabled={formLocked}
2208
+ ariaLabel={s.kronoFocusShowInHeader}
2209
+ />
2210
+ </Field>
2211
+ <Field
2212
+ label={s.kronoFocusShowInTaskOps}
2213
+ description={s.kronoFocusShowInTaskOpsDesc}
2214
+ >
2215
+ <SettingsCheckbox
2216
+ checked={form.dashboardShowKronoFocusInTaskOps}
2217
+ onChange={(v) =>
2218
+ update("dashboardShowKronoFocusInTaskOps", v)
2219
+ }
2220
+ disabled={formLocked}
2221
+ ariaLabel={s.kronoFocusShowInTaskOps}
2222
+ />
2223
+ </Field>
2224
+ </section>
2225
+
2226
+ <section
2227
+ id="settings-task-tags"
2228
+ className={SETTINGS_SECTION_SCROLL}
2229
+ >
2230
+ <h2 className="text-xs font-semibold uppercase tracking-wide text-zinc-500">
2231
+ {s.sectionTaskTags}
2232
+ </h2>
2233
+ <Field
2234
+ label={s.taskDefaultTagBucketEnabled}
2235
+ description={s.taskDefaultTagBucketEnabledDesc}
2236
+ >
2237
+ <SettingsCheckbox
2238
+ checked={form.taskDefaultTagBucketEnabled}
2239
+ onChange={(v) => update("taskDefaultTagBucketEnabled", v)}
2240
+ disabled={formLocked}
2241
+ ariaLabel={s.taskDefaultTagBucketEnabled}
2242
+ />
2243
+ </Field>
2244
+ <Field
2245
+ label={s.dashboardAllowTaskStartTimeEdit}
2246
+ description={s.dashboardAllowTaskStartTimeEditDesc}
2247
+ >
2248
+ <SettingsCheckbox
2249
+ checked={form.dashboardAllowTaskStartTimeEdit}
2250
+ onChange={(v) =>
2251
+ update("dashboardAllowTaskStartTimeEdit", v)
2252
+ }
2253
+ disabled={formLocked}
2254
+ ariaLabel={s.dashboardAllowTaskStartTimeEdit}
2255
+ />
2256
+ </Field>
2257
+ <Field
2258
+ label={s.dashboardAllowSessionStartTimeEdit}
2259
+ description={s.dashboardAllowSessionStartTimeEditDesc}
2260
+ >
2261
+ <SettingsCheckbox
2262
+ checked={form.dashboardAllowSessionStartTimeEdit}
2263
+ onChange={(v) =>
2264
+ update("dashboardAllowSessionStartTimeEdit", v)
2265
+ }
2266
+ disabled={formLocked}
2267
+ ariaLabel={s.dashboardAllowSessionStartTimeEdit}
2268
+ />
2269
+ </Field>
2270
+ <Field
2271
+ label={s.dashboardAllowTaskEndTimeEdit}
2272
+ description={s.dashboardAllowTaskEndTimeEditDesc}
2273
+ >
2274
+ <SettingsCheckbox
2275
+ checked={form.dashboardAllowTaskEndTimeEdit}
2276
+ onChange={(v) => update("dashboardAllowTaskEndTimeEdit", v)}
2277
+ disabled={formLocked}
2278
+ ariaLabel={s.dashboardAllowTaskEndTimeEdit}
2279
+ />
2280
+ </Field>
2281
+ <Field
2282
+ label={s.dashboardAllowSessionEndTimeEdit}
2283
+ description={s.dashboardAllowSessionEndTimeEditDesc}
2284
+ >
2285
+ <SettingsCheckbox
2286
+ checked={form.dashboardAllowSessionEndTimeEdit}
2287
+ onChange={(v) =>
2288
+ update("dashboardAllowSessionEndTimeEdit", v)
2289
+ }
2290
+ disabled={formLocked}
2291
+ ariaLabel={s.dashboardAllowSessionEndTimeEdit}
2292
+ />
2293
+ </Field>
2294
+ </section>
2295
+
2296
+ <section
2297
+ id="settings-collection"
2298
+ className={SETTINGS_SECTION_SCROLL}
2299
+ >
2300
+ <h2 className="text-xs font-semibold uppercase tracking-wide text-zinc-500">
2301
+ {s.sectionCollection}
2302
+ </h2>
2303
+ <Field
2304
+ label={s.flushInterval}
2305
+ description={s.flushIntervalDesc}
2306
+ >
2307
+ <input
2308
+ type="number"
2309
+ min={5}
2310
+ max={3600}
2311
+ className={inputClass(formLocked)}
2312
+ value={form.flushIntervalSeconds}
2313
+ onChange={(e) =>
2314
+ update("flushIntervalSeconds", Number(e.target.value))
2315
+ }
2316
+ disabled={formLocked}
2317
+ />
2318
+ </Field>
2319
+ <Field
2320
+ label={s.heartbeatInterval}
2321
+ description={s.heartbeatIntervalDesc}
2322
+ >
2323
+ <input
2324
+ type="number"
2325
+ min={30}
2326
+ max={86400}
2327
+ className={inputClass(formLocked)}
2328
+ value={form.heartbeatIntervalSeconds}
2329
+ onChange={(e) =>
2330
+ update("heartbeatIntervalSeconds", Number(e.target.value))
2331
+ }
2332
+ disabled={formLocked}
2333
+ />
2334
+ </Field>
2335
+ <Field label={s.maxBuffered} description={s.maxBufferedDesc}>
2336
+ <input
2337
+ type="number"
2338
+ min={100}
2339
+ max={100000}
2340
+ className={inputClass(formLocked)}
2341
+ value={form.maxBufferedEvents}
2342
+ onChange={(e) =>
2343
+ update("maxBufferedEvents", Number(e.target.value))
2344
+ }
2345
+ disabled={formLocked}
2346
+ />
2347
+ </Field>
2348
+ </section>
2349
+
2350
+ <section
2351
+ id="settings-history"
2352
+ className={SETTINGS_SECTION_SCROLL}
2353
+ >
2354
+ <h2 className="text-xs font-semibold uppercase tracking-wide text-zinc-500">
2355
+ {s.sectionHistory}
2356
+ </h2>
2357
+ <p className="text-xs leading-relaxed text-zinc-500">
2358
+ {s.historyLocalFirstHint}
2359
+ </p>
2360
+ <Field label={s.historyPath} description={s.historyPathDesc}>
2361
+ <div className="flex flex-wrap items-center gap-2">
2362
+ <input
2363
+ type="text"
2364
+ className={`${inputClass(formLocked)} max-w-xl flex-1`}
2365
+ value={form.historyStoragePath}
2366
+ onChange={(e) =>
2367
+ update("historyStoragePath", e.target.value)
2368
+ }
2369
+ disabled={formLocked}
2370
+ autoComplete="off"
2371
+ />
2372
+ <button
2373
+ type="button"
2374
+ className="shrink-0 rounded-lg border border-zinc-600 px-3 py-2 text-sm text-zinc-300 hover:bg-zinc-800 disabled:opacity-50"
2375
+ disabled={formLocked}
2376
+ onClick={() => void pickStorage()}
2377
+ >
2378
+ {s.pickStorageFolder}
2379
+ </button>
2380
+ </div>
2381
+ </Field>
2382
+ <Field
2383
+ label={s.localPersistenceDriver}
2384
+ description={s.localPersistenceDriverDesc}
2385
+ >
2386
+ <select
2387
+ className={inputClass(formLocked)}
2388
+ value={form.localPersistenceDriver}
2389
+ onChange={(e) =>
2390
+ update("localPersistenceDriver", e.target.value)
2391
+ }
2392
+ disabled={formLocked}
2393
+ aria-label={s.localPersistenceDriver}
2394
+ >
2395
+ <option value="json">{s.persistenceDriverJson}</option>
2396
+ <option value="sqlite">{s.persistenceDriverSqlite}</option>
2397
+ </select>
2398
+ <p className="mt-2 max-w-xl text-xs text-zinc-500 dark:text-zinc-400">
2399
+ {s.localPersistenceReloadHint}
2400
+ </p>
2401
+ </Field>
2402
+ <Field
2403
+ label={s.historyPartition}
2404
+ description={s.historyPartitionDesc}
2405
+ >
2406
+ <select
2407
+ className={inputClass(formLocked)}
2408
+ value={form.historyPartition}
2409
+ onChange={(e) => update("historyPartition", e.target.value)}
2410
+ disabled={formLocked}
2411
+ >
2412
+ <option value="daily">{s.partitionDaily}</option>
2413
+ <option value="weekly">{s.partitionWeekly}</option>
2414
+ <option value="monthly">{s.partitionMonthly}</option>
2415
+ </select>
2416
+ </Field>
2417
+ <Field
2418
+ label={s.sessionStartMode}
2419
+ description={s.sessionStartModeDesc}
2420
+ >
2421
+ <select
2422
+ className={inputClass(formLocked)}
2423
+ value={form.sessionStartMode}
2424
+ onChange={(e) => update("sessionStartMode", e.target.value)}
2425
+ disabled={formLocked}
2426
+ >
2427
+ <option value="continue">{s.modeContinue}</option>
2428
+ <option value="new">{s.modeNew}</option>
2429
+ </select>
2430
+ </Field>
2431
+ </section>
2432
+
2433
+ <section id="settings-export" className={SETTINGS_SECTION_SCROLL}>
2434
+ <h2 className="text-xs font-semibold uppercase tracking-wide text-zinc-500">
2435
+ {s.sectionExport}
2436
+ </h2>
2437
+ <Field label={s.exportFormat} description={s.exportFormatDesc}>
2438
+ <select
2439
+ className={inputClass(formLocked)}
2440
+ value={form.sessionExportFormat}
2441
+ onChange={(e) =>
2442
+ update("sessionExportFormat", e.target.value)
2443
+ }
2444
+ disabled={formLocked}
2445
+ >
2446
+ <option value="json">{s.fmtJson}</option>
2447
+ <option value="csv">{s.fmtCsv}</option>
2448
+ <option value="jiraTable">{s.fmtJira}</option>
2449
+ <option value="sap">{s.fmtSap}</option>
2450
+ </select>
2451
+ </Field>
2452
+ <Field label={s.csvDelimiter} description={s.csvDelimiterDesc}>
2453
+ <input
2454
+ type="text"
2455
+ maxLength={4}
2456
+ className={inputClass(formLocked)}
2457
+ value={form.csvDelimiter}
2458
+ onChange={(e) => update("csvDelimiter", e.target.value)}
2459
+ disabled={formLocked}
2460
+ />
2461
+ </Field>
2462
+ </section>
2463
+
2464
+ {payload ? (
2465
+ <SettingsTagsProjectsSection
2466
+ s={s}
2467
+ lang={lang}
2468
+ payload={payload}
2469
+ saving={formLocked}
2470
+ refresh={refresh}
2471
+ />
2472
+ ) : null}
2473
+
2474
+ <section id="settings-git" className={SETTINGS_SECTION_SCROLL}>
2475
+ <h2 className="text-xs font-semibold uppercase tracking-wide text-zinc-500">
2476
+ {s.sectionGit}
2477
+ </h2>
2478
+ <div
2479
+ id="settings-git-identity"
2480
+ className="scroll-mt-24 space-y-4 rounded-lg border border-zinc-200 bg-zinc-50/80 p-4 dark:border-zinc-700/80 dark:bg-zinc-950/40"
2481
+ >
2482
+ <div>
2483
+ <h3 className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
2484
+ {s.gitIdentitySubheading}
2485
+ </h3>
2486
+ <p className="mt-1 text-xs text-zinc-600 dark:text-zinc-400">
2487
+ {s.gitIdentityIntro}
2488
+ </p>
2489
+ </div>
2490
+ <Field
2491
+ label={s.gitIdentityNameLabel}
2492
+ description={s.gitIdentityNameDesc}
2493
+ >
2494
+ <input
2495
+ type="text"
2496
+ className={inputClass(formLocked || gitIdentitySaving)}
2497
+ value={gitIdName}
2498
+ onChange={(e) => setGitIdName(e.target.value)}
2499
+ disabled={formLocked || gitIdentitySaving}
2500
+ autoComplete="name"
2501
+ spellCheck={false}
2502
+ />
2503
+ </Field>
2504
+ <Field
2505
+ label={s.gitIdentityEmailLabel}
2506
+ description={s.gitIdentityEmailDesc}
2507
+ >
2508
+ <input
2509
+ type="email"
2510
+ className={inputClass(formLocked || gitIdentitySaving)}
2511
+ value={gitIdEmail}
2512
+ onChange={(e) => setGitIdEmail(e.target.value)}
2513
+ disabled={formLocked || gitIdentitySaving}
2514
+ autoComplete="email"
2515
+ spellCheck={false}
2516
+ />
2517
+ </Field>
2518
+ <Field
2519
+ label={s.gitIdentityLoginLabel}
2520
+ description={s.gitIdentityLoginDesc}
2521
+ >
2522
+ <input
2523
+ type="text"
2524
+ className={inputClass(formLocked || gitIdentitySaving)}
2525
+ value={gitIdLogin}
2526
+ onChange={(e) => setGitIdLogin(e.target.value)}
2527
+ disabled={formLocked || gitIdentitySaving}
2528
+ autoComplete="username"
2529
+ spellCheck={false}
2530
+ />
2531
+ </Field>
2532
+ <div className="flex flex-wrap items-center gap-3">
2533
+ <button
2534
+ type="button"
2535
+ className="rounded-lg bg-violet-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-violet-500 disabled:opacity-50"
2536
+ disabled={formLocked || gitIdentitySaving}
2537
+ onClick={() => void saveGitIdentity()}
2538
+ >
2539
+ {gitIdentitySaving
2540
+ ? s.gitIdentitySaving
2541
+ : s.gitIdentitySave}
2542
+ </button>
2543
+ {gitIdentitySavedFlash ? (
2544
+ <span className="text-sm text-emerald-600 dark:text-emerald-400">
2545
+ {s.gitIdentitySavedFlash}
2546
+ </span>
2547
+ ) : null}
2548
+ </div>
2549
+ </div>
2550
+ <Field label={s.autoGitSync} description={s.autoGitSyncDesc}>
2551
+ <Toggle
2552
+ checked={form.autoGitSync}
2553
+ onChange={(v) => update("autoGitSync", v)}
2554
+ disabled={formLocked}
2555
+ ariaLabel={s.autoGitSync}
2556
+ />
2557
+ </Field>
2558
+ <Field
2559
+ label={s.gitlabInstanceUrl}
2560
+ description={s.gitlabInstanceUrlDesc}
2561
+ >
2562
+ <input
2563
+ type="url"
2564
+ className={inputClass(formLocked)}
2565
+ value={form.gitlabApiBaseUrl}
2566
+ onChange={(e) => update("gitlabApiBaseUrl", e.target.value)}
2567
+ disabled={formLocked}
2568
+ autoComplete="off"
2569
+ spellCheck={false}
2570
+ placeholder="https://gitlab.com"
2571
+ aria-label={s.gitlabInstanceUrl}
2572
+ />
2573
+ <p className="mt-1.5 text-xs leading-relaxed text-zinc-500 dark:text-zinc-400">
2574
+ {s.gitlabTest401Help}
2575
+ </p>
2576
+ </Field>
2577
+ <Field label={s.gitlabToken} description={s.gitlabTokenDesc}>
2578
+ <p className="mb-2 text-xs text-zinc-400">
2579
+ {gitlabTokenStatusText}
2580
+ </p>
2581
+ <input
2582
+ type="password"
2583
+ className={inputClass(
2584
+ formLocked || gitlabTokenSaving || glabRepoBusy,
2585
+ )}
2586
+ value={gitlabTokenDraft}
2587
+ onChange={(e) => setGitlabTokenDraft(e.target.value)}
2588
+ disabled={formLocked || gitlabTokenSaving || glabRepoBusy}
2589
+ autoComplete="off"
2590
+ spellCheck={false}
2591
+ placeholder={gitlabTokenStoredFlag ? "••••••••" : ""}
2592
+ aria-label={s.gitlabToken}
2593
+ />
2594
+ <div className="mt-2 flex flex-wrap gap-2">
2595
+ <button
2596
+ type="button"
2597
+ className="rounded-lg bg-violet-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-violet-500 disabled:opacity-50"
2598
+ disabled={
2599
+ formLocked ||
2600
+ gitlabTokenSaving ||
2601
+ glabRepoBusy ||
2602
+ gitlabTokenDraft.trim().length === 0
2603
+ }
2604
+ title={
2605
+ gitlabTokenDraft.trim().length === 0
2606
+ ? s.gitlabTokenSaveNeedValue
2607
+ : undefined
2608
+ }
2609
+ onClick={() => void saveGitlabToken(gitlabTokenDraft)}
2610
+ >
2611
+ {gitlabTokenSaving ? s.saving : s.gitlabTokenSave}
2612
+ </button>
2613
+ <button
2614
+ type="button"
2615
+ className="rounded-lg border border-zinc-600 px-3 py-1.5 text-sm text-zinc-300 hover:bg-zinc-800 disabled:opacity-50"
2616
+ disabled={
2617
+ formLocked ||
2618
+ gitlabTokenSaving ||
2619
+ glabRepoBusy ||
2620
+ !gitlabTokenStoredFlag
2621
+ }
2622
+ onClick={() => void clearGitlabTokenSetting()}
2623
+ >
2624
+ {s.gitlabTokenClear}
2625
+ </button>
2626
+ </div>
2627
+ {gitlabTokenStoredFlag &&
2628
+ gitlabTokenDraft.trim() === "" &&
2629
+ !gitlabTokenFromEnvFlag ? (
2630
+ <p className="mt-2 text-xs leading-relaxed text-zinc-500 dark:text-zinc-400">
2631
+ {s.gitlabTestPasteStoredHint}
2632
+ </p>
2633
+ ) : null}
2634
+ <div className="mt-3 flex flex-wrap items-center gap-2">
2635
+ <button
2636
+ type="button"
2637
+ className="rounded-lg border border-zinc-600 px-3 py-1.5 text-sm text-zinc-300 hover:bg-zinc-800 disabled:opacity-50"
2638
+ disabled={
2639
+ formLocked ||
2640
+ gitlabTokenSaving ||
2641
+ glabRepoBusy ||
2642
+ gitlabTestBusy ||
2643
+ !gitlabCanAttemptTest
2644
+ }
2645
+ aria-label={s.gitlabTestConnectionAria}
2646
+ onClick={() => void testGitlabConnection(s)}
2647
+ >
2648
+ {gitlabTestBusy
2649
+ ? s.gitlabTestRunning
2650
+ : s.gitlabTestConnection}
2651
+ </button>
2652
+ </div>
2653
+ {gitlabTestFeedback ? (
2654
+ <p
2655
+ className={
2656
+ gitlabTestFeedback.tone === "ok"
2657
+ ? "mt-2 text-sm text-emerald-600 dark:text-emerald-400"
2658
+ : gitlabTestFeedback.tone === "warn"
2659
+ ? "mt-2 text-sm text-amber-600 dark:text-amber-400"
2660
+ : "mt-2 text-sm text-red-600 dark:text-red-400"
2661
+ }
2662
+ >
2663
+ {gitlabTestFeedback.text}
2664
+ </p>
2665
+ ) : null}
2666
+ {gitlabTokenStoredFlag || gitlabTokenFromEnvFlag ? (
2667
+ <p
2668
+ className={
2669
+ gitlabApiVerifiedFlag
2670
+ ? "mt-2 text-xs text-emerald-600 dark:text-emerald-400"
2671
+ : "mt-2 text-xs text-zinc-500 dark:text-zinc-400"
2672
+ }
2673
+ >
2674
+ {gitlabApiVerifiedFlag
2675
+ ? s.gitlabApiVerifiedYes
2676
+ : s.gitlabApiVerifiedNo}
2677
+ </p>
2678
+ ) : null}
2679
+ </Field>
2680
+ {suggestGlabRemoteRepo ? (
2681
+ <div className="max-w-md rounded-lg border border-violet-500/35 bg-violet-950/25 px-3 py-3 text-sm text-zinc-200">
2682
+ <p
2683
+ id={glabSuggestDescId}
2684
+ className="leading-snug text-zinc-300"
2685
+ >
2686
+ {s.glabRemoteSuggest}
2687
+ </p>
2688
+ <button
2689
+ type="button"
2690
+ className="mt-3 w-fit rounded-lg border border-violet-500/60 bg-violet-600/20 px-3 py-1.5 text-sm text-violet-100 hover:bg-violet-600/35 disabled:opacity-50"
2691
+ disabled={formLocked || glabRepoBusy || gitlabTokenSaving}
2692
+ aria-label={s.glabRemoteCreate}
2693
+ aria-describedby={glabSuggestDescId}
2694
+ onClick={() => void createGlabRemoteWithGlab()}
2695
+ >
2696
+ {glabRepoBusy ? s.glabRemoteCreating : s.glabRemoteCreate}
2697
+ </button>
2698
+ </div>
2699
+ ) : null}
2700
+ <Field label={s.gitRemoteUrl} description={s.gitRemoteUrlDesc}>
2701
+ <input
2702
+ type="text"
2703
+ className={inputClass(formLocked)}
2704
+ value={form.gitRemoteUrl}
2705
+ onChange={(e) => update("gitRemoteUrl", e.target.value)}
2706
+ disabled={formLocked}
2707
+ autoComplete="off"
2708
+ />
2709
+ </Field>
2710
+ </section>
2711
+
2712
+ <section id="settings-mongo" className={SETTINGS_SECTION_SCROLL}>
2713
+ <h2 className="text-xs font-semibold uppercase tracking-wide text-zinc-500">
2714
+ {s.sectionMongo}
2715
+ </h2>
2716
+ <Field label={s.mongoEnabled} description={s.mongoEnabledDesc}>
2717
+ <Toggle
2718
+ checked={form.mongodbEnabled}
2719
+ onChange={(v) => update("mongodbEnabled", v)}
2720
+ disabled={formLocked}
2721
+ ariaLabel={s.mongoEnabled}
2722
+ />
2723
+ </Field>
2724
+ <Field
2725
+ label={s.mongoDatabase}
2726
+ description={s.mongoDatabaseDesc}
2727
+ >
2728
+ <input
2729
+ type="text"
2730
+ className={inputClass(formLocked || !form.mongodbEnabled)}
2731
+ value={form.mongodbDatabaseName}
2732
+ onChange={(e) =>
2733
+ update("mongodbDatabaseName", e.target.value)
2734
+ }
2735
+ disabled={formLocked || !form.mongodbEnabled}
2736
+ autoComplete="off"
2737
+ spellCheck={false}
2738
+ aria-label={s.mongoDatabase}
2739
+ />
2740
+ </Field>
2741
+ <Field
2742
+ label={s.mongoCollection}
2743
+ description={s.mongoCollectionDesc}
2744
+ >
2745
+ <input
2746
+ type="text"
2747
+ className={inputClass(formLocked || !form.mongodbEnabled)}
2748
+ value={form.mongodbCollectionName}
2749
+ onChange={(e) =>
2750
+ update("mongodbCollectionName", e.target.value)
2751
+ }
2752
+ disabled={formLocked || !form.mongodbEnabled}
2753
+ autoComplete="off"
2754
+ spellCheck={false}
2755
+ aria-label={s.mongoCollection}
2756
+ />
2757
+ </Field>
2758
+
2759
+ <div
2760
+ id="settings-mongo-connection"
2761
+ className="scroll-mt-24 border-t border-zinc-800 pt-6"
2762
+ >
2763
+ <h3 className="text-sm font-medium text-zinc-200">
2764
+ {s.sectionMongoConnection}
2765
+ </h3>
2766
+ <p className="mt-1 text-xs leading-relaxed text-zinc-500">
2767
+ {s.mongoConnectionIntro}
2768
+ </p>
2769
+ {form.mongodbEnabled ? (
2770
+ <>
2771
+ <p className="mt-3 text-xs text-zinc-500">
2772
+ {s.mongoRemoteStatus}:{" "}
2773
+ {mongoRemoteStatus === "connected"
2774
+ ? s.mongoStatusConnected
2775
+ : mongoRemoteStatus === "failed"
2776
+ ? s.mongoStatusFailed
2777
+ : s.mongoStatusPending}
2778
+ {" · "}
2779
+ {mongoUriConfigured ? (
2780
+ <span className="text-emerald-400/90">
2781
+ {s.mongoConnectionResolvableYes}
2782
+ </span>
2783
+ ) : (
2784
+ <span className="text-amber-400/90">
2785
+ {s.mongoConnectionResolvableNo}
2786
+ </span>
2787
+ )}
2788
+ </p>
2789
+ <div className="mt-3 flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
2790
+ <button
2791
+ type="button"
2792
+ className="w-fit rounded-lg border border-zinc-600 px-3 py-1.5 text-sm text-zinc-200 hover:bg-zinc-800 disabled:opacity-50"
2793
+ disabled={
2794
+ formLocked ||
2795
+ mongoTestBusy ||
2796
+ mongoUriSaving ||
2797
+ mongoPasswordSaving
2798
+ }
2799
+ aria-label={s.mongoTestConnectionAria}
2800
+ onClick={() => void testMongoConnection(s, form)}
2801
+ >
2802
+ {mongoTestBusy
2803
+ ? s.mongoTestRunning
2804
+ : s.mongoTestConnection}
2805
+ </button>
2806
+ {mongoTestFeedback ? (
2807
+ <p
2808
+ role="status"
2809
+ className={`text-sm ${
2810
+ mongoTestFeedback.tone === "ok"
2811
+ ? "text-emerald-400"
2812
+ : mongoTestFeedback.tone === "warn"
2813
+ ? "text-amber-400"
2814
+ : "text-red-300"
2815
+ }`}
2816
+ >
2817
+ {mongoTestFeedback.text}
2818
+ </p>
2819
+ ) : null}
2820
+ </div>
2821
+ <p className="mt-2 max-w-xl text-xs text-zinc-500">
2822
+ {s.mongoTestUsesSavedConfigHint}
2823
+ </p>
2824
+ <div className="mt-4 border-t border-zinc-800 pt-4">
2825
+ <h4 className="text-xs font-semibold uppercase tracking-wide text-zinc-500">
2826
+ {s.mongoSyncSectionTitle}
2827
+ </h4>
2828
+ <MongoMirrorSyncLine
2829
+ t={dt}
2830
+ sync={
2831
+ payload?.mongoMirrorSync as
2832
+ | MongoMirrorSyncPayload
2833
+ | undefined
2834
+ }
2835
+ mongoEnabled={mongoMirrorSavedOn}
2836
+ />
2837
+ <section
2838
+ className="mt-2 space-y-1 border-t border-zinc-800/80 pt-2"
2839
+ aria-labelledby="settings-kronosys-workspace-paths"
2840
+ >
2841
+ <p
2842
+ id="settings-kronosys-workspace-paths"
2843
+ className="text-[11px] font-medium text-zinc-500"
2844
+ >
2845
+ {s.workspaceFoldersLabel}
2846
+ </p>
2847
+ {showWorkspaceFoldersEmpty ? (
2848
+ <p className="text-[11px] text-zinc-500">
2849
+ {s.workspaceFoldersEmpty}
2850
+ </p>
2851
+ ) : resolvedWorkspaceRoots.length > 0 ? (
2852
+ <ul className="list-none space-y-1 font-mono text-[11px] leading-snug text-zinc-400">
2853
+ {resolvedWorkspaceRoots.map((p) => (
2854
+ <li key={p} className="break-all">
2855
+ {p}
2856
+ </li>
2857
+ ))}
2858
+ </ul>
2859
+ ) : null}
2860
+ </section>
2861
+ <button
2862
+ type="button"
2863
+ className="mt-3 rounded-lg border border-zinc-600 px-3 py-1.5 text-sm text-zinc-200 hover:bg-zinc-800 disabled:opacity-50"
2864
+ disabled={
2865
+ formLocked ||
2866
+ mongoResyncBusy ||
2867
+ mongoTestBusy ||
2868
+ mongoUriSaving ||
2869
+ mongoPasswordSaving ||
2870
+ !mongoMirrorSavedOn
2871
+ }
2872
+ aria-label={s.mongoResyncAriaLabel}
2873
+ onClick={() => openMongoResyncConfirm()}
2874
+ >
2875
+ {mongoResyncBusy
2876
+ ? s.mongoResyncRunning
2877
+ : s.mongoResyncButton}
2878
+ </button>
2879
+ <p className="mt-2 max-w-xl text-xs text-zinc-500">
2880
+ {s.mongoResyncHint}
2881
+ </p>
2882
+ </div>
2883
+ </>
2884
+ ) : null}
2885
+ </div>
2886
+
2887
+ <Field label={s.mongoHost} description={s.mongoHostDesc}>
2888
+ <input
2889
+ type="text"
2890
+ className={inputClass(formLocked || !form.mongodbEnabled)}
2891
+ value={form.mongodbHost}
2892
+ onChange={(e) => update("mongodbHost", e.target.value)}
2893
+ disabled={formLocked || !form.mongodbEnabled}
2894
+ autoComplete="off"
2895
+ spellCheck={false}
2896
+ aria-label={s.mongoHost}
2897
+ />
2898
+ </Field>
2899
+ <Field label={s.mongoPort} description={s.mongoPortDesc}>
2900
+ <input
2901
+ type="number"
2902
+ min={1}
2903
+ max={65535}
2904
+ className={inputClass(formLocked || !form.mongodbEnabled)}
2905
+ value={form.mongodbPort}
2906
+ onChange={(e) =>
2907
+ update("mongodbPort", Number(e.target.value))
2908
+ }
2909
+ disabled={formLocked || !form.mongodbEnabled}
2910
+ aria-label={s.mongoPort}
2911
+ />
2912
+ </Field>
2913
+ <Field label={s.mongoUser} description={s.mongoUserDesc}>
2914
+ <input
2915
+ type="text"
2916
+ className={inputClass(formLocked || !form.mongodbEnabled)}
2917
+ value={form.mongodbUsername}
2918
+ onChange={(e) => update("mongodbUsername", e.target.value)}
2919
+ disabled={formLocked || !form.mongodbEnabled}
2920
+ autoComplete="off"
2921
+ spellCheck={false}
2922
+ aria-label={s.mongoUser}
2923
+ />
2924
+ </Field>
2925
+ <Field
2926
+ label={s.mongoAuthSource}
2927
+ description={s.mongoAuthSourceDesc}
2928
+ >
2929
+ <input
2930
+ type="text"
2931
+ className={inputClass(formLocked || !form.mongodbEnabled)}
2932
+ value={form.mongodbAuthSource}
2933
+ onChange={(e) =>
2934
+ update("mongodbAuthSource", e.target.value)
2935
+ }
2936
+ disabled={formLocked || !form.mongodbEnabled}
2937
+ autoComplete="off"
2938
+ spellCheck={false}
2939
+ aria-label={s.mongoAuthSource}
2940
+ />
2941
+ </Field>
2942
+ <Field
2943
+ label={s.mongoPassword}
2944
+ description={s.mongoPasswordDesc}
2945
+ >
2946
+ <p className="mb-2 text-xs text-zinc-400">
2947
+ {mongoPasswordConfigured
2948
+ ? s.mongoPasswordConfiguredYes
2949
+ : s.mongoPasswordConfiguredNo}
2950
+ </p>
2951
+ <input
2952
+ type="password"
2953
+ className={inputClass(
2954
+ formLocked ||
2955
+ mongoPasswordSaving ||
2956
+ mongoUriSaving ||
2957
+ !form.mongodbEnabled,
2958
+ )}
2959
+ value={mongoPasswordDraft}
2960
+ onChange={(e) => setMongoPasswordDraft(e.target.value)}
2961
+ disabled={
2962
+ formLocked ||
2963
+ mongoPasswordSaving ||
2964
+ mongoUriSaving ||
2965
+ !form.mongodbEnabled
2966
+ }
2967
+ autoComplete="new-password"
2968
+ placeholder={mongoPasswordConfigured ? "••••••••" : ""}
2969
+ aria-label={s.mongoPassword}
2970
+ />
2971
+ <div className="mt-2 flex flex-wrap gap-2">
2972
+ <button
2973
+ type="button"
2974
+ className="rounded-lg bg-violet-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-violet-500 disabled:opacity-50"
2975
+ disabled={
2976
+ formLocked ||
2977
+ mongoPasswordSaving ||
2978
+ mongoUriSaving ||
2979
+ !form.mongodbEnabled
2980
+ }
2981
+ onClick={() => void saveMongoPassword(mongoPasswordDraft)}
2982
+ >
2983
+ {mongoPasswordSaving ? s.saving : s.mongoPasswordSave}
2984
+ </button>
2985
+ <button
2986
+ type="button"
2987
+ className="rounded-lg border border-zinc-600 px-3 py-1.5 text-sm text-zinc-300 hover:bg-zinc-800 disabled:opacity-50"
2988
+ disabled={
2989
+ formLocked ||
2990
+ mongoPasswordSaving ||
2991
+ mongoUriSaving ||
2992
+ !form.mongodbEnabled
2993
+ }
2994
+ onClick={() => void saveMongoPassword("")}
2995
+ >
2996
+ {s.mongoPasswordClear}
2997
+ </button>
2998
+ </div>
2999
+ </Field>
3000
+
3001
+ <Field
3002
+ label={s.mongoManualUri}
3003
+ description={s.mongoManualUriDesc}
3004
+ >
3005
+ <p className="mb-2 text-xs text-zinc-400">
3006
+ {mongoManualUriConfigured
3007
+ ? s.mongoUriConfiguredYes
3008
+ : s.mongoUriConfiguredNo}
3009
+ </p>
3010
+ <input
3011
+ type="password"
3012
+ className={inputClass(
3013
+ formLocked ||
3014
+ mongoUriSaving ||
3015
+ mongoPasswordSaving ||
3016
+ !form.mongodbEnabled,
3017
+ )}
3018
+ value={mongoUriDraft}
3019
+ onChange={(e) => setMongoUriDraft(e.target.value)}
3020
+ disabled={
3021
+ formLocked ||
3022
+ mongoUriSaving ||
3023
+ mongoPasswordSaving ||
3024
+ !form.mongodbEnabled
3025
+ }
3026
+ autoComplete="off"
3027
+ placeholder={mongoManualUriConfigured ? "••••••••" : ""}
3028
+ aria-label={s.mongoUri}
3029
+ />
3030
+ <p className="mt-1 text-xs text-zinc-500">{s.mongoUriDesc}</p>
3031
+ <div className="mt-2 flex flex-wrap gap-2">
3032
+ <button
3033
+ type="button"
3034
+ className="rounded-lg bg-violet-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-violet-500 disabled:opacity-50"
3035
+ disabled={
3036
+ formLocked ||
3037
+ mongoUriSaving ||
3038
+ mongoPasswordSaving ||
3039
+ !form.mongodbEnabled
3040
+ }
3041
+ onClick={() => void saveMongoUri(mongoUriDraft)}
3042
+ >
3043
+ {mongoUriSaving ? s.saving : s.mongoUriSave}
3044
+ </button>
3045
+ <button
3046
+ type="button"
3047
+ className="rounded-lg border border-zinc-600 px-3 py-1.5 text-sm text-zinc-300 hover:bg-zinc-800 disabled:opacity-50"
3048
+ disabled={
3049
+ formLocked ||
3050
+ mongoUriSaving ||
3051
+ mongoPasswordSaving ||
3052
+ !form.mongodbEnabled
3053
+ }
3054
+ onClick={() => void saveMongoUri("")}
3055
+ >
3056
+ {s.mongoUriClear}
3057
+ </button>
3058
+ </div>
3059
+ </Field>
3060
+ </section>
3061
+
3062
+ <section
3063
+ id="settings-workspace-loc"
3064
+ className={SETTINGS_SECTION_SCROLL}
3065
+ >
3066
+ <h2 className="text-xs font-semibold uppercase tracking-wide text-zinc-500">
3067
+ {s.sectionWorkspaceLoc}
3068
+ </h2>
3069
+ {form.usageProfile === "manager" ? (
3070
+ <p className="mt-2 text-sm text-zinc-500">
3071
+ {s.workspaceLocSectionHiddenManager}
3072
+ </p>
3073
+ ) : (
3074
+ <>
3075
+ <Field
3076
+ label={s.workspaceLocExcludedDirs}
3077
+ description={s.workspaceLocExcludedDirsDesc}
3078
+ >
3079
+ <textarea
3080
+ className={textareaClass(formLocked)}
3081
+ value={form.workspaceLocExcludedDirsText}
3082
+ onChange={(e) =>
3083
+ update("workspaceLocExcludedDirsText", e.target.value)
3084
+ }
3085
+ disabled={formLocked}
3086
+ spellCheck={false}
3087
+ aria-label={s.workspaceLocExcludedDirs}
3088
+ />
3089
+ </Field>
3090
+ <Field
3091
+ label={s.workspaceLocExcludedPatterns}
3092
+ description={s.workspaceLocExcludedPatternsDesc}
3093
+ >
3094
+ <textarea
3095
+ className={textareaClass(formLocked)}
3096
+ value={form.workspaceLocExcludedPathPatternsText}
3097
+ onChange={(e) =>
3098
+ update(
3099
+ "workspaceLocExcludedPathPatternsText",
3100
+ e.target.value,
3101
+ )
3102
+ }
3103
+ disabled={formLocked}
3104
+ spellCheck={false}
3105
+ aria-label={s.workspaceLocExcludedPatterns}
3106
+ />
3107
+ </Field>
3108
+ </>
3109
+ )}
3110
+ </section>
3111
+
3112
+ <section id="settings-web" className={SETTINGS_SECTION_SCROLL}>
3113
+ <h2 className="text-xs font-semibold uppercase tracking-wide text-zinc-500">
3114
+ {s.sectionWeb}
3115
+ </h2>
3116
+
3117
+ <div className="flex flex-col gap-8 mb-10">
3118
+ <Field
3119
+ label={s.dashboardDisplayTimeZone}
3120
+ description={s.dashboardDisplayTimeZoneDesc}
3121
+ >
3122
+ <select
3123
+ className={inputClass(formLocked)}
3124
+ value={form.dashboardDisplayTimeZone}
3125
+ onChange={(e) =>
3126
+ update("dashboardDisplayTimeZone", e.target.value)
3127
+ }
3128
+ disabled={formLocked}
3129
+ >
3130
+ {DASHBOARD_TIME_ZONE_SELECT_OPTIONS.map((opt) => (
3131
+ <option key={opt.id} value={opt.id}>
3132
+ {lang === "fr" ? opt.labelFr : opt.labelEn}
3133
+ </option>
3134
+ ))}
3135
+ </select>
3136
+ </Field>
3137
+
3138
+ <Field
3139
+ label={s.dashboardClockFormat}
3140
+ description={s.dashboardClockFormatDesc}
3141
+ >
3142
+ <div className="flex flex-wrap items-center gap-4">
3143
+ <label className="flex items-center gap-2 text-sm text-zinc-700 dark:text-zinc-300">
3144
+ <input
3145
+ type="radio"
3146
+ name="dashboardUse24HourClock"
3147
+ checked={form.dashboardUse24HourClock === false}
3148
+ onChange={() =>
3149
+ update("dashboardUse24HourClock", false)
3150
+ }
3151
+ disabled={formLocked}
3152
+ className="h-4 w-4 accent-violet-600"
3153
+ />
3154
+ {s.dashboardClock12hOption}
3155
+ </label>
3156
+ <label className="flex items-center gap-2 text-sm text-zinc-700 dark:text-zinc-300">
3157
+ <input
3158
+ type="radio"
3159
+ name="dashboardUse24HourClock"
3160
+ checked={form.dashboardUse24HourClock === true}
3161
+ onChange={() =>
3162
+ update("dashboardUse24HourClock", true)
3163
+ }
3164
+ disabled={formLocked}
3165
+ className="h-4 w-4 accent-violet-600"
3166
+ />
3167
+ {s.dashboardClock24hOption}
3168
+ </label>
3169
+ </div>
3170
+ </Field>
3171
+ </div>
3172
+ <div
3173
+ id="settings-dashboard-tour"
3174
+ className="scroll-mt-24 mb-8 rounded-xl border border-zinc-200 bg-zinc-50/80 p-4 dark:border-zinc-700 dark:bg-zinc-900/50"
3175
+ >
3176
+ <h3 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">
3177
+ {s.dashboardTourBlockTitle}
3178
+ </h3>
3179
+ <p className="mt-2 text-sm leading-relaxed text-zinc-600 dark:text-zinc-400">
3180
+ {s.dashboardTourBlockIntro}
3181
+ </p>
3182
+ <Link
3183
+ href="/?tour=replay"
3184
+ className="mt-4 inline-flex items-center justify-center rounded-lg border border-violet-500/50 bg-violet-500/10 px-4 py-2 text-sm font-medium text-violet-800 transition hover:border-violet-500/75 hover:bg-violet-500/18 dark:border-violet-400/45 dark:bg-violet-600/20 dark:text-violet-100 dark:hover:border-violet-400/60 dark:hover:bg-violet-600/30"
3185
+ >
3186
+ {s.dashboardTourReplayLinkLabel}
3187
+ </Link>
3188
+ <button
3189
+ type="button"
3190
+ className="mt-4 ml-3 inline-flex items-center justify-center rounded-lg border border-zinc-500/50 bg-zinc-500/10 px-4 py-2 text-sm font-medium text-zinc-800 transition hover:border-zinc-500/75 hover:bg-zinc-500/18 dark:border-zinc-400/45 dark:bg-zinc-600/20 dark:text-zinc-100 dark:hover:border-zinc-400/60 dark:hover:bg-zinc-600/30"
3191
+ onClick={() => {
3192
+ resetDashboardTour();
3193
+ resetSettingsTour();
3194
+ setSettingsTourOpen(true);
3195
+ }}
3196
+ >
3197
+ {dt.tourUndismissBtn}
3198
+ </button>
3199
+ </div>
3200
+ <div
3201
+ id="settings-dashboard-column-hints"
3202
+ className="scroll-mt-24 mb-8 rounded-xl border border-zinc-200 bg-zinc-50/80 p-4 dark:border-zinc-700 dark:bg-zinc-900/50"
3203
+ >
3204
+ <h3 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">
3205
+ {s.dashboardColumnHintsBlockTitle}
3206
+ </h3>
3207
+ <p className="mt-2 text-sm leading-relaxed text-zinc-600 dark:text-zinc-400">
3208
+ {s.dashboardColumnHintsBlockIntro}
3209
+ </p>
3210
+ <div className="mt-4 flex flex-wrap items-center gap-x-3 gap-y-2">
3211
+ <button
3212
+ type="button"
3213
+ className="inline-flex items-center justify-center rounded-lg border border-zinc-500/50 bg-zinc-500/10 px-4 py-2 text-sm font-medium text-zinc-800 transition hover:border-zinc-500/75 hover:bg-zinc-500/18 dark:border-zinc-400/45 dark:bg-zinc-600/20 dark:text-zinc-100 dark:hover:border-zinc-400/60 dark:hover:bg-zinc-600/30"
3214
+ onClick={restoreDashboardColumnHintsPreference}
3215
+ >
3216
+ {s.dashboardColumnHintsRestoreBtn}
3217
+ </button>
3218
+ {columnHintsRestoreFlash ? (
3219
+ <span
3220
+ className="text-sm text-emerald-600 dark:text-emerald-400"
3221
+ role="status"
3222
+ >
3223
+ {s.dashboardColumnHintsRestoreDone}
3224
+ </span>
3225
+ ) : null}
3226
+ </div>
3227
+ </div>
3228
+ <div
3229
+ id="settings-reporting-tour"
3230
+ className="scroll-mt-24 mb-8 rounded-xl border border-zinc-200 bg-zinc-50/80 p-4 dark:border-zinc-700 dark:bg-zinc-900/50"
3231
+ >
3232
+ <h3 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">
3233
+ {s.reportingTourBlockTitle}
3234
+ </h3>
3235
+ <p className="mt-2 text-sm leading-relaxed text-zinc-600 dark:text-zinc-400">
3236
+ {s.reportingTourBlockIntro}
3237
+ </p>
3238
+ <Link
3239
+ href="/reporting?tour=replay"
3240
+ className="mt-4 inline-flex items-center justify-center rounded-lg border border-violet-500/50 bg-violet-500/10 px-4 py-2 text-sm font-medium text-violet-800 transition hover:border-violet-500/75 hover:bg-violet-500/18 dark:border-violet-400/45 dark:bg-violet-600/20 dark:text-violet-100 dark:hover:border-violet-400/60 dark:hover:bg-violet-600/30"
3241
+ >
3242
+ {s.reportingTourReplayLinkLabel}
3243
+ </Link>
3244
+ </div>
3245
+ <Field label={s.localApiPort} description={s.localApiPortDesc}>
3246
+ <input
3247
+ type="number"
3248
+ min={1024}
3249
+ max={65535}
3250
+ className={inputClass(formLocked)}
3251
+ value={form.localApiPort}
3252
+ onChange={(e) =>
3253
+ update("localApiPort", Number(e.target.value))
3254
+ }
3255
+ disabled={formLocked}
3256
+ />
3257
+ <p className="mt-2 text-xs text-amber-200/80">
3258
+ {s.hintPortRestart}
3259
+ </p>
3260
+ </Field>
3261
+ <Field
3262
+ label={s.dashboardWebUrl}
3263
+ description={s.dashboardWebUrlDesc}
3264
+ >
3265
+ <input
3266
+ type="url"
3267
+ className={inputClass(formLocked)}
3268
+ value={form.dashboardWebUrl}
3269
+ onChange={(e) => update("dashboardWebUrl", e.target.value)}
3270
+ disabled={formLocked}
3271
+ />
3272
+ <p className="mt-2 text-xs text-zinc-500">
3273
+ {s.hintDashboardUrl}
3274
+ </p>
3275
+ </Field>
3276
+ <div
3277
+ id="settings-session-duration-alert"
3278
+ className="scroll-mt-24"
3279
+ >
3280
+ <Field
3281
+ label={s.dashboardSessionDurationAlertHours}
3282
+ description={s.dashboardSessionDurationAlertHoursDesc}
3283
+ >
3284
+ <input
3285
+ type="number"
3286
+ min={1}
3287
+ max={8760}
3288
+ className={inputClass(formLocked)}
3289
+ value={form.dashboardSessionDurationAlertHours}
3290
+ onChange={(e) =>
3291
+ update(
3292
+ "dashboardSessionDurationAlertHours",
3293
+ Number(e.target.value),
3294
+ )
3295
+ }
3296
+ disabled={formLocked}
3297
+ aria-label={s.dashboardSessionDurationAlertHours}
3298
+ />
3299
+ </Field>
3300
+ </div>
3301
+ </section>
3302
+
3303
+ <section
3304
+ id="settings-archived-sessions"
3305
+ className={SETTINGS_SECTION_SCROLL}
3306
+ >
3307
+ <h2 className="text-xs font-semibold uppercase tracking-wide text-zinc-500">
3308
+ {s.sectionArchivedSessions}
3309
+ </h2>
3310
+ <p className="text-xs leading-relaxed text-zinc-500">
3311
+ {s.archivedSessionsIntro}
3312
+ </p>
3313
+ <ul className="mt-4 divide-y divide-zinc-800 rounded-lg border border-zinc-800">
3314
+ {archivedTotal === 0 ? (
3315
+ <li className="px-3 py-8 text-center text-sm text-zinc-500">
3316
+ {dt.archivesEmpty}
3317
+ </li>
3318
+ ) : (
3319
+ archivedPageRows.map((sess) => {
3320
+ const label =
3321
+ sess.sessionName?.trim() || sess.sessionId.slice(0, 8);
3322
+ const n = archivedTaskCount(sess);
3323
+ const busy = archivedRestoreBusyId === sess.sessionId;
3324
+ return (
3325
+ <li
3326
+ key={sess.sessionId}
3327
+ className="flex items-center justify-between gap-2 px-3 py-2.5 hover:bg-zinc-800/40"
3328
+ >
3329
+ <div className="min-w-0 flex-1">
3330
+ <div className="truncate font-medium text-zinc-200">
3331
+ {label}
3332
+ </div>
3333
+ <div className="text-[0.7rem] text-zinc-500">
3334
+ {n} {sessionTaskCountNoun(n, dt, lang)}
3335
+ </div>
3336
+ </div>
3337
+ <button
3338
+ type="button"
3339
+ disabled={busy || formLocked}
3340
+ className="shrink-0 rounded-lg border border-violet-600/50 px-3 py-1.5 text-sm text-violet-200 hover:bg-violet-950/40 disabled:cursor-not-allowed disabled:opacity-50"
3341
+ onClick={() =>
3342
+ void restoreArchivedSession(sess.sessionId)
3343
+ }
3344
+ >
3345
+ {dt.sessionRestoreBtn}
3346
+ </button>
3347
+ </li>
3348
+ );
3349
+ })
3350
+ )}
3351
+ </ul>
3352
+ {archivedTotal > ARCHIVED_SESSIONS_PAGE_SIZE ? (
3353
+ <nav
3354
+ className="mt-3 flex flex-wrap items-center justify-between gap-2"
3355
+ aria-label={s.archivesPaginationNavAria}
3356
+ >
3357
+ <button
3358
+ type="button"
3359
+ disabled={archivedPage <= 0 || formLocked}
3360
+ className="rounded-lg border border-zinc-600 px-3 py-1.5 text-sm text-zinc-300 hover:bg-zinc-800 disabled:cursor-not-allowed disabled:opacity-40"
3361
+ onClick={() =>
3362
+ setArchivedSessionsPage((p) => Math.max(0, p - 1))
3363
+ }
3364
+ >
3365
+ {s.archivesPaginationPrev}
3366
+ </button>
3367
+ <span className="text-xs tabular-nums text-zinc-500">
3368
+ {s.archivesPaginationRange
3369
+ .replace(
3370
+ "{from}",
3371
+ String(
3372
+ archivedTotal === 0 ? 0 : archivedSliceStart + 1,
3373
+ ),
3374
+ )
3375
+ .replace("{to}", String(archivedRangeTo))
3376
+ .replace("{total}", String(archivedTotal))}
3377
+ </span>
3378
+ <button
3379
+ type="button"
3380
+ disabled={archivedPage >= maxArchivedPage || formLocked}
3381
+ className="rounded-lg border border-zinc-600 px-3 py-1.5 text-sm text-zinc-300 hover:bg-zinc-800 disabled:cursor-not-allowed disabled:opacity-40"
3382
+ onClick={() =>
3383
+ setArchivedSessionsPage((p) =>
3384
+ Math.min(maxArchivedPage, p + 1),
3385
+ )
3386
+ }
3387
+ >
3388
+ {s.archivesPaginationNext}
3389
+ </button>
3390
+ </nav>
3391
+ ) : archivedTotal > 0 ? (
3392
+ <p className="mt-2 text-xs tabular-nums text-zinc-500">
3393
+ {s.archivesPaginationRange
3394
+ .replace("{from}", String(archivedSliceStart + 1))
3395
+ .replace("{to}", String(archivedRangeTo))
3396
+ .replace("{total}", String(archivedTotal))}
3397
+ </p>
3398
+ ) : null}
3399
+ </section>
3400
+
3401
+ <section
3402
+ id="settings-danger-zone"
3403
+ className={SETTINGS_SECTION_SCROLL}
3404
+ >
3405
+ <h2 className="text-xs font-semibold uppercase tracking-wide text-red-700 dark:text-red-400/95">
3406
+ {s.sectionDangerZone}
3407
+ </h2>
3408
+ <div className="max-w-xl rounded-xl border border-red-300/90 bg-red-50/80 p-4 dark:border-red-900/55 dark:bg-red-950/30">
3409
+ <h3 className="text-sm font-semibold text-red-900 dark:text-red-200">
3410
+ {s.dangerClearHistoryTitle}
3411
+ </h3>
3412
+ <p className="mt-2 text-xs leading-relaxed text-red-900/85 dark:text-red-200/85">
3413
+ {s.dangerClearHistoryBodyIntro}
3414
+ </p>
3415
+ <ul className="mt-2 list-disc space-y-1.5 pl-5 text-xs leading-relaxed text-red-900/85 dark:text-red-200/85">
3416
+ {s.dangerClearHistoryBodyBullets.map((line) => (
3417
+ <li key={line}>{line}</li>
3418
+ ))}
3419
+ </ul>
3420
+ <p className="mt-2 text-xs leading-relaxed text-red-900/85 dark:text-red-200/85">
3421
+ {s.dangerClearHistoryBodyAfterList1}
3422
+ </p>
3423
+ <p className="mt-2 text-xs leading-relaxed text-red-900/85 dark:text-red-200/85">
3424
+ {s.dangerClearHistoryBodyAfterList2}
3425
+ </p>
3426
+ <button
3427
+ type="button"
3428
+ className="mt-4 rounded-lg border border-red-600/80 bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-500 dark:border-red-500 dark:bg-red-800/90 dark:hover:bg-red-700"
3429
+ onClick={() => setClearHistoryConfirmOpen(true)}
3430
+ >
3431
+ {s.dangerClearHistoryButton}
3432
+ </button>
3433
+ </div>
3434
+ </section>
3435
+ </div>
3436
+ </div>
3437
+ ) : null}
3438
+ </main>
3439
+ <DashboardAlertModal
3440
+ open={settingsDialogAlert !== null}
3441
+ message={settingsDialogAlert ?? ""}
3442
+ okLabel={s.dialogOkBtn}
3443
+ onClose={() => setSettingsDialogAlert(null)}
3444
+ />
3445
+ <DashboardConfirmModal
3446
+ open={mongoResyncConfirmOpen}
3447
+ message={s.mongoResyncConfirm}
3448
+ cancelLabel={s.dialogCancelBtn}
3449
+ confirmLabel={s.dialogConfirmBtn}
3450
+ onCancel={() => setMongoResyncConfirmOpen(false)}
3451
+ onConfirm={() => void executeMongoResync()}
3452
+ />
3453
+ <DashboardConfirmModal
3454
+ open={clearHistoryConfirmOpen}
3455
+ title={s.dangerClearHistoryTitle}
3456
+ message={s.dangerClearHistoryConfirm}
3457
+ cancelLabel={s.dialogCancelBtn}
3458
+ confirmLabel={s.dialogConfirmBtn}
3459
+ confirmVariant="danger"
3460
+ extra={dangerClearHistoryModalExtra}
3461
+ pending={clearHistoryBusy}
3462
+ typeToConfirm={{
3463
+ expected: s.dangerClearHistoryConfirmPhrase,
3464
+ instruction: s.dangerClearHistoryTypeInstruction.replace(
3465
+ "{phrase}",
3466
+ s.dangerClearHistoryConfirmPhrase,
3467
+ ),
3468
+ label: s.dangerClearHistoryTypeFieldLabel,
3469
+ inputAriaLabel: s.dangerClearHistoryTypeInputAria,
3470
+ }}
3471
+ onCancel={() => {
3472
+ if (!clearHistoryBusy) {
3473
+ setClearHistoryConfirmOpen(false);
3474
+ }
3475
+ }}
3476
+ onConfirm={() => {
3477
+ void clearHistory();
3478
+ }}
3479
+ />
3480
+ <DashboardConfirmModal
3481
+ open={resetDefaultsConfirmOpen}
3482
+ title={s.resetToDefaults}
3483
+ message={s.resetToDefaultsConfirm}
3484
+ cancelLabel={s.dialogCancelBtn}
3485
+ confirmLabel={s.dialogConfirmBtn}
3486
+ onCancel={() => {
3487
+ if (!resetDefaultsBusy) {
3488
+ setResetDefaultsConfirmOpen(false);
3489
+ }
3490
+ }}
3491
+ onConfirm={() => {
3492
+ setResetDefaultsConfirmOpen(false);
3493
+ void applyResetToDefaults();
3494
+ }}
3495
+ />
3496
+ <ScrollToTopFab ariaLabel={s.scrollToTopAria} />
3497
+ <SettingsTour
3498
+ open={settingsTourOpen}
3499
+ onOpenChange={setSettingsTourOpen}
3500
+ dt={dt}
3501
+ />
3502
+ </div>
3503
+ );
3504
+ }
3505
+
3506
+ export default function SettingsPage() {
3507
+ return (
3508
+ <Suspense
3509
+ fallback={
3510
+ <div className="min-h-screen bg-zinc-100 px-6 py-10 text-sm text-zinc-500 dark:bg-zinc-900">
3511
+ Kronosys…
3512
+ </div>
3513
+ }
3514
+ >
3515
+ <SettingsPageContent />
3516
+ </Suspense>
3517
+ );
3518
+ }