@nightkatana/kronosys-app 1.0.0-beta.2 → 1.0.0-beta.21

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 (112) hide show
  1. package/README.md +28 -1
  2. package/app/api/action/route.ts +39 -3
  3. package/app/api/action-logs/route.ts +24 -0
  4. package/app/api/backup/route.ts +1 -1
  5. package/app/api/restore/route.ts +145 -0
  6. package/app/changelog/page.tsx +71 -4
  7. package/app/globals.css +127 -0
  8. package/app/guide/page.tsx +61 -15
  9. package/app/implementation/page.tsx +700 -0
  10. package/app/layout.tsx +14 -3
  11. package/app/licenses/page.tsx +99 -37
  12. package/app/logs/page.tsx +258 -0
  13. package/app/manifest.ts +5 -5
  14. package/app/page.tsx +784 -229
  15. package/app/reporting/page.tsx +1266 -474
  16. package/app/settings/page.tsx +252 -18
  17. package/bin/kronosys.mjs +140 -15
  18. package/components/KronosysPayloadProvider.tsx +2 -0
  19. package/components/RouteTransition.tsx +18 -0
  20. package/components/dashboard/AppShellCommandCenterPlaceholder.tsx +17 -0
  21. package/components/dashboard/AppShellHeaderSessionMeta.tsx +210 -0
  22. package/components/dashboard/AppShellHeaderWallClock.tsx +54 -0
  23. package/components/dashboard/AppShellLiveSessionDrawer.tsx +154 -38
  24. package/components/dashboard/AppShellRouteNav.tsx +323 -48
  25. package/components/dashboard/DashboardPauseBackdrop.tsx +50 -0
  26. package/components/dashboard/DashboardSimpleModal.tsx +168 -25
  27. package/components/dashboard/DashboardTour.tsx +115 -29
  28. package/components/dashboard/GlobalPauseConfirmModal.tsx +183 -0
  29. package/components/dashboard/KronosysDatetimePopoverField.tsx +167 -122
  30. package/components/dashboard/KronosysTimePopoverField.tsx +54 -12
  31. package/components/dashboard/NewSessionScopeModal.tsx +211 -20
  32. package/components/dashboard/PlannedTaskBoundaryConflictWatcher.tsx +275 -0
  33. package/components/dashboard/ReportingTour.tsx +87 -21
  34. package/components/dashboard/SavedProjectPicker.tsx +16 -3
  35. package/components/dashboard/SelectedSessionSidebarBlock.tsx +512 -142
  36. package/components/dashboard/SessionListPanel.tsx +327 -44
  37. package/components/dashboard/SettingsTagsProjectsSection.tsx +1073 -264
  38. package/components/dashboard/SettingsTaskTemplatesSection.tsx +316 -0
  39. package/components/dashboard/SettingsTour.tsx +86 -21
  40. package/components/dashboard/TagPills.tsx +14 -1
  41. package/components/dashboard/TaskFocusPanel.tsx +1081 -478
  42. package/components/dashboard/TaskSessionLiveCard.tsx +650 -135
  43. package/components/dashboard/TaskTimelineGanttModal.tsx +601 -0
  44. package/components/dashboard/taskFieldStyles.ts +20 -4
  45. package/components/dashboard/useReportingInteractionState.ts +80 -0
  46. package/lib/appShellHeaderClasses.ts +13 -0
  47. package/lib/businessRulesMatrix.ts +210 -0
  48. package/lib/copyToClipboard.ts +43 -0
  49. package/lib/dashboardCopy.ts +494 -84
  50. package/lib/dashboardQuickSearch.ts +54 -2
  51. package/lib/dashboardTimeZone.ts +109 -0
  52. package/lib/formatAppShellWallClock.ts +66 -0
  53. package/lib/formatSessionNameTemplate.ts +141 -0
  54. package/lib/generatedUserChangelog.ts +177 -6
  55. package/lib/globalPausePreview.ts +292 -0
  56. package/lib/implementationNotes.ts +1188 -0
  57. package/lib/kronosysApi.ts +6 -0
  58. package/lib/kronosysDashboardModalGates.ts +24 -0
  59. package/lib/plannedBoundaryAttention.ts +9 -0
  60. package/lib/plannedBoundaryConflict.ts +23 -0
  61. package/lib/reportingAggregate.ts +517 -75
  62. package/lib/reportingMetricHelp.ts +8 -0
  63. package/lib/reportingStrings.ts +37 -3
  64. package/lib/sessionListMerge.ts +4 -0
  65. package/lib/sessionTaskSidebarStats.ts +182 -21
  66. package/lib/settingsCopy.ts +178 -4
  67. package/lib/taskParsing.ts +360 -103
  68. package/lib/taskTemplateDraft.ts +135 -0
  69. package/lib/taskTimelineGantt.ts +265 -0
  70. package/lib/temporalDisplayPlanned.ts +71 -0
  71. package/lib/userGuideCopy.ts +121 -47
  72. package/next.config.ts +7 -0
  73. package/package.json +12 -24
  74. package/server/actionDispatch.ts +1000 -77
  75. package/server/actionTaskSession.ts +337 -24
  76. package/server/db.ts +7 -15
  77. package/server/dbSchema.ts +24 -0
  78. package/server/defaultCfg.ts +5 -0
  79. package/server/gitlabTokenStore.ts +0 -12
  80. package/server/liveHistorySync.ts +53 -0
  81. package/server/mainTimerHydrate.ts +38 -2
  82. package/server/payloadStore.ts +33 -11
  83. package/server/sessionWallHydrate.ts +66 -3
  84. package/server/userActionLog.ts +126 -0
  85. package/sonar-project.properties +11 -0
  86. package/tsconfig.json +2 -1
  87. package/components/dashboard/IssuePickerModal.tsx +0 -168
  88. package/components/dashboard/ThemeToggle.test.tsx +0 -26
  89. package/lib/backupCsvExport.test.ts +0 -149
  90. package/lib/dashboardQuickSearchQuery.test.ts +0 -63
  91. package/lib/dataDir.test.ts +0 -87
  92. package/lib/formatIsoShort.test.ts +0 -46
  93. package/lib/kronoFocusRhythm.test.ts +0 -130
  94. package/lib/kronoFocusTimerUrgency.test.ts +0 -74
  95. package/lib/legacyKronoFocusStorageKeys.test.ts +0 -29
  96. package/lib/reportingAggregate.test.ts +0 -325
  97. package/lib/reportingNonFinalIndicators.test.ts +0 -157
  98. package/lib/reportingTagWeekBreakdown.test.ts +0 -141
  99. package/lib/reportingWeekLayout.test.ts +0 -239
  100. package/lib/sessionAssiduity.test.ts +0 -25
  101. package/lib/sessionEndWarnings.test.ts +0 -200
  102. package/lib/sessionListMerge.test.ts +0 -101
  103. package/lib/sessionTaskSidebarStats.test.ts +0 -24
  104. package/lib/taskParsing.test.ts +0 -153
  105. package/lib/usageProfile.test.ts +0 -84
  106. package/server/actionDispatch.test.ts +0 -723
  107. package/server/actionTaskSession.test.ts +0 -713
  108. package/server/kronoFocusHydrate.test.ts +0 -142
  109. package/server/kronoFocusMigrate.test.ts +0 -53
  110. package/server/mainTimerHydrate.test.ts +0 -65
  111. package/server/payloadStore.test.ts +0 -78
  112. package/server/sessionWallHydrate.test.ts +0 -46
@@ -8,11 +8,16 @@ import {
8
8
  } from "./taskParsing";
9
9
  import type { KronosysUpdatePayload } from "./kronosysApi";
10
10
  import { LEGACY_TASK_CYCLES_KEY, LEGACY_TASK_USED_FLAG_KEY } from "./legacyKronoFocusStorageKeys";
11
- import { calendarDateKeyInTimeZone } from "./dashboardTimeZone";
11
+ import {
12
+ calendarDateKeyInTimeZone,
13
+ calendarDayStartUtcMsInTimeZone,
14
+ splitMinutesByCalendarDay,
15
+ } from "./dashboardTimeZone";
12
16
  import {
13
17
  localWeekStartKeyFromDayKey,
14
18
  type ReportingWeekStartsOn,
15
19
  } from "./reportingWeekLayout";
20
+ import { normalizeSessionEndReasonKind } from "./sessionEndReason";
16
21
 
17
22
  export type LooseTask = {
18
23
  id?: string;
@@ -20,13 +25,33 @@ export type LooseTask = {
20
25
  endTime?: string;
21
26
  durationMs?: number;
22
27
  isDone?: boolean;
28
+ manualTaskTimerPaused?: boolean;
23
29
  usedKronoFocus?: boolean;
24
30
  kronoFocusCycles?: number;
25
31
  tags?: string[];
26
32
  project?: string | null;
33
+ /** Projet issu d’un jeton `!` (temps personnel), distinct des projets `@` productifs. */
34
+ personalProject?: boolean;
27
35
  subtasks?: Array<{ done?: boolean }>;
28
36
  };
29
37
 
38
+ /** Filtre les tâches pour les agrégats « @ projet » vs « ! projet personnel ». */
39
+ export type ReportingTaskProjectScope = "all" | "work" | "personal";
40
+
41
+ export function taskMatchesReportingProjectScope(
42
+ task: LooseTask,
43
+ scope: ReportingTaskProjectScope,
44
+ ): boolean {
45
+ const isP = task.personalProject === true;
46
+ if (scope === "all") {
47
+ return true;
48
+ }
49
+ if (scope === "work") {
50
+ return !isP;
51
+ }
52
+ return isP;
53
+ }
54
+
30
55
  export type LooseSession = {
31
56
  sessionId: string;
32
57
  savedAt?: string;
@@ -56,6 +81,8 @@ export type LooseSession = {
56
81
  * Présent seulement si `scheduledStartAt` a été enregistré.
57
82
  */
58
83
  sessionStartOffsetMinutes?: number | null;
84
+ /** Catégorie enregistrée à la clôture de session (voir {@link normalizeSessionEndReasonKind}). */
85
+ sessionEndReasonKind?: string;
59
86
  };
60
87
 
61
88
  export function mergeSessionsFromPayload(payload: KronosysUpdatePayload): LooseSession[] {
@@ -193,7 +220,14 @@ export function localWeekMondayFromDayKey(dayKey: string): string | null {
193
220
  }
194
221
 
195
222
  /**
196
- * Durée « murale » de la session : valeur persistée si disponible, sinon écart entre `startAt` et `endAt` (minutes).
223
+ * Durée « murale » de la session : valeur persistée si disponible, sinon écart `startAt` `endAt` (minutes).
224
+ *
225
+ * Repli de dernier recours : si `endAt` manque et que la durée n’a pas été matérialisée
226
+ * (sessions « orphelines » : restées vivantes en historique sans clôture explicite, héritages
227
+ * de bogues côté hôte), on retombe sur `savedAt - startAt`. `savedAt` = dernier sync de la
228
+ * session ; c’est une borne supérieure raisonnable du temps écoulé tant que la session
229
+ * était active, et ça vaut mieux que de compter 0 minute pour une session qui a clairement
230
+ * eu de l’activité (présence de tâches, durées de tâches non nulles, etc.).
197
231
  */
198
232
  export function sessionWallClockMinutes(s: LooseSession): number {
199
233
  const persisted = s.sessionDurationMinutes;
@@ -205,9 +239,191 @@ export function sessionWallClockMinutes(s: LooseSession): number {
205
239
  if (Number.isFinite(start) && Number.isFinite(end) && end > start) {
206
240
  return (end - start) / 60000;
207
241
  }
242
+ const saved = s.savedAt ? Date.parse(s.savedAt) : NaN;
243
+ if (Number.isFinite(start) && Number.isFinite(saved) && saved > start) {
244
+ return (saved - start) / 60000;
245
+ }
208
246
  return 0;
209
247
  }
210
248
 
249
+ function parseTaskIntervalMs(task: LooseTask): [number, number] | null {
250
+ const start = typeof task.startTime === "string" ? Date.parse(task.startTime) : NaN;
251
+ const end = typeof task.endTime === "string" ? Date.parse(task.endTime) : NaN;
252
+ if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) {
253
+ return null;
254
+ }
255
+ return [start, end];
256
+ }
257
+
258
+ /**
259
+ * Bornes UTC `[startMs, endMs]` du « segment d’ouverture » de la session. `endMs` privilégie
260
+ * `endAt` (session clôturée), retombe sur `savedAt` (session live → dernier sync ; orpheline →
261
+ * dernière trace en historique). Renvoie `null` si on ne peut pas borner correctement.
262
+ */
263
+ function sessionWallSpanRangeMs(s: LooseSession): [number, number] | null {
264
+ const startMs = s.startAt ? Date.parse(s.startAt) : NaN;
265
+ if (!Number.isFinite(startMs)) {
266
+ return null;
267
+ }
268
+ const endIsoCandidate =
269
+ typeof s.endAt === "string" && s.endAt.trim() !== ""
270
+ ? s.endAt
271
+ : (s.savedAt ?? null);
272
+ const endMs = endIsoCandidate ? Date.parse(endIsoCandidate) : NaN;
273
+ if (!Number.isFinite(endMs) || endMs <= startMs) {
274
+ return null;
275
+ }
276
+ return [startMs, endMs];
277
+ }
278
+
279
+ /**
280
+ * Minutes « murales » de la session ventilées par jour calendaire local. Le total persisté
281
+ * (`sessionDurationMinutes` quand > 0, sinon `endAt - startAt`) est réparti **au prorata** de la
282
+ * fraction du segment d’ouverture tombant dans chaque jour. Les pauses ne sont donc pas
283
+ * attribuées précisément à leurs jours, mais le total reste correct et le découpage minuit est
284
+ * respecté.
285
+ *
286
+ * Renvoie une map vide quand la session n’a pas de bornes utilisables (orpheline sans `savedAt`).
287
+ */
288
+ export function sessionWallMinutesByDay(s: LooseSession, timeZone: string): Map<string, number> {
289
+ const span = sessionWallSpanRangeMs(s);
290
+ if (!span) {
291
+ return new Map();
292
+ }
293
+ const [startMs, endMs] = span;
294
+ const spanByDay = splitMinutesByCalendarDay(startMs, endMs, timeZone);
295
+ const totalSpanMin = (endMs - startMs) / 60000;
296
+ if (totalSpanMin <= 0 || spanByDay.size === 0) {
297
+ return new Map();
298
+ }
299
+ const persistedTotal = sessionWallClockMinutes(s);
300
+ if (persistedTotal <= 0) {
301
+ return new Map();
302
+ }
303
+ if (spanByDay.size === 1) {
304
+ /** Cas typique : la session tient dans un seul jour calendaire. On évite l’imprécision
305
+ * arithmétique liée au prorata et on alloue exactement le total persisté. */
306
+ const [k] = spanByDay.keys();
307
+ return new Map([[k, persistedTotal]]);
308
+ }
309
+ const out = new Map<string, number>();
310
+ for (const [day, dayMin] of spanByDay) {
311
+ const ratio = dayMin / totalSpanMin;
312
+ const allocated = persistedTotal * ratio;
313
+ if (allocated > 0) {
314
+ out.set(day, allocated);
315
+ }
316
+ }
317
+ return out;
318
+ }
319
+
320
+ /**
321
+ * Minutes d’une tâche (`durationMs`) ventilées par jour calendaire local, **au prorata** de la
322
+ * fraction du segment `[startTime, endTime]` tombant dans chaque jour. Pour les tâches qui
323
+ * tiennent dans un seul jour, tout va dans ce jour ; sinon le découpage minuit est respecté.
324
+ *
325
+ * Renvoie `null` si on n’a pas de segment utilisable — l’appelant peut alors retomber sur
326
+ * la stratégie « jour de fin de tâche » (`endTime ?? startTime`).
327
+ */
328
+ export function taskMinutesByDayInTimeZone(
329
+ task: LooseTask,
330
+ timeZone: string,
331
+ ): Map<string, number> | null {
332
+ const interval = parseTaskIntervalMs(task);
333
+ if (!interval) {
334
+ return null;
335
+ }
336
+ const [startMs, endMs] = interval;
337
+ const spanByDay = splitMinutesByCalendarDay(startMs, endMs, timeZone);
338
+ if (spanByDay.size === 0) {
339
+ return null;
340
+ }
341
+ const totalSpanMin = (endMs - startMs) / 60000;
342
+ const persistedMin = (task.durationMs ?? 0) / 60000;
343
+ if (persistedMin <= 0 || totalSpanMin <= 0) {
344
+ return null;
345
+ }
346
+ if (spanByDay.size === 1) {
347
+ const [k] = spanByDay.keys();
348
+ return new Map([[k, persistedMin]]);
349
+ }
350
+ const out = new Map<string, number>();
351
+ for (const [day, dayMin] of spanByDay) {
352
+ const allocated = persistedMin * (dayMin / totalSpanMin);
353
+ if (allocated > 0) {
354
+ out.set(day, allocated);
355
+ }
356
+ }
357
+ return out;
358
+ }
359
+
360
+ /**
361
+ * Découpe l’intervalle wall `[startTime, endTime]` d’une tâche aux frontières minuit (jour
362
+ * calendaire local) et renvoie pour chaque jour la liste des sous-intervalles `[start, end]` en
363
+ * millisecondes UTC. Sert à calculer correctement la durée non concurrente (union d’intervalles)
364
+ * par jour quand des tâches se chevauchent et qu’une partie traverse minuit.
365
+ */
366
+ export function taskIntervalsByDayInTimeZone(
367
+ task: LooseTask,
368
+ timeZone: string,
369
+ ): Map<string, Array<[number, number]>> {
370
+ const out = new Map<string, Array<[number, number]>>();
371
+ const interval = parseTaskIntervalMs(task);
372
+ if (!interval) {
373
+ return out;
374
+ }
375
+ const [startMs, endMs] = interval;
376
+ let cursor = startMs;
377
+ /** Garde-fou : même borne que `splitMinutesByCalendarDay`. */
378
+ const maxDays = 366 * 5;
379
+ for (let i = 0; i < maxDays && cursor < endMs; i += 1) {
380
+ const dayKey = calendarDateKeyInTimeZone(new Date(cursor).toISOString(), timeZone);
381
+ if (!dayKey) {
382
+ return new Map();
383
+ }
384
+ const [yStr, mStr, dStr] = dayKey.split("-");
385
+ const y = Number.parseInt(yStr, 10);
386
+ const m = Number.parseInt(mStr, 10);
387
+ const d = Number.parseInt(dStr, 10);
388
+ if (!Number.isFinite(y) || !Number.isFinite(m) || !Number.isFinite(d)) {
389
+ return new Map();
390
+ }
391
+ const nextDayStart = calendarDayStartUtcMsInTimeZone(y, m, d + 1, timeZone);
392
+ const sliceEnd = Math.min(endMs, nextDayStart);
393
+ if (sliceEnd <= cursor) {
394
+ break;
395
+ }
396
+ const list = out.get(dayKey) ?? [];
397
+ list.push([cursor, sliceEnd]);
398
+ out.set(dayKey, list);
399
+ cursor = sliceEnd;
400
+ }
401
+ return out;
402
+ }
403
+
404
+ function mergeIntervalsToMinutes(intervals: Array<[number, number]>): number {
405
+ if (intervals.length === 0) {
406
+ return 0;
407
+ }
408
+ const sorted = [...intervals].sort((a, b) => a[0] - b[0]);
409
+ let [curStart, curEnd] = sorted[0];
410
+ let totalMs = 0;
411
+ for (let i = 1; i < sorted.length; i += 1) {
412
+ const [s, e] = sorted[i];
413
+ if (s <= curEnd) {
414
+ if (e > curEnd) {
415
+ curEnd = e;
416
+ }
417
+ continue;
418
+ }
419
+ totalMs += Math.max(0, curEnd - curStart);
420
+ curStart = s;
421
+ curEnd = e;
422
+ }
423
+ totalMs += Math.max(0, curEnd - curStart);
424
+ return totalMs / 60000;
425
+ }
426
+
211
427
  function uniqueTaskTagEntries(
212
428
  task: LooseTask,
213
429
  fallbackTagDisplay: string
@@ -275,7 +491,12 @@ export function aggregateTagTaskMinutesByDayAndWeek(
275
491
  /** Libellé pour l’étiquette réservée « sans étiquette » / `default` (ex. « default » ou « défaut » selon la langue). */
276
492
  fallbackTaskTagDisplay: string,
277
493
  /** Lorsque `false`, les tâches sans étiquette sont ventilées sous la clé vide (pas `default`). */
278
- defaultTagBucketEnabled: boolean = true
494
+ defaultTagBucketEnabled: boolean = true,
495
+ /**
496
+ * Limite les tâches comptées : `all` (défaut) = tout, `work` = hors `personalProject`,
497
+ * `personal` = uniquement tâches avec `personalProject: true`.
498
+ */
499
+ taskProjectScope: ReportingTaskProjectScope = "all",
279
500
  ): { byDay: ReportingTagTimeDayRow[]; byWeek: ReportingTagTimeWeekRow[] } {
280
501
  const byTagDay = new Map<string, Map<string, number>>();
281
502
  const byTagWeek = new Map<string, Map<string, number>>();
@@ -321,37 +542,53 @@ export function aggregateTagTaskMinutesByDayAndWeek(
321
542
  if (!taskIncludedInReportingTaskMetrics(s, t)) {
322
543
  continue;
323
544
  }
324
- const taskDay = calendarDateKeyInTimeZone(t.endTime ?? t.startTime, timeZone);
325
- if (!unbounded && !dayInRange(taskDay, dateFrom, dateTo)) {
545
+ if (!taskMatchesReportingProjectScope(t, taskProjectScope)) {
326
546
  continue;
327
547
  }
328
- const taskBucket = taskDay ?? (unbounded ? UNDATED_KEY : null);
329
- if (!taskBucket) {
548
+
549
+ /* Ventilation par jour calendaire (prorata du segment `[startTime, endTime]`) avec repli
550
+ * sur le jour de fin de tâche pour les tâches sans bornes utilisables (durée 0,
551
+ * `startTime`/`endTime` manquants). */
552
+ const splitMinutes = taskMinutesByDayInTimeZone(t, timeZone);
553
+ const distribution: Array<{ day: string; minutes: number }> = (() => {
554
+ if (splitMinutes && splitMinutes.size > 0) {
555
+ return [...splitMinutes].map(([day, minutes]) => ({ day, minutes }));
556
+ }
557
+ const fallbackDay = calendarDateKeyInTimeZone(t.endTime ?? t.startTime, timeZone);
558
+ const bucket = fallbackDay ?? (unbounded ? UNDATED_KEY : null);
559
+ const mins = (t.durationMs ?? 0) / 60000;
560
+ if (!bucket || mins <= 0) {
561
+ return [];
562
+ }
563
+ return [{ day: bucket, minutes: mins }];
564
+ })();
565
+ if (distribution.length === 0) {
330
566
  continue;
331
567
  }
332
568
 
333
- const mins = (t.durationMs ?? 0) / 60000;
334
- if (mins <= 0) {
569
+ const inRange = distribution.filter(({ day }) => unbounded || dayInRange(day, dateFrom, dateTo));
570
+ if (inRange.length === 0) {
335
571
  continue;
336
572
  }
337
573
 
338
574
  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);
575
+ const tagKeyList: Array<{ key: string; display: string }> =
576
+ entries.length === 0
577
+ ? [{
578
+ key: defaultTagBucketEnabled ? DEFAULT_FALLBACK_TASK_TAG : "",
579
+ display: fallbackTaskTagDisplay,
580
+ }]
581
+ : entries;
582
+
583
+ for (const { day, minutes } of inRange) {
584
+ if (minutes <= 0) {
585
+ continue;
349
586
  }
350
- } else {
351
- for (const e of entries) {
352
- addDay(e.key, e.display, taskBucket, mins);
587
+ const weekStart = day !== UNDATED_KEY ? localWeekStartKeyFromDayKey(day, weekStartsOn) : null;
588
+ for (const e of tagKeyList) {
589
+ addDay(e.key, e.display, day, minutes);
353
590
  if (weekStart) {
354
- addWeek(e.key, weekStart, mins);
591
+ addWeek(e.key, weekStart, minutes);
355
592
  }
356
593
  }
357
594
  }
@@ -399,6 +636,8 @@ export type ReportingResult = {
399
636
  tasksByDayActive: Record<string, number>;
400
637
  /** Minutes enregistrées sur les tâches (durationMs), par jour de tâche. */
401
638
  taskMinutesByDay: Record<string, number>;
639
+ /** Minutes non concurrentes (union des intervalles start/end), par jour de tâche. */
640
+ nonConcurrentTaskMinutesByDay: Record<string, number>;
402
641
  /** Minutes de codage session (codingMinutesSession), par jour de session. */
403
642
  sessionCodingMinutesByDay: Record<string, number>;
404
643
  /**
@@ -432,8 +671,22 @@ export type ReportingResult = {
432
671
  * ou `null` si aucune.
433
672
  */
434
673
  assiduityAverageLateMinutesWhenLate: number | null;
674
+ /**
675
+ * Comptage des sessions dans la plage par type de clôture enregistré (`planned` / `early` / …).
676
+ * Clé `unspecified` : pas de catégorie valide (session ouverte, ou clôture sans choix).
677
+ */
678
+ sessionCountByClosureKind: Record<string, number>;
435
679
  };
436
680
 
681
+ /** Ordre d’affichage des lignes « sessions par type de clôture » dans les rapports. */
682
+ export const REPORTING_SESSION_CLOSURE_DISPLAY_ORDER = [
683
+ "planned",
684
+ "early",
685
+ "overrun",
686
+ "other",
687
+ "unspecified",
688
+ ] as const;
689
+
437
690
  export function aggregateReporting(
438
691
  sessions: LooseSession[],
439
692
  selectedTagKeys: Set<string>,
@@ -447,8 +700,10 @@ export function aggregateReporting(
447
700
  const tasksByDayDone: Record<string, number> = {};
448
701
  const tasksByDayActive: Record<string, number> = {};
449
702
  const taskMinutesByDay: Record<string, number> = {};
703
+ const nonConcurrentTaskMinutesByDay: Record<string, number> = {};
450
704
  const sessionCodingMinutesByDay: Record<string, number> = {};
451
705
  const sessionWallClockMinutesByDay: Record<string, number> = {};
706
+ const taskIntervalsByDay = new Map<string, Array<[number, number]>>();
452
707
 
453
708
  let kronoFocusSessionsCompleted = 0;
454
709
  let kronoFocusTasksUsedCount = 0;
@@ -467,9 +722,27 @@ export function aggregateReporting(
467
722
  let assiduityReferenceSessionCount = 0;
468
723
  let assiduityLateSessionCount = 0;
469
724
  let assiduityLateMinutesTotal = 0;
725
+ const sessionCountByClosureKind: Record<string, number> = {};
470
726
 
471
727
  const unbounded = !dateFrom && !dateTo;
472
728
 
729
+ /**
730
+ * Ajoute des minutes à un agrégat jour-par-jour en respectant le filtre de plage.
731
+ */
732
+ const addInRange = (
733
+ target: Record<string, number>,
734
+ day: string,
735
+ minutes: number,
736
+ ): void => {
737
+ if (minutes <= 0) {
738
+ return;
739
+ }
740
+ if (!unbounded && !dayInRange(day, dateFrom, dateTo)) {
741
+ return;
742
+ }
743
+ target[day] = (target[day] ?? 0) + minutes;
744
+ };
745
+
473
746
  for (const s of sessions) {
474
747
  const allTasks = collectTasksDeduped(s);
475
748
  const matching = allTasks.filter((t) => taskMatchesTags(t, selectedTagKeys, defaultTagBucketEnabled));
@@ -480,29 +753,23 @@ export function aggregateReporting(
480
753
  const sessionDay = calendarDateKeyInTimeZone(s.savedAt ?? s.endAt ?? s.startAt, timeZone);
481
754
  const sessionRangeOk = unbounded || dayInRange(sessionDay, dateFrom, dateTo);
482
755
 
756
+ /* Métriques attribuées au jour de clôture (compte de sessions, type de clôture, lignes,
757
+ * assiduité, compteurs KronoFocus) : on continue à utiliser `sessionDay` comme bucket
758
+ * unique — cohérent avec « 1 session = 1 jour de clôture ». */
483
759
  if (sessionRangeOk) {
760
+ const rk = normalizeSessionEndReasonKind(s.sessionEndReasonKind);
761
+ const closureBucket = rk || "unspecified";
762
+ sessionCountByClosureKind[closureBucket] =
763
+ (sessionCountByClosureKind[closureBucket] ?? 0) + 1;
484
764
  const sessionBucket = sessionDay ?? (unbounded ? UNDATED_KEY : null);
485
765
  if (sessionBucket) {
486
766
  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
767
  }
498
768
  sessionCountContributing += 1;
499
769
  const sc = s.kronoFocus?.sessionsCompleted;
500
770
  if (typeof sc === "number" && sc > 0) {
501
771
  kronoFocusSessionsCompleted += sc;
502
772
  }
503
- sessionCodingMinutesTotal += s.codingMinutesSession ?? 0;
504
- sessionActiveMinutesTotal += s.activeMinutes ?? 0;
505
- sessionWallClockMinutesTotal += sessionWallClockMinutes(s);
506
773
  if (typeof s.linesWrittenTotal === "number") {
507
774
  linesWrittenTotalSum += s.linesWrittenTotal;
508
775
  }
@@ -514,7 +781,10 @@ export function aggregateReporting(
514
781
  }
515
782
  mergeTupleMap(locByLanguageAcc, s.locByLanguage);
516
783
  mergeTupleMap(codingSignalsAcc, s.codingSignalsByLanguage);
517
- if (typeof s.sessionStartOffsetMinutes === "number" && Number.isFinite(s.sessionStartOffsetMinutes)) {
784
+ if (
785
+ typeof s.sessionStartOffsetMinutes === "number"
786
+ && Number.isFinite(s.sessionStartOffsetMinutes)
787
+ ) {
518
788
  assiduityReferenceSessionCount += 1;
519
789
  if (s.sessionStartOffsetMinutes > 0) {
520
790
  assiduityLateSessionCount += 1;
@@ -523,30 +793,125 @@ export function aggregateReporting(
523
793
  }
524
794
  }
525
795
 
796
+ /* Durée murale et codage session : ventilation par jour calendaire local en répartissant
797
+ * le total persisté au prorata du temps mural écoulé chaque jour (sessions à cheval sur
798
+ * minuit). Si le découpage est impossible (orpheline sans `savedAt` valide), on retombe
799
+ * sur l’ancien comportement « tout sur le jour de clôture ». */
800
+ const wallByDay = sessionWallMinutesByDay(s, timeZone);
801
+ if (wallByDay.size > 0) {
802
+ let wallAllocated = 0;
803
+ for (const v of wallByDay.values()) {
804
+ wallAllocated += v;
805
+ }
806
+ const codingTotal = s.codingMinutesSession ?? 0;
807
+ const activeTotal = s.activeMinutes ?? 0;
808
+ for (const [day, dayWallMin] of wallByDay) {
809
+ if (!unbounded && !dayInRange(day, dateFrom, dateTo)) {
810
+ continue;
811
+ }
812
+ sessionWallClockMinutesByDay[day] =
813
+ (sessionWallClockMinutesByDay[day] ?? 0) + dayWallMin;
814
+ sessionWallClockMinutesTotal += dayWallMin;
815
+ const ratio = wallAllocated > 0 ? dayWallMin / wallAllocated : 0;
816
+ if (codingTotal > 0) {
817
+ const allocCoding = codingTotal * ratio;
818
+ if (allocCoding > 0) {
819
+ sessionCodingMinutesByDay[day] =
820
+ (sessionCodingMinutesByDay[day] ?? 0) + allocCoding;
821
+ sessionCodingMinutesTotal += allocCoding;
822
+ }
823
+ }
824
+ if (activeTotal > 0) {
825
+ sessionActiveMinutesTotal += activeTotal * ratio;
826
+ }
827
+ }
828
+ } else if (sessionRangeOk && sessionDay) {
829
+ /* Repli : pas de span exploitable (rare). On préserve le comportement historique pour
830
+ * ne pas perdre la session du compte « total » à cause d’un défaut de bornes. */
831
+ const wm = sessionWallClockMinutes(s);
832
+ if (wm > 0) {
833
+ sessionWallClockMinutesByDay[sessionDay] =
834
+ (sessionWallClockMinutesByDay[sessionDay] ?? 0) + wm;
835
+ sessionWallClockMinutesTotal += wm;
836
+ }
837
+ const cm = s.codingMinutesSession ?? 0;
838
+ if (cm > 0) {
839
+ sessionCodingMinutesByDay[sessionDay] =
840
+ (sessionCodingMinutesByDay[sessionDay] ?? 0) + cm;
841
+ sessionCodingMinutesTotal += cm;
842
+ }
843
+ sessionActiveMinutesTotal += s.activeMinutes ?? 0;
844
+ }
845
+
526
846
  for (const t of matching) {
527
847
  if (!taskIncludedInReportingTaskMetrics(s, t)) {
528
848
  continue;
529
849
  }
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) {
850
+
851
+ /* Ventilation des minutes de tâche par jour : prorata du segment `[startTime, endTime]` ;
852
+ * repli sur le jour de fin de tâche si on n’a pas de bornes utilisables (tâche sans
853
+ * `endTime` finie, durée 0, etc.). */
854
+ const taskMinByDay = taskMinutesByDayInTimeZone(t, timeZone);
855
+ const taskCountedDay = (() => {
856
+ if (taskMinByDay) {
857
+ for (const day of taskMinByDay.keys()) {
858
+ if (unbounded || dayInRange(day, dateFrom, dateTo)) {
859
+ return day;
860
+ }
861
+ }
862
+ return null;
863
+ }
864
+ const fallbackDay = calendarDateKeyInTimeZone(t.endTime ?? t.startTime, timeZone);
865
+ if (!fallbackDay) {
866
+ return unbounded ? UNDATED_KEY : null;
867
+ }
868
+ if (!unbounded && !dayInRange(fallbackDay, dateFrom, dateTo)) {
869
+ return null;
870
+ }
871
+ return fallbackDay;
872
+ })();
873
+
874
+ if (taskCountedDay === null) {
536
875
  continue;
537
876
  }
538
877
 
539
878
  taskCountContributing += 1;
540
- const mins = (t.durationMs ?? 0) / 60000;
541
- taskMinutesTotal += mins;
542
- if (mins > 0) {
543
- taskMinutesByDay[taskBucket] = (taskMinutesByDay[taskBucket] ?? 0) + mins;
879
+ if (t.isDone === true) {
880
+ tasksByDayDone[taskCountedDay] = (tasksByDayDone[taskCountedDay] ?? 0) + 1;
881
+ } else {
882
+ tasksByDayActive[taskCountedDay] = (tasksByDayActive[taskCountedDay] ?? 0) + 1;
544
883
  }
545
884
 
546
- if (t.isDone === true) {
547
- tasksByDayDone[taskBucket] = (tasksByDayDone[taskBucket] ?? 0) + 1;
885
+ if (taskMinByDay) {
886
+ for (const [day, mins] of taskMinByDay) {
887
+ addInRange(taskMinutesByDay, day, mins);
888
+ if (unbounded || dayInRange(day, dateFrom, dateTo)) {
889
+ taskMinutesTotal += mins;
890
+ }
891
+ }
548
892
  } else {
549
- tasksByDayActive[taskBucket] = (tasksByDayActive[taskBucket] ?? 0) + 1;
893
+ /* Repli sans bornes : tout sur le jour de fin (UNDATED si plage ouverte). */
894
+ const mins = (t.durationMs ?? 0) / 60000;
895
+ if (mins > 0) {
896
+ addInRange(taskMinutesByDay, taskCountedDay, mins);
897
+ if (unbounded || dayInRange(taskCountedDay, dateFrom, dateTo)) {
898
+ taskMinutesTotal += mins;
899
+ }
900
+ }
901
+ }
902
+
903
+ /* Intervalles non concurrents : on découpe à minuit pour que le merge par jour reflète
904
+ * correctement la fraction tombant dans chaque journée. */
905
+ const splitIntervals = taskIntervalsByDayInTimeZone(t, timeZone);
906
+ for (const [day, intervals] of splitIntervals) {
907
+ if (!unbounded && !dayInRange(day, dateFrom, dateTo)) {
908
+ continue;
909
+ }
910
+ const list = taskIntervalsByDay.get(day) ?? [];
911
+ for (const iv of intervals) {
912
+ list.push(iv);
913
+ }
914
+ taskIntervalsByDay.set(day, list);
550
915
  }
551
916
 
552
917
  const tr = t as Record<string, unknown>;
@@ -566,6 +931,12 @@ export function aggregateReporting(
566
931
  }
567
932
 
568
933
  const locByLanguageMerged = [...locByLanguageAcc.entries()].sort((a, b) => b[1] - a[1]);
934
+ for (const [bucket, intervals] of taskIntervalsByDay) {
935
+ const unionMinutes = mergeIntervalsToMinutes(intervals);
936
+ if (unionMinutes > 0) {
937
+ nonConcurrentTaskMinutesByDay[bucket] = unionMinutes;
938
+ }
939
+ }
569
940
  const codingSignalsByLanguageMerged = [...codingSignalsAcc.entries()].sort((a, b) => b[1] - a[1]);
570
941
  const assiduityAverageLateMinutesWhenLate =
571
942
  assiduityLateSessionCount > 0 ? assiduityLateMinutesTotal / assiduityLateSessionCount : null;
@@ -575,6 +946,7 @@ export function aggregateReporting(
575
946
  tasksByDayDone,
576
947
  tasksByDayActive,
577
948
  taskMinutesByDay,
949
+ nonConcurrentTaskMinutesByDay,
578
950
  sessionCodingMinutesByDay,
579
951
  sessionWallClockMinutesByDay,
580
952
  kronoFocusSessionsCompleted,
@@ -595,6 +967,7 @@ export function aggregateReporting(
595
967
  assiduityLateSessionCount,
596
968
  assiduityLateMinutesTotal,
597
969
  assiduityAverageLateMinutesWhenLate,
970
+ sessionCountByClosureKind,
598
971
  };
599
972
  }
600
973
 
@@ -625,7 +998,7 @@ function compareProjectKeyForSort(a: string, b: string): number {
625
998
  }
626
999
 
627
1000
  /**
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é).
1001
+ * Temps enregistré sur les tâches (`durationMs`) par projet (`@` productif ou `!` personnel selon le filtre) et par jour de tâche.
629
1002
  */
630
1003
  export function aggregateProjectTaskMinutesByDay(
631
1004
  sessions: LooseSession[],
@@ -633,7 +1006,9 @@ export function aggregateProjectTaskMinutesByDay(
633
1006
  dateFrom: string | null,
634
1007
  dateTo: string | null,
635
1008
  timeZone: string,
636
- defaultTagBucketEnabled: boolean = true
1009
+ defaultTagBucketEnabled: boolean = true,
1010
+ /** Par défaut `work` : agrégat des projets `@` (hors temps personnel `!`). */
1011
+ taskProjectScope: ReportingTaskProjectScope = "work",
637
1012
  ): ReportingProjectTimeDayRow[] {
638
1013
  const byProjDay = new Map<string, Map<string, number>>();
639
1014
  const displayByProj = new Map<string, string>();
@@ -650,30 +1025,49 @@ export function aggregateProjectTaskMinutesByDay(
650
1025
  if (!taskIncludedInReportingTaskMetrics(s, t)) {
651
1026
  continue;
652
1027
  }
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) {
1028
+ if (!taskMatchesReportingProjectScope(t, taskProjectScope)) {
659
1029
  continue;
660
1030
  }
661
1031
 
662
- const mins = (t.durationMs ?? 0) / 60000;
663
- if (mins <= 0) {
1032
+ const splitMinutes = taskMinutesByDayInTimeZone(t, timeZone);
1033
+ const distribution: Array<{ day: string; minutes: number }> = (() => {
1034
+ if (splitMinutes && splitMinutes.size > 0) {
1035
+ return [...splitMinutes].map(([day, minutes]) => ({ day, minutes }));
1036
+ }
1037
+ const fallbackDay = calendarDateKeyInTimeZone(t.endTime ?? t.startTime, timeZone);
1038
+ const bucket = fallbackDay ?? (unbounded ? UNDATED_KEY : null);
1039
+ const mins = (t.durationMs ?? 0) / 60000;
1040
+ if (!bucket || mins <= 0) {
1041
+ return [];
1042
+ }
1043
+ return [{ day: bucket, minutes: mins }];
1044
+ })();
1045
+ if (distribution.length === 0) {
664
1046
  continue;
665
1047
  }
666
1048
 
667
1049
  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);
1050
+ if (taskProjectScope === "personal" && !raw) {
1051
+ continue;
673
1052
  }
674
- inner.set(taskBucket, (inner.get(taskBucket) ?? 0) + mins);
675
- if (raw && !displayByProj.has(key)) {
676
- displayByProj.set(key, formatProjectDisplay(raw));
1053
+ const key = raw ? normalizeProjectKey(raw).toLowerCase() : "";
1054
+ const displayOpts = taskProjectScope === "personal" ? ({ personal: true } as const) : undefined;
1055
+ for (const { day, minutes } of distribution) {
1056
+ if (!unbounded && !dayInRange(day, dateFrom, dateTo)) {
1057
+ continue;
1058
+ }
1059
+ if (minutes <= 0) {
1060
+ continue;
1061
+ }
1062
+ let inner = byProjDay.get(key);
1063
+ if (!inner) {
1064
+ inner = new Map();
1065
+ byProjDay.set(key, inner);
1066
+ }
1067
+ inner.set(day, (inner.get(day) ?? 0) + minutes);
1068
+ if (raw && !displayByProj.has(key)) {
1069
+ displayByProj.set(key, formatProjectDisplay(raw, displayOpts));
1070
+ }
677
1071
  }
678
1072
  }
679
1073
  }
@@ -686,7 +1080,8 @@ export function aggregateProjectTaskMinutesByDay(
686
1080
  displayProject:
687
1081
  projectKey === ""
688
1082
  ? ""
689
- : (displayByProj.get(projectKey) ?? formatProjectDisplay(projectKey)),
1083
+ : (displayByProj.get(projectKey) ??
1084
+ formatProjectDisplay(projectKey, taskProjectScope === "personal" ? { personal: true } : undefined)),
690
1085
  day,
691
1086
  minutes,
692
1087
  });
@@ -747,14 +1142,28 @@ export function aggregateReportingByProject(
747
1142
  dateFrom: string | null,
748
1143
  dateTo: string | null,
749
1144
  timeZone: string,
750
- defaultTagBucketEnabled: boolean = true
1145
+ defaultTagBucketEnabled: boolean = true,
1146
+ /** Par défaut `work` : projets `@` (hors temps personnel `!`). */
1147
+ taskProjectScope: ReportingTaskProjectScope = "work",
751
1148
  ): ReportingProjectRow[] {
752
1149
  const byKey = new Map<string, { label: string; minutes: number; count: number }>();
753
1150
  const unbounded = !dateFrom && !dateTo;
1151
+ const displayOpts = taskProjectScope === "personal" ? ({ personal: true } as const) : undefined;
754
1152
 
755
- const bump = (key: string, label: string, mins: number) => {
1153
+ const bumpMinutes = (key: string, label: string, mins: number): void => {
1154
+ if (mins <= 0) {
1155
+ return;
1156
+ }
756
1157
  const cur = byKey.get(key) ?? { label, minutes: 0, count: 0 };
757
1158
  cur.minutes += mins;
1159
+ if (label && !cur.label) {
1160
+ cur.label = label;
1161
+ }
1162
+ byKey.set(key, cur);
1163
+ };
1164
+
1165
+ const bumpCount = (key: string, label: string): void => {
1166
+ const cur = byKey.get(key) ?? { label, minutes: 0, count: 0 };
758
1167
  cur.count += 1;
759
1168
  if (label && !cur.label) {
760
1169
  cur.label = label;
@@ -773,14 +1182,47 @@ export function aggregateReportingByProject(
773
1182
  if (!taskIncludedInReportingTaskMetrics(s, t)) {
774
1183
  continue;
775
1184
  }
776
- const taskDay = calendarDateKeyInTimeZone(t.endTime ?? t.startTime, timeZone);
777
- if (!unbounded && !dayInRange(taskDay, dateFrom, dateTo)) {
1185
+ if (!taskMatchesReportingProjectScope(t, taskProjectScope)) {
778
1186
  continue;
779
1187
  }
1188
+
1189
+ const splitMinutes = taskMinutesByDayInTimeZone(t, timeZone);
1190
+ const distribution: Array<{ day: string; minutes: number }> = (() => {
1191
+ if (splitMinutes && splitMinutes.size > 0) {
1192
+ return [...splitMinutes].map(([day, minutes]) => ({ day, minutes }));
1193
+ }
1194
+ const fallbackDay = calendarDateKeyInTimeZone(t.endTime ?? t.startTime, timeZone);
1195
+ const bucket = fallbackDay ?? (unbounded ? UNDATED_KEY : null);
1196
+ const mins = (t.durationMs ?? 0) / 60000;
1197
+ if (!bucket) {
1198
+ return [];
1199
+ }
1200
+ return [{ day: bucket, minutes: mins }];
1201
+ })();
1202
+ if (distribution.length === 0) {
1203
+ continue;
1204
+ }
1205
+
780
1206
  const raw = typeof t.project === "string" ? t.project.trim() : "";
1207
+ if (taskProjectScope === "personal" && !raw) {
1208
+ continue;
1209
+ }
781
1210
  const key = raw ? normalizeProjectKey(raw).toLowerCase() : "";
782
- const mins = (t.durationMs ?? 0) / 60000;
783
- bump(key, formatProjectDisplay(raw), mins);
1211
+ const label = formatProjectDisplay(raw, displayOpts);
1212
+
1213
+ let counted = false;
1214
+ for (const { day, minutes } of distribution) {
1215
+ if (!unbounded && !dayInRange(day, dateFrom, dateTo)) {
1216
+ continue;
1217
+ }
1218
+ bumpMinutes(key, label, minutes);
1219
+ if (!counted) {
1220
+ /* Une tâche traversant minuit n’est comptée qu’une fois dans `taskCount`, sur le
1221
+ * premier jour en plage : conserve la sémantique « 1 tâche = 1 contribution ». */
1222
+ bumpCount(key, label);
1223
+ counted = true;
1224
+ }
1225
+ }
784
1226
  }
785
1227
  }
786
1228