@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,435 @@
1
+ "use client";
2
+
3
+ import {
4
+ useCallback,
5
+ useEffect,
6
+ useLayoutEffect,
7
+ useMemo,
8
+ useRef,
9
+ useState,
10
+ type CSSProperties,
11
+ } from "react";
12
+ import { createPortal } from "react-dom";
13
+ import { markDashboardTourCompleted } from "@/lib/dashboardTourStorage";
14
+ import type { DashboardStrings } from "@/lib/dashboardCopy";
15
+ import { tbVioletTextSm } from "@/lib/translucentButtonClasses";
16
+
17
+ /** Ancres CSS, dans l’ordre des étapes (parcours « spotlight » / coach marks). */
18
+ const BASE_STEP_SELECTORS = [
19
+ "#dashboard-tour-anchor-intro",
20
+ "#dashboard-tour-anchor-column-hints",
21
+ "#dashboard-col-sessions",
22
+ "#dashboard-col-tasks",
23
+ "#dashboard-col-tags",
24
+ "#dashboard-tour-anchor-app-toolbar",
25
+ "#dashboard-tour-anchor-user-storage",
26
+ ] as const;
27
+
28
+ /** Même id que le conteneur du minuteur KronoFocus dans l’en-tête (si affiché). */
29
+ const KRONO_FOCUS_HEADER_TOUR_SELECTOR = "#dashboard-tour-anchor-kronoFocus-header";
30
+
31
+ /** Même id que le conteneur de la bannière identité Git sur la page tableau de bord. */
32
+ const GIT_IDENTITY_BANNER_TOUR_SELECTOR = "#dashboard-tour-anchor-git-identity-banner";
33
+
34
+ const HOLE_PADDING_PX = 10;
35
+ const TOOLTIP_MAX_W = 360;
36
+ const VIEW_MARGIN = 12;
37
+ const TOOLTIP_GAP = 12;
38
+
39
+ type HoleRect = { top: number; left: number; width: number; height: number };
40
+
41
+ type TipPlacement = "below" | "above" | "right" | "left";
42
+
43
+ function tooltipIntersectsHole(
44
+ tip: { top: number; left: number; width: number; height: number },
45
+ hole: HoleRect
46
+ ): boolean {
47
+ return !(
48
+ tip.left + tip.width <= hole.left ||
49
+ hole.left + hole.width <= tip.left ||
50
+ tip.top + tip.height <= hole.top ||
51
+ hole.top + hole.height <= tip.top
52
+ );
53
+ }
54
+
55
+ function clampTipPosition(
56
+ top: number,
57
+ left: number,
58
+ ph: number,
59
+ wTip: number,
60
+ vw: number,
61
+ vh: number
62
+ ): { top: number; left: number } {
63
+ return {
64
+ top: Math.max(VIEW_MARGIN, Math.min(top, vh - ph - VIEW_MARGIN)),
65
+ left: Math.max(VIEW_MARGIN, Math.min(left, vw - wTip - VIEW_MARGIN)),
66
+ };
67
+ }
68
+
69
+ /** Calcule une position du panneau qui évite de recouvrir le trou (ex. colonne Sessions étroite). */
70
+ function computeTooltipPosition(
71
+ hole: HoleRect,
72
+ vw: number,
73
+ vh: number,
74
+ wTip: number,
75
+ ph: number,
76
+ placementPriority: readonly TipPlacement[]
77
+ ): { top: number; left: number } {
78
+ const g = TOOLTIP_GAP;
79
+ const cx = hole.left + hole.width / 2 - wTip / 2;
80
+ const cy = hole.top + hole.height / 2 - ph / 2;
81
+
82
+ const variants: Record<TipPlacement, { top: number; left: number }> = {
83
+ below: { top: hole.top + hole.height + g, left: cx },
84
+ above: { top: hole.top - g - ph, left: cx },
85
+ right: { top: cy, left: hole.left + hole.width + g },
86
+ left: { top: cy, left: hole.left - g - wTip },
87
+ };
88
+
89
+ for (const key of placementPriority) {
90
+ const raw = variants[key];
91
+ const p = clampTipPosition(raw.top, raw.left, ph, wTip, vw, vh);
92
+ if (!tooltipIntersectsHole({ ...p, width: wTip, height: ph }, hole)) {
93
+ return p;
94
+ }
95
+ }
96
+
97
+ const fallback = clampTipPosition(variants.below.top, variants.below.left, ph, wTip, vw, vh);
98
+ return fallback;
99
+ }
100
+
101
+ function useEscapeDismiss(open: boolean, onDismiss: () => void) {
102
+ useEffect(() => {
103
+ if (!open) {
104
+ return;
105
+ }
106
+ const onKey = (e: KeyboardEvent) => {
107
+ if (e.key === "Escape") {
108
+ e.preventDefault();
109
+ onDismiss();
110
+ }
111
+ };
112
+ document.addEventListener("keydown", onKey);
113
+ return () => document.removeEventListener("keydown", onKey);
114
+ }, [open, onDismiss]);
115
+ }
116
+
117
+ function expandRect(r: DOMRect, pad: number): HoleRect {
118
+ return {
119
+ top: r.top - pad,
120
+ left: r.left - pad,
121
+ width: r.width + 2 * pad,
122
+ height: r.height + 2 * pad,
123
+ };
124
+ }
125
+
126
+ export function DashboardTour({
127
+ open,
128
+ onOpenChange,
129
+ dt,
130
+ kronoFocusTourStep = false,
131
+ gitIdentityBannerTourStep = false,
132
+ }: {
133
+ open: boolean;
134
+ onOpenChange: (open: boolean) => void;
135
+ dt: DashboardStrings;
136
+ /** Étape KronoFocus : ancre présente seulement si le minuteur est affiché dans l’en-tête. */
137
+ kronoFocusTourStep?: boolean;
138
+ /** Dernière étape : bannière « configurer l’identité Git » (ancre présente seulement si la bannière est affichée). */
139
+ gitIdentityBannerTourStep?: boolean;
140
+ }) {
141
+ const [step, setStep] = useState(0);
142
+ const [hole, setHole] = useState<HoleRect | null>(null);
143
+ const [tipStyle, setTipStyle] = useState<CSSProperties>({});
144
+ const panelRef = useRef<HTMLDivElement>(null);
145
+ const primaryBtnRef = useRef<HTMLButtonElement>(null);
146
+
147
+ const selectors = useMemo((): string[] => {
148
+ const out: string[] = [...BASE_STEP_SELECTORS];
149
+ if (kronoFocusTourStep) {
150
+ out.push(KRONO_FOCUS_HEADER_TOUR_SELECTOR);
151
+ }
152
+ if (gitIdentityBannerTourStep) {
153
+ out.push(GIT_IDENTITY_BANNER_TOUR_SELECTOR);
154
+ }
155
+ return out;
156
+ }, [kronoFocusTourStep, gitIdentityBannerTourStep]);
157
+
158
+ const steps = useMemo(() => {
159
+ const base: { title: string; body: string }[] = [
160
+ { title: dt.tourStep1Title, body: dt.tourStep1Body },
161
+ { title: dt.tourStep2Title, body: dt.tourStep2Body },
162
+ { title: dt.tourStep3Title, body: dt.tourStep3Body },
163
+ { title: dt.tourStep4Title, body: dt.tourStep4Body },
164
+ { title: dt.tourStep5Title, body: dt.tourStep5Body },
165
+ { title: dt.tourStep6Title, body: dt.tourStep6Body },
166
+ { title: dt.tourStep7Title, body: dt.tourStep7Body },
167
+ ];
168
+ if (kronoFocusTourStep) {
169
+ base.push({ title: dt.tourStep8Title, body: dt.tourStep8Body });
170
+ }
171
+ if (gitIdentityBannerTourStep) {
172
+ base.push({ title: dt.tourStep9Title, body: dt.tourStep9Body });
173
+ }
174
+ return base;
175
+ }, [dt, kronoFocusTourStep, gitIdentityBannerTourStep]);
176
+
177
+ const total = steps.length;
178
+ const last = step >= total - 1;
179
+ const current = steps[step] ?? steps[0];
180
+ const selector = selectors[Math.min(step, selectors.length - 1)];
181
+
182
+ useEffect(() => {
183
+ if (open) {
184
+ setStep(0);
185
+ }
186
+ }, [open]);
187
+
188
+ const finish = useCallback(() => {
189
+ markDashboardTourCompleted();
190
+ onOpenChange(false);
191
+ }, [onOpenChange]);
192
+
193
+ useEscapeDismiss(open, finish);
194
+
195
+ const updateHoleFromDom = useCallback(() => {
196
+ if (!open) {
197
+ setHole(null);
198
+ return;
199
+ }
200
+ const el = document.querySelector(selector);
201
+ if (!el || !(el instanceof HTMLElement)) {
202
+ setHole(null);
203
+ return;
204
+ }
205
+ setHole(expandRect(el.getBoundingClientRect(), HOLE_PADDING_PX));
206
+ }, [open, selector]);
207
+
208
+ useLayoutEffect(() => {
209
+ if (!open) {
210
+ setHole(null);
211
+ return;
212
+ }
213
+ const el = document.querySelector(selector);
214
+ if (el instanceof HTMLElement) {
215
+ el.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "auto" });
216
+ }
217
+ updateHoleFromDom();
218
+ const raf = requestAnimationFrame(updateHoleFromDom);
219
+ window.addEventListener("scroll", updateHoleFromDom, true);
220
+ window.addEventListener("resize", updateHoleFromDom);
221
+ const observed = el instanceof HTMLElement ? el : null;
222
+ const ro =
223
+ observed && typeof ResizeObserver !== "undefined"
224
+ ? new ResizeObserver(() => updateHoleFromDom())
225
+ : null;
226
+ if (observed && ro) {
227
+ ro.observe(observed);
228
+ }
229
+ return () => {
230
+ cancelAnimationFrame(raf);
231
+ window.removeEventListener("scroll", updateHoleFromDom, true);
232
+ window.removeEventListener("resize", updateHoleFromDom);
233
+ ro?.disconnect();
234
+ };
235
+ }, [open, selector, step, updateHoleFromDom]);
236
+
237
+ useLayoutEffect(() => {
238
+ if (!open) {
239
+ return;
240
+ }
241
+ const vw = typeof globalThis.window !== "undefined" ? globalThis.window.innerWidth : 1024;
242
+ const vh = typeof globalThis.window !== "undefined" ? globalThis.window.innerHeight : 768;
243
+ const w = Math.min(TOOLTIP_MAX_W, vw - 2 * VIEW_MARGIN);
244
+
245
+ if (!hole) {
246
+ setTipStyle({
247
+ position: "fixed",
248
+ top: "50%",
249
+ left: "50%",
250
+ transform: "translate(-50%, -50%)",
251
+ width: w,
252
+ maxWidth: "calc(100vw - 2rem)",
253
+ zIndex: 212,
254
+ });
255
+ return;
256
+ }
257
+
258
+ const place = (): void => {
259
+ const panel = panelRef.current;
260
+ const ph = panel?.getBoundingClientRect().height ?? 220;
261
+ /** Étape « Sessions » : colonne étroite à gauche — placer le panneau à droite en priorité pour ne pas masquer le trou. */
262
+ const placementPriority: readonly TipPlacement[] =
263
+ step === 2 ? ["right", "below", "above", "left"] : ["below", "above", "right", "left"];
264
+ const { top, left } = computeTooltipPosition(hole, vw, vh, w, ph, placementPriority);
265
+ setTipStyle({
266
+ position: "fixed",
267
+ top,
268
+ left,
269
+ transform: undefined,
270
+ width: w,
271
+ maxWidth: undefined,
272
+ zIndex: 212,
273
+ });
274
+ };
275
+
276
+ place();
277
+ const raf = globalThis.requestAnimationFrame(place);
278
+ return () => globalThis.cancelAnimationFrame(raf);
279
+ }, [open, hole, step, current.title, current.body]);
280
+
281
+ useEffect(() => {
282
+ if (!open) {
283
+ return;
284
+ }
285
+ const t = window.setTimeout(() => primaryBtnRef.current?.focus(), 80);
286
+ return () => window.clearTimeout(t);
287
+ }, [open, step]);
288
+
289
+ if (!open) {
290
+ return null;
291
+ }
292
+
293
+ const progressLabel = dt.tourProgressLabel.replace("{n}", String(step + 1)).replace("{total}", String(total));
294
+
295
+ const secondaryBtn =
296
+ "rounded-lg border border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-800 transition hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-200 dark:hover:bg-zinc-700";
297
+
298
+ const vw = typeof window !== "undefined" ? window.innerWidth : 0;
299
+ const vh = typeof window !== "undefined" ? window.innerHeight : 0;
300
+
301
+ const fullBackdrop = !hole ? (
302
+ <div className="fixed inset-0 z-[210] bg-black/55" aria-hidden />
303
+ ) : null;
304
+
305
+ const dimPanels =
306
+ hole && vw > 0 && vh > 0
307
+ ? (() => {
308
+ const { top: t, left: l, width: w, height: h } = hole;
309
+ const topH = Math.max(0, t);
310
+ const bottomTop = t + h;
311
+ const bottomH = Math.max(0, vh - bottomTop);
312
+ const leftW = Math.max(0, l);
313
+ const rightLeft = l + w;
314
+ const rightW = Math.max(0, vw - rightLeft);
315
+ return (
316
+ <>
317
+ <div
318
+ className="fixed bg-black/55"
319
+ style={{ top: 0, left: 0, width: vw, height: topH, zIndex: 210 }}
320
+ aria-hidden
321
+ />
322
+ <div
323
+ className="fixed bg-black/55"
324
+ style={{ top: bottomTop, left: 0, width: vw, height: bottomH, zIndex: 210 }}
325
+ aria-hidden
326
+ />
327
+ <div
328
+ className="fixed bg-black/55"
329
+ style={{ top: t, left: 0, width: leftW, height: h, zIndex: 210 }}
330
+ aria-hidden
331
+ />
332
+ <div
333
+ className="fixed bg-black/55"
334
+ style={{ top: t, left: rightLeft, width: rightW, height: h, zIndex: 210 }}
335
+ aria-hidden
336
+ />
337
+ <div
338
+ className="pointer-events-none fixed rounded-xl border-2 border-violet-500 shadow-[0_0_0_1px_rgba(139,92,246,0.35)] dark:border-violet-400 dark:shadow-[0_0_0_1px_rgba(167,139,250,0.35)]"
339
+ style={{
340
+ top: t,
341
+ left: l,
342
+ width: w,
343
+ height: h,
344
+ zIndex: 211,
345
+ }}
346
+ aria-hidden
347
+ />
348
+ </>
349
+ );
350
+ })()
351
+ : null;
352
+
353
+ const node = createPortal(
354
+ <>
355
+ {fullBackdrop}
356
+ {dimPanels}
357
+ <div
358
+ ref={panelRef}
359
+ role="dialog"
360
+ aria-modal="true"
361
+ aria-labelledby="dashboard-tour-title"
362
+ aria-describedby="dashboard-tour-body"
363
+ className="rounded-xl border border-zinc-300 bg-white shadow-2xl dark:border-zinc-600 dark:bg-zinc-900"
364
+ style={tipStyle}
365
+ onMouseDown={(e) => e.stopPropagation()}
366
+ >
367
+ <div className="border-b border-zinc-200 px-4 py-3 dark:border-zinc-700">
368
+ <p className="text-xs font-medium uppercase tracking-wide text-violet-600 dark:text-violet-300">
369
+ {progressLabel}
370
+ </p>
371
+ <h2 id="dashboard-tour-title" className="mt-1 text-base font-semibold text-zinc-900 dark:text-zinc-100">
372
+ {current.title}
373
+ </h2>
374
+ </div>
375
+ <div id="dashboard-tour-body" className="max-h-[min(42vh,18rem)] overflow-y-auto px-4 py-3">
376
+ <p className="whitespace-pre-wrap text-sm leading-relaxed text-zinc-700 dark:text-zinc-300">{current.body}</p>
377
+ {kronoFocusTourStep && step === BASE_STEP_SELECTORS.length ? (
378
+ <p className="mt-3 text-sm leading-relaxed">
379
+ <a
380
+ href={dt.tourStep8LearnMoreUrl}
381
+ target="_blank"
382
+ rel="noopener noreferrer"
383
+ className="font-medium text-violet-700 underline decoration-violet-400/80 underline-offset-2 hover:text-violet-900 dark:text-violet-300 dark:decoration-violet-500/60 dark:hover:text-violet-200"
384
+ aria-label={dt.tourStep8LearnMoreAriaLabel}
385
+ >
386
+ {dt.tourStep8LearnMoreLabel}
387
+ </a>
388
+ </p>
389
+ ) : null}
390
+ </div>
391
+ <div className="flex flex-wrap items-center justify-between gap-2 border-t border-zinc-200 px-4 py-3 dark:border-zinc-700">
392
+ <div className="flex gap-1.5" role="presentation" aria-hidden>
393
+ {steps.map((_, i) => (
394
+ <span
395
+ key={i}
396
+ className={`h-2 w-2 rounded-full ${i === step ? "bg-violet-500 dark:bg-violet-400" : "bg-zinc-300 dark:bg-zinc-600"}`}
397
+ />
398
+ ))}
399
+ </div>
400
+ <div className="flex flex-wrap items-center justify-end gap-2">
401
+ <button
402
+ type="button"
403
+ className="text-sm text-zinc-500 underline-offset-2 hover:text-zinc-800 hover:underline dark:text-zinc-400 dark:hover:text-zinc-200"
404
+ onClick={finish}
405
+ >
406
+ {dt.tourSkipBtn}
407
+ </button>
408
+ {step > 0 ? (
409
+ <button type="button" className={secondaryBtn} onClick={() => setStep((s) => Math.max(0, s - 1))}>
410
+ {dt.tourBackBtn}
411
+ </button>
412
+ ) : null}
413
+ {last ? (
414
+ <button ref={primaryBtnRef} type="button" className={tbVioletTextSm} onClick={finish}>
415
+ {dt.tourDoneBtn}
416
+ </button>
417
+ ) : (
418
+ <button
419
+ ref={primaryBtnRef}
420
+ type="button"
421
+ className={tbVioletTextSm}
422
+ onClick={() => setStep((s) => Math.min(total - 1, s + 1))}
423
+ >
424
+ {dt.tourNextBtn}
425
+ </button>
426
+ )}
427
+ </div>
428
+ </div>
429
+ </div>
430
+ </>,
431
+ document.body
432
+ );
433
+
434
+ return node;
435
+ }
@@ -0,0 +1,39 @@
1
+ "use client";
2
+
3
+ import { Fragment, type ReactNode } from "react";
4
+ import { useDescriptionPopoverAfterMs } from "./useDescriptionPopoverAfterMs";
5
+
6
+ /**
7
+ * Texte ou pastille : définition en popover après 3 s (survol / focus), si {@link description} est non vide.
8
+ */
9
+ export function DeferredDescriptionPopoverWrap({
10
+ description,
11
+ chipClassName,
12
+ children,
13
+ }: {
14
+ description?: string | null;
15
+ chipClassName?: string;
16
+ children: ReactNode;
17
+ }) {
18
+ const { hasDescription, triggerProps, popoverLayer, anchorWrapperProps } =
19
+ useDescriptionPopoverAfterMs(description);
20
+
21
+ const chipMerged = [chipClassName, hasDescription ? "cursor-help select-none" : ""]
22
+ .filter(Boolean)
23
+ .join(" ");
24
+
25
+ if (!hasDescription) {
26
+ return <span className={chipMerged}>{children}</span>;
27
+ }
28
+
29
+ return (
30
+ <Fragment>
31
+ <span className="relative inline-flex max-w-full" {...anchorWrapperProps}>
32
+ <span className={chipMerged} {...triggerProps}>
33
+ {children}
34
+ </span>
35
+ </span>
36
+ {popoverLayer}
37
+ </Fragment>
38
+ );
39
+ }
@@ -0,0 +1,130 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import type { DashboardStrings } from "@/lib/dashboardCopy";
5
+ import { tbModalDanger } from "@/lib/translucentButtonClasses";
6
+
7
+ export function DeleteSessionModal({
8
+ open,
9
+ sessionLabel,
10
+ moveTargets,
11
+ t,
12
+ onClose,
13
+ onConfirm,
14
+ }: {
15
+ open: boolean;
16
+ sessionLabel: string;
17
+ moveTargets: { id: string; label: string }[];
18
+ t: DashboardStrings;
19
+ onClose: () => void;
20
+ onConfirm: (opts: { moveTasksToSessionId?: string }) => void | Promise<void>;
21
+ }) {
22
+ const [mode, setMode] = useState<"discard" | "move">("discard");
23
+ const [targetId, setTargetId] = useState("");
24
+
25
+ /** Stable key so parent re-renders (new array reference) don’t reset the select. */
26
+ const moveTargetIdsKey = [...moveTargets]
27
+ .map((x) => x.id)
28
+ .sort((a, b) => a.localeCompare(b))
29
+ .join("\0");
30
+
31
+ useEffect(() => {
32
+ if (!open) {
33
+ setMode("discard");
34
+ setTargetId("");
35
+ return;
36
+ }
37
+ setMode("discard");
38
+ setTargetId(moveTargets[0]?.id ?? "");
39
+ // `moveTargets` omitted on purpose: only `moveTargetIdsKey` (stable across reference churn) should reset the form.
40
+ // eslint-disable-next-line react-hooks/exhaustive-deps
41
+ }, [open, moveTargetIdsKey]);
42
+
43
+ if (!open) {
44
+ return null;
45
+ }
46
+
47
+ const canSubmit = mode === "discard" || (mode === "move" && targetId !== "");
48
+
49
+ return (
50
+ <div
51
+ className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 p-4"
52
+ role="dialog"
53
+ aria-modal="true"
54
+ aria-labelledby="del-session-title"
55
+ >
56
+ <div className="max-h-[90vh] w-full max-w-md overflow-y-auto rounded-xl border border-zinc-200 bg-white p-5 shadow-xl dark:border-zinc-700 dark:bg-zinc-900">
57
+ <h2 id="del-session-title" className="text-lg font-semibold text-zinc-900 dark:text-zinc-100">
58
+ {t.sessionDeleteTitle}
59
+ </h2>
60
+ <p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
61
+ <span className="font-medium text-zinc-800 dark:text-zinc-300">{sessionLabel}</span>
62
+ {" — "}
63
+ {t.sessionDeleteIntro}
64
+ </p>
65
+ <div className="mt-4 space-y-3">
66
+ <label className="flex cursor-pointer items-start gap-2 text-sm text-zinc-800 dark:text-zinc-200">
67
+ <input
68
+ type="radio"
69
+ name="delMode"
70
+ checked={mode === "discard"}
71
+ onChange={() => setMode("discard")}
72
+ className="mt-1"
73
+ />
74
+ <span>{t.sessionDeleteDiscardTasks}</span>
75
+ </label>
76
+ <label className="flex cursor-pointer items-start gap-2 text-sm text-zinc-800 dark:text-zinc-200">
77
+ <input
78
+ type="radio"
79
+ name="delMode"
80
+ checked={mode === "move"}
81
+ onChange={() => setMode("move")}
82
+ className="mt-1"
83
+ />
84
+ <span>{t.sessionDeleteMoveTasks}</span>
85
+ </label>
86
+ {mode === "move" ? (
87
+ <div className="ml-6">
88
+ <label className="block text-xs font-medium uppercase text-zinc-500 dark:text-zinc-500">
89
+ {t.sessionDeleteTargetLabel}
90
+ </label>
91
+ <select
92
+ className="mt-1 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100"
93
+ value={targetId}
94
+ onChange={(e) => setTargetId(e.target.value)}
95
+ >
96
+ <option value="">{t.sessionDeleteTargetPlaceholder}</option>
97
+ {moveTargets.map((x) => (
98
+ <option key={x.id} value={x.id}>
99
+ {x.label}
100
+ </option>
101
+ ))}
102
+ </select>
103
+ </div>
104
+ ) : null}
105
+ </div>
106
+ <div className="mt-6 flex flex-wrap justify-end gap-2">
107
+ <button
108
+ type="button"
109
+ className="rounded-lg border border-zinc-300 px-4 py-2 text-sm text-zinc-700 hover:bg-zinc-100 dark:border-zinc-600 dark:text-zinc-300 dark:hover:bg-zinc-800"
110
+ onClick={onClose}
111
+ >
112
+ {t.sessionDeleteCancelBtn}
113
+ </button>
114
+ <button
115
+ type="button"
116
+ disabled={!canSubmit}
117
+ className={tbModalDanger}
118
+ onClick={() =>
119
+ void onConfirm({
120
+ moveTasksToSessionId: mode === "move" ? targetId : undefined,
121
+ })
122
+ }
123
+ >
124
+ {t.sessionDeleteConfirmBtn}
125
+ </button>
126
+ </div>
127
+ </div>
128
+ </div>
129
+ );
130
+ }
@@ -0,0 +1,31 @@
1
+ "use client";
2
+
3
+ import type { CSSProperties } from "react";
4
+
5
+ /** Bulle de définition pour le hook `useDescriptionPopoverAfterMs` (contenu portail). */
6
+ export function DescriptionTooltipPortaled({
7
+ domId,
8
+ style,
9
+ text,
10
+ onPointerEnter,
11
+ onPointerLeave,
12
+ }: {
13
+ domId: string;
14
+ style: CSSProperties;
15
+ text: string;
16
+ onPointerEnter: () => void;
17
+ onPointerLeave: () => void;
18
+ }) {
19
+ return (
20
+ <div
21
+ id={domId}
22
+ role="tooltip"
23
+ style={style}
24
+ className="pointer-events-auto whitespace-pre-line rounded-lg border border-zinc-600 bg-zinc-900 p-2.5 text-left text-[0.7rem] leading-snug text-zinc-200 shadow-lg dark:bg-zinc-950"
25
+ onPointerEnter={onPointerEnter}
26
+ onPointerLeave={onPointerLeave}
27
+ >
28
+ {text}
29
+ </div>
30
+ );
31
+ }