@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
@@ -10,6 +10,8 @@ import {
10
10
  normalizeTaskTagsForStorage,
11
11
  parseTaskWithAutoTags,
12
12
  readTaskDefaultTagBucketEnabled,
13
+ resolvePersonalProjectForTaskUpdate,
14
+ resolveProjectForTaskUpdate,
13
15
  type TaskTagsStorageNormalizeOpts,
14
16
  } from "@/lib/taskParsing";
15
17
 
@@ -19,14 +21,23 @@ import {
19
21
  asRecord,
20
22
  deleteSubtaskInSession,
21
23
  deleteTaskInSession,
24
+ ensureMainTimerSegmentForRunningTask,
25
+ findTaskRecord,
22
26
  finishTaskInSession,
27
+ flushMainTimerSegmentOnTask,
28
+ flushSubtaskTimerOnTask,
29
+ forEachTaskRecordInSession,
23
30
  prepareSessionForExclusiveMainTimerUnpaused,
24
31
  purgeProjectEverywhere,
25
32
  purgeTagEverywhere,
33
+ renameProjectEverywhere,
34
+ renameTagEverywhere,
26
35
  reorderSubtasksInSession,
27
36
  resolveTaskSession,
37
+ SUBTASK_TIMER_STARTED_AT,
28
38
  setActiveSubtaskTimerInSession,
29
39
  setTaskPausedInSession,
40
+ scheduleTaskEndTimeInSession,
30
41
  toggleSubtaskInSession,
31
42
  updateSubtaskTitleInSession,
32
43
  updateTaskStartTimeInSession,
@@ -43,7 +54,13 @@ import {
43
54
  import {
44
55
  DEFAULT_DASHBOARD_TIME_ZONE,
45
56
  isValidIanaTimeZone,
57
+ readDashboardTimeZoneFromCfg,
46
58
  } from "@/lib/dashboardTimeZone";
59
+ import {
60
+ formatSessionNameTemplate,
61
+ SESSION_NAME_TEMPLATE_CFG_MAX_LEN,
62
+ SESSION_NAME_TEMPLATE_MAX_LEN,
63
+ } from "@/lib/formatSessionNameTemplate";
47
64
  import { defaultKronosysCfg } from "./defaultCfg";
48
65
  import { clearGitlabPatFromStore, readGitlabPatFromStore, writeGitlabPatToStore } from "./gitlabTokenStore";
49
66
  import {
@@ -59,10 +76,13 @@ import {
59
76
  clampWorkDurationSeconds,
60
77
  readWorkDurationSeconds,
61
78
  } from "@/lib/kronoFocusRhythm";
62
- import { readPayload, writePayload } from "./payloadStore";
79
+ import { withPayloadWriteAsync, writePayload } from "./payloadStore";
80
+ import { syncLiveIntoHistory } from "./liveHistorySync";
63
81
  import {
64
82
  ensureSessionWallSegmentOnLive,
83
+ finalizeLiveSessionClosedAt,
65
84
  flushSessionWallSegmentOnLive,
85
+ resumeLiveSessionWallIfPaused,
66
86
  SESSION_WALL_SEGMENT_STARTED_AT,
67
87
  } from "./sessionWallHydrate";
68
88
 
@@ -71,6 +91,22 @@ type ActionResult = {
71
91
  result?: Record<string, unknown>;
72
92
  };
73
93
 
94
+ type TaskTemplateRecord = {
95
+ id: string;
96
+ name: string;
97
+ tags: string[];
98
+ project: string | null;
99
+ personalProject?: boolean;
100
+ createdAt: string;
101
+ updatedAt: string;
102
+ };
103
+
104
+ type ActionHandlerContext = {
105
+ p: KronosysUpdatePayload;
106
+ body: Record<string, unknown>;
107
+ finish: (result?: Record<string, unknown>) => ActionResult;
108
+ };
109
+
74
110
  function tryPersistDevDataPreferenceFromCfg(merged: Record<string, unknown>): void {
75
111
  if (process.env.NODE_ENV !== "development") {
76
112
  return;
@@ -86,52 +122,133 @@ function tryPersistDevDataPreferenceFromCfg(merged: Record<string, unknown>): vo
86
122
  }
87
123
  }
88
124
 
89
- function syncLiveIntoHistory(p: KronosysUpdatePayload): void {
90
- const cur = asRecord(p.current);
91
- if (!cur) {
92
- return;
125
+ function ensureLive(p: KronosysUpdatePayload): Record<string, unknown> {
126
+ if (!p.current) {
127
+ p.current = {};
93
128
  }
94
- const sid = typeof cur.sessionId === "string" ? cur.sessionId.trim() : "";
95
- if (!sid) {
96
- return;
129
+ return p.current as Record<string, unknown>;
130
+ }
131
+
132
+ type GlobalPauseContextSnapshot = {
133
+ sessionWasPaused: boolean;
134
+ taskIds: string[];
135
+ subtaskTimers: Array<{ taskId: string; subtaskId: string }>;
136
+ };
137
+
138
+ function readGlobalPauseContext(cur: Record<string, unknown>): GlobalPauseContextSnapshot | null {
139
+ const rec = asRecord(cur.globalPauseContext);
140
+ if (!rec) {
141
+ return null;
97
142
  }
98
- const hist = ([...(p.history || [])] as Record<string, unknown>[]).filter((h) => h.sessionId !== sid);
99
- const snap: Record<string, unknown> = {
100
- sessionId: sid,
101
- sessionName: cur.sessionName ?? "",
102
- savedAt: new Date().toISOString(),
103
- createdAt: cur.createdAt ?? cur.startAt ?? null,
104
- startAt: cur.startAt ?? null,
105
- endAt: cur.endAt ?? null,
106
- sessionDurationMinutes: cur.sessionDurationMinutes,
107
- tasks: cur.tasks ?? [],
108
- activeTasks: cur.activeTasks ?? [],
109
- activeTask: cur.activeTask ?? null,
110
- archived: cur.archived === true,
143
+ const taskIds = Array.isArray(rec.taskIds)
144
+ ? rec.taskIds
145
+ .map((v) => (typeof v === "string" ? v.trim() : ""))
146
+ .filter((v) => v.length > 0)
147
+ : [];
148
+ const subtaskTimers = Array.isArray(rec.subtaskTimers)
149
+ ? rec.subtaskTimers
150
+ .map((row) => {
151
+ const rr = asRecord(row);
152
+ const taskId = typeof rr?.taskId === "string" ? rr.taskId.trim() : "";
153
+ const subtaskId = typeof rr?.subtaskId === "string" ? rr.subtaskId.trim() : "";
154
+ return taskId && subtaskId ? { taskId, subtaskId } : null;
155
+ })
156
+ .filter((row): row is { taskId: string; subtaskId: string } => row !== null)
157
+ : [];
158
+ return {
159
+ sessionWasPaused: rec.sessionWasPaused === true,
160
+ taskIds,
161
+ subtaskTimers,
111
162
  };
112
- if (typeof cur.scheduledStartAt === "string" && cur.scheduledStartAt.trim() !== "") {
113
- snap.scheduledStartAt = cur.scheduledStartAt;
163
+ }
164
+
165
+ /** Instantané des minuteurs actifs mis en pause par la pause session (reprise ciblée). */
166
+ type SessionPauseContextSnapshot = {
167
+ taskIds: string[];
168
+ subtaskTimers: Array<{ taskId: string; subtaskId: string }>;
169
+ pausedAt: string;
170
+ };
171
+
172
+ function readSessionPauseContext(cur: Record<string, unknown>): SessionPauseContextSnapshot | null {
173
+ const rec = asRecord(cur.sessionPauseContext);
174
+ if (!rec) {
175
+ return null;
114
176
  }
115
- if (typeof cur.sessionStartOffsetMinutes === "number" && Number.isFinite(cur.sessionStartOffsetMinutes)) {
116
- snap.sessionStartOffsetMinutes = cur.sessionStartOffsetMinutes;
177
+ const taskIds = Array.isArray(rec.taskIds)
178
+ ? rec.taskIds
179
+ .map((v) => (typeof v === "string" ? v.trim() : ""))
180
+ .filter((v) => v.length > 0)
181
+ : [];
182
+ const subtaskTimers = Array.isArray(rec.subtaskTimers)
183
+ ? rec.subtaskTimers
184
+ .map((row) => {
185
+ const rr = asRecord(row);
186
+ const taskId = typeof rr?.taskId === "string" ? rr.taskId.trim() : "";
187
+ const subtaskId = typeof rr?.subtaskId === "string" ? rr.subtaskId.trim() : "";
188
+ return taskId && subtaskId ? { taskId, subtaskId } : null;
189
+ })
190
+ .filter((row): row is { taskId: string; subtaskId: string } => row !== null)
191
+ : [];
192
+ const pausedAt = typeof rec.pausedAt === "string" ? rec.pausedAt.trim() : "";
193
+ if (!pausedAt) {
194
+ return null;
117
195
  }
118
- const rk = normalizeSessionEndReasonKind(cur.sessionEndReasonKind);
119
- if (rk) {
120
- snap.sessionEndReasonKind = rk;
196
+ return { taskIds, subtaskTimers, pausedAt };
197
+ }
198
+
199
+ function pruneSessionPauseContextForTask(sess: Record<string, unknown>, taskId: string): void {
200
+ const tid = String(taskId ?? "").trim();
201
+ if (!tid) {
202
+ return;
121
203
  }
122
- const rn = normalizeSessionEndReasonNote(cur.sessionEndReasonNote);
123
- if (rn.length > 0) {
124
- snap.sessionEndReasonNote = rn;
204
+ const rec = asRecord(sess.sessionPauseContext);
205
+ if (!rec) {
206
+ return;
207
+ }
208
+ const taskIds = Array.isArray(rec.taskIds)
209
+ ? rec.taskIds.map((v) => String(v)).filter((id) => id !== tid)
210
+ : [];
211
+ const subtaskTimers = Array.isArray(rec.subtaskTimers)
212
+ ? rec.subtaskTimers.filter((row) => {
213
+ const rr = asRecord(row);
214
+ return String(rr?.taskId ?? "").trim() !== tid;
215
+ })
216
+ : [];
217
+ if (taskIds.length === 0 && subtaskTimers.length === 0) {
218
+ delete sess.sessionPauseContext;
219
+ } else {
220
+ sess.sessionPauseContext = {
221
+ ...rec,
222
+ taskIds,
223
+ subtaskTimers,
224
+ };
125
225
  }
126
- hist.unshift(snap);
127
- p.history = hist;
128
226
  }
129
227
 
130
- function ensureLive(p: KronosysUpdatePayload): Record<string, unknown> {
131
- if (!p.current) {
132
- p.current = {};
133
- }
134
- return p.current as Record<string, unknown>;
228
+ function restoreLiveTasksFromSessionPauseSnapshot(
229
+ cur: Record<string, unknown>,
230
+ snap: SessionPauseContextSnapshot,
231
+ ): void {
232
+ const nowIso = new Date().toISOString();
233
+ const taskIds = new Set(snap.taskIds);
234
+ const subtaskMap = new Map(snap.subtaskTimers.map((row) => [row.taskId, row.subtaskId]));
235
+ forEachTaskRecordInSession(cur, (task, taskId) => {
236
+ if (task.isDone === true) {
237
+ return;
238
+ }
239
+ const subId = subtaskMap.get(taskId);
240
+ if (subId) {
241
+ task.activeSubtaskTimerId = subId;
242
+ task.manualTaskTimerPaused = false;
243
+ task[SUBTASK_TIMER_STARTED_AT] = nowIso;
244
+ return;
245
+ }
246
+ if (!taskIds.has(taskId)) {
247
+ return;
248
+ }
249
+ task.manualTaskTimerPaused = false;
250
+ ensureMainTimerSegmentForRunningTask(task);
251
+ });
135
252
  }
136
253
 
137
254
  /** Enrichit `knownTags` avec des étiquettes vues sur une tâche (suggestions / datalist). */
@@ -170,11 +287,157 @@ function mergeDiscoveredProjectIntoPayloadKnownProjects(
170
287
  p.knownProjects = known;
171
288
  }
172
289
 
290
+ /** Enrichit `knownPersonalProjects` (jetons `!`) — distinct des projets `@`. */
291
+ function mergeDiscoveredPersonalProjectIntoPayloadKnownPersonalProjects(
292
+ p: KronosysUpdatePayload,
293
+ project: string | null | undefined,
294
+ personal: boolean | undefined
295
+ ): void {
296
+ if (!personal || project === undefined || project === null) {
297
+ return;
298
+ }
299
+ const pk = normalizeProjectKey(String(project).trim());
300
+ if (!pk) {
301
+ return;
302
+ }
303
+ const lk = pk.toLowerCase();
304
+ const known = [...(((p as Record<string, unknown>).knownPersonalProjects || []) as string[])];
305
+ if (known.some((x) => normalizeProjectKey(String(x)).toLowerCase() === lk)) {
306
+ return;
307
+ }
308
+ known.push(pk);
309
+ (p as Record<string, unknown>).knownPersonalProjects = known;
310
+ }
311
+
173
312
  function mergeCfg(p: KronosysUpdatePayload, patch: Record<string, unknown>): void {
174
313
  const base = { ...defaultKronosysCfg(), ...(asRecord(p.cfg) ?? {}) };
175
314
  p.cfg = { ...base, ...patch };
176
315
  }
177
316
 
317
+ function handleUpdateKronosysSettings({
318
+ p,
319
+ body,
320
+ finish,
321
+ }: ActionHandlerContext): ActionResult {
322
+ const s = asRecord(body.settings);
323
+ if (!s) {
324
+ return finish();
325
+ }
326
+ const merged = { ...s };
327
+ if (typeof merged.gitlabApiBaseUrl === "string") {
328
+ const parsed = parseGitlabInstanceOrigin(merged.gitlabApiBaseUrl);
329
+ merged.gitlabApiBaseUrl = parsed ?? "";
330
+ }
331
+ tryPersistDevDataPreferenceFromCfg(merged);
332
+ if (typeof merged.dashboardDisplayTimeZone === "string") {
333
+ const z = merged.dashboardDisplayTimeZone.trim();
334
+ merged.dashboardDisplayTimeZone = isValidIanaTimeZone(z) ? z : DEFAULT_DASHBOARD_TIME_ZONE;
335
+ }
336
+ if ("dashboardUse24HourClock" in merged) {
337
+ merged.dashboardUse24HourClock = merged.dashboardUse24HourClock !== false;
338
+ }
339
+ if (typeof merged.dashboardDefaultSessionNameTemplate === "string") {
340
+ merged.dashboardDefaultSessionNameTemplate = merged.dashboardDefaultSessionNameTemplate
341
+ .trim()
342
+ .slice(0, SESSION_NAME_TEMPLATE_CFG_MAX_LEN);
343
+ }
344
+ p.cfg = { ...defaultKronosysCfg(), ...(p.cfg as Record<string, unknown>), ...merged };
345
+ return finish();
346
+ }
347
+
348
+ function handleResetKronosysSettings({ p, finish }: ActionHandlerContext): ActionResult {
349
+ resetCfgToDefaults(p);
350
+ tryPersistDevDataPreferenceFromCfg({ developmentUseProductionData: false });
351
+ return finish();
352
+ }
353
+
354
+ function handleSetGitIdentity({ p, body, finish }: ActionHandlerContext): ActionResult {
355
+ const name = typeof body.gitUserName === "string" ? body.gitUserName.trim() : "";
356
+ const email = typeof body.gitUserEmail === "string" ? body.gitUserEmail.trim() : "";
357
+ const login = typeof body.gitAccountLogin === "string" ? body.gitAccountLogin.trim() : "";
358
+ p.gitIdentity = {
359
+ gitUserName: name.length > 0 ? name : null,
360
+ gitUserEmail: email.length > 0 ? email : null,
361
+ gitAccountLogin: login.length > 0 ? login : null,
362
+ };
363
+ return finish();
364
+ }
365
+
366
+ function readTaskTemplates(p: KronosysUpdatePayload): TaskTemplateRecord[] {
367
+ const raw = Array.isArray((p as Record<string, unknown>).taskTemplates)
368
+ ? (((p as Record<string, unknown>).taskTemplates as unknown[]) ?? [])
369
+ : [];
370
+ const out: TaskTemplateRecord[] = [];
371
+ for (const row of raw) {
372
+ const rec = asRecord(row);
373
+ if (!rec) {
374
+ continue;
375
+ }
376
+ const id = typeof rec.id === "string" ? rec.id.trim() : "";
377
+ const name = typeof rec.name === "string" ? rec.name.trim() : "";
378
+ if (!id || !name) {
379
+ continue;
380
+ }
381
+ const tags = Array.isArray(rec.tags)
382
+ ? rec.tags
383
+ .map((t) => (typeof t === "string" ? normalizeTagKey(t) : ""))
384
+ .filter((t) => t.length > 0)
385
+ : [];
386
+ const projectRaw = typeof rec.project === "string" ? rec.project.trim() : "";
387
+ const project = projectRaw ? normalizeProjectKey(projectRaw) : null;
388
+ const personalProject = rec.personalProject === true;
389
+ const createdAt = typeof rec.createdAt === "string" && rec.createdAt.trim() ? rec.createdAt : new Date().toISOString();
390
+ const updatedAt = typeof rec.updatedAt === "string" && rec.updatedAt.trim() ? rec.updatedAt : createdAt;
391
+ out.push({ id, name, tags, project, personalProject, createdAt, updatedAt });
392
+ }
393
+ return out;
394
+ }
395
+
396
+ function transformTaskTagsInPayload(
397
+ p: KronosysUpdatePayload,
398
+ sourceTag: string,
399
+ targetTag: string,
400
+ mode: "move" | "copy",
401
+ tagNormOpts: TaskTagsStorageNormalizeOpts
402
+ ): void {
403
+ const sourceNorm = normalizeTagKey(sourceTag);
404
+ const targetNorm = normalizeTagKey(targetTag);
405
+ if (!sourceNorm || !targetNorm || sourceNorm.toLowerCase() === targetNorm.toLowerCase()) {
406
+ return;
407
+ }
408
+ const applyOnSession = (sess: Record<string, unknown> | undefined): void => {
409
+ if (!sess) {
410
+ return;
411
+ }
412
+ forEachTaskRecordInSession(sess, (task) => {
413
+ const tagsRaw = Array.isArray(task.tags) ? (task.tags as string[]) : [];
414
+ if (tagsRaw.length === 0) {
415
+ return;
416
+ }
417
+ const lowered = tagsRaw.map((t) => normalizeTagKey(String(t)).toLowerCase());
418
+ const sourceL = sourceNorm.toLowerCase();
419
+ if (!lowered.some((x) => x === sourceL)) {
420
+ return;
421
+ }
422
+ let next = tagsRaw.filter((t) => normalizeTagKey(String(t)).toLowerCase() !== sourceL);
423
+ if (mode === "copy") {
424
+ next = [...tagsRaw];
425
+ }
426
+ if (!next.some((t) => normalizeTagKey(String(t)).toLowerCase() === targetNorm.toLowerCase())) {
427
+ next.push(targetNorm);
428
+ }
429
+ task.tags = normalizeTaskTagsForStorage(next, tagNormOpts);
430
+ });
431
+ };
432
+ applyOnSession(asRecord(p.current));
433
+ for (const row of (p.history || []) as Record<string, unknown>[]) {
434
+ applyOnSession(row);
435
+ }
436
+ for (const row of (p.historyArchived || []) as Record<string, unknown>[]) {
437
+ applyOnSession(row);
438
+ }
439
+ }
440
+
178
441
  const CFG_KEYS_PRESERVED_ON_RESET: readonly string[] = [
179
442
  "gitlabTokenStored",
180
443
  "gitlabTokenFromEnv",
@@ -599,6 +862,8 @@ function newTaskRecord(
599
862
  name: string;
600
863
  tags?: string[];
601
864
  project?: string | null;
865
+ personalProject?: boolean;
866
+ note?: string;
602
867
  },
603
868
  tagNormOpts: TaskTagsStorageNormalizeOpts
604
869
  ): Record<string, unknown> {
@@ -612,21 +877,50 @@ function newTaskRecord(
612
877
  kronoFocusCycles: 0,
613
878
  tags: normalizeTaskTagsForStorage(input.tags, tagNormOpts),
614
879
  project: input.project ?? null,
880
+ personalProject: input.personalProject === true,
881
+ note: typeof input.note === "string" ? input.note : "",
615
882
  subtasks: [],
616
883
  };
617
884
  }
618
885
 
886
+ let dispatchQueue: Promise<void> = Promise.resolve();
887
+
888
+ function enqueueDispatch<T>(run: () => Promise<T>): Promise<T> {
889
+ const next = dispatchQueue.then(run, run);
890
+ dispatchQueue = next.then(
891
+ () => undefined,
892
+ () => undefined,
893
+ );
894
+ return next;
895
+ }
896
+
619
897
  export async function dispatchKronosysAction(body: Record<string, unknown>): Promise<ActionResult> {
898
+ return enqueueDispatch(async () =>
899
+ withPayloadWriteAsync((p) => dispatchKronosysActionWithPayload(body, p)),
900
+ );
901
+ }
902
+
903
+ async function dispatchKronosysActionWithPayload(
904
+ body: Record<string, unknown>,
905
+ p: KronosysUpdatePayload,
906
+ ): Promise<ActionResult> {
620
907
  const type = typeof body.type === "string" ? body.type : "";
621
- const p = readPayload();
622
908
  const tagNormOpts: TaskTagsStorageNormalizeOpts = {
623
909
  assignDefaultTagBucket: readTaskDefaultTagBucketEnabled(p.cfg),
624
910
  };
625
911
 
626
- const finish = (): ActionResult => {
627
- writePayload(p);
628
- return { ok: true, result: {} };
912
+ const finish = (result: Record<string, unknown> = {}): ActionResult => {
913
+ return { ok: true, result };
914
+ };
915
+ const domainHandlers: Record<string, (ctx: ActionHandlerContext) => ActionResult> = {
916
+ updateKronosysSettings: handleUpdateKronosysSettings,
917
+ resetKronosysSettings: handleResetKronosysSettings,
918
+ setGitIdentity: handleSetGitIdentity,
629
919
  };
920
+ const domainHandler = domainHandlers[type];
921
+ if (domainHandler) {
922
+ return domainHandler({ p, body, finish });
923
+ }
630
924
 
631
925
  switch (type) {
632
926
  case "updateKronosysSettings": {
@@ -645,6 +939,10 @@ export async function dispatchKronosysAction(body: Record<string, unknown>): Pro
645
939
  if ("dashboardUse24HourClock" in merged) {
646
940
  merged.dashboardUse24HourClock = merged.dashboardUse24HourClock !== false;
647
941
  }
942
+ if (typeof merged.dashboardDefaultSessionNameTemplate === "string") {
943
+ merged.dashboardDefaultSessionNameTemplate =
944
+ merged.dashboardDefaultSessionNameTemplate.trim().slice(0, SESSION_NAME_TEMPLATE_CFG_MAX_LEN);
945
+ }
648
946
  p.cfg = { ...defaultKronosysCfg(), ...(p.cfg as Record<string, unknown>), ...merged };
649
947
  }
650
948
  return finish();
@@ -677,16 +975,123 @@ export async function dispatchKronosysAction(body: Record<string, unknown>): Pro
677
975
  }
678
976
  case "setPaused": {
679
977
  const cur = ensureLive(p);
978
+ if (readGlobalPauseContext(cur)) {
979
+ syncLiveIntoHistory(p);
980
+ return finish();
981
+ }
680
982
  if (body.paused === true) {
983
+ if (cur.isPaused === true) {
984
+ syncLiveIntoHistory(p);
985
+ return finish();
986
+ }
987
+ const pausedAt = new Date().toISOString();
988
+ const snapshot: SessionPauseContextSnapshot = {
989
+ taskIds: [],
990
+ subtaskTimers: [],
991
+ pausedAt,
992
+ };
993
+ forEachTaskRecordInSession(cur, (task, taskId) => {
994
+ if (task.isDone === true) {
995
+ return;
996
+ }
997
+ const activeSubtaskId =
998
+ typeof task.activeSubtaskTimerId === "string" ? task.activeSubtaskTimerId.trim() : "";
999
+ if (activeSubtaskId.length > 0) {
1000
+ snapshot.subtaskTimers.push({ taskId, subtaskId: activeSubtaskId });
1001
+ flushSubtaskTimerOnTask(task);
1002
+ }
1003
+ if (task.manualTaskTimerPaused !== true) {
1004
+ snapshot.taskIds.push(taskId);
1005
+ flushMainTimerSegmentOnTask(task);
1006
+ task.manualTaskTimerPaused = true;
1007
+ }
1008
+ });
1009
+ cur.sessionPauseContext = {
1010
+ taskIds: snapshot.taskIds,
1011
+ subtaskTimers: snapshot.subtaskTimers,
1012
+ pausedAt: snapshot.pausedAt,
1013
+ };
681
1014
  flushSessionWallSegmentOnLive(cur);
682
1015
  cur.isPaused = true;
683
1016
  } else {
1017
+ const sessSnap = readSessionPauseContext(cur);
1018
+ if (sessSnap) {
1019
+ restoreLiveTasksFromSessionPauseSnapshot(cur, sessSnap);
1020
+ delete cur.sessionPauseContext;
1021
+ }
684
1022
  cur.isPaused = false;
685
1023
  ensureSessionWallSegmentOnLive(cur);
686
1024
  }
687
1025
  syncLiveIntoHistory(p);
688
1026
  return finish();
689
1027
  }
1028
+ case "toggleGlobalPauseContext": {
1029
+ const cur = ensureLive(p);
1030
+ const existing = readGlobalPauseContext(cur);
1031
+ if (existing) {
1032
+ const nowIso = new Date().toISOString();
1033
+ const taskIds = new Set(existing.taskIds);
1034
+ const subtaskMap = new Map(existing.subtaskTimers.map((row) => [row.taskId, row.subtaskId]));
1035
+ if (!existing.sessionWasPaused) {
1036
+ cur.isPaused = false;
1037
+ ensureSessionWallSegmentOnLive(cur);
1038
+ }
1039
+ forEachTaskRecordInSession(cur, (task, taskId) => {
1040
+ if (task.isDone === true) {
1041
+ return;
1042
+ }
1043
+ const taskSubtaskId = subtaskMap.get(taskId);
1044
+ if (taskSubtaskId) {
1045
+ task.activeSubtaskTimerId = taskSubtaskId;
1046
+ task.manualTaskTimerPaused = false;
1047
+ task[SUBTASK_TIMER_STARTED_AT] = nowIso;
1048
+ return;
1049
+ }
1050
+ if (!taskIds.has(taskId)) {
1051
+ return;
1052
+ }
1053
+ task.manualTaskTimerPaused = false;
1054
+ ensureMainTimerSegmentForRunningTask(task);
1055
+ });
1056
+ delete cur.globalPauseContext;
1057
+ syncLiveIntoHistory(p);
1058
+ return finish();
1059
+ }
1060
+ delete cur.sessionPauseContext;
1061
+ const snapshot: GlobalPauseContextSnapshot = {
1062
+ sessionWasPaused: cur.isPaused === true,
1063
+ taskIds: [],
1064
+ subtaskTimers: [],
1065
+ };
1066
+ forEachTaskRecordInSession(cur, (task, taskId) => {
1067
+ if (task.isDone === true) {
1068
+ return;
1069
+ }
1070
+ const activeSubtaskId =
1071
+ typeof task.activeSubtaskTimerId === "string" ? task.activeSubtaskTimerId.trim() : "";
1072
+ if (activeSubtaskId.length > 0) {
1073
+ snapshot.subtaskTimers.push({ taskId, subtaskId: activeSubtaskId });
1074
+ flushSubtaskTimerOnTask(task);
1075
+ }
1076
+ if (task.manualTaskTimerPaused !== true) {
1077
+ snapshot.taskIds.push(taskId);
1078
+ flushMainTimerSegmentOnTask(task);
1079
+ task.manualTaskTimerPaused = true;
1080
+ }
1081
+ });
1082
+ if (cur.isPaused !== true) {
1083
+ flushSessionWallSegmentOnLive(cur);
1084
+ cur.isPaused = true;
1085
+ }
1086
+ cur.globalPauseContext = {
1087
+ sessionWasPaused: snapshot.sessionWasPaused,
1088
+ taskIds: snapshot.taskIds,
1089
+ subtaskTimers: snapshot.subtaskTimers,
1090
+ pausedAt: new Date().toISOString(),
1091
+ };
1092
+ syncLiveIntoHistory(p);
1093
+ return finish();
1094
+ }
690
1095
  case "setSessionName": {
691
1096
  const name = typeof body.name === "string" ? body.name : "";
692
1097
  const sid = typeof body.sessionId === "string" ? body.sessionId : undefined;
@@ -710,6 +1115,29 @@ export async function dispatchKronosysAction(body: Record<string, unknown>): Pro
710
1115
  syncLiveIntoHistory(p);
711
1116
  return finish();
712
1117
  }
1118
+ case "setSessionNote": {
1119
+ const note = typeof body.note === "string" ? body.note : "";
1120
+ const sid = typeof body.sessionId === "string" ? body.sessionId : undefined;
1121
+ const cur = asRecord(p.current);
1122
+ if (sid && cur?.sessionId === sid) {
1123
+ cur.sessionNote = note;
1124
+ } else if (!sid && cur) {
1125
+ cur.sessionNote = note;
1126
+ }
1127
+ const hist = (p.history || []) as Record<string, unknown>[];
1128
+ const id = sid ?? (typeof cur?.sessionId === "string" ? cur.sessionId : "");
1129
+ const row = hist.find((h) => h.sessionId === id);
1130
+ if (row) {
1131
+ row.sessionNote = note;
1132
+ }
1133
+ const arch = (p.historyArchived || []) as Record<string, unknown>[];
1134
+ const ar = arch.find((h) => h.sessionId === id);
1135
+ if (ar) {
1136
+ ar.sessionNote = note;
1137
+ }
1138
+ syncLiveIntoHistory(p);
1139
+ return finish();
1140
+ }
713
1141
  case "setSessionStartTime": {
714
1142
  const sidFromBody = typeof body.sessionId === "string" ? body.sessionId.trim() : "";
715
1143
  const isoRaw = typeof body.startAt === "string" ? body.startAt.trim() : "";
@@ -729,9 +1157,12 @@ export async function dispatchKronosysAction(body: Record<string, unknown>): Pro
729
1157
  if (!row) {
730
1158
  return;
731
1159
  }
732
- row.startAt = startAtIso;
733
1160
  const endRaw = typeof row.endAt === "string" ? row.endAt.trim() : "";
734
1161
  const endMs = Date.parse(endRaw);
1162
+ if (Number.isFinite(endMs) && startMs > endMs) {
1163
+ return;
1164
+ }
1165
+ row.startAt = startAtIso;
735
1166
  if (Number.isFinite(endMs) && endMs >= startMs) {
736
1167
  row.sessionDurationMinutes = (endMs - startMs) / 60000;
737
1168
  return;
@@ -770,24 +1201,34 @@ export async function dispatchKronosysAction(body: Record<string, unknown>): Pro
770
1201
  return;
771
1202
  }
772
1203
  const prevEnd = typeof row.endAt === "string" ? row.endAt.trim() : "";
773
- if (!prevEnd) {
774
- return;
775
- }
776
1204
  const startRaw = typeof row.startAt === "string" ? row.startAt.trim() : "";
777
1205
  const startMs = Date.parse(startRaw);
778
1206
  if (!Number.isFinite(startMs) || endMs < startMs) {
779
1207
  return;
780
1208
  }
1209
+ if (!prevEnd) {
1210
+ if (row === cur && endMs <= Date.now()) {
1211
+ finalizeLiveSessionClosedAt(p, endMs);
1212
+ return;
1213
+ }
1214
+ row.scheduledEndAt = endAtIso;
1215
+ return;
1216
+ }
781
1217
  row.endAt = endAtIso;
782
1218
  row.sessionDurationMinutes = (endMs - startMs) / 60000;
783
1219
  };
1220
+ let finalizedLiveSession = false;
784
1221
  if (cur?.sessionId === id) {
1222
+ const hadCurrentBefore = !!asRecord(p.current);
785
1223
  applyToRow(cur);
1224
+ finalizedLiveSession = hadCurrentBefore && !asRecord(p.current);
1225
+ }
1226
+ if (!finalizedLiveSession) {
1227
+ const hist = (p.history || []) as Record<string, unknown>[];
1228
+ applyToRow(hist.find((h) => h.sessionId === id));
1229
+ const arch = (p.historyArchived || []) as Record<string, unknown>[];
1230
+ applyToRow(arch.find((h) => h.sessionId === id));
786
1231
  }
787
- const hist = (p.history || []) as Record<string, unknown>[];
788
- applyToRow(hist.find((h) => h.sessionId === id));
789
- const arch = (p.historyArchived || []) as Record<string, unknown>[];
790
- applyToRow(arch.find((h) => h.sessionId === id));
791
1232
  syncLiveIntoHistory(p);
792
1233
  return finish();
793
1234
  }
@@ -828,29 +1269,152 @@ export async function dispatchKronosysAction(body: Record<string, unknown>): Pro
828
1269
  }
829
1270
  case "newSession": {
830
1271
  const id = randomUUID();
831
- const startMs = Date.now();
832
- const now = new Date(startMs).toISOString();
1272
+ const recordMs = Date.now();
1273
+ const recordIso = new Date(recordMs).toISOString();
1274
+ const bodyRec = body as Record<string, unknown>;
1275
+ const startAtOverrideRaw =
1276
+ typeof bodyRec.sessionStartAt === "string" ? bodyRec.sessionStartAt.trim() : "";
1277
+ const parsedOverrideMs =
1278
+ startAtOverrideRaw !== "" ? Date.parse(startAtOverrideRaw) : Number.NaN;
1279
+ const hadExplicitStart = startAtOverrideRaw !== "" && Number.isFinite(parsedOverrideMs);
1280
+ /** Début réellement dans le passé : on n’écrase pas la session live, on ajoute une ligne d’historique. */
1281
+ const isBackdatedOnly =
1282
+ hadExplicitStart && parsedOverrideMs < recordMs - 1000;
1283
+ const mergedCfg = { ...defaultKronosysCfg(), ...(asRecord(p.cfg) ?? {}) };
1284
+ const tmplRaw =
1285
+ typeof mergedCfg.dashboardDefaultSessionNameTemplate === "string"
1286
+ ? mergedCfg.dashboardDefaultSessionNameTemplate.trim()
1287
+ : "";
1288
+ const tz = readDashboardTimeZoneFromCfg(mergedCfg);
833
1289
  const prevCur = asRecord(p.current);
834
1290
  const prevLang = typeof prevCur?.language === "string" ? String(prevCur.language) : "en";
835
- const bodyRec = body as Record<string, unknown>;
836
1291
  const schedIn = bodyRec.scheduledStartAt;
837
- const ass: SessionStartAssiduity | undefined =
1292
+
1293
+ if (isBackdatedOnly) {
1294
+ const endAtOverrideRaw =
1295
+ typeof bodyRec.sessionEndAt === "string" ? bodyRec.sessionEndAt.trim() : "";
1296
+ const parsedEndMs =
1297
+ endAtOverrideRaw !== "" ? Date.parse(endAtOverrideRaw) : Number.NaN;
1298
+ const hadExplicitEnd = endAtOverrideRaw !== "" && Number.isFinite(parsedEndMs);
1299
+ if (!hadExplicitEnd) {
1300
+ return {
1301
+ ok: false,
1302
+ result: {
1303
+ newSessionError:
1304
+ "Une session passée exige une date et une heure de fin (`sessionEndAt`).",
1305
+ },
1306
+ };
1307
+ }
1308
+ if (parsedEndMs <= parsedOverrideMs) {
1309
+ return {
1310
+ ok: false,
1311
+ result: {
1312
+ newSessionError: "La fin de session doit être strictement après le début.",
1313
+ },
1314
+ };
1315
+ }
1316
+ if (parsedEndMs > recordMs + 1000) {
1317
+ return {
1318
+ ok: false,
1319
+ result: {
1320
+ newSessionError: "La fin de session ne peut pas être dans le futur.",
1321
+ },
1322
+ };
1323
+ }
1324
+ const sessionStartMs = parsedOverrideMs;
1325
+ const sessionStartIso = new Date(sessionStartMs).toISOString();
1326
+ const sessionEndIso = new Date(parsedEndMs).toISOString();
1327
+ const computedSessionName =
1328
+ tmplRaw === ""
1329
+ ? ""
1330
+ : formatSessionNameTemplate(tmplRaw, {
1331
+ sessionId: id,
1332
+ atMs: sessionStartMs,
1333
+ timeZone: tz,
1334
+ }).slice(0, SESSION_NAME_TEMPLATE_MAX_LEN);
1335
+ const ass: SessionStartAssiduity | undefined =
1336
+ typeof schedIn === "string" && schedIn.trim() !== ""
1337
+ ? (assiduityFromScheduledStart(sessionStartMs, schedIn) ?? undefined)
1338
+ : undefined;
1339
+ const hist = [...((p.history || []) as Record<string, unknown>[])];
1340
+ const newRow: Record<string, unknown> = {
1341
+ sessionId: id,
1342
+ sessionName: computedSessionName,
1343
+ sessionNote: "",
1344
+ archived: false,
1345
+ isPaused: true,
1346
+ savedAt: recordIso,
1347
+ createdAt: sessionStartIso,
1348
+ startAt: sessionStartIso,
1349
+ endAt: sessionEndIso,
1350
+ ...(ass
1351
+ ? { scheduledStartAt: ass.scheduledStartAt, sessionStartOffsetMinutes: ass.sessionStartOffsetMinutes }
1352
+ : {}),
1353
+ sessionDurationMinutes: Math.max(0, (parsedEndMs - sessionStartMs) / 60000),
1354
+ codingMinutesSession: 0,
1355
+ activeMinutes: 0,
1356
+ totalEvents: 0,
1357
+ language: prevLang,
1358
+ tasks: [],
1359
+ activeTasks: [],
1360
+ activeTask: null,
1361
+ sessionScope: body.sessionScope,
1362
+ };
1363
+ hist.unshift(newRow);
1364
+ p.history = hist;
1365
+ return finish({ newHistorySessionId: id });
1366
+ }
1367
+
1368
+ // Lancement immédiat : instantané d’ouverture = maintenant (le client impose la fin de session live si besoin).
1369
+ const immediateStartIso = recordIso;
1370
+ const immediateSessionName =
1371
+ tmplRaw === ""
1372
+ ? ""
1373
+ : formatSessionNameTemplate(tmplRaw, {
1374
+ sessionId: id,
1375
+ atMs: recordMs,
1376
+ timeZone: tz,
1377
+ }).slice(0, SESSION_NAME_TEMPLATE_MAX_LEN);
1378
+ /* Si la session courante est encore vivante (pas de `endAt`) au moment où l’on en démarre une
1379
+ * nouvelle, on la finalise proprement avant de la sortir de `current` :
1380
+ * - flush du segment de durée murale ;
1381
+ * - `endAt` au moment de l’ouverture de la nouvelle session ;
1382
+ * - `isPaused = true` (la session quitte le rôle de session live).
1383
+ * Sans ça, l’ancienne session se retrouvait dans `history` avec `endAt: null` et
1384
+ * `sessionDurationMinutes: 0` (orpheline), faisant disparaître son temps mural des rapports.
1385
+ */
1386
+ const prevLive = asRecord(p.current);
1387
+ if (prevLive) {
1388
+ const prevSid = typeof prevLive.sessionId === "string" ? prevLive.sessionId.trim() : "";
1389
+ const prevEndRaw = typeof prevLive.endAt === "string" ? prevLive.endAt.trim() : "";
1390
+ if (prevSid && prevEndRaw === "") {
1391
+ flushSessionWallSegmentOnLive(prevLive);
1392
+ prevLive.endAt = recordIso;
1393
+ prevLive.isPaused = true;
1394
+ }
1395
+ }
1396
+ syncLiveIntoHistory(p);
1397
+ const assImmediate: SessionStartAssiduity | undefined =
838
1398
  typeof schedIn === "string" && schedIn.trim() !== ""
839
- ? (assiduityFromScheduledStart(startMs, schedIn) ?? undefined)
1399
+ ? (assiduityFromScheduledStart(recordMs, schedIn) ?? undefined)
840
1400
  : undefined;
841
1401
  p.current = {
842
1402
  sessionId: id,
843
- sessionName: "",
1403
+ sessionName: immediateSessionName,
1404
+ sessionNote: "",
844
1405
  archived: false,
845
1406
  isPaused: false,
846
- savedAt: now,
847
- createdAt: now,
848
- startAt: now,
1407
+ savedAt: recordIso,
1408
+ createdAt: immediateStartIso,
1409
+ startAt: immediateStartIso,
849
1410
  endAt: null,
850
- ...(ass
851
- ? { scheduledStartAt: ass.scheduledStartAt, sessionStartOffsetMinutes: ass.sessionStartOffsetMinutes }
1411
+ ...(assImmediate
1412
+ ? {
1413
+ scheduledStartAt: assImmediate.scheduledStartAt,
1414
+ sessionStartOffsetMinutes: assImmediate.sessionStartOffsetMinutes,
1415
+ }
852
1416
  : {}),
853
- [SESSION_WALL_SEGMENT_STARTED_AT]: now,
1417
+ [SESSION_WALL_SEGMENT_STARTED_AT]: recordIso,
854
1418
  sessionDurationMinutes: 0,
855
1419
  codingMinutesSession: 0,
856
1420
  activeMinutes: 0,
@@ -880,16 +1444,42 @@ export async function dispatchKronosysAction(body: Record<string, unknown>): Pro
880
1444
  Array.isArray(body.tags) ? (body.tags as string[]) : [],
881
1445
  tagNormOpts
882
1446
  );
883
- const project = typeof body.project === "string" ? body.project : undefined;
1447
+ const parsedTitle = parseTaskWithAutoTags(name);
1448
+ const projFromBody = typeof body.project === "string" ? body.project.trim() : "";
1449
+ const projectResolved = projFromBody
1450
+ ? normalizeProjectKey(projFromBody)
1451
+ : parsedTitle.project
1452
+ ? normalizeProjectKey(parsedTitle.project)
1453
+ : null;
1454
+ const personalResolved =
1455
+ (typeof body.personalProject === "boolean" ? body.personalProject : parsedTitle.personalProject) &&
1456
+ Boolean(projectResolved);
1457
+ const note = typeof body.note === "string" ? body.note : "";
884
1458
  mergeDiscoveredTagsIntoPayloadKnownTags(p, mergeTagsForDisplay(name, tags));
885
- mergeDiscoveredProjectIntoPayloadKnownProjects(p, project);
886
- mergeDiscoveredProjectIntoPayloadKnownProjects(p, parseTaskWithAutoTags(name).project);
887
- const task = newTaskRecord({ name, tags, project }, tagNormOpts);
1459
+ mergeDiscoveredProjectIntoPayloadKnownProjects(p, projectResolved);
1460
+ mergeDiscoveredPersonalProjectIntoPayloadKnownPersonalProjects(p, projectResolved, personalResolved);
1461
+ mergeDiscoveredProjectIntoPayloadKnownProjects(p, parsedTitle.project);
1462
+ mergeDiscoveredPersonalProjectIntoPayloadKnownPersonalProjects(
1463
+ p,
1464
+ parsedTitle.project,
1465
+ parsedTitle.personalProject,
1466
+ );
1467
+ const task = newTaskRecord(
1468
+ {
1469
+ name,
1470
+ tags,
1471
+ project: projectResolved,
1472
+ personalProject: personalResolved,
1473
+ note,
1474
+ },
1475
+ tagNormOpts,
1476
+ );
888
1477
  const active = Array.isArray(cur.activeTasks) ? ([...cur.activeTasks] as Record<string, unknown>[]) : [];
889
1478
  active.push(task);
890
1479
  cur.activeTasks = active;
891
1480
  cur.activeTask = task;
892
1481
  prepareSessionForExclusiveMainTimerUnpaused(cur, String(task.id));
1482
+ resumeLiveSessionWallIfPaused(cur);
893
1483
  const bodyRec = body as Record<string, unknown>;
894
1484
  if (body.startKronoFocus === true || bodyRec[LEGACY_START_TASK_WITH_TIMER_BODY_KEY] === true) {
895
1485
  const pm = ensureKronoFocus(cur);
@@ -950,10 +1540,12 @@ export async function dispatchKronosysAction(body: Record<string, unknown>): Pro
950
1540
  p.inspectingSessionId = null;
951
1541
  p.knownTags = [];
952
1542
  p.knownProjects = [];
1543
+ (p as Record<string, unknown>).knownPersonalProjects = [];
953
1544
  p.userKnownTags = [];
954
1545
  p.excludedSuggestionTags = [];
955
1546
  p.tagDescriptions = {};
956
1547
  p.projectDescriptions = {};
1548
+ (p as Record<string, unknown>).taskTemplates = [];
957
1549
  p.gitIdentity = {};
958
1550
  delete p.gitStats;
959
1551
  delete p.dismissArchiveSessionConfirm;
@@ -964,8 +1556,37 @@ export async function dispatchKronosysAction(body: Record<string, unknown>): Pro
964
1556
  case "endLiveSession": {
965
1557
  const cur = asRecord(p.current);
966
1558
  if (cur) {
1559
+ const taskHandling =
1560
+ body.taskHandling === "finish"
1561
+ ? "finish"
1562
+ : body.taskHandling === "moveToPausedSession"
1563
+ ? "moveToPausedSession"
1564
+ : "keep";
1565
+ /* Une session terminée ne peut pas laisser des minuteurs « en cours » : on vide les segments
1566
+ * et on met en pause, quel que soit le mode (keep / finish / transposition). */
1567
+ forEachTaskRecordInSession(cur, (task) => {
1568
+ if (task.isDone === true) {
1569
+ return;
1570
+ }
1571
+ flushSubtaskTimerOnTask(task);
1572
+ flushMainTimerSegmentOnTask(task);
1573
+ task.manualTaskTimerPaused = true;
1574
+ });
1575
+ if (taskHandling === "finish") {
1576
+ const openTaskIds: string[] = [];
1577
+ forEachTaskRecordInSession(cur, (_task, taskId) => {
1578
+ const task = findTaskRecord(cur, taskId);
1579
+ if (task && task.isDone !== true) {
1580
+ openTaskIds.push(taskId);
1581
+ }
1582
+ });
1583
+ for (const taskId of openTaskIds) {
1584
+ finishTaskInSession(cur, taskId, false, tagNormOpts);
1585
+ }
1586
+ }
967
1587
  flushSessionWallSegmentOnLive(cur);
968
1588
  cur.endAt = new Date().toISOString();
1589
+ delete cur.scheduledEndAt;
969
1590
  const rk = normalizeSessionEndReasonKind(body.sessionEndReasonKind);
970
1591
  if (rk) {
971
1592
  cur.sessionEndReasonKind = rk;
@@ -978,7 +1599,50 @@ export async function dispatchKronosysAction(body: Record<string, unknown>): Pro
978
1599
  } else {
979
1600
  delete cur.sessionEndReasonNote;
980
1601
  }
1602
+ let movedSession: Record<string, unknown> | null = null;
1603
+ if (taskHandling === "moveToPausedSession") {
1604
+ const carriedTasks: Record<string, unknown>[] = [];
1605
+ forEachTaskRecordInSession(cur, (task) => {
1606
+ if (task.isDone === true) {
1607
+ return;
1608
+ }
1609
+ carriedTasks.push({
1610
+ ...task,
1611
+ manualTaskTimerPaused: true,
1612
+ activeSubtaskTimerId: null,
1613
+ });
1614
+ });
1615
+ if (carriedTasks.length > 0) {
1616
+ const now = new Date().toISOString();
1617
+ movedSession = {
1618
+ sessionId: randomUUID(),
1619
+ sessionName:
1620
+ typeof cur.sessionName === "string" && cur.sessionName.trim() !== ""
1621
+ ? `${cur.sessionName} (continued)`
1622
+ : "Continued session",
1623
+ archived: false,
1624
+ isPaused: true,
1625
+ savedAt: now,
1626
+ createdAt: now,
1627
+ startAt: now,
1628
+ endAt: null,
1629
+ sessionDurationMinutes: 0,
1630
+ codingMinutesSession: 0,
1631
+ activeMinutes: 0,
1632
+ totalEvents: 0,
1633
+ language: typeof cur.language === "string" ? cur.language : "en",
1634
+ tasks: carriedTasks,
1635
+ activeTasks: [],
1636
+ activeTask: null,
1637
+ };
1638
+ }
1639
+ }
981
1640
  syncLiveIntoHistory(p);
1641
+ if (movedSession) {
1642
+ p.current = movedSession;
1643
+ syncLiveIntoHistory(p);
1644
+ return finish();
1645
+ }
982
1646
  }
983
1647
  p.current = undefined;
984
1648
  return finish();
@@ -995,6 +1659,8 @@ export async function dispatchKronosysAction(body: Record<string, unknown>): Pro
995
1659
  : typeof body.project === "string"
996
1660
  ? body.project
997
1661
  : undefined;
1662
+ const personalPatch =
1663
+ body.personalProject === undefined ? undefined : body.personalProject === true;
998
1664
  if (
999
1665
  !updateTaskInSession(
1000
1666
  ctx.session,
@@ -1003,12 +1669,27 @@ export async function dispatchKronosysAction(body: Record<string, unknown>): Pro
1003
1669
  name: typeof body.name === "string" ? body.name : undefined,
1004
1670
  tags: Array.isArray(body.tags) ? (body.tags as string[]) : undefined,
1005
1671
  project: projectPatch,
1672
+ personalProject: personalPatch,
1673
+ note: typeof body.note === "string" ? body.note : undefined,
1006
1674
  },
1007
1675
  tagNormOpts
1008
1676
  )
1009
1677
  ) {
1010
1678
  return finish();
1011
1679
  }
1680
+ if (typeof body.name === "string") {
1681
+ const inferredProj = resolveProjectForTaskUpdate(body.name);
1682
+ const inferredPers = resolvePersonalProjectForTaskUpdate(body.name);
1683
+ const t = findTaskRecord(ctx.session, taskId);
1684
+ if (t) {
1685
+ if (inferredProj !== undefined) {
1686
+ t.project = inferredProj;
1687
+ }
1688
+ if (inferredPers !== undefined) {
1689
+ t.personalProject = inferredPers;
1690
+ }
1691
+ }
1692
+ }
1012
1693
  if (Array.isArray(body.tags)) {
1013
1694
  mergeDiscoveredTagsIntoPayloadKnownTags(p, body.tags as string[]);
1014
1695
  } else if (typeof body.name === "string") {
@@ -1016,8 +1697,22 @@ export async function dispatchKronosysAction(body: Record<string, unknown>): Pro
1016
1697
  }
1017
1698
  if (typeof projectPatch === "string") {
1018
1699
  mergeDiscoveredProjectIntoPayloadKnownProjects(p, projectPatch);
1700
+ const tAfter = findTaskRecord(ctx.session, taskId);
1701
+ const pers =
1702
+ personalPatch !== undefined
1703
+ ? personalPatch
1704
+ : typeof body.name === "string"
1705
+ ? parseTaskWithAutoTags(body.name).personalProject
1706
+ : tAfter?.personalProject === true;
1707
+ mergeDiscoveredPersonalProjectIntoPayloadKnownPersonalProjects(
1708
+ p,
1709
+ normalizeProjectKey(projectPatch),
1710
+ Boolean(pers),
1711
+ );
1019
1712
  } else if (typeof body.name === "string") {
1020
- mergeDiscoveredProjectIntoPayloadKnownProjects(p, parseTaskWithAutoTags(body.name).project);
1713
+ const pt = parseTaskWithAutoTags(body.name);
1714
+ mergeDiscoveredProjectIntoPayloadKnownProjects(p, pt.project);
1715
+ mergeDiscoveredPersonalProjectIntoPayloadKnownPersonalProjects(p, pt.project, pt.personalProject);
1021
1716
  }
1022
1717
  if (ctx.isLive) {
1023
1718
  syncLiveIntoHistory(p);
@@ -1034,7 +1729,18 @@ export async function dispatchKronosysAction(body: Record<string, unknown>): Pro
1034
1729
  if (!taskId || !startTime) {
1035
1730
  return finish();
1036
1731
  }
1037
- const ok = updateTaskStartTimeInSession(ctx.session, taskId, startTime);
1732
+ const durationAdjustModeRaw =
1733
+ body.durationAdjustMode === "keep" || body.durationAdjustMode === "manual" || body.durationAdjustMode === "from_bounds"
1734
+ ? body.durationAdjustMode
1735
+ : undefined;
1736
+ const manualDurationMs =
1737
+ typeof body.manualDurationMs === "number" && Number.isFinite(body.manualDurationMs)
1738
+ ? Math.max(0, Math.floor(body.manualDurationMs))
1739
+ : undefined;
1740
+ const ok = updateTaskStartTimeInSession(ctx.session, taskId, startTime, {
1741
+ durationAdjustMode: durationAdjustModeRaw,
1742
+ manualDurationMs,
1743
+ });
1038
1744
  if (ok && ctx.isLive) {
1039
1745
  syncLiveIntoHistory(p);
1040
1746
  }
@@ -1050,7 +1756,20 @@ export async function dispatchKronosysAction(body: Record<string, unknown>): Pro
1050
1756
  if (!taskId || !endTime) {
1051
1757
  return finish();
1052
1758
  }
1053
- const ok = updateTaskEndTimeInSession(ctx.session, taskId, endTime);
1759
+ const durationAdjustModeRaw =
1760
+ body.durationAdjustMode === "keep" || body.durationAdjustMode === "manual" || body.durationAdjustMode === "from_bounds"
1761
+ ? body.durationAdjustMode
1762
+ : undefined;
1763
+ const manualDurationMs =
1764
+ typeof body.manualDurationMs === "number" && Number.isFinite(body.manualDurationMs)
1765
+ ? Math.max(0, Math.floor(body.manualDurationMs))
1766
+ : undefined;
1767
+ const ok = updateTaskEndTimeInSession(ctx.session, taskId, endTime, {
1768
+ durationAdjustMode: durationAdjustModeRaw,
1769
+ manualDurationMs,
1770
+ })
1771
+ ? true
1772
+ : scheduleTaskEndTimeInSession(ctx.session, taskId, endTime);
1054
1773
  if (ok && ctx.isLive) {
1055
1774
  syncLiveIntoHistory(p);
1056
1775
  }
@@ -1088,7 +1807,13 @@ export async function dispatchKronosysAction(body: Record<string, unknown>): Pro
1088
1807
  if (!ctx) {
1089
1808
  return finish();
1090
1809
  }
1091
- setTaskPausedInSession(ctx.session, String(body.taskId ?? ""), body.paused === true);
1810
+ const paused = body.paused === true;
1811
+ const taskId = String(body.taskId ?? "");
1812
+ setTaskPausedInSession(ctx.session, taskId, paused);
1813
+ if (ctx.isLive && !paused) {
1814
+ pruneSessionPauseContextForTask(ctx.session, taskId);
1815
+ resumeLiveSessionWallIfPaused(ctx.session);
1816
+ }
1092
1817
  if (ctx.isLive) {
1093
1818
  syncLiveIntoHistory(p);
1094
1819
  }
@@ -1099,8 +1824,11 @@ export async function dispatchKronosysAction(body: Record<string, unknown>): Pro
1099
1824
  if (!ctx) {
1100
1825
  return finish();
1101
1826
  }
1102
- setTaskPausedInSession(ctx.session, String(body.taskId ?? ""), false);
1827
+ const taskId = String(body.taskId ?? "");
1828
+ setTaskPausedInSession(ctx.session, taskId, false);
1103
1829
  if (ctx.isLive) {
1830
+ pruneSessionPauseContextForTask(ctx.session, taskId);
1831
+ resumeLiveSessionWallIfPaused(ctx.session);
1104
1832
  syncLiveIntoHistory(p);
1105
1833
  }
1106
1834
  return finish();
@@ -1117,20 +1845,49 @@ export async function dispatchKronosysAction(body: Record<string, unknown>): Pro
1117
1845
  );
1118
1846
  const project =
1119
1847
  body.project === null ? null : typeof body.project === "string" ? body.project : undefined;
1848
+ const parsedTitle = parseTaskWithAutoTags(name);
1849
+ const projectResolved =
1850
+ project !== undefined && project !== null && String(project).trim()
1851
+ ? normalizeProjectKey(String(project).trim())
1852
+ : parsedTitle.project
1853
+ ? normalizeProjectKey(parsedTitle.project)
1854
+ : null;
1855
+ const personalResolved =
1856
+ (typeof body.personalProject === "boolean" ? body.personalProject : parsedTitle.personalProject) &&
1857
+ Boolean(projectResolved);
1120
1858
  const durationMs = typeof body.durationMs === "number" ? Math.round(body.durationMs) : 0;
1859
+ const note = typeof body.note === "string" ? body.note : "";
1121
1860
  const startTime = typeof body.startTime === "string" ? body.startTime : "";
1122
1861
  const endTime = typeof body.endTime === "string" ? body.endTime : "";
1123
- addHistoricalTaskToSession(
1862
+ const added = addHistoricalTaskToSession(
1124
1863
  ctx.session,
1125
- { name, tags, project, durationMs, startTime, endTime },
1864
+ {
1865
+ name,
1866
+ tags,
1867
+ project: projectResolved,
1868
+ personalProject: personalResolved,
1869
+ note,
1870
+ durationMs,
1871
+ startTime,
1872
+ endTime,
1873
+ },
1126
1874
  randomUUID(),
1127
1875
  tagNormOpts
1128
1876
  );
1129
- mergeDiscoveredTagsIntoPayloadKnownTags(p, mergeTagsForDisplay(name, tags));
1130
- if (typeof project === "string") {
1131
- mergeDiscoveredProjectIntoPayloadKnownProjects(p, project);
1877
+ if (!added) {
1878
+ return finish();
1132
1879
  }
1133
- mergeDiscoveredProjectIntoPayloadKnownProjects(p, parseTaskWithAutoTags(name).project);
1880
+ mergeDiscoveredTagsIntoPayloadKnownTags(p, mergeTagsForDisplay(name, tags));
1881
+ if (projectResolved) {
1882
+ mergeDiscoveredProjectIntoPayloadKnownProjects(p, projectResolved);
1883
+ mergeDiscoveredPersonalProjectIntoPayloadKnownPersonalProjects(p, projectResolved, personalResolved);
1884
+ }
1885
+ mergeDiscoveredProjectIntoPayloadKnownProjects(p, parsedTitle.project);
1886
+ mergeDiscoveredPersonalProjectIntoPayloadKnownPersonalProjects(
1887
+ p,
1888
+ parsedTitle.project,
1889
+ parsedTitle.personalProject,
1890
+ );
1134
1891
  if (ctx.isLive) {
1135
1892
  syncLiveIntoHistory(p);
1136
1893
  }
@@ -1343,6 +2100,110 @@ export async function dispatchKronosysAction(body: Record<string, unknown>): Pro
1343
2100
  p.userKnownTags = user;
1344
2101
  return finish();
1345
2102
  }
2103
+ case "saveTaskTemplate": {
2104
+ const name = typeof body.name === "string" ? body.name.trim() : "";
2105
+ const tags = normalizeTaskTagsForStorage(
2106
+ Array.isArray(body.tags) ? (body.tags as string[]) : [],
2107
+ tagNormOpts
2108
+ );
2109
+ const projectRaw = typeof body.project === "string" ? body.project.trim() : "";
2110
+ const project = projectRaw ? normalizeProjectKey(projectRaw) : null;
2111
+ if (!name && tags.length === 0 && !project) {
2112
+ return finish();
2113
+ }
2114
+ const incomingPersonal =
2115
+ typeof body.personalProject === "boolean" ? body.personalProject : undefined;
2116
+ const now = new Date().toISOString();
2117
+ const list = readTaskTemplates(p);
2118
+ const idOpt = typeof body.id === "string" ? body.id.trim() : "";
2119
+ if (idOpt) {
2120
+ const ix = list.findIndex((tpl) => tpl.id === idOpt);
2121
+ if (ix < 0) {
2122
+ return finish();
2123
+ }
2124
+ const prev = list[ix]!;
2125
+ const personalProject =
2126
+ incomingPersonal !== undefined ? incomingPersonal : prev.personalProject === true;
2127
+ list[ix] = {
2128
+ ...prev,
2129
+ name: name || prev.name,
2130
+ tags,
2131
+ project,
2132
+ personalProject,
2133
+ updatedAt: now,
2134
+ };
2135
+ (p as Record<string, unknown>).taskTemplates = list.slice(0, 200);
2136
+ if (tags.length > 0) {
2137
+ mergeDiscoveredTagsIntoPayloadKnownTags(p, tags);
2138
+ }
2139
+ if (project) {
2140
+ if (personalProject) {
2141
+ mergeDiscoveredPersonalProjectIntoPayloadKnownPersonalProjects(p, project, true);
2142
+ } else {
2143
+ mergeDiscoveredProjectIntoPayloadKnownProjects(p, project);
2144
+ }
2145
+ }
2146
+ return finish();
2147
+ }
2148
+ const sigPersonal = incomingPersonal === true;
2149
+ const sig = `${name.toLowerCase()}|${sigPersonal ? "p" : "w"}|${project ? project.toLowerCase() : ""}|${tags
2150
+ .map((t) => t.toLowerCase())
2151
+ .sort()
2152
+ .join(",")}`;
2153
+ const idx = list.findIndex((tpl) => {
2154
+ const rowSig = `${tpl.name.toLowerCase()}|${tpl.personalProject === true ? "p" : "w"}|${tpl.project ? tpl.project.toLowerCase() : ""}|${[...tpl.tags]
2155
+ .map((t) => t.toLowerCase())
2156
+ .sort()
2157
+ .join(",")}`;
2158
+ return rowSig === sig;
2159
+ });
2160
+ if (idx >= 0) {
2161
+ const prev = list[idx]!;
2162
+ const personalProject =
2163
+ incomingPersonal !== undefined ? incomingPersonal : prev.personalProject === true;
2164
+ list[idx] = {
2165
+ ...prev,
2166
+ name: name || prev.name,
2167
+ tags,
2168
+ project,
2169
+ personalProject,
2170
+ updatedAt: now,
2171
+ };
2172
+ } else {
2173
+ list.unshift({
2174
+ id: randomUUID(),
2175
+ name: name || tags.join(" "),
2176
+ tags,
2177
+ project,
2178
+ personalProject: incomingPersonal === true,
2179
+ createdAt: now,
2180
+ updatedAt: now,
2181
+ });
2182
+ }
2183
+ (p as Record<string, unknown>).taskTemplates = list.slice(0, 200);
2184
+ if (tags.length > 0) {
2185
+ mergeDiscoveredTagsIntoPayloadKnownTags(p, tags);
2186
+ }
2187
+ if (project) {
2188
+ const row = list[idx >= 0 ? idx : 0]!;
2189
+ if (row.personalProject === true) {
2190
+ mergeDiscoveredPersonalProjectIntoPayloadKnownPersonalProjects(p, project, true);
2191
+ } else {
2192
+ mergeDiscoveredProjectIntoPayloadKnownProjects(p, project);
2193
+ }
2194
+ }
2195
+ return finish();
2196
+ }
2197
+ case "removeTaskTemplate": {
2198
+ const rawId =
2199
+ typeof body.taskTemplateId === "string" ? body.taskTemplateId.trim() : "";
2200
+ if (!rawId) {
2201
+ return finish();
2202
+ }
2203
+ const list = readTaskTemplates(p).filter((t) => t.id !== rawId);
2204
+ (p as Record<string, unknown>).taskTemplates = list;
2205
+ return finish();
2206
+ }
1346
2207
  case "removeUserKnownTag": {
1347
2208
  const raw = typeof body.tag === "string" ? body.tag : "";
1348
2209
  const lk = normalizeTagKey(raw).toLowerCase();
@@ -1369,6 +2230,11 @@ export async function dispatchKronosysAction(body: Record<string, unknown>): Pro
1369
2230
  p.projectDescriptions = pd;
1370
2231
  return finish();
1371
2232
  }
2233
+ case "rememberKnownProject": {
2234
+ const raw = typeof body.name === "string" ? body.name : "";
2235
+ mergeDiscoveredProjectIntoPayloadKnownProjects(p, raw);
2236
+ return finish();
2237
+ }
1372
2238
  case "includeTagFromSuggestions": {
1373
2239
  const raw = typeof body.tag === "string" ? body.tag : "";
1374
2240
  const lk = normalizeTagKey(raw).toLowerCase();
@@ -1401,6 +2267,63 @@ export async function dispatchKronosysAction(body: Record<string, unknown>): Pro
1401
2267
  purgeProjectEverywhere(p, raw);
1402
2268
  return finish();
1403
2269
  }
2270
+ case "renameTagMetadata": {
2271
+ const source = typeof body.sourceTag === "string" ? body.sourceTag : "";
2272
+ const target = typeof body.targetTag === "string" ? body.targetTag : "";
2273
+ renameTagEverywhere(p, source, target, tagNormOpts);
2274
+ return finish();
2275
+ }
2276
+ case "renameProjectMetadata": {
2277
+ const source = typeof body.sourceName === "string" ? body.sourceName : "";
2278
+ const target = typeof body.targetName === "string" ? body.targetName : "";
2279
+ const personalOnly = body.personalOnly === true;
2280
+ renameProjectEverywhere(p, source, target, { personalOnly });
2281
+ return finish();
2282
+ }
2283
+ case "transformKnownTagScope": {
2284
+ const sourceRaw = typeof body.sourceTag === "string" ? body.sourceTag : "";
2285
+ const targetRaw = typeof body.targetTag === "string" ? body.targetTag : "";
2286
+ const mode: "move" | "copy" = body.mode === "copy" ? "copy" : "move";
2287
+ const source = normalizeTagKey(sourceRaw);
2288
+ const target = normalizeTagKey(targetRaw);
2289
+ if (!source || !target || source.toLowerCase() === target.toLowerCase()) {
2290
+ return finish();
2291
+ }
2292
+ transformTaskTagsInPayload(p, source, target, mode, tagNormOpts);
2293
+ const sourceL = source.toLowerCase();
2294
+ const targetL = target.toLowerCase();
2295
+ const known = [...((p.knownTags || []) as string[])];
2296
+ if (!known.some((t) => normalizeTagKey(t).toLowerCase() === targetL)) {
2297
+ known.push(target);
2298
+ }
2299
+ p.knownTags = mode === "move" ? known.filter((t) => normalizeTagKey(t).toLowerCase() !== sourceL) : known;
2300
+ const userKnown = [...((p.userKnownTags || []) as string[])];
2301
+ if (userKnown.some((t) => normalizeTagKey(t).toLowerCase() === sourceL)) {
2302
+ if (!userKnown.some((t) => normalizeTagKey(t).toLowerCase() === targetL)) {
2303
+ userKnown.push(target);
2304
+ }
2305
+ }
2306
+ p.userKnownTags =
2307
+ mode === "move" ? userKnown.filter((t) => normalizeTagKey(t).toLowerCase() !== sourceL) : userKnown;
2308
+ const excluded = [...((p.excludedSuggestionTags || []) as string[])];
2309
+ if (excluded.some((t) => normalizeTagKey(t).toLowerCase() === sourceL)) {
2310
+ if (!excluded.some((t) => normalizeTagKey(t).toLowerCase() === targetL)) {
2311
+ excluded.push(target);
2312
+ }
2313
+ }
2314
+ p.excludedSuggestionTags =
2315
+ mode === "move" ? excluded.filter((t) => normalizeTagKey(t).toLowerCase() !== sourceL) : excluded;
2316
+ const td = { ...(asRecord(p.tagDescriptions) ?? {}) } as Record<string, string>;
2317
+ const sourceDesc = typeof td[sourceL] === "string" ? td[sourceL] : "";
2318
+ if (sourceDesc && !td[targetL]) {
2319
+ td[targetL] = sourceDesc;
2320
+ }
2321
+ if (mode === "move") {
2322
+ delete td[sourceL];
2323
+ }
2324
+ p.tagDescriptions = td;
2325
+ return finish();
2326
+ }
1404
2327
  case "fetchRemoteIssues":
1405
2328
  return await dispatchFetchRemoteIssues(p, body);
1406
2329
  case "pushSessionToMongo":