@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,220 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createElement,
|
|
5
|
+
useCallback,
|
|
6
|
+
useEffect,
|
|
7
|
+
useId,
|
|
8
|
+
useLayoutEffect,
|
|
9
|
+
useRef,
|
|
10
|
+
useState,
|
|
11
|
+
type CSSProperties,
|
|
12
|
+
type ReactNode,
|
|
13
|
+
type RefCallback,
|
|
14
|
+
} from "react";
|
|
15
|
+
import { createPortal } from "react-dom";
|
|
16
|
+
import { DescriptionTooltipPortaled } from "./DescriptionTooltipPortaled";
|
|
17
|
+
|
|
18
|
+
const DEFAULT_OPEN_DELAY_MS = 3000;
|
|
19
|
+
const LEAVE_GRACE_MS = 200;
|
|
20
|
+
|
|
21
|
+
const TIP_PLACEHOLDER: CSSProperties = {
|
|
22
|
+
position: "fixed",
|
|
23
|
+
top: 0,
|
|
24
|
+
left: 0,
|
|
25
|
+
width: 288,
|
|
26
|
+
zIndex: 90,
|
|
27
|
+
visibility: "hidden",
|
|
28
|
+
pointerEvents: "none",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Infobulle « définition » au survol ou au focus : ouverture après quelques secondes (voir constante).
|
|
33
|
+
* Rendu en portail `fixed` pour éviter le clip des ancêtres `overflow-*` ; délai court au survol
|
|
34
|
+
* entre la pastille et la bulle pour ne pas fermer trop tôt.
|
|
35
|
+
*/
|
|
36
|
+
export function useDescriptionPopoverAfterMs(
|
|
37
|
+
description: string | undefined | null,
|
|
38
|
+
delayMs: number = DEFAULT_OPEN_DELAY_MS
|
|
39
|
+
): {
|
|
40
|
+
hasDescription: boolean;
|
|
41
|
+
triggerProps: {
|
|
42
|
+
onPointerEnter: () => void;
|
|
43
|
+
onPointerLeave: () => void;
|
|
44
|
+
onPointerCancel: () => void;
|
|
45
|
+
onFocus: () => void;
|
|
46
|
+
onBlur: () => void;
|
|
47
|
+
};
|
|
48
|
+
popoverLayer: ReactNode;
|
|
49
|
+
/** À fusionner sur le conteneur qui englobe la pastille (ref + survol pour la grâce de fermeture). */
|
|
50
|
+
anchorWrapperProps: {
|
|
51
|
+
ref: RefCallback<HTMLSpanElement>;
|
|
52
|
+
onPointerEnter: () => void;
|
|
53
|
+
onPointerLeave: () => void;
|
|
54
|
+
};
|
|
55
|
+
} {
|
|
56
|
+
const [open, setOpen] = useState(false);
|
|
57
|
+
const openTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
58
|
+
const leaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
59
|
+
const anchorRef = useRef<HTMLSpanElement | null>(null);
|
|
60
|
+
const reactId = useId().replace(/:/g, "");
|
|
61
|
+
const tipElementId = `kronosys-desc-tip-${reactId}`;
|
|
62
|
+
const desc = description?.trim() ?? "";
|
|
63
|
+
|
|
64
|
+
const clearOpenTimer = useCallback(() => {
|
|
65
|
+
if (openTimerRef.current !== null) {
|
|
66
|
+
clearTimeout(openTimerRef.current);
|
|
67
|
+
openTimerRef.current = null;
|
|
68
|
+
}
|
|
69
|
+
}, []);
|
|
70
|
+
|
|
71
|
+
const clearLeaveTimer = useCallback(() => {
|
|
72
|
+
if (leaveTimerRef.current !== null) {
|
|
73
|
+
clearTimeout(leaveTimerRef.current);
|
|
74
|
+
leaveTimerRef.current = null;
|
|
75
|
+
}
|
|
76
|
+
}, []);
|
|
77
|
+
|
|
78
|
+
const scheduleOpen = useCallback(() => {
|
|
79
|
+
if (!desc) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
clearOpenTimer();
|
|
83
|
+
openTimerRef.current = setTimeout(() => setOpen(true), delayMs);
|
|
84
|
+
}, [desc, delayMs, clearOpenTimer]);
|
|
85
|
+
|
|
86
|
+
const close = useCallback(() => {
|
|
87
|
+
clearOpenTimer();
|
|
88
|
+
clearLeaveTimer();
|
|
89
|
+
setOpen(false);
|
|
90
|
+
}, [clearOpenTimer, clearLeaveTimer]);
|
|
91
|
+
|
|
92
|
+
const scheduleCloseGrace = useCallback(() => {
|
|
93
|
+
clearLeaveTimer();
|
|
94
|
+
leaveTimerRef.current = setTimeout(() => {
|
|
95
|
+
leaveTimerRef.current = null;
|
|
96
|
+
setOpen(false);
|
|
97
|
+
}, LEAVE_GRACE_MS);
|
|
98
|
+
}, [clearLeaveTimer]);
|
|
99
|
+
|
|
100
|
+
useEffect(
|
|
101
|
+
() => () => {
|
|
102
|
+
clearOpenTimer();
|
|
103
|
+
clearLeaveTimer();
|
|
104
|
+
},
|
|
105
|
+
[clearOpenTimer, clearLeaveTimer]
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
if (!desc) {
|
|
110
|
+
close();
|
|
111
|
+
}
|
|
112
|
+
}, [desc, close]);
|
|
113
|
+
|
|
114
|
+
const [tipStyle, setTipStyle] = useState<CSSProperties | undefined>(undefined);
|
|
115
|
+
|
|
116
|
+
useLayoutEffect(() => {
|
|
117
|
+
if (!open || !desc) {
|
|
118
|
+
setTipStyle(undefined);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const place = () => {
|
|
122
|
+
const a = anchorRef.current;
|
|
123
|
+
if (!a) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const r = a.getBoundingClientRect();
|
|
127
|
+
const vw = globalThis.innerWidth;
|
|
128
|
+
const vh = globalThis.innerHeight;
|
|
129
|
+
const margin = 10;
|
|
130
|
+
const maxW = Math.min(18 * 16, vw - 2 * margin);
|
|
131
|
+
let left = r.left;
|
|
132
|
+
left = Math.max(margin, Math.min(left, vw - maxW - margin));
|
|
133
|
+
let top = r.bottom - 4;
|
|
134
|
+
const tip = document.getElementById(tipElementId);
|
|
135
|
+
if (tip instanceof HTMLElement) {
|
|
136
|
+
const th = tip.getBoundingClientRect().height;
|
|
137
|
+
if (top + th > vh - margin) {
|
|
138
|
+
top = Math.max(margin, r.top - 4 - th);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (top < margin) {
|
|
142
|
+
top = margin;
|
|
143
|
+
}
|
|
144
|
+
setTipStyle({
|
|
145
|
+
position: "fixed",
|
|
146
|
+
top,
|
|
147
|
+
left,
|
|
148
|
+
width: maxW,
|
|
149
|
+
zIndex: 90,
|
|
150
|
+
});
|
|
151
|
+
};
|
|
152
|
+
place();
|
|
153
|
+
const raf = requestAnimationFrame(place);
|
|
154
|
+
globalThis.addEventListener("scroll", place, true);
|
|
155
|
+
globalThis.addEventListener("resize", place);
|
|
156
|
+
return () => {
|
|
157
|
+
cancelAnimationFrame(raf);
|
|
158
|
+
globalThis.removeEventListener("scroll", place, true);
|
|
159
|
+
globalThis.removeEventListener("resize", place);
|
|
160
|
+
};
|
|
161
|
+
}, [open, desc, tipElementId]);
|
|
162
|
+
|
|
163
|
+
const setAnchorRef = useCallback<RefCallback<HTMLSpanElement>>((node) => {
|
|
164
|
+
anchorRef.current = node;
|
|
165
|
+
}, []);
|
|
166
|
+
|
|
167
|
+
if (!desc) {
|
|
168
|
+
return {
|
|
169
|
+
hasDescription: false,
|
|
170
|
+
triggerProps: {
|
|
171
|
+
onPointerEnter: () => {},
|
|
172
|
+
onPointerLeave: () => {},
|
|
173
|
+
onPointerCancel: () => {},
|
|
174
|
+
onFocus: () => {},
|
|
175
|
+
onBlur: () => {},
|
|
176
|
+
},
|
|
177
|
+
popoverLayer: null,
|
|
178
|
+
anchorWrapperProps: {
|
|
179
|
+
ref: setAnchorRef,
|
|
180
|
+
onPointerEnter: () => {},
|
|
181
|
+
onPointerLeave: () => {},
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const mergedTipStyle = tipStyle ?? (open ? TIP_PLACEHOLDER : undefined);
|
|
187
|
+
|
|
188
|
+
/* eslint-disable react-hooks/refs -- faux positif ESLint sur createElement + createPortal */
|
|
189
|
+
const popoverLayer =
|
|
190
|
+
open && typeof document !== "undefined" && mergedTipStyle
|
|
191
|
+
? createPortal(
|
|
192
|
+
createElement(DescriptionTooltipPortaled, {
|
|
193
|
+
domId: tipElementId,
|
|
194
|
+
style: mergedTipStyle,
|
|
195
|
+
text: desc,
|
|
196
|
+
onPointerEnter: clearLeaveTimer,
|
|
197
|
+
onPointerLeave: scheduleCloseGrace,
|
|
198
|
+
}),
|
|
199
|
+
document.body
|
|
200
|
+
)
|
|
201
|
+
: null;
|
|
202
|
+
/* eslint-enable react-hooks/refs */
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
hasDescription: true,
|
|
206
|
+
triggerProps: {
|
|
207
|
+
onPointerEnter: scheduleOpen,
|
|
208
|
+
onPointerLeave: clearOpenTimer,
|
|
209
|
+
onPointerCancel: clearOpenTimer,
|
|
210
|
+
onFocus: scheduleOpen,
|
|
211
|
+
onBlur: close,
|
|
212
|
+
},
|
|
213
|
+
popoverLayer,
|
|
214
|
+
anchorWrapperProps: {
|
|
215
|
+
ref: setAnchorRef,
|
|
216
|
+
onPointerEnter: clearLeaveTimer,
|
|
217
|
+
onPointerLeave: scheduleCloseGrace,
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useLayoutEffect, useState } from "react";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Affiche les secondes restantes du KronoFocus en phase « running » à partir de
|
|
7
|
+
* `kronoFocusDeadlineAtMs` (horloge murale), comme le chronomètre de tâche qui lisse
|
|
8
|
+
* entre deux réponses API. Sans échéance (données héritées), on retombe sur `serverSecs`.
|
|
9
|
+
*
|
|
10
|
+
* `Date.now()` vit dans un effet pour respecter react-hooks/purity (pas d’horloge en rendu).
|
|
11
|
+
*/
|
|
12
|
+
export function useKronoFocusLiveSeconds(
|
|
13
|
+
serverSecs: number,
|
|
14
|
+
status: "idle" | "running" | "paused",
|
|
15
|
+
deadlineMs: number | undefined
|
|
16
|
+
): number {
|
|
17
|
+
const [fromDeadlineSecs, setFromDeadlineSecs] = useState<number | null>(null);
|
|
18
|
+
|
|
19
|
+
useLayoutEffect(() => {
|
|
20
|
+
if (status !== "running" || typeof deadlineMs !== "number" || !Number.isFinite(deadlineMs)) {
|
|
21
|
+
setFromDeadlineSecs(null);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
const tick = () => {
|
|
25
|
+
setFromDeadlineSecs(Math.max(0, Math.ceil((deadlineMs - Date.now()) / 1000)));
|
|
26
|
+
};
|
|
27
|
+
tick();
|
|
28
|
+
const id = globalThis.setInterval(tick, 100);
|
|
29
|
+
return () => globalThis.clearInterval(id);
|
|
30
|
+
}, [status, deadlineMs]);
|
|
31
|
+
|
|
32
|
+
if (status === "running" && typeof deadlineMs === "number" && Number.isFinite(deadlineMs)) {
|
|
33
|
+
return fromDeadlineSecs ?? Math.max(0, Math.floor(serverSecs));
|
|
34
|
+
}
|
|
35
|
+
return Math.max(0, Math.floor(serverSecs));
|
|
36
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Durée affichée à partir de `serverMs` ; si `smooth` est vrai, ajoute le temps écoulé
|
|
7
|
+
* localement entre deux mises à jour serveur (y compris quand la base est encore 0).
|
|
8
|
+
*/
|
|
9
|
+
export function useSmoothStopwatchDisplayMs(serverMs: number, smooth: boolean): number {
|
|
10
|
+
const base = Math.max(0, Math.floor(serverMs));
|
|
11
|
+
const [display, setDisplay] = useState(base);
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
if (!smooth) {
|
|
14
|
+
setDisplay(base);
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
setDisplay(base);
|
|
18
|
+
const t0 = Date.now();
|
|
19
|
+
const id = globalThis.setInterval(() => {
|
|
20
|
+
setDisplay(base + (Date.now() - t0));
|
|
21
|
+
}, 50);
|
|
22
|
+
return () => globalThis.clearInterval(id);
|
|
23
|
+
}, [smooth, base]);
|
|
24
|
+
return smooth ? display : base;
|
|
25
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mise en page de l’en-tête commun (repères de repli, bordure, fond) : identique sur toutes
|
|
3
|
+
* les routes pour limiter les sauts visuels lors des changements de page.
|
|
4
|
+
*/
|
|
5
|
+
export const appShellHeaderClassName =
|
|
6
|
+
"sticky top-0 z-50 border-b border-zinc-200 bg-zinc-100/95 backdrop-blur-sm dark:border-zinc-800 dark:bg-zinc-900/95 px-5 py-5 sm:px-8 sm:py-5 lg:px-10 lg:py-6 xl:px-12";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Ligne titre / fil d’Ariane (gauche) et barre d’outils navigation + thème + langue (droite).
|
|
10
|
+
*/
|
|
11
|
+
export const appShellHeaderToolRowClassName =
|
|
12
|
+
"flex w-full flex-col gap-3 sm:flex-row sm:items-start sm:justify-between";
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { unzipSync } from "fflate";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import type { KronosysUpdatePayload } from "@/lib/kronosysApi";
|
|
4
|
+
import {
|
|
5
|
+
buildCsvBackupZipSync,
|
|
6
|
+
buildGitIdentityCsv,
|
|
7
|
+
buildSessionsBackupCsv,
|
|
8
|
+
buildStoreJsonBlobsCsv,
|
|
9
|
+
buildSubtasksBackupCsv,
|
|
10
|
+
buildTagDescriptionsCsv,
|
|
11
|
+
buildTagProjectRegistryCsv,
|
|
12
|
+
buildTasksBackupCsv,
|
|
13
|
+
isCsvBackupTable,
|
|
14
|
+
} from "./backupCsvExport";
|
|
15
|
+
|
|
16
|
+
function samplePayload(): KronosysUpdatePayload {
|
|
17
|
+
return {
|
|
18
|
+
viewType: "dashboard",
|
|
19
|
+
cfg: { autoStart: false, plannedSessions: [{ id: "p1" }] },
|
|
20
|
+
history: [
|
|
21
|
+
{
|
|
22
|
+
sessionId: "s-hist",
|
|
23
|
+
sessionName: "Hist",
|
|
24
|
+
startAt: "2026-04-01T10:00:00.000Z",
|
|
25
|
+
endAt: "2026-04-01T11:00:00.000Z",
|
|
26
|
+
savedAt: "2026-04-01T11:05:00.000Z",
|
|
27
|
+
sessionDurationMinutes: 60,
|
|
28
|
+
tasks: [
|
|
29
|
+
{
|
|
30
|
+
id: "t1",
|
|
31
|
+
name: "Do, thing",
|
|
32
|
+
tags: ["a", "b"],
|
|
33
|
+
project: "acme",
|
|
34
|
+
isDone: true,
|
|
35
|
+
durationMs: 3_600_000,
|
|
36
|
+
subtasks: [{ id: "st1", title: "Step, one", done: true, durationMs: 1000 }],
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
historyArchived: [
|
|
42
|
+
{
|
|
43
|
+
sessionId: "s-arch",
|
|
44
|
+
sessionName: "Arch",
|
|
45
|
+
savedAt: "2026-04-02T12:00:00.000Z",
|
|
46
|
+
tasks: [],
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
current: {
|
|
50
|
+
sessionId: "s-live",
|
|
51
|
+
sessionName: "Live",
|
|
52
|
+
savedAt: "2026-04-03T08:00:00.000Z",
|
|
53
|
+
activeTasks: [{ id: "t2", name: "Running", isDone: false, durationMs: 0 }],
|
|
54
|
+
tasks: [],
|
|
55
|
+
},
|
|
56
|
+
knownTags: ["a"],
|
|
57
|
+
knownProjects: ["acme"],
|
|
58
|
+
userKnownTags: ["pinned"],
|
|
59
|
+
excludedSuggestionTags: ["hide-me"],
|
|
60
|
+
tagDescriptions: { alpha: "Desc, with comma" },
|
|
61
|
+
projectDescriptions: { acme: "Client" },
|
|
62
|
+
gitIdentity: { gitUserName: "n", gitUserEmail: "e@x.ca", gitAccountLogin: "gh" },
|
|
63
|
+
inspectingSessionId: "s-hist",
|
|
64
|
+
dismissArchiveSessionConfirm: true,
|
|
65
|
+
} as KronosysUpdatePayload;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
describe("backupCsvExport", () => {
|
|
69
|
+
it("isCsvBackupTable reconnaît les noms de tables", () => {
|
|
70
|
+
expect(isCsvBackupTable("sessions")).toBe(true);
|
|
71
|
+
expect(isCsvBackupTable("nope")).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("buildSessionsBackupCsv inclut la source et le JSON de session", () => {
|
|
75
|
+
const csv = buildSessionsBackupCsv(samplePayload());
|
|
76
|
+
expect(csv).toContain("scheduledStartAt");
|
|
77
|
+
expect(csv).toContain("sessionStartOffsetMinutes");
|
|
78
|
+
expect(csv).toContain("sessionSource");
|
|
79
|
+
expect(csv).toContain("s-hist");
|
|
80
|
+
expect(csv).toContain("history");
|
|
81
|
+
expect(csv).toContain("historyArchived");
|
|
82
|
+
expect(csv).toContain("current");
|
|
83
|
+
expect(csv).toContain("sessionJson");
|
|
84
|
+
expect(csv).toContain("Hist");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("buildTasksBackupCsv échappe les virgules dans le nom et inclut taskJson", () => {
|
|
88
|
+
const csv = buildTasksBackupCsv(samplePayload());
|
|
89
|
+
expect(csv).toContain("Do, thing");
|
|
90
|
+
expect(csv).toContain("t1");
|
|
91
|
+
expect(csv).toContain("taskJson");
|
|
92
|
+
expect(csv).toContain('""id"":""t1""');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("buildSubtasksBackupCsv liste les sous-tâches", () => {
|
|
96
|
+
const csv = buildSubtasksBackupCsv(samplePayload());
|
|
97
|
+
expect(csv).toContain("st1");
|
|
98
|
+
expect(csv).toContain("Step, one");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("buildTagProjectRegistryCsv couvre les quatre listes", () => {
|
|
102
|
+
const csv = buildTagProjectRegistryCsv(samplePayload());
|
|
103
|
+
expect(csv).toContain("known_tag,a");
|
|
104
|
+
expect(csv).toContain("known_project,acme");
|
|
105
|
+
expect(csv).toContain("user_known_tag,pinned");
|
|
106
|
+
expect(csv).toContain("excluded_suggestion_tag,hide-me");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("buildTagDescriptionsCsv échappe les descriptions", () => {
|
|
110
|
+
const csv = buildTagDescriptionsCsv(samplePayload());
|
|
111
|
+
expect(csv).toContain("alpha");
|
|
112
|
+
expect(csv).toContain("Desc, with comma");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("buildGitIdentityCsv exporte les trois champs", () => {
|
|
116
|
+
const csv = buildGitIdentityCsv(samplePayload());
|
|
117
|
+
expect(csv.split("\n")[1]).toContain("n");
|
|
118
|
+
expect(csv.split("\n")[1]).toContain("e@x.ca");
|
|
119
|
+
expect(csv.split("\n")[1]).toContain("gh");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("buildCsvBackupZipSync regroupe les huit CSV", () => {
|
|
123
|
+
const stamp = "TEST-STAMP";
|
|
124
|
+
const zip = buildCsvBackupZipSync(samplePayload(), stamp);
|
|
125
|
+
const entries = unzipSync(zip);
|
|
126
|
+
const names = Object.keys(entries).sort();
|
|
127
|
+
expect(names).toEqual([
|
|
128
|
+
`kronosys-git_identity-${stamp}.csv`,
|
|
129
|
+
`kronosys-project_descriptions-${stamp}.csv`,
|
|
130
|
+
`kronosys-sessions-${stamp}.csv`,
|
|
131
|
+
`kronosys-store_json_blobs-${stamp}.csv`,
|
|
132
|
+
`kronosys-subtasks-${stamp}.csv`,
|
|
133
|
+
`kronosys-tag_descriptions-${stamp}.csv`,
|
|
134
|
+
`kronosys-tag_project_registry-${stamp}.csv`,
|
|
135
|
+
`kronosys-tasks-${stamp}.csv`,
|
|
136
|
+
]);
|
|
137
|
+
const sessionsUtf8 = new TextDecoder().decode(entries[`kronosys-sessions-${stamp}.csv`]);
|
|
138
|
+
expect(sessionsUtf8).toContain("sessionId");
|
|
139
|
+
expect(sessionsUtf8).toContain("s-hist");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("buildStoreJsonBlobsCsv inclut cfg et métadonnées de tableau de bord", () => {
|
|
143
|
+
const csv = buildStoreJsonBlobsCsv(samplePayload());
|
|
144
|
+
expect(csv.startsWith("blobKind,json\n")).toBe(true);
|
|
145
|
+
expect(csv).toContain("autoStart");
|
|
146
|
+
expect(csv).toContain("inspectingSessionId");
|
|
147
|
+
expect(csv).toContain("s-hist");
|
|
148
|
+
});
|
|
149
|
+
});
|