@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,179 @@
|
|
|
1
|
+
import type { DashboardSearchItem } from "@/lib/dashboardQuickSearch";
|
|
2
|
+
|
|
3
|
+
export type DurationSearchPredicate = { op: "lt" | "gt"; seconds: number };
|
|
4
|
+
|
|
5
|
+
function timeToSeconds2(m: number, s: number): number {
|
|
6
|
+
return m * 60 + s;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function timeToSeconds3(h: number, m: number, s: number): number {
|
|
10
|
+
return h * 3600 + m * 60 + s;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
type Parsed = { op: "lt" | "gt"; seconds: number; match: string };
|
|
14
|
+
|
|
15
|
+
function asOp(s: string): "lt" | "gt" | null {
|
|
16
|
+
if (s === ">") {
|
|
17
|
+
return "gt";
|
|
18
|
+
}
|
|
19
|
+
if (s === "<") {
|
|
20
|
+
return "lt";
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Cherche le plus tôt { index, kind, groups } en essayant 4 formes, triées par longueur de match décroissante. */
|
|
26
|
+
function findFirstDurationClause(s: string, from: number): Parsed | null {
|
|
27
|
+
const attempts: Array<() => Parsed | null> = [
|
|
28
|
+
() => {
|
|
29
|
+
const m = s.slice(from).match(/^(\d+):(\d+):(\d+)\s*([><])/);
|
|
30
|
+
if (!m) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
const o = asOp(m[4]!);
|
|
34
|
+
if (!o) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
const a = Number(m[1]);
|
|
38
|
+
const b = Number(m[2]);
|
|
39
|
+
const c = Number(m[3]);
|
|
40
|
+
if (![a, b, c].every((x) => Number.isFinite(x) && x >= 0)) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
return { op: o, seconds: timeToSeconds3(a, b, c), match: m[0]! };
|
|
44
|
+
},
|
|
45
|
+
() => {
|
|
46
|
+
const m = s.slice(from).match(/^([><])\s*(\d+):(\d+):(\d+)/);
|
|
47
|
+
if (!m) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
const o = asOp(m[1]!);
|
|
51
|
+
if (!o) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
const a = Number(m[2]);
|
|
55
|
+
const b = Number(m[3]);
|
|
56
|
+
const c = Number(m[4]);
|
|
57
|
+
if (![a, b, c].every((x) => Number.isFinite(x) && x >= 0)) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
return { op: o, seconds: timeToSeconds3(a, b, c), match: m[0]! };
|
|
61
|
+
},
|
|
62
|
+
() => {
|
|
63
|
+
const m = s.slice(from).match(/^(\d+):(\d+)\s*([><])/);
|
|
64
|
+
if (!m) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
const o = asOp(m[3]!);
|
|
68
|
+
if (!o) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
const a = Number(m[1]);
|
|
72
|
+
const b = Number(m[2]);
|
|
73
|
+
if (![a, b].every((x) => Number.isFinite(x) && x >= 0)) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
return { op: o, seconds: timeToSeconds2(a, b), match: m[0]! };
|
|
77
|
+
},
|
|
78
|
+
() => {
|
|
79
|
+
const m = s.slice(from).match(/^([><])\s*(\d+):(\d+)/);
|
|
80
|
+
if (!m) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
const o = asOp(m[1]!);
|
|
84
|
+
if (!o) {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
const a = Number(m[2]);
|
|
88
|
+
const b = Number(m[3]);
|
|
89
|
+
if (![a, b].every((x) => Number.isFinite(x) && x >= 0)) {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
return { op: o, seconds: timeToSeconds2(a, b), match: m[0]! };
|
|
93
|
+
},
|
|
94
|
+
];
|
|
95
|
+
for (const tryAt of attempts) {
|
|
96
|
+
const p = tryAt();
|
|
97
|
+
if (p) {
|
|
98
|
+
return p;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Découpe la requête : prédicats de durée (tous requis) et
|
|
106
|
+
* le reste pour le filtrage sur le haystack.
|
|
107
|
+
*/
|
|
108
|
+
export function parseDataSearchQuery(raw: string): {
|
|
109
|
+
textPart: string;
|
|
110
|
+
durationPredicates: DurationSearchPredicate[];
|
|
111
|
+
} {
|
|
112
|
+
const seen = new Set<string>();
|
|
113
|
+
const durationPredicates: DurationSearchPredicate[] = [];
|
|
114
|
+
const add = (p: Parsed) => {
|
|
115
|
+
const row: DurationSearchPredicate = { op: p.op, seconds: p.seconds };
|
|
116
|
+
const k = `${row.op}\0${row.seconds}`;
|
|
117
|
+
if (!seen.has(k)) {
|
|
118
|
+
seen.add(k);
|
|
119
|
+
durationPredicates.push(row);
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
let t = String(raw)
|
|
124
|
+
.replace(/\r\n/g, "\n")
|
|
125
|
+
.replace(/\n/g, " ");
|
|
126
|
+
let from = 0;
|
|
127
|
+
while (from < t.length) {
|
|
128
|
+
if (!/[\d><]/.test(t[from]!)) {
|
|
129
|
+
from += 1;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
const p = findFirstDurationClause(t, from);
|
|
133
|
+
if (p) {
|
|
134
|
+
add(p);
|
|
135
|
+
t = `${t.slice(0, from)} ${t.slice(from + p.match.length)}`;
|
|
136
|
+
from = 0;
|
|
137
|
+
} else {
|
|
138
|
+
from += 1;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const textPart = t.replace(/\s+/g, " ").trim().toLowerCase();
|
|
142
|
+
return {
|
|
143
|
+
textPart,
|
|
144
|
+
durationPredicates,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function itemPassesDuration(
|
|
149
|
+
item: DashboardSearchItem,
|
|
150
|
+
predicates: DurationSearchPredicate[]
|
|
151
|
+
): boolean {
|
|
152
|
+
if (predicates.length === 0) {
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
const sec = item.durationForSearchSec;
|
|
156
|
+
if (sec === undefined || !Number.isFinite(sec)) {
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
for (const p of predicates) {
|
|
160
|
+
if (p.op === "gt" && sec <= p.seconds) {
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
if (p.op === "lt" && sec >= p.seconds) {
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function dataSearchItemMatches(
|
|
171
|
+
item: DashboardSearchItem,
|
|
172
|
+
queryTrimmed: string
|
|
173
|
+
): boolean {
|
|
174
|
+
const { textPart, durationPredicates } = parseDataSearchQuery(queryTrimmed);
|
|
175
|
+
if (textPart.length > 0 && !item.haystack.includes(textPart)) {
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
return itemPassesDuration(item, durationPredicates);
|
|
179
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ajoute `session` à l’URL pour conserver la session du tableau de bord lors des changements de route.
|
|
3
|
+
* La query est insérée avant le fragment (`#…`), conformément aux URI.
|
|
4
|
+
*/
|
|
5
|
+
export function withDashboardSessionParam(path: string, sessionId: string | null | undefined): string {
|
|
6
|
+
const id = typeof sessionId === "string" ? sessionId.trim() : "";
|
|
7
|
+
if (!id) {
|
|
8
|
+
return path;
|
|
9
|
+
}
|
|
10
|
+
const hashIndex = path.indexOf("#");
|
|
11
|
+
const base = hashIndex >= 0 ? path.slice(0, hashIndex) : path;
|
|
12
|
+
const hash = hashIndex >= 0 ? path.slice(hashIndex) : "";
|
|
13
|
+
const joiner = base.includes("?") ? "&" : "?";
|
|
14
|
+
return `${base}${joiner}session=${encodeURIComponent(id)}${hash}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Met à jour ou retire `session` dans la query en conservant les autres paramètres (ex. `tour`).
|
|
19
|
+
*/
|
|
20
|
+
export function pathnameWithUpdatedSessionQuery(
|
|
21
|
+
pathname: string,
|
|
22
|
+
currentSearch: string,
|
|
23
|
+
sessionId: string | null
|
|
24
|
+
): string {
|
|
25
|
+
const next = new URLSearchParams(currentSearch);
|
|
26
|
+
if (sessionId === null || sessionId === "") {
|
|
27
|
+
next.delete("session");
|
|
28
|
+
} else {
|
|
29
|
+
next.set("session", sessionId);
|
|
30
|
+
}
|
|
31
|
+
const q = next.toString();
|
|
32
|
+
return q ? `${pathname}?${q}` : pathname;
|
|
33
|
+
}
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/** Stockage local des raccourcis personnalisés (tableau de bord). */
|
|
2
|
+
export const DASHBOARD_SHORTCUTS_STORAGE_KEY = "kronosys.dashboard.keybindings.v1";
|
|
3
|
+
|
|
4
|
+
/** Combinaisons par défaut (identifiant d’action → chaîne `ctrl+alt+n`, etc.). */
|
|
5
|
+
export const DEFAULT_DASHBOARD_COMBOS: Record<string, string> = {
|
|
6
|
+
"new-session": "alt+shift+n",
|
|
7
|
+
refresh: "alt+shift+r",
|
|
8
|
+
reporting: "alt+shift+g",
|
|
9
|
+
settings: "alt+shift+s",
|
|
10
|
+
"user-guide": "alt+shift+u",
|
|
11
|
+
"focus-sessions": "ctrl+alt+h",
|
|
12
|
+
"focus-tasks": "ctrl+alt+j",
|
|
13
|
+
"focus-tags": "ctrl+alt+y",
|
|
14
|
+
"toggle-theme": "alt+shift+t",
|
|
15
|
+
"toggle-lang": "alt+shift+l",
|
|
16
|
+
"end-live-session": "alt+shift+e",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type ParsedCombo = {
|
|
20
|
+
ctrl: boolean;
|
|
21
|
+
meta: boolean;
|
|
22
|
+
shift: boolean;
|
|
23
|
+
alt: boolean;
|
|
24
|
+
/** Touche logique : lettre minuscule, `digit1`…`digit9`, ou noms `escape`, `enter`… */
|
|
25
|
+
key: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const MODS = new Set([
|
|
29
|
+
"ctrl",
|
|
30
|
+
"control",
|
|
31
|
+
"meta",
|
|
32
|
+
"cmd",
|
|
33
|
+
"command",
|
|
34
|
+
"shift",
|
|
35
|
+
"alt",
|
|
36
|
+
"option",
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
export function parseComboString(raw: string): ParsedCombo | null {
|
|
40
|
+
const s = raw.trim().toLowerCase();
|
|
41
|
+
if (!s) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
const parts = s.split("+").map((x) => x.trim());
|
|
45
|
+
let ctrl = false;
|
|
46
|
+
let meta = false;
|
|
47
|
+
let shift = false;
|
|
48
|
+
let alt = false;
|
|
49
|
+
let key = "";
|
|
50
|
+
for (const p of parts) {
|
|
51
|
+
if (!p) {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (MODS.has(p)) {
|
|
55
|
+
if (p === "ctrl" || p === "control") {
|
|
56
|
+
ctrl = true;
|
|
57
|
+
}
|
|
58
|
+
if (p === "meta" || p === "cmd" || p === "command") {
|
|
59
|
+
meta = true;
|
|
60
|
+
}
|
|
61
|
+
if (p === "shift") {
|
|
62
|
+
shift = true;
|
|
63
|
+
}
|
|
64
|
+
if (p === "alt" || p === "option") {
|
|
65
|
+
alt = true;
|
|
66
|
+
}
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
key = p;
|
|
70
|
+
}
|
|
71
|
+
if (!key) {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
if (key === "space") {
|
|
75
|
+
key = " ";
|
|
76
|
+
}
|
|
77
|
+
const digit = key.match(/^digit([0-9])$/);
|
|
78
|
+
if (digit) {
|
|
79
|
+
key = digit[1] ?? key;
|
|
80
|
+
}
|
|
81
|
+
return { ctrl, meta, shift, alt, key };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function eventKeyToken(e: KeyboardEvent): string {
|
|
85
|
+
const code = typeof e.code === "string" ? e.code : "";
|
|
86
|
+
if (code.startsWith("Digit") && code.length === 6) {
|
|
87
|
+
return code.slice(5).toLowerCase();
|
|
88
|
+
}
|
|
89
|
+
if (code === "Space") {
|
|
90
|
+
return " ";
|
|
91
|
+
}
|
|
92
|
+
const key = typeof e.key === "string" ? e.key : "";
|
|
93
|
+
if (key.length === 1) {
|
|
94
|
+
return key.toLowerCase();
|
|
95
|
+
}
|
|
96
|
+
if (key) {
|
|
97
|
+
return key.toLowerCase();
|
|
98
|
+
}
|
|
99
|
+
if (code.startsWith("Key") && code.length === 4) {
|
|
100
|
+
return code.slice(3).toLowerCase();
|
|
101
|
+
}
|
|
102
|
+
return "";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Vérifie si `e` correspond à la combinaison (modificateurs + touche). */
|
|
106
|
+
export function eventMatchesCombo(e: KeyboardEvent, combo: string): boolean {
|
|
107
|
+
const p = parseComboString(combo);
|
|
108
|
+
if (!p) {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
const token = eventKeyToken(e);
|
|
112
|
+
const rawKey = typeof e.key === "string" ? e.key : "";
|
|
113
|
+
const rawCode = typeof e.code === "string" ? e.code : "";
|
|
114
|
+
if (token !== p.key && !(p.key === " " && (rawKey === " " || rawCode === "Space"))) {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
if (e.ctrlKey !== p.ctrl) {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
if (e.metaKey !== p.meta) {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
if (e.shiftKey !== p.shift) {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
if (e.altKey !== p.alt) {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Palette : Ctrl+K ou Cmd+K (sans autre modificateur). */
|
|
133
|
+
export function eventOpensCommandPalette(e: KeyboardEvent): boolean {
|
|
134
|
+
const rawKey = typeof e.key === "string" ? e.key : "";
|
|
135
|
+
const rawCode = typeof e.code === "string" ? e.code : "";
|
|
136
|
+
const isK = rawKey.toLowerCase() === "k" || rawCode === "KeyK";
|
|
137
|
+
if (!isK) {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
if (e.shiftKey || e.altKey) {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
return e.ctrlKey || e.metaKey;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function loadComboOverrides(): Record<string, string> {
|
|
147
|
+
if (typeof window === "undefined") {
|
|
148
|
+
return {};
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
const raw = window.localStorage.getItem(DASHBOARD_SHORTCUTS_STORAGE_KEY);
|
|
152
|
+
if (!raw) {
|
|
153
|
+
return {};
|
|
154
|
+
}
|
|
155
|
+
const o = JSON.parse(raw) as unknown;
|
|
156
|
+
if (!o || typeof o !== "object" || Array.isArray(o)) {
|
|
157
|
+
return {};
|
|
158
|
+
}
|
|
159
|
+
const out: Record<string, string> = {};
|
|
160
|
+
for (const [k, v] of Object.entries(o as Record<string, unknown>)) {
|
|
161
|
+
if (typeof v === "string" && v.trim()) {
|
|
162
|
+
out[k] = v.trim().toLowerCase();
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return out;
|
|
166
|
+
} catch {
|
|
167
|
+
return {};
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function saveComboOverrides(overrides: Record<string, string>): void {
|
|
172
|
+
if (typeof window === "undefined") {
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
try {
|
|
176
|
+
window.localStorage.setItem(DASHBOARD_SHORTCUTS_STORAGE_KEY, JSON.stringify(overrides));
|
|
177
|
+
} catch {
|
|
178
|
+
/* quota / mode privé */
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function mergeCombos(overrides: Record<string, string>): Record<string, string> {
|
|
183
|
+
return { ...DEFAULT_DASHBOARD_COMBOS, ...overrides };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Sérialise un événement clavier (hors touches seules type Shift) pour enregistrement. */
|
|
187
|
+
export function serializeEventAsCombo(e: KeyboardEvent): string | null {
|
|
188
|
+
if (e.repeat) {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
const keyTok = eventKeyToken(e);
|
|
192
|
+
if (!keyTok || keyTok === "control" || keyTok === "meta" || keyTok === "shift" || keyTok === "alt") {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
if (typeof e.key === "string" && e.key === "Escape") {
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
const parts: string[] = [];
|
|
199
|
+
if (e.ctrlKey) {
|
|
200
|
+
parts.push("ctrl");
|
|
201
|
+
}
|
|
202
|
+
if (e.metaKey) {
|
|
203
|
+
parts.push("meta");
|
|
204
|
+
}
|
|
205
|
+
if (e.shiftKey) {
|
|
206
|
+
parts.push("shift");
|
|
207
|
+
}
|
|
208
|
+
if (e.altKey) {
|
|
209
|
+
parts.push("alt");
|
|
210
|
+
}
|
|
211
|
+
if (parts.length === 0) {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
const rawCode = typeof e.code === "string" ? e.code : "";
|
|
215
|
+
const rawKey = typeof e.key === "string" ? e.key : "";
|
|
216
|
+
const k =
|
|
217
|
+
rawCode.startsWith("Digit") && rawCode.length === 6
|
|
218
|
+
? rawCode.slice(5).toLowerCase()
|
|
219
|
+
: rawCode === "Space" || rawKey === " "
|
|
220
|
+
? "space"
|
|
221
|
+
: keyTok.length === 1
|
|
222
|
+
? keyTok
|
|
223
|
+
: rawKey.length === 1
|
|
224
|
+
? rawKey.toLowerCase()
|
|
225
|
+
: keyTok;
|
|
226
|
+
if (!k) {
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
parts.push(k);
|
|
230
|
+
return parts.join("+");
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function isReservedPaletteCombo(combo: string): boolean {
|
|
234
|
+
const p = parseComboString(combo);
|
|
235
|
+
if (!p) {
|
|
236
|
+
return true;
|
|
237
|
+
}
|
|
238
|
+
const mod = p.ctrl || p.meta;
|
|
239
|
+
return mod && !p.shift && !p.alt && p.key === "k";
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function formatComboForDisplay(combo: string, isMac: boolean): string {
|
|
243
|
+
const p = parseComboString(combo);
|
|
244
|
+
if (!p) {
|
|
245
|
+
return combo;
|
|
246
|
+
}
|
|
247
|
+
const bits: string[] = [];
|
|
248
|
+
if (p.ctrl) {
|
|
249
|
+
bits.push(isMac ? "⌃" : "Ctrl");
|
|
250
|
+
}
|
|
251
|
+
if (p.meta) {
|
|
252
|
+
bits.push(isMac ? "⌘" : "Win");
|
|
253
|
+
}
|
|
254
|
+
if (p.shift) {
|
|
255
|
+
bits.push(isMac ? "⇧" : "Shift");
|
|
256
|
+
}
|
|
257
|
+
if (p.alt) {
|
|
258
|
+
bits.push(isMac ? "⌥" : "Alt");
|
|
259
|
+
}
|
|
260
|
+
const k =
|
|
261
|
+
p.key === " "
|
|
262
|
+
? "Space"
|
|
263
|
+
: p.key.length === 1
|
|
264
|
+
? p.key.toUpperCase()
|
|
265
|
+
: p.key.charAt(0).toUpperCase() + p.key.slice(1);
|
|
266
|
+
bits.push(k);
|
|
267
|
+
return bits.join(isMac ? "" : "+");
|
|
268
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/** Fuseau IANA par défaut : heure de l’Est (Amérique du Nord). */
|
|
2
|
+
export const DEFAULT_DASHBOARD_TIME_ZONE = "America/Toronto";
|
|
3
|
+
|
|
4
|
+
const CFG_KEY = "dashboardDisplayTimeZone";
|
|
5
|
+
|
|
6
|
+
/** Libellés pour la liste déroulante des paramètres (clé = identifiant IANA). */
|
|
7
|
+
export const DASHBOARD_TIME_ZONE_SELECT_OPTIONS: readonly {
|
|
8
|
+
id: string;
|
|
9
|
+
labelEn: string;
|
|
10
|
+
labelFr: string;
|
|
11
|
+
}[] = [
|
|
12
|
+
{ id: "America/Toronto", labelEn: "Eastern — Toronto", labelFr: "Heure de l’Est — Toronto" },
|
|
13
|
+
{ id: "America/New_York", labelEn: "Eastern — New York", labelFr: "Heure de l’Est — New York" },
|
|
14
|
+
{ id: "America/Chicago", labelEn: "Central — Chicago", labelFr: "Heure du Centre — Chicago" },
|
|
15
|
+
{ id: "America/Denver", labelEn: "Mountain — Denver", labelFr: "Heure des Rocheuses — Denver" },
|
|
16
|
+
{ id: "America/Los_Angeles", labelEn: "Pacific — Los Angeles", labelFr: "Heure du Pacifique — Los Angeles" },
|
|
17
|
+
{ id: "America/Vancouver", labelEn: "Pacific — Vancouver", labelFr: "Heure du Pacifique — Vancouver" },
|
|
18
|
+
{ id: "America/Halifax", labelEn: "Atlantic — Halifax", labelFr: "Heure de l’Atlantique — Halifax" },
|
|
19
|
+
{ id: "America/St_Johns", labelEn: "Newfoundland — St. John’s", labelFr: "Terre-Neuve — Saint-Jean" },
|
|
20
|
+
{ id: "America/Mexico_City", labelEn: "Central — Mexico City", labelFr: "Heure du Centre — Mexico" },
|
|
21
|
+
{ id: "America/Sao_Paulo", labelEn: "São Paulo", labelFr: "São Paulo" },
|
|
22
|
+
{ id: "Europe/London", labelEn: "London", labelFr: "Londres" },
|
|
23
|
+
{ id: "Europe/Paris", labelEn: "Paris", labelFr: "Paris" },
|
|
24
|
+
{ id: "Europe/Berlin", labelEn: "Berlin", labelFr: "Berlin" },
|
|
25
|
+
{ id: "Europe/Madrid", labelEn: "Madrid", labelFr: "Madrid" },
|
|
26
|
+
{ id: "Europe/Zurich", labelEn: "Zurich", labelFr: "Zurich" },
|
|
27
|
+
{ id: "Asia/Tokyo", labelEn: "Tokyo", labelFr: "Tokyo" },
|
|
28
|
+
{ id: "Asia/Seoul", labelEn: "Seoul", labelFr: "Séoul" },
|
|
29
|
+
{ id: "Asia/Shanghai", labelEn: "Shanghai", labelFr: "Shanghai" },
|
|
30
|
+
{ id: "Asia/Singapore", labelEn: "Singapore", labelFr: "Singapour" },
|
|
31
|
+
{ id: "Asia/Kolkata", labelEn: "India", labelFr: "Inde" },
|
|
32
|
+
{ id: "Australia/Sydney", labelEn: "Sydney", labelFr: "Sydney" },
|
|
33
|
+
{ id: "Pacific/Auckland", labelEn: "Auckland", labelFr: "Auckland" },
|
|
34
|
+
{ id: "UTC", labelEn: "UTC", labelFr: "UTC" },
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
export function isValidIanaTimeZone(tz: string): boolean {
|
|
38
|
+
const t = tz.trim();
|
|
39
|
+
if (!t) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
new Intl.DateTimeFormat("en-US", { timeZone: t }).format();
|
|
44
|
+
return true;
|
|
45
|
+
} catch {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function readDashboardTimeZoneFromCfg(cfg: Record<string, unknown> | undefined): string {
|
|
51
|
+
const raw = cfg?.[CFG_KEY];
|
|
52
|
+
if (typeof raw !== "string") {
|
|
53
|
+
return DEFAULT_DASHBOARD_TIME_ZONE;
|
|
54
|
+
}
|
|
55
|
+
const z = raw.trim();
|
|
56
|
+
return z && isValidIanaTimeZone(z) ? z : DEFAULT_DASHBOARD_TIME_ZONE;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Jour calendaire `YYYY-MM-DD` dans le fuseau donné (pour regroupements rapport / filtres).
|
|
61
|
+
*/
|
|
62
|
+
export function calendarDateKeyInTimeZone(iso: string | null | undefined, timeZone: string): string | null {
|
|
63
|
+
if (!iso || typeof iso !== "string") {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
const d = new Date(iso);
|
|
67
|
+
if (Number.isNaN(d.getTime())) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
const tz = timeZone.trim() || DEFAULT_DASHBOARD_TIME_ZONE;
|
|
71
|
+
if (!isValidIanaTimeZone(tz)) {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
const parts = new Intl.DateTimeFormat("en-CA", {
|
|
76
|
+
timeZone: tz,
|
|
77
|
+
year: "numeric",
|
|
78
|
+
month: "2-digit",
|
|
79
|
+
day: "2-digit",
|
|
80
|
+
}).formatToParts(d);
|
|
81
|
+
const y = parts.find((p) => p.type === "year")?.value;
|
|
82
|
+
const m = parts.find((p) => p.type === "month")?.value;
|
|
83
|
+
const day = parts.find((p) => p.type === "day")?.value;
|
|
84
|
+
if (!y || !m || !day) {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
return `${y}-${m}-${day}`;
|
|
88
|
+
} catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/** Indique que la visite guidée du tableau de bord a été vue (ou passée). */
|
|
2
|
+
export const DASHBOARD_TOUR_COMPLETED_KEY = "kronosys.dashboard.tour.completed.v1";
|
|
3
|
+
/** Indique que la visite guidée des paramètres a été vue (ou passée). */
|
|
4
|
+
export const SETTINGS_TOUR_COMPLETED_KEY = "kronosys.settings.tour.completed.v1";
|
|
5
|
+
/** Indique que la visite guidée de la page Rapports a été vue (ou passée). */
|
|
6
|
+
export const REPORTING_TOUR_COMPLETED_KEY = "kronosys.reporting.tour.completed.v1";
|
|
7
|
+
|
|
8
|
+
function isCompleted(key: string): boolean {
|
|
9
|
+
if (typeof window === "undefined") {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
try {
|
|
13
|
+
return window.localStorage.getItem(key) === "1";
|
|
14
|
+
} catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function markCompleted(key: string, value: boolean): void {
|
|
20
|
+
if (typeof window === "undefined") {
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
try {
|
|
24
|
+
if (value) {
|
|
25
|
+
window.localStorage.setItem(key, "1");
|
|
26
|
+
} else {
|
|
27
|
+
window.localStorage.removeItem(key);
|
|
28
|
+
}
|
|
29
|
+
} catch {
|
|
30
|
+
/* quota / navigation privée */
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function isDashboardTourCompleted(): boolean {
|
|
35
|
+
return isCompleted(DASHBOARD_TOUR_COMPLETED_KEY);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function markDashboardTourCompleted(): void {
|
|
39
|
+
markCompleted(DASHBOARD_TOUR_COMPLETED_KEY, true);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function resetDashboardTour(): void {
|
|
43
|
+
markCompleted(DASHBOARD_TOUR_COMPLETED_KEY, false);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function isSettingsTourCompleted(): boolean {
|
|
47
|
+
return isCompleted(SETTINGS_TOUR_COMPLETED_KEY);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function markSettingsTourCompleted(): void {
|
|
51
|
+
markCompleted(SETTINGS_TOUR_COMPLETED_KEY, true);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function resetSettingsTour(): void {
|
|
55
|
+
markCompleted(SETTINGS_TOUR_COMPLETED_KEY, false);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function isReportingTourCompleted(): boolean {
|
|
59
|
+
return isCompleted(REPORTING_TOUR_COMPLETED_KEY);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function markReportingTourCompleted(): void {
|
|
63
|
+
markCompleted(REPORTING_TOUR_COMPLETED_KEY, true);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function resetReportingTour(): void {
|
|
67
|
+
markCompleted(REPORTING_TOUR_COMPLETED_KEY, false);
|
|
68
|
+
}
|