@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
@@ -0,0 +1,135 @@
1
+ import {
2
+ formatProjectDisplay,
3
+ formatTagDisplay,
4
+ normalizeProjectKey,
5
+ normalizeTagKey,
6
+ taskTitleForDisplay,
7
+ } from "@/lib/taskParsing";
8
+
9
+ /** Entrée `taskTemplates` du payload (après validation légère). */
10
+ export type ParsedTaskTemplateRow = {
11
+ id: string;
12
+ name: string;
13
+ tags: string[];
14
+ project: string | null;
15
+ /** Projet personnel (`!`) — distinct des projets `@`. */
16
+ personalProject?: boolean;
17
+ updatedAt?: string;
18
+ };
19
+
20
+ export function formatTaskTemplateDraftLine(tpl: {
21
+ name: string;
22
+ tags?: string[];
23
+ project?: string | null;
24
+ personalProject?: boolean;
25
+ }): string {
26
+ const title = taskTitleForDisplay(tpl.name).trim();
27
+ const tags = (tpl.tags ?? [])
28
+ .map((tag) => `#${normalizeTagKey(tag)}`)
29
+ .filter(Boolean);
30
+ const project =
31
+ typeof tpl.project === "string" && tpl.project.trim()
32
+ ? formatProjectDisplay(tpl.project, {
33
+ personal: tpl.personalProject === true,
34
+ })
35
+ : "";
36
+ return [title, ...tags, project].filter(Boolean).join(" ").trim();
37
+ }
38
+
39
+ /**
40
+ * Libellé lisible pour datalist / aide : préfixe localisé + titre · métadonnées.
41
+ */
42
+ export function formatTaskTemplateDatalistLabel(
43
+ tpl: {
44
+ name: string;
45
+ tags?: string[];
46
+ project?: string | null;
47
+ personalProject?: boolean;
48
+ },
49
+ prefix: string,
50
+ ): string {
51
+ const title = taskTitleForDisplay(tpl.name).trim() || "—";
52
+ const metaParts: string[] = [];
53
+ for (const tag of tpl.tags ?? []) {
54
+ const k = normalizeTagKey(tag);
55
+ if (k) {
56
+ metaParts.push(formatTagDisplay(k));
57
+ }
58
+ }
59
+ if (typeof tpl.project === "string" && tpl.project.trim()) {
60
+ metaParts.push(
61
+ formatProjectDisplay(tpl.project, {
62
+ personal: tpl.personalProject === true,
63
+ }),
64
+ );
65
+ }
66
+ const suffix = metaParts.length > 0 ? ` · ${metaParts.join(" · ")}` : "";
67
+ return `${prefix} ${title}${suffix}`.trim();
68
+ }
69
+
70
+ export function parseTaskTemplatesFromPayload(
71
+ raw: unknown,
72
+ ): ParsedTaskTemplateRow[] {
73
+ if (!Array.isArray(raw)) {
74
+ return [];
75
+ }
76
+ const out: ParsedTaskTemplateRow[] = [];
77
+ for (const row of raw) {
78
+ if (!row || typeof row !== "object" || Array.isArray(row)) {
79
+ continue;
80
+ }
81
+ const rec = row as Record<string, unknown>;
82
+ const id = typeof rec.id === "string" ? rec.id.trim() : "";
83
+ const name = typeof rec.name === "string" ? rec.name.trim() : "";
84
+ if (!id || !name) {
85
+ continue;
86
+ }
87
+ const tags = Array.isArray(rec.tags)
88
+ ? rec.tags
89
+ .map((t) => (typeof t === "string" ? normalizeTagKey(t) : ""))
90
+ .filter((t) => t.length > 0)
91
+ : [];
92
+ const project =
93
+ typeof rec.project === "string"
94
+ ? normalizeProjectKey(rec.project)
95
+ : null;
96
+ const personalProject = rec.personalProject === true;
97
+ const updatedAt =
98
+ typeof rec.updatedAt === "string" ? rec.updatedAt : undefined;
99
+ out.push({ id, name, tags, project, personalProject, updatedAt });
100
+ }
101
+ return out;
102
+ }
103
+
104
+ function dedupeAndSortTagKeys(tags: string[]): string[] {
105
+ const seen = new Set<string>();
106
+ const out: string[] = [];
107
+ for (const t of tags) {
108
+ const key = normalizeTagKey(t).trim().toLowerCase();
109
+ if (!key || seen.has(key)) {
110
+ continue;
111
+ }
112
+ seen.add(key);
113
+ out.push(key);
114
+ }
115
+ out.sort();
116
+ return out;
117
+ }
118
+
119
+ /** Signature stable pour comparer une tâche avec un template (insensible à la casse / ordre des tags). */
120
+ export function buildTaskTemplateSignature(input: {
121
+ name: string;
122
+ tags?: string[];
123
+ project?: string | null;
124
+ /** Même clé projet `@` / `!` doit produire des signatures distinctes. */
125
+ personalProject?: boolean;
126
+ }): string {
127
+ const name = taskTitleForDisplay(input.name).trim().toLowerCase();
128
+ const project =
129
+ typeof input.project === "string" && input.project.trim()
130
+ ? normalizeProjectKey(input.project).toLowerCase()
131
+ : "";
132
+ const scope = input.personalProject === true ? "p" : "w";
133
+ const tags = dedupeAndSortTagKeys(input.tags ?? []);
134
+ return `${name}|${scope}|${project}|${tags.join(",")}`;
135
+ }
@@ -0,0 +1,265 @@
1
+ import type { Lang } from "@/lib/dashboardCopy";
2
+ import { formatIsoInstantShort } from "@/lib/formatIsoShort";
3
+ import { formatDuration } from "@/lib/taskParsing";
4
+
5
+ export type TaskTimelineGanttRow = {
6
+ id: string;
7
+ name: string;
8
+ project: string | null;
9
+ /** Temps personnel (`!projet`) vs productif (`@`). */
10
+ personalProject?: boolean;
11
+ startMs: number;
12
+ /** Absent lorsque la tâche est encore au minuteur (fin = « maintenant » dans la modale). */
13
+ endMs?: number;
14
+ /** Durée suivie par le minuteur (ms), tel que stocké côté tâche ; absent si inconnu. */
15
+ timerDurationMs: number | null;
16
+ startLabel: string;
17
+ endLabel: string;
18
+ durationLabel: string | null;
19
+ };
20
+
21
+ type TaskLike = {
22
+ id?: string;
23
+ name?: string;
24
+ startTime?: string;
25
+ endTime?: string;
26
+ durationMs?: number;
27
+ project?: string | null;
28
+ personalProject?: boolean;
29
+ taskTimerLaps?: Array<{
30
+ startTime?: string;
31
+ endTime?: string;
32
+ durationMs?: number;
33
+ }>;
34
+ taskCurrentLapStartedAt?: string | number | null;
35
+ };
36
+
37
+ /**
38
+ * Même fusion que le panneau Tâches : `tasks` + pile active (sans doublon id).
39
+ */
40
+ export function mergeSessionTasksForTimeline(
41
+ sess: {
42
+ tasks?: unknown;
43
+ activeTasks?: unknown;
44
+ activeTask?: unknown;
45
+ } | null | undefined,
46
+ ): TaskLike[] {
47
+ if (!sess) {
48
+ return [];
49
+ }
50
+ const map = new Map<string, TaskLike>();
51
+ const base = Array.isArray(sess.tasks) ? sess.tasks : [];
52
+ for (const raw of base) {
53
+ const t = raw as TaskLike;
54
+ if (t?.id) {
55
+ map.set(String(t.id), t);
56
+ }
57
+ }
58
+ const stack: TaskLike[] =
59
+ Array.isArray(sess.activeTasks) && sess.activeTasks.length > 0
60
+ ? [...(sess.activeTasks as TaskLike[])]
61
+ : sess.activeTask
62
+ ? [sess.activeTask as TaskLike]
63
+ : [];
64
+ for (const t of stack) {
65
+ if (t?.id) {
66
+ map.set(String(t.id), t);
67
+ }
68
+ }
69
+ return [...map.values()];
70
+ }
71
+
72
+ export function buildTaskTimelineGanttRows(
73
+ mergedTasks: TaskLike[],
74
+ opts: {
75
+ isInspecting: boolean;
76
+ lang: Lang;
77
+ displayTimeZone: string;
78
+ use24HourClock: boolean;
79
+ },
80
+ ): TaskTimelineGanttRow[] {
81
+ const out: TaskTimelineGanttRow[] = [];
82
+ const { isInspecting, lang, displayTimeZone, use24HourClock } = opts;
83
+ for (const task of mergedTasks) {
84
+ const taskId = String(task.id ?? "").trim();
85
+ if (!taskId) {
86
+ continue;
87
+ }
88
+ const taskName = typeof task.name === "string" ? task.name : "";
89
+ const taskProject = task.project ?? null;
90
+ const personalProject = task.personalProject === true;
91
+ const laps = Array.isArray(task.taskTimerLaps) ? task.taskTimerLaps : [];
92
+ let lapRowCount = 0;
93
+ for (let i = 0; i < laps.length; i++) {
94
+ const lap = laps[i];
95
+ const startMs =
96
+ typeof lap?.startTime === "string" ? Date.parse(lap.startTime) : Number.NaN;
97
+ const endMsRaw =
98
+ typeof lap?.endTime === "string" ? Date.parse(lap.endTime) : Number.NaN;
99
+ if (!Number.isFinite(startMs) || !Number.isFinite(endMsRaw) || endMsRaw < startMs) {
100
+ continue;
101
+ }
102
+ const timerDurationMs =
103
+ typeof lap?.durationMs === "number" && Number.isFinite(lap.durationMs)
104
+ ? Math.max(0, Math.floor(lap.durationMs))
105
+ : Math.max(0, Math.floor(endMsRaw - startMs));
106
+ if (timerDurationMs <= 0) {
107
+ continue;
108
+ }
109
+ const startLabel =
110
+ formatIsoInstantShort(
111
+ new Date(startMs).toISOString(),
112
+ lang,
113
+ displayTimeZone,
114
+ use24HourClock,
115
+ ) ?? "—";
116
+ const endLabel =
117
+ formatIsoInstantShort(
118
+ new Date(endMsRaw).toISOString(),
119
+ lang,
120
+ displayTimeZone,
121
+ use24HourClock,
122
+ ) ?? "—";
123
+ lapRowCount += 1;
124
+ out.push({
125
+ id: `${taskId}::lap::${i + 1}`,
126
+ name: taskName,
127
+ project: taskProject,
128
+ personalProject,
129
+ startMs,
130
+ endMs: endMsRaw,
131
+ timerDurationMs,
132
+ startLabel,
133
+ endLabel,
134
+ durationLabel: formatDuration(timerDurationMs / 60000),
135
+ });
136
+ }
137
+ const runningLapStartRaw = task.taskCurrentLapStartedAt;
138
+ const runningLapStartMs =
139
+ typeof runningLapStartRaw === "string"
140
+ ? Date.parse(runningLapStartRaw)
141
+ : typeof runningLapStartRaw === "number" && Number.isFinite(runningLapStartRaw)
142
+ ? runningLapStartRaw
143
+ : Number.NaN;
144
+ let hasLapRows = lapRowCount > 0;
145
+ if (!isInspecting && Number.isFinite(runningLapStartMs)) {
146
+ const nowMs = Date.now();
147
+ if (nowMs >= runningLapStartMs) {
148
+ const timerDurationMs = Math.max(0, Math.floor(nowMs - runningLapStartMs));
149
+ const startLabel =
150
+ formatIsoInstantShort(
151
+ new Date(runningLapStartMs).toISOString(),
152
+ lang,
153
+ displayTimeZone,
154
+ use24HourClock,
155
+ ) ?? "—";
156
+ out.push({
157
+ id: `${taskId}::lap::running`,
158
+ name: taskName,
159
+ project: taskProject,
160
+ personalProject,
161
+ startMs: runningLapStartMs,
162
+ endMs: undefined,
163
+ timerDurationMs,
164
+ startLabel,
165
+ endLabel: lang === "fr" ? "en cours" : "running",
166
+ durationLabel: timerDurationMs > 0 ? formatDuration(timerDurationMs / 60000) : null,
167
+ });
168
+ hasLapRows = true;
169
+ }
170
+ }
171
+ if (hasLapRows) {
172
+ continue;
173
+ }
174
+ let startMs =
175
+ typeof task.startTime === "string"
176
+ ? Date.parse(task.startTime)
177
+ : Number.NaN;
178
+ const endMsRaw =
179
+ typeof task.endTime === "string"
180
+ ? Date.parse(task.endTime)
181
+ : Number.NaN;
182
+ if (!Number.isFinite(startMs) && !Number.isFinite(endMsRaw)) {
183
+ continue;
184
+ }
185
+ if (!Number.isFinite(startMs) && Number.isFinite(endMsRaw)) {
186
+ const dur =
187
+ typeof task.durationMs === "number" &&
188
+ Number.isFinite(task.durationMs)
189
+ ? task.durationMs
190
+ : 60_000;
191
+ startMs = endMsRaw - dur;
192
+ }
193
+ if (!Number.isFinite(startMs)) {
194
+ continue;
195
+ }
196
+ const hasEnd = Number.isFinite(endMsRaw);
197
+ const endMs = hasEnd ? endMsRaw : undefined;
198
+
199
+ const startLabel =
200
+ formatIsoInstantShort(
201
+ new Date(startMs).toISOString(),
202
+ lang,
203
+ displayTimeZone,
204
+ use24HourClock,
205
+ ) ?? "—";
206
+ const endLabel = hasEnd
207
+ ? formatIsoInstantShort(
208
+ new Date(endMsRaw).toISOString(),
209
+ lang,
210
+ displayTimeZone,
211
+ use24HourClock,
212
+ ) ?? "—"
213
+ : isInspecting
214
+ ? "—"
215
+ : lang === "fr"
216
+ ? "en cours"
217
+ : "running";
218
+ const durationMin =
219
+ typeof task.durationMs === "number" && Number.isFinite(task.durationMs)
220
+ ? task.durationMs / 60000
221
+ : Number.NaN;
222
+ const timerDurationMs =
223
+ typeof task.durationMs === "number" && Number.isFinite(task.durationMs)
224
+ ? task.durationMs
225
+ : null;
226
+ out.push({
227
+ id: taskId,
228
+ name: taskName,
229
+ project: taskProject,
230
+ personalProject,
231
+ startMs,
232
+ endMs,
233
+ timerDurationMs,
234
+ startLabel,
235
+ endLabel,
236
+ durationLabel:
237
+ Number.isFinite(durationMin) && durationMin > 0
238
+ ? formatDuration(durationMin)
239
+ : null,
240
+ });
241
+ }
242
+ out.sort(
243
+ (a, b) => a.startMs - b.startMs || String(a.id).localeCompare(b.id),
244
+ );
245
+ return out;
246
+ }
247
+
248
+ export function parseSessionBoundsMs(session: {
249
+ startAt?: string | null;
250
+ endAt?: string | null;
251
+ } | null | undefined): {
252
+ sessionStartMs: number | null;
253
+ sessionEndMs: number | null;
254
+ } {
255
+ const rawStart =
256
+ typeof session?.startAt === "string" ? session.startAt.trim() : "";
257
+ const rawEnd =
258
+ typeof session?.endAt === "string" ? session.endAt.trim() : "";
259
+ const sessionStartMs = rawStart ? Date.parse(rawStart) : Number.NaN;
260
+ const sessionEndMs = rawEnd ? Date.parse(rawEnd) : Number.NaN;
261
+ return {
262
+ sessionStartMs: Number.isFinite(sessionStartMs) ? sessionStartMs : null,
263
+ sessionEndMs: Number.isFinite(sessionEndMs) ? sessionEndMs : null,
264
+ };
265
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Détermine si une tâche doit s’afficher comme « planifiée » (fenêtre temporelle encore dans le futur),
3
+ * plutôt que comme en cours ou terminée.
4
+ */
5
+
6
+ function parseIsoMs(raw: string | undefined | null): number {
7
+ if (typeof raw !== "string") {
8
+ return Number.NaN;
9
+ }
10
+ const t = raw.trim();
11
+ if (!t) {
12
+ return Number.NaN;
13
+ }
14
+ const ms = Date.parse(t);
15
+ return Number.isFinite(ms) ? ms : Number.NaN;
16
+ }
17
+
18
+ export type TaskTemporalPlannedInput = {
19
+ isDone?: boolean;
20
+ startTime?: string;
21
+ endTime?: string;
22
+ };
23
+
24
+ /**
25
+ * - Tâche non terminée : `startTime` strictement postérieur à `nowMs`.
26
+ * - Tâche terminée : affichage « planifié » seulement si **tout** le créneau est encore dans le futur —
27
+ * `startTime` et `endTime` parseables et strictement postérieurs à `nowMs`. Sinon (début déjà passé,
28
+ * fin encore future : ex. fin programmée sur une tâche déjà terminée), la carte suit la section **terminées**,
29
+ * pas **Planifiées**, pour ne pas bloquer visuellement entre deux mondes jusqu’à l’heure de fin.
30
+ * - Si `startTime` est absent ou non parseable mais `endTime` est future : on conserve le comportement
31
+ * hérité (planifié tant que la fin est future) pour les entrées rétro sans début fiable.
32
+ */
33
+ export function isTaskDisplayPlanned(
34
+ task: TaskTemporalPlannedInput,
35
+ nowMs: number = Date.now(),
36
+ ): boolean {
37
+ if (task.isDone === true) {
38
+ const endMs = parseIsoMs(task.endTime);
39
+ if (!Number.isFinite(endMs) || endMs <= nowMs) {
40
+ return false;
41
+ }
42
+ const startMs = parseIsoMs(task.startTime);
43
+ if (Number.isFinite(startMs)) {
44
+ return startMs > nowMs;
45
+ }
46
+ return true;
47
+ }
48
+ const startMs = parseIsoMs(task.startTime);
49
+ return Number.isFinite(startMs) && startMs > nowMs;
50
+ }
51
+
52
+ export type SessionTemporalPlannedInput = {
53
+ startAt?: string | null;
54
+ endAt?: string | null;
55
+ };
56
+
57
+ /** Session ouverte dont l’horodatage officiel de début est encore dans le futur. */
58
+ export function isOpenSessionDisplayPlanned(
59
+ session: SessionTemporalPlannedInput | null | undefined,
60
+ nowMs: number = Date.now(),
61
+ ): boolean {
62
+ const endRaw =
63
+ typeof session?.endAt === "string" ? session.endAt.trim() : "";
64
+ if (endRaw) {
65
+ return false;
66
+ }
67
+ const startMs = parseIsoMs(
68
+ typeof session?.startAt === "string" ? session.startAt : null,
69
+ );
70
+ return Number.isFinite(startMs) && startMs > nowMs;
71
+ }