@nightkatana/kronosys-app 1.0.0-beta.2 → 1.0.0-beta.21

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 (112) hide show
  1. package/README.md +28 -1
  2. package/app/api/action/route.ts +39 -3
  3. package/app/api/action-logs/route.ts +24 -0
  4. package/app/api/backup/route.ts +1 -1
  5. package/app/api/restore/route.ts +145 -0
  6. package/app/changelog/page.tsx +71 -4
  7. package/app/globals.css +127 -0
  8. package/app/guide/page.tsx +61 -15
  9. package/app/implementation/page.tsx +700 -0
  10. package/app/layout.tsx +14 -3
  11. package/app/licenses/page.tsx +99 -37
  12. package/app/logs/page.tsx +258 -0
  13. package/app/manifest.ts +5 -5
  14. package/app/page.tsx +784 -229
  15. package/app/reporting/page.tsx +1266 -474
  16. package/app/settings/page.tsx +252 -18
  17. package/bin/kronosys.mjs +140 -15
  18. package/components/KronosysPayloadProvider.tsx +2 -0
  19. package/components/RouteTransition.tsx +18 -0
  20. package/components/dashboard/AppShellCommandCenterPlaceholder.tsx +17 -0
  21. package/components/dashboard/AppShellHeaderSessionMeta.tsx +210 -0
  22. package/components/dashboard/AppShellHeaderWallClock.tsx +54 -0
  23. package/components/dashboard/AppShellLiveSessionDrawer.tsx +154 -38
  24. package/components/dashboard/AppShellRouteNav.tsx +323 -48
  25. package/components/dashboard/DashboardPauseBackdrop.tsx +50 -0
  26. package/components/dashboard/DashboardSimpleModal.tsx +168 -25
  27. package/components/dashboard/DashboardTour.tsx +115 -29
  28. package/components/dashboard/GlobalPauseConfirmModal.tsx +183 -0
  29. package/components/dashboard/KronosysDatetimePopoverField.tsx +167 -122
  30. package/components/dashboard/KronosysTimePopoverField.tsx +54 -12
  31. package/components/dashboard/NewSessionScopeModal.tsx +211 -20
  32. package/components/dashboard/PlannedTaskBoundaryConflictWatcher.tsx +275 -0
  33. package/components/dashboard/ReportingTour.tsx +87 -21
  34. package/components/dashboard/SavedProjectPicker.tsx +16 -3
  35. package/components/dashboard/SelectedSessionSidebarBlock.tsx +512 -142
  36. package/components/dashboard/SessionListPanel.tsx +327 -44
  37. package/components/dashboard/SettingsTagsProjectsSection.tsx +1073 -264
  38. package/components/dashboard/SettingsTaskTemplatesSection.tsx +316 -0
  39. package/components/dashboard/SettingsTour.tsx +86 -21
  40. package/components/dashboard/TagPills.tsx +14 -1
  41. package/components/dashboard/TaskFocusPanel.tsx +1081 -478
  42. package/components/dashboard/TaskSessionLiveCard.tsx +650 -135
  43. package/components/dashboard/TaskTimelineGanttModal.tsx +601 -0
  44. package/components/dashboard/taskFieldStyles.ts +20 -4
  45. package/components/dashboard/useReportingInteractionState.ts +80 -0
  46. package/lib/appShellHeaderClasses.ts +13 -0
  47. package/lib/businessRulesMatrix.ts +210 -0
  48. package/lib/copyToClipboard.ts +43 -0
  49. package/lib/dashboardCopy.ts +494 -84
  50. package/lib/dashboardQuickSearch.ts +54 -2
  51. package/lib/dashboardTimeZone.ts +109 -0
  52. package/lib/formatAppShellWallClock.ts +66 -0
  53. package/lib/formatSessionNameTemplate.ts +141 -0
  54. package/lib/generatedUserChangelog.ts +177 -6
  55. package/lib/globalPausePreview.ts +292 -0
  56. package/lib/implementationNotes.ts +1188 -0
  57. package/lib/kronosysApi.ts +6 -0
  58. package/lib/kronosysDashboardModalGates.ts +24 -0
  59. package/lib/plannedBoundaryAttention.ts +9 -0
  60. package/lib/plannedBoundaryConflict.ts +23 -0
  61. package/lib/reportingAggregate.ts +517 -75
  62. package/lib/reportingMetricHelp.ts +8 -0
  63. package/lib/reportingStrings.ts +37 -3
  64. package/lib/sessionListMerge.ts +4 -0
  65. package/lib/sessionTaskSidebarStats.ts +182 -21
  66. package/lib/settingsCopy.ts +178 -4
  67. package/lib/taskParsing.ts +360 -103
  68. package/lib/taskTemplateDraft.ts +135 -0
  69. package/lib/taskTimelineGantt.ts +265 -0
  70. package/lib/temporalDisplayPlanned.ts +71 -0
  71. package/lib/userGuideCopy.ts +121 -47
  72. package/next.config.ts +7 -0
  73. package/package.json +12 -24
  74. package/server/actionDispatch.ts +1000 -77
  75. package/server/actionTaskSession.ts +337 -24
  76. package/server/db.ts +7 -15
  77. package/server/dbSchema.ts +24 -0
  78. package/server/defaultCfg.ts +5 -0
  79. package/server/gitlabTokenStore.ts +0 -12
  80. package/server/liveHistorySync.ts +53 -0
  81. package/server/mainTimerHydrate.ts +38 -2
  82. package/server/payloadStore.ts +33 -11
  83. package/server/sessionWallHydrate.ts +66 -3
  84. package/server/userActionLog.ts +126 -0
  85. package/sonar-project.properties +11 -0
  86. package/tsconfig.json +2 -1
  87. package/components/dashboard/IssuePickerModal.tsx +0 -168
  88. package/components/dashboard/ThemeToggle.test.tsx +0 -26
  89. package/lib/backupCsvExport.test.ts +0 -149
  90. package/lib/dashboardQuickSearchQuery.test.ts +0 -63
  91. package/lib/dataDir.test.ts +0 -87
  92. package/lib/formatIsoShort.test.ts +0 -46
  93. package/lib/kronoFocusRhythm.test.ts +0 -130
  94. package/lib/kronoFocusTimerUrgency.test.ts +0 -74
  95. package/lib/legacyKronoFocusStorageKeys.test.ts +0 -29
  96. package/lib/reportingAggregate.test.ts +0 -325
  97. package/lib/reportingNonFinalIndicators.test.ts +0 -157
  98. package/lib/reportingTagWeekBreakdown.test.ts +0 -141
  99. package/lib/reportingWeekLayout.test.ts +0 -239
  100. package/lib/sessionAssiduity.test.ts +0 -25
  101. package/lib/sessionEndWarnings.test.ts +0 -200
  102. package/lib/sessionListMerge.test.ts +0 -101
  103. package/lib/sessionTaskSidebarStats.test.ts +0 -24
  104. package/lib/taskParsing.test.ts +0 -153
  105. package/lib/usageProfile.test.ts +0 -84
  106. package/server/actionDispatch.test.ts +0 -723
  107. package/server/actionTaskSession.test.ts +0 -713
  108. package/server/kronoFocusHydrate.test.ts +0 -142
  109. package/server/kronoFocusMigrate.test.ts +0 -53
  110. package/server/mainTimerHydrate.test.ts +0 -65
  111. package/server/payloadStore.test.ts +0 -78
  112. package/server/sessionWallHydrate.test.ts +0 -46
@@ -1,8 +1,11 @@
1
1
  "use client";
2
2
 
3
3
  import { useEffect, useId, useState, type ReactNode } from "react";
4
+ import { X } from "lucide-react";
4
5
  import { tbModalDanger, tbModalPrimary } from "@/lib/translucentButtonClasses";
5
6
 
7
+ const MODAL_FADE_MS = 150;
8
+
6
9
  function useEscapeClose(open: boolean, onClose: () => void) {
7
10
  useEffect(() => {
8
11
  if (!open) {
@@ -18,6 +21,27 @@ function useEscapeClose(open: boolean, onClose: () => void) {
18
21
  }, [open, onClose]);
19
22
  }
20
23
 
24
+ function useModalPresence(open: boolean) {
25
+ const [shouldRender, setShouldRender] = useState(open);
26
+ const [visible, setVisible] = useState(open);
27
+
28
+ useEffect(() => {
29
+ if (open) {
30
+ setShouldRender(true);
31
+ const frame = globalThis.requestAnimationFrame(() => setVisible(true));
32
+ return () => globalThis.cancelAnimationFrame(frame);
33
+ }
34
+ setVisible(false);
35
+ const timeout = globalThis.setTimeout(
36
+ () => setShouldRender(false),
37
+ MODAL_FADE_MS,
38
+ );
39
+ return () => globalThis.clearTimeout(timeout);
40
+ }, [open]);
41
+
42
+ return { shouldRender, visible };
43
+ }
44
+
21
45
  /** Message seul, bouton OK (remplace `alert()`). */
22
46
  export function DashboardAlertModal({
23
47
  open,
@@ -35,37 +59,47 @@ export function DashboardAlertModal({
35
59
  const titleId = useId();
36
60
  const messageId = useId();
37
61
  useEscapeClose(open, onClose);
62
+ const { shouldRender, visible } = useModalPresence(open);
38
63
 
39
- if (!open) {
64
+ if (!shouldRender) {
40
65
  return null;
41
66
  }
42
67
 
43
68
  return (
44
69
  <div
45
- className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 p-4"
70
+ className={`fixed inset-0 z-[100] flex items-center justify-center bg-black/60 p-4 transition-opacity duration-150 ease-out motion-reduce:transition-none ${
71
+ visible ? "opacity-100" : "opacity-0"
72
+ }`}
46
73
  role="alertdialog"
47
74
  aria-modal="true"
48
75
  aria-labelledby={title ? titleId : messageId}
49
76
  aria-describedby={title ? messageId : undefined}
50
77
  >
51
- <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">
78
+ <div
79
+ className={`max-h-[90vh] w-full max-w-md overflow-y-auto rounded-xl border border-zinc-200 bg-white p-5 shadow-xl transition-all duration-150 ease-out motion-reduce:transition-none dark:border-zinc-700 dark:bg-zinc-900 ${
80
+ visible
81
+ ? "translate-y-0 scale-100 opacity-100"
82
+ : "translate-y-1 scale-[0.985] opacity-0"
83
+ }`}
84
+ >
52
85
  {title ? (
53
- <h2 id={titleId} className="text-lg font-semibold text-zinc-900 dark:text-zinc-100">
86
+ <h2
87
+ id={titleId}
88
+ className="text-lg font-semibold text-zinc-900 dark:text-zinc-100"
89
+ >
54
90
  {title}
55
91
  </h2>
56
92
  ) : null}
57
93
  <p
58
94
  id={messageId}
59
- className={`whitespace-pre-wrap text-sm leading-relaxed text-zinc-600 dark:text-zinc-300 ${title ? "mt-3" : ""}`}
95
+ className={`whitespace-pre-wrap text-sm leading-relaxed text-zinc-600 dark:text-zinc-300 ${
96
+ title ? "mt-3" : ""
97
+ }`}
60
98
  >
61
99
  {message}
62
100
  </p>
63
101
  <div className="mt-6 flex justify-end">
64
- <button
65
- type="button"
66
- className={tbModalPrimary}
67
- onClick={onClose}
68
- >
102
+ <button type="button" className={tbModalPrimary} onClick={onClose}>
69
103
  {okLabel}
70
104
  </button>
71
105
  </div>
@@ -125,6 +159,7 @@ export function DashboardConfirmModal({
125
159
  const messageId = useId();
126
160
  const typeFieldId = useId();
127
161
  const [typeDraft, setTypeDraft] = useState("");
162
+ const { shouldRender, visible } = useModalPresence(open);
128
163
  useEscapeClose(open, () => {
129
164
  if (pending) {
130
165
  return;
@@ -138,30 +173,44 @@ export function DashboardConfirmModal({
138
173
  }
139
174
  }, [open]);
140
175
 
141
- if (!open) {
176
+ if (!shouldRender) {
142
177
  return null;
143
178
  }
144
179
 
145
180
  const typedOk = !typeToConfirm || typeDraft === typeToConfirm.expected;
146
- const confirmClass = confirmVariant === "danger" ? tbModalDanger : tbModalPrimary;
181
+ const confirmClass =
182
+ confirmVariant === "danger" ? tbModalDanger : tbModalPrimary;
147
183
 
148
184
  return (
149
185
  <div
150
- className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 p-4"
186
+ className={`fixed inset-0 z-[100] flex items-center justify-center bg-black/60 p-4 transition-opacity duration-150 ease-out motion-reduce:transition-none ${
187
+ visible ? "opacity-100" : "opacity-0"
188
+ }`}
151
189
  role="dialog"
152
190
  aria-modal="true"
153
191
  aria-labelledby={title ? titleId : messageId}
154
192
  aria-describedby={title ? messageId : undefined}
155
193
  >
156
- <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">
194
+ <div
195
+ className={`max-h-[90vh] w-full max-w-md overflow-y-auto rounded-xl border border-zinc-200 bg-white p-5 shadow-xl transition-all duration-150 ease-out motion-reduce:transition-none dark:border-zinc-700 dark:bg-zinc-900 ${
196
+ visible
197
+ ? "translate-y-0 scale-100 opacity-100"
198
+ : "translate-y-1 scale-[0.985] opacity-0"
199
+ }`}
200
+ >
157
201
  {title ? (
158
- <h2 id={titleId} className="text-lg font-semibold text-zinc-900 dark:text-zinc-100">
202
+ <h2
203
+ id={titleId}
204
+ className="text-lg font-semibold text-zinc-900 dark:text-zinc-100"
205
+ >
159
206
  {title}
160
207
  </h2>
161
208
  ) : null}
162
209
  <p
163
210
  id={messageId}
164
- className={`whitespace-pre-wrap text-sm leading-relaxed text-zinc-600 dark:text-zinc-300 ${title ? "mt-3" : ""}`}
211
+ className={`whitespace-pre-wrap text-sm leading-relaxed text-zinc-600 dark:text-zinc-300 ${
212
+ title ? "mt-3" : ""
213
+ }`}
165
214
  >
166
215
  {message}
167
216
  </p>
@@ -180,8 +229,13 @@ export function DashboardConfirmModal({
180
229
  {extra ? <div className="mt-4">{extra}</div> : null}
181
230
  {typeToConfirm ? (
182
231
  <div className="mt-4 space-y-2">
183
- <p className="text-sm leading-relaxed text-zinc-600 dark:text-zinc-400">{typeToConfirm.instruction}</p>
184
- <label htmlFor={typeFieldId} className="block text-xs font-semibold uppercase tracking-wide text-zinc-500">
232
+ <p className="text-sm leading-relaxed text-zinc-600 dark:text-zinc-400">
233
+ {typeToConfirm.instruction}
234
+ </p>
235
+ <label
236
+ htmlFor={typeFieldId}
237
+ className="block text-xs font-semibold uppercase tracking-wide text-zinc-500"
238
+ >
185
239
  {typeToConfirm.label}
186
240
  </label>
187
241
  <input
@@ -265,30 +319,45 @@ export function DashboardTriActionModal({
265
319
  const titleId = useId();
266
320
  const messageId = useId();
267
321
  useEscapeClose(open, onDismiss);
322
+ const { shouldRender, visible } = useModalPresence(open);
268
323
 
269
- if (!open) {
324
+ if (!shouldRender) {
270
325
  return null;
271
326
  }
272
327
 
273
- const primaryClass = primaryVariant === "danger" ? tbModalDanger : tbModalPrimary;
328
+ const primaryClass =
329
+ primaryVariant === "danger" ? tbModalDanger : tbModalPrimary;
274
330
 
275
331
  return (
276
332
  <div
277
- className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 p-4"
333
+ className={`fixed inset-0 z-[100] flex items-center justify-center bg-black/60 p-4 transition-opacity duration-150 ease-out motion-reduce:transition-none ${
334
+ visible ? "opacity-100" : "opacity-0"
335
+ }`}
278
336
  role="dialog"
279
337
  aria-modal="true"
280
338
  aria-labelledby={title ? titleId : messageId}
281
339
  aria-describedby={title ? messageId : undefined}
282
340
  >
283
- <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">
341
+ <div
342
+ className={`max-h-[90vh] w-full max-w-md overflow-y-auto rounded-xl border border-zinc-200 bg-white p-5 shadow-xl transition-all duration-150 ease-out motion-reduce:transition-none dark:border-zinc-700 dark:bg-zinc-900 ${
343
+ visible
344
+ ? "translate-y-0 scale-100 opacity-100"
345
+ : "translate-y-1 scale-[0.985] opacity-0"
346
+ }`}
347
+ >
284
348
  {title ? (
285
- <h2 id={titleId} className="text-lg font-semibold text-zinc-900 dark:text-zinc-100">
349
+ <h2
350
+ id={titleId}
351
+ className="text-lg font-semibold text-zinc-900 dark:text-zinc-100"
352
+ >
286
353
  {title}
287
354
  </h2>
288
355
  ) : null}
289
356
  <p
290
357
  id={messageId}
291
- className={`whitespace-pre-wrap text-sm leading-relaxed text-zinc-600 dark:text-zinc-300 ${title ? "mt-3" : ""}`}
358
+ className={`whitespace-pre-wrap text-sm leading-relaxed text-zinc-600 dark:text-zinc-300 ${
359
+ title ? "mt-3" : ""
360
+ }`}
292
361
  >
293
362
  {message}
294
363
  </p>
@@ -327,7 +396,11 @@ export function DashboardTriActionModal({
327
396
  >
328
397
  {secondaryLabel}
329
398
  </button>
330
- <button type="button" className={primaryClass} onClick={() => void onPrimary()}>
399
+ <button
400
+ type="button"
401
+ className={primaryClass}
402
+ onClick={() => void onPrimary()}
403
+ >
331
404
  {primaryLabel}
332
405
  </button>
333
406
  </div>
@@ -335,3 +408,73 @@ export function DashboardTriActionModal({
335
408
  </div>
336
409
  );
337
410
  }
411
+
412
+ /** Contenu libre scrollable (formulaire, aide longue, carte session) — fermeture ×, Escape, clic fond. */
413
+ export function DashboardPanelModal({
414
+ open,
415
+ title,
416
+ onClose,
417
+ closeAriaLabel,
418
+ children,
419
+ }: {
420
+ open: boolean;
421
+ title: string;
422
+ onClose: () => void;
423
+ closeAriaLabel: string;
424
+ children: ReactNode;
425
+ }) {
426
+ const titleId = useId();
427
+ useEscapeClose(open, onClose);
428
+ const { shouldRender, visible } = useModalPresence(open);
429
+
430
+ if (!shouldRender) {
431
+ return null;
432
+ }
433
+
434
+ return (
435
+ <div
436
+ className={`fixed inset-0 z-[98] overflow-y-auto overscroll-y-contain bg-black/55 px-3 pt-[max(5.25rem,env(safe-area-inset-top,0px)+3.25rem)] pb-6 transition-opacity duration-150 ease-out motion-reduce:transition-none sm:px-4 sm:pb-8 sm:pt-28 ${
437
+ visible ? "opacity-100" : "opacity-0"
438
+ }`}
439
+ role="dialog"
440
+ aria-modal="true"
441
+ aria-labelledby={titleId}
442
+ onClick={(e) => {
443
+ if (e.target === e.currentTarget) {
444
+ onClose();
445
+ }
446
+ }}
447
+ >
448
+ <div className="mx-auto w-full max-w-6xl pb-1">
449
+ <div
450
+ className={`flex max-h-[min(42dvh,22rem)] w-full flex-col overflow-hidden rounded-xl border border-zinc-200 bg-white shadow-xl transition-all duration-150 ease-out motion-reduce:transition-none dark:border-zinc-700 dark:bg-zinc-900 sm:max-h-[min(46dvh,26rem)] md:max-h-[min(50dvh,28rem)] ${
451
+ visible
452
+ ? "translate-y-0 scale-100 opacity-100"
453
+ : "translate-y-1 scale-[0.99] opacity-0"
454
+ }`}
455
+ onClick={(e) => e.stopPropagation()}
456
+ >
457
+ <div className="flex shrink-0 items-center justify-between gap-3 border-b border-zinc-200 px-3 py-2.5 dark:border-zinc-700 sm:px-4 sm:py-3">
458
+ <h2
459
+ id={titleId}
460
+ className="min-w-0 text-sm font-semibold tracking-tight text-zinc-900 sm:text-base dark:text-zinc-100"
461
+ >
462
+ {title}
463
+ </h2>
464
+ <button
465
+ type="button"
466
+ className="inline-flex size-9 shrink-0 items-center justify-center rounded-lg border border-transparent text-zinc-500 transition hover:border-zinc-300 hover:bg-zinc-100 hover:text-zinc-800 dark:hover:border-zinc-600 dark:hover:bg-zinc-800 dark:hover:text-zinc-200"
467
+ aria-label={closeAriaLabel}
468
+ onClick={onClose}
469
+ >
470
+ <X size={20} strokeWidth={2.25} aria-hidden />
471
+ </button>
472
+ </div>
473
+ <div className="min-h-0 flex-1 overflow-y-auto px-3 py-3 sm:px-4 sm:py-4">
474
+ {children}
475
+ </div>
476
+ </div>
477
+ </div>
478
+ </div>
479
+ );
480
+ }
@@ -10,6 +10,7 @@ import {
10
10
  type CSSProperties,
11
11
  } from "react";
12
12
  import { createPortal } from "react-dom";
13
+ import { postKronosysAction } from "@/lib/kronosysApi";
13
14
  import { markDashboardTourCompleted } from "@/lib/dashboardTourStorage";
14
15
  import type { DashboardStrings } from "@/lib/dashboardCopy";
15
16
  import { tbVioletTextSm } from "@/lib/translucentButtonClasses";
@@ -26,10 +27,12 @@ const BASE_STEP_SELECTORS = [
26
27
  ] as const;
27
28
 
28
29
  /** 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
+ const KRONO_FOCUS_HEADER_TOUR_SELECTOR =
31
+ "#dashboard-tour-anchor-kronoFocus-header";
30
32
 
31
33
  /** 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";
34
+ const GIT_IDENTITY_BANNER_TOUR_SELECTOR =
35
+ "#dashboard-tour-anchor-git-identity-banner";
33
36
 
34
37
  const HOLE_PADDING_PX = 10;
35
38
  const TOOLTIP_MAX_W = 360;
@@ -42,7 +45,7 @@ type TipPlacement = "below" | "above" | "right" | "left";
42
45
 
43
46
  function tooltipIntersectsHole(
44
47
  tip: { top: number; left: number; width: number; height: number },
45
- hole: HoleRect
48
+ hole: HoleRect,
46
49
  ): boolean {
47
50
  return !(
48
51
  tip.left + tip.width <= hole.left ||
@@ -58,7 +61,7 @@ function clampTipPosition(
58
61
  ph: number,
59
62
  wTip: number,
60
63
  vw: number,
61
- vh: number
64
+ vh: number,
62
65
  ): { top: number; left: number } {
63
66
  return {
64
67
  top: Math.max(VIEW_MARGIN, Math.min(top, vh - ph - VIEW_MARGIN)),
@@ -73,7 +76,7 @@ function computeTooltipPosition(
73
76
  vh: number,
74
77
  wTip: number,
75
78
  ph: number,
76
- placementPriority: readonly TipPlacement[]
79
+ placementPriority: readonly TipPlacement[],
77
80
  ): { top: number; left: number } {
78
81
  const g = TOOLTIP_GAP;
79
82
  const cx = hole.left + hole.width / 2 - wTip / 2;
@@ -94,7 +97,14 @@ function computeTooltipPosition(
94
97
  }
95
98
  }
96
99
 
97
- const fallback = clampTipPosition(variants.below.top, variants.below.left, ph, wTip, vw, vh);
100
+ const fallback = clampTipPosition(
101
+ variants.below.top,
102
+ variants.below.left,
103
+ ph,
104
+ wTip,
105
+ vw,
106
+ vh,
107
+ );
98
108
  return fallback;
99
109
  }
100
110
 
@@ -185,12 +195,22 @@ export function DashboardTour({
185
195
  }
186
196
  }, [open]);
187
197
 
188
- const finish = useCallback(() => {
189
- markDashboardTourCompleted();
190
- onOpenChange(false);
191
- }, [onOpenChange]);
198
+ const finish = useCallback(
199
+ (reason: "skip" | "done" | "escape") => {
200
+ void postKronosysAction({
201
+ type: "tourInteraction",
202
+ tour: "dashboard_spotlight",
203
+ action: reason,
204
+ stepIndex: step,
205
+ stepTotal: total,
206
+ }).catch(() => undefined);
207
+ markDashboardTourCompleted();
208
+ onOpenChange(false);
209
+ },
210
+ [onOpenChange, step, total],
211
+ );
192
212
 
193
- useEscapeDismiss(open, finish);
213
+ useEscapeDismiss(open, () => finish("escape"));
194
214
 
195
215
  const updateHoleFromDom = useCallback(() => {
196
216
  if (!open) {
@@ -212,7 +232,11 @@ export function DashboardTour({
212
232
  }
213
233
  const el = document.querySelector(selector);
214
234
  if (el instanceof HTMLElement) {
215
- el.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "auto" });
235
+ el.scrollIntoView({
236
+ block: "nearest",
237
+ inline: "nearest",
238
+ behavior: "auto",
239
+ });
216
240
  }
217
241
  updateHoleFromDom();
218
242
  const raf = requestAnimationFrame(updateHoleFromDom);
@@ -238,8 +262,14 @@ export function DashboardTour({
238
262
  if (!open) {
239
263
  return;
240
264
  }
241
- const vw = typeof globalThis.window !== "undefined" ? globalThis.window.innerWidth : 1024;
242
- const vh = typeof globalThis.window !== "undefined" ? globalThis.window.innerHeight : 768;
265
+ const vw =
266
+ typeof globalThis.window !== "undefined"
267
+ ? globalThis.window.innerWidth
268
+ : 1024;
269
+ const vh =
270
+ typeof globalThis.window !== "undefined"
271
+ ? globalThis.window.innerHeight
272
+ : 768;
243
273
  const w = Math.min(TOOLTIP_MAX_W, vw - 2 * VIEW_MARGIN);
244
274
 
245
275
  if (!hole) {
@@ -260,8 +290,17 @@ export function DashboardTour({
260
290
  const ph = panel?.getBoundingClientRect().height ?? 220;
261
291
  /** Étape « Sessions » : colonne étroite à gauche — placer le panneau à droite en priorité pour ne pas masquer le trou. */
262
292
  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);
293
+ step === 2
294
+ ? ["right", "below", "above", "left"]
295
+ : ["below", "above", "right", "left"];
296
+ const { top, left } = computeTooltipPosition(
297
+ hole,
298
+ vw,
299
+ vh,
300
+ w,
301
+ ph,
302
+ placementPriority,
303
+ );
265
304
  setTipStyle({
266
305
  position: "fixed",
267
306
  top,
@@ -290,7 +329,9 @@ export function DashboardTour({
290
329
  return null;
291
330
  }
292
331
 
293
- const progressLabel = dt.tourProgressLabel.replace("{n}", String(step + 1)).replace("{total}", String(total));
332
+ const progressLabel = dt.tourProgressLabel
333
+ .replace("{n}", String(step + 1))
334
+ .replace("{total}", String(total));
294
335
 
295
336
  const secondaryBtn =
296
337
  "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";
@@ -316,22 +357,46 @@ export function DashboardTour({
316
357
  <>
317
358
  <div
318
359
  className="fixed bg-black/55"
319
- style={{ top: 0, left: 0, width: vw, height: topH, zIndex: 210 }}
360
+ style={{
361
+ top: 0,
362
+ left: 0,
363
+ width: vw,
364
+ height: topH,
365
+ zIndex: 210,
366
+ }}
320
367
  aria-hidden
321
368
  />
322
369
  <div
323
370
  className="fixed bg-black/55"
324
- style={{ top: bottomTop, left: 0, width: vw, height: bottomH, zIndex: 210 }}
371
+ style={{
372
+ top: bottomTop,
373
+ left: 0,
374
+ width: vw,
375
+ height: bottomH,
376
+ zIndex: 210,
377
+ }}
325
378
  aria-hidden
326
379
  />
327
380
  <div
328
381
  className="fixed bg-black/55"
329
- style={{ top: t, left: 0, width: leftW, height: h, zIndex: 210 }}
382
+ style={{
383
+ top: t,
384
+ left: 0,
385
+ width: leftW,
386
+ height: h,
387
+ zIndex: 210,
388
+ }}
330
389
  aria-hidden
331
390
  />
332
391
  <div
333
392
  className="fixed bg-black/55"
334
- style={{ top: t, left: rightLeft, width: rightW, height: h, zIndex: 210 }}
393
+ style={{
394
+ top: t,
395
+ left: rightLeft,
396
+ width: rightW,
397
+ height: h,
398
+ zIndex: 210,
399
+ }}
335
400
  aria-hidden
336
401
  />
337
402
  <div
@@ -368,12 +433,20 @@ export function DashboardTour({
368
433
  <p className="text-xs font-medium uppercase tracking-wide text-violet-600 dark:text-violet-300">
369
434
  {progressLabel}
370
435
  </p>
371
- <h2 id="dashboard-tour-title" className="mt-1 text-base font-semibold text-zinc-900 dark:text-zinc-100">
436
+ <h2
437
+ id="dashboard-tour-title"
438
+ className="mt-1 text-base font-semibold text-zinc-900 dark:text-zinc-100"
439
+ >
372
440
  {current.title}
373
441
  </h2>
374
442
  </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>
443
+ <div
444
+ id="dashboard-tour-body"
445
+ className="max-h-[min(42vh,18rem)] overflow-y-auto px-4 py-3"
446
+ >
447
+ <p className="whitespace-pre-wrap text-sm leading-relaxed text-zinc-700 dark:text-zinc-300">
448
+ {current.body}
449
+ </p>
377
450
  {kronoFocusTourStep && step === BASE_STEP_SELECTORS.length ? (
378
451
  <p className="mt-3 text-sm leading-relaxed">
379
452
  <a
@@ -393,7 +466,11 @@ export function DashboardTour({
393
466
  {steps.map((_, i) => (
394
467
  <span
395
468
  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"}`}
469
+ className={`h-2 w-2 rounded-full ${
470
+ i === step
471
+ ? "bg-violet-500 dark:bg-violet-400"
472
+ : "bg-zinc-300 dark:bg-zinc-600"
473
+ }`}
397
474
  />
398
475
  ))}
399
476
  </div>
@@ -401,17 +478,26 @@ export function DashboardTour({
401
478
  <button
402
479
  type="button"
403
480
  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}
481
+ onClick={() => finish("skip")}
405
482
  >
406
483
  {dt.tourSkipBtn}
407
484
  </button>
408
485
  {step > 0 ? (
409
- <button type="button" className={secondaryBtn} onClick={() => setStep((s) => Math.max(0, s - 1))}>
486
+ <button
487
+ type="button"
488
+ className={secondaryBtn}
489
+ onClick={() => setStep((s) => Math.max(0, s - 1))}
490
+ >
410
491
  {dt.tourBackBtn}
411
492
  </button>
412
493
  ) : null}
413
494
  {last ? (
414
- <button ref={primaryBtnRef} type="button" className={tbVioletTextSm} onClick={finish}>
495
+ <button
496
+ ref={primaryBtnRef}
497
+ type="button"
498
+ className={tbVioletTextSm}
499
+ onClick={() => finish("done")}
500
+ >
415
501
  {dt.tourDoneBtn}
416
502
  </button>
417
503
  ) : (
@@ -428,7 +514,7 @@ export function DashboardTour({
428
514
  </div>
429
515
  </div>
430
516
  </>,
431
- document.body
517
+ document.body,
432
518
  );
433
519
 
434
520
  return node;