@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.
Files changed (46) hide show
  1. package/README.md +1 -1
  2. package/app/changelog/page.tsx +87 -19
  3. package/app/globals.css +10 -8
  4. package/app/guide/page.tsx +71 -34
  5. package/app/implementation/page.tsx +70 -60
  6. package/app/licenses/page.tsx +79 -47
  7. package/app/logs/page.tsx +103 -47
  8. package/app/page.tsx +104 -169
  9. package/app/reporting/page.tsx +1918 -1436
  10. package/app/settings/page.tsx +66 -44
  11. package/components/KronosysPayloadProvider.tsx +19 -5
  12. package/components/dashboard/AppShellHeaderKronoFocus.tsx +78 -0
  13. package/components/dashboard/AppShellHeaderToolbarLayout.tsx +36 -0
  14. package/components/dashboard/AppShellHeaderUtilityRibbon.tsx +19 -0
  15. package/components/dashboard/AppShellHeaderWallClock.tsx +23 -17
  16. package/components/dashboard/AppShellRouteNav.tsx +336 -209
  17. package/components/dashboard/AppShellToolbarCommandCenter.tsx +225 -0
  18. package/components/dashboard/AppShellToolbarRouteNav.tsx +204 -0
  19. package/components/dashboard/DashboardCommandCenter.tsx +119 -30
  20. package/components/dashboard/KronoFocusPanel.tsx +287 -260
  21. package/components/dashboard/LanguageMenu.tsx +23 -7
  22. package/components/dashboard/PageRefreshButton.tsx +42 -16
  23. package/components/dashboard/ReportingTour.tsx +20 -2
  24. package/components/dashboard/SessionListPanel.tsx +4 -4
  25. package/components/dashboard/ThemeToggle.tsx +4 -3
  26. package/components/dashboard/useAnchoredFloatingPortalStyle.ts +9 -2
  27. package/components/dashboard/useKronoFocusLiveSeconds.ts +4 -2
  28. package/lib/appShellHeaderClasses.ts +22 -3
  29. package/lib/appShellToolbarChrome.ts +112 -0
  30. package/lib/appShellToolbarDeferredIntents.ts +112 -0
  31. package/lib/appShellToolbarSessionSlices.ts +67 -0
  32. package/lib/dashboardCopy.ts +78 -29
  33. package/lib/dashboardQuickSearch.ts +37 -6
  34. package/lib/dashboardUrlSession.ts +36 -0
  35. package/lib/generatedUserChangelog.ts +14 -0
  36. package/lib/implementationNotes.ts +18 -14
  37. package/lib/reportingAggregate.ts +68 -9
  38. package/lib/reportingMetricHelp.ts +8 -8
  39. package/lib/reportingStrings.ts +118 -9
  40. package/lib/reportingTagWeekBreakdown.ts +55 -13
  41. package/lib/settingsCopy.ts +6 -7
  42. package/lib/userGuideCopy.ts +29 -26
  43. package/package.json +7 -5
  44. package/server/db.ts +6 -4
  45. package/server/dbSchema.ts +2 -2
  46. 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={`inline-flex h-10 min-h-10 shrink-0 items-center gap-2 rounded-lg border px-2.5 text-sm transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-500/45 sm:px-3 ${
62
+ className={
59
63
  open
60
- ? "border-violet-500/50 bg-violet-50 text-zinc-900 shadow-[0_0_0_1px_rgba(139,92,246,0.12)] dark:bg-zinc-800/90 dark:text-zinc-100"
61
- : "border-zinc-300 bg-white text-zinc-800 hover:border-zinc-400 hover:bg-zinc-100 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-200 dark:hover:border-zinc-500 dark:hover:bg-zinc-800/60"
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 size={18} className="shrink-0 text-violet-600 dark:text-violet-400/90" strokeWidth={1.75} aria-hidden />
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 ${open ? "rotate-180" : ""}`}
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 size={16} className="shrink-0 text-violet-600 dark:text-violet-400" strokeWidth={2.5} aria-hidden />
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 clearBannerTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
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(inlineMessages) && bannerPhase !== "idle" && bannerLabel;
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
- <div className="relative inline-flex flex-col items-end">
119
+ <>
104
120
  <button
121
+ ref={buttonRef}
105
122
  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}`}
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 size={18} className="animate-spin text-violet-600 dark:text-violet-400" aria-hidden />
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
- <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>
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
- { title: dt.reportingTourStep5Title, body: dt.reportingTourStep5Body },
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
- ? "Switch to light theme"
22
- : "Switch to dark theme";
22
+ ? "Switch to light theme"
23
+ : "Switch to dark theme";
23
24
 
24
25
  return (
25
26
  <button
26
27
  type="button"
27
- className={`inline-flex size-10 shrink-0 items-center justify-center rounded-lg border border-zinc-300 bg-white text-zinc-700 hover:bg-zinc-100 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-200 dark:hover:bg-zinc-700 ${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 = opts.align === "end" ? r.right - w : r.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 { useLayoutEffect, useState } from "react";
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
- useLayoutEffect(() => {
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
- * Barre d’actions à droite : même empilement que le tableau de bord (recherche / raccourcis,
22
- * navigation, thème, rafraîchissement, langue) pour éviter les décalages entre routes.
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-h-10 shrink-0 flex-wrap items-center justify-end gap-1.5";
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
+ }