@nightkatana/kronosys-app 1.0.0-beta.21 → 1.0.0-beta.22
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 +1 -1
- package/app/changelog/page.tsx +87 -19
- package/app/globals.css +10 -8
- package/app/guide/page.tsx +71 -34
- package/app/implementation/page.tsx +70 -60
- package/app/licenses/page.tsx +79 -47
- package/app/logs/page.tsx +103 -47
- package/app/page.tsx +104 -169
- package/app/reporting/page.tsx +1918 -1436
- package/app/settings/page.tsx +66 -44
- package/components/KronosysPayloadProvider.tsx +19 -5
- package/components/dashboard/AppShellHeaderKronoFocus.tsx +78 -0
- package/components/dashboard/AppShellHeaderToolbarLayout.tsx +36 -0
- package/components/dashboard/AppShellHeaderUtilityRibbon.tsx +19 -0
- package/components/dashboard/AppShellHeaderWallClock.tsx +23 -17
- package/components/dashboard/AppShellRouteNav.tsx +336 -209
- package/components/dashboard/AppShellToolbarCommandCenter.tsx +225 -0
- package/components/dashboard/AppShellToolbarRouteNav.tsx +204 -0
- package/components/dashboard/DashboardCommandCenter.tsx +119 -30
- package/components/dashboard/KronoFocusPanel.tsx +287 -260
- package/components/dashboard/LanguageMenu.tsx +23 -7
- package/components/dashboard/PageRefreshButton.tsx +42 -16
- package/components/dashboard/ReportingTour.tsx +20 -2
- package/components/dashboard/SessionListPanel.tsx +4 -4
- package/components/dashboard/ThemeToggle.tsx +4 -3
- package/components/dashboard/useAnchoredFloatingPortalStyle.ts +9 -2
- package/components/dashboard/useKronoFocusLiveSeconds.ts +4 -2
- package/lib/appShellHeaderClasses.ts +22 -3
- package/lib/appShellToolbarChrome.ts +112 -0
- package/lib/appShellToolbarDeferredIntents.ts +112 -0
- package/lib/appShellToolbarSessionSlices.ts +67 -0
- package/lib/dashboardCopy.ts +78 -29
- package/lib/dashboardQuickSearch.ts +37 -6
- package/lib/dashboardUrlSession.ts +36 -0
- package/lib/generatedUserChangelog.ts +14 -0
- package/lib/implementationNotes.ts +18 -14
- package/lib/reportingAggregate.ts +68 -9
- package/lib/reportingMetricHelp.ts +8 -8
- package/lib/reportingStrings.ts +118 -9
- package/lib/reportingTagWeekBreakdown.ts +55 -13
- package/lib/settingsCopy.ts +6 -7
- package/lib/userGuideCopy.ts +29 -26
- package/package.json +7 -5
- package/server/db.ts +6 -4
- package/server/dbSchema.ts +2 -2
- package/components/dashboard/AppShellCommandCenterPlaceholder.tsx +0 -17
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
import { useEffect, useId, useRef, useState } from "react";
|
|
4
4
|
import { Check, ChevronDown, Globe } from "lucide-react";
|
|
5
5
|
import type { Lang } from "@/lib/dashboardCopy";
|
|
6
|
+
import {
|
|
7
|
+
appShellToolbarRaisedLangTriggerClosedClass,
|
|
8
|
+
appShellToolbarRaisedLangTriggerOpenClass,
|
|
9
|
+
} from "@/lib/appShellToolbarChrome";
|
|
6
10
|
|
|
7
11
|
const LANGS: Lang[] = ["en", "fr"];
|
|
8
12
|
|
|
@@ -55,22 +59,29 @@ export function LanguageMenu({
|
|
|
55
59
|
<div className="relative inline-flex shrink-0" ref={rootRef}>
|
|
56
60
|
<button
|
|
57
61
|
type="button"
|
|
58
|
-
className={
|
|
62
|
+
className={
|
|
59
63
|
open
|
|
60
|
-
?
|
|
61
|
-
:
|
|
62
|
-
}
|
|
64
|
+
? appShellToolbarRaisedLangTriggerOpenClass
|
|
65
|
+
: appShellToolbarRaisedLangTriggerClosedClass
|
|
66
|
+
}
|
|
63
67
|
aria-label={triggerAriaLabel}
|
|
64
68
|
aria-haspopup="menu"
|
|
65
69
|
aria-expanded={open ? "true" : "false"}
|
|
66
70
|
aria-controls={menuId}
|
|
67
71
|
onClick={() => setOpen((o) => !o)}
|
|
68
72
|
>
|
|
69
|
-
<Globe
|
|
73
|
+
<Globe
|
|
74
|
+
size={18}
|
|
75
|
+
className="shrink-0 text-violet-600 dark:text-violet-400/90"
|
|
76
|
+
strokeWidth={1.75}
|
|
77
|
+
aria-hidden
|
|
78
|
+
/>
|
|
70
79
|
<span className="max-w-36 truncate font-medium">{currentLabel}</span>
|
|
71
80
|
<ChevronDown
|
|
72
81
|
size={18}
|
|
73
|
-
className={`shrink-0 text-zinc-600 transition-transform duration-200 dark:text-zinc-500 ${
|
|
82
|
+
className={`shrink-0 text-zinc-600 transition-transform duration-200 dark:text-zinc-500 ${
|
|
83
|
+
open ? "rotate-180" : ""
|
|
84
|
+
}`}
|
|
74
85
|
strokeWidth={2}
|
|
75
86
|
aria-hidden
|
|
76
87
|
/>
|
|
@@ -108,7 +119,12 @@ export function LanguageMenu({
|
|
|
108
119
|
>
|
|
109
120
|
<span className="font-medium">{labelFor(code)}</span>
|
|
110
121
|
{selected ? (
|
|
111
|
-
<Check
|
|
122
|
+
<Check
|
|
123
|
+
size={16}
|
|
124
|
+
className="shrink-0 text-violet-600 dark:text-violet-400"
|
|
125
|
+
strokeWidth={2.5}
|
|
126
|
+
aria-hidden
|
|
127
|
+
/>
|
|
112
128
|
) : (
|
|
113
129
|
<span className="h-4 w-4 shrink-0" aria-hidden />
|
|
114
130
|
)}
|
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
import { Loader2, RotateCw } from "lucide-react";
|
|
4
4
|
import { useEffect, useRef, useState } from "react";
|
|
5
|
+
import { createPortal } from "react-dom";
|
|
6
|
+
import { appShellToolbarIconLinkClass } from "@/lib/appShellToolbarChrome";
|
|
7
|
+
import { useAnchoredFloatingPortalStyle } from "@/components/dashboard/useAnchoredFloatingPortalStyle";
|
|
5
8
|
|
|
6
9
|
export type PageRefreshInlineMessages = {
|
|
7
10
|
loading: string;
|
|
@@ -30,7 +33,11 @@ export function PageRefreshButton({
|
|
|
30
33
|
}) {
|
|
31
34
|
const [loading, setLoading] = useState(false);
|
|
32
35
|
const [bannerPhase, setBannerPhase] = useState<BannerPhase>("idle");
|
|
33
|
-
const
|
|
36
|
+
const buttonRef = useRef<HTMLButtonElement>(null);
|
|
37
|
+
const bannerRef = useRef<HTMLDivElement>(null);
|
|
38
|
+
const clearBannerTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
|
|
39
|
+
null,
|
|
40
|
+
);
|
|
34
41
|
|
|
35
42
|
useEffect(
|
|
36
43
|
() => () => {
|
|
@@ -38,7 +45,7 @@ export function PageRefreshButton({
|
|
|
38
45
|
clearTimeout(clearBannerTimerRef.current);
|
|
39
46
|
}
|
|
40
47
|
},
|
|
41
|
-
[]
|
|
48
|
+
[],
|
|
42
49
|
);
|
|
43
50
|
|
|
44
51
|
const scheduleBannerClear = () => {
|
|
@@ -92,18 +99,28 @@ export function PageRefreshButton({
|
|
|
92
99
|
}
|
|
93
100
|
}
|
|
94
101
|
|
|
95
|
-
const showBanner = Boolean(
|
|
102
|
+
const showBanner = Boolean(
|
|
103
|
+
inlineMessages && bannerPhase !== "idle" && bannerLabel,
|
|
104
|
+
);
|
|
96
105
|
|
|
97
106
|
const bannerCls =
|
|
98
107
|
bannerPhase === "loading" || bannerPhase === "success"
|
|
99
108
|
? "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
109
|
: "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
110
|
|
|
111
|
+
const bannerStyle = useAnchoredFloatingPortalStyle(
|
|
112
|
+
showBanner,
|
|
113
|
+
buttonRef,
|
|
114
|
+
bannerRef,
|
|
115
|
+
{ align: "end", maxWidthRem: 32 },
|
|
116
|
+
);
|
|
117
|
+
|
|
102
118
|
return (
|
|
103
|
-
|
|
119
|
+
<>
|
|
104
120
|
<button
|
|
121
|
+
ref={buttonRef}
|
|
105
122
|
type="button"
|
|
106
|
-
className={
|
|
123
|
+
className={`${appShellToolbarIconLinkClass} disabled:cursor-wait disabled:opacity-50 ${className}`}
|
|
107
124
|
onClick={() => void handleRefresh()}
|
|
108
125
|
disabled={loading}
|
|
109
126
|
title={title}
|
|
@@ -111,20 +128,29 @@ export function PageRefreshButton({
|
|
|
111
128
|
aria-busy={loading ? "true" : "false"}
|
|
112
129
|
>
|
|
113
130
|
{loading ? (
|
|
114
|
-
<Loader2
|
|
131
|
+
<Loader2
|
|
132
|
+
size={18}
|
|
133
|
+
className="animate-spin text-violet-600 dark:text-violet-400"
|
|
134
|
+
aria-hidden
|
|
135
|
+
/>
|
|
115
136
|
) : (
|
|
116
137
|
<RotateCw size={18} aria-hidden />
|
|
117
138
|
)}
|
|
118
139
|
</button>
|
|
119
|
-
{showBanner &&
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
140
|
+
{showBanner && typeof document !== "undefined"
|
|
141
|
+
? createPortal(
|
|
142
|
+
<div
|
|
143
|
+
ref={bannerRef}
|
|
144
|
+
role="status"
|
|
145
|
+
aria-live="polite"
|
|
146
|
+
style={bannerStyle}
|
|
147
|
+
className={`pointer-events-none whitespace-normal rounded-md px-2.5 py-1.5 text-right text-[0.8125rem] font-medium leading-snug ${bannerCls}`}
|
|
148
|
+
>
|
|
149
|
+
{bannerLabel}
|
|
150
|
+
</div>,
|
|
151
|
+
document.body,
|
|
152
|
+
)
|
|
153
|
+
: null}
|
|
154
|
+
</>
|
|
129
155
|
);
|
|
130
156
|
}
|
|
@@ -19,6 +19,7 @@ const FILTERS_SELECTOR = "#report-filters";
|
|
|
19
19
|
const KPI_SELECTOR = "#report-summary-kpis";
|
|
20
20
|
const CHART_SELECTOR = "#report-chart-sessions";
|
|
21
21
|
const TAG_TIME_SELECTOR = "#report-tag-time";
|
|
22
|
+
const PROJECT_SECTION_SELECTOR = "#report-projects";
|
|
22
23
|
/** Conteneur principal (contenu + sommaire) : repère stable sur tous les écrans. */
|
|
23
24
|
const TOC_LAYOUT_SELECTOR = "#reporting-tour-anchor-toc-layout";
|
|
24
25
|
|
|
@@ -57,11 +58,14 @@ function expandRect(r: DOMRect, pad: number): HoleRect {
|
|
|
57
58
|
export function ReportingTour({
|
|
58
59
|
open,
|
|
59
60
|
onOpenChange,
|
|
61
|
+
onStepChange,
|
|
60
62
|
dt,
|
|
61
63
|
hasReportingChartData,
|
|
62
64
|
}: {
|
|
63
65
|
open: boolean;
|
|
64
66
|
onOpenChange: (open: boolean) => void;
|
|
67
|
+
/** When the spotlight step changes (tour open), parent can sync tabs / layout. */
|
|
68
|
+
onStepChange?: (stepIndex: number) => void;
|
|
65
69
|
dt: DashboardStrings;
|
|
66
70
|
/** Inclut les étapes graphiques et temps par étiquette (sinon elles sont omises). */
|
|
67
71
|
hasReportingChartData: boolean;
|
|
@@ -82,9 +86,16 @@ export function ReportingTour({
|
|
|
82
86
|
if (hasReportingChartData) {
|
|
83
87
|
s.push(
|
|
84
88
|
{ title: dt.reportingTourStep4Title, body: dt.reportingTourStep4Body },
|
|
85
|
-
{
|
|
89
|
+
{
|
|
90
|
+
title: dt.reportingTourStep5aTitle,
|
|
91
|
+
body: dt.reportingTourStep5aBody,
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
title: dt.reportingTourStep5bTitle,
|
|
95
|
+
body: dt.reportingTourStep5bBody,
|
|
96
|
+
},
|
|
86
97
|
);
|
|
87
|
-
sel.push(CHART_SELECTOR, TAG_TIME_SELECTOR);
|
|
98
|
+
sel.push(CHART_SELECTOR, TAG_TIME_SELECTOR, PROJECT_SECTION_SELECTOR);
|
|
88
99
|
}
|
|
89
100
|
s.push({
|
|
90
101
|
title: dt.reportingTourStep6Title,
|
|
@@ -105,6 +116,13 @@ export function ReportingTour({
|
|
|
105
116
|
}
|
|
106
117
|
}, [open]);
|
|
107
118
|
|
|
119
|
+
useEffect(() => {
|
|
120
|
+
if (!open) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
onStepChange?.(step);
|
|
124
|
+
}, [open, step, onStepChange]);
|
|
125
|
+
|
|
108
126
|
const finish = useCallback(
|
|
109
127
|
(reason: "skip" | "done" | "escape") => {
|
|
110
128
|
void postKronosysAction({
|
|
@@ -158,10 +158,6 @@ export function SessionListPanel({
|
|
|
158
158
|
forcePageScroll?: boolean;
|
|
159
159
|
}) {
|
|
160
160
|
const sessionRowExitDoneRef = useRef<(() => void) | undefined>(undefined);
|
|
161
|
-
useLayoutEffect(() => {
|
|
162
|
-
sessionRowExitDoneRef.current = onSessionRowExitAnimationDone;
|
|
163
|
-
}, [onSessionRowExitAnimationDone]);
|
|
164
|
-
|
|
165
161
|
/** Double frame pour garantir une transition depuis l’état initial. */
|
|
166
162
|
const [exitStyleSessionId, setExitStyleSessionId] = useState<string | null>(
|
|
167
163
|
null,
|
|
@@ -174,6 +170,10 @@ export function SessionListPanel({
|
|
|
174
170
|
>(null);
|
|
175
171
|
const [listNowMs, setListNowMs] = useState(() => Date.now());
|
|
176
172
|
|
|
173
|
+
useLayoutEffect(() => {
|
|
174
|
+
sessionRowExitDoneRef.current = onSessionRowExitAnimationDone;
|
|
175
|
+
}, [onSessionRowExitAnimationDone]);
|
|
176
|
+
|
|
177
177
|
useEffect(() => {
|
|
178
178
|
if (!sessionRowExitAnimateId) {
|
|
179
179
|
setExitStyleSessionId(null);
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { Moon, Sun } from "lucide-react";
|
|
4
4
|
import { useKronosysTheme } from "@/components/ThemeProvider";
|
|
5
|
+
import { appShellToolbarIconLinkClass } from "@/lib/appShellToolbarChrome";
|
|
5
6
|
|
|
6
7
|
export function ThemeToggle({
|
|
7
8
|
lang,
|
|
@@ -18,13 +19,13 @@ export function ThemeToggle({
|
|
|
18
19
|
? "Passer au thème clair"
|
|
19
20
|
: "Passer au thème foncé"
|
|
20
21
|
: isDark
|
|
21
|
-
|
|
22
|
-
|
|
22
|
+
? "Switch to light theme"
|
|
23
|
+
: "Switch to dark theme";
|
|
23
24
|
|
|
24
25
|
return (
|
|
25
26
|
<button
|
|
26
27
|
type="button"
|
|
27
|
-
className={
|
|
28
|
+
className={`${appShellToolbarIconLinkClass} disabled:cursor-wait disabled:opacity-50 ${className}`}
|
|
28
29
|
aria-label={aria}
|
|
29
30
|
title={aria}
|
|
30
31
|
onClick={() => toggleTheme()}
|
|
@@ -5,7 +5,7 @@ import { useLayoutEffect, useState, type CSSProperties, type RefObject } from "r
|
|
|
5
5
|
const VIEW_MARGIN = 10;
|
|
6
6
|
const GAP_PX = 4;
|
|
7
7
|
|
|
8
|
-
export type AnchoredFloatingAlign = "start" | "end";
|
|
8
|
+
export type AnchoredFloatingAlign = "start" | "end" | "center";
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* Position `fixed` pour un panneau en portail (évite le clip des ancêtres `overflow-*`
|
|
@@ -34,7 +34,14 @@ export function useAnchoredFloatingPortalStyle(
|
|
|
34
34
|
const vw = globalThis.innerWidth;
|
|
35
35
|
const vh = globalThis.innerHeight;
|
|
36
36
|
const w = Math.min(opts.maxWidthRem * 16, vw - 2 * VIEW_MARGIN);
|
|
37
|
-
let left
|
|
37
|
+
let left: number;
|
|
38
|
+
if (opts.align === "end") {
|
|
39
|
+
left = r.right - w;
|
|
40
|
+
} else if (opts.align === "center") {
|
|
41
|
+
left = r.left + r.width / 2 - w / 2;
|
|
42
|
+
} else {
|
|
43
|
+
left = r.left;
|
|
44
|
+
}
|
|
38
45
|
left = Math.max(VIEW_MARGIN, Math.min(left, vw - w - VIEW_MARGIN));
|
|
39
46
|
let top = r.bottom + GAP_PX;
|
|
40
47
|
const panel = panelRef.current;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Affiche les secondes restantes du KronoFocus en phase « running » à partir de
|
|
@@ -8,6 +8,8 @@ import { useLayoutEffect, useState } from "react";
|
|
|
8
8
|
* entre deux réponses API. Sans échéance (données héritées), on retombe sur `serverSecs`.
|
|
9
9
|
*
|
|
10
10
|
* `Date.now()` vit dans un effet pour respecter react-hooks/purity (pas d’horloge en rendu).
|
|
11
|
+
* `useEffect` (et non `useLayoutEffect`) évite une mise à jour d’état synchrone pendant la phase layout,
|
|
12
|
+
* source d’avertissements avec React 19 lorsque le parent / Suspense n’a pas fini de monter.
|
|
11
13
|
*/
|
|
12
14
|
export function useKronoFocusLiveSeconds(
|
|
13
15
|
serverSecs: number,
|
|
@@ -16,7 +18,7 @@ export function useKronoFocusLiveSeconds(
|
|
|
16
18
|
): number {
|
|
17
19
|
const [fromDeadlineSecs, setFromDeadlineSecs] = useState<number | null>(null);
|
|
18
20
|
|
|
19
|
-
|
|
21
|
+
useEffect(() => {
|
|
20
22
|
if (status !== "running" || typeof deadlineMs !== "number" || !Number.isFinite(deadlineMs)) {
|
|
21
23
|
setFromDeadlineSecs(null);
|
|
22
24
|
return;
|
|
@@ -18,8 +18,27 @@ export const appShellHeaderTitleMetaRowClassName =
|
|
|
18
18
|
"mb-3 flex w-full flex-col gap-3 sm:mb-4 sm:flex-row sm:items-start sm:justify-between sm:gap-6";
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
|
-
*
|
|
22
|
-
*
|
|
21
|
+
* Rangée d’outils : conteneur pleine largeur. Aucun défilement horizontal — si la fenêtre est plus
|
|
22
|
+
* étroite que le bloc regroupé, ses segments passent à la ligne (voir `*ClusterClassName`).
|
|
23
23
|
*/
|
|
24
24
|
export const appShellHeaderToolbarClassName =
|
|
25
|
-
"flex min-
|
|
25
|
+
"flex w-full min-w-0 shrink-0";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Bloc regroupé occupant toute la largeur disponible : horloge, KronoFocus, recherche, navigation,
|
|
29
|
+
* utilitaires. Le centrage horizontal est porté par le cluster lui-même afin que chaque rangée soit
|
|
30
|
+
* centrée, qu’il y en ait une seule ou plusieurs après basculement (`flex-wrap`).
|
|
31
|
+
*/
|
|
32
|
+
export const appShellHeaderToolbarClusterClassName =
|
|
33
|
+
"flex w-full min-w-0 flex-wrap items-center justify-center gap-2";
|
|
34
|
+
|
|
35
|
+
/** Segment gauche du cluster (horloge, KronoFocus, commandes). */
|
|
36
|
+
export const appShellHeaderToolbarLeadingClassName =
|
|
37
|
+
"flex min-w-0 shrink-0 flex-row flex-nowrap items-center gap-2";
|
|
38
|
+
|
|
39
|
+
/** Navigation (icônes de routes) au sein du cluster. */
|
|
40
|
+
export const appShellHeaderToolbarNavClassName = "relative shrink-0";
|
|
41
|
+
|
|
42
|
+
/** Segment droit du cluster (thème, langue, etc.). */
|
|
43
|
+
export const appShellHeaderToolbarTrailingClassName =
|
|
44
|
+
"flex shrink-0 flex-row flex-nowrap items-center gap-2";
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Styles « ruban » de l’en-tête : alvéole (tray) et boutons en relief alignés sur
|
|
3
|
+
* {@link AppShellRouteNav}.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Alvéole : groupe de contrôles dans un creux (`py` = espacement vertical intérieur). */
|
|
7
|
+
export const appShellToolbarRibbonGroupClass =
|
|
8
|
+
"inline-flex shrink-0 flex-nowrap items-center gap-x-2 rounded-lg border border-zinc-300/60 bg-zinc-200/50 px-1.5 py-2 " +
|
|
9
|
+
"shadow-[inset_0_2px_6px_rgba(15,23,42,0.1),inset_0_1px_2px_rgba(15,23,42,0.06),inset_0_-1px_0_rgba(255,255,255,0.35)] " +
|
|
10
|
+
"dark:border-zinc-800 dark:bg-zinc-950/65 " +
|
|
11
|
+
"dark:shadow-[inset_0_2px_10px_rgba(0,0,0,0.65),inset_0_1px_0_rgba(255,255,255,0.05)]";
|
|
12
|
+
|
|
13
|
+
/** Variante alvéole large (ex. KronoFocus en-tête) : même respiration verticale. */
|
|
14
|
+
export const appShellToolbarRibbonTrayWideClass =
|
|
15
|
+
"inline-flex min-h-0 min-w-0 max-w-full shrink-0 flex-nowrap items-center gap-x-2 overflow-x-auto rounded-lg border border-zinc-300/60 bg-zinc-200/50 px-1.5 py-2 " +
|
|
16
|
+
"shadow-[inset_0_2px_6px_rgba(15,23,42,0.1),inset_0_1px_2px_rgba(15,23,42,0.06),inset_0_-1px_0_rgba(255,255,255,0.35)] " +
|
|
17
|
+
"dark:border-zinc-800 dark:bg-zinc-950/65 " +
|
|
18
|
+
"dark:shadow-[inset_0_2px_10px_rgba(0,0,0,0.65),inset_0_1px_0_rgba(255,255,255,0.05)]";
|
|
19
|
+
|
|
20
|
+
/** Cellule relief h10 (libellé de phase, compteur) : même chrome que les boutons icône. */
|
|
21
|
+
export const appShellToolbarInsetCellH10Class =
|
|
22
|
+
"inline-flex h-10 min-h-10 shrink-0 items-center justify-center rounded-lg border border-zinc-300/90 bg-gradient-to-b from-white to-zinc-100 " +
|
|
23
|
+
"shadow-[0_1px_2px_rgba(15,23,42,0.12),0_2px_3px_rgba(15,23,42,0.06),inset_0_1px_0_rgba(255,255,255,0.9)] " +
|
|
24
|
+
"dark:border-zinc-500/85 dark:from-zinc-600 dark:to-zinc-800 " +
|
|
25
|
+
"dark:shadow-[0_1px_2px_rgba(0,0,0,0.5),inset_0_1px_0_rgba(255,255,255,0.14)]";
|
|
26
|
+
|
|
27
|
+
/** Variante cliquable (compteur KronoFocus, etc.). */
|
|
28
|
+
export const appShellToolbarInsetCellH10ButtonClass =
|
|
29
|
+
`${appShellToolbarInsetCellH10Class} ` +
|
|
30
|
+
"cursor-pointer outline-none transition hover:border-zinc-400 hover:from-zinc-50 hover:to-zinc-100 hover:shadow-[0_2px_4px_rgba(15,23,42,0.12),inset_0_1px_0_rgba(255,255,255,0.95)] " +
|
|
31
|
+
"active:translate-y-px active:shadow-[inset_0_2px_3px_rgba(15,23,42,0.1)] " +
|
|
32
|
+
"dark:hover:border-zinc-400 dark:hover:from-zinc-600 dark:hover:to-zinc-800 dark:active:shadow-[inset_0_2px_5px_rgba(0,0,0,0.45)] " +
|
|
33
|
+
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-violet-500/55";
|
|
34
|
+
|
|
35
|
+
export const appShellToolbarIconLinkClass =
|
|
36
|
+
"inline-flex size-10 items-center justify-center rounded-lg border border-zinc-300/90 bg-gradient-to-b from-white to-zinc-100 text-zinc-700 " +
|
|
37
|
+
"shadow-[0_1px_2px_rgba(15,23,42,0.12),0_2px_3px_rgba(15,23,42,0.06),inset_0_1px_0_rgba(255,255,255,0.9)] " +
|
|
38
|
+
"transition hover:border-zinc-400 hover:from-zinc-50 hover:to-zinc-100 hover:shadow-[0_2px_4px_rgba(15,23,42,0.12),inset_0_1px_0_rgba(255,255,255,0.95)] " +
|
|
39
|
+
"active:translate-y-px active:shadow-[inset_0_2px_3px_rgba(15,23,42,0.1)] " +
|
|
40
|
+
"dark:border-zinc-500/85 dark:from-zinc-600 dark:to-zinc-800 dark:text-zinc-100 " +
|
|
41
|
+
"dark:shadow-[0_1px_2px_rgba(0,0,0,0.5),inset_0_1px_0_rgba(255,255,255,0.14)] " +
|
|
42
|
+
"dark:hover:border-zinc-400 dark:hover:from-zinc-600 dark:hover:to-zinc-800 " +
|
|
43
|
+
"dark:active:shadow-[inset_0_2px_5px_rgba(0,0,0,0.45)] " +
|
|
44
|
+
"outline-none focus-visible:ring-2 focus-visible:ring-violet-500/45";
|
|
45
|
+
|
|
46
|
+
export const appShellToolbarIconActiveClass =
|
|
47
|
+
"inline-flex size-10 items-center justify-center rounded-lg border border-violet-400/85 " +
|
|
48
|
+
"bg-gradient-to-b from-violet-100 to-violet-200/95 text-violet-950 " +
|
|
49
|
+
"shadow-[0_1px_2px_rgba(91,33,182,0.18),0_2px_3px_rgba(91,33,182,0.08),inset_0_1px_0_rgba(255,255,255,0.65)] " +
|
|
50
|
+
"dark:border-violet-500/55 dark:from-violet-950/50 dark:to-violet-950/75 dark:text-violet-100 " +
|
|
51
|
+
"dark:shadow-[0_1px_3px_rgba(0,0,0,0.55),inset_0_1px_0_rgba(255,255,255,0.1)] " +
|
|
52
|
+
"outline-none focus-visible:ring-2 focus-visible:ring-violet-500/45";
|
|
53
|
+
|
|
54
|
+
/** Anneau autour du bouton pause globale lorsque la reprise est disponible (pause active). */
|
|
55
|
+
export const appShellToolbarGlobalPauseResumeHighlightClass =
|
|
56
|
+
"ring-1 ring-amber-500/90 ring-offset-1 ring-offset-zinc-200 shadow-[0_0_12px_rgba(245,158,11,0.38)] dark:ring-amber-400/85 dark:ring-offset-zinc-950 dark:shadow-[0_0_16px_rgba(251,191,36,0.26)]";
|
|
57
|
+
|
|
58
|
+
export const appShellToolbarDashboardPulseChromeClass =
|
|
59
|
+
"ring-2 ring-amber-400/85 motion-safe:animate-pulse shadow-[0_0_14px_rgba(251,191,36,0.35)] dark:ring-amber-300/80";
|
|
60
|
+
|
|
61
|
+
const raisedPadInteractive =
|
|
62
|
+
"rounded-lg border border-zinc-300/90 bg-gradient-to-b from-white to-zinc-100 text-zinc-700 " +
|
|
63
|
+
"shadow-[0_1px_2px_rgba(15,23,42,0.12),0_2px_3px_rgba(15,23,42,0.06),inset_0_1px_0_rgba(255,255,255,0.9)] " +
|
|
64
|
+
"transition hover:border-zinc-400 hover:from-zinc-50 hover:to-zinc-100 hover:shadow-[0_2px_4px_rgba(15,23,42,0.12),inset_0_1px_0_rgba(255,255,255,0.95)] " +
|
|
65
|
+
"active:translate-y-px active:shadow-[inset_0_2px_3px_rgba(15,23,42,0.1)] " +
|
|
66
|
+
"dark:border-zinc-500/85 dark:from-zinc-600 dark:to-zinc-800 dark:text-zinc-100 " +
|
|
67
|
+
"dark:shadow-[0_1px_2px_rgba(0,0,0,0.5),inset_0_1px_0_rgba(255,255,255,0.14)] " +
|
|
68
|
+
"dark:hover:border-zinc-400 dark:hover:from-zinc-600 dark:hover:to-zinc-800 " +
|
|
69
|
+
"dark:active:shadow-[inset_0_2px_5px_rgba(0,0,0,0.45)] " +
|
|
70
|
+
"outline-none focus-visible:ring-2 focus-visible:ring-violet-500/45";
|
|
71
|
+
|
|
72
|
+
/** Déclencheur large (recherche, horloge murale) : même relief que les icônes. */
|
|
73
|
+
export const appShellToolbarRaisedWideTriggerClass =
|
|
74
|
+
"inline-flex h-10 shrink-0 items-center " + raisedPadInteractive;
|
|
75
|
+
|
|
76
|
+
/** Menu langue fermé : relief + texte. */
|
|
77
|
+
export const appShellToolbarRaisedLangTriggerClosedClass =
|
|
78
|
+
"inline-flex h-10 min-h-10 shrink-0 items-center gap-2 rounded-lg border border-zinc-300/90 bg-gradient-to-b from-white to-zinc-100 px-2.5 text-sm text-zinc-800 outline-none focus-visible:ring-2 focus-visible:ring-violet-500/45 " +
|
|
79
|
+
"shadow-[0_1px_2px_rgba(15,23,42,0.12),0_2px_3px_rgba(15,23,42,0.06),inset_0_1px_0_rgba(255,255,255,0.9)] " +
|
|
80
|
+
"transition hover:border-zinc-400 hover:from-zinc-50 hover:to-zinc-100 hover:shadow-[0_2px_4px_rgba(15,23,42,0.12),inset_0_1px_0_rgba(255,255,255,0.95)] " +
|
|
81
|
+
"active:translate-y-px active:shadow-[inset_0_2px_3px_rgba(15,23,42,0.1)] " +
|
|
82
|
+
"dark:border-zinc-500/85 dark:from-zinc-600 dark:to-zinc-800 dark:text-zinc-200 " +
|
|
83
|
+
"dark:shadow-[0_1px_2px_rgba(0,0,0,0.5),inset_0_1px_0_rgba(255,255,255,0.14)] " +
|
|
84
|
+
"dark:hover:border-zinc-400 dark:hover:from-zinc-600 dark:hover:to-zinc-800 " +
|
|
85
|
+
"dark:active:shadow-[inset_0_2px_5px_rgba(0,0,0,0.45)] sm:px-3";
|
|
86
|
+
|
|
87
|
+
/** Menu langue ouvert : état actif type ruban. */
|
|
88
|
+
export const appShellToolbarRaisedLangTriggerOpenClass =
|
|
89
|
+
"inline-flex h-10 min-h-10 shrink-0 items-center gap-2 rounded-lg border border-violet-400/85 bg-gradient-to-b from-violet-100 to-violet-200/95 px-2.5 text-sm text-zinc-900 outline-none focus-visible:ring-2 focus-visible:ring-violet-500/45 " +
|
|
90
|
+
"shadow-[0_1px_2px_rgba(91,33,182,0.18),0_2px_3px_rgba(91,33,182,0.08),inset_0_1px_0_rgba(255,255,255,0.65),0_0_0_1px_rgba(139,92,246,0.12)] " +
|
|
91
|
+
"dark:border-violet-500/55 dark:from-violet-950/50 dark:to-violet-950/75 dark:text-zinc-100 " +
|
|
92
|
+
"dark:shadow-[0_1px_3px_rgba(0,0,0,0.55),inset_0_1px_0_rgba(255,255,255,0.1)] sm:px-3";
|
|
93
|
+
|
|
94
|
+
/** Contrôle carré 40×40 (même gabarit que la navigation). */
|
|
95
|
+
export const appShellToolbarRaisedMdSquareClass =
|
|
96
|
+
"inline-flex size-10 shrink-0 cursor-pointer items-center justify-center rounded-lg border border-zinc-300/90 bg-gradient-to-b from-white to-zinc-100 p-0 leading-none text-zinc-700 outline-none " +
|
|
97
|
+
"shadow-[0_1px_2px_rgba(15,23,42,0.12),0_2px_3px_rgba(15,23,42,0.06),inset_0_1px_0_rgba(255,255,255,0.9)] " +
|
|
98
|
+
"transition hover:border-zinc-400 hover:from-zinc-50 hover:to-zinc-100 " +
|
|
99
|
+
"active:translate-y-px active:shadow-[inset_0_2px_3px_rgba(15,23,42,0.1)] " +
|
|
100
|
+
"dark:border-zinc-500/85 dark:from-zinc-600 dark:to-zinc-800 dark:text-zinc-100 " +
|
|
101
|
+
"dark:shadow-[0_1px_2px_rgba(0,0,0,0.5),inset_0_1px_0_rgba(255,255,255,0.14)] " +
|
|
102
|
+
"dark:hover:border-zinc-400 dark:hover:from-zinc-600 dark:hover:to-zinc-800 " +
|
|
103
|
+
"dark:active:shadow-[inset_0_2px_5px_rgba(0,0,0,0.45)] " +
|
|
104
|
+
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-violet-500/55";
|
|
105
|
+
|
|
106
|
+
export const appShellToolbarRaisedMdSquareNeutralTextClass =
|
|
107
|
+
appShellToolbarRaisedMdSquareClass +
|
|
108
|
+
" hover:text-zinc-900 dark:hover:text-zinc-50 disabled:pointer-events-none disabled:opacity-45 disabled:cursor-not-allowed";
|
|
109
|
+
|
|
110
|
+
export const appShellToolbarRaisedMdSquareAccentTextClass =
|
|
111
|
+
appShellToolbarRaisedMdSquareClass +
|
|
112
|
+
" text-violet-800 hover:from-violet-50 hover:to-violet-100/90 dark:text-violet-200 dark:hover:from-violet-950/40 dark:hover:to-violet-900/50 disabled:pointer-events-none disabled:opacity-45 disabled:cursor-not-allowed";
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
const KEY_SCROLL = "kronosys_deferred_scroll_v1";
|
|
2
|
+
const KEY_TODAY_GANTT = "kronosys_deferred_today_gantt_v1";
|
|
3
|
+
const KEY_NEW_SESSION = "kronosys_deferred_new_session_v1";
|
|
4
|
+
const KEY_SESSION_LIST_FOCUS = "kronosys_deferred_session_list_focus_v1";
|
|
5
|
+
const KEY_TASK_FOCUS = "kronosys_deferred_task_focus_v1";
|
|
6
|
+
const KEY_TEMPLATE_DRAFT = "kronosys_deferred_task_template_draft_v1";
|
|
7
|
+
|
|
8
|
+
export type AppShellDeferredScrollTarget =
|
|
9
|
+
| "sessions"
|
|
10
|
+
| "tasks"
|
|
11
|
+
| "tags";
|
|
12
|
+
|
|
13
|
+
function write(key: string, value: string) {
|
|
14
|
+
try {
|
|
15
|
+
globalThis.sessionStorage?.setItem(key, value);
|
|
16
|
+
} catch {
|
|
17
|
+
// ignore
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function read(key: string): string | null {
|
|
22
|
+
try {
|
|
23
|
+
return globalThis.sessionStorage?.getItem(key) ?? null;
|
|
24
|
+
} catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function remove(key: string) {
|
|
30
|
+
try {
|
|
31
|
+
globalThis.sessionStorage?.removeItem(key);
|
|
32
|
+
} catch {
|
|
33
|
+
// ignore
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function stashAppShellDeferredScroll(target: AppShellDeferredScrollTarget) {
|
|
38
|
+
write(KEY_SCROLL, target);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function consumeAppShellDeferredScroll():
|
|
42
|
+
| AppShellDeferredScrollTarget
|
|
43
|
+
| null {
|
|
44
|
+
const v = read(KEY_SCROLL);
|
|
45
|
+
remove(KEY_SCROLL);
|
|
46
|
+
if (v === "sessions" || v === "tasks" || v === "tags") {
|
|
47
|
+
return v;
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function stashAppShellDeferredTodayGantt() {
|
|
53
|
+
write(KEY_TODAY_GANTT, "1");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function consumeAppShellDeferredTodayGantt(): boolean {
|
|
57
|
+
const v = read(KEY_TODAY_GANTT);
|
|
58
|
+
remove(KEY_TODAY_GANTT);
|
|
59
|
+
return v === "1";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function stashAppShellDeferredNewSession() {
|
|
63
|
+
write(KEY_NEW_SESSION, "1");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function consumeAppShellDeferredNewSession(): boolean {
|
|
67
|
+
const v = read(KEY_NEW_SESSION);
|
|
68
|
+
remove(KEY_NEW_SESSION);
|
|
69
|
+
return v === "1";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function stashAppShellDeferredSessionListFocus(sessionId: string) {
|
|
73
|
+
const id = sessionId.trim();
|
|
74
|
+
if (id) {
|
|
75
|
+
write(KEY_SESSION_LIST_FOCUS, id);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function consumeAppShellDeferredSessionListFocus(): string | null {
|
|
80
|
+
const v = read(KEY_SESSION_LIST_FOCUS);
|
|
81
|
+
remove(KEY_SESSION_LIST_FOCUS);
|
|
82
|
+
const t = v?.trim() ?? "";
|
|
83
|
+
return t !== "" ? t : null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function stashAppShellDeferredTaskFocus(taskId: string) {
|
|
87
|
+
const id = taskId.trim();
|
|
88
|
+
if (id) {
|
|
89
|
+
write(KEY_TASK_FOCUS, id);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function consumeAppShellDeferredTaskFocus(): string | null {
|
|
94
|
+
const v = read(KEY_TASK_FOCUS);
|
|
95
|
+
remove(KEY_TASK_FOCUS);
|
|
96
|
+
const t = v?.trim() ?? "";
|
|
97
|
+
return t !== "" ? t : null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function stashAppShellDeferredTaskTemplateDraft(draftLine: string) {
|
|
101
|
+
const d = draftLine.trim();
|
|
102
|
+
if (d) {
|
|
103
|
+
write(KEY_TEMPLATE_DRAFT, d);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function consumeAppShellDeferredTaskTemplateDraft(): string | null {
|
|
108
|
+
const v = read(KEY_TEMPLATE_DRAFT);
|
|
109
|
+
remove(KEY_TEMPLATE_DRAFT);
|
|
110
|
+
const t = v?.trim() ?? "";
|
|
111
|
+
return t !== "" ? t : null;
|
|
112
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { SessionListEntry } from "@/components/dashboard/SessionListPanel";
|
|
2
|
+
import type { KronosysUpdatePayload } from "@/lib/kronosysApi";
|
|
3
|
+
import { mergeLiveSessionIntoHistory } from "@/lib/sessionListMerge";
|
|
4
|
+
import {
|
|
5
|
+
resolveUrlSession,
|
|
6
|
+
type UrlSessionLiveShape,
|
|
7
|
+
} from "@/lib/dashboardUrlSession";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Découpe payload + `?session=` comme le tableau de bord (session inspectée vs live) pour la recherche
|
|
11
|
+
* et la navigation d’en-tête partagée.
|
|
12
|
+
*/
|
|
13
|
+
export function computeToolbarSessionSlices(
|
|
14
|
+
payload: KronosysUpdatePayload | null,
|
|
15
|
+
sessionUrlParam: string | null,
|
|
16
|
+
): {
|
|
17
|
+
live: UrlSessionLiveShape | undefined;
|
|
18
|
+
history: SessionListEntry[];
|
|
19
|
+
historyArchived: SessionListEntry[];
|
|
20
|
+
sessionCurrent: Record<string, unknown> | undefined;
|
|
21
|
+
} {
|
|
22
|
+
const live = payload?.current as UrlSessionLiveShape | undefined;
|
|
23
|
+
const historyArchived = (payload?.historyArchived ||
|
|
24
|
+
[]) as SessionListEntry[];
|
|
25
|
+
const history = !payload
|
|
26
|
+
? ([] as SessionListEntry[])
|
|
27
|
+
: mergeLiveSessionIntoHistory(
|
|
28
|
+
(payload.history || []) as SessionListEntry[],
|
|
29
|
+
live as never,
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
const inspectingId = payload?.inspectingSessionId as
|
|
33
|
+
| string
|
|
34
|
+
| null
|
|
35
|
+
| undefined;
|
|
36
|
+
const urlResolution = resolveUrlSession(
|
|
37
|
+
sessionUrlParam,
|
|
38
|
+
payload,
|
|
39
|
+
live,
|
|
40
|
+
history,
|
|
41
|
+
historyArchived,
|
|
42
|
+
);
|
|
43
|
+
const isDetachedUrlTab = urlResolution.mode === "ok";
|
|
44
|
+
const liveSid =
|
|
45
|
+
typeof live?.sessionId === "string" ? live.sessionId.trim() : "";
|
|
46
|
+
const urlSessionFocusId =
|
|
47
|
+
urlResolution.mode === "ok" ? urlResolution.id.trim() : "";
|
|
48
|
+
const columnArchiveId =
|
|
49
|
+
isDetachedUrlTab && urlSessionFocusId !== liveSid
|
|
50
|
+
? urlSessionFocusId
|
|
51
|
+
: typeof inspectingId === "string" && inspectingId.trim() !== ""
|
|
52
|
+
? inspectingId.trim()
|
|
53
|
+
: null;
|
|
54
|
+
|
|
55
|
+
const viewingSession = columnArchiveId
|
|
56
|
+
? (history.find((s) => s.sessionId === columnArchiveId) ||
|
|
57
|
+
historyArchived.find((s) => s.sessionId === columnArchiveId)) as
|
|
58
|
+
| Record<string, unknown>
|
|
59
|
+
| undefined
|
|
60
|
+
: undefined;
|
|
61
|
+
|
|
62
|
+
const sessionCurrent = (viewingSession ?? live) as
|
|
63
|
+
| Record<string, unknown>
|
|
64
|
+
| undefined;
|
|
65
|
+
|
|
66
|
+
return { live, history, historyArchived, sessionCurrent };
|
|
67
|
+
}
|