@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.
- package/README.md +81 -0
- package/app/api/action/route.ts +16 -0
- package/app/api/backup/route.ts +84 -0
- package/app/api/health/route.ts +22 -0
- package/app/api/state/route.ts +27 -0
- package/app/apple-icon.png +0 -0
- package/app/changelog/page.tsx +122 -0
- package/app/globals.css +210 -0
- package/app/guide/layout.tsx +11 -0
- package/app/guide/page.tsx +278 -0
- package/app/icon.png +0 -0
- package/app/layout.tsx +77 -0
- package/app/licenses/layout.tsx +11 -0
- package/app/licenses/page.tsx +246 -0
- package/app/manifest.ts +32 -0
- package/app/page.tsx +1610 -0
- package/app/reporting/page.tsx +2943 -0
- package/app/settings/layout.tsx +10 -0
- package/app/settings/page.tsx +3518 -0
- package/bin/kronosys.mjs +46 -0
- package/components/KronosysPackageVersionProvider.tsx +19 -0
- package/components/KronosysPayloadProvider.tsx +109 -0
- package/components/PwaRegister.tsx +25 -0
- package/components/SiteLegalFooter.tsx +21 -0
- package/components/ThemeProvider.tsx +78 -0
- package/components/dashboard/AppShellLiveSessionDrawer.tsx +394 -0
- package/components/dashboard/AppShellRouteNav.tsx +131 -0
- package/components/dashboard/AppVersionStamp.tsx +16 -0
- package/components/dashboard/DashboardCollapsibleSection.tsx +57 -0
- package/components/dashboard/DashboardColumnHintsBanner.tsx +159 -0
- package/components/dashboard/DashboardCommandCenter.tsx +470 -0
- package/components/dashboard/DashboardLangGateModal.tsx +118 -0
- package/components/dashboard/DashboardLoadingOverlay.tsx +42 -0
- package/components/dashboard/DashboardSimpleModal.tsx +337 -0
- package/components/dashboard/DashboardSuspenseFallback.tsx +52 -0
- package/components/dashboard/DashboardToastProvider.tsx +64 -0
- package/components/dashboard/DashboardTour.tsx +435 -0
- package/components/dashboard/DeferredDescriptionPopoverWrap.tsx +39 -0
- package/components/dashboard/DeleteSessionModal.tsx +130 -0
- package/components/dashboard/DescriptionTooltipPortaled.tsx +31 -0
- package/components/dashboard/GitIdentityQuickSetupModal.tsx +211 -0
- package/components/dashboard/HeaderIntegrationBadges.tsx +69 -0
- package/components/dashboard/InlineMetricHelpTrigger.tsx +102 -0
- package/components/dashboard/IssuePickerModal.tsx +168 -0
- package/components/dashboard/KronoFocusPanel.tsx +834 -0
- package/components/dashboard/KronosysDatetimePopoverField.tsx +357 -0
- package/components/dashboard/KronosysTimePopoverField.tsx +233 -0
- package/components/dashboard/LanguageMenu.tsx +123 -0
- package/components/dashboard/MongoMirrorSyncLine.tsx +57 -0
- package/components/dashboard/NewSessionScopeModal.tsx +410 -0
- package/components/dashboard/PageRefreshButton.tsx +130 -0
- package/components/dashboard/PlainHelpPopover.tsx +97 -0
- package/components/dashboard/ReportingPageToc.tsx +68 -0
- package/components/dashboard/ReportingTour.tsx +342 -0
- package/components/dashboard/SavedProjectPicker.tsx +92 -0
- package/components/dashboard/SavedTagPicker.tsx +115 -0
- package/components/dashboard/ScrollToTopFab.tsx +41 -0
- package/components/dashboard/SelectedSessionSidebarBlock.tsx +630 -0
- package/components/dashboard/SessionEndReasonEditor.tsx +114 -0
- package/components/dashboard/SessionListPanel.tsx +320 -0
- package/components/dashboard/SessionLocMetricsSection.tsx +128 -0
- package/components/dashboard/SettingsTagsProjectsSection.tsx +993 -0
- package/components/dashboard/SettingsTour.tsx +332 -0
- package/components/dashboard/TagPills.tsx +149 -0
- package/components/dashboard/TagsHelpTrigger.tsx +84 -0
- package/components/dashboard/TaskFocusPanel.tsx +1261 -0
- package/components/dashboard/TaskSessionLiveCard.tsx +832 -0
- package/components/dashboard/TaskSubtasksBlock.tsx +748 -0
- package/components/dashboard/ThemeToggle.test.tsx +26 -0
- package/components/dashboard/ThemeToggle.tsx +36 -0
- package/components/dashboard/UserGuideBodyText.tsx +62 -0
- package/components/dashboard/WorkspaceGitRepoCard.tsx +191 -0
- package/components/dashboard/taskFieldStyles.ts +139 -0
- package/components/dashboard/useAnchoredFloatingPortalStyle.ts +71 -0
- package/components/dashboard/useDescriptionPopoverAfterMs.ts +220 -0
- package/components/dashboard/useKronoFocusLiveSeconds.ts +36 -0
- package/components/dashboard/useSmoothStopwatchMs.ts +25 -0
- package/lib/appShellHeaderClasses.ts +12 -0
- package/lib/backupCsvExport.test.ts +149 -0
- package/lib/backupCsvExport.ts +392 -0
- package/lib/changelogCopy.ts +34 -0
- package/lib/concurrentTaskStartPreference.ts +29 -0
- package/lib/dashboardClockFormat.ts +13 -0
- package/lib/dashboardColumnChrome.ts +3 -0
- package/lib/dashboardColumnHintsStorage.ts +57 -0
- package/lib/dashboardCopy.ts +1831 -0
- package/lib/dashboardDetachedUrlHintStorage.ts +24 -0
- package/lib/dashboardGitIdentityBannerStorage.ts +36 -0
- package/lib/dashboardLangStorage.ts +72 -0
- package/lib/dashboardQuickSearch.ts +476 -0
- package/lib/dashboardQuickSearchQuery.test.ts +63 -0
- package/lib/dashboardQuickSearchQuery.ts +179 -0
- package/lib/dashboardSessionNav.ts +33 -0
- package/lib/dashboardShortcuts.ts +268 -0
- package/lib/dashboardTimeZone.ts +91 -0
- package/lib/dashboardTourStorage.ts +68 -0
- package/lib/dataDir.test.ts +87 -0
- package/lib/dataDir.ts +83 -0
- package/lib/devDataPreferenceFile.ts +55 -0
- package/lib/devDataRuntimeInfo.ts +34 -0
- package/lib/formatIsoShort.test.ts +46 -0
- package/lib/formatIsoShort.ts +29 -0
- package/lib/generatedUserChangelog.ts +34 -0
- package/lib/gitlabIssueSearch.ts +8 -0
- package/lib/kronoFocusDurationHistory.ts +71 -0
- package/lib/kronoFocusRhythm.test.ts +130 -0
- package/lib/kronoFocusRhythm.ts +46 -0
- package/lib/kronoFocusTimerUrgency.test.ts +74 -0
- package/lib/kronoFocusTimerUrgency.ts +24 -0
- package/lib/kronosysApi.ts +143 -0
- package/lib/legacyEditorPayloadKeys.ts +52 -0
- package/lib/legacyKronoFocusStorageKeys.test.ts +29 -0
- package/lib/legacyKronoFocusStorageKeys.ts +32 -0
- package/lib/licensesCopy.ts +128 -0
- package/lib/openPlainTextInNewTab.ts +49 -0
- package/lib/readKronosysPackageVersion.ts +10 -0
- package/lib/reportingAggregate.test.ts +325 -0
- package/lib/reportingAggregate.ts +819 -0
- package/lib/reportingDatePresets.ts +41 -0
- package/lib/reportingMetricHelp.ts +430 -0
- package/lib/reportingNonFinalIndicators.test.ts +157 -0
- package/lib/reportingNonFinalIndicators.ts +102 -0
- package/lib/reportingStrings.ts +491 -0
- package/lib/reportingTagWeekBreakdown.test.ts +141 -0
- package/lib/reportingTagWeekBreakdown.ts +181 -0
- package/lib/reportingWeekLayout.test.ts +239 -0
- package/lib/reportingWeekLayout.ts +313 -0
- package/lib/sessionAssiduity.test.ts +25 -0
- package/lib/sessionAssiduity.ts +33 -0
- package/lib/sessionEndReason.ts +55 -0
- package/lib/sessionEndWarnings.test.ts +200 -0
- package/lib/sessionEndWarnings.ts +125 -0
- package/lib/sessionListMerge.test.ts +101 -0
- package/lib/sessionListMerge.ts +70 -0
- package/lib/sessionTaskSidebarStats.test.ts +24 -0
- package/lib/sessionTaskSidebarStats.ts +54 -0
- package/lib/settingsCopy.ts +1276 -0
- package/lib/taskParsing.test.ts +153 -0
- package/lib/taskParsing.ts +737 -0
- package/lib/theme.ts +15 -0
- package/lib/translucentButtonClasses.ts +34 -0
- package/lib/usageProfile.test.ts +84 -0
- package/lib/usageProfile.ts +52 -0
- package/lib/userGuideCopy.ts +464 -0
- package/lib/workspaceLocDefaults.ts +21 -0
- package/next-env.d.ts +6 -0
- package/next.config.ts +15 -0
- package/package.json +87 -0
- package/postcss.config.mjs +12 -0
- package/public/apple-icon.png +0 -0
- package/public/favicon.ico +0 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/icon-192.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/icon.png +0 -0
- package/public/next.svg +1 -0
- package/public/sw.js +13 -0
- package/public/traceback.png +0 -0
- package/public/vercel.svg +1 -0
- package/public/window.svg +1 -0
- package/server/actionDispatch.test.ts +723 -0
- package/server/actionDispatch.ts +1476 -0
- package/server/actionTaskSession.test.ts +713 -0
- package/server/actionTaskSession.ts +717 -0
- package/server/db.ts +42 -0
- package/server/defaultCfg.ts +87 -0
- package/server/gitlabTokenStore.ts +34 -0
- package/server/kronoFocusHydrate.test.ts +142 -0
- package/server/kronoFocusHydrate.ts +69 -0
- package/server/kronoFocusMigrate.test.ts +53 -0
- package/server/kronoFocusMigrate.ts +78 -0
- package/server/mainTimerHydrate.test.ts +65 -0
- package/server/mainTimerHydrate.ts +53 -0
- package/server/payloadStore.test.ts +78 -0
- package/server/payloadStore.ts +83 -0
- package/server/sessionWallHydrate.test.ts +46 -0
- package/server/sessionWallHydrate.ts +88 -0
- 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
|
+
}
|