@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,394 @@
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import { useLayoutEffect, useMemo, useState } from "react";
5
+ import { usePathname } from "next/navigation";
6
+ import { Check, ChevronLeft, ChevronRight, Circle, LayoutDashboard, Timer } from "lucide-react";
7
+ import { useKronosysPayload } from "@/components/KronosysPayloadProvider";
8
+ import { useSmoothStopwatchDisplayMs } from "@/components/dashboard/useSmoothStopwatchMs";
9
+ import { useKronoFocusLiveSeconds } from "@/components/dashboard/useKronoFocusLiveSeconds";
10
+ import { dashboardStrings, type DashboardStrings, type Lang } from "@/lib/dashboardCopy";
11
+ import { formatStopwatchMs, formatWallDurationMs, taskTitleForDisplay } from "@/lib/taskParsing";
12
+ import { withDashboardSessionParam } from "@/lib/dashboardSessionNav";
13
+
14
+ type LiveTask = {
15
+ id?: string;
16
+ name?: string;
17
+ isDone?: boolean;
18
+ manualTaskTimerPaused?: boolean;
19
+ activeSubtaskTimerId?: string;
20
+ durationMs?: number;
21
+ subtasks?: Array<{ id?: string; title?: string; done?: boolean; durationMs?: number }>;
22
+ };
23
+
24
+ type LiveShape = {
25
+ sessionId?: string;
26
+ sessionName?: string;
27
+ archived?: boolean;
28
+ endAt?: string | null;
29
+ isPaused?: boolean;
30
+ sessionDurationMinutes?: number;
31
+ activeTasks?: LiveTask[];
32
+ activeTask?: LiveTask | null;
33
+ language?: string;
34
+ kronoFocus?: {
35
+ mode: "work" | "break" | "longBreak";
36
+ status: "idle" | "running" | "paused";
37
+ timeLeftSeconds: number;
38
+ kronoFocusDeadlineAtMs?: number;
39
+ linkedTaskId?: string;
40
+ linkedTaskName?: string;
41
+ };
42
+ };
43
+
44
+ function kfPhaseLabel(dt: DashboardStrings, mode: "work" | "break" | "longBreak" | undefined): string {
45
+ if (mode === "break") {
46
+ return dt.breakMode;
47
+ }
48
+ if (mode === "longBreak") {
49
+ return dt.longBreakMode;
50
+ }
51
+ return dt.appShellLiveDrawerKronoFocusPhaseWork;
52
+ }
53
+
54
+ /** Même format que le bandeau KronoFocus du tableau de bord (temps restant). */
55
+ function kfCountdownHMS(totalSec: number): string {
56
+ const s = Math.max(0, Math.floor(totalSec));
57
+ const h = Math.floor(s / 3600);
58
+ const m = Math.floor((s % 3600) / 60);
59
+ const sec = s % 60;
60
+ return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}`;
61
+ }
62
+
63
+ function truncateDrawerLabel(s: string, max: number): string {
64
+ const t = s.trim();
65
+ if (t.length <= max) {
66
+ return t;
67
+ }
68
+ return `${t.slice(0, max - 1)}…`;
69
+ }
70
+
71
+ function runningTasksFromLive(live: LiveShape | undefined): LiveTask[] {
72
+ if (!live) {
73
+ return [];
74
+ }
75
+ const raw =
76
+ Array.isArray(live.activeTasks) && live.activeTasks.length > 0
77
+ ? live.activeTasks
78
+ : live.activeTask
79
+ ? [live.activeTask]
80
+ : [];
81
+ return raw.filter((t) => t && !t.isDone && !t.manualTaskTimerPaused);
82
+ }
83
+
84
+ function subtaskTitleFor(task: LiveTask, subId: string): string | undefined {
85
+ const list = task.subtasks;
86
+ if (!Array.isArray(list)) {
87
+ return undefined;
88
+ }
89
+ const row = list.find((s) => String(s.id) === subId);
90
+ const t = row?.title;
91
+ return typeof t === "string" && t.trim() !== "" ? t.trim() : undefined;
92
+ }
93
+
94
+ function DrawerSubtaskRow({
95
+ sub,
96
+ isTracking,
97
+ }: Readonly<{
98
+ sub: { id?: string; title?: string; done?: boolean; durationMs?: number };
99
+ isTracking: boolean;
100
+ }>) {
101
+ const label = typeof sub.title === "string" ? sub.title.trim() : "";
102
+ const baseMs = Math.max(0, Math.floor(Number(sub.durationMs) || 0));
103
+ const displayMs = useSmoothStopwatchDisplayMs(baseMs, isTracking);
104
+ const timeStr = formatStopwatchMs(displayMs);
105
+ const done = sub.done === true;
106
+
107
+ return (
108
+ <li className="flex min-w-0 items-baseline justify-between gap-1.5 py-0.5">
109
+ <span className="flex min-w-0 items-center gap-1">
110
+ {done ? (
111
+ <Check className="size-3 shrink-0 text-emerald-600 dark:text-emerald-400" strokeWidth={2.5} aria-hidden />
112
+ ) : (
113
+ <Circle
114
+ className={`size-2.5 shrink-0 ${isTracking ? "text-emerald-600 dark:text-emerald-400" : "text-zinc-400 dark:text-zinc-500"}`}
115
+ strokeWidth={2}
116
+ aria-hidden
117
+ />
118
+ )}
119
+ <span
120
+ className={`min-w-0 truncate ${done ? "text-zinc-500 line-through dark:text-zinc-500" : "text-zinc-700 dark:text-zinc-300"}`}
121
+ >
122
+ {label || "—"}
123
+ </span>
124
+ </span>
125
+ <span
126
+ className={`shrink-0 font-mono tabular-nums tracking-tight ${isTracking ? "font-semibold text-emerald-700 dark:text-emerald-400" : "text-zinc-500 dark:text-zinc-400"}`}
127
+ >
128
+ {timeStr}
129
+ </span>
130
+ </li>
131
+ );
132
+ }
133
+
134
+ function DrawerRunningTaskRow({
135
+ task,
136
+ subtaskTrackingLabel,
137
+ dashboardT,
138
+ }: Readonly<{
139
+ task: LiveTask;
140
+ /** Libellé du suivi actif sur une sous-tâche (ligne de secours). */
141
+ subtaskTrackingLabel: string;
142
+ dashboardT: DashboardStrings;
143
+ }>) {
144
+ const title = taskTitleForDisplay(typeof task.name === "string" ? task.name : "");
145
+ const subId = String(task.activeSubtaskTimerId ?? "").trim();
146
+ const subTitle = subId ? subtaskTitleFor(task, subId) : undefined;
147
+ const baseMs = Math.max(0, Math.floor(Number(task.durationMs) || 0));
148
+ /** Pendant un suivi sous-tâche, le parent n’a pas de segment principal : afficher la valeur persistée. */
149
+ const smoothMain = subId === "";
150
+ const displayMs = useSmoothStopwatchDisplayMs(baseMs, smoothMain);
151
+ const timeStr = formatStopwatchMs(displayMs);
152
+ const subs = Array.isArray(task.subtasks) ? task.subtasks : [];
153
+ const hasSubList = subs.length > 0;
154
+
155
+ return (
156
+ <li className="min-w-0 text-xs leading-snug">
157
+ <div className="flex min-w-0 items-baseline justify-between gap-2">
158
+ <span className="min-w-0 truncate font-medium text-zinc-800 dark:text-zinc-200">{title}</span>
159
+ <span
160
+ className={`shrink-0 font-mono text-xs tabular-nums tracking-tight ${smoothMain ? "font-semibold text-emerald-700 dark:text-emerald-400" : "text-zinc-600 dark:text-zinc-400"}`}
161
+ aria-live={smoothMain ? "polite" : "off"}
162
+ >
163
+ {timeStr}
164
+ </span>
165
+ </div>
166
+ {hasSubList ? (
167
+ <ul
168
+ className="mt-1.5 space-y-0.5 border-l border-zinc-200 pl-2 dark:border-zinc-600"
169
+ aria-label={dashboardT.subtasksHeading}
170
+ >
171
+ {subs.map((st) => {
172
+ const sid = String(st.id ?? "").trim();
173
+ return (
174
+ <DrawerSubtaskRow key={sid || String(st.title)} sub={st} isTracking={subId !== "" && subId === sid} />
175
+ );
176
+ })}
177
+ </ul>
178
+ ) : subTitle ? (
179
+ <span className="mt-1 block truncate border-l border-zinc-200 pl-2 text-zinc-500 dark:border-zinc-600 dark:text-zinc-400">
180
+ {subtaskTrackingLabel}: {subTitle}
181
+ </span>
182
+ ) : null}
183
+ </li>
184
+ );
185
+ }
186
+
187
+ export function AppShellLiveSessionDrawer() {
188
+ const pathname = usePathname();
189
+ const { payload } = useKronosysPayload();
190
+ const [collapsed, setCollapsed] = useState(false);
191
+
192
+ const onDashboardHome = pathname === "/";
193
+ const live = payload?.current as LiveShape | undefined;
194
+ const lang: Lang = live?.language === "fr" ? "fr" : "en";
195
+ const dt = dashboardStrings(lang);
196
+
197
+ const liveSid = typeof live?.sessionId === "string" ? live.sessionId.trim() : "";
198
+ const hasLiveSession = liveSid !== "" && live?.archived !== true;
199
+ const show = !onDashboardHome && Boolean(payload) && hasLiveSession;
200
+
201
+ const sessionWallMinutes = live?.sessionDurationMinutes ?? 0;
202
+ const wallClockMsBase = useMemo(
203
+ () => Math.max(0, Math.floor(sessionWallMinutes * 60_000)),
204
+ [sessionWallMinutes]
205
+ );
206
+ const sessionEnded = typeof live?.endAt === "string" && live.endAt.trim() !== "";
207
+ const smoothSessionWall =
208
+ liveSid !== "" &&
209
+ live?.archived !== true &&
210
+ !sessionEnded &&
211
+ live?.isPaused !== true;
212
+ const sessionWallDisplayMs = useSmoothStopwatchDisplayMs(wallClockMsBase, smoothSessionWall);
213
+
214
+ const runningTasks = useMemo(() => runningTasksFromLive(live), [live]);
215
+ const kf = live?.kronoFocus;
216
+ const kfSecs = useKronoFocusLiveSeconds(
217
+ kf?.timeLeftSeconds ?? 0,
218
+ kf?.status ?? "idle",
219
+ kf?.kronoFocusDeadlineAtMs
220
+ );
221
+ const kfActive = kf?.status === "running" || kf?.status === "paused";
222
+ const kfLinkedName =
223
+ typeof kf?.linkedTaskName === "string" && kf.linkedTaskName.trim() !== "" ? kf.linkedTaskName.trim() : "";
224
+ const hasActivity =
225
+ runningTasks.length > 0 || live?.isPaused === true || kfActive;
226
+
227
+ /** Réserve la largeur du tiroir sur le flux du `body` pour ne pas masquer le contenu (guide, etc.). */
228
+ useLayoutEffect(() => {
229
+ if (typeof document === "undefined") {
230
+ return;
231
+ }
232
+ const body = document.body;
233
+ if (!show) {
234
+ body.style.paddingRight = "";
235
+ return () => {
236
+ body.style.paddingRight = "";
237
+ };
238
+ }
239
+ body.style.paddingRight = collapsed ? "2.75rem" : "min(18rem, 100vw)";
240
+ return () => {
241
+ body.style.paddingRight = "";
242
+ };
243
+ }, [show, collapsed]);
244
+
245
+ if (!show) {
246
+ return null;
247
+ }
248
+
249
+ const dashHref = withDashboardSessionParam("/", liveSid);
250
+ const sessionLabel =
251
+ typeof live?.sessionName === "string" && live.sessionName.trim() !== ""
252
+ ? live.sessionName.trim()
253
+ : liveSid.slice(0, 8);
254
+
255
+ const wallLabel = formatWallDurationMs(sessionWallDisplayMs);
256
+
257
+ /** Largeur ancrée au bord droit du viewport (pas de marge fantôme) ; sur très petit écran on ne dépasse pas l’écran. */
258
+ const panelWidth = collapsed ? "w-11" : "w-72 max-w-[min(18rem,100vw)]";
259
+
260
+ return (
261
+ <aside
262
+ data-kronosys-live-drawer=""
263
+ className={`fixed right-0 bottom-0 z-40 flex min-h-0 flex-col overflow-hidden rounded-l-xl border-l border-zinc-200 bg-white/95 shadow-[-6px_0_20px_-4px_rgba(0,0,0,0.08)] backdrop-blur-sm transition-[width] duration-200 dark:border-zinc-700 dark:bg-zinc-900/95 dark:shadow-[-6px_0_24px_-4px_rgba(0,0,0,0.35)] ${panelWidth} top-36 sm:top-32`}
264
+ aria-label={dt.appShellLiveDrawerAria}
265
+ >
266
+ <div className="flex shrink-0 items-center gap-1 border-b border-zinc-200 px-1 py-1.5 dark:border-zinc-700">
267
+ <button
268
+ type="button"
269
+ className="inline-flex size-8 shrink-0 items-center justify-center rounded-md border border-zinc-200 text-zinc-600 hover:bg-zinc-100 dark:border-zinc-600 dark:text-zinc-300 dark:hover:bg-zinc-800/80"
270
+ onClick={() => setCollapsed((c) => !c)}
271
+ title={collapsed ? dt.appShellLiveDrawerExpand : dt.appShellLiveDrawerCollapse}
272
+ aria-expanded={collapsed === false}
273
+ aria-label={collapsed ? dt.appShellLiveDrawerExpand : dt.appShellLiveDrawerCollapse}
274
+ >
275
+ {collapsed ? (
276
+ <ChevronLeft size={18} strokeWidth={2} className="shrink-0" aria-hidden />
277
+ ) : (
278
+ <ChevronRight size={18} strokeWidth={2} className="shrink-0" aria-hidden />
279
+ )}
280
+ </button>
281
+ {!collapsed ? (
282
+ <span className="min-w-0 flex-1 truncate text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
283
+ {dt.appShellLiveDrawerTitle}
284
+ </span>
285
+ ) : null}
286
+ {hasActivity ? (
287
+ <span
288
+ className="mr-1 inline-flex size-2 shrink-0 rounded-full bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.7)]"
289
+ aria-hidden
290
+ />
291
+ ) : null}
292
+ </div>
293
+
294
+ {collapsed && kfActive ? (
295
+ <div
296
+ className="flex min-h-0 flex-1 flex-col items-center gap-1 border-t border-zinc-200 px-0.5 py-2 dark:border-zinc-700"
297
+ aria-label={dt.kronoFocusTitle}
298
+ >
299
+ <Timer className="shrink-0 text-violet-600 dark:text-violet-400" size={16} strokeWidth={2} aria-hidden />
300
+ <span
301
+ className="w-full max-w-[2.75rem] break-all text-center font-mono text-[0.58rem] font-semibold leading-tight tracking-tight text-violet-800 tabular-nums dark:text-violet-200"
302
+ aria-live="polite"
303
+ >
304
+ {kfCountdownHMS(kfSecs)}
305
+ </span>
306
+ </div>
307
+ ) : null}
308
+
309
+ {!collapsed ? (
310
+ <div className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto px-3 py-3 text-sm text-zinc-800 dark:text-zinc-100">
311
+ {kfActive ? (
312
+ <section
313
+ className="rounded-lg border border-violet-400/55 bg-violet-50/90 p-3 shadow-sm dark:border-violet-600/45 dark:bg-violet-950/35"
314
+ aria-label={dt.kronoFocusTitle}
315
+ >
316
+ <p className="text-[0.65rem] font-semibold uppercase tracking-wide text-violet-800 dark:text-violet-300">
317
+ {dt.kronoFocusTitle}
318
+ </p>
319
+ <p className="mt-0.5 text-xs font-medium text-zinc-800 dark:text-zinc-200">
320
+ {kfPhaseLabel(dt, kf?.mode)}
321
+ </p>
322
+ <p className="mt-1 text-xs text-zinc-600 dark:text-zinc-400">
323
+ {kf?.status === "running"
324
+ ? dt.appShellLiveDrawerKronoFocusRunning
325
+ : dt.appShellLiveDrawerKronoFocusPaused}
326
+ </p>
327
+ <p
328
+ className="mt-1.5 font-mono text-lg font-semibold tracking-tight text-violet-950 tabular-nums dark:text-violet-100"
329
+ aria-live="polite"
330
+ >
331
+ {kfCountdownHMS(kfSecs)}
332
+ </p>
333
+ {kfLinkedName ? (
334
+ <p className="mt-1.5 min-w-0 text-[0.65rem] leading-snug text-zinc-600 dark:text-zinc-400">
335
+ <span className="font-medium text-zinc-500 dark:text-zinc-500">{dt.kronoFocusLinkedTaskIntro}</span>{" "}
336
+ <span className="break-words">{truncateDrawerLabel(kfLinkedName, 120)}</span>
337
+ </p>
338
+ ) : null}
339
+ </section>
340
+ ) : null}
341
+
342
+ <div className="min-w-0">
343
+ <p className="truncate font-medium text-zinc-900 dark:text-zinc-50">{sessionLabel}</p>
344
+ <p className="mt-1 text-xs text-zinc-600 dark:text-zinc-400">
345
+ <span className="font-medium text-zinc-500 dark:text-zinc-500">{dt.appShellLiveDrawerWallClock}</span>
346
+ {" · "}
347
+ <span
348
+ className={`tabular-nums ${smoothSessionWall ? "font-mono font-semibold text-emerald-700 dark:text-emerald-400" : "text-zinc-600 dark:text-zinc-400"}`}
349
+ aria-live={smoothSessionWall ? "polite" : "off"}
350
+ >
351
+ {wallLabel}
352
+ </span>
353
+ </p>
354
+ {live?.isPaused === true ? (
355
+ <p className="mt-1 text-xs font-medium text-amber-700 dark:text-amber-300">{dt.appShellLiveDrawerSessionPaused}</p>
356
+ ) : null}
357
+ </div>
358
+
359
+ <div className="min-w-0">
360
+ <p className="text-[0.65rem] font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-500">
361
+ {dt.appShellLiveDrawerTasksHeading}
362
+ </p>
363
+ {runningTasks.length === 0 ? (
364
+ <p className="mt-1 text-xs text-zinc-500 dark:text-zinc-500">{dt.appShellLiveDrawerNoRunningTasks}</p>
365
+ ) : (
366
+ <ul className="mt-1.5 space-y-2">
367
+ {runningTasks.map((t) => {
368
+ const id = typeof t.id === "string" ? t.id : "";
369
+ const title = taskTitleForDisplay(typeof t.name === "string" ? t.name : "");
370
+ return (
371
+ <DrawerRunningTaskRow
372
+ key={id || title}
373
+ task={t}
374
+ subtaskTrackingLabel={dt.appShellLiveDrawerSubtaskTracking}
375
+ dashboardT={dt}
376
+ />
377
+ );
378
+ })}
379
+ </ul>
380
+ )}
381
+ </div>
382
+
383
+ <Link
384
+ href={dashHref}
385
+ className="mt-auto inline-flex items-center justify-center gap-2 rounded-lg border border-violet-400/60 bg-violet-50 px-3 py-2 text-xs font-medium text-violet-950 hover:bg-violet-100/90 dark:border-violet-600/50 dark:bg-violet-950/40 dark:text-violet-100 dark:hover:bg-violet-900/50"
386
+ >
387
+ <LayoutDashboard size={16} strokeWidth={2} className="shrink-0" aria-hidden />
388
+ {dt.appShellLiveDrawerOpenDashboard}
389
+ </Link>
390
+ </div>
391
+ ) : null}
392
+ </aside>
393
+ );
394
+ }
@@ -0,0 +1,131 @@
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import { BarChart3, BookOpen, FileText, LayoutDashboard, Settings } from "lucide-react";
5
+ import { withDashboardSessionParam } from "@/lib/dashboardSessionNav";
6
+
7
+ const iconLinkClass =
8
+ "inline-flex size-10 items-center justify-center rounded-lg border border-zinc-300 bg-white text-zinc-700 transition hover:border-zinc-400 hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-800/80 dark:text-zinc-200 dark:hover:border-zinc-500 dark:hover:bg-zinc-800";
9
+
10
+ const iconActiveClass =
11
+ "inline-flex size-10 items-center justify-center rounded-lg border border-violet-400/70 bg-violet-100/90 text-violet-950 dark:border-violet-600/60 dark:bg-violet-950/40 dark:text-violet-100";
12
+
13
+ export type AppShellRouteNavLabels = {
14
+ dashboard: string;
15
+ reporting: string;
16
+ settings: string;
17
+ /** Guide d’utilisation in-app. */
18
+ guide: string;
19
+ /** Libellé infobulle / `aria-label` pour l’icône « licences » (page licences uniquement). */
20
+ licenses?: string;
21
+ };
22
+
23
+ export type AppShellRouteNavCurrent = "dashboard" | "reporting" | "settings" | "licenses" | "guide";
24
+
25
+ type Props = Readonly<{
26
+ current: AppShellRouteNavCurrent;
27
+ labels: AppShellRouteNavLabels;
28
+ /** `aria-label` du `<nav>` (recommandé : chaîne localisée côté appelant). */
29
+ navAriaLabel: string;
30
+ className?: string;
31
+ /** Identifiant de session du tableau de bord (`?session=`) à conserver sur les liens internes. */
32
+ dashboardSessionId?: string | null;
33
+ }>;
34
+
35
+ export function AppShellRouteNav({
36
+ current,
37
+ labels,
38
+ navAriaLabel,
39
+ className,
40
+ dashboardSessionId,
41
+ }: Props) {
42
+ const wrapClass = className ?? "flex flex-wrap items-center gap-1.5";
43
+ const dash = (path: string) => withDashboardSessionParam(path, dashboardSessionId);
44
+
45
+ if (current === "dashboard") {
46
+ return (
47
+ <nav className={wrapClass} aria-label={navAriaLabel}>
48
+ <Link
49
+ href={dash("/guide")}
50
+ className={iconLinkClass}
51
+ title={labels.guide}
52
+ aria-label={labels.guide}
53
+ >
54
+ <BookOpen size={20} strokeWidth={2} className="shrink-0" aria-hidden />
55
+ </Link>
56
+ <Link
57
+ href={dash("/reporting")}
58
+ className={iconLinkClass}
59
+ title={labels.reporting}
60
+ aria-label={labels.reporting}
61
+ >
62
+ <BarChart3 size={20} strokeWidth={2} className="shrink-0" aria-hidden />
63
+ </Link>
64
+ <Link
65
+ href={dash("/settings")}
66
+ className={iconLinkClass}
67
+ title={labels.settings}
68
+ aria-label={labels.settings}
69
+ >
70
+ <Settings size={20} strokeWidth={2} className="shrink-0" aria-hidden />
71
+ </Link>
72
+ </nav>
73
+ );
74
+ }
75
+
76
+ const licensesTitle = labels.licenses ?? "Licenses";
77
+
78
+ return (
79
+ <nav className={wrapClass} aria-label={navAriaLabel}>
80
+ <Link href={dash("/")} className={iconLinkClass} title={labels.dashboard} aria-label={labels.dashboard}>
81
+ <LayoutDashboard size={20} strokeWidth={2} className="shrink-0" aria-hidden />
82
+ </Link>
83
+
84
+ {current === "reporting" ? (
85
+ <span className={iconActiveClass} title={labels.reporting} aria-label={labels.reporting} aria-current="page">
86
+ <BarChart3 size={20} strokeWidth={2} className="shrink-0" aria-hidden />
87
+ </span>
88
+ ) : (
89
+ <Link
90
+ href={dash("/reporting")}
91
+ className={iconLinkClass}
92
+ title={labels.reporting}
93
+ aria-label={labels.reporting}
94
+ >
95
+ <BarChart3 size={20} strokeWidth={2} className="shrink-0" aria-hidden />
96
+ </Link>
97
+ )}
98
+
99
+ {current === "settings" ? (
100
+ <span className={iconActiveClass} title={labels.settings} aria-label={labels.settings} aria-current="page">
101
+ <Settings size={20} strokeWidth={2} className="shrink-0" aria-hidden />
102
+ </span>
103
+ ) : (
104
+ <Link
105
+ href={dash("/settings")}
106
+ className={iconLinkClass}
107
+ title={labels.settings}
108
+ aria-label={labels.settings}
109
+ >
110
+ <Settings size={20} strokeWidth={2} className="shrink-0" aria-hidden />
111
+ </Link>
112
+ )}
113
+
114
+ {current === "guide" ? (
115
+ <span className={iconActiveClass} title={labels.guide} aria-label={labels.guide} aria-current="page">
116
+ <BookOpen size={20} strokeWidth={2} className="shrink-0" aria-hidden />
117
+ </span>
118
+ ) : (
119
+ <Link href={dash("/guide")} className={iconLinkClass} title={labels.guide} aria-label={labels.guide}>
120
+ <BookOpen size={20} strokeWidth={2} className="shrink-0" aria-hidden />
121
+ </Link>
122
+ )}
123
+
124
+ {current === "licenses" ? (
125
+ <span className={iconActiveClass} title={licensesTitle} aria-label={licensesTitle} aria-current="page">
126
+ <FileText size={20} strokeWidth={2} className="shrink-0" aria-hidden />
127
+ </span>
128
+ ) : null}
129
+ </nav>
130
+ );
131
+ }
@@ -0,0 +1,16 @@
1
+ "use client";
2
+
3
+ import { useKronosysPackageVersion } from "@/components/KronosysPackageVersionProvider";
4
+
5
+ /** `ariaLabelTemplate` doit contenir `{version}` (remplacé par la valeur lue côté serveur dans le layout). */
6
+ export function AppVersionStamp({ ariaLabelTemplate }: { ariaLabelTemplate: string }) {
7
+ const version = useKronosysPackageVersion();
8
+ return (
9
+ <span
10
+ className="tabular-nums text-zinc-500 dark:text-zinc-500"
11
+ aria-label={ariaLabelTemplate.replace("{version}", version)}
12
+ >
13
+ v{version}
14
+ </span>
15
+ );
16
+ }
@@ -0,0 +1,57 @@
1
+ "use client";
2
+
3
+ import { ChevronRight } from "lucide-react";
4
+ import { useId, useState, type ReactNode } from "react";
5
+
6
+ /**
7
+ * Carte tableau de bord repliable (repliée par défaut si `defaultExpanded` omis).
8
+ */
9
+ export function DashboardCollapsibleSection({
10
+ title,
11
+ headerTrailing,
12
+ children,
13
+ defaultExpanded = false,
14
+ sectionAriaLabel,
15
+ }: {
16
+ title: ReactNode;
17
+ headerTrailing?: ReactNode;
18
+ children: ReactNode;
19
+ defaultExpanded?: boolean;
20
+ /** Libellé de la région pour les lecteurs d’écran (ex. titre de carte déjà présent ailleurs). */
21
+ sectionAriaLabel?: string;
22
+ }) {
23
+ const [open, setOpen] = useState(defaultExpanded);
24
+ const contentId = useId();
25
+ const headingId = useId();
26
+
27
+ return (
28
+ <section
29
+ className="rounded-xl border border-zinc-200 bg-zinc-50/90 p-4 sm:p-5 dark:border-zinc-800 dark:bg-zinc-900/50"
30
+ aria-label={sectionAriaLabel}
31
+ >
32
+ <div className="flex items-center gap-2">
33
+ <button
34
+ type="button"
35
+ id={headingId}
36
+ aria-expanded={open ? "true" : "false"}
37
+ aria-controls={contentId}
38
+ onClick={() => setOpen((v) => !v)}
39
+ className="flex min-w-0 flex-1 items-center gap-2 rounded-md py-0.5 text-left transition-colors hover:bg-zinc-200/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-500/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:hover:bg-zinc-800/40 dark:focus-visible:ring-offset-zinc-900"
40
+ >
41
+ <ChevronRight
42
+ className={`size-4 shrink-0 text-zinc-500 transition-transform duration-200 ${open ? "rotate-90" : ""}`}
43
+ strokeWidth={2}
44
+ aria-hidden
45
+ />
46
+ <span className="min-w-0">{title}</span>
47
+ </button>
48
+ {headerTrailing ? <div className="flex shrink-0 items-center gap-1">{headerTrailing}</div> : null}
49
+ </div>
50
+ {open ? (
51
+ <div id={contentId} className="mt-3" aria-labelledby={headingId}>
52
+ {children}
53
+ </div>
54
+ ) : null}
55
+ </section>
56
+ );
57
+ }