@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.
Files changed (179) hide show
  1. package/README.md +81 -0
  2. package/app/api/action/route.ts +16 -0
  3. package/app/api/backup/route.ts +84 -0
  4. package/app/api/health/route.ts +22 -0
  5. package/app/api/state/route.ts +27 -0
  6. package/app/apple-icon.png +0 -0
  7. package/app/changelog/page.tsx +122 -0
  8. package/app/globals.css +210 -0
  9. package/app/guide/layout.tsx +11 -0
  10. package/app/guide/page.tsx +278 -0
  11. package/app/icon.png +0 -0
  12. package/app/layout.tsx +77 -0
  13. package/app/licenses/layout.tsx +11 -0
  14. package/app/licenses/page.tsx +246 -0
  15. package/app/manifest.ts +32 -0
  16. package/app/page.tsx +1610 -0
  17. package/app/reporting/page.tsx +2943 -0
  18. package/app/settings/layout.tsx +10 -0
  19. package/app/settings/page.tsx +3518 -0
  20. package/bin/kronosys.mjs +46 -0
  21. package/components/KronosysPackageVersionProvider.tsx +19 -0
  22. package/components/KronosysPayloadProvider.tsx +109 -0
  23. package/components/PwaRegister.tsx +25 -0
  24. package/components/SiteLegalFooter.tsx +21 -0
  25. package/components/ThemeProvider.tsx +78 -0
  26. package/components/dashboard/AppShellLiveSessionDrawer.tsx +394 -0
  27. package/components/dashboard/AppShellRouteNav.tsx +131 -0
  28. package/components/dashboard/AppVersionStamp.tsx +16 -0
  29. package/components/dashboard/DashboardCollapsibleSection.tsx +57 -0
  30. package/components/dashboard/DashboardColumnHintsBanner.tsx +159 -0
  31. package/components/dashboard/DashboardCommandCenter.tsx +470 -0
  32. package/components/dashboard/DashboardLangGateModal.tsx +118 -0
  33. package/components/dashboard/DashboardLoadingOverlay.tsx +42 -0
  34. package/components/dashboard/DashboardSimpleModal.tsx +337 -0
  35. package/components/dashboard/DashboardSuspenseFallback.tsx +52 -0
  36. package/components/dashboard/DashboardToastProvider.tsx +64 -0
  37. package/components/dashboard/DashboardTour.tsx +435 -0
  38. package/components/dashboard/DeferredDescriptionPopoverWrap.tsx +39 -0
  39. package/components/dashboard/DeleteSessionModal.tsx +130 -0
  40. package/components/dashboard/DescriptionTooltipPortaled.tsx +31 -0
  41. package/components/dashboard/GitIdentityQuickSetupModal.tsx +211 -0
  42. package/components/dashboard/HeaderIntegrationBadges.tsx +69 -0
  43. package/components/dashboard/InlineMetricHelpTrigger.tsx +102 -0
  44. package/components/dashboard/IssuePickerModal.tsx +168 -0
  45. package/components/dashboard/KronoFocusPanel.tsx +834 -0
  46. package/components/dashboard/KronosysDatetimePopoverField.tsx +357 -0
  47. package/components/dashboard/KronosysTimePopoverField.tsx +233 -0
  48. package/components/dashboard/LanguageMenu.tsx +123 -0
  49. package/components/dashboard/MongoMirrorSyncLine.tsx +57 -0
  50. package/components/dashboard/NewSessionScopeModal.tsx +410 -0
  51. package/components/dashboard/PageRefreshButton.tsx +130 -0
  52. package/components/dashboard/PlainHelpPopover.tsx +97 -0
  53. package/components/dashboard/ReportingPageToc.tsx +68 -0
  54. package/components/dashboard/ReportingTour.tsx +342 -0
  55. package/components/dashboard/SavedProjectPicker.tsx +92 -0
  56. package/components/dashboard/SavedTagPicker.tsx +115 -0
  57. package/components/dashboard/ScrollToTopFab.tsx +41 -0
  58. package/components/dashboard/SelectedSessionSidebarBlock.tsx +630 -0
  59. package/components/dashboard/SessionEndReasonEditor.tsx +114 -0
  60. package/components/dashboard/SessionListPanel.tsx +320 -0
  61. package/components/dashboard/SessionLocMetricsSection.tsx +128 -0
  62. package/components/dashboard/SettingsTagsProjectsSection.tsx +993 -0
  63. package/components/dashboard/SettingsTour.tsx +332 -0
  64. package/components/dashboard/TagPills.tsx +149 -0
  65. package/components/dashboard/TagsHelpTrigger.tsx +84 -0
  66. package/components/dashboard/TaskFocusPanel.tsx +1261 -0
  67. package/components/dashboard/TaskSessionLiveCard.tsx +832 -0
  68. package/components/dashboard/TaskSubtasksBlock.tsx +748 -0
  69. package/components/dashboard/ThemeToggle.test.tsx +26 -0
  70. package/components/dashboard/ThemeToggle.tsx +36 -0
  71. package/components/dashboard/UserGuideBodyText.tsx +62 -0
  72. package/components/dashboard/WorkspaceGitRepoCard.tsx +191 -0
  73. package/components/dashboard/taskFieldStyles.ts +139 -0
  74. package/components/dashboard/useAnchoredFloatingPortalStyle.ts +71 -0
  75. package/components/dashboard/useDescriptionPopoverAfterMs.ts +220 -0
  76. package/components/dashboard/useKronoFocusLiveSeconds.ts +36 -0
  77. package/components/dashboard/useSmoothStopwatchMs.ts +25 -0
  78. package/lib/appShellHeaderClasses.ts +12 -0
  79. package/lib/backupCsvExport.test.ts +149 -0
  80. package/lib/backupCsvExport.ts +392 -0
  81. package/lib/changelogCopy.ts +34 -0
  82. package/lib/concurrentTaskStartPreference.ts +29 -0
  83. package/lib/dashboardClockFormat.ts +13 -0
  84. package/lib/dashboardColumnChrome.ts +3 -0
  85. package/lib/dashboardColumnHintsStorage.ts +57 -0
  86. package/lib/dashboardCopy.ts +1831 -0
  87. package/lib/dashboardDetachedUrlHintStorage.ts +24 -0
  88. package/lib/dashboardGitIdentityBannerStorage.ts +36 -0
  89. package/lib/dashboardLangStorage.ts +72 -0
  90. package/lib/dashboardQuickSearch.ts +476 -0
  91. package/lib/dashboardQuickSearchQuery.test.ts +63 -0
  92. package/lib/dashboardQuickSearchQuery.ts +179 -0
  93. package/lib/dashboardSessionNav.ts +33 -0
  94. package/lib/dashboardShortcuts.ts +268 -0
  95. package/lib/dashboardTimeZone.ts +91 -0
  96. package/lib/dashboardTourStorage.ts +68 -0
  97. package/lib/dataDir.test.ts +87 -0
  98. package/lib/dataDir.ts +83 -0
  99. package/lib/devDataPreferenceFile.ts +55 -0
  100. package/lib/devDataRuntimeInfo.ts +34 -0
  101. package/lib/formatIsoShort.test.ts +46 -0
  102. package/lib/formatIsoShort.ts +29 -0
  103. package/lib/generatedUserChangelog.ts +34 -0
  104. package/lib/gitlabIssueSearch.ts +8 -0
  105. package/lib/kronoFocusDurationHistory.ts +71 -0
  106. package/lib/kronoFocusRhythm.test.ts +130 -0
  107. package/lib/kronoFocusRhythm.ts +46 -0
  108. package/lib/kronoFocusTimerUrgency.test.ts +74 -0
  109. package/lib/kronoFocusTimerUrgency.ts +24 -0
  110. package/lib/kronosysApi.ts +143 -0
  111. package/lib/legacyEditorPayloadKeys.ts +52 -0
  112. package/lib/legacyKronoFocusStorageKeys.test.ts +29 -0
  113. package/lib/legacyKronoFocusStorageKeys.ts +32 -0
  114. package/lib/licensesCopy.ts +128 -0
  115. package/lib/openPlainTextInNewTab.ts +49 -0
  116. package/lib/readKronosysPackageVersion.ts +10 -0
  117. package/lib/reportingAggregate.test.ts +325 -0
  118. package/lib/reportingAggregate.ts +819 -0
  119. package/lib/reportingDatePresets.ts +41 -0
  120. package/lib/reportingMetricHelp.ts +430 -0
  121. package/lib/reportingNonFinalIndicators.test.ts +157 -0
  122. package/lib/reportingNonFinalIndicators.ts +102 -0
  123. package/lib/reportingStrings.ts +491 -0
  124. package/lib/reportingTagWeekBreakdown.test.ts +141 -0
  125. package/lib/reportingTagWeekBreakdown.ts +181 -0
  126. package/lib/reportingWeekLayout.test.ts +239 -0
  127. package/lib/reportingWeekLayout.ts +313 -0
  128. package/lib/sessionAssiduity.test.ts +25 -0
  129. package/lib/sessionAssiduity.ts +33 -0
  130. package/lib/sessionEndReason.ts +55 -0
  131. package/lib/sessionEndWarnings.test.ts +200 -0
  132. package/lib/sessionEndWarnings.ts +125 -0
  133. package/lib/sessionListMerge.test.ts +101 -0
  134. package/lib/sessionListMerge.ts +70 -0
  135. package/lib/sessionTaskSidebarStats.test.ts +24 -0
  136. package/lib/sessionTaskSidebarStats.ts +54 -0
  137. package/lib/settingsCopy.ts +1276 -0
  138. package/lib/taskParsing.test.ts +153 -0
  139. package/lib/taskParsing.ts +737 -0
  140. package/lib/theme.ts +15 -0
  141. package/lib/translucentButtonClasses.ts +34 -0
  142. package/lib/usageProfile.test.ts +84 -0
  143. package/lib/usageProfile.ts +52 -0
  144. package/lib/userGuideCopy.ts +464 -0
  145. package/lib/workspaceLocDefaults.ts +21 -0
  146. package/next-env.d.ts +6 -0
  147. package/next.config.ts +15 -0
  148. package/package.json +87 -0
  149. package/postcss.config.mjs +12 -0
  150. package/public/apple-icon.png +0 -0
  151. package/public/favicon.ico +0 -0
  152. package/public/file.svg +1 -0
  153. package/public/globe.svg +1 -0
  154. package/public/icon-192.png +0 -0
  155. package/public/icon-512.png +0 -0
  156. package/public/icon.png +0 -0
  157. package/public/next.svg +1 -0
  158. package/public/sw.js +13 -0
  159. package/public/traceback.png +0 -0
  160. package/public/vercel.svg +1 -0
  161. package/public/window.svg +1 -0
  162. package/server/actionDispatch.test.ts +723 -0
  163. package/server/actionDispatch.ts +1476 -0
  164. package/server/actionTaskSession.test.ts +713 -0
  165. package/server/actionTaskSession.ts +717 -0
  166. package/server/db.ts +42 -0
  167. package/server/defaultCfg.ts +87 -0
  168. package/server/gitlabTokenStore.ts +34 -0
  169. package/server/kronoFocusHydrate.test.ts +142 -0
  170. package/server/kronoFocusHydrate.ts +69 -0
  171. package/server/kronoFocusMigrate.test.ts +53 -0
  172. package/server/kronoFocusMigrate.ts +78 -0
  173. package/server/mainTimerHydrate.test.ts +65 -0
  174. package/server/mainTimerHydrate.ts +53 -0
  175. package/server/payloadStore.test.ts +78 -0
  176. package/server/payloadStore.ts +83 -0
  177. package/server/sessionWallHydrate.test.ts +46 -0
  178. package/server/sessionWallHydrate.ts +88 -0
  179. package/tsconfig.json +41 -0
@@ -0,0 +1,819 @@
1
+ import {
2
+ DEFAULT_FALLBACK_TASK_TAG,
3
+ formatProjectDisplay,
4
+ formatTagDisplay,
5
+ isFallbackTaskTagKey,
6
+ normalizeProjectKey,
7
+ normalizeTagKey,
8
+ } from "./taskParsing";
9
+ import type { KronosysUpdatePayload } from "./kronosysApi";
10
+ import { LEGACY_TASK_CYCLES_KEY, LEGACY_TASK_USED_FLAG_KEY } from "./legacyKronoFocusStorageKeys";
11
+ import { calendarDateKeyInTimeZone } from "./dashboardTimeZone";
12
+ import {
13
+ localWeekStartKeyFromDayKey,
14
+ type ReportingWeekStartsOn,
15
+ } from "./reportingWeekLayout";
16
+
17
+ export type LooseTask = {
18
+ id?: string;
19
+ startTime?: string;
20
+ endTime?: string;
21
+ durationMs?: number;
22
+ isDone?: boolean;
23
+ usedKronoFocus?: boolean;
24
+ kronoFocusCycles?: number;
25
+ tags?: string[];
26
+ project?: string | null;
27
+ subtasks?: Array<{ done?: boolean }>;
28
+ };
29
+
30
+ export type LooseSession = {
31
+ sessionId: string;
32
+ savedAt?: string;
33
+ startAt?: string | null;
34
+ endAt?: string | null;
35
+ /** Fenêtre d’ouverture de session (premier → dernier événement), en minutes — voir {@link sessionWallClockMinutes}. */
36
+ sessionDurationMinutes?: number;
37
+ archived?: boolean;
38
+ codingMinutesSession?: number;
39
+ activeMinutes?: number;
40
+ linesWrittenTotal?: number;
41
+ linesWrittenHuman?: number;
42
+ linesWrittenAi?: number;
43
+ locByLanguage?: Array<[string, number]>;
44
+ codingSignalsByLanguage?: Array<[string, number]>;
45
+ kronoFocus?: { sessionsCompleted?: number };
46
+ tasks?: LooseTask[];
47
+ activeTasks?: LooseTask[];
48
+ activeTask?: LooseTask | null;
49
+ /**
50
+ * Heure de début de référence (ISO) fournie à la création, si l’hôte a indiqué une
51
+ * planification (horaire, session planifiée, etc.).
52
+ */
53
+ scheduledStartAt?: string | null;
54
+ /**
55
+ * `startAt` effectif moins l’heure de référence, en minutes (positif = retard).
56
+ * Présent seulement si `scheduledStartAt` a été enregistré.
57
+ */
58
+ sessionStartOffsetMinutes?: number | null;
59
+ };
60
+
61
+ export function mergeSessionsFromPayload(payload: KronosysUpdatePayload): LooseSession[] {
62
+ const map = new Map<string, LooseSession>();
63
+ const archived = (payload.historyArchived || []) as LooseSession[];
64
+ const hist = (payload.history || []) as LooseSession[];
65
+ for (const s of archived) {
66
+ if (s.sessionId) {
67
+ map.set(s.sessionId, s);
68
+ }
69
+ }
70
+ for (const s of hist) {
71
+ if (s.sessionId) {
72
+ map.set(s.sessionId, s);
73
+ }
74
+ }
75
+ const cur = payload.current as LooseSession | undefined;
76
+ if (cur?.sessionId) {
77
+ map.set(cur.sessionId, cur);
78
+ }
79
+ return [...map.values()];
80
+ }
81
+
82
+ export function collectTasksDeduped(s: LooseSession): LooseTask[] {
83
+ const list: LooseTask[] = [...(s.tasks || [])];
84
+ const ids = new Set(
85
+ list.map((t) => t.id).filter((id): id is string => typeof id === "string" && id.length > 0)
86
+ );
87
+ const actives =
88
+ Array.isArray(s.activeTasks) && s.activeTasks.length > 0
89
+ ? s.activeTasks
90
+ : s.activeTask
91
+ ? [s.activeTask]
92
+ : [];
93
+ for (const at of actives) {
94
+ const aid = at.id;
95
+ if (aid && !ids.has(aid)) {
96
+ list.push(at);
97
+ ids.add(aid);
98
+ }
99
+ }
100
+ return list;
101
+ }
102
+
103
+ export function buildTagFilterSet(selectedTags: string[]): Set<string> {
104
+ const set = new Set<string>();
105
+ for (const t of selectedTags) {
106
+ const k = normalizeTagKey(t).toLowerCase();
107
+ if (k) {
108
+ set.add(k);
109
+ }
110
+ }
111
+ return set;
112
+ }
113
+
114
+ export function taskMatchesTags(
115
+ task: LooseTask,
116
+ filter: Set<string>,
117
+ defaultTagBucketEnabled: boolean = true
118
+ ): boolean {
119
+ if (filter.size === 0) {
120
+ return true;
121
+ }
122
+ const tags = task.tags || [];
123
+ const keys = tags
124
+ .map((t) => normalizeTagKey(String(t)).toLowerCase())
125
+ .filter((k) => k.length > 0);
126
+ const effective =
127
+ keys.length > 0 ? keys : defaultTagBucketEnabled ? [DEFAULT_FALLBACK_TASK_TAG] : [];
128
+ return effective.some((k) => filter.has(k));
129
+ }
130
+
131
+ /**
132
+ * Sessions archivées : les métriques basées sur les tâches n’incluent que le travail
133
+ * entièrement bouclé (tâche terminée et toutes les sous-tâches cochées, s’il y en a).
134
+ */
135
+ export function taskCountsTowardArchivedSessionReporting(task: LooseTask): boolean {
136
+ if (task.isDone !== true) {
137
+ return false;
138
+ }
139
+ const subs = task.subtasks;
140
+ if (subs && subs.length > 0) {
141
+ return subs.every((st) => st.done === true);
142
+ }
143
+ return true;
144
+ }
145
+
146
+ function taskIncludedInReportingTaskMetrics(session: LooseSession, task: LooseTask): boolean {
147
+ if (session.archived === true) {
148
+ return taskCountsTowardArchivedSessionReporting(task);
149
+ }
150
+ return true;
151
+ }
152
+
153
+ function mergeTupleMap(target: Map<string, number>, raw: unknown): void {
154
+ if (!Array.isArray(raw)) {
155
+ return;
156
+ }
157
+ for (const item of raw) {
158
+ if (!Array.isArray(item) || item.length < 2) {
159
+ continue;
160
+ }
161
+ const k = item[0];
162
+ const v = item[1];
163
+ if (typeof k !== "string" || typeof v !== "number") {
164
+ continue;
165
+ }
166
+ target.set(k, (target.get(k) ?? 0) + v);
167
+ }
168
+ }
169
+
170
+ export const UNDATED_KEY = "undated";
171
+
172
+ export function dayInRange(day: string | null, from: string | null, to: string | null): boolean {
173
+ if (!from && !to) {
174
+ return true;
175
+ }
176
+ if (!day) {
177
+ return false;
178
+ }
179
+ if (from && day < from) {
180
+ return false;
181
+ }
182
+ if (to && day > to) {
183
+ return false;
184
+ }
185
+ return true;
186
+ }
187
+
188
+ /**
189
+ * Lundi (date locale) de la semaine calendaire contenant `dayKey` — identique à {@link localWeekStartKeyFromDayKey}(…, `"monday"`).
190
+ */
191
+ export function localWeekMondayFromDayKey(dayKey: string): string | null {
192
+ return localWeekStartKeyFromDayKey(dayKey, "monday");
193
+ }
194
+
195
+ /**
196
+ * Durée « murale » de la session : valeur persistée si disponible, sinon écart entre `startAt` et `endAt` (minutes).
197
+ */
198
+ export function sessionWallClockMinutes(s: LooseSession): number {
199
+ const persisted = s.sessionDurationMinutes;
200
+ if (typeof persisted === "number" && Number.isFinite(persisted) && persisted > 0) {
201
+ return persisted;
202
+ }
203
+ const start = s.startAt ? Date.parse(s.startAt) : NaN;
204
+ const end = s.endAt ? Date.parse(s.endAt) : NaN;
205
+ if (Number.isFinite(start) && Number.isFinite(end) && end > start) {
206
+ return (end - start) / 60000;
207
+ }
208
+ return 0;
209
+ }
210
+
211
+ function uniqueTaskTagEntries(
212
+ task: LooseTask,
213
+ fallbackTagDisplay: string
214
+ ): Array<{ key: string; display: string }> {
215
+ const tags = task.tags ?? [];
216
+ const seen = new Set<string>();
217
+ const out: Array<{ key: string; display: string }> = [];
218
+ for (const raw of tags) {
219
+ const token = normalizeTagKey(String(raw)).trim();
220
+ if (!token) {
221
+ continue;
222
+ }
223
+ const key = token.toLowerCase();
224
+ if (seen.has(key)) {
225
+ continue;
226
+ }
227
+ seen.add(key);
228
+ const display =
229
+ key === DEFAULT_FALLBACK_TASK_TAG ? fallbackTagDisplay : formatTagDisplay(token);
230
+ out.push({ key, display });
231
+ }
232
+ return out;
233
+ }
234
+
235
+ function compareTagKeyForSort(a: string, b: string): number {
236
+ const fa = isFallbackTaskTagKey(a);
237
+ const fb = isFallbackTaskTagKey(b);
238
+ if (fa && !fb) {
239
+ return 1;
240
+ }
241
+ if (!fa && fb) {
242
+ return -1;
243
+ }
244
+ return a.localeCompare(b);
245
+ }
246
+
247
+ export type ReportingTagTimeDayRow = {
248
+ /** Clé stable (`default` = sans étiquette explicite ; clé vide possible pour données historiques). */
249
+ tagKey: string;
250
+ displayTag: string;
251
+ day: string;
252
+ minutes: number;
253
+ };
254
+
255
+ export type ReportingTagTimeWeekRow = {
256
+ tagKey: string;
257
+ displayTag: string;
258
+ /** Premier jour de la semaine (AAAA-MM-JJ, local), selon `weekStartsOn`. */
259
+ weekStart: string;
260
+ minutes: number;
261
+ };
262
+
263
+ /**
264
+ * Temps enregistré sur les tâches ventilé par étiquette (`#tag`), par jour de tâche et par semaine.
265
+ * Si une tâche porte plusieurs étiquettes, la durée complète est comptée pour chacune (les sommes par étiquette peuvent donc dépasser le temps réel lorsqu’on les additionne).
266
+ */
267
+ export function aggregateTagTaskMinutesByDayAndWeek(
268
+ sessions: LooseSession[],
269
+ selectedTagKeys: Set<string>,
270
+ dateFrom: string | null,
271
+ dateTo: string | null,
272
+ /** Fuseau IANA pour le jour calendaire des tâches (ex. `America/Toronto`). */
273
+ timeZone: string,
274
+ weekStartsOn: ReportingWeekStartsOn,
275
+ /** Libellé pour l’étiquette réservée « sans étiquette » / `default` (ex. « default » ou « défaut » selon la langue). */
276
+ fallbackTaskTagDisplay: string,
277
+ /** Lorsque `false`, les tâches sans étiquette sont ventilées sous la clé vide (pas `default`). */
278
+ defaultTagBucketEnabled: boolean = true
279
+ ): { byDay: ReportingTagTimeDayRow[]; byWeek: ReportingTagTimeWeekRow[] } {
280
+ const byTagDay = new Map<string, Map<string, number>>();
281
+ const byTagWeek = new Map<string, Map<string, number>>();
282
+ const displayByTag = new Map<string, string>();
283
+
284
+ const addDay = (tagKey: string, display: string, day: string, mins: number) => {
285
+ if (mins <= 0) {
286
+ return;
287
+ }
288
+ let inner = byTagDay.get(tagKey);
289
+ if (!inner) {
290
+ inner = new Map();
291
+ byTagDay.set(tagKey, inner);
292
+ }
293
+ inner.set(day, (inner.get(day) ?? 0) + mins);
294
+ if (display && !displayByTag.has(tagKey)) {
295
+ displayByTag.set(tagKey, display);
296
+ }
297
+ };
298
+
299
+ const addWeek = (tagKey: string, weekStart: string, mins: number) => {
300
+ if (mins <= 0) {
301
+ return;
302
+ }
303
+ let inner = byTagWeek.get(tagKey);
304
+ if (!inner) {
305
+ inner = new Map();
306
+ byTagWeek.set(tagKey, inner);
307
+ }
308
+ inner.set(weekStart, (inner.get(weekStart) ?? 0) + mins);
309
+ };
310
+
311
+ const unbounded = !dateFrom && !dateTo;
312
+
313
+ for (const s of sessions) {
314
+ const allTasks = collectTasksDeduped(s);
315
+ const matching = allTasks.filter((t) => taskMatchesTags(t, selectedTagKeys, defaultTagBucketEnabled));
316
+ if (selectedTagKeys.size > 0 && matching.length === 0) {
317
+ continue;
318
+ }
319
+
320
+ for (const t of matching) {
321
+ if (!taskIncludedInReportingTaskMetrics(s, t)) {
322
+ continue;
323
+ }
324
+ const taskDay = calendarDateKeyInTimeZone(t.endTime ?? t.startTime, timeZone);
325
+ if (!unbounded && !dayInRange(taskDay, dateFrom, dateTo)) {
326
+ continue;
327
+ }
328
+ const taskBucket = taskDay ?? (unbounded ? UNDATED_KEY : null);
329
+ if (!taskBucket) {
330
+ continue;
331
+ }
332
+
333
+ const mins = (t.durationMs ?? 0) / 60000;
334
+ if (mins <= 0) {
335
+ continue;
336
+ }
337
+
338
+ const entries = uniqueTaskTagEntries(t, fallbackTaskTagDisplay);
339
+ const weekStart =
340
+ taskBucket !== UNDATED_KEY
341
+ ? localWeekStartKeyFromDayKey(taskBucket, weekStartsOn)
342
+ : null;
343
+
344
+ if (entries.length === 0) {
345
+ const fallbackKey = defaultTagBucketEnabled ? DEFAULT_FALLBACK_TASK_TAG : "";
346
+ addDay(fallbackKey, fallbackTaskTagDisplay, taskBucket, mins);
347
+ if (weekStart) {
348
+ addWeek(fallbackKey, weekStart, mins);
349
+ }
350
+ } else {
351
+ for (const e of entries) {
352
+ addDay(e.key, e.display, taskBucket, mins);
353
+ if (weekStart) {
354
+ addWeek(e.key, weekStart, mins);
355
+ }
356
+ }
357
+ }
358
+ }
359
+ }
360
+
361
+ const byDay: ReportingTagTimeDayRow[] = [];
362
+ for (const [tagKey, inner] of byTagDay) {
363
+ for (const [day, minutes] of inner) {
364
+ byDay.push({
365
+ tagKey,
366
+ displayTag: isFallbackTaskTagKey(tagKey)
367
+ ? fallbackTaskTagDisplay
368
+ : (displayByTag.get(tagKey) ?? formatTagDisplay(tagKey)),
369
+ day,
370
+ minutes,
371
+ });
372
+ }
373
+ }
374
+ byDay.sort((a, b) => a.day.localeCompare(b.day) || compareTagKeyForSort(a.tagKey, b.tagKey));
375
+
376
+ const byWeek: ReportingTagTimeWeekRow[] = [];
377
+ for (const [tagKey, inner] of byTagWeek) {
378
+ for (const [weekStart, minutes] of inner) {
379
+ byWeek.push({
380
+ tagKey,
381
+ displayTag: isFallbackTaskTagKey(tagKey)
382
+ ? fallbackTaskTagDisplay
383
+ : (displayByTag.get(tagKey) ?? formatTagDisplay(tagKey)),
384
+ weekStart,
385
+ minutes,
386
+ });
387
+ }
388
+ }
389
+ byWeek.sort(
390
+ (a, b) => a.weekStart.localeCompare(b.weekStart) || compareTagKeyForSort(a.tagKey, b.tagKey)
391
+ );
392
+
393
+ return { byDay, byWeek };
394
+ }
395
+
396
+ export type ReportingResult = {
397
+ sessionsByDay: Record<string, number>;
398
+ tasksByDayDone: Record<string, number>;
399
+ tasksByDayActive: Record<string, number>;
400
+ /** Minutes enregistrées sur les tâches (durationMs), par jour de tâche. */
401
+ taskMinutesByDay: Record<string, number>;
402
+ /** Minutes de codage session (codingMinutesSession), par jour de session. */
403
+ sessionCodingMinutesByDay: Record<string, number>;
404
+ /**
405
+ * Durée « murale » des sessions (sessionDurationMinutes ou startAt→endAt), par jour de session.
406
+ */
407
+ sessionWallClockMinutesByDay: Record<string, number>;
408
+ kronoFocusSessionsCompleted: number;
409
+ kronoFocusTasksUsedCount: number;
410
+ kronoFocusTaskCyclesSum: number;
411
+ taskMinutesTotal: number;
412
+ sessionCodingMinutesTotal: number;
413
+ sessionActiveMinutesTotal: number;
414
+ /** Somme des durées murales des sessions dans la plage (voir {@link sessionWallClockMinutes}). */
415
+ sessionWallClockMinutesTotal: number;
416
+ sessionCountContributing: number;
417
+ taskCountContributing: number;
418
+ /** Sommes sur les sessions dans la plage (mêmes filtres que les totaux session). */
419
+ linesWrittenTotalSum: number;
420
+ linesWrittenHumanSum: number;
421
+ linesWrittenAiSum: number;
422
+ locByLanguageMerged: Array<[string, number]>;
423
+ codingSignalsByLanguageMerged: Array<[string, number]>;
424
+ /** Sessions dont l’hôte a enregistré une heure de référence (même règles de plage / étiquettes). */
425
+ assiduityReferenceSessionCount: number;
426
+ /** Parmi elles, sessions commencées après l’heure de référence (`sessionStartOffsetMinutes` > 0). */
427
+ assiduityLateSessionCount: number;
428
+ /** Somme des retards (minutes), compte seulement la partie > 0 par session. */
429
+ assiduityLateMinutesTotal: number;
430
+ /**
431
+ * Moyenne des retards (minutes) sur les seules sessions en retard dans la plage,
432
+ * ou `null` si aucune.
433
+ */
434
+ assiduityAverageLateMinutesWhenLate: number | null;
435
+ };
436
+
437
+ export function aggregateReporting(
438
+ sessions: LooseSession[],
439
+ selectedTagKeys: Set<string>,
440
+ dateFrom: string | null,
441
+ dateTo: string | null,
442
+ /** Fuseau IANA pour le jour calendaire des sessions et des tâches. */
443
+ timeZone: string,
444
+ defaultTagBucketEnabled: boolean = true
445
+ ): ReportingResult {
446
+ const sessionsByDay: Record<string, number> = {};
447
+ const tasksByDayDone: Record<string, number> = {};
448
+ const tasksByDayActive: Record<string, number> = {};
449
+ const taskMinutesByDay: Record<string, number> = {};
450
+ const sessionCodingMinutesByDay: Record<string, number> = {};
451
+ const sessionWallClockMinutesByDay: Record<string, number> = {};
452
+
453
+ let kronoFocusSessionsCompleted = 0;
454
+ let kronoFocusTasksUsedCount = 0;
455
+ let kronoFocusTaskCyclesSum = 0;
456
+ let taskMinutesTotal = 0;
457
+ let sessionCodingMinutesTotal = 0;
458
+ let sessionActiveMinutesTotal = 0;
459
+ let sessionWallClockMinutesTotal = 0;
460
+ let sessionCountContributing = 0;
461
+ let taskCountContributing = 0;
462
+ let linesWrittenTotalSum = 0;
463
+ let linesWrittenHumanSum = 0;
464
+ let linesWrittenAiSum = 0;
465
+ const locByLanguageAcc = new Map<string, number>();
466
+ const codingSignalsAcc = new Map<string, number>();
467
+ let assiduityReferenceSessionCount = 0;
468
+ let assiduityLateSessionCount = 0;
469
+ let assiduityLateMinutesTotal = 0;
470
+
471
+ const unbounded = !dateFrom && !dateTo;
472
+
473
+ for (const s of sessions) {
474
+ const allTasks = collectTasksDeduped(s);
475
+ const matching = allTasks.filter((t) => taskMatchesTags(t, selectedTagKeys, defaultTagBucketEnabled));
476
+ if (selectedTagKeys.size > 0 && matching.length === 0) {
477
+ continue;
478
+ }
479
+
480
+ const sessionDay = calendarDateKeyInTimeZone(s.savedAt ?? s.endAt ?? s.startAt, timeZone);
481
+ const sessionRangeOk = unbounded || dayInRange(sessionDay, dateFrom, dateTo);
482
+
483
+ if (sessionRangeOk) {
484
+ const sessionBucket = sessionDay ?? (unbounded ? UNDATED_KEY : null);
485
+ if (sessionBucket) {
486
+ sessionsByDay[sessionBucket] = (sessionsByDay[sessionBucket] ?? 0) + 1;
487
+ const cm = s.codingMinutesSession ?? 0;
488
+ if (cm > 0) {
489
+ sessionCodingMinutesByDay[sessionBucket] =
490
+ (sessionCodingMinutesByDay[sessionBucket] ?? 0) + cm;
491
+ }
492
+ const wm = sessionWallClockMinutes(s);
493
+ if (wm > 0) {
494
+ sessionWallClockMinutesByDay[sessionBucket] =
495
+ (sessionWallClockMinutesByDay[sessionBucket] ?? 0) + wm;
496
+ }
497
+ }
498
+ sessionCountContributing += 1;
499
+ const sc = s.kronoFocus?.sessionsCompleted;
500
+ if (typeof sc === "number" && sc > 0) {
501
+ kronoFocusSessionsCompleted += sc;
502
+ }
503
+ sessionCodingMinutesTotal += s.codingMinutesSession ?? 0;
504
+ sessionActiveMinutesTotal += s.activeMinutes ?? 0;
505
+ sessionWallClockMinutesTotal += sessionWallClockMinutes(s);
506
+ if (typeof s.linesWrittenTotal === "number") {
507
+ linesWrittenTotalSum += s.linesWrittenTotal;
508
+ }
509
+ if (typeof s.linesWrittenHuman === "number") {
510
+ linesWrittenHumanSum += s.linesWrittenHuman;
511
+ }
512
+ if (typeof s.linesWrittenAi === "number") {
513
+ linesWrittenAiSum += s.linesWrittenAi;
514
+ }
515
+ mergeTupleMap(locByLanguageAcc, s.locByLanguage);
516
+ mergeTupleMap(codingSignalsAcc, s.codingSignalsByLanguage);
517
+ if (typeof s.sessionStartOffsetMinutes === "number" && Number.isFinite(s.sessionStartOffsetMinutes)) {
518
+ assiduityReferenceSessionCount += 1;
519
+ if (s.sessionStartOffsetMinutes > 0) {
520
+ assiduityLateSessionCount += 1;
521
+ assiduityLateMinutesTotal += s.sessionStartOffsetMinutes;
522
+ }
523
+ }
524
+ }
525
+
526
+ for (const t of matching) {
527
+ if (!taskIncludedInReportingTaskMetrics(s, t)) {
528
+ continue;
529
+ }
530
+ const taskDay = calendarDateKeyInTimeZone(t.endTime ?? t.startTime, timeZone);
531
+ if (!unbounded && !dayInRange(taskDay, dateFrom, dateTo)) {
532
+ continue;
533
+ }
534
+ const taskBucket = taskDay ?? (unbounded ? UNDATED_KEY : null);
535
+ if (!taskBucket) {
536
+ continue;
537
+ }
538
+
539
+ taskCountContributing += 1;
540
+ const mins = (t.durationMs ?? 0) / 60000;
541
+ taskMinutesTotal += mins;
542
+ if (mins > 0) {
543
+ taskMinutesByDay[taskBucket] = (taskMinutesByDay[taskBucket] ?? 0) + mins;
544
+ }
545
+
546
+ if (t.isDone === true) {
547
+ tasksByDayDone[taskBucket] = (tasksByDayDone[taskBucket] ?? 0) + 1;
548
+ } else {
549
+ tasksByDayActive[taskBucket] = (tasksByDayActive[taskBucket] ?? 0) + 1;
550
+ }
551
+
552
+ const tr = t as Record<string, unknown>;
553
+ if (t.usedKronoFocus === true || tr[LEGACY_TASK_USED_FLAG_KEY] === true) {
554
+ kronoFocusTasksUsedCount += 1;
555
+ }
556
+ const cyc =
557
+ typeof t.kronoFocusCycles === "number"
558
+ ? t.kronoFocusCycles
559
+ : typeof tr[LEGACY_TASK_CYCLES_KEY] === "number"
560
+ ? tr[LEGACY_TASK_CYCLES_KEY]
561
+ : undefined;
562
+ if (typeof cyc === "number" && cyc > 0) {
563
+ kronoFocusTaskCyclesSum += cyc;
564
+ }
565
+ }
566
+ }
567
+
568
+ const locByLanguageMerged = [...locByLanguageAcc.entries()].sort((a, b) => b[1] - a[1]);
569
+ const codingSignalsByLanguageMerged = [...codingSignalsAcc.entries()].sort((a, b) => b[1] - a[1]);
570
+ const assiduityAverageLateMinutesWhenLate =
571
+ assiduityLateSessionCount > 0 ? assiduityLateMinutesTotal / assiduityLateSessionCount : null;
572
+
573
+ return {
574
+ sessionsByDay,
575
+ tasksByDayDone,
576
+ tasksByDayActive,
577
+ taskMinutesByDay,
578
+ sessionCodingMinutesByDay,
579
+ sessionWallClockMinutesByDay,
580
+ kronoFocusSessionsCompleted,
581
+ kronoFocusTasksUsedCount,
582
+ kronoFocusTaskCyclesSum,
583
+ taskMinutesTotal,
584
+ sessionCodingMinutesTotal,
585
+ sessionActiveMinutesTotal,
586
+ sessionWallClockMinutesTotal,
587
+ sessionCountContributing,
588
+ taskCountContributing,
589
+ linesWrittenTotalSum,
590
+ linesWrittenHumanSum,
591
+ linesWrittenAiSum,
592
+ locByLanguageMerged,
593
+ codingSignalsByLanguageMerged,
594
+ assiduityReferenceSessionCount,
595
+ assiduityLateSessionCount,
596
+ assiduityLateMinutesTotal,
597
+ assiduityAverageLateMinutesWhenLate,
598
+ };
599
+ }
600
+
601
+ export type ReportingProjectRow = {
602
+ /** Clé stable tri (vide = sans projet). */
603
+ projectKey: string;
604
+ /** Libellé affiché (vide si sans projet — remplacer côté UI). */
605
+ displayLabel: string;
606
+ minutes: number;
607
+ taskCount: number;
608
+ };
609
+
610
+ export type ReportingProjectTimeDayRow = {
611
+ projectKey: string;
612
+ displayProject: string;
613
+ day: string;
614
+ minutes: number;
615
+ };
616
+
617
+ function compareProjectKeyForSort(a: string, b: string): number {
618
+ if (a === "" && b !== "") {
619
+ return 1;
620
+ }
621
+ if (b === "" && a !== "") {
622
+ return -1;
623
+ }
624
+ return a.localeCompare(b);
625
+ }
626
+
627
+ /**
628
+ * Temps enregistré sur les tâches (`durationMs`) par `@projet` et par jour de tâche (mêmes filtres que le tableau projet agrégé).
629
+ */
630
+ export function aggregateProjectTaskMinutesByDay(
631
+ sessions: LooseSession[],
632
+ selectedTagKeys: Set<string>,
633
+ dateFrom: string | null,
634
+ dateTo: string | null,
635
+ timeZone: string,
636
+ defaultTagBucketEnabled: boolean = true
637
+ ): ReportingProjectTimeDayRow[] {
638
+ const byProjDay = new Map<string, Map<string, number>>();
639
+ const displayByProj = new Map<string, string>();
640
+ const unbounded = !dateFrom && !dateTo;
641
+
642
+ for (const s of sessions) {
643
+ const allTasks = collectTasksDeduped(s);
644
+ const matching = allTasks.filter((t) => taskMatchesTags(t, selectedTagKeys, defaultTagBucketEnabled));
645
+ if (selectedTagKeys.size > 0 && matching.length === 0) {
646
+ continue;
647
+ }
648
+
649
+ for (const t of matching) {
650
+ if (!taskIncludedInReportingTaskMetrics(s, t)) {
651
+ continue;
652
+ }
653
+ const taskDay = calendarDateKeyInTimeZone(t.endTime ?? t.startTime, timeZone);
654
+ if (!unbounded && !dayInRange(taskDay, dateFrom, dateTo)) {
655
+ continue;
656
+ }
657
+ const taskBucket = taskDay ?? (unbounded ? UNDATED_KEY : null);
658
+ if (!taskBucket) {
659
+ continue;
660
+ }
661
+
662
+ const mins = (t.durationMs ?? 0) / 60000;
663
+ if (mins <= 0) {
664
+ continue;
665
+ }
666
+
667
+ const raw = typeof t.project === "string" ? t.project.trim() : "";
668
+ const key = raw ? normalizeProjectKey(raw).toLowerCase() : "";
669
+ let inner = byProjDay.get(key);
670
+ if (!inner) {
671
+ inner = new Map();
672
+ byProjDay.set(key, inner);
673
+ }
674
+ inner.set(taskBucket, (inner.get(taskBucket) ?? 0) + mins);
675
+ if (raw && !displayByProj.has(key)) {
676
+ displayByProj.set(key, formatProjectDisplay(raw));
677
+ }
678
+ }
679
+ }
680
+
681
+ const out: ReportingProjectTimeDayRow[] = [];
682
+ for (const [projectKey, inner] of byProjDay) {
683
+ for (const [day, minutes] of inner) {
684
+ out.push({
685
+ projectKey,
686
+ displayProject:
687
+ projectKey === ""
688
+ ? ""
689
+ : (displayByProj.get(projectKey) ?? formatProjectDisplay(projectKey)),
690
+ day,
691
+ minutes,
692
+ });
693
+ }
694
+ }
695
+ out.sort((a, b) => a.day.localeCompare(b.day) || compareProjectKeyForSort(a.projectKey, b.projectKey));
696
+ return out;
697
+ }
698
+
699
+ /**
700
+ * Minutes enregistrées sur des tâches d’**sessions archivées** qui sont **exclues** des graphiques
701
+ * (tâche non terminée ou sous-tâche non cochée), avec les mêmes filtres dates et étiquettes que les autres agrégats tâche.
702
+ * Sert d’indicateur pour éviter d’afficher « rien » alors qu’il existait du temps sur disque.
703
+ */
704
+ export function aggregateArchivedExcludedTaskMinutes(
705
+ sessions: LooseSession[],
706
+ selectedTagKeys: Set<string>,
707
+ dateFrom: string | null,
708
+ dateTo: string | null,
709
+ timeZone: string,
710
+ defaultTagBucketEnabled: boolean = true
711
+ ): number {
712
+ const unbounded = !dateFrom && !dateTo;
713
+ let total = 0;
714
+ for (const s of sessions) {
715
+ if (s.archived !== true) {
716
+ continue;
717
+ }
718
+ const allTasks = collectTasksDeduped(s);
719
+ const matching = allTasks.filter((t) => taskMatchesTags(t, selectedTagKeys, defaultTagBucketEnabled));
720
+ if (selectedTagKeys.size > 0 && matching.length === 0) {
721
+ continue;
722
+ }
723
+ for (const t of matching) {
724
+ if (taskIncludedInReportingTaskMetrics(s, t)) {
725
+ continue;
726
+ }
727
+ const taskDay = calendarDateKeyInTimeZone(t.endTime ?? t.startTime, timeZone);
728
+ if (!unbounded && !dayInRange(taskDay, dateFrom, dateTo)) {
729
+ continue;
730
+ }
731
+ const taskBucket = taskDay ?? (unbounded ? UNDATED_KEY : null);
732
+ if (!taskBucket) {
733
+ continue;
734
+ }
735
+ const mins = (t.durationMs ?? 0) / 60000;
736
+ if (mins > 0) {
737
+ total += mins;
738
+ }
739
+ }
740
+ }
741
+ return total;
742
+ }
743
+
744
+ export function aggregateReportingByProject(
745
+ sessions: LooseSession[],
746
+ selectedTagKeys: Set<string>,
747
+ dateFrom: string | null,
748
+ dateTo: string | null,
749
+ timeZone: string,
750
+ defaultTagBucketEnabled: boolean = true
751
+ ): ReportingProjectRow[] {
752
+ const byKey = new Map<string, { label: string; minutes: number; count: number }>();
753
+ const unbounded = !dateFrom && !dateTo;
754
+
755
+ const bump = (key: string, label: string, mins: number) => {
756
+ const cur = byKey.get(key) ?? { label, minutes: 0, count: 0 };
757
+ cur.minutes += mins;
758
+ cur.count += 1;
759
+ if (label && !cur.label) {
760
+ cur.label = label;
761
+ }
762
+ byKey.set(key, cur);
763
+ };
764
+
765
+ for (const s of sessions) {
766
+ const allTasks = collectTasksDeduped(s);
767
+ const matching = allTasks.filter((t) => taskMatchesTags(t, selectedTagKeys, defaultTagBucketEnabled));
768
+ if (selectedTagKeys.size > 0 && matching.length === 0) {
769
+ continue;
770
+ }
771
+
772
+ for (const t of matching) {
773
+ if (!taskIncludedInReportingTaskMetrics(s, t)) {
774
+ continue;
775
+ }
776
+ const taskDay = calendarDateKeyInTimeZone(t.endTime ?? t.startTime, timeZone);
777
+ if (!unbounded && !dayInRange(taskDay, dateFrom, dateTo)) {
778
+ continue;
779
+ }
780
+ const raw = typeof t.project === "string" ? t.project.trim() : "";
781
+ const key = raw ? normalizeProjectKey(raw).toLowerCase() : "";
782
+ const mins = (t.durationMs ?? 0) / 60000;
783
+ bump(key, formatProjectDisplay(raw), mins);
784
+ }
785
+ }
786
+
787
+ return [...byKey.entries()]
788
+ .map(([projectKey, v]) => ({
789
+ projectKey,
790
+ displayLabel: v.label,
791
+ minutes: v.minutes,
792
+ taskCount: v.count,
793
+ }))
794
+ .sort((a, b) => b.minutes - a.minutes);
795
+ }
796
+
797
+ export function sortedDayKeys(
798
+ ...maps: Array<Record<string, number>>
799
+ ): string[] {
800
+ const set = new Set<string>();
801
+ for (const m of maps) {
802
+ for (const k of Object.keys(m)) {
803
+ set.add(k);
804
+ }
805
+ }
806
+ return [...set].sort();
807
+ }
808
+
809
+ export function maxBucket(maps: Array<Record<string, number>>): number {
810
+ let m = 0;
811
+ for (const map of maps) {
812
+ for (const v of Object.values(map)) {
813
+ if (v > m) {
814
+ m = v;
815
+ }
816
+ }
817
+ }
818
+ return m || 1;
819
+ }