@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,717 @@
1
+ import { randomUUID } from "node:crypto";
2
+
3
+ import type { KronosysUpdatePayload } from "@/lib/kronosysApi";
4
+ import {
5
+ normalizeProjectKey,
6
+ normalizeTagKey,
7
+ normalizeTaskTagsForStorage,
8
+ type TaskTagsStorageNormalizeOpts,
9
+ } from "@/lib/taskParsing";
10
+
11
+ export function asRecord(v: unknown): Record<string, unknown> | undefined {
12
+ return v && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, unknown>) : undefined;
13
+ }
14
+
15
+ export type ResolvedTaskSession = { session: Record<string, unknown>; isLive: boolean };
16
+
17
+ /**
18
+ * Résout la session cible pour les mutations de tâches.
19
+ * Sans `sessionId` : session live (`current`) uniquement.
20
+ */
21
+ export function resolveTaskSession(p: KronosysUpdatePayload, sessionId: unknown): ResolvedTaskSession | null {
22
+ const sid = typeof sessionId === "string" ? sessionId.trim() : "";
23
+ const cur = asRecord(p.current);
24
+ if (!sid) {
25
+ if (!cur) {
26
+ return null;
27
+ }
28
+ return { session: cur, isLive: true };
29
+ }
30
+ if (cur && String(cur.sessionId ?? "") === sid) {
31
+ return { session: cur, isLive: true };
32
+ }
33
+ const hist = (p.history || []) as Record<string, unknown>[];
34
+ const row = hist.find((h) => String(h.sessionId ?? "") === sid);
35
+ if (row) {
36
+ return { session: row, isLive: false };
37
+ }
38
+ const arch = (p.historyArchived || []) as Record<string, unknown>[];
39
+ const ar = arch.find((h) => String(h.sessionId ?? "") === sid);
40
+ return ar ? { session: ar, isLive: false } : null;
41
+ }
42
+
43
+ export function getActiveTasksArray(sess: Record<string, unknown>): Record<string, unknown>[] {
44
+ return Array.isArray(sess.activeTasks) ? ([...sess.activeTasks] as Record<string, unknown>[]) : [];
45
+ }
46
+
47
+ export function getTasksArray(sess: Record<string, unknown>): Record<string, unknown>[] {
48
+ return Array.isArray(sess.tasks) ? ([...sess.tasks] as Record<string, unknown>[]) : [];
49
+ }
50
+
51
+ /** Repère une tâche : pile active (`activeTasks` / `activeTask`) puis liste `tasks`. */
52
+ export function findTaskRecord(sess: Record<string, unknown>, taskId: string): Record<string, unknown> | null {
53
+ const id = taskId;
54
+ for (const t of getActiveTasksArray(sess)) {
55
+ if (String(t.id) === id) {
56
+ return t;
57
+ }
58
+ }
59
+ const at = asRecord(sess.activeTask);
60
+ if (at && String(at.id) === id) {
61
+ return at;
62
+ }
63
+ return getTasksArray(sess).find((t) => String(t.id) === id) ?? null;
64
+ }
65
+
66
+ /**
67
+ * Parcourt toutes les tâches d’une session sans doublon (même tâche dans `activeTasks` + `activeTask` + `tasks`).
68
+ */
69
+ export function forEachTaskRecordInSession(
70
+ sess: Record<string, unknown>,
71
+ fn: (task: Record<string, unknown>, taskId: string) => void
72
+ ): void {
73
+ const seen = new Set<string>();
74
+ for (const t of getActiveTasksArray(sess)) {
75
+ const id = String(t.id ?? "");
76
+ if (!id || seen.has(id)) {
77
+ continue;
78
+ }
79
+ seen.add(id);
80
+ fn(t, id);
81
+ }
82
+ const at = asRecord(sess.activeTask);
83
+ if (at) {
84
+ const id = String(at.id ?? "");
85
+ if (id && !seen.has(id)) {
86
+ seen.add(id);
87
+ fn(at, id);
88
+ }
89
+ }
90
+ for (const t of getTasksArray(sess)) {
91
+ const id = String(t.id ?? "");
92
+ if (!id || seen.has(id)) {
93
+ continue;
94
+ }
95
+ seen.add(id);
96
+ fn(t, id);
97
+ }
98
+ }
99
+
100
+ export function flushAllSubtaskTimersInSession(sess: Record<string, unknown>): void {
101
+ forEachTaskRecordInSession(sess, (task) => {
102
+ flushSubtaskTimerOnTask(task);
103
+ });
104
+ }
105
+
106
+ /**
107
+ * Avant de laisser une tâche au minuteur principal (non en pause) : seule tâche « en cours »
108
+ * (minuteries parentes ailleurs en pause ; tous les minuteurs sous-tâches arrêtés en accumulant le temps).
109
+ */
110
+ export function prepareSessionForExclusiveMainTimerUnpaused(
111
+ sess: Record<string, unknown>,
112
+ mainTaskIdToUnpause: string
113
+ ): void {
114
+ flushAllSubtaskTimersInSession(sess);
115
+ const idKeep = String(mainTaskIdToUnpause);
116
+ forEachTaskRecordInSession(sess, (task, id) => {
117
+ if (task.isDone === true) {
118
+ return;
119
+ }
120
+ if (id !== idKeep && task.manualTaskTimerPaused !== true) {
121
+ flushMainTimerSegmentOnTask(task);
122
+ }
123
+ });
124
+ forEachTaskRecordInSession(sess, (task, id) => {
125
+ if (task.isDone === true) {
126
+ return;
127
+ }
128
+ task.manualTaskTimerPaused = id !== idKeep;
129
+ });
130
+ forEachTaskRecordInSession(sess, (task, id) => {
131
+ if (id === idKeep && task.isDone !== true && task.manualTaskTimerPaused !== true) {
132
+ ensureMainTimerSegmentForRunningTask(task);
133
+ }
134
+ });
135
+ }
136
+
137
+ /**
138
+ * Avant de démarrer le suivi d’une sous-tâche : arrêt des suivi sous-tâches sur les autres tâches,
139
+ * puis toutes les **autres** tâches non terminées en pause (minuteur parent) — la tâche ciblée reste
140
+ * gérée par l’appelant (`manualTaskTimerPaused = false` sur le parent, en parallèle du suivi).
141
+ */
142
+ export function prepareSessionForSubtaskTimer(sess: Record<string, unknown>, focusTaskId: string): void {
143
+ const idFocus = String(focusTaskId);
144
+ forEachTaskRecordInSession(sess, (task, id) => {
145
+ if (id === idFocus) {
146
+ return;
147
+ }
148
+ if (task.isDone === true) {
149
+ return;
150
+ }
151
+ if (task.manualTaskTimerPaused !== true) {
152
+ flushMainTimerSegmentOnTask(task);
153
+ }
154
+ flushSubtaskTimerOnTask(task);
155
+ });
156
+ forEachTaskRecordInSession(sess, (task, id) => {
157
+ if (task.isDone === true) {
158
+ return;
159
+ }
160
+ if (id === idFocus) {
161
+ return;
162
+ }
163
+ task.manualTaskTimerPaused = true;
164
+ });
165
+ }
166
+
167
+ function ensureSubtasks(task: Record<string, unknown>): Record<string, unknown>[] {
168
+ if (!Array.isArray(task.subtasks)) {
169
+ task.subtasks = [];
170
+ }
171
+ return task.subtasks as Record<string, unknown>[];
172
+ }
173
+
174
+ /** Somme des `durationMs` enregistrées sur les sous-tâches (entiers non négatifs). */
175
+ export function sumSubtasksDurationMsStored(task: Record<string, unknown>): number {
176
+ let sum = 0;
177
+ for (const st of ensureSubtasks(task)) {
178
+ if (!st || typeof st !== "object" || Array.isArray(st)) {
179
+ continue;
180
+ }
181
+ const row = st as Record<string, unknown>;
182
+ const d = row.durationMs;
183
+ const v = typeof d === "number" && Number.isFinite(d) ? d : 0;
184
+ sum += Math.max(0, Math.floor(v));
185
+ }
186
+ return sum;
187
+ }
188
+
189
+ /**
190
+ * Règle métier : le total parent (`durationMs`) peut dépasser la somme des points (travail sans
191
+ * suivi par sous-tâche), jamais l’inverse. Si des données contredisent cela, on élève le parent.
192
+ */
193
+ export function ensureTaskParentDurationCoversSubtasksMs(task: Record<string, unknown>): void {
194
+ const sumSub = sumSubtasksDurationMsStored(task);
195
+ const raw = task.durationMs;
196
+ const parent = typeof raw === "number" && Number.isFinite(raw) ? Math.max(0, Math.floor(raw)) : 0;
197
+ if (sumSub > parent) {
198
+ task.durationMs = sumSub;
199
+ }
200
+ }
201
+
202
+ /** Clé ISO : début du segment de minuteur courant sur une sous-tâche (tâche parente). */
203
+ const SUBTASK_TIMER_STARTED_AT = "subtaskTimerStartedAt";
204
+
205
+ /** Clé ISO : début du segment du minuteur principal (hors sous-tâche) — persisté pour ne pas perdre le temps hors tableau de bord. */
206
+ export const MAIN_TIMER_SEGMENT_STARTED_AT = "mainTimerSegmentStartedAt";
207
+
208
+ /**
209
+ * Vide le segment de minuteur principal en cours : ajoute le temps écoulé à `durationMs`.
210
+ * Sans effet s’un suivi sous-tâche est actif (le parent ne cumule pas en parallèle).
211
+ */
212
+ export function flushMainTimerSegmentOnTask(task: Record<string, unknown>): void {
213
+ if (String(task.activeSubtaskTimerId ?? "").trim() !== "") {
214
+ return;
215
+ }
216
+ const raw = task[MAIN_TIMER_SEGMENT_STARTED_AT];
217
+ if (typeof raw !== "string" || raw.trim() === "") {
218
+ return;
219
+ }
220
+ const startedMs = Date.parse(raw);
221
+ if (!Number.isFinite(startedMs)) {
222
+ delete task[MAIN_TIMER_SEGMENT_STARTED_AT];
223
+ return;
224
+ }
225
+ const elapsed = Math.max(0, Math.floor(Date.now() - startedMs));
226
+ const parentPrev =
227
+ typeof task.durationMs === "number" && Number.isFinite(task.durationMs) ? Number(task.durationMs) : 0;
228
+ task.durationMs = Math.floor(parentPrev + elapsed);
229
+ delete task[MAIN_TIMER_SEGMENT_STARTED_AT];
230
+ ensureTaskParentDurationCoversSubtasksMs(task);
231
+ }
232
+
233
+ /** Démarre un segment de minuteur principal si la tâche est au suivi (hors sous-tâche). */
234
+ export function ensureMainTimerSegmentForRunningTask(task: Record<string, unknown>): void {
235
+ if (task.isDone === true) {
236
+ return;
237
+ }
238
+ if (task.manualTaskTimerPaused === true) {
239
+ return;
240
+ }
241
+ if (String(task.activeSubtaskTimerId ?? "").trim() !== "") {
242
+ return;
243
+ }
244
+ const raw = task[MAIN_TIMER_SEGMENT_STARTED_AT];
245
+ if (typeof raw === "string" && raw.trim() !== "") {
246
+ return;
247
+ }
248
+ task[MAIN_TIMER_SEGMENT_STARTED_AT] = new Date().toISOString();
249
+ }
250
+
251
+ /**
252
+ * Ajoute le segment de minuteur sous-tâche en cours à `durationMs` de la sous-tâche active
253
+ * et le même segment à la tâche parente (répartition par point + total tâche ; les rapports ne lisent que le parent).
254
+ * Le total parent peut rester supérieur à la somme des points (travail sans sous-tâches), jamais l’inverse
255
+ * — rattrapage via `ensureTaskParentDurationCoversSubtasksMs` en fin de flush.
256
+ * Efface ensuite `activeSubtaskTimerId` et l’horodatage de segment.
257
+ */
258
+ export function flushSubtaskTimerOnTask(task: Record<string, unknown>): void {
259
+ const activeId = String(task.activeSubtaskTimerId ?? "").trim();
260
+ if (!activeId) {
261
+ return;
262
+ }
263
+ const raw = task[SUBTASK_TIMER_STARTED_AT];
264
+ const startedMs =
265
+ typeof raw === "string" && raw.trim() !== ""
266
+ ? Date.parse(raw)
267
+ : typeof raw === "number" && Number.isFinite(raw)
268
+ ? raw
269
+ : Number.NaN;
270
+ if (!Number.isFinite(startedMs)) {
271
+ task.activeSubtaskTimerId = null;
272
+ delete task[SUBTASK_TIMER_STARTED_AT];
273
+ return;
274
+ }
275
+ const elapsed = Math.max(0, Date.now() - startedMs);
276
+ const st = ensureSubtasks(task).find((s) => String(s.id) === activeId);
277
+ if (st && typeof st === "object" && !Array.isArray(st)) {
278
+ const row = st as Record<string, unknown>;
279
+ const prev = typeof row.durationMs === "number" && Number.isFinite(row.durationMs) ? row.durationMs : 0;
280
+ row.durationMs = Math.floor(Number(prev) + elapsed);
281
+ }
282
+ const parentPrev =
283
+ typeof task.durationMs === "number" && Number.isFinite(task.durationMs) ? Number(task.durationMs) : 0;
284
+ task.durationMs = Math.floor(parentPrev + elapsed);
285
+ task.activeSubtaskTimerId = null;
286
+ delete task[SUBTASK_TIMER_STARTED_AT];
287
+ ensureTaskParentDurationCoversSubtasksMs(task);
288
+ }
289
+
290
+ export function finishTaskInSession(
291
+ sess: Record<string, unknown>,
292
+ taskId: string,
293
+ shouldCommit: boolean,
294
+ tagNormOpts?: TaskTagsStorageNormalizeOpts
295
+ ): boolean {
296
+ const id = taskId;
297
+ let task: Record<string, unknown> | null = null;
298
+ let fromActiveStack = false;
299
+
300
+ const activeArr = getActiveTasksArray(sess);
301
+ const idx = activeArr.findIndex((t) => String(t.id) === id);
302
+ const at = asRecord(sess.activeTask);
303
+
304
+ if (idx >= 0) {
305
+ task = activeArr[idx] as Record<string, unknown>;
306
+ const sameAsActive = Boolean(at && String(at.id) === id);
307
+ activeArr.splice(idx, 1);
308
+ sess.activeTasks = activeArr;
309
+ if (sameAsActive) {
310
+ sess.activeTask = activeArr[0] ?? null;
311
+ }
312
+ fromActiveStack = true;
313
+ } else if (at && String(at.id) === id) {
314
+ task = at;
315
+ sess.activeTask = null;
316
+ sess.activeTasks = activeArr;
317
+ fromActiveStack = true;
318
+ } else {
319
+ task = getTasksArray(sess).find((t) => String(t.id) === id) ?? null;
320
+ if (!task || task.isDone === true) {
321
+ return false;
322
+ }
323
+ }
324
+
325
+ flushSubtaskTimerOnTask(task);
326
+ flushMainTimerSegmentOnTask(task);
327
+ task.isDone = true;
328
+ task.endTime = new Date().toISOString();
329
+ task.shouldCommit = shouldCommit;
330
+ task.manualTaskTimerPaused = false;
331
+ for (const st of ensureSubtasks(task)) {
332
+ if (st && typeof st === "object" && !Array.isArray(st)) {
333
+ (st as Record<string, unknown>).done = true;
334
+ }
335
+ }
336
+
337
+ task.tags = normalizeTaskTagsForStorage((task.tags as string[] | undefined) ?? [], tagNormOpts);
338
+ ensureTaskParentDurationCoversSubtasksMs(task);
339
+
340
+ const tasks = getTasksArray(sess);
341
+ const existingIdx = tasks.findIndex((t) => String(t.id) === id);
342
+ if (existingIdx >= 0) {
343
+ tasks[existingIdx] = task;
344
+ } else {
345
+ tasks.push(task);
346
+ }
347
+ sess.tasks = tasks;
348
+
349
+ if (fromActiveStack) {
350
+ const after = getActiveTasksArray(sess);
351
+ const curAt = asRecord(sess.activeTask);
352
+ if (curAt && String(curAt.id) === id) {
353
+ sess.activeTask = after[0] ?? null;
354
+ }
355
+ if (!sess.activeTask && after.length > 0) {
356
+ sess.activeTask = after[0] ?? null;
357
+ }
358
+ }
359
+ return true;
360
+ }
361
+
362
+ export function deleteTaskInSession(sess: Record<string, unknown>, taskId: string): boolean {
363
+ const id = taskId;
364
+ const before = findTaskRecord(sess, id);
365
+ if (!before) {
366
+ return false;
367
+ }
368
+ flushSubtaskTimerOnTask(before);
369
+ flushMainTimerSegmentOnTask(before);
370
+ sess.activeTasks = getActiveTasksArray(sess).filter((t) => String(t.id) !== id);
371
+ sess.tasks = getTasksArray(sess).filter((t) => String(t.id) !== id);
372
+ const curAt = asRecord(sess.activeTask);
373
+ if (curAt && String(curAt.id) === id) {
374
+ const next = (sess.activeTasks as Record<string, unknown>[])[0] ?? null;
375
+ sess.activeTask = next;
376
+ }
377
+ return true;
378
+ }
379
+
380
+ export function updateTaskInSession(
381
+ sess: Record<string, unknown>,
382
+ taskId: string,
383
+ patch: { name?: string; tags?: string[]; project?: string | null },
384
+ tagNormOpts?: TaskTagsStorageNormalizeOpts
385
+ ): boolean {
386
+ const task = findTaskRecord(sess, taskId);
387
+ if (!task) {
388
+ return false;
389
+ }
390
+ if (typeof patch.name === "string") {
391
+ task.name = patch.name;
392
+ }
393
+ if (Array.isArray(patch.tags)) {
394
+ task.tags = normalizeTaskTagsForStorage(patch.tags, tagNormOpts);
395
+ }
396
+ if (patch.project !== undefined) {
397
+ task.project = patch.project;
398
+ }
399
+ return true;
400
+ }
401
+
402
+ export function updateTaskStartTimeInSession(
403
+ sess: Record<string, unknown>,
404
+ taskId: string,
405
+ startTimeIso: string
406
+ ): boolean {
407
+ const task = findTaskRecord(sess, taskId);
408
+ if (!task) {
409
+ return false;
410
+ }
411
+ const startMs = Date.parse(startTimeIso);
412
+ if (!Number.isFinite(startMs)) {
413
+ return false;
414
+ }
415
+ const nowMs = Date.now();
416
+ task.startTime = new Date(startMs).toISOString();
417
+ const endRaw = typeof task.endTime === "string" ? task.endTime : "";
418
+ const endMs = Date.parse(endRaw);
419
+ if (Number.isFinite(endMs) && endMs >= startMs) {
420
+ task.durationMs = Math.max(0, Math.floor(endMs - startMs));
421
+ return true;
422
+ }
423
+ task.durationMs = Math.max(0, Math.floor(nowMs - startMs));
424
+ const subtaskRunning = String(task.activeSubtaskTimerId ?? "").trim() !== "";
425
+ if (!subtaskRunning && task.manualTaskTimerPaused !== true && task.isDone !== true) {
426
+ task[MAIN_TIMER_SEGMENT_STARTED_AT] = new Date(nowMs).toISOString();
427
+ }
428
+ ensureTaskParentDurationCoversSubtasksMs(task);
429
+ return true;
430
+ }
431
+
432
+ /**
433
+ * Corrige l’heure de fin d’une tâche **terminée** ; recalcule `durationMs`.
434
+ * Les tâches encore ouvertes utilisent le flux habituel (terminer / minuteur).
435
+ */
436
+ export function updateTaskEndTimeInSession(
437
+ sess: Record<string, unknown>,
438
+ taskId: string,
439
+ endTimeIso: string
440
+ ): boolean {
441
+ const task = findTaskRecord(sess, taskId);
442
+ if (!task || task.isDone !== true) {
443
+ return false;
444
+ }
445
+ const endMs = Date.parse(endTimeIso);
446
+ if (!Number.isFinite(endMs)) {
447
+ return false;
448
+ }
449
+ const startRaw = typeof task.startTime === "string" ? task.startTime : "";
450
+ const startMs = Date.parse(startRaw);
451
+ if (!Number.isFinite(startMs) || endMs < startMs) {
452
+ return false;
453
+ }
454
+ task.endTime = new Date(endMs).toISOString();
455
+ task.durationMs = Math.max(0, Math.floor(endMs - startMs));
456
+ ensureTaskParentDurationCoversSubtasksMs(task);
457
+ return true;
458
+ }
459
+
460
+ export function setTaskPausedInSession(sess: Record<string, unknown>, taskId: string, paused: boolean): boolean {
461
+ const task = findTaskRecord(sess, taskId);
462
+ if (!task || task.isDone === true) {
463
+ return false;
464
+ }
465
+ if (paused) {
466
+ flushSubtaskTimerOnTask(task);
467
+ flushMainTimerSegmentOnTask(task);
468
+ task.manualTaskTimerPaused = true;
469
+ } else {
470
+ flushSubtaskTimerOnTask(task);
471
+ prepareSessionForExclusiveMainTimerUnpaused(sess, taskId);
472
+ }
473
+ return true;
474
+ }
475
+
476
+ export function toggleSubtaskInSession(
477
+ sess: Record<string, unknown>,
478
+ taskId: string,
479
+ subtaskId: string
480
+ ): boolean {
481
+ const task = findTaskRecord(sess, taskId);
482
+ if (!task) {
483
+ return false;
484
+ }
485
+ const st = ensureSubtasks(task).find((s) => String(s.id) === subtaskId);
486
+ if (!st) {
487
+ return false;
488
+ }
489
+ const sid = String(subtaskId);
490
+ const wasTracking = String(task.activeSubtaskTimerId ?? "") === sid;
491
+ const nextDone = !Boolean(st.done);
492
+ if (wasTracking && nextDone) {
493
+ flushSubtaskTimerOnTask(task);
494
+ }
495
+ st.done = nextDone;
496
+ return true;
497
+ }
498
+
499
+ export function updateSubtaskTitleInSession(
500
+ sess: Record<string, unknown>,
501
+ taskId: string,
502
+ subtaskId: string,
503
+ title: string
504
+ ): boolean {
505
+ const task = findTaskRecord(sess, taskId);
506
+ if (!task) {
507
+ return false;
508
+ }
509
+ const st = ensureSubtasks(task).find((s) => String(s.id) === subtaskId);
510
+ if (!st) {
511
+ return false;
512
+ }
513
+ st.title = title;
514
+ return true;
515
+ }
516
+
517
+ export function setActiveSubtaskTimerInSession(
518
+ sess: Record<string, unknown>,
519
+ taskId: string,
520
+ subtaskId: string | null
521
+ ): boolean {
522
+ const task = findTaskRecord(sess, taskId);
523
+ if (!task) {
524
+ return false;
525
+ }
526
+ if (subtaskId === null || subtaskId === undefined) {
527
+ flushSubtaskTimerOnTask(task);
528
+ return true;
529
+ }
530
+ const sid = String(subtaskId);
531
+ const st = ensureSubtasks(task).find((s) => String(s.id) === sid);
532
+ if (!st) {
533
+ return false;
534
+ }
535
+ flushMainTimerSegmentOnTask(task);
536
+ prepareSessionForSubtaskTimer(sess, String(taskId));
537
+ flushSubtaskTimerOnTask(task);
538
+ task.activeSubtaskTimerId = sid;
539
+ task[SUBTASK_TIMER_STARTED_AT] = new Date().toISOString();
540
+ task.manualTaskTimerPaused = false;
541
+ return true;
542
+ }
543
+
544
+ export function deleteSubtaskInSession(sess: Record<string, unknown>, taskId: string, subtaskId: string): boolean {
545
+ const task = findTaskRecord(sess, taskId);
546
+ if (!task) {
547
+ return false;
548
+ }
549
+ const sid = String(subtaskId);
550
+ if (String(task.activeSubtaskTimerId ?? "") === sid) {
551
+ flushSubtaskTimerOnTask(task);
552
+ }
553
+ const list = ensureSubtasks(task);
554
+ const next = list.filter((s) => String(s.id) !== sid);
555
+ task.subtasks = next;
556
+ ensureTaskParentDurationCoversSubtasksMs(task);
557
+ return true;
558
+ }
559
+
560
+ /**
561
+ * Réordonne les sous-tâches selon une liste complète d’identifiants (permutation exacte attendue).
562
+ */
563
+ export function reorderSubtasksInSession(
564
+ sess: Record<string, unknown>,
565
+ taskId: string,
566
+ orderedSubtaskIds: string[]
567
+ ): boolean {
568
+ const task = findTaskRecord(sess, taskId);
569
+ if (!task) {
570
+ return false;
571
+ }
572
+ const list = ensureSubtasks(task);
573
+ if (list.length <= 1) {
574
+ return false;
575
+ }
576
+ const currentIds = new Set(list.map((s) => String(s.id)));
577
+ if (orderedSubtaskIds.length !== currentIds.size) {
578
+ return false;
579
+ }
580
+ const seen = new Set<string>();
581
+ for (const raw of orderedSubtaskIds) {
582
+ const id = String(raw);
583
+ if (!currentIds.has(id) || seen.has(id)) {
584
+ return false;
585
+ }
586
+ seen.add(id);
587
+ }
588
+ const byId = new Map(list.map((s) => [String(s.id), s]));
589
+ task.subtasks = orderedSubtaskIds.map((raw) => byId.get(String(raw))!);
590
+ return true;
591
+ }
592
+
593
+ export function addSubtaskInSession(sess: Record<string, unknown>, taskId: string, title: string): boolean {
594
+ const task = findTaskRecord(sess, taskId);
595
+ if (!task || task.isDone === true) {
596
+ return false;
597
+ }
598
+ const list = ensureSubtasks(task);
599
+ list.push({
600
+ id: randomUUID(),
601
+ title,
602
+ done: false,
603
+ durationMs: 0,
604
+ });
605
+ task.subtasks = list;
606
+ return true;
607
+ }
608
+
609
+ export function addHistoricalTaskToSession(
610
+ sess: Record<string, unknown>,
611
+ input: {
612
+ name: string;
613
+ tags: string[];
614
+ project?: string | null;
615
+ durationMs: number;
616
+ startTime: string;
617
+ endTime: string;
618
+ },
619
+ newId: string,
620
+ tagNormOpts?: TaskTagsStorageNormalizeOpts
621
+ ): void {
622
+ const task: Record<string, unknown> = {
623
+ id: newId,
624
+ name: input.name.trim(),
625
+ startTime: input.startTime,
626
+ endTime: input.endTime,
627
+ durationMs: input.durationMs,
628
+ isDone: true,
629
+ kronoFocusCycles: 0,
630
+ tags: normalizeTaskTagsForStorage(input.tags, tagNormOpts),
631
+ project: input.project ?? null,
632
+ subtasks: [],
633
+ };
634
+ const tasks = getTasksArray(sess);
635
+ tasks.push(task);
636
+ sess.tasks = tasks;
637
+ }
638
+
639
+ function eachSessionRecord(p: KronosysUpdatePayload, fn: (sess: Record<string, unknown>) => void): void {
640
+ const cur = asRecord(p.current);
641
+ if (cur) {
642
+ fn(cur);
643
+ }
644
+ for (const h of (p.history || []) as Record<string, unknown>[]) {
645
+ fn(h);
646
+ }
647
+ for (const h of (p.historyArchived || []) as Record<string, unknown>[]) {
648
+ fn(h);
649
+ }
650
+ }
651
+
652
+ function stripTagFromTaskTags(
653
+ task: Record<string, unknown>,
654
+ tagLower: string,
655
+ tagNormOpts?: TaskTagsStorageNormalizeOpts
656
+ ): void {
657
+ if (!Array.isArray(task.tags)) {
658
+ return;
659
+ }
660
+ const tags = task.tags as string[];
661
+ task.tags = normalizeTaskTagsForStorage(
662
+ tags.filter((x) => normalizeTagKey(x).toLowerCase() !== tagLower),
663
+ tagNormOpts
664
+ );
665
+ }
666
+
667
+ export function purgeTagEverywhere(
668
+ p: KronosysUpdatePayload,
669
+ rawTag: string,
670
+ tagNormOpts?: TaskTagsStorageNormalizeOpts
671
+ ): void {
672
+ const nk = normalizeTagKey(rawTag).toLowerCase();
673
+ p.knownTags = ((p.knownTags || []) as string[]).filter((t) => normalizeTagKey(t).toLowerCase() !== nk);
674
+ p.userKnownTags = ((p.userKnownTags || []) as string[]).filter((t) => normalizeTagKey(t).toLowerCase() !== nk);
675
+ p.excludedSuggestionTags = ((p.excludedSuggestionTags || []) as string[]).filter(
676
+ (t) => normalizeTagKey(t).toLowerCase() !== nk
677
+ );
678
+ const td = { ...(asRecord(p.tagDescriptions) ?? {}) } as Record<string, string>;
679
+ delete td[nk];
680
+ p.tagDescriptions = td;
681
+
682
+ eachSessionRecord(p, (sess) => {
683
+ for (const t of [...getActiveTasksArray(sess), ...getTasksArray(sess)]) {
684
+ stripTagFromTaskTags(t, nk, tagNormOpts);
685
+ }
686
+ const at = asRecord(sess.activeTask);
687
+ if (at) {
688
+ stripTagFromTaskTags(at, nk, tagNormOpts);
689
+ }
690
+ });
691
+ }
692
+
693
+ export function purgeProjectEverywhere(p: KronosysUpdatePayload, rawName: string): void {
694
+ const pk = normalizeProjectKey(rawName).toLowerCase();
695
+ p.knownProjects = ((p.knownProjects || []) as string[]).filter(
696
+ (n) => normalizeProjectKey(n).toLowerCase() !== pk
697
+ );
698
+ const pd = { ...(asRecord(p.projectDescriptions) ?? {}) } as Record<string, string>;
699
+ delete pd[pk];
700
+ p.projectDescriptions = pd;
701
+
702
+ eachSessionRecord(p, (sess) => {
703
+ for (const t of [...getActiveTasksArray(sess), ...getTasksArray(sess)]) {
704
+ const proj = t.project;
705
+ if (typeof proj === "string" && normalizeProjectKey(proj).toLowerCase() === pk) {
706
+ t.project = null;
707
+ }
708
+ }
709
+ const at = asRecord(sess.activeTask);
710
+ if (at) {
711
+ const proj = at.project;
712
+ if (typeof proj === "string" && normalizeProjectKey(proj).toLowerCase() === pk) {
713
+ at.project = null;
714
+ }
715
+ }
716
+ });
717
+ }