@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,394 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
import { useLayoutEffect, useMemo, useState } from "react";
|
|
5
|
+
import { usePathname } from "next/navigation";
|
|
6
|
+
import { Check, ChevronLeft, ChevronRight, Circle, LayoutDashboard, Timer } from "lucide-react";
|
|
7
|
+
import { useKronosysPayload } from "@/components/KronosysPayloadProvider";
|
|
8
|
+
import { useSmoothStopwatchDisplayMs } from "@/components/dashboard/useSmoothStopwatchMs";
|
|
9
|
+
import { useKronoFocusLiveSeconds } from "@/components/dashboard/useKronoFocusLiveSeconds";
|
|
10
|
+
import { dashboardStrings, type DashboardStrings, type Lang } from "@/lib/dashboardCopy";
|
|
11
|
+
import { formatStopwatchMs, formatWallDurationMs, taskTitleForDisplay } from "@/lib/taskParsing";
|
|
12
|
+
import { withDashboardSessionParam } from "@/lib/dashboardSessionNav";
|
|
13
|
+
|
|
14
|
+
type LiveTask = {
|
|
15
|
+
id?: string;
|
|
16
|
+
name?: string;
|
|
17
|
+
isDone?: boolean;
|
|
18
|
+
manualTaskTimerPaused?: boolean;
|
|
19
|
+
activeSubtaskTimerId?: string;
|
|
20
|
+
durationMs?: number;
|
|
21
|
+
subtasks?: Array<{ id?: string; title?: string; done?: boolean; durationMs?: number }>;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type LiveShape = {
|
|
25
|
+
sessionId?: string;
|
|
26
|
+
sessionName?: string;
|
|
27
|
+
archived?: boolean;
|
|
28
|
+
endAt?: string | null;
|
|
29
|
+
isPaused?: boolean;
|
|
30
|
+
sessionDurationMinutes?: number;
|
|
31
|
+
activeTasks?: LiveTask[];
|
|
32
|
+
activeTask?: LiveTask | null;
|
|
33
|
+
language?: string;
|
|
34
|
+
kronoFocus?: {
|
|
35
|
+
mode: "work" | "break" | "longBreak";
|
|
36
|
+
status: "idle" | "running" | "paused";
|
|
37
|
+
timeLeftSeconds: number;
|
|
38
|
+
kronoFocusDeadlineAtMs?: number;
|
|
39
|
+
linkedTaskId?: string;
|
|
40
|
+
linkedTaskName?: string;
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
function kfPhaseLabel(dt: DashboardStrings, mode: "work" | "break" | "longBreak" | undefined): string {
|
|
45
|
+
if (mode === "break") {
|
|
46
|
+
return dt.breakMode;
|
|
47
|
+
}
|
|
48
|
+
if (mode === "longBreak") {
|
|
49
|
+
return dt.longBreakMode;
|
|
50
|
+
}
|
|
51
|
+
return dt.appShellLiveDrawerKronoFocusPhaseWork;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Même format que le bandeau KronoFocus du tableau de bord (temps restant). */
|
|
55
|
+
function kfCountdownHMS(totalSec: number): string {
|
|
56
|
+
const s = Math.max(0, Math.floor(totalSec));
|
|
57
|
+
const h = Math.floor(s / 3600);
|
|
58
|
+
const m = Math.floor((s % 3600) / 60);
|
|
59
|
+
const sec = s % 60;
|
|
60
|
+
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function truncateDrawerLabel(s: string, max: number): string {
|
|
64
|
+
const t = s.trim();
|
|
65
|
+
if (t.length <= max) {
|
|
66
|
+
return t;
|
|
67
|
+
}
|
|
68
|
+
return `${t.slice(0, max - 1)}…`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function runningTasksFromLive(live: LiveShape | undefined): LiveTask[] {
|
|
72
|
+
if (!live) {
|
|
73
|
+
return [];
|
|
74
|
+
}
|
|
75
|
+
const raw =
|
|
76
|
+
Array.isArray(live.activeTasks) && live.activeTasks.length > 0
|
|
77
|
+
? live.activeTasks
|
|
78
|
+
: live.activeTask
|
|
79
|
+
? [live.activeTask]
|
|
80
|
+
: [];
|
|
81
|
+
return raw.filter((t) => t && !t.isDone && !t.manualTaskTimerPaused);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function subtaskTitleFor(task: LiveTask, subId: string): string | undefined {
|
|
85
|
+
const list = task.subtasks;
|
|
86
|
+
if (!Array.isArray(list)) {
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
const row = list.find((s) => String(s.id) === subId);
|
|
90
|
+
const t = row?.title;
|
|
91
|
+
return typeof t === "string" && t.trim() !== "" ? t.trim() : undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function DrawerSubtaskRow({
|
|
95
|
+
sub,
|
|
96
|
+
isTracking,
|
|
97
|
+
}: Readonly<{
|
|
98
|
+
sub: { id?: string; title?: string; done?: boolean; durationMs?: number };
|
|
99
|
+
isTracking: boolean;
|
|
100
|
+
}>) {
|
|
101
|
+
const label = typeof sub.title === "string" ? sub.title.trim() : "";
|
|
102
|
+
const baseMs = Math.max(0, Math.floor(Number(sub.durationMs) || 0));
|
|
103
|
+
const displayMs = useSmoothStopwatchDisplayMs(baseMs, isTracking);
|
|
104
|
+
const timeStr = formatStopwatchMs(displayMs);
|
|
105
|
+
const done = sub.done === true;
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<li className="flex min-w-0 items-baseline justify-between gap-1.5 py-0.5">
|
|
109
|
+
<span className="flex min-w-0 items-center gap-1">
|
|
110
|
+
{done ? (
|
|
111
|
+
<Check className="size-3 shrink-0 text-emerald-600 dark:text-emerald-400" strokeWidth={2.5} aria-hidden />
|
|
112
|
+
) : (
|
|
113
|
+
<Circle
|
|
114
|
+
className={`size-2.5 shrink-0 ${isTracking ? "text-emerald-600 dark:text-emerald-400" : "text-zinc-400 dark:text-zinc-500"}`}
|
|
115
|
+
strokeWidth={2}
|
|
116
|
+
aria-hidden
|
|
117
|
+
/>
|
|
118
|
+
)}
|
|
119
|
+
<span
|
|
120
|
+
className={`min-w-0 truncate ${done ? "text-zinc-500 line-through dark:text-zinc-500" : "text-zinc-700 dark:text-zinc-300"}`}
|
|
121
|
+
>
|
|
122
|
+
{label || "—"}
|
|
123
|
+
</span>
|
|
124
|
+
</span>
|
|
125
|
+
<span
|
|
126
|
+
className={`shrink-0 font-mono tabular-nums tracking-tight ${isTracking ? "font-semibold text-emerald-700 dark:text-emerald-400" : "text-zinc-500 dark:text-zinc-400"}`}
|
|
127
|
+
>
|
|
128
|
+
{timeStr}
|
|
129
|
+
</span>
|
|
130
|
+
</li>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function DrawerRunningTaskRow({
|
|
135
|
+
task,
|
|
136
|
+
subtaskTrackingLabel,
|
|
137
|
+
dashboardT,
|
|
138
|
+
}: Readonly<{
|
|
139
|
+
task: LiveTask;
|
|
140
|
+
/** Libellé du suivi actif sur une sous-tâche (ligne de secours). */
|
|
141
|
+
subtaskTrackingLabel: string;
|
|
142
|
+
dashboardT: DashboardStrings;
|
|
143
|
+
}>) {
|
|
144
|
+
const title = taskTitleForDisplay(typeof task.name === "string" ? task.name : "");
|
|
145
|
+
const subId = String(task.activeSubtaskTimerId ?? "").trim();
|
|
146
|
+
const subTitle = subId ? subtaskTitleFor(task, subId) : undefined;
|
|
147
|
+
const baseMs = Math.max(0, Math.floor(Number(task.durationMs) || 0));
|
|
148
|
+
/** Pendant un suivi sous-tâche, le parent n’a pas de segment principal : afficher la valeur persistée. */
|
|
149
|
+
const smoothMain = subId === "";
|
|
150
|
+
const displayMs = useSmoothStopwatchDisplayMs(baseMs, smoothMain);
|
|
151
|
+
const timeStr = formatStopwatchMs(displayMs);
|
|
152
|
+
const subs = Array.isArray(task.subtasks) ? task.subtasks : [];
|
|
153
|
+
const hasSubList = subs.length > 0;
|
|
154
|
+
|
|
155
|
+
return (
|
|
156
|
+
<li className="min-w-0 text-xs leading-snug">
|
|
157
|
+
<div className="flex min-w-0 items-baseline justify-between gap-2">
|
|
158
|
+
<span className="min-w-0 truncate font-medium text-zinc-800 dark:text-zinc-200">{title}</span>
|
|
159
|
+
<span
|
|
160
|
+
className={`shrink-0 font-mono text-xs tabular-nums tracking-tight ${smoothMain ? "font-semibold text-emerald-700 dark:text-emerald-400" : "text-zinc-600 dark:text-zinc-400"}`}
|
|
161
|
+
aria-live={smoothMain ? "polite" : "off"}
|
|
162
|
+
>
|
|
163
|
+
{timeStr}
|
|
164
|
+
</span>
|
|
165
|
+
</div>
|
|
166
|
+
{hasSubList ? (
|
|
167
|
+
<ul
|
|
168
|
+
className="mt-1.5 space-y-0.5 border-l border-zinc-200 pl-2 dark:border-zinc-600"
|
|
169
|
+
aria-label={dashboardT.subtasksHeading}
|
|
170
|
+
>
|
|
171
|
+
{subs.map((st) => {
|
|
172
|
+
const sid = String(st.id ?? "").trim();
|
|
173
|
+
return (
|
|
174
|
+
<DrawerSubtaskRow key={sid || String(st.title)} sub={st} isTracking={subId !== "" && subId === sid} />
|
|
175
|
+
);
|
|
176
|
+
})}
|
|
177
|
+
</ul>
|
|
178
|
+
) : subTitle ? (
|
|
179
|
+
<span className="mt-1 block truncate border-l border-zinc-200 pl-2 text-zinc-500 dark:border-zinc-600 dark:text-zinc-400">
|
|
180
|
+
{subtaskTrackingLabel}: {subTitle}
|
|
181
|
+
</span>
|
|
182
|
+
) : null}
|
|
183
|
+
</li>
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function AppShellLiveSessionDrawer() {
|
|
188
|
+
const pathname = usePathname();
|
|
189
|
+
const { payload } = useKronosysPayload();
|
|
190
|
+
const [collapsed, setCollapsed] = useState(false);
|
|
191
|
+
|
|
192
|
+
const onDashboardHome = pathname === "/";
|
|
193
|
+
const live = payload?.current as LiveShape | undefined;
|
|
194
|
+
const lang: Lang = live?.language === "fr" ? "fr" : "en";
|
|
195
|
+
const dt = dashboardStrings(lang);
|
|
196
|
+
|
|
197
|
+
const liveSid = typeof live?.sessionId === "string" ? live.sessionId.trim() : "";
|
|
198
|
+
const hasLiveSession = liveSid !== "" && live?.archived !== true;
|
|
199
|
+
const show = !onDashboardHome && Boolean(payload) && hasLiveSession;
|
|
200
|
+
|
|
201
|
+
const sessionWallMinutes = live?.sessionDurationMinutes ?? 0;
|
|
202
|
+
const wallClockMsBase = useMemo(
|
|
203
|
+
() => Math.max(0, Math.floor(sessionWallMinutes * 60_000)),
|
|
204
|
+
[sessionWallMinutes]
|
|
205
|
+
);
|
|
206
|
+
const sessionEnded = typeof live?.endAt === "string" && live.endAt.trim() !== "";
|
|
207
|
+
const smoothSessionWall =
|
|
208
|
+
liveSid !== "" &&
|
|
209
|
+
live?.archived !== true &&
|
|
210
|
+
!sessionEnded &&
|
|
211
|
+
live?.isPaused !== true;
|
|
212
|
+
const sessionWallDisplayMs = useSmoothStopwatchDisplayMs(wallClockMsBase, smoothSessionWall);
|
|
213
|
+
|
|
214
|
+
const runningTasks = useMemo(() => runningTasksFromLive(live), [live]);
|
|
215
|
+
const kf = live?.kronoFocus;
|
|
216
|
+
const kfSecs = useKronoFocusLiveSeconds(
|
|
217
|
+
kf?.timeLeftSeconds ?? 0,
|
|
218
|
+
kf?.status ?? "idle",
|
|
219
|
+
kf?.kronoFocusDeadlineAtMs
|
|
220
|
+
);
|
|
221
|
+
const kfActive = kf?.status === "running" || kf?.status === "paused";
|
|
222
|
+
const kfLinkedName =
|
|
223
|
+
typeof kf?.linkedTaskName === "string" && kf.linkedTaskName.trim() !== "" ? kf.linkedTaskName.trim() : "";
|
|
224
|
+
const hasActivity =
|
|
225
|
+
runningTasks.length > 0 || live?.isPaused === true || kfActive;
|
|
226
|
+
|
|
227
|
+
/** Réserve la largeur du tiroir sur le flux du `body` pour ne pas masquer le contenu (guide, etc.). */
|
|
228
|
+
useLayoutEffect(() => {
|
|
229
|
+
if (typeof document === "undefined") {
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
const body = document.body;
|
|
233
|
+
if (!show) {
|
|
234
|
+
body.style.paddingRight = "";
|
|
235
|
+
return () => {
|
|
236
|
+
body.style.paddingRight = "";
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
body.style.paddingRight = collapsed ? "2.75rem" : "min(18rem, 100vw)";
|
|
240
|
+
return () => {
|
|
241
|
+
body.style.paddingRight = "";
|
|
242
|
+
};
|
|
243
|
+
}, [show, collapsed]);
|
|
244
|
+
|
|
245
|
+
if (!show) {
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const dashHref = withDashboardSessionParam("/", liveSid);
|
|
250
|
+
const sessionLabel =
|
|
251
|
+
typeof live?.sessionName === "string" && live.sessionName.trim() !== ""
|
|
252
|
+
? live.sessionName.trim()
|
|
253
|
+
: liveSid.slice(0, 8);
|
|
254
|
+
|
|
255
|
+
const wallLabel = formatWallDurationMs(sessionWallDisplayMs);
|
|
256
|
+
|
|
257
|
+
/** Largeur ancrée au bord droit du viewport (pas de marge fantôme) ; sur très petit écran on ne dépasse pas l’écran. */
|
|
258
|
+
const panelWidth = collapsed ? "w-11" : "w-72 max-w-[min(18rem,100vw)]";
|
|
259
|
+
|
|
260
|
+
return (
|
|
261
|
+
<aside
|
|
262
|
+
data-kronosys-live-drawer=""
|
|
263
|
+
className={`fixed right-0 bottom-0 z-40 flex min-h-0 flex-col overflow-hidden rounded-l-xl border-l border-zinc-200 bg-white/95 shadow-[-6px_0_20px_-4px_rgba(0,0,0,0.08)] backdrop-blur-sm transition-[width] duration-200 dark:border-zinc-700 dark:bg-zinc-900/95 dark:shadow-[-6px_0_24px_-4px_rgba(0,0,0,0.35)] ${panelWidth} top-36 sm:top-32`}
|
|
264
|
+
aria-label={dt.appShellLiveDrawerAria}
|
|
265
|
+
>
|
|
266
|
+
<div className="flex shrink-0 items-center gap-1 border-b border-zinc-200 px-1 py-1.5 dark:border-zinc-700">
|
|
267
|
+
<button
|
|
268
|
+
type="button"
|
|
269
|
+
className="inline-flex size-8 shrink-0 items-center justify-center rounded-md border border-zinc-200 text-zinc-600 hover:bg-zinc-100 dark:border-zinc-600 dark:text-zinc-300 dark:hover:bg-zinc-800/80"
|
|
270
|
+
onClick={() => setCollapsed((c) => !c)}
|
|
271
|
+
title={collapsed ? dt.appShellLiveDrawerExpand : dt.appShellLiveDrawerCollapse}
|
|
272
|
+
aria-expanded={collapsed === false}
|
|
273
|
+
aria-label={collapsed ? dt.appShellLiveDrawerExpand : dt.appShellLiveDrawerCollapse}
|
|
274
|
+
>
|
|
275
|
+
{collapsed ? (
|
|
276
|
+
<ChevronLeft size={18} strokeWidth={2} className="shrink-0" aria-hidden />
|
|
277
|
+
) : (
|
|
278
|
+
<ChevronRight size={18} strokeWidth={2} className="shrink-0" aria-hidden />
|
|
279
|
+
)}
|
|
280
|
+
</button>
|
|
281
|
+
{!collapsed ? (
|
|
282
|
+
<span className="min-w-0 flex-1 truncate text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
|
|
283
|
+
{dt.appShellLiveDrawerTitle}
|
|
284
|
+
</span>
|
|
285
|
+
) : null}
|
|
286
|
+
{hasActivity ? (
|
|
287
|
+
<span
|
|
288
|
+
className="mr-1 inline-flex size-2 shrink-0 rounded-full bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.7)]"
|
|
289
|
+
aria-hidden
|
|
290
|
+
/>
|
|
291
|
+
) : null}
|
|
292
|
+
</div>
|
|
293
|
+
|
|
294
|
+
{collapsed && kfActive ? (
|
|
295
|
+
<div
|
|
296
|
+
className="flex min-h-0 flex-1 flex-col items-center gap-1 border-t border-zinc-200 px-0.5 py-2 dark:border-zinc-700"
|
|
297
|
+
aria-label={dt.kronoFocusTitle}
|
|
298
|
+
>
|
|
299
|
+
<Timer className="shrink-0 text-violet-600 dark:text-violet-400" size={16} strokeWidth={2} aria-hidden />
|
|
300
|
+
<span
|
|
301
|
+
className="w-full max-w-[2.75rem] break-all text-center font-mono text-[0.58rem] font-semibold leading-tight tracking-tight text-violet-800 tabular-nums dark:text-violet-200"
|
|
302
|
+
aria-live="polite"
|
|
303
|
+
>
|
|
304
|
+
{kfCountdownHMS(kfSecs)}
|
|
305
|
+
</span>
|
|
306
|
+
</div>
|
|
307
|
+
) : null}
|
|
308
|
+
|
|
309
|
+
{!collapsed ? (
|
|
310
|
+
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto px-3 py-3 text-sm text-zinc-800 dark:text-zinc-100">
|
|
311
|
+
{kfActive ? (
|
|
312
|
+
<section
|
|
313
|
+
className="rounded-lg border border-violet-400/55 bg-violet-50/90 p-3 shadow-sm dark:border-violet-600/45 dark:bg-violet-950/35"
|
|
314
|
+
aria-label={dt.kronoFocusTitle}
|
|
315
|
+
>
|
|
316
|
+
<p className="text-[0.65rem] font-semibold uppercase tracking-wide text-violet-800 dark:text-violet-300">
|
|
317
|
+
{dt.kronoFocusTitle}
|
|
318
|
+
</p>
|
|
319
|
+
<p className="mt-0.5 text-xs font-medium text-zinc-800 dark:text-zinc-200">
|
|
320
|
+
{kfPhaseLabel(dt, kf?.mode)}
|
|
321
|
+
</p>
|
|
322
|
+
<p className="mt-1 text-xs text-zinc-600 dark:text-zinc-400">
|
|
323
|
+
{kf?.status === "running"
|
|
324
|
+
? dt.appShellLiveDrawerKronoFocusRunning
|
|
325
|
+
: dt.appShellLiveDrawerKronoFocusPaused}
|
|
326
|
+
</p>
|
|
327
|
+
<p
|
|
328
|
+
className="mt-1.5 font-mono text-lg font-semibold tracking-tight text-violet-950 tabular-nums dark:text-violet-100"
|
|
329
|
+
aria-live="polite"
|
|
330
|
+
>
|
|
331
|
+
{kfCountdownHMS(kfSecs)}
|
|
332
|
+
</p>
|
|
333
|
+
{kfLinkedName ? (
|
|
334
|
+
<p className="mt-1.5 min-w-0 text-[0.65rem] leading-snug text-zinc-600 dark:text-zinc-400">
|
|
335
|
+
<span className="font-medium text-zinc-500 dark:text-zinc-500">{dt.kronoFocusLinkedTaskIntro}</span>{" "}
|
|
336
|
+
<span className="break-words">{truncateDrawerLabel(kfLinkedName, 120)}</span>
|
|
337
|
+
</p>
|
|
338
|
+
) : null}
|
|
339
|
+
</section>
|
|
340
|
+
) : null}
|
|
341
|
+
|
|
342
|
+
<div className="min-w-0">
|
|
343
|
+
<p className="truncate font-medium text-zinc-900 dark:text-zinc-50">{sessionLabel}</p>
|
|
344
|
+
<p className="mt-1 text-xs text-zinc-600 dark:text-zinc-400">
|
|
345
|
+
<span className="font-medium text-zinc-500 dark:text-zinc-500">{dt.appShellLiveDrawerWallClock}</span>
|
|
346
|
+
{" · "}
|
|
347
|
+
<span
|
|
348
|
+
className={`tabular-nums ${smoothSessionWall ? "font-mono font-semibold text-emerald-700 dark:text-emerald-400" : "text-zinc-600 dark:text-zinc-400"}`}
|
|
349
|
+
aria-live={smoothSessionWall ? "polite" : "off"}
|
|
350
|
+
>
|
|
351
|
+
{wallLabel}
|
|
352
|
+
</span>
|
|
353
|
+
</p>
|
|
354
|
+
{live?.isPaused === true ? (
|
|
355
|
+
<p className="mt-1 text-xs font-medium text-amber-700 dark:text-amber-300">{dt.appShellLiveDrawerSessionPaused}</p>
|
|
356
|
+
) : null}
|
|
357
|
+
</div>
|
|
358
|
+
|
|
359
|
+
<div className="min-w-0">
|
|
360
|
+
<p className="text-[0.65rem] font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-500">
|
|
361
|
+
{dt.appShellLiveDrawerTasksHeading}
|
|
362
|
+
</p>
|
|
363
|
+
{runningTasks.length === 0 ? (
|
|
364
|
+
<p className="mt-1 text-xs text-zinc-500 dark:text-zinc-500">{dt.appShellLiveDrawerNoRunningTasks}</p>
|
|
365
|
+
) : (
|
|
366
|
+
<ul className="mt-1.5 space-y-2">
|
|
367
|
+
{runningTasks.map((t) => {
|
|
368
|
+
const id = typeof t.id === "string" ? t.id : "";
|
|
369
|
+
const title = taskTitleForDisplay(typeof t.name === "string" ? t.name : "");
|
|
370
|
+
return (
|
|
371
|
+
<DrawerRunningTaskRow
|
|
372
|
+
key={id || title}
|
|
373
|
+
task={t}
|
|
374
|
+
subtaskTrackingLabel={dt.appShellLiveDrawerSubtaskTracking}
|
|
375
|
+
dashboardT={dt}
|
|
376
|
+
/>
|
|
377
|
+
);
|
|
378
|
+
})}
|
|
379
|
+
</ul>
|
|
380
|
+
)}
|
|
381
|
+
</div>
|
|
382
|
+
|
|
383
|
+
<Link
|
|
384
|
+
href={dashHref}
|
|
385
|
+
className="mt-auto inline-flex items-center justify-center gap-2 rounded-lg border border-violet-400/60 bg-violet-50 px-3 py-2 text-xs font-medium text-violet-950 hover:bg-violet-100/90 dark:border-violet-600/50 dark:bg-violet-950/40 dark:text-violet-100 dark:hover:bg-violet-900/50"
|
|
386
|
+
>
|
|
387
|
+
<LayoutDashboard size={16} strokeWidth={2} className="shrink-0" aria-hidden />
|
|
388
|
+
{dt.appShellLiveDrawerOpenDashboard}
|
|
389
|
+
</Link>
|
|
390
|
+
</div>
|
|
391
|
+
) : null}
|
|
392
|
+
</aside>
|
|
393
|
+
);
|
|
394
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
import { BarChart3, BookOpen, FileText, LayoutDashboard, Settings } from "lucide-react";
|
|
5
|
+
import { withDashboardSessionParam } from "@/lib/dashboardSessionNav";
|
|
6
|
+
|
|
7
|
+
const iconLinkClass =
|
|
8
|
+
"inline-flex size-10 items-center justify-center rounded-lg border border-zinc-300 bg-white text-zinc-700 transition hover:border-zinc-400 hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-800/80 dark:text-zinc-200 dark:hover:border-zinc-500 dark:hover:bg-zinc-800";
|
|
9
|
+
|
|
10
|
+
const iconActiveClass =
|
|
11
|
+
"inline-flex size-10 items-center justify-center rounded-lg border border-violet-400/70 bg-violet-100/90 text-violet-950 dark:border-violet-600/60 dark:bg-violet-950/40 dark:text-violet-100";
|
|
12
|
+
|
|
13
|
+
export type AppShellRouteNavLabels = {
|
|
14
|
+
dashboard: string;
|
|
15
|
+
reporting: string;
|
|
16
|
+
settings: string;
|
|
17
|
+
/** Guide d’utilisation in-app. */
|
|
18
|
+
guide: string;
|
|
19
|
+
/** Libellé infobulle / `aria-label` pour l’icône « licences » (page licences uniquement). */
|
|
20
|
+
licenses?: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type AppShellRouteNavCurrent = "dashboard" | "reporting" | "settings" | "licenses" | "guide";
|
|
24
|
+
|
|
25
|
+
type Props = Readonly<{
|
|
26
|
+
current: AppShellRouteNavCurrent;
|
|
27
|
+
labels: AppShellRouteNavLabels;
|
|
28
|
+
/** `aria-label` du `<nav>` (recommandé : chaîne localisée côté appelant). */
|
|
29
|
+
navAriaLabel: string;
|
|
30
|
+
className?: string;
|
|
31
|
+
/** Identifiant de session du tableau de bord (`?session=`) à conserver sur les liens internes. */
|
|
32
|
+
dashboardSessionId?: string | null;
|
|
33
|
+
}>;
|
|
34
|
+
|
|
35
|
+
export function AppShellRouteNav({
|
|
36
|
+
current,
|
|
37
|
+
labels,
|
|
38
|
+
navAriaLabel,
|
|
39
|
+
className,
|
|
40
|
+
dashboardSessionId,
|
|
41
|
+
}: Props) {
|
|
42
|
+
const wrapClass = className ?? "flex flex-wrap items-center gap-1.5";
|
|
43
|
+
const dash = (path: string) => withDashboardSessionParam(path, dashboardSessionId);
|
|
44
|
+
|
|
45
|
+
if (current === "dashboard") {
|
|
46
|
+
return (
|
|
47
|
+
<nav className={wrapClass} aria-label={navAriaLabel}>
|
|
48
|
+
<Link
|
|
49
|
+
href={dash("/guide")}
|
|
50
|
+
className={iconLinkClass}
|
|
51
|
+
title={labels.guide}
|
|
52
|
+
aria-label={labels.guide}
|
|
53
|
+
>
|
|
54
|
+
<BookOpen size={20} strokeWidth={2} className="shrink-0" aria-hidden />
|
|
55
|
+
</Link>
|
|
56
|
+
<Link
|
|
57
|
+
href={dash("/reporting")}
|
|
58
|
+
className={iconLinkClass}
|
|
59
|
+
title={labels.reporting}
|
|
60
|
+
aria-label={labels.reporting}
|
|
61
|
+
>
|
|
62
|
+
<BarChart3 size={20} strokeWidth={2} className="shrink-0" aria-hidden />
|
|
63
|
+
</Link>
|
|
64
|
+
<Link
|
|
65
|
+
href={dash("/settings")}
|
|
66
|
+
className={iconLinkClass}
|
|
67
|
+
title={labels.settings}
|
|
68
|
+
aria-label={labels.settings}
|
|
69
|
+
>
|
|
70
|
+
<Settings size={20} strokeWidth={2} className="shrink-0" aria-hidden />
|
|
71
|
+
</Link>
|
|
72
|
+
</nav>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const licensesTitle = labels.licenses ?? "Licenses";
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<nav className={wrapClass} aria-label={navAriaLabel}>
|
|
80
|
+
<Link href={dash("/")} className={iconLinkClass} title={labels.dashboard} aria-label={labels.dashboard}>
|
|
81
|
+
<LayoutDashboard size={20} strokeWidth={2} className="shrink-0" aria-hidden />
|
|
82
|
+
</Link>
|
|
83
|
+
|
|
84
|
+
{current === "reporting" ? (
|
|
85
|
+
<span className={iconActiveClass} title={labels.reporting} aria-label={labels.reporting} aria-current="page">
|
|
86
|
+
<BarChart3 size={20} strokeWidth={2} className="shrink-0" aria-hidden />
|
|
87
|
+
</span>
|
|
88
|
+
) : (
|
|
89
|
+
<Link
|
|
90
|
+
href={dash("/reporting")}
|
|
91
|
+
className={iconLinkClass}
|
|
92
|
+
title={labels.reporting}
|
|
93
|
+
aria-label={labels.reporting}
|
|
94
|
+
>
|
|
95
|
+
<BarChart3 size={20} strokeWidth={2} className="shrink-0" aria-hidden />
|
|
96
|
+
</Link>
|
|
97
|
+
)}
|
|
98
|
+
|
|
99
|
+
{current === "settings" ? (
|
|
100
|
+
<span className={iconActiveClass} title={labels.settings} aria-label={labels.settings} aria-current="page">
|
|
101
|
+
<Settings size={20} strokeWidth={2} className="shrink-0" aria-hidden />
|
|
102
|
+
</span>
|
|
103
|
+
) : (
|
|
104
|
+
<Link
|
|
105
|
+
href={dash("/settings")}
|
|
106
|
+
className={iconLinkClass}
|
|
107
|
+
title={labels.settings}
|
|
108
|
+
aria-label={labels.settings}
|
|
109
|
+
>
|
|
110
|
+
<Settings size={20} strokeWidth={2} className="shrink-0" aria-hidden />
|
|
111
|
+
</Link>
|
|
112
|
+
)}
|
|
113
|
+
|
|
114
|
+
{current === "guide" ? (
|
|
115
|
+
<span className={iconActiveClass} title={labels.guide} aria-label={labels.guide} aria-current="page">
|
|
116
|
+
<BookOpen size={20} strokeWidth={2} className="shrink-0" aria-hidden />
|
|
117
|
+
</span>
|
|
118
|
+
) : (
|
|
119
|
+
<Link href={dash("/guide")} className={iconLinkClass} title={labels.guide} aria-label={labels.guide}>
|
|
120
|
+
<BookOpen size={20} strokeWidth={2} className="shrink-0" aria-hidden />
|
|
121
|
+
</Link>
|
|
122
|
+
)}
|
|
123
|
+
|
|
124
|
+
{current === "licenses" ? (
|
|
125
|
+
<span className={iconActiveClass} title={licensesTitle} aria-label={licensesTitle} aria-current="page">
|
|
126
|
+
<FileText size={20} strokeWidth={2} className="shrink-0" aria-hidden />
|
|
127
|
+
</span>
|
|
128
|
+
) : null}
|
|
129
|
+
</nav>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useKronosysPackageVersion } from "@/components/KronosysPackageVersionProvider";
|
|
4
|
+
|
|
5
|
+
/** `ariaLabelTemplate` doit contenir `{version}` (remplacé par la valeur lue côté serveur dans le layout). */
|
|
6
|
+
export function AppVersionStamp({ ariaLabelTemplate }: { ariaLabelTemplate: string }) {
|
|
7
|
+
const version = useKronosysPackageVersion();
|
|
8
|
+
return (
|
|
9
|
+
<span
|
|
10
|
+
className="tabular-nums text-zinc-500 dark:text-zinc-500"
|
|
11
|
+
aria-label={ariaLabelTemplate.replace("{version}", version)}
|
|
12
|
+
>
|
|
13
|
+
v{version}
|
|
14
|
+
</span>
|
|
15
|
+
);
|
|
16
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { ChevronRight } from "lucide-react";
|
|
4
|
+
import { useId, useState, type ReactNode } from "react";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Carte tableau de bord repliable (repliée par défaut si `defaultExpanded` omis).
|
|
8
|
+
*/
|
|
9
|
+
export function DashboardCollapsibleSection({
|
|
10
|
+
title,
|
|
11
|
+
headerTrailing,
|
|
12
|
+
children,
|
|
13
|
+
defaultExpanded = false,
|
|
14
|
+
sectionAriaLabel,
|
|
15
|
+
}: {
|
|
16
|
+
title: ReactNode;
|
|
17
|
+
headerTrailing?: ReactNode;
|
|
18
|
+
children: ReactNode;
|
|
19
|
+
defaultExpanded?: boolean;
|
|
20
|
+
/** Libellé de la région pour les lecteurs d’écran (ex. titre de carte déjà présent ailleurs). */
|
|
21
|
+
sectionAriaLabel?: string;
|
|
22
|
+
}) {
|
|
23
|
+
const [open, setOpen] = useState(defaultExpanded);
|
|
24
|
+
const contentId = useId();
|
|
25
|
+
const headingId = useId();
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<section
|
|
29
|
+
className="rounded-xl border border-zinc-200 bg-zinc-50/90 p-4 sm:p-5 dark:border-zinc-800 dark:bg-zinc-900/50"
|
|
30
|
+
aria-label={sectionAriaLabel}
|
|
31
|
+
>
|
|
32
|
+
<div className="flex items-center gap-2">
|
|
33
|
+
<button
|
|
34
|
+
type="button"
|
|
35
|
+
id={headingId}
|
|
36
|
+
aria-expanded={open ? "true" : "false"}
|
|
37
|
+
aria-controls={contentId}
|
|
38
|
+
onClick={() => setOpen((v) => !v)}
|
|
39
|
+
className="flex min-w-0 flex-1 items-center gap-2 rounded-md py-0.5 text-left transition-colors hover:bg-zinc-200/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-500/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:hover:bg-zinc-800/40 dark:focus-visible:ring-offset-zinc-900"
|
|
40
|
+
>
|
|
41
|
+
<ChevronRight
|
|
42
|
+
className={`size-4 shrink-0 text-zinc-500 transition-transform duration-200 ${open ? "rotate-90" : ""}`}
|
|
43
|
+
strokeWidth={2}
|
|
44
|
+
aria-hidden
|
|
45
|
+
/>
|
|
46
|
+
<span className="min-w-0">{title}</span>
|
|
47
|
+
</button>
|
|
48
|
+
{headerTrailing ? <div className="flex shrink-0 items-center gap-1">{headerTrailing}</div> : null}
|
|
49
|
+
</div>
|
|
50
|
+
{open ? (
|
|
51
|
+
<div id={contentId} className="mt-3" aria-labelledby={headingId}>
|
|
52
|
+
{children}
|
|
53
|
+
</div>
|
|
54
|
+
) : null}
|
|
55
|
+
</section>
|
|
56
|
+
);
|
|
57
|
+
}
|