@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,313 @@
|
|
|
1
|
+
/** Aligné sur {@link UNDATED_KEY} dans `reportingAggregate` (évite import circulaire). */
|
|
2
|
+
const UNDATED = "undated";
|
|
3
|
+
|
|
4
|
+
export type ReportingWeekStartsOn = "monday" | "sunday" | "saturday";
|
|
5
|
+
|
|
6
|
+
const JS_DAY: Record<ReportingWeekStartsOn, number> = {
|
|
7
|
+
monday: 1,
|
|
8
|
+
sunday: 0,
|
|
9
|
+
saturday: 6,
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
function pad2(n: number): string {
|
|
13
|
+
return String(n).padStart(2, "0");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** `YYYY-MM-DD` + `delta` jours (calendrier local). */
|
|
17
|
+
export function addDaysYmd(dayKey: string, delta: number): string {
|
|
18
|
+
const [y, m, d] = dayKey.split("-").map(Number);
|
|
19
|
+
const x = new Date(y, m - 1, d);
|
|
20
|
+
x.setDate(x.getDate() + delta);
|
|
21
|
+
return `${x.getFullYear()}-${pad2(x.getMonth() + 1)}-${pad2(x.getDate())}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Nombre de jours calendaires locaux entre deux clés `YYYY-MM-DD` (fin − début). */
|
|
25
|
+
export function localCalendarDaysBetween(startKey: string, endKey: string): number {
|
|
26
|
+
const [y1, m1, d1] = startKey.split("-").map(Number);
|
|
27
|
+
const [y2, m2, d2] = endKey.split("-").map(Number);
|
|
28
|
+
const a = new Date(y1, m1 - 1, d1);
|
|
29
|
+
const b = new Date(y2, m2 - 1, d2);
|
|
30
|
+
return Math.round((b.getTime() - a.getTime()) / 86400000);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Date du premier jour de la semaine (selon `weekStartsOn`) contenant `dayKey`.
|
|
35
|
+
*/
|
|
36
|
+
export function localWeekStartKeyFromDayKey(
|
|
37
|
+
dayKey: string,
|
|
38
|
+
weekStartsOn: ReportingWeekStartsOn
|
|
39
|
+
): string | null {
|
|
40
|
+
if (!dayKey || dayKey === UNDATED) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
const parts = dayKey.split("-");
|
|
44
|
+
if (parts.length !== 3) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
const y = Number(parts[0]);
|
|
48
|
+
const mo = Number(parts[1]);
|
|
49
|
+
const d = Number(parts[2]);
|
|
50
|
+
if (!y || !mo || !d) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
const x = new Date(y, mo - 1, d);
|
|
54
|
+
if (Number.isNaN(x.getTime())) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
const W = JS_DAY[weekStartsOn];
|
|
58
|
+
const dow = x.getDay();
|
|
59
|
+
const offset = (dow - W + 7) % 7;
|
|
60
|
+
x.setDate(x.getDate() - offset);
|
|
61
|
+
return `${x.getFullYear()}-${pad2(x.getMonth() + 1)}-${pad2(x.getDate())}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Libellés courts des 7 jours dans l’ordre des colonnes (selon le premier jour de semaine). */
|
|
65
|
+
export function weekdayColumnLabels(
|
|
66
|
+
weekStartsOn: ReportingWeekStartsOn,
|
|
67
|
+
locale: string
|
|
68
|
+
): string[] {
|
|
69
|
+
const fmt = new Intl.DateTimeFormat(locale, { weekday: "short" });
|
|
70
|
+
const baseSunday = new Date(2026, 0, 4);
|
|
71
|
+
const W = JS_DAY[weekStartsOn];
|
|
72
|
+
const deltaToFirst = (W - baseSunday.getDay() + 7) % 7;
|
|
73
|
+
const start = new Date(baseSunday);
|
|
74
|
+
start.setDate(start.getDate() + deltaToFirst);
|
|
75
|
+
const out: string[] = [];
|
|
76
|
+
for (let i = 0; i < 7; i++) {
|
|
77
|
+
const cell = new Date(start);
|
|
78
|
+
cell.setDate(cell.getDate() + i);
|
|
79
|
+
out.push(fmt.format(cell));
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export type WeekdayDateColumnHeader = {
|
|
85
|
+
/** Clé `YYYY-MM-DD` de la colonne (alignée sur les cellules du tableau). */
|
|
86
|
+
dateKey: string;
|
|
87
|
+
/** Jour de la semaine court (ex. lun., Mon). */
|
|
88
|
+
weekdayShort: string;
|
|
89
|
+
/** Date calendaire courte (ex. 3 févr., Feb 3). */
|
|
90
|
+
calendarDateShort: string;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* En-têtes des 7 colonnes d’une semaine : jour de la semaine + date réelle pour chaque `YYYY-MM-DD`
|
|
95
|
+
* à partir de `weekStartKey` (premier jour de la bande, selon le réglage « semaine commence »).
|
|
96
|
+
*/
|
|
97
|
+
export function weekdayDateColumnHeaders(
|
|
98
|
+
weekStartKey: string,
|
|
99
|
+
locale: string
|
|
100
|
+
): WeekdayDateColumnHeader[] {
|
|
101
|
+
const wdFmt = new Intl.DateTimeFormat(locale, { weekday: "short" });
|
|
102
|
+
const dateFmt = new Intl.DateTimeFormat(locale, { day: "numeric", month: "short" });
|
|
103
|
+
const out: WeekdayDateColumnHeader[] = [];
|
|
104
|
+
for (let i = 0; i < 7; i++) {
|
|
105
|
+
const dateKey = addDaysYmd(weekStartKey, i);
|
|
106
|
+
const [y, m, d] = dateKey.split("-").map(Number);
|
|
107
|
+
const cell = new Date(y, m - 1, d);
|
|
108
|
+
if (Number.isNaN(cell.getTime())) {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
out.push({
|
|
112
|
+
dateKey,
|
|
113
|
+
weekdayShort: wdFmt.format(cell),
|
|
114
|
+
calendarDateShort: dateFmt.format(cell),
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Plage affichée pour une ligne de semaine (dates locales). */
|
|
121
|
+
export function formatWeekRangeLabel(weekStartKey: string, locale: string): string {
|
|
122
|
+
const endKey = addDaysYmd(weekStartKey, 6);
|
|
123
|
+
const [y1, m1, d1] = weekStartKey.split("-").map(Number);
|
|
124
|
+
const [y2, m2, d2] = endKey.split("-").map(Number);
|
|
125
|
+
const di = new Intl.DateTimeFormat(locale, { day: "numeric", month: "short" });
|
|
126
|
+
if (m1 === m2 && y1 === y2) {
|
|
127
|
+
return `${d1}–${di.format(new Date(y2, m2 - 1, d2))} ${y2}`;
|
|
128
|
+
}
|
|
129
|
+
return `${di.format(new Date(y1, m1 - 1, d1))} – ${di.format(new Date(y2, m2 - 1, d2))} ${y2}`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export type TagWeekCalendarRow = {
|
|
133
|
+
tagKey: string;
|
|
134
|
+
displayTag: string;
|
|
135
|
+
weekStart: string;
|
|
136
|
+
slots: number[];
|
|
137
|
+
total: number;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
export type TagWeekCalendarDayInput = {
|
|
141
|
+
day: string;
|
|
142
|
+
tagKey: string;
|
|
143
|
+
displayTag: string;
|
|
144
|
+
minutes: number;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
export type KeyedWeekCalendarDayInput = {
|
|
148
|
+
day: string;
|
|
149
|
+
rowKey: string;
|
|
150
|
+
displayLabel: string;
|
|
151
|
+
minutes: number;
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
export type KeyedWeekCalendarRow = {
|
|
155
|
+
rowKey: string;
|
|
156
|
+
displayLabel: string;
|
|
157
|
+
weekStart: string;
|
|
158
|
+
slots: number[];
|
|
159
|
+
total: number;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
function compareRowKeyEmptyLast(a: string, b: string): number {
|
|
163
|
+
if (a === "" && b !== "") {
|
|
164
|
+
return 1;
|
|
165
|
+
}
|
|
166
|
+
if (b === "" && a !== "") {
|
|
167
|
+
return -1;
|
|
168
|
+
}
|
|
169
|
+
return a.localeCompare(b);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Agrège des lignes « jour » (clé + libellé) en grilles 7 jours + total par semaine.
|
|
174
|
+
*/
|
|
175
|
+
export function buildKeyedWeekCalendarRows(
|
|
176
|
+
byDay: readonly KeyedWeekCalendarDayInput[],
|
|
177
|
+
weekStartsOn: ReportingWeekStartsOn
|
|
178
|
+
): KeyedWeekCalendarRow[] {
|
|
179
|
+
type Acc = { displayLabel: string; weeks: Map<string, number[]> };
|
|
180
|
+
const byEntity = new Map<string, Acc>();
|
|
181
|
+
|
|
182
|
+
for (const row of byDay) {
|
|
183
|
+
if (row.day === UNDATED) {
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
const ws = localWeekStartKeyFromDayKey(row.day, weekStartsOn);
|
|
187
|
+
if (!ws) {
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
const idx = localCalendarDaysBetween(ws, row.day);
|
|
191
|
+
if (idx < 0 || idx > 6 || row.minutes <= 0) {
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
let acc = byEntity.get(row.rowKey);
|
|
196
|
+
if (!acc) {
|
|
197
|
+
acc = { displayLabel: row.displayLabel, weeks: new Map() };
|
|
198
|
+
byEntity.set(row.rowKey, acc);
|
|
199
|
+
}
|
|
200
|
+
if (row.displayLabel && row.rowKey !== "") {
|
|
201
|
+
acc.displayLabel = row.displayLabel;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
let slots = acc.weeks.get(ws);
|
|
205
|
+
if (!slots) {
|
|
206
|
+
slots = [0, 0, 0, 0, 0, 0, 0];
|
|
207
|
+
acc.weeks.set(ws, slots);
|
|
208
|
+
}
|
|
209
|
+
slots[idx] += row.minutes;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const flat: KeyedWeekCalendarRow[] = [];
|
|
213
|
+
for (const [rowKey, acc] of byEntity) {
|
|
214
|
+
for (const [weekStart, slots] of acc.weeks) {
|
|
215
|
+
const total = slots.reduce((s, v) => s + v, 0);
|
|
216
|
+
flat.push({
|
|
217
|
+
rowKey,
|
|
218
|
+
displayLabel: acc.displayLabel,
|
|
219
|
+
weekStart,
|
|
220
|
+
slots: [...slots],
|
|
221
|
+
total,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
flat.sort(
|
|
227
|
+
(a, b) =>
|
|
228
|
+
compareRowKeyEmptyLast(a.rowKey, b.rowKey) ||
|
|
229
|
+
a.weekStart.localeCompare(b.weekStart)
|
|
230
|
+
);
|
|
231
|
+
return flat;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Agrège les lignes « jour » en grilles 7 jours + total par semaine et par étiquette.
|
|
236
|
+
*/
|
|
237
|
+
export function buildTagWeekCalendarRows(
|
|
238
|
+
byDay: readonly TagWeekCalendarDayInput[],
|
|
239
|
+
weekStartsOn: ReportingWeekStartsOn
|
|
240
|
+
): TagWeekCalendarRow[] {
|
|
241
|
+
return buildKeyedWeekCalendarRows(
|
|
242
|
+
byDay.map((r) => ({
|
|
243
|
+
day: r.day,
|
|
244
|
+
rowKey: r.tagKey,
|
|
245
|
+
displayLabel: r.displayTag,
|
|
246
|
+
minutes: r.minutes,
|
|
247
|
+
})),
|
|
248
|
+
weekStartsOn
|
|
249
|
+
).map((r) => ({
|
|
250
|
+
tagKey: r.rowKey,
|
|
251
|
+
displayTag: r.displayLabel,
|
|
252
|
+
weekStart: r.weekStart,
|
|
253
|
+
slots: r.slots,
|
|
254
|
+
total: r.total,
|
|
255
|
+
}));
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export type ProjectWeekCalendarDayInput = {
|
|
259
|
+
day: string;
|
|
260
|
+
projectKey: string;
|
|
261
|
+
displayProject: string;
|
|
262
|
+
minutes: number;
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
export type ProjectWeekCalendarRow = {
|
|
266
|
+
projectKey: string;
|
|
267
|
+
displayProject: string;
|
|
268
|
+
weekStart: string;
|
|
269
|
+
slots: number[];
|
|
270
|
+
total: number;
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
/** Même logique que {@link buildTagWeekCalendarRows} pour les `@projet`. */
|
|
274
|
+
export function buildProjectWeekCalendarRows(
|
|
275
|
+
byDay: readonly ProjectWeekCalendarDayInput[],
|
|
276
|
+
weekStartsOn: ReportingWeekStartsOn
|
|
277
|
+
): ProjectWeekCalendarRow[] {
|
|
278
|
+
return buildKeyedWeekCalendarRows(
|
|
279
|
+
byDay.map((r) => ({
|
|
280
|
+
day: r.day,
|
|
281
|
+
rowKey: r.projectKey,
|
|
282
|
+
displayLabel: r.displayProject,
|
|
283
|
+
minutes: r.minutes,
|
|
284
|
+
})),
|
|
285
|
+
weekStartsOn
|
|
286
|
+
).map((r) => ({
|
|
287
|
+
projectKey: r.rowKey,
|
|
288
|
+
displayProject: r.displayLabel,
|
|
289
|
+
weekStart: r.weekStart,
|
|
290
|
+
slots: r.slots,
|
|
291
|
+
total: r.total,
|
|
292
|
+
}));
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export const REPORTING_WEEK_START_STORAGE_KEY = "kronosys-reporting-weekStartsOn";
|
|
296
|
+
|
|
297
|
+
export function readWeekStartsOnFromStorage(): ReportingWeekStartsOn {
|
|
298
|
+
if (typeof window === "undefined") {
|
|
299
|
+
return "monday";
|
|
300
|
+
}
|
|
301
|
+
const v = window.localStorage.getItem(REPORTING_WEEK_START_STORAGE_KEY);
|
|
302
|
+
if (v === "sunday" || v === "saturday" || v === "monday") {
|
|
303
|
+
return v;
|
|
304
|
+
}
|
|
305
|
+
return "monday";
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export function writeWeekStartsOnToStorage(value: ReportingWeekStartsOn): void {
|
|
309
|
+
if (typeof window === "undefined") {
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
window.localStorage.setItem(REPORTING_WEEK_START_STORAGE_KEY, value);
|
|
313
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { assiduityFromScheduledStart } from "./sessionAssiduity";
|
|
3
|
+
|
|
4
|
+
describe("sessionAssiduity", () => {
|
|
5
|
+
it("retourne un écart positif si l’ouverture est après l’heure prévue", () => {
|
|
6
|
+
const a = assiduityFromScheduledStart(
|
|
7
|
+
Date.parse("2026-04-24T09:15:00.000Z"),
|
|
8
|
+
"2026-04-24T09:00:00.000Z"
|
|
9
|
+
);
|
|
10
|
+
expect(a?.sessionStartOffsetMinutes).toBe(15);
|
|
11
|
+
expect(a?.scheduledStartAt).toBe("2026-04-24T09:00:00.000Z");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("retourne un écart négatif si l’ouverture est en avance", () => {
|
|
15
|
+
const a = assiduityFromScheduledStart(
|
|
16
|
+
Date.parse("2026-04-24T08:50:00.000Z"),
|
|
17
|
+
"2026-04-24T09:00:00.000Z"
|
|
18
|
+
);
|
|
19
|
+
expect(a?.sessionStartOffsetMinutes).toBe(-10);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("refuse une chaîne d’horodatage invalide", () => {
|
|
23
|
+
expect(assiduityFromScheduledStart(Date.now(), "pas-une-date")).toBeNull();
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Assiduité : comparaison entre l’heure d’ouverture réelle d’une session et une heure de
|
|
3
|
+
* début de référence fournie par l’hôte (p. ex. processus lié à l’horaire ou aux sessions
|
|
4
|
+
* planifiées). Sert au reporting (retards cumulés, moyennes) et à l’export.
|
|
5
|
+
*/
|
|
6
|
+
export type SessionStartAssiduity = {
|
|
7
|
+
/** `scheduledStartAt` normalisé (ISO). */
|
|
8
|
+
scheduledStartAt: string;
|
|
9
|
+
/**
|
|
10
|
+
* `startAt` effectif moins l’heure de référence, en minutes (arrondi) :
|
|
11
|
+
* positif = ouverture après l’heure prévue, négatif = avant.
|
|
12
|
+
*/
|
|
13
|
+
sessionStartOffsetMinutes: number;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param actualStartAtMs Horodatage de création de la session (même repère que `startAt`)
|
|
18
|
+
* @param scheduledStartAtIso Heure de début de référence (toute chaîne `Date` valide)
|
|
19
|
+
*/
|
|
20
|
+
export function assiduityFromScheduledStart(
|
|
21
|
+
actualStartAtMs: number,
|
|
22
|
+
scheduledStartAtIso: string
|
|
23
|
+
): SessionStartAssiduity | null {
|
|
24
|
+
const t = Date.parse(String(scheduledStartAtIso).trim());
|
|
25
|
+
if (!Number.isFinite(t)) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
const offset = Math.round((actualStartAtMs - t) / 60_000);
|
|
29
|
+
return {
|
|
30
|
+
scheduledStartAt: new Date(t).toISOString(),
|
|
31
|
+
sessionStartOffsetMinutes: offset,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { DashboardStrings } from "@/lib/dashboardCopy";
|
|
2
|
+
|
|
3
|
+
const ALLOWED_KINDS = new Set(["planned", "early", "overrun", "other"]);
|
|
4
|
+
|
|
5
|
+
export type SessionEndReasonKind = "planned" | "early" | "overrun" | "other";
|
|
6
|
+
|
|
7
|
+
export function normalizeSessionEndReasonKind(raw: unknown): SessionEndReasonKind | "" {
|
|
8
|
+
const s = typeof raw === "string" ? raw.trim() : "";
|
|
9
|
+
return ALLOWED_KINDS.has(s) ? (s as SessionEndReasonKind) : "";
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function normalizeSessionEndReasonNote(raw: unknown): string {
|
|
13
|
+
if (typeof raw !== "string") {
|
|
14
|
+
return "";
|
|
15
|
+
}
|
|
16
|
+
return raw.trim().slice(0, 500);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type SessionEndReasonLabels = Pick<
|
|
20
|
+
DashboardStrings,
|
|
21
|
+
| "sessionEndReasonPlanned"
|
|
22
|
+
| "sessionEndReasonEarly"
|
|
23
|
+
| "sessionEndReasonOverrun"
|
|
24
|
+
| "sessionEndReasonOther"
|
|
25
|
+
>;
|
|
26
|
+
|
|
27
|
+
/** Ligne lisible pour la carte session (session terminée avec raison enregistrée). */
|
|
28
|
+
export function formatSessionEndReasonLine(
|
|
29
|
+
t: SessionEndReasonLabels,
|
|
30
|
+
kind: string | undefined,
|
|
31
|
+
note: string | undefined
|
|
32
|
+
): string | null {
|
|
33
|
+
const k = normalizeSessionEndReasonKind(kind);
|
|
34
|
+
const n = normalizeSessionEndReasonNote(note);
|
|
35
|
+
const label =
|
|
36
|
+
k === "planned"
|
|
37
|
+
? t.sessionEndReasonPlanned
|
|
38
|
+
: k === "early"
|
|
39
|
+
? t.sessionEndReasonEarly
|
|
40
|
+
: k === "overrun"
|
|
41
|
+
? t.sessionEndReasonOverrun
|
|
42
|
+
: k === "other"
|
|
43
|
+
? t.sessionEndReasonOther
|
|
44
|
+
: "";
|
|
45
|
+
if (!label && !n) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
if (!label) {
|
|
49
|
+
return n;
|
|
50
|
+
}
|
|
51
|
+
if (!n) {
|
|
52
|
+
return label;
|
|
53
|
+
}
|
|
54
|
+
return `${label} — ${n}`;
|
|
55
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
formatEndLiveSessionConfirmMessage,
|
|
5
|
+
formatEndLiveSessionModalIntro,
|
|
6
|
+
getEndLiveSessionWarningFlags,
|
|
7
|
+
shouldConfirmEndLiveSession,
|
|
8
|
+
} from "./sessionEndWarnings";
|
|
9
|
+
|
|
10
|
+
const STRINGS = {
|
|
11
|
+
sessionEndLiveConfirmIntro: "Attention :",
|
|
12
|
+
sessionEndLiveConfirmIntroCalm: "Tout est tranquille.",
|
|
13
|
+
sessionEndLiveWarnActiveTask: "Une tâche est en cours.",
|
|
14
|
+
sessionEndLiveWarnPausedTasks: "Des tâches sont en attente.",
|
|
15
|
+
sessionEndLiveWarnIncompleteSubtasks: "Des sous-tâches sont ouvertes.",
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
describe("getEndLiveSessionWarningFlags", () => {
|
|
19
|
+
it("retourne tout false si live est null", () => {
|
|
20
|
+
const flags = getEndLiveSessionWarningFlags(null);
|
|
21
|
+
expect(flags).toEqual({
|
|
22
|
+
hasActiveTracking: false,
|
|
23
|
+
hasPausedOrPendingTasks: false,
|
|
24
|
+
hasIncompleteSubtasks: false,
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("retourne tout false si live est undefined", () => {
|
|
29
|
+
expect(getEndLiveSessionWarningFlags(undefined)).toEqual({
|
|
30
|
+
hasActiveTracking: false,
|
|
31
|
+
hasPausedOrPendingTasks: false,
|
|
32
|
+
hasIncompleteSubtasks: false,
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("hasActiveTracking=true si activeTasks contient une tâche non terminée non en pause", () => {
|
|
37
|
+
const flags = getEndLiveSessionWarningFlags({
|
|
38
|
+
activeTasks: [{ id: "t1", isDone: false, manualTaskTimerPaused: false }],
|
|
39
|
+
});
|
|
40
|
+
expect(flags.hasActiveTracking).toBe(true);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("hasActiveTracking=false si la tâche active est en pause manuelle", () => {
|
|
44
|
+
const flags = getEndLiveSessionWarningFlags({
|
|
45
|
+
activeTasks: [{ id: "t1", isDone: false, manualTaskTimerPaused: true }],
|
|
46
|
+
});
|
|
47
|
+
expect(flags.hasActiveTracking).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("hasActiveTracking=false si la tâche active est terminée", () => {
|
|
51
|
+
const flags = getEndLiveSessionWarningFlags({
|
|
52
|
+
activeTasks: [{ id: "t1", isDone: true }],
|
|
53
|
+
});
|
|
54
|
+
expect(flags.hasActiveTracking).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("utilise activeTask (forme simple) si activeTasks est absent", () => {
|
|
58
|
+
const flags = getEndLiveSessionWarningFlags({
|
|
59
|
+
activeTask: { id: "t1", isDone: false, manualTaskTimerPaused: false },
|
|
60
|
+
});
|
|
61
|
+
expect(flags.hasActiveTracking).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("hasPausedOrPendingTasks=true si tasks contient une tâche non terminée", () => {
|
|
65
|
+
const flags = getEndLiveSessionWarningFlags({
|
|
66
|
+
tasks: [{ id: "t1", isDone: false }],
|
|
67
|
+
});
|
|
68
|
+
expect(flags.hasPausedOrPendingTasks).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("hasPausedOrPendingTasks=false si toutes les tâches sont terminées", () => {
|
|
72
|
+
const flags = getEndLiveSessionWarningFlags({
|
|
73
|
+
tasks: [{ id: "t1", isDone: true }, { id: "t2", isDone: true }],
|
|
74
|
+
});
|
|
75
|
+
expect(flags.hasPausedOrPendingTasks).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("hasIncompleteSubtasks=true si une tâche a une sous-tâche non terminée", () => {
|
|
79
|
+
const flags = getEndLiveSessionWarningFlags({
|
|
80
|
+
tasks: [{ id: "t1", isDone: false, subtasks: [{ done: false }] }],
|
|
81
|
+
});
|
|
82
|
+
expect(flags.hasIncompleteSubtasks).toBe(true);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("hasIncompleteSubtasks=false si toutes les sous-tâches sont done", () => {
|
|
86
|
+
const flags = getEndLiveSessionWarningFlags({
|
|
87
|
+
tasks: [{ id: "t1", isDone: false, subtasks: [{ done: true }] }],
|
|
88
|
+
});
|
|
89
|
+
expect(flags.hasIncompleteSubtasks).toBe(false);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("tâche active avec sous-tâche incomplète est détectée même si absente de tasks", () => {
|
|
93
|
+
const flags = getEndLiveSessionWarningFlags({
|
|
94
|
+
activeTasks: [{ id: "t-unique", isDone: false, subtasks: [{ done: false }] }],
|
|
95
|
+
tasks: [],
|
|
96
|
+
});
|
|
97
|
+
expect(flags.hasIncompleteSubtasks).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("avertit pour une tâche active non terminée absente de la liste tasks", () => {
|
|
101
|
+
expect(
|
|
102
|
+
getEndLiveSessionWarningFlags({
|
|
103
|
+
tasks: [],
|
|
104
|
+
activeTasks: [{ id: "a", isDone: false }],
|
|
105
|
+
})
|
|
106
|
+
).toEqual({
|
|
107
|
+
hasActiveTracking: true,
|
|
108
|
+
hasPausedOrPendingTasks: true,
|
|
109
|
+
hasIncompleteSubtasks: false,
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("avertit pour une tâche active en pause absente de la liste tasks", () => {
|
|
114
|
+
expect(
|
|
115
|
+
getEndLiveSessionWarningFlags({
|
|
116
|
+
tasks: [],
|
|
117
|
+
activeTasks: [{ id: "a", isDone: false, manualTaskTimerPaused: true }],
|
|
118
|
+
}).hasPausedOrPendingTasks
|
|
119
|
+
).toBe(true);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("avertit pour une sous-tâche ouverte sur activeTask quand activeTasks existe aussi", () => {
|
|
123
|
+
expect(
|
|
124
|
+
getEndLiveSessionWarningFlags({
|
|
125
|
+
tasks: [],
|
|
126
|
+
activeTasks: [{ id: "a", isDone: true }],
|
|
127
|
+
activeTask: {
|
|
128
|
+
id: "b",
|
|
129
|
+
isDone: true,
|
|
130
|
+
subtasks: [{ done: true }, { done: false }],
|
|
131
|
+
},
|
|
132
|
+
}).hasIncompleteSubtasks
|
|
133
|
+
).toBe(true);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe("shouldConfirmEndLiveSession", () => {
|
|
138
|
+
it("retourne false si aucun flag", () => {
|
|
139
|
+
expect(shouldConfirmEndLiveSession({
|
|
140
|
+
hasActiveTracking: false,
|
|
141
|
+
hasPausedOrPendingTasks: false,
|
|
142
|
+
hasIncompleteSubtasks: false,
|
|
143
|
+
})).toBe(false);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("retourne true si hasActiveTracking", () => {
|
|
147
|
+
expect(shouldConfirmEndLiveSession({
|
|
148
|
+
hasActiveTracking: true,
|
|
149
|
+
hasPausedOrPendingTasks: false,
|
|
150
|
+
hasIncompleteSubtasks: false,
|
|
151
|
+
})).toBe(true);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("retourne true si hasPausedOrPendingTasks seulement", () => {
|
|
155
|
+
expect(shouldConfirmEndLiveSession({
|
|
156
|
+
hasActiveTracking: false,
|
|
157
|
+
hasPausedOrPendingTasks: true,
|
|
158
|
+
hasIncompleteSubtasks: false,
|
|
159
|
+
})).toBe(true);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe("formatEndLiveSessionConfirmMessage", () => {
|
|
164
|
+
it("inclut uniquement les bullets correspondant aux flags actifs", () => {
|
|
165
|
+
const msg = formatEndLiveSessionConfirmMessage(
|
|
166
|
+
{ hasActiveTracking: true, hasPausedOrPendingTasks: false, hasIncompleteSubtasks: true },
|
|
167
|
+
STRINGS,
|
|
168
|
+
);
|
|
169
|
+
expect(msg).toContain("Attention :");
|
|
170
|
+
expect(msg).toContain("• Une tâche est en cours.");
|
|
171
|
+
expect(msg).toContain("• Des sous-tâches sont ouvertes.");
|
|
172
|
+
expect(msg).not.toContain("attente");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("message vide de bullets si aucun flag (cas théorique)", () => {
|
|
176
|
+
const msg = formatEndLiveSessionConfirmMessage(
|
|
177
|
+
{ hasActiveTracking: false, hasPausedOrPendingTasks: false, hasIncompleteSubtasks: false },
|
|
178
|
+
STRINGS,
|
|
179
|
+
);
|
|
180
|
+
expect(msg).toBe("Attention :\n");
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
describe("formatEndLiveSessionModalIntro", () => {
|
|
185
|
+
it("retourne le message calme si aucun flag", () => {
|
|
186
|
+
const intro = formatEndLiveSessionModalIntro(
|
|
187
|
+
{ hasActiveTracking: false, hasPausedOrPendingTasks: false, hasIncompleteSubtasks: false },
|
|
188
|
+
STRINGS,
|
|
189
|
+
);
|
|
190
|
+
expect(intro).toBe("Tout est tranquille.");
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("retourne le message d'avertissement si au moins un flag", () => {
|
|
194
|
+
const intro = formatEndLiveSessionModalIntro(
|
|
195
|
+
{ hasActiveTracking: false, hasPausedOrPendingTasks: true, hasIncompleteSubtasks: false },
|
|
196
|
+
STRINGS,
|
|
197
|
+
);
|
|
198
|
+
expect(intro).toContain("Des tâches sont en attente.");
|
|
199
|
+
});
|
|
200
|
+
});
|