@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
@@ -1,713 +0,0 @@
1
- import { afterEach, describe, expect, it, vi } from "vitest";
2
-
3
- import type { KronosysUpdatePayload } from "@/lib/kronosysApi";
4
-
5
- import {
6
- ensureTaskParentDurationCoversSubtasksMs,
7
- finishTaskInSession,
8
- findTaskRecord,
9
- flushAllSubtaskTimersInSession,
10
- prepareSessionForExclusiveMainTimerUnpaused,
11
- prepareSessionForSubtaskTimer,
12
- purgeTagEverywhere,
13
- reorderSubtasksInSession,
14
- resolveTaskSession,
15
- setActiveSubtaskTimerInSession,
16
- setTaskPausedInSession,
17
- sumSubtasksDurationMsStored,
18
- updateTaskInSession,
19
- } from "./actionTaskSession";
20
-
21
- function basePayload(): KronosysUpdatePayload {
22
- return {
23
- viewType: "dashboard",
24
- cfg: {},
25
- history: [],
26
- historyArchived: [],
27
- knownTags: ["alpha"],
28
- userKnownTags: [],
29
- excludedSuggestionTags: [],
30
- tagDescriptions: {},
31
- projectDescriptions: {},
32
- };
33
- }
34
-
35
- describe("resolveTaskSession", () => {
36
- it("sans sessionId retourne la session live", () => {
37
- const p = basePayload();
38
- p.current = { sessionId: "live-1", tasks: [], activeTasks: [], activeTask: null };
39
- const r = resolveTaskSession(p, undefined);
40
- expect(r?.isLive).toBe(true);
41
- expect(String(r?.session.sessionId)).toBe("live-1");
42
- });
43
-
44
- it("résout une entrée d’historique", () => {
45
- const p = basePayload();
46
- p.current = { sessionId: "live-1", tasks: [], activeTasks: [], activeTask: null };
47
- p.history = [{ sessionId: "old-1", sessionName: "Arch", tasks: [], activeTasks: [], activeTask: null }];
48
- const r = resolveTaskSession(p, "old-1");
49
- expect(r?.isLive).toBe(false);
50
- expect(String(r?.session.sessionId)).toBe("old-1");
51
- });
52
- });
53
-
54
- describe("finishTaskInSession", () => {
55
- it("déplace une tâche active vers tasks terminées", () => {
56
- const sess: Record<string, unknown> = {
57
- sessionId: "s",
58
- tasks: [],
59
- activeTasks: [
60
- {
61
- id: "t1",
62
- name: "X",
63
- startTime: "2020-01-01T00:00:00.000Z",
64
- durationMs: 0,
65
- isDone: false,
66
- tags: [],
67
- project: null,
68
- subtasks: [],
69
- },
70
- ],
71
- activeTask: null,
72
- };
73
- const ok = finishTaskInSession(sess, "t1", false);
74
- expect(ok).toBe(true);
75
- expect((sess.activeTasks as unknown[]).length).toBe(0);
76
- const tasks = sess.tasks as Record<string, unknown>[];
77
- expect(tasks.length).toBe(1);
78
- expect(tasks[0].isDone).toBe(true);
79
- expect(tasks[0].shouldCommit).toBe(false);
80
- expect(tasks[0].tags).toEqual(["default"]);
81
- });
82
-
83
- it("marque toutes les sous-tâches comme terminées à la fin de la tâche", () => {
84
- const sess: Record<string, unknown> = {
85
- sessionId: "s",
86
- tasks: [],
87
- activeTasks: [
88
- {
89
- id: "t1",
90
- name: "X",
91
- startTime: "2020-01-01T00:00:00.000Z",
92
- durationMs: 0,
93
- isDone: false,
94
- tags: [],
95
- project: null,
96
- subtasks: [
97
- { id: "s1", title: "a", done: false },
98
- { id: "s2", title: "b", done: true },
99
- ],
100
- },
101
- ],
102
- activeTask: null,
103
- };
104
- const ok = finishTaskInSession(sess, "t1", false);
105
- expect(ok).toBe(true);
106
- const tasks = sess.tasks as Record<string, unknown>[];
107
- const subs = tasks[0].subtasks as { done: boolean }[];
108
- expect(subs.every((x) => x.done === true)).toBe(true);
109
- expect(tasks[0].tags as string[]).toEqual(["default"]);
110
- });
111
- });
112
-
113
- describe("coherence parent / sous-tâches (durationMs)", () => {
114
- it("ensureTaskParentDurationCoversSubtasksMs élève le parent si la somme des points la dépasse", () => {
115
- const task: Record<string, unknown> = {
116
- id: "t",
117
- durationMs: 30_000,
118
- subtasks: [
119
- { id: "a", title: "A", done: false, durationMs: 20_000 },
120
- { id: "b", title: "B", done: false, durationMs: 25_000 },
121
- ],
122
- };
123
- expect(sumSubtasksDurationMsStored(task)).toBe(45_000);
124
- ensureTaskParentDurationCoversSubtasksMs(task);
125
- expect(task.durationMs).toBe(45_000);
126
- });
127
-
128
- it("ne réduit jamais le parent quand la somme des points est plus basse (temps sans point)", () => {
129
- const task: Record<string, unknown> = {
130
- id: "t",
131
- durationMs: 120_000,
132
- subtasks: [{ id: "a", title: "A", done: false, durationMs: 30_000 }],
133
- };
134
- ensureTaskParentDurationCoversSubtasksMs(task);
135
- expect(task.durationMs).toBe(120_000);
136
- });
137
- });
138
-
139
- describe("reorderSubtasksInSession", () => {
140
- it("réordonne les sous-tâches selon la liste d’identifiants", () => {
141
- const sess: Record<string, unknown> = {
142
- sessionId: "s",
143
- tasks: [],
144
- activeTasks: [
145
- {
146
- id: "t1",
147
- name: "X",
148
- startTime: "2020-01-01T00:00:00.000Z",
149
- durationMs: 0,
150
- isDone: false,
151
- tags: [],
152
- project: null,
153
- subtasks: [
154
- { id: "a", title: "premier", done: false },
155
- { id: "b", title: "deuxième", done: false },
156
- { id: "c", title: "troisième", done: false },
157
- ],
158
- },
159
- ],
160
- activeTask: null,
161
- };
162
- const ok = reorderSubtasksInSession(sess, "t1", ["c", "a", "b"]);
163
- expect(ok).toBe(true);
164
- const subs = (findTaskRecord(sess, "t1")?.subtasks ?? []) as { id: string }[];
165
- expect(subs.map((s) => s.id)).toEqual(["c", "a", "b"]);
166
- });
167
-
168
- it("refuse une liste incomplète ou invalide", () => {
169
- const sess: Record<string, unknown> = {
170
- sessionId: "s",
171
- tasks: [],
172
- activeTasks: [
173
- {
174
- id: "t1",
175
- name: "X",
176
- startTime: "2020-01-01T00:00:00.000Z",
177
- durationMs: 0,
178
- isDone: false,
179
- tags: [],
180
- project: null,
181
- subtasks: [
182
- { id: "a", title: "premier", done: false },
183
- { id: "b", title: "deuxième", done: false },
184
- ],
185
- },
186
- ],
187
- activeTask: null,
188
- };
189
- const before = JSON.stringify(findTaskRecord(sess, "t1")?.subtasks);
190
- expect(reorderSubtasksInSession(sess, "t1", ["a"])).toBe(false);
191
- expect(JSON.stringify(findTaskRecord(sess, "t1")?.subtasks)).toBe(before);
192
- expect(reorderSubtasksInSession(sess, "t1", ["a", "x"])).toBe(false);
193
- expect(JSON.stringify(findTaskRecord(sess, "t1")?.subtasks)).toBe(before);
194
- });
195
- });
196
-
197
- describe("setActiveSubtaskTimerInSession", () => {
198
- afterEach(() => {
199
- vi.useRealTimers();
200
- });
201
-
202
- it("enregistre la durée sur la sous-tâche à l’arrêt du minuteur", () => {
203
- vi.useFakeTimers();
204
- vi.setSystemTime(new Date("2026-01-10T12:00:00.000Z"));
205
- const sess: Record<string, unknown> = {
206
- sessionId: "s",
207
- tasks: [],
208
- activeTasks: [
209
- {
210
- id: "t1",
211
- name: "X",
212
- startTime: "2026-01-10T11:00:00.000Z",
213
- durationMs: 0,
214
- isDone: false,
215
- tags: [],
216
- project: null,
217
- subtasks: [{ id: "sub-a", title: "Point", done: false, durationMs: 0 }],
218
- },
219
- ],
220
- activeTask: null,
221
- };
222
- expect(setActiveSubtaskTimerInSession(sess, "t1", "sub-a")).toBe(true);
223
- vi.setSystemTime(new Date("2026-01-10T12:03:00.000Z"));
224
- expect(setActiveSubtaskTimerInSession(sess, "t1", null)).toBe(true);
225
- const task = (sess.activeTasks as Record<string, unknown>[])[0];
226
- const subs = task.subtasks as Record<string, unknown>[];
227
- expect(subs[0].durationMs).toBe(3 * 60 * 1000);
228
- expect(task.durationMs).toBe(3 * 60 * 1000);
229
- expect(task.activeSubtaskTimerId).toBeNull();
230
- });
231
-
232
- it("cumule plusieurs segments sur la même sous-tâche", () => {
233
- vi.useFakeTimers();
234
- vi.setSystemTime(new Date("2026-01-10T12:00:00.000Z"));
235
- const sess: Record<string, unknown> = {
236
- sessionId: "s",
237
- tasks: [],
238
- activeTasks: [
239
- {
240
- id: "t1",
241
- name: "X",
242
- startTime: "2026-01-10T11:00:00.000Z",
243
- durationMs: 0,
244
- isDone: false,
245
- tags: [],
246
- project: null,
247
- subtasks: [{ id: "sub-a", title: "Point", done: false, durationMs: 1000 }],
248
- },
249
- ],
250
- activeTask: null,
251
- };
252
- setActiveSubtaskTimerInSession(sess, "t1", "sub-a");
253
- vi.setSystemTime(new Date("2026-01-10T12:01:00.000Z"));
254
- setActiveSubtaskTimerInSession(sess, "t1", null);
255
- vi.setSystemTime(new Date("2026-01-10T12:02:00.000Z"));
256
- setActiveSubtaskTimerInSession(sess, "t1", "sub-a");
257
- vi.setSystemTime(new Date("2026-01-10T12:04:00.000Z"));
258
- setActiveSubtaskTimerInSession(sess, "t1", null);
259
- const task = (sess.activeTasks as Record<string, unknown>[])[0];
260
- const subs = (task.subtasks as Record<string, unknown>[])[0];
261
- expect(subs.durationMs).toBe(1000 + 60_000 + 120_000);
262
- // Total parent = somme des points (le millier initial était déjà sur la sous-tâche)
263
- expect(task.durationMs).toBe(1000 + 60_000 + 120_000);
264
- });
265
-
266
- it("déplace le suivi sur une autre tâche : l’ancienne sous-tâche est comptée et toutes les tâches seulement en pause (minuteur parent)", () => {
267
- vi.useFakeTimers();
268
- vi.setSystemTime(new Date("2026-01-10T12:00:00.000Z"));
269
- const sess: Record<string, unknown> = {
270
- sessionId: "s",
271
- tasks: [],
272
- activeTasks: [
273
- {
274
- id: "t1",
275
- name: "A",
276
- startTime: "2026-01-10T11:00:00.000Z",
277
- durationMs: 0,
278
- isDone: false,
279
- manualTaskTimerPaused: false,
280
- tags: [],
281
- project: null,
282
- subtasks: [{ id: "sub-1", title: "P1", done: false, durationMs: 0 }],
283
- },
284
- {
285
- id: "t2",
286
- name: "B",
287
- startTime: "2026-01-10T11:00:00.000Z",
288
- durationMs: 0,
289
- isDone: false,
290
- manualTaskTimerPaused: false,
291
- tags: [],
292
- project: null,
293
- subtasks: [{ id: "sub-2", title: "P2", done: false, durationMs: 0 }],
294
- },
295
- ],
296
- activeTask: null,
297
- };
298
- setActiveSubtaskTimerInSession(sess, "t1", "sub-1");
299
- vi.setSystemTime(new Date("2026-01-10T12:05:00.000Z"));
300
- setActiveSubtaskTimerInSession(sess, "t2", "sub-2");
301
- const t1 = (sess.activeTasks as Record<string, unknown>[]).find((x) => x.id === "t1");
302
- const t2 = (sess.activeTasks as Record<string, unknown>[]).find((x) => x.id === "t2");
303
- expect(t1?.activeSubtaskTimerId).toBeNull();
304
- expect(t1?.manualTaskTimerPaused).toBe(true);
305
- expect((t1?.subtasks as { durationMs?: number }[] | undefined)?.[0]?.durationMs).toBe(5 * 60_000);
306
- expect(t2?.activeSubtaskTimerId).toBe("sub-2");
307
- expect(t2?.manualTaskTimerPaused).toBe(false);
308
- });
309
- });
310
-
311
- describe("exclusivité des minuteurs (helpers)", () => {
312
- afterEach(() => {
313
- vi.useRealTimers();
314
- });
315
-
316
- it("prepareSessionForExclusiveMainTimerUnpaused : toutes les sous-tâches sont arrêtées et seule la tâche ciblée a le parent non en pause", () => {
317
- const sess: Record<string, unknown> = {
318
- sessionId: "s",
319
- activeTasks: [
320
- {
321
- id: "a",
322
- name: "A",
323
- isDone: false,
324
- manualTaskTimerPaused: true,
325
- subtasks: [
326
- { id: "s1", title: "c", done: false, durationMs: 0 },
327
- { id: "s2", title: "d", done: false, durationMs: 0 },
328
- ],
329
- activeSubtaskTimerId: "s1",
330
- },
331
- {
332
- id: "b",
333
- name: "B",
334
- isDone: false,
335
- manualTaskTimerPaused: true,
336
- subtasks: [],
337
- },
338
- ],
339
- activeTask: null,
340
- tasks: [
341
- {
342
- id: "c",
343
- name: "C",
344
- isDone: true,
345
- manualTaskTimerPaused: true,
346
- subtasks: [],
347
- },
348
- ],
349
- };
350
- prepareSessionForExclusiveMainTimerUnpaused(sess, "b");
351
- const tA = findTaskRecord(sess, "a");
352
- const tB = findTaskRecord(sess, "b");
353
- const tC = findTaskRecord(sess, "c");
354
- expect(tA?.activeSubtaskTimerId).toBeNull();
355
- expect(tA?.manualTaskTimerPaused).toBe(true);
356
- expect(tB?.manualTaskTimerPaused).toBe(false);
357
- expect(tC?.manualTaskTimerPaused).toBe(true);
358
- });
359
-
360
- it("prepareSessionForSubtaskTimer : le suivi d’autres tâches (y compris dans `tasks` seul) est vidé", () => {
361
- const sess: Record<string, unknown> = {
362
- sessionId: "s",
363
- activeTasks: [
364
- {
365
- id: "a",
366
- name: "A",
367
- isDone: false,
368
- manualTaskTimerPaused: false,
369
- subtasks: [],
370
- },
371
- ],
372
- activeTask: null,
373
- tasks: [
374
- {
375
- id: "b",
376
- name: "B",
377
- isDone: false,
378
- subtasks: [{ id: "sb", title: "P", done: false, durationMs: 0 }],
379
- activeSubtaskTimerId: "sb",
380
- },
381
- ],
382
- };
383
- prepareSessionForSubtaskTimer(sess, "a");
384
- const tA = findTaskRecord(sess, "a");
385
- const tB = findTaskRecord(sess, "b");
386
- expect(tA?.manualTaskTimerPaused).toBe(false);
387
- expect(tB?.activeSubtaskTimerId).toBeNull();
388
- });
389
-
390
- it("flushAllSubtaskTimersInSession compte le temps de chaque segment puis vide les suivi", () => {
391
- vi.useFakeTimers();
392
- vi.setSystemTime(new Date("2026-03-01T10:00:00.000Z"));
393
- const started = "2026-03-01T09:58:00.000Z";
394
- const sess: Record<string, unknown> = {
395
- sessionId: "s",
396
- activeTasks: [
397
- {
398
- id: "x",
399
- isDone: false,
400
- durationMs: 0,
401
- subtasks: [{ id: "s1", title: "P", done: false, durationMs: 0 }],
402
- activeSubtaskTimerId: "s1",
403
- subtaskTimerStartedAt: started,
404
- },
405
- {
406
- id: "y",
407
- isDone: false,
408
- durationMs: 0,
409
- subtasks: [{ id: "s2", title: "Q", done: false, durationMs: 0 }],
410
- activeSubtaskTimerId: "s2",
411
- subtaskTimerStartedAt: "2026-03-01T09:55:00.000Z",
412
- },
413
- ],
414
- activeTask: null,
415
- tasks: [],
416
- };
417
- flushAllSubtaskTimersInSession(sess);
418
- const tx = (sess.activeTasks as Record<string, unknown>[]).find((t) => t.id === "x");
419
- const ty = (sess.activeTasks as Record<string, unknown>[]).find((t) => t.id === "y");
420
- expect(tx?.activeSubtaskTimerId).toBeNull();
421
- expect(((tx?.subtasks as { durationMs?: number }[] | undefined) ?? [])[0]?.durationMs).toBe(2 * 60_000);
422
- expect(tx?.durationMs).toBe(2 * 60_000);
423
- expect(ty?.activeSubtaskTimerId).toBeNull();
424
- });
425
- });
426
-
427
- describe("exclusivité des minuteurs (sous-tâches)", () => {
428
- afterEach(() => {
429
- vi.useRealTimers();
430
- });
431
-
432
- it("démarrer une sous-tâche : seules les autres tâches parentes en pause, la tâche ciblée en marche", () => {
433
- const sess: Record<string, unknown> = {
434
- sessionId: "s",
435
- activeTasks: [
436
- {
437
- id: "t1",
438
- isDone: false,
439
- manualTaskTimerPaused: false,
440
- subtasks: [{ id: "st1", title: "a", done: false, durationMs: 0 }],
441
- tags: [],
442
- project: null,
443
- },
444
- {
445
- id: "t2",
446
- isDone: false,
447
- manualTaskTimerPaused: false,
448
- subtasks: [],
449
- tags: [],
450
- project: null,
451
- },
452
- {
453
- id: "t3",
454
- isDone: false,
455
- manualTaskTimerPaused: true,
456
- subtasks: [],
457
- tags: [],
458
- project: null,
459
- },
460
- ],
461
- activeTask: null,
462
- tasks: [],
463
- };
464
- expect(setActiveSubtaskTimerInSession(sess, "t1", "st1")).toBe(true);
465
- expect(findTaskRecord(sess, "t1")?.manualTaskTimerPaused).toBe(false);
466
- expect(findTaskRecord(sess, "t2")?.manualTaskTimerPaused).toBe(true);
467
- expect(findTaskRecord(sess, "t3")?.manualTaskTimerPaused).toBe(true);
468
- });
469
-
470
- it("basculer de sous-tâche a à b sur le même parent : a accumule, seul b reste actif", () => {
471
- vi.useFakeTimers();
472
- vi.setSystemTime(new Date("2026-05-20T15:00:00.000Z"));
473
- const sess: Record<string, unknown> = {
474
- sessionId: "s",
475
- activeTasks: [
476
- {
477
- id: "p",
478
- isDone: false,
479
- durationMs: 0,
480
- subtasks: [
481
- { id: "sa", title: "1", done: false, durationMs: 0 },
482
- { id: "sb", title: "2", done: false, durationMs: 0 },
483
- ],
484
- tags: [],
485
- project: null,
486
- },
487
- ],
488
- activeTask: null,
489
- tasks: [],
490
- };
491
- setActiveSubtaskTimerInSession(sess, "p", "sa");
492
- vi.setSystemTime(new Date("2026-05-20T15:00:10.000Z"));
493
- setActiveSubtaskTimerInSession(sess, "p", "sb");
494
- const task = findTaskRecord(sess, "p");
495
- const subs = task?.subtasks as { id: string; durationMs?: number }[];
496
- expect(subs.find((s) => s.id === "sa")?.durationMs).toBe(10_000);
497
- expect(subs.find((s) => s.id === "sb")?.durationMs).toBe(0);
498
- expect(task?.activeSubtaskTimerId).toBe("sb");
499
- });
500
-
501
- it("arrêter le suivi (null) ne remet pas la parente en pause : elle était en marche avec le suivi", () => {
502
- const sess: Record<string, unknown> = {
503
- sessionId: "s",
504
- activeTasks: [
505
- {
506
- id: "k",
507
- isDone: false,
508
- manualTaskTimerPaused: true,
509
- subtasks: [{ id: "u", title: "U", done: false, durationMs: 0 }],
510
- },
511
- ],
512
- activeTask: null,
513
- tasks: [],
514
- };
515
- setActiveSubtaskTimerInSession(sess, "k", "u");
516
- expect(findTaskRecord(sess, "k")?.manualTaskTimerPaused).toBe(false);
517
- expect(setActiveSubtaskTimerInSession(sess, "k", null)).toBe(true);
518
- expect(findTaskRecord(sess, "k")?.manualTaskTimerPaused).toBe(false);
519
- });
520
-
521
- it("reprendre la tâche A avec une tâche B en suivi de sous-tâche horodaté : le temps B est compté puis effacé", () => {
522
- vi.useFakeTimers();
523
- vi.setSystemTime(new Date("2026-01-20T12:00:00.000Z"));
524
- const sess: Record<string, unknown> = {
525
- sessionId: "s",
526
- activeTasks: [
527
- {
528
- id: "A",
529
- name: "A",
530
- startTime: "2020-01-01T00:00:00.000Z",
531
- isDone: false,
532
- durationMs: 0,
533
- manualTaskTimerPaused: true,
534
- tags: [],
535
- project: null,
536
- subtasks: [],
537
- },
538
- {
539
- id: "B",
540
- name: "B",
541
- startTime: "2020-01-01T00:00:00.000Z",
542
- isDone: false,
543
- durationMs: 0,
544
- manualTaskTimerPaused: true,
545
- tags: [],
546
- project: null,
547
- subtasks: [{ id: "sb", title: "B", done: false, durationMs: 0 }],
548
- activeSubtaskTimerId: "sb",
549
- subtaskTimerStartedAt: "2026-01-20T11:59:00.000Z",
550
- },
551
- ],
552
- activeTask: null,
553
- tasks: [],
554
- };
555
- vi.setSystemTime(new Date("2026-01-20T12:00:00.000Z"));
556
- expect(setTaskPausedInSession(sess, "A", false)).toBe(true);
557
- const tA = findTaskRecord(sess, "A");
558
- const tB = findTaskRecord(sess, "B");
559
- expect(tA?.manualTaskTimerPaused).toBe(false);
560
- expect(tB?.activeSubtaskTimerId).toBeNull();
561
- expect(
562
- (tB?.subtasks as { id: string; durationMs?: number }[] | undefined)?.find((s) => s.id === "sb")?.durationMs
563
- ).toBe(60_000);
564
- });
565
- });
566
-
567
- describe("setTaskPausedInSession (exclusivité)", () => {
568
- it("mettre la tâche parente en pause arrête le suivi de sous-tâche en cours", () => {
569
- vi.useFakeTimers();
570
- vi.setSystemTime(new Date("2026-02-10T12:00:00.000Z"));
571
- const sess: Record<string, unknown> = {
572
- sessionId: "s",
573
- activeTasks: [
574
- {
575
- id: "m",
576
- isDone: false,
577
- manualTaskTimerPaused: false,
578
- durationMs: 0,
579
- subtasks: [{ id: "subx", title: "X", done: false, durationMs: 0 }],
580
- activeSubtaskTimerId: "subx",
581
- subtaskTimerStartedAt: "2026-02-10T11:50:00.000Z",
582
- },
583
- ],
584
- activeTask: null,
585
- tasks: [],
586
- };
587
- expect(setTaskPausedInSession(sess, "m", true)).toBe(true);
588
- const t = findTaskRecord(sess, "m");
589
- expect(t?.manualTaskTimerPaused).toBe(true);
590
- expect(t?.activeSubtaskTimerId).toBeNull();
591
- expect(((t?.subtasks as { durationMs?: number }[] | undefined) ?? [])[0]?.durationMs).toBe(10 * 60_000);
592
- vi.useRealTimers();
593
- });
594
-
595
- it("reprendre le minuteur tâche vide les suivi sous-tâches et seule celle-ci non en pause", () => {
596
- const sess: Record<string, unknown> = {
597
- sessionId: "s",
598
- tasks: [],
599
- activeTasks: [
600
- {
601
- id: "a",
602
- name: "A",
603
- startTime: "2020-01-01T00:00:00.000Z",
604
- durationMs: 0,
605
- isDone: false,
606
- manualTaskTimerPaused: true,
607
- tags: [],
608
- project: null,
609
- subtasks: [{ id: "s1", title: "x", done: false, durationMs: 0 }],
610
- activeSubtaskTimerId: "s1",
611
- },
612
- {
613
- id: "b",
614
- name: "B",
615
- startTime: "2020-01-01T00:00:00.000Z",
616
- durationMs: 0,
617
- isDone: false,
618
- manualTaskTimerPaused: true,
619
- tags: [],
620
- project: null,
621
- subtasks: [],
622
- },
623
- ],
624
- activeTask: null,
625
- };
626
- expect(setTaskPausedInSession(sess, "a", false)).toBe(true);
627
- const tA = (sess.activeTasks as Record<string, unknown>[])[0];
628
- const tB = (sess.activeTasks as Record<string, unknown>[])[1];
629
- expect(tA?.activeSubtaskTimerId).toBeNull();
630
- expect(tA?.manualTaskTimerPaused).toBe(false);
631
- expect(tB?.manualTaskTimerPaused).toBe(true);
632
- });
633
- });
634
-
635
- describe("findTaskRecord / updateTaskInSession", () => {
636
- it("met à jour le nom d’une tâche dans la pile", () => {
637
- const sess: Record<string, unknown> = {
638
- activeTasks: [{ id: "a", name: "n0", isDone: false, tags: [], subtasks: [] }],
639
- activeTask: null,
640
- tasks: [],
641
- };
642
- expect(updateTaskInSession(sess, "a", { name: "n1" })).toBe(true);
643
- expect(findTaskRecord(sess, "a")?.name).toBe("n1");
644
- });
645
-
646
- it("normalise les étiquettes vides vers l’étiquette réservée default", () => {
647
- const sess: Record<string, unknown> = {
648
- activeTasks: [{ id: "a", name: "n0", isDone: false, tags: ["bug"], subtasks: [] }],
649
- activeTask: null,
650
- tasks: [],
651
- };
652
- expect(updateTaskInSession(sess, "a", { tags: [] })).toBe(true);
653
- expect(findTaskRecord(sess, "a")?.tags).toEqual(["default"]);
654
- });
655
-
656
- it("normalise les étiquettes vides vers [] si assignDefaultTagBucket est false", () => {
657
- const sess: Record<string, unknown> = {
658
- activeTasks: [{ id: "a", name: "n0", isDone: false, tags: ["bug"], subtasks: [] }],
659
- activeTask: null,
660
- tasks: [],
661
- };
662
- expect(updateTaskInSession(sess, "a", { tags: [] }, { assignDefaultTagBucket: false })).toBe(true);
663
- expect(findTaskRecord(sess, "a")?.tags).toEqual([]);
664
- });
665
- });
666
-
667
- describe("purgeTagEverywhere", () => {
668
- it("retire l’étiquette des listes et des tâches", () => {
669
- const p = basePayload();
670
- p.knownTags = ["bug", "feat"];
671
- p.userKnownTags = ["bug"];
672
- p.tagDescriptions = { bug: "d" };
673
- p.current = {
674
- sessionId: "c",
675
- tasks: [{ id: "1", name: "t", tags: ["bug", "x"], isDone: true, subtasks: [] }],
676
- activeTasks: [],
677
- activeTask: null,
678
- };
679
- purgeTagEverywhere(p, "bug");
680
- expect((p.knownTags as string[]).some((x) => x.toLowerCase() === "bug")).toBe(false);
681
- const t = (p.current as Record<string, unknown>).tasks as Record<string, unknown>[];
682
- expect((t[0].tags as string[]).includes("bug")).toBe(false);
683
- expect((t[0].tags as string[]).includes("x")).toBe(true);
684
- });
685
-
686
- it("purgeTagEverywhere : retirer la dernière étiquette laisse le bucket default", () => {
687
- const p = basePayload();
688
- p.knownTags = ["solo"];
689
- p.current = {
690
- sessionId: "c",
691
- tasks: [{ id: "1", name: "t", tags: ["solo"], isDone: true, subtasks: [] }],
692
- activeTasks: [],
693
- activeTask: null,
694
- };
695
- purgeTagEverywhere(p, "solo");
696
- const t = (p.current as Record<string, unknown>).tasks as Record<string, unknown>[];
697
- expect(t[0].tags as string[]).toEqual(["default"]);
698
- });
699
-
700
- it("purgeTagEverywhere : sans bucket par défaut, dernière étiquette retirée → []", () => {
701
- const p = basePayload();
702
- p.knownTags = ["solo"];
703
- p.current = {
704
- sessionId: "c",
705
- tasks: [{ id: "1", name: "t", tags: ["solo"], isDone: true, subtasks: [] }],
706
- activeTasks: [],
707
- activeTask: null,
708
- };
709
- purgeTagEverywhere(p, "solo", { assignDefaultTagBucket: false });
710
- const t = (p.current as Record<string, unknown>).tasks as Record<string, unknown>[];
711
- expect(t[0].tags as string[]).toEqual([]);
712
- });
713
- });