@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,130 @@
1
+ "use client";
2
+
3
+ import { Loader2, RotateCw } from "lucide-react";
4
+ import { useEffect, useRef, useState } from "react";
5
+
6
+ export type PageRefreshInlineMessages = {
7
+ loading: string;
8
+ success: string;
9
+ error: string;
10
+ };
11
+
12
+ type BannerPhase = "idle" | "loading" | "success" | "error";
13
+
14
+ const BANNER_DISMISS_MS = 2800;
15
+
16
+ export function PageRefreshButton({
17
+ title,
18
+ ariaLabel,
19
+ onRefresh,
20
+ className = "",
21
+ inlineMessages,
22
+ }: {
23
+ title: string;
24
+ ariaLabel: string;
25
+ /** Recharge les données (ex. `fetchKronosysState`) ; renvoyer `false` signale un échec. */
26
+ onRefresh: () => boolean | void | Promise<boolean | void>;
27
+ className?: string;
28
+ /** Si défini, affiche un court libellé sous le bouton (vert / ambre) au lieu d’un toast global. */
29
+ inlineMessages?: PageRefreshInlineMessages;
30
+ }) {
31
+ const [loading, setLoading] = useState(false);
32
+ const [bannerPhase, setBannerPhase] = useState<BannerPhase>("idle");
33
+ const clearBannerTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
34
+
35
+ useEffect(
36
+ () => () => {
37
+ if (clearBannerTimerRef.current) {
38
+ clearTimeout(clearBannerTimerRef.current);
39
+ }
40
+ },
41
+ []
42
+ );
43
+
44
+ const scheduleBannerClear = () => {
45
+ if (clearBannerTimerRef.current) {
46
+ clearTimeout(clearBannerTimerRef.current);
47
+ }
48
+ clearBannerTimerRef.current = setTimeout(() => {
49
+ setBannerPhase("idle");
50
+ clearBannerTimerRef.current = null;
51
+ }, BANNER_DISMISS_MS);
52
+ };
53
+
54
+ const handleRefresh = async () => {
55
+ if (inlineMessages) {
56
+ if (clearBannerTimerRef.current) {
57
+ clearTimeout(clearBannerTimerRef.current);
58
+ clearBannerTimerRef.current = null;
59
+ }
60
+ setBannerPhase("loading");
61
+ }
62
+ setLoading(true);
63
+ const t0 = Date.now();
64
+ let ok = true;
65
+ try {
66
+ const r = await onRefresh();
67
+ ok = r !== false;
68
+ } catch {
69
+ ok = false;
70
+ } finally {
71
+ const minMs = 400;
72
+ const wait = minMs - (Date.now() - t0);
73
+ if (wait > 0) {
74
+ await new Promise((r) => setTimeout(r, wait));
75
+ }
76
+ if (inlineMessages) {
77
+ setBannerPhase(ok ? "success" : "error");
78
+ scheduleBannerClear();
79
+ }
80
+ setLoading(false);
81
+ }
82
+ };
83
+
84
+ let bannerLabel = "";
85
+ if (inlineMessages) {
86
+ if (bannerPhase === "loading") {
87
+ bannerLabel = inlineMessages.loading;
88
+ } else if (bannerPhase === "success") {
89
+ bannerLabel = inlineMessages.success;
90
+ } else if (bannerPhase === "error") {
91
+ bannerLabel = inlineMessages.error;
92
+ }
93
+ }
94
+
95
+ const showBanner = Boolean(inlineMessages) && bannerPhase !== "idle" && bannerLabel;
96
+
97
+ const bannerCls =
98
+ bannerPhase === "loading" || bannerPhase === "success"
99
+ ? "border border-emerald-400/60 bg-emerald-50 text-emerald-950 shadow-sm dark:border-emerald-500/45 dark:bg-emerald-950/75 dark:text-emerald-50"
100
+ : "border border-amber-400/60 bg-amber-50 text-amber-950 shadow-sm dark:border-amber-600/40 dark:bg-amber-950/70 dark:text-amber-100";
101
+
102
+ return (
103
+ <div className="relative inline-flex flex-col items-end">
104
+ <button
105
+ type="button"
106
+ className={`inline-flex size-10 shrink-0 items-center justify-center rounded-lg border border-zinc-300 bg-white text-zinc-700 shadow-sm transition hover:border-zinc-400 hover:bg-zinc-50 disabled:opacity-50 dark:border-zinc-600 dark:bg-zinc-800/90 dark:text-zinc-200 dark:hover:border-zinc-500 dark:hover:bg-zinc-800 ${className}`}
107
+ onClick={() => void handleRefresh()}
108
+ disabled={loading}
109
+ title={title}
110
+ aria-label={ariaLabel}
111
+ aria-busy={loading ? "true" : "false"}
112
+ >
113
+ {loading ? (
114
+ <Loader2 size={18} className="animate-spin text-violet-600 dark:text-violet-400" aria-hidden />
115
+ ) : (
116
+ <RotateCw size={18} aria-hidden />
117
+ )}
118
+ </button>
119
+ {showBanner && (
120
+ <div
121
+ role="status"
122
+ aria-live="polite"
123
+ className={`pointer-events-none absolute right-0 top-full z-[70] mt-1.5 w-max max-w-[min(calc(100vw-2rem),32rem)] whitespace-normal rounded-md px-2.5 py-1.5 text-right text-[0.8125rem] font-medium leading-snug ${bannerCls}`}
124
+ >
125
+ {bannerLabel}
126
+ </div>
127
+ )}
128
+ </div>
129
+ );
130
+ }
@@ -0,0 +1,97 @@
1
+ "use client";
2
+
3
+ import { useEffect, useId, useRef, useState, type CSSProperties } from "react";
4
+ import { createPortal } from "react-dom";
5
+ import { CircleHelp } from "lucide-react";
6
+ import { useAnchoredFloatingPortalStyle } from "./useAnchoredFloatingPortalStyle";
7
+
8
+ const PANEL_PLACEHOLDER_STYLE: CSSProperties = {
9
+ position: "fixed",
10
+ top: 0,
11
+ left: 0,
12
+ width: 320,
13
+ zIndex: 80,
14
+ visibility: "hidden",
15
+ pointerEvents: "none",
16
+ };
17
+
18
+ /**
19
+ * Bouton (?) qui ouvre un panneau : rendu en portail fixe pour éviter le clip des rangées
20
+ * `overflow-x-auto` (plus de zone scroll « popover »).
21
+ */
22
+ export function PlainHelpPopover({
23
+ ariaLabel,
24
+ body,
25
+ compact = true,
26
+ }: {
27
+ ariaLabel: string;
28
+ /** Texte du panneau (paragraphes séparés par \\n\\n si besoin). */
29
+ body: string;
30
+ compact?: boolean;
31
+ }) {
32
+ const [open, setOpen] = useState(false);
33
+ const triggerRef = useRef<HTMLDivElement>(null);
34
+ const panelRef = useRef<HTMLDivElement>(null);
35
+ const id = useId();
36
+
37
+ const panelStyle = useAnchoredFloatingPortalStyle(open, triggerRef, panelRef, {
38
+ align: "start",
39
+ maxWidthRem: 20,
40
+ });
41
+
42
+ useEffect(() => {
43
+ if (!open) {
44
+ return;
45
+ }
46
+ const onDoc = (e: MouseEvent) => {
47
+ const t = e.target as Node;
48
+ if (!triggerRef.current?.contains(t) && !panelRef.current?.contains(t)) {
49
+ setOpen(false);
50
+ }
51
+ };
52
+ document.addEventListener("mousedown", onDoc);
53
+ return () => document.removeEventListener("mousedown", onDoc);
54
+ }, [open]);
55
+
56
+ const icon = compact ? 11 : 18;
57
+
58
+ const mergedStyle = panelStyle ?? (open ? PANEL_PLACEHOLDER_STYLE : undefined);
59
+
60
+ const panel =
61
+ open && typeof document !== "undefined" && mergedStyle
62
+ ? createPortal(
63
+ <div
64
+ ref={panelRef}
65
+ id={`${id}-plain-help`}
66
+ style={mergedStyle}
67
+ className="rounded-lg border border-zinc-200 bg-white p-2.5 text-left shadow-xl dark:border-zinc-600 dark:bg-zinc-900"
68
+ role="region"
69
+ aria-label={ariaLabel}
70
+ >
71
+ <p className="whitespace-pre-line text-[0.7rem] leading-snug text-zinc-700 dark:text-zinc-300">
72
+ {body.trim()}
73
+ </p>
74
+ </div>,
75
+ document.body
76
+ )
77
+ : null;
78
+
79
+ return (
80
+ <div
81
+ className={`relative flex shrink-0 items-center justify-center self-center ${compact ? "h-5 min-w-5" : "h-10"}`}
82
+ ref={triggerRef}
83
+ >
84
+ <button
85
+ type="button"
86
+ className={`text-zinc-500 hover:bg-zinc-200/90 hover:text-zinc-800 dark:hover:bg-zinc-800 dark:hover:text-zinc-300 ${compact ? "flex size-full items-center justify-center rounded-sm p-0" : "rounded-md p-1.5"}`}
87
+ aria-label={ariaLabel}
88
+ aria-expanded={open ? "true" : "false"}
89
+ aria-controls={`${id}-plain-help`}
90
+ onClick={() => setOpen((o) => !o)}
91
+ >
92
+ <CircleHelp size={icon} strokeWidth={1.75} aria-hidden />
93
+ </button>
94
+ {panel}
95
+ </div>
96
+ );
97
+ }
@@ -0,0 +1,68 @@
1
+ type ReportingTocEntry = Readonly<{ id: string; label: string }>;
2
+
3
+ export function ReportingPageTocMobile({
4
+ title,
5
+ ariaLabel,
6
+ entries,
7
+ }: Readonly<{
8
+ title: string;
9
+ ariaLabel: string;
10
+ entries: readonly ReportingTocEntry[];
11
+ }>) {
12
+ if (entries.length === 0) {
13
+ return null;
14
+ }
15
+ return (
16
+ <nav aria-label={ariaLabel} className="mb-6 lg:hidden">
17
+ <h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-zinc-500">{title}</h2>
18
+ <ul className="flex flex-wrap gap-2">
19
+ {entries.map((e) => (
20
+ <li key={e.id}>
21
+ <a
22
+ href={`#${e.id}`}
23
+ className="inline-block max-w-full rounded-md border border-zinc-300 bg-white px-2.5 py-1.5 text-xs leading-snug text-zinc-700 underline-offset-2 hover:border-violet-500/55 hover:text-violet-800 dark:border-zinc-700/90 dark:bg-zinc-900/80 dark:text-zinc-300 dark:hover:text-violet-200"
24
+ >
25
+ {e.label}
26
+ </a>
27
+ </li>
28
+ ))}
29
+ </ul>
30
+ </nav>
31
+ );
32
+ }
33
+
34
+ export function ReportingPageTocDesktop({
35
+ title,
36
+ ariaLabel,
37
+ entries,
38
+ }: Readonly<{
39
+ title: string;
40
+ ariaLabel: string;
41
+ entries: readonly ReportingTocEntry[];
42
+ }>) {
43
+ if (entries.length === 0) {
44
+ return null;
45
+ }
46
+ return (
47
+ <nav
48
+ aria-label={ariaLabel}
49
+ className="hidden lg:block lg:shrink-0 lg:self-start lg:border-l lg:border-zinc-200 lg:pl-5 xl:pl-6 dark:lg:border-zinc-800"
50
+ >
51
+ <div className="sticky top-28">
52
+ <h2 className="mb-3 text-xs font-semibold uppercase tracking-wide text-zinc-500">{title}</h2>
53
+ <ul className="space-y-0.5">
54
+ {entries.map((e) => (
55
+ <li key={e.id}>
56
+ <a
57
+ href={`#${e.id}`}
58
+ className="block rounded-r-md py-1.5 pl-2 text-sm leading-snug text-zinc-600 underline-offset-2 hover:bg-zinc-200/70 hover:text-violet-800 dark:text-zinc-400 dark:hover:bg-zinc-800/50 dark:hover:text-violet-200"
59
+ >
60
+ {e.label}
61
+ </a>
62
+ </li>
63
+ ))}
64
+ </ul>
65
+ </div>
66
+ </nav>
67
+ );
68
+ }
@@ -0,0 +1,342 @@
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 { markReportingTourCompleted } from "@/lib/dashboardTourStorage";
14
+ import type { DashboardStrings } from "@/lib/dashboardCopy";
15
+
16
+ const INTRO_SELECTOR = "#reporting-tour-anchor-intro";
17
+ const FILTERS_SELECTOR = "#report-filters";
18
+ const KPI_SELECTOR = "#report-summary-kpis";
19
+ const CHART_SELECTOR = "#report-chart-sessions";
20
+ const TAG_TIME_SELECTOR = "#report-tag-time";
21
+ /** Conteneur principal (contenu + sommaire) : repère stable sur tous les écrans. */
22
+ const TOC_LAYOUT_SELECTOR = "#reporting-tour-anchor-toc-layout";
23
+
24
+ const HOLE_PADDING_PX = 10;
25
+ const TOOLTIP_MAX_W = 360;
26
+ const VIEW_MARGIN = 12;
27
+ const TOOLTIP_GAP = 12;
28
+
29
+ function useEscapeDismiss(open: boolean, onDismiss: () => void) {
30
+ useEffect(() => {
31
+ if (!open) {
32
+ return;
33
+ }
34
+ const onKey = (e: KeyboardEvent) => {
35
+ if (e.key === "Escape") {
36
+ e.preventDefault();
37
+ onDismiss();
38
+ }
39
+ };
40
+ document.addEventListener("keydown", onKey);
41
+ return () => document.removeEventListener("keydown", onKey);
42
+ }, [open, onDismiss]);
43
+ }
44
+
45
+ type HoleRect = { top: number; left: number; width: number; height: number };
46
+
47
+ function expandRect(r: DOMRect, pad: number): HoleRect {
48
+ return {
49
+ top: r.top - pad,
50
+ left: r.left - pad,
51
+ width: r.width + 2 * pad,
52
+ height: r.height + 2 * pad,
53
+ };
54
+ }
55
+
56
+ export function ReportingTour({
57
+ open,
58
+ onOpenChange,
59
+ dt,
60
+ hasReportingChartData,
61
+ }: {
62
+ open: boolean;
63
+ onOpenChange: (open: boolean) => void;
64
+ dt: DashboardStrings;
65
+ /** Inclut les étapes graphiques et temps par étiquette (sinon elles sont omises). */
66
+ hasReportingChartData: boolean;
67
+ }) {
68
+ const [step, setStep] = useState(0);
69
+ const [hole, setHole] = useState<HoleRect | null>(null);
70
+ const [tipStyle, setTipStyle] = useState<CSSProperties>({});
71
+ const panelRef = useRef<HTMLDivElement>(null);
72
+ const primaryBtnRef = useRef<HTMLButtonElement>(null);
73
+
74
+ const { selectors, steps } = useMemo(() => {
75
+ const s = [
76
+ { title: dt.reportingTourStep1Title, body: dt.reportingTourStep1Body },
77
+ { title: dt.reportingTourStep2Title, body: dt.reportingTourStep2Body },
78
+ { title: dt.reportingTourStep3Title, body: dt.reportingTourStep3Body },
79
+ ];
80
+ const sel = [INTRO_SELECTOR, FILTERS_SELECTOR, KPI_SELECTOR];
81
+ if (hasReportingChartData) {
82
+ s.push(
83
+ { title: dt.reportingTourStep4Title, body: dt.reportingTourStep4Body },
84
+ { title: dt.reportingTourStep5Title, body: dt.reportingTourStep5Body }
85
+ );
86
+ sel.push(CHART_SELECTOR, TAG_TIME_SELECTOR);
87
+ }
88
+ s.push({ title: dt.reportingTourStep6Title, body: dt.reportingTourStep6Body });
89
+ sel.push(TOC_LAYOUT_SELECTOR);
90
+ return { selectors: sel, steps: s };
91
+ }, [dt, hasReportingChartData]);
92
+
93
+ const total = steps.length;
94
+ const last = step >= total - 1;
95
+ const current = steps[step] ?? steps[0];
96
+ const selector = selectors[Math.min(step, selectors.length - 1)];
97
+
98
+ useEffect(() => {
99
+ if (open) {
100
+ setStep(0);
101
+ }
102
+ }, [open]);
103
+
104
+ const finish = useCallback(() => {
105
+ markReportingTourCompleted();
106
+ onOpenChange(false);
107
+ }, [onOpenChange]);
108
+
109
+ useEscapeDismiss(open, finish);
110
+
111
+ const updateHoleFromDom = useCallback(() => {
112
+ if (!open) {
113
+ setHole(null);
114
+ return;
115
+ }
116
+ const el = document.querySelector(selector);
117
+ if (!el || !(el instanceof HTMLElement)) {
118
+ setHole(null);
119
+ return;
120
+ }
121
+ setHole(expandRect(el.getBoundingClientRect(), HOLE_PADDING_PX));
122
+ }, [open, selector]);
123
+
124
+ useLayoutEffect(() => {
125
+ if (!open) {
126
+ setHole(null);
127
+ return;
128
+ }
129
+ const el = document.querySelector(selector);
130
+ if (el instanceof HTMLElement) {
131
+ el.scrollIntoView({ block: "center", inline: "nearest", behavior: "auto" });
132
+ }
133
+ updateHoleFromDom();
134
+ const raf = requestAnimationFrame(updateHoleFromDom);
135
+ window.addEventListener("scroll", updateHoleFromDom, true);
136
+ window.addEventListener("resize", updateHoleFromDom);
137
+ const observed = el instanceof HTMLElement ? el : null;
138
+ const ro =
139
+ observed && typeof ResizeObserver !== "undefined"
140
+ ? new ResizeObserver(() => updateHoleFromDom())
141
+ : null;
142
+ if (observed && ro) {
143
+ ro.observe(observed);
144
+ }
145
+ return () => {
146
+ cancelAnimationFrame(raf);
147
+ window.removeEventListener("scroll", updateHoleFromDom, true);
148
+ window.removeEventListener("resize", updateHoleFromDom);
149
+ ro?.disconnect();
150
+ };
151
+ }, [open, selector, step, updateHoleFromDom]);
152
+
153
+ useLayoutEffect(() => {
154
+ if (!open) {
155
+ return;
156
+ }
157
+ const vw = typeof window !== "undefined" ? window.innerWidth : 1024;
158
+ const vh = typeof window !== "undefined" ? window.innerHeight : 768;
159
+ const w = Math.min(TOOLTIP_MAX_W, vw - 2 * VIEW_MARGIN);
160
+
161
+ if (!hole) {
162
+ setTipStyle({
163
+ position: "fixed",
164
+ top: "50%",
165
+ left: "50%",
166
+ transform: "translate(-50%, -50%)",
167
+ width: w,
168
+ maxWidth: "calc(100vw - 2rem)",
169
+ zIndex: 212,
170
+ });
171
+ return;
172
+ }
173
+
174
+ const panel = panelRef.current;
175
+ const ph = panel?.getBoundingClientRect().height ?? 220;
176
+
177
+ let top = hole.top + hole.height + TOOLTIP_GAP;
178
+ if (top + ph > vh - VIEW_MARGIN && hole.top - TOOLTIP_GAP - ph >= VIEW_MARGIN) {
179
+ top = hole.top - TOOLTIP_GAP - ph;
180
+ }
181
+ top = Math.max(VIEW_MARGIN, Math.min(top, vh - ph - VIEW_MARGIN));
182
+
183
+ let left = hole.left + hole.width / 2 - w / 2;
184
+ left = Math.max(VIEW_MARGIN, Math.min(left, vw - w - VIEW_MARGIN));
185
+
186
+ setTipStyle({
187
+ position: "fixed",
188
+ top,
189
+ left,
190
+ transform: undefined,
191
+ width: w,
192
+ maxWidth: undefined,
193
+ zIndex: 212,
194
+ });
195
+ }, [open, hole, step, current.title, current.body]);
196
+
197
+ useEffect(() => {
198
+ if (!open) {
199
+ return;
200
+ }
201
+ const t = window.setTimeout(() => primaryBtnRef.current?.focus(), 80);
202
+ return () => window.clearTimeout(t);
203
+ }, [open, step]);
204
+
205
+ if (!open) {
206
+ return null;
207
+ }
208
+
209
+ const progressLabel = dt.reportingTourProgressLabel
210
+ .replace("{n}", String(step + 1))
211
+ .replace("{total}", String(total));
212
+
213
+ const secondaryBtn =
214
+ "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";
215
+ const primaryBtn =
216
+ "rounded-lg bg-violet-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-violet-700 focus:outline-none focus:ring-2 focus:ring-violet-500 focus:ring-offset-2 dark:bg-violet-500 dark:hover:bg-violet-600 dark:focus:ring-offset-zinc-900";
217
+
218
+ const vw = typeof window !== "undefined" ? window.innerWidth : 0;
219
+ const vh = typeof window !== "undefined" ? window.innerHeight : 0;
220
+
221
+ const fullBackdrop = !hole ? (
222
+ <div className="fixed inset-0 z-[210] bg-black/55" aria-hidden />
223
+ ) : null;
224
+
225
+ const dimPanels =
226
+ hole && vw > 0 && vh > 0
227
+ ? (() => {
228
+ const { top: t, left: l, width: w, height: h } = hole;
229
+ const topH = Math.max(0, t);
230
+ const bottomTop = t + h;
231
+ const bottomH = Math.max(0, vh - bottomTop);
232
+ const leftW = Math.max(0, l);
233
+ const rightLeft = l + w;
234
+ const rightW = Math.max(0, vw - rightLeft);
235
+ return (
236
+ <>
237
+ <div
238
+ className="fixed bg-black/55"
239
+ style={{ top: 0, left: 0, width: vw, height: topH, zIndex: 210 }}
240
+ aria-hidden
241
+ />
242
+ <div
243
+ className="fixed bg-black/55"
244
+ style={{ top: bottomTop, left: 0, width: vw, height: bottomH, zIndex: 210 }}
245
+ aria-hidden
246
+ />
247
+ <div
248
+ className="fixed bg-black/55"
249
+ style={{ top: t, left: 0, width: leftW, height: h, zIndex: 210 }}
250
+ aria-hidden
251
+ />
252
+ <div
253
+ className="fixed bg-black/55"
254
+ style={{ top: t, left: rightLeft, width: rightW, height: h, zIndex: 210 }}
255
+ aria-hidden
256
+ />
257
+ <div
258
+ 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)]"
259
+ style={{
260
+ top: t,
261
+ left: l,
262
+ width: w,
263
+ height: h,
264
+ zIndex: 211,
265
+ }}
266
+ aria-hidden
267
+ />
268
+ </>
269
+ );
270
+ })()
271
+ : null;
272
+
273
+ const node = createPortal(
274
+ <>
275
+ {fullBackdrop}
276
+ {dimPanels}
277
+ <div
278
+ ref={panelRef}
279
+ role="dialog"
280
+ aria-modal="true"
281
+ aria-labelledby="reporting-tour-title"
282
+ aria-describedby="reporting-tour-body"
283
+ className="rounded-xl border border-zinc-300 bg-white shadow-2xl dark:border-zinc-600 dark:bg-zinc-900"
284
+ style={tipStyle}
285
+ onMouseDown={(e) => e.stopPropagation()}
286
+ >
287
+ <div className="border-b border-zinc-200 px-4 py-3 dark:border-zinc-700">
288
+ <p className="text-xs font-medium uppercase tracking-wide text-violet-600 dark:text-violet-300">
289
+ {progressLabel}
290
+ </p>
291
+ <h2 id="reporting-tour-title" className="mt-1 text-base font-semibold text-zinc-900 dark:text-zinc-100">
292
+ {current.title}
293
+ </h2>
294
+ </div>
295
+ <div id="reporting-tour-body" className="max-h-[min(42vh,18rem)] overflow-y-auto px-4 py-3">
296
+ <p className="whitespace-pre-wrap text-sm leading-relaxed text-zinc-700 dark:text-zinc-300">{current.body}</p>
297
+ </div>
298
+ <div className="flex flex-wrap items-center justify-between gap-2 border-t border-zinc-200 px-4 py-3 dark:border-zinc-700">
299
+ <div className="flex gap-1.5" role="presentation" aria-hidden>
300
+ {steps.map((_, i) => (
301
+ <span
302
+ key={i}
303
+ className={`h-2 w-2 rounded-full ${i === step ? "bg-violet-500 dark:bg-violet-400" : "bg-zinc-300 dark:bg-zinc-600"}`}
304
+ />
305
+ ))}
306
+ </div>
307
+ <div className="flex flex-wrap items-center justify-end gap-2">
308
+ <button
309
+ type="button"
310
+ className="text-sm text-zinc-500 underline-offset-2 hover:text-zinc-800 hover:underline dark:text-zinc-400 dark:hover:text-zinc-200"
311
+ onClick={finish}
312
+ >
313
+ {dt.tourSkipBtn}
314
+ </button>
315
+ {step > 0 ? (
316
+ <button type="button" className={secondaryBtn} onClick={() => setStep((s) => Math.max(0, s - 1))}>
317
+ {dt.tourBackBtn}
318
+ </button>
319
+ ) : null}
320
+ {last ? (
321
+ <button ref={primaryBtnRef} type="button" className={primaryBtn} onClick={finish}>
322
+ {dt.tourDoneBtn}
323
+ </button>
324
+ ) : (
325
+ <button
326
+ ref={primaryBtnRef}
327
+ type="button"
328
+ className={primaryBtn}
329
+ onClick={() => setStep((s) => Math.min(total - 1, s + 1))}
330
+ >
331
+ {dt.tourNextBtn}
332
+ </button>
333
+ )}
334
+ </div>
335
+ </div>
336
+ </div>
337
+ </>,
338
+ document.body
339
+ );
340
+
341
+ return node;
342
+ }