@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
@@ -114,6 +114,8 @@ export type ReportingMetricHelpBlock = {
114
114
  metricHelpAssiduityLateTotalBody: string;
115
115
  metricHelpAssiduityAvgLateAria: string;
116
116
  metricHelpAssiduityAvgLateBody: string;
117
+ metricHelpClosureBreakdownAria: string;
118
+ metricHelpClosureBreakdownBody: string;
117
119
  };
118
120
 
119
121
  export const reportingMetricHelpEn: ReportingMetricHelpBlock = {
@@ -269,6 +271,9 @@ export const reportingMetricHelpEn: ReportingMetricHelpBlock = {
269
271
  metricHelpAssiduityAvgLateAria: "Help: average late when late",
270
272
  metricHelpAssiduityAvgLateBody:
271
273
  "Cumulative late minutes divided by the number of late sessions (unchanged if there are zero late sessions, shown as —). A simple punctuality read when a reference time was stored.",
274
+ metricHelpClosureBreakdownAria: "Help: sessions by closure type",
275
+ metricHelpClosureBreakdownBody:
276
+ "How many included sessions fall into each closure category saved when the session ended (planned, early, overrun, other). “Unspecified” covers open sessions and ended sessions where no category was saved. Same session-day and tag rules as “Sessions (in range)”; the five counts sum to that total.",
272
277
  };
273
278
 
274
279
  export const reportingMetricHelpFr: ReportingMetricHelpBlock = {
@@ -427,4 +432,7 @@ export const reportingMetricHelpFr: ReportingMetricHelpBlock = {
427
432
  metricHelpAssiduityAvgLateAria: "Aide : retard moyen (lorsqu’en retard)",
428
433
  metricHelpAssiduityAvgLateBody:
429
434
  "Retard cumulé divisé par le nombre de sessions en retard ; indiqué en « — » s’il n’y en a pas. Pertinent lorsqu’une heure de début a été enregistrée côté hôte.",
435
+ metricHelpClosureBreakdownAria: "Aide : sessions par type de clôture",
436
+ metricHelpClosureBreakdownBody:
437
+ "Nombre de sessions incluses par catégorie enregistrée à la fin de session (conforme au prévu, anticipée, prolongation, autre). « Sans catégorie » regroupe les sessions encore ouvertes et celles terminées sans choix enregistré. Mêmes règles de jour de session et d’étiquettes que « Sessions (plage) » ; la somme des cinq lignes égale ce total.",
430
438
  };
@@ -1,4 +1,5 @@
1
1
  import type { Lang } from "./dashboardCopy";
2
+ import { dashboardStrings } from "./dashboardCopy";
2
3
  import {
3
4
  reportingMetricHelpEn,
4
5
  reportingMetricHelpFr,
@@ -9,8 +10,13 @@ export type ReportingNavStrings = {
9
10
  dashboard: string;
10
11
  reporting: string;
11
12
  settings: string;
13
+ logs: string;
12
14
  /** Guide d’utilisation in-app (`/guide`). */
13
15
  guide: string;
16
+ /** Page inventaire des user stories (`/implementation`). */
17
+ implementation: string;
18
+ /** Infobulle lorsque l’icône tableau de bord pulse (rappel conflit minuteurs). */
19
+ dashboardAttentionHint?: string;
14
20
  };
15
21
 
16
22
  export type ReportingStrings = ReportingNavStrings &
@@ -87,6 +93,8 @@ export type ReportingStrings = ReportingNavStrings &
87
93
  tableActive: string;
88
94
  /** Temps enregistré sur les tâches (durationMs), par jour de tâche. */
89
95
  tableTaskTime: string;
96
+ /** Temps non concurrent (union des intervalles horodatés), par jour de tâche. */
97
+ tableTaskTimeNonConcurrent: string;
90
98
  /** Temps de codage session (codingMinutesSession), par jour de session. */
91
99
  tableSessionCoding: string;
92
100
  tableSessionWall: string;
@@ -149,6 +157,11 @@ export type ReportingStrings = ReportingNavStrings &
149
157
  tocNavAria: string;
150
158
  /** Libellé court pour la grille d’indicateurs (pas de titre visible dans la section). */
151
159
  tocSummaryKpis: string;
160
+ /** Sessions regroupées par raison de clôture (sous-bloc du résumé). */
161
+ tocClosureBreakdown: string;
162
+ closureBreakdownTitle: string;
163
+ /** Sessions sans catégorie de clôture enregistrée (ou session encore ouverte). */
164
+ closureBreakdownUnspecified: string;
152
165
  /** Libellé pour le tableau jour / sessions / tâches. */
153
166
  tocDailyTable: string;
154
167
  tocTagTimeSection: string;
@@ -170,7 +183,9 @@ const en: ReportingStrings = {
170
183
  dashboard: "Dashboard",
171
184
  reporting: "Reporting",
172
185
  settings: "Settings",
186
+ logs: "Action logs",
173
187
  guide: "User guide",
188
+ implementation: "User stories — implementation",
174
189
  title: "Reporting",
175
190
  subtitle:
176
191
  "Aggregates from Kronosys history (local API). Filter by date range and tags. Task time by @project is included.",
@@ -197,7 +212,7 @@ const en: ReportingStrings = {
197
212
 
198
213
  • Workspace folder snapshot (lines in the open project): ignores filters — not Kronosys history.
199
214
 
200
- • Summary KPIs, charts, day-by-day table: filters apply. Session-day side: session count, session coding/active/wall time, KronoFocus sessions completed, lines written, and (when the host stored a planned start) punctuality aggregates: reference count, late session count, cumulative and average late minutes. Task-day side: task rows, recorded task time, done/in-progress counts, task KronoFocus used/cycles.
215
+ • Summary KPIs, charts, day-by-day table: filters apply. Session-day side: session count, session coding/active/wall time, KronoFocus sessions completed, lines written, breakdown of sessions by closure type when the host saved one at session end, and (when the host stored a planned start) punctuality aggregates: reference count, late session count, cumulative and average late minutes. Task-day side: task rows, recorded task time, done/in-progress counts, task KronoFocus used/cycles.
201
216
 
202
217
  • “Lines by language” and “Coding signals” tables: session day within the range; with tags selected, a whole session is dropped if no task matches (same rule as elsewhere).
203
218
 
@@ -249,6 +264,7 @@ Archived sessions: task-based metrics only count fully completed tasks (subtasks
249
264
  tableDone: "Done",
250
265
  tableActive: "Active",
251
266
  tableTaskTime: "Task time",
267
+ tableTaskTimeNonConcurrent: "Task time (non-concurrent)",
252
268
  tableSessionCoding: "Session coding",
253
269
  tableSessionWall: "Session span",
254
270
  undatedLabel: "Undated",
@@ -307,6 +323,9 @@ Archived sessions: task-based metrics only count fully completed tasks (subtasks
307
323
  tocTitle: "On this page",
308
324
  tocNavAria: "Reporting — sections on this page",
309
325
  tocSummaryKpis: "Summary indicators",
326
+ tocClosureBreakdown: "Sessions by closure type",
327
+ closureBreakdownTitle: "Sessions by closure type",
328
+ closureBreakdownUnspecified: "Unspecified (open or no category)",
310
329
  tocDailyTable: "Day-by-day table",
311
330
  tocTagTimeSection: "Time by tag",
312
331
  weekNavPrev: "Previous week",
@@ -326,7 +345,9 @@ const fr: ReportingStrings = {
326
345
  dashboard: "Tableau de bord",
327
346
  reporting: "Rapports",
328
347
  settings: "Paramètres",
348
+ logs: "Journal des actions",
329
349
  guide: "Guide d’utilisation",
350
+ implementation: "User stories — implémentation",
330
351
  title: "Rapports",
331
352
  subtitle:
332
353
  "Agrégats à partir de l’historique Kronosys (API locale). Filtrez par plage de dates et par étiquettes. Le temps par @projet est inclus.",
@@ -354,7 +375,7 @@ const fr: ReportingStrings = {
354
375
 
355
376
  • Instantané « Dossier ouvert » (lignes par langage du workspace) : hors filtres — ce n’est pas l’historique Kronosys.
356
377
 
357
- • Indicateurs de synthèse, graphiques, tableau par jour : la plage et les étiquettes s’appliquent. Côté jour de session : nombre de sessions, temps de codage et d’activité, durée murale, KronoFocus « sessions terminées », lignes écrites, et (si l’hôte a enregistré un début prévu) indicateurs d’assiduité : sessions avec référence, sessions en retard, retard cumulé, retard moyen lorsqu’en retard. Côté jour de tâche : lignes de tâches, temps enregistré, tâches terminées / en cours, KronoFocus côté tâches.
378
+ • Indicateurs de synthèse, graphiques, tableau par jour : la plage et les étiquettes s’appliquent. Côté jour de session : nombre de sessions, temps de codage et d’activité, durée murale, KronoFocus « sessions terminées », lignes écrites, ventilation des sessions par type de clôture lorsque l’hôte en a enregistré un à la fin de session, et (si l’hôte a enregistré un début prévu) indicateurs d’assiduité : sessions avec référence, sessions en retard, retard cumulé, retard moyen lorsqu’en retard. Côté jour de tâche : lignes de tâches, temps enregistré, tâches terminées / en cours, KronoFocus côté tâches.
358
379
 
359
380
  • Tableaux « Lignes par langage » et « Signaux de codage » : jour de session dans la plage ; avec étiquettes sélectionnées, toute la session est exclue si aucune tâche ne correspond (même règle qu’ailleurs).
360
381
 
@@ -407,6 +428,7 @@ Sessions archivées : pour les métriques basées sur les tâches, seules les t
407
428
  tableDone: "Terminées",
408
429
  tableActive: "En cours",
409
430
  tableTaskTime: "Temps (tâches)",
431
+ tableTaskTimeNonConcurrent: "Temps (tâches, non concurrent)",
410
432
  tableSessionCoding: "Codage (sessions)",
411
433
  tableSessionWall: "Durée session",
412
434
  undatedLabel: "Sans date",
@@ -466,6 +488,9 @@ Sessions archivées : pour les métriques basées sur les tâches, seules les t
466
488
  tocTitle: "Sur cette page",
467
489
  tocNavAria: "Rapports — sections de la page",
468
490
  tocSummaryKpis: "Indicateurs de synthèse",
491
+ tocClosureBreakdown: "Sessions par type de clôture",
492
+ closureBreakdownTitle: "Sessions par type de clôture",
493
+ closureBreakdownUnspecified: "Sans catégorie (ouverte ou non enregistrée)",
469
494
  tocDailyTable: "Tableau par jour",
470
495
  tocTagTimeSection: "Temps par étiquette",
471
496
  weekNavPrev: "Semaine précédente",
@@ -487,5 +512,14 @@ export function reportingStrings(lang: Lang): ReportingStrings {
487
512
 
488
513
  export function reportingNav(lang: Lang): ReportingNavStrings {
489
514
  const s = reportingStrings(lang);
490
- return { dashboard: s.dashboard, reporting: s.reporting, settings: s.settings, guide: s.guide };
515
+ const d = dashboardStrings(lang);
516
+ return {
517
+ dashboard: s.dashboard,
518
+ reporting: s.reporting,
519
+ settings: s.settings,
520
+ logs: s.logs,
521
+ guide: s.guide,
522
+ implementation: s.implementation,
523
+ dashboardAttentionHint: d.navDashboardPlannedConflictPulseHint,
524
+ };
491
525
  }
@@ -7,6 +7,7 @@ type LiveLike = {
7
7
  createdAt?: string | null;
8
8
  startAt?: string | null;
9
9
  endAt?: string | null;
10
+ sessionNote?: string;
10
11
  sessionEndReasonKind?: unknown;
11
12
  sessionEndReasonNote?: unknown;
12
13
  sessionDurationMinutes?: number;
@@ -66,5 +67,8 @@ export function mergeLiveSessionIntoHistory(
66
67
  if (typeof live?.sessionEndReasonNote === "string" && live.sessionEndReasonNote.trim() !== "") {
67
68
  entry.sessionEndReasonNote = live.sessionEndReasonNote.trim();
68
69
  }
70
+ if (typeof live?.sessionNote === "string") {
71
+ entry.sessionNote = live.sessionNote;
72
+ }
69
73
  return [entry, ...without];
70
74
  }
@@ -2,53 +2,214 @@
2
2
  * Compteurs de tâches pour la carte « session sélectionnée », alignés sur {@link TaskFocusPanel}.
3
3
  */
4
4
 
5
- type TaskLike = { id?: string; isDone?: boolean; manualTaskTimerPaused?: boolean };
5
+ import { isTaskDisplayPlanned } from "./temporalDisplayPlanned";
6
+
7
+ type TaskLike = {
8
+ id?: string;
9
+ isDone?: boolean;
10
+ manualTaskTimerPaused?: boolean;
11
+ startTime?: string;
12
+ endTime?: string;
13
+ };
14
+ type SubtaskLike = { id?: string; durationMs?: number };
15
+ type SessionTaskLike = TaskLike & {
16
+ durationMs?: number;
17
+ subtasks?: SubtaskLike[];
18
+ activeSubtaskTimerId?: string | null;
19
+ subtaskTimerStartedAt?: string | number;
20
+ mainTimerSegmentStartedAt?: string | null;
21
+ };
6
22
 
7
23
  type SessionLike = {
8
- tasks?: TaskLike[];
9
- activeTasks?: TaskLike[];
10
- activeTask?: TaskLike | null;
24
+ tasks?: SessionTaskLike[];
25
+ activeTasks?: SessionTaskLike[];
26
+ activeTask?: SessionTaskLike | null;
11
27
  /** Session clôturée : pas de compteurs « en cours » ou « en liste » pour l’affichage carte session. */
12
28
  endAt?: string | null;
13
29
  };
30
+ const SUBTASK_TIMER_STARTED_AT = "subtaskTimerStartedAt";
31
+ const MAIN_TIMER_SEGMENT_STARTED_AT = "mainTimerSegmentStartedAt";
14
32
 
15
33
  function sessionClosedForDisplay(session: SessionLike | null | undefined): boolean {
16
34
  return typeof session?.endAt === "string" && session.endAt.trim() !== "";
17
35
  }
18
36
 
19
- function activeStackFromSession(session: SessionLike | null | undefined): TaskLike[] {
37
+ function activeStackFromSession(session: SessionLike | null | undefined): SessionTaskLike[] {
20
38
  if (!session) {
21
39
  return [];
22
40
  }
23
- const raw =
24
- Array.isArray(session.activeTasks) && session.activeTasks.length > 0
25
- ? session.activeTasks
26
- : session.activeTask
27
- ? [session.activeTask]
28
- : [];
29
- return raw.filter((t): t is TaskLike => Boolean(t));
41
+ const hasActiveTasks = Array.isArray(session.activeTasks) && session.activeTasks.length > 0;
42
+ let raw: Array<SessionTaskLike | null | undefined> = [];
43
+ if (hasActiveTasks) {
44
+ raw = session.activeTasks ?? [];
45
+ } else if (session.activeTask) {
46
+ raw = [session.activeTask];
47
+ }
48
+ return raw.filter((t): t is SessionTaskLike => Boolean(t));
49
+ }
50
+
51
+ function asNonNegativeIntMs(raw: unknown): number {
52
+ if (typeof raw !== "number" || !Number.isFinite(raw)) {
53
+ return 0;
54
+ }
55
+ return Math.max(0, Math.floor(raw));
30
56
  }
31
57
 
32
- /** Tâches avec minuteur actif (!terminé, pas en pause manuelle). */
33
- export function countRunningTasksOnTimer(session: SessionLike | null | undefined): number {
34
- return activeStackFromSession(session).filter((t) => !t.isDone && !t.manualTaskTimerPaused).length;
58
+ function readTimerStartMs(raw: unknown): number | null {
59
+ if (typeof raw === "string" && raw.trim() !== "") {
60
+ const parsed = Date.parse(raw);
61
+ return Number.isFinite(parsed) ? parsed : null;
62
+ }
63
+ if (typeof raw === "number" && Number.isFinite(raw)) {
64
+ return raw;
65
+ }
66
+ return null;
67
+ }
68
+
69
+ function collectUniqueSessionTasks(session: SessionLike | null | undefined): SessionTaskLike[] {
70
+ const taskRows = new Map<string, SessionTaskLike>();
71
+ const pushTask = (task: SessionTaskLike | null | undefined) => {
72
+ if (!task) {
73
+ return;
74
+ }
75
+ const id = typeof task.id === "string" ? task.id.trim() : "";
76
+ if (!id || taskRows.has(id)) {
77
+ return;
78
+ }
79
+ taskRows.set(id, task);
80
+ };
81
+ for (const row of session?.activeTasks ?? []) {
82
+ pushTask(row);
83
+ }
84
+ pushTask(session?.activeTask ?? null);
85
+ for (const row of session?.tasks ?? []) {
86
+ pushTask(row);
87
+ }
88
+ return [...taskRows.values()];
89
+ }
90
+
91
+ function getTaskSubtasksSnapshot(task: SessionTaskLike): {
92
+ subtaskCount: number;
93
+ subtaskStoredMs: number;
94
+ } {
95
+ const subtasks = Array.isArray(task.subtasks) ? task.subtasks : [];
96
+ let subtaskStoredMs = 0;
97
+ for (const subtask of subtasks) {
98
+ subtaskStoredMs += asNonNegativeIntMs(subtask?.durationMs);
99
+ }
100
+ return {
101
+ subtaskCount: subtasks.length,
102
+ subtaskStoredMs,
103
+ };
104
+ }
105
+
106
+ function getTaskInflightTimerMs(task: SessionTaskLike, nowMs: number): {
107
+ taskInflightMs: number;
108
+ subtaskInflightMs: number;
109
+ } {
110
+ if (isTaskDisplayPlanned(task, nowMs)) {
111
+ return { taskInflightMs: 0, subtaskInflightMs: 0 };
112
+ }
113
+ const activeSubtaskId =
114
+ typeof task.activeSubtaskTimerId === "string" ? task.activeSubtaskTimerId.trim() : "";
115
+ if (activeSubtaskId) {
116
+ const startedMs = readTimerStartMs(task[SUBTASK_TIMER_STARTED_AT]);
117
+ const subtaskInflightMs =
118
+ startedMs === null ? 0 : Math.max(0, Math.floor(nowMs - startedMs));
119
+ return { taskInflightMs: subtaskInflightMs, subtaskInflightMs };
120
+ }
121
+ const startedMs = readTimerStartMs(task[MAIN_TIMER_SEGMENT_STARTED_AT]);
122
+ const taskInflightMs = startedMs === null ? 0 : Math.max(0, Math.floor(nowMs - startedMs));
123
+ return { taskInflightMs, subtaskInflightMs: 0 };
124
+ }
125
+
126
+ /** Tâches avec minuteur actif (!terminé, pas en pause manuelle, pas « planifiée » dans le futur). */
127
+ export function countRunningTasksOnTimer(
128
+ session: SessionLike | null | undefined,
129
+ nowMs: number = Date.now(),
130
+ ): number {
131
+ return activeStackFromSession(session).filter(
132
+ (t) =>
133
+ !t.isDone &&
134
+ !t.manualTaskTimerPaused &&
135
+ !isTaskDisplayPlanned(t, nowMs),
136
+ ).length;
35
137
  }
36
138
 
37
139
  /**
38
- * Même regroupement que le panneau tâches : minuteur actif, liste des non terminées, terminées.
140
+ * Même regroupement que le panneau tâches : minuteur actif, planifiées, liste des autres non terminées, terminées.
39
141
  */
40
- export function countSessionTasksForSidebar(session: SessionLike | null | undefined): {
142
+ export function countSessionTasksForSidebar(
143
+ session: SessionLike | null | undefined,
144
+ nowMs: number = Date.now(),
145
+ ): {
41
146
  running: number;
147
+ planned: number;
42
148
  pausedList: number;
43
149
  completed: number;
44
150
  } {
45
- const taskList = session?.tasks ?? [];
46
- const completed = taskList.filter((t) => t.isDone === true).length;
47
151
  const closed = sessionClosedForDisplay(session);
48
- const pausedList = closed ? 0 : taskList.filter((t) => t.isDone !== true).length;
152
+ const unique = collectUniqueSessionTasks(session);
153
+ let planned = 0;
154
+ let completed = 0;
155
+ let pausedList = 0;
156
+ for (const t of unique) {
157
+ if (isTaskDisplayPlanned(t, nowMs)) {
158
+ planned += 1;
159
+ continue;
160
+ }
161
+ if (t.isDone === true) {
162
+ completed += 1;
163
+ continue;
164
+ }
165
+ if (closed) {
166
+ continue;
167
+ }
168
+ const onStack = activeStackFromSession(session).some(
169
+ (a) => String(a.id) === String(t.id),
170
+ );
171
+ const stackRunning =
172
+ onStack && !t.isDone && !t.manualTaskTimerPaused && !isTaskDisplayPlanned(t, nowMs);
173
+ if (!stackRunning) {
174
+ pausedList += 1;
175
+ }
176
+ }
49
177
  return {
50
- running: closed ? 0 : countRunningTasksOnTimer(session),
178
+ running: closed ? 0 : countRunningTasksOnTimer(session, nowMs),
179
+ planned,
51
180
  pausedList,
52
181
  completed,
53
182
  };
54
183
  }
184
+
185
+ export function getSessionTaskTimerBreakdown(
186
+ session: SessionLike | null | undefined,
187
+ nowMs: number = Date.now(),
188
+ ): {
189
+ taskCount: number;
190
+ subtaskCount: number;
191
+ taskTimersTotalMs: number;
192
+ subtaskTimersTotalMs: number;
193
+ } {
194
+ const tasks = collectUniqueSessionTasks(session);
195
+
196
+ let taskTimersTotalMs = 0;
197
+ let subtaskTimersTotalMs = 0;
198
+ let subtaskCount = 0;
199
+
200
+ for (const task of tasks) {
201
+ const baseMs = asNonNegativeIntMs(task.durationMs);
202
+ const subtaskSnapshot = getTaskSubtasksSnapshot(task);
203
+ const inflight = getTaskInflightTimerMs(task, nowMs);
204
+ subtaskCount += subtaskSnapshot.subtaskCount;
205
+ taskTimersTotalMs += baseMs + inflight.taskInflightMs;
206
+ subtaskTimersTotalMs += subtaskSnapshot.subtaskStoredMs + inflight.subtaskInflightMs;
207
+ }
208
+
209
+ return {
210
+ taskCount: tasks.length,
211
+ subtaskCount,
212
+ taskTimersTotalMs,
213
+ subtaskTimersTotalMs,
214
+ };
215
+ }