@nightkatana/kronosys-app 1.0.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. package/README.md +81 -0
  2. package/app/api/action/route.ts +16 -0
  3. package/app/api/backup/route.ts +84 -0
  4. package/app/api/health/route.ts +22 -0
  5. package/app/api/state/route.ts +27 -0
  6. package/app/apple-icon.png +0 -0
  7. package/app/changelog/page.tsx +122 -0
  8. package/app/globals.css +210 -0
  9. package/app/guide/layout.tsx +11 -0
  10. package/app/guide/page.tsx +278 -0
  11. package/app/icon.png +0 -0
  12. package/app/layout.tsx +77 -0
  13. package/app/licenses/layout.tsx +11 -0
  14. package/app/licenses/page.tsx +246 -0
  15. package/app/manifest.ts +32 -0
  16. package/app/page.tsx +1610 -0
  17. package/app/reporting/page.tsx +2943 -0
  18. package/app/settings/layout.tsx +10 -0
  19. package/app/settings/page.tsx +3518 -0
  20. package/bin/kronosys.mjs +46 -0
  21. package/components/KronosysPackageVersionProvider.tsx +19 -0
  22. package/components/KronosysPayloadProvider.tsx +109 -0
  23. package/components/PwaRegister.tsx +25 -0
  24. package/components/SiteLegalFooter.tsx +21 -0
  25. package/components/ThemeProvider.tsx +78 -0
  26. package/components/dashboard/AppShellLiveSessionDrawer.tsx +394 -0
  27. package/components/dashboard/AppShellRouteNav.tsx +131 -0
  28. package/components/dashboard/AppVersionStamp.tsx +16 -0
  29. package/components/dashboard/DashboardCollapsibleSection.tsx +57 -0
  30. package/components/dashboard/DashboardColumnHintsBanner.tsx +159 -0
  31. package/components/dashboard/DashboardCommandCenter.tsx +470 -0
  32. package/components/dashboard/DashboardLangGateModal.tsx +118 -0
  33. package/components/dashboard/DashboardLoadingOverlay.tsx +42 -0
  34. package/components/dashboard/DashboardSimpleModal.tsx +337 -0
  35. package/components/dashboard/DashboardSuspenseFallback.tsx +52 -0
  36. package/components/dashboard/DashboardToastProvider.tsx +64 -0
  37. package/components/dashboard/DashboardTour.tsx +435 -0
  38. package/components/dashboard/DeferredDescriptionPopoverWrap.tsx +39 -0
  39. package/components/dashboard/DeleteSessionModal.tsx +130 -0
  40. package/components/dashboard/DescriptionTooltipPortaled.tsx +31 -0
  41. package/components/dashboard/GitIdentityQuickSetupModal.tsx +211 -0
  42. package/components/dashboard/HeaderIntegrationBadges.tsx +69 -0
  43. package/components/dashboard/InlineMetricHelpTrigger.tsx +102 -0
  44. package/components/dashboard/IssuePickerModal.tsx +168 -0
  45. package/components/dashboard/KronoFocusPanel.tsx +834 -0
  46. package/components/dashboard/KronosysDatetimePopoverField.tsx +357 -0
  47. package/components/dashboard/KronosysTimePopoverField.tsx +233 -0
  48. package/components/dashboard/LanguageMenu.tsx +123 -0
  49. package/components/dashboard/MongoMirrorSyncLine.tsx +57 -0
  50. package/components/dashboard/NewSessionScopeModal.tsx +410 -0
  51. package/components/dashboard/PageRefreshButton.tsx +130 -0
  52. package/components/dashboard/PlainHelpPopover.tsx +97 -0
  53. package/components/dashboard/ReportingPageToc.tsx +68 -0
  54. package/components/dashboard/ReportingTour.tsx +342 -0
  55. package/components/dashboard/SavedProjectPicker.tsx +92 -0
  56. package/components/dashboard/SavedTagPicker.tsx +115 -0
  57. package/components/dashboard/ScrollToTopFab.tsx +41 -0
  58. package/components/dashboard/SelectedSessionSidebarBlock.tsx +630 -0
  59. package/components/dashboard/SessionEndReasonEditor.tsx +114 -0
  60. package/components/dashboard/SessionListPanel.tsx +320 -0
  61. package/components/dashboard/SessionLocMetricsSection.tsx +128 -0
  62. package/components/dashboard/SettingsTagsProjectsSection.tsx +993 -0
  63. package/components/dashboard/SettingsTour.tsx +332 -0
  64. package/components/dashboard/TagPills.tsx +149 -0
  65. package/components/dashboard/TagsHelpTrigger.tsx +84 -0
  66. package/components/dashboard/TaskFocusPanel.tsx +1261 -0
  67. package/components/dashboard/TaskSessionLiveCard.tsx +832 -0
  68. package/components/dashboard/TaskSubtasksBlock.tsx +748 -0
  69. package/components/dashboard/ThemeToggle.test.tsx +26 -0
  70. package/components/dashboard/ThemeToggle.tsx +36 -0
  71. package/components/dashboard/UserGuideBodyText.tsx +62 -0
  72. package/components/dashboard/WorkspaceGitRepoCard.tsx +191 -0
  73. package/components/dashboard/taskFieldStyles.ts +139 -0
  74. package/components/dashboard/useAnchoredFloatingPortalStyle.ts +71 -0
  75. package/components/dashboard/useDescriptionPopoverAfterMs.ts +220 -0
  76. package/components/dashboard/useKronoFocusLiveSeconds.ts +36 -0
  77. package/components/dashboard/useSmoothStopwatchMs.ts +25 -0
  78. package/lib/appShellHeaderClasses.ts +12 -0
  79. package/lib/backupCsvExport.test.ts +149 -0
  80. package/lib/backupCsvExport.ts +392 -0
  81. package/lib/changelogCopy.ts +34 -0
  82. package/lib/concurrentTaskStartPreference.ts +29 -0
  83. package/lib/dashboardClockFormat.ts +13 -0
  84. package/lib/dashboardColumnChrome.ts +3 -0
  85. package/lib/dashboardColumnHintsStorage.ts +57 -0
  86. package/lib/dashboardCopy.ts +1831 -0
  87. package/lib/dashboardDetachedUrlHintStorage.ts +24 -0
  88. package/lib/dashboardGitIdentityBannerStorage.ts +36 -0
  89. package/lib/dashboardLangStorage.ts +72 -0
  90. package/lib/dashboardQuickSearch.ts +476 -0
  91. package/lib/dashboardQuickSearchQuery.test.ts +63 -0
  92. package/lib/dashboardQuickSearchQuery.ts +179 -0
  93. package/lib/dashboardSessionNav.ts +33 -0
  94. package/lib/dashboardShortcuts.ts +268 -0
  95. package/lib/dashboardTimeZone.ts +91 -0
  96. package/lib/dashboardTourStorage.ts +68 -0
  97. package/lib/dataDir.test.ts +87 -0
  98. package/lib/dataDir.ts +83 -0
  99. package/lib/devDataPreferenceFile.ts +55 -0
  100. package/lib/devDataRuntimeInfo.ts +34 -0
  101. package/lib/formatIsoShort.test.ts +46 -0
  102. package/lib/formatIsoShort.ts +29 -0
  103. package/lib/generatedUserChangelog.ts +34 -0
  104. package/lib/gitlabIssueSearch.ts +8 -0
  105. package/lib/kronoFocusDurationHistory.ts +71 -0
  106. package/lib/kronoFocusRhythm.test.ts +130 -0
  107. package/lib/kronoFocusRhythm.ts +46 -0
  108. package/lib/kronoFocusTimerUrgency.test.ts +74 -0
  109. package/lib/kronoFocusTimerUrgency.ts +24 -0
  110. package/lib/kronosysApi.ts +143 -0
  111. package/lib/legacyEditorPayloadKeys.ts +52 -0
  112. package/lib/legacyKronoFocusStorageKeys.test.ts +29 -0
  113. package/lib/legacyKronoFocusStorageKeys.ts +32 -0
  114. package/lib/licensesCopy.ts +128 -0
  115. package/lib/openPlainTextInNewTab.ts +49 -0
  116. package/lib/readKronosysPackageVersion.ts +10 -0
  117. package/lib/reportingAggregate.test.ts +325 -0
  118. package/lib/reportingAggregate.ts +819 -0
  119. package/lib/reportingDatePresets.ts +41 -0
  120. package/lib/reportingMetricHelp.ts +430 -0
  121. package/lib/reportingNonFinalIndicators.test.ts +157 -0
  122. package/lib/reportingNonFinalIndicators.ts +102 -0
  123. package/lib/reportingStrings.ts +491 -0
  124. package/lib/reportingTagWeekBreakdown.test.ts +141 -0
  125. package/lib/reportingTagWeekBreakdown.ts +181 -0
  126. package/lib/reportingWeekLayout.test.ts +239 -0
  127. package/lib/reportingWeekLayout.ts +313 -0
  128. package/lib/sessionAssiduity.test.ts +25 -0
  129. package/lib/sessionAssiduity.ts +33 -0
  130. package/lib/sessionEndReason.ts +55 -0
  131. package/lib/sessionEndWarnings.test.ts +200 -0
  132. package/lib/sessionEndWarnings.ts +125 -0
  133. package/lib/sessionListMerge.test.ts +101 -0
  134. package/lib/sessionListMerge.ts +70 -0
  135. package/lib/sessionTaskSidebarStats.test.ts +24 -0
  136. package/lib/sessionTaskSidebarStats.ts +54 -0
  137. package/lib/settingsCopy.ts +1276 -0
  138. package/lib/taskParsing.test.ts +153 -0
  139. package/lib/taskParsing.ts +737 -0
  140. package/lib/theme.ts +15 -0
  141. package/lib/translucentButtonClasses.ts +34 -0
  142. package/lib/usageProfile.test.ts +84 -0
  143. package/lib/usageProfile.ts +52 -0
  144. package/lib/userGuideCopy.ts +464 -0
  145. package/lib/workspaceLocDefaults.ts +21 -0
  146. package/next-env.d.ts +6 -0
  147. package/next.config.ts +15 -0
  148. package/package.json +87 -0
  149. package/postcss.config.mjs +12 -0
  150. package/public/apple-icon.png +0 -0
  151. package/public/favicon.ico +0 -0
  152. package/public/file.svg +1 -0
  153. package/public/globe.svg +1 -0
  154. package/public/icon-192.png +0 -0
  155. package/public/icon-512.png +0 -0
  156. package/public/icon.png +0 -0
  157. package/public/next.svg +1 -0
  158. package/public/sw.js +13 -0
  159. package/public/traceback.png +0 -0
  160. package/public/vercel.svg +1 -0
  161. package/public/window.svg +1 -0
  162. package/server/actionDispatch.test.ts +723 -0
  163. package/server/actionDispatch.ts +1476 -0
  164. package/server/actionTaskSession.test.ts +713 -0
  165. package/server/actionTaskSession.ts +717 -0
  166. package/server/db.ts +42 -0
  167. package/server/defaultCfg.ts +87 -0
  168. package/server/gitlabTokenStore.ts +34 -0
  169. package/server/kronoFocusHydrate.test.ts +142 -0
  170. package/server/kronoFocusHydrate.ts +69 -0
  171. package/server/kronoFocusMigrate.test.ts +53 -0
  172. package/server/kronoFocusMigrate.ts +78 -0
  173. package/server/mainTimerHydrate.test.ts +65 -0
  174. package/server/mainTimerHydrate.ts +53 -0
  175. package/server/payloadStore.test.ts +78 -0
  176. package/server/payloadStore.ts +83 -0
  177. package/server/sessionWallHydrate.test.ts +46 -0
  178. package/server/sessionWallHydrate.ts +88 -0
  179. package/tsconfig.json +41 -0
@@ -0,0 +1,713 @@
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
+ });