@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
@@ -200,16 +200,48 @@ export function ensureTaskParentDurationCoversSubtasksMs(task: Record<string, un
200
200
  }
201
201
 
202
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";
203
+ export const SUBTASK_TIMER_STARTED_AT = "subtaskTimerStartedAt";
204
204
 
205
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
206
  export const MAIN_TIMER_SEGMENT_STARTED_AT = "mainTimerSegmentStartedAt";
207
+ /** Clé ISO : heure de fin planifiée d'une tâche en cours. */
208
+ export const TASK_SCHEDULED_END_AT = "scheduledEndAt";
209
+ /** Liste chronologique des segments de suivi (laps) d'une tâche. */
210
+ export const TASK_TIMER_LAPS = "taskTimerLaps";
211
+ /** Clé ISO : début du lap courant (stable entre les matérialisations de segment). */
212
+ export const TASK_CURRENT_LAP_STARTED_AT = "taskCurrentLapStartedAt";
213
+
214
+ function ensureTaskTimerLaps(task: Record<string, unknown>): Record<string, unknown>[] {
215
+ if (!Array.isArray(task[TASK_TIMER_LAPS])) {
216
+ task[TASK_TIMER_LAPS] = [];
217
+ }
218
+ return task[TASK_TIMER_LAPS] as Record<string, unknown>[];
219
+ }
220
+
221
+ function appendTaskTimerLap(task: Record<string, unknown>, startMs: number, endMs: number): void {
222
+ if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs < startMs) {
223
+ return;
224
+ }
225
+ const durationMs = Math.max(0, Math.floor(endMs - startMs));
226
+ if (durationMs < 1000) {
227
+ return;
228
+ }
229
+ const laps = ensureTaskTimerLaps(task);
230
+ laps.push({
231
+ startTime: new Date(startMs).toISOString(),
232
+ endTime: new Date(endMs).toISOString(),
233
+ durationMs,
234
+ });
235
+ }
207
236
 
208
237
  /**
209
238
  * Vide le segment de minuteur principal en cours : ajoute le temps écoulé à `durationMs`.
210
239
  * Sans effet s’un suivi sous-tâche est actif (le parent ne cumule pas en parallèle).
211
240
  */
212
- export function flushMainTimerSegmentOnTask(task: Record<string, unknown>): void {
241
+ export function flushMainTimerSegmentOnTask(
242
+ task: Record<string, unknown>,
243
+ flushClockEndMs: number = Date.now(),
244
+ ): void {
213
245
  if (String(task.activeSubtaskTimerId ?? "").trim() !== "") {
214
246
  return;
215
247
  }
@@ -222,11 +254,18 @@ export function flushMainTimerSegmentOnTask(task: Record<string, unknown>): void
222
254
  delete task[MAIN_TIMER_SEGMENT_STARTED_AT];
223
255
  return;
224
256
  }
225
- const elapsed = Math.max(0, Math.floor(Date.now() - startedMs));
257
+ const elapsed = Math.max(0, Math.floor(flushClockEndMs - startedMs));
226
258
  const parentPrev =
227
259
  typeof task.durationMs === "number" && Number.isFinite(task.durationMs) ? Number(task.durationMs) : 0;
228
260
  task.durationMs = Math.floor(parentPrev + elapsed);
261
+ const lapRaw = task[TASK_CURRENT_LAP_STARTED_AT];
262
+ const lapStartedMs =
263
+ typeof lapRaw === "string" && lapRaw.trim() !== ""
264
+ ? Date.parse(lapRaw)
265
+ : Number.NaN;
266
+ appendTaskTimerLap(task, Number.isFinite(lapStartedMs) ? lapStartedMs : startedMs, flushClockEndMs);
229
267
  delete task[MAIN_TIMER_SEGMENT_STARTED_AT];
268
+ delete task[TASK_CURRENT_LAP_STARTED_AT];
230
269
  ensureTaskParentDurationCoversSubtasksMs(task);
231
270
  }
232
271
 
@@ -243,9 +282,15 @@ export function ensureMainTimerSegmentForRunningTask(task: Record<string, unknow
243
282
  }
244
283
  const raw = task[MAIN_TIMER_SEGMENT_STARTED_AT];
245
284
  if (typeof raw === "string" && raw.trim() !== "") {
285
+ const lapRaw = task[TASK_CURRENT_LAP_STARTED_AT];
286
+ if (!(typeof lapRaw === "string" && lapRaw.trim() !== "")) {
287
+ task[TASK_CURRENT_LAP_STARTED_AT] = raw;
288
+ }
246
289
  return;
247
290
  }
248
- task[MAIN_TIMER_SEGMENT_STARTED_AT] = new Date().toISOString();
291
+ const nowIso = new Date().toISOString();
292
+ task[MAIN_TIMER_SEGMENT_STARTED_AT] = nowIso;
293
+ task[TASK_CURRENT_LAP_STARTED_AT] = nowIso;
249
294
  }
250
295
 
251
296
  /**
@@ -255,7 +300,10 @@ export function ensureMainTimerSegmentForRunningTask(task: Record<string, unknow
255
300
  * — rattrapage via `ensureTaskParentDurationCoversSubtasksMs` en fin de flush.
256
301
  * Efface ensuite `activeSubtaskTimerId` et l’horodatage de segment.
257
302
  */
258
- export function flushSubtaskTimerOnTask(task: Record<string, unknown>): void {
303
+ export function flushSubtaskTimerOnTask(
304
+ task: Record<string, unknown>,
305
+ flushClockEndMs: number = Date.now(),
306
+ ): void {
259
307
  const activeId = String(task.activeSubtaskTimerId ?? "").trim();
260
308
  if (!activeId) {
261
309
  return;
@@ -272,7 +320,7 @@ export function flushSubtaskTimerOnTask(task: Record<string, unknown>): void {
272
320
  delete task[SUBTASK_TIMER_STARTED_AT];
273
321
  return;
274
322
  }
275
- const elapsed = Math.max(0, Date.now() - startedMs);
323
+ const elapsed = Math.max(0, flushClockEndMs - startedMs);
276
324
  const st = ensureSubtasks(task).find((s) => String(s.id) === activeId);
277
325
  if (st && typeof st === "object" && !Array.isArray(st)) {
278
326
  const row = st as Record<string, unknown>;
@@ -282,8 +330,15 @@ export function flushSubtaskTimerOnTask(task: Record<string, unknown>): void {
282
330
  const parentPrev =
283
331
  typeof task.durationMs === "number" && Number.isFinite(task.durationMs) ? Number(task.durationMs) : 0;
284
332
  task.durationMs = Math.floor(parentPrev + elapsed);
333
+ const lapRaw = task[TASK_CURRENT_LAP_STARTED_AT];
334
+ const lapStartedMs =
335
+ typeof lapRaw === "string" && lapRaw.trim() !== ""
336
+ ? Date.parse(lapRaw)
337
+ : Number.NaN;
338
+ appendTaskTimerLap(task, Number.isFinite(lapStartedMs) ? lapStartedMs : startedMs, flushClockEndMs);
285
339
  task.activeSubtaskTimerId = null;
286
340
  delete task[SUBTASK_TIMER_STARTED_AT];
341
+ delete task[TASK_CURRENT_LAP_STARTED_AT];
287
342
  ensureTaskParentDurationCoversSubtasksMs(task);
288
343
  }
289
344
 
@@ -291,7 +346,8 @@ export function finishTaskInSession(
291
346
  sess: Record<string, unknown>,
292
347
  taskId: string,
293
348
  shouldCommit: boolean,
294
- tagNormOpts?: TaskTagsStorageNormalizeOpts
349
+ tagNormOpts?: TaskTagsStorageNormalizeOpts,
350
+ options?: { completionInstantMs?: number },
295
351
  ): boolean {
296
352
  const id = taskId;
297
353
  let task: Record<string, unknown> | null = null;
@@ -322,10 +378,16 @@ export function finishTaskInSession(
322
378
  }
323
379
  }
324
380
 
325
- flushSubtaskTimerOnTask(task);
326
- flushMainTimerSegmentOnTask(task);
381
+ const flushEndMs =
382
+ typeof options?.completionInstantMs === "number" && Number.isFinite(options.completionInstantMs)
383
+ ? options.completionInstantMs
384
+ : Date.now();
385
+
386
+ flushSubtaskTimerOnTask(task, flushEndMs);
387
+ flushMainTimerSegmentOnTask(task, flushEndMs);
327
388
  task.isDone = true;
328
- task.endTime = new Date().toISOString();
389
+ task.endTime = new Date(flushEndMs).toISOString();
390
+ delete task[TASK_SCHEDULED_END_AT];
329
391
  task.shouldCommit = shouldCommit;
330
392
  task.manualTaskTimerPaused = false;
331
393
  for (const st of ensureSubtasks(task)) {
@@ -380,7 +442,13 @@ export function deleteTaskInSession(sess: Record<string, unknown>, taskId: strin
380
442
  export function updateTaskInSession(
381
443
  sess: Record<string, unknown>,
382
444
  taskId: string,
383
- patch: { name?: string; tags?: string[]; project?: string | null },
445
+ patch: {
446
+ name?: string;
447
+ tags?: string[];
448
+ project?: string | null;
449
+ personalProject?: boolean;
450
+ note?: string;
451
+ },
384
452
  tagNormOpts?: TaskTagsStorageNormalizeOpts
385
453
  ): boolean {
386
454
  const task = findTaskRecord(sess, taskId);
@@ -396,13 +464,23 @@ export function updateTaskInSession(
396
464
  if (patch.project !== undefined) {
397
465
  task.project = patch.project;
398
466
  }
467
+ if (patch.personalProject !== undefined) {
468
+ task.personalProject = patch.personalProject === true;
469
+ }
470
+ if (typeof patch.note === "string") {
471
+ task.note = patch.note;
472
+ }
399
473
  return true;
400
474
  }
401
475
 
402
476
  export function updateTaskStartTimeInSession(
403
477
  sess: Record<string, unknown>,
404
478
  taskId: string,
405
- startTimeIso: string
479
+ startTimeIso: string,
480
+ options?: {
481
+ durationAdjustMode?: "keep" | "manual" | "from_bounds";
482
+ manualDurationMs?: number;
483
+ }
406
484
  ): boolean {
407
485
  const task = findTaskRecord(sess, taskId);
408
486
  if (!task) {
@@ -412,20 +490,39 @@ export function updateTaskStartTimeInSession(
412
490
  if (!Number.isFinite(startMs)) {
413
491
  return false;
414
492
  }
493
+ const boundEndRaw = typeof task.endTime === "string" ? task.endTime : "";
494
+ const boundEndMs = Date.parse(boundEndRaw);
495
+ if (Number.isFinite(boundEndMs) && startMs > boundEndMs) {
496
+ return false;
497
+ }
415
498
  const nowMs = Date.now();
499
+ const prevDurationMs =
500
+ typeof task.durationMs === "number" && Number.isFinite(task.durationMs) ? Math.max(0, Math.floor(task.durationMs)) : 0;
501
+ const mode = options?.durationAdjustMode ?? "from_bounds";
502
+ const manualMs =
503
+ typeof options?.manualDurationMs === "number" && Number.isFinite(options.manualDurationMs)
504
+ ? Math.max(0, Math.floor(options.manualDurationMs))
505
+ : null;
416
506
  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) {
507
+ const endMs = boundEndMs;
508
+ if (mode === "keep") {
509
+ task.durationMs = prevDurationMs;
510
+ } else if (mode === "manual" && manualMs !== null) {
511
+ task.durationMs = manualMs;
512
+ } else if (Number.isFinite(endMs) && endMs >= startMs) {
420
513
  task.durationMs = Math.max(0, Math.floor(endMs - startMs));
421
- return true;
514
+ } else {
515
+ task.durationMs = Math.max(0, Math.floor(nowMs - startMs));
422
516
  }
423
- task.durationMs = Math.max(0, Math.floor(nowMs - startMs));
424
517
  const subtaskRunning = String(task.activeSubtaskTimerId ?? "").trim() !== "";
425
518
  if (!subtaskRunning && task.manualTaskTimerPaused !== true && task.isDone !== true) {
426
519
  task[MAIN_TIMER_SEGMENT_STARTED_AT] = new Date(nowMs).toISOString();
427
520
  }
428
- ensureTaskParentDurationCoversSubtasksMs(task);
521
+ // Ne pas réimposer la somme des sous-tâches après un recalcul explicite (bornes ou manuel) :
522
+ // sinon la durée choisie par l’utilisateur·rice est écrasée.
523
+ if (mode === "keep") {
524
+ ensureTaskParentDurationCoversSubtasksMs(task);
525
+ }
429
526
  return true;
430
527
  }
431
528
 
@@ -436,7 +533,11 @@ export function updateTaskStartTimeInSession(
436
533
  export function updateTaskEndTimeInSession(
437
534
  sess: Record<string, unknown>,
438
535
  taskId: string,
439
- endTimeIso: string
536
+ endTimeIso: string,
537
+ options?: {
538
+ durationAdjustMode?: "keep" | "manual" | "from_bounds";
539
+ manualDurationMs?: number;
540
+ }
440
541
  ): boolean {
441
542
  const task = findTaskRecord(sess, taskId);
442
543
  if (!task || task.isDone !== true) {
@@ -451,9 +552,55 @@ export function updateTaskEndTimeInSession(
451
552
  if (!Number.isFinite(startMs) || endMs < startMs) {
452
553
  return false;
453
554
  }
555
+ const prevDurationMs =
556
+ typeof task.durationMs === "number" && Number.isFinite(task.durationMs) ? Math.max(0, Math.floor(task.durationMs)) : 0;
557
+ const mode = options?.durationAdjustMode ?? "from_bounds";
558
+ const manualMs =
559
+ typeof options?.manualDurationMs === "number" && Number.isFinite(options.manualDurationMs)
560
+ ? Math.max(0, Math.floor(options.manualDurationMs))
561
+ : null;
454
562
  task.endTime = new Date(endMs).toISOString();
455
- task.durationMs = Math.max(0, Math.floor(endMs - startMs));
456
- ensureTaskParentDurationCoversSubtasksMs(task);
563
+ if (mode === "keep") {
564
+ task.durationMs = prevDurationMs;
565
+ } else if (mode === "manual" && manualMs !== null) {
566
+ task.durationMs = manualMs;
567
+ } else {
568
+ task.durationMs = Math.max(0, Math.floor(endMs - startMs));
569
+ }
570
+ // Même logique que `updateTaskStartTimeInSession` : après « depuis début/fin » ou « manuel »,
571
+ // ne pas forcer le parent ≥ somme des sous-tâches (sinon le recalcul est annulé).
572
+ if (mode === "keep") {
573
+ ensureTaskParentDurationCoversSubtasksMs(task);
574
+ }
575
+ return true;
576
+ }
577
+
578
+ /**
579
+ * Programme une heure de fin pour une tâche en cours ; si l'échéance est déjà passée, termine la tâche à cet instant.
580
+ */
581
+ export function scheduleTaskEndTimeInSession(
582
+ sess: Record<string, unknown>,
583
+ taskId: string,
584
+ endTimeIso: string,
585
+ nowMs = Date.now()
586
+ ): boolean {
587
+ const task = findTaskRecord(sess, taskId);
588
+ if (!task || task.isDone === true) {
589
+ return false;
590
+ }
591
+ const endMs = Date.parse(endTimeIso);
592
+ if (!Number.isFinite(endMs)) {
593
+ return false;
594
+ }
595
+ const startRaw = typeof task.startTime === "string" ? task.startTime : "";
596
+ const startMs = Date.parse(startRaw);
597
+ if (!Number.isFinite(startMs) || endMs < startMs) {
598
+ return false;
599
+ }
600
+ if (endMs <= nowMs) {
601
+ return finishTaskInSession(sess, taskId, false, undefined, { completionInstantMs: endMs });
602
+ }
603
+ task[TASK_SCHEDULED_END_AT] = new Date(endMs).toISOString();
457
604
  return true;
458
605
  }
459
606
 
@@ -536,7 +683,9 @@ export function setActiveSubtaskTimerInSession(
536
683
  prepareSessionForSubtaskTimer(sess, String(taskId));
537
684
  flushSubtaskTimerOnTask(task);
538
685
  task.activeSubtaskTimerId = sid;
539
- task[SUBTASK_TIMER_STARTED_AT] = new Date().toISOString();
686
+ const nowIso = new Date().toISOString();
687
+ task[SUBTASK_TIMER_STARTED_AT] = nowIso;
688
+ task[TASK_CURRENT_LAP_STARTED_AT] = nowIso;
540
689
  task.manualTaskTimerPaused = false;
541
690
  return true;
542
691
  }
@@ -612,28 +761,43 @@ export function addHistoricalTaskToSession(
612
761
  name: string;
613
762
  tags: string[];
614
763
  project?: string | null;
764
+ personalProject?: boolean;
765
+ note?: string;
615
766
  durationMs: number;
616
767
  startTime: string;
617
768
  endTime: string;
618
769
  },
619
770
  newId: string,
620
771
  tagNormOpts?: TaskTagsStorageNormalizeOpts
621
- ): void {
772
+ ): boolean {
773
+ const startMs = Date.parse(typeof input.startTime === "string" ? input.startTime : "");
774
+ const endMs = Date.parse(typeof input.endTime === "string" ? input.endTime : "");
775
+ if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs <= startMs) {
776
+ return false;
777
+ }
778
+ const spanMs = endMs - startMs;
779
+ const dmRaw = typeof input.durationMs === "number" && Number.isFinite(input.durationMs) ? Math.floor(input.durationMs) : NaN;
780
+ if (!Number.isFinite(dmRaw) || dmRaw <= 0 || dmRaw > spanMs) {
781
+ return false;
782
+ }
622
783
  const task: Record<string, unknown> = {
623
784
  id: newId,
624
785
  name: input.name.trim(),
625
786
  startTime: input.startTime,
626
787
  endTime: input.endTime,
627
- durationMs: input.durationMs,
788
+ durationMs: dmRaw,
628
789
  isDone: true,
629
790
  kronoFocusCycles: 0,
630
791
  tags: normalizeTaskTagsForStorage(input.tags, tagNormOpts),
631
792
  project: input.project ?? null,
793
+ personalProject: input.personalProject === true,
794
+ note: typeof input.note === "string" ? input.note : "",
632
795
  subtasks: [],
633
796
  };
634
797
  const tasks = getTasksArray(sess);
635
798
  tasks.push(task);
636
799
  sess.tasks = tasks;
800
+ return true;
637
801
  }
638
802
 
639
803
  function eachSessionRecord(p: KronosysUpdatePayload, fn: (sess: Record<string, unknown>) => void): void {
@@ -715,3 +879,152 @@ export function purgeProjectEverywhere(p: KronosysUpdatePayload, rawName: string
715
879
  }
716
880
  });
717
881
  }
882
+
883
+ export function renameTagEverywhere(
884
+ p: KronosysUpdatePayload,
885
+ sourceRawTag: string,
886
+ targetRawTag: string,
887
+ tagNormOpts?: TaskTagsStorageNormalizeOpts
888
+ ): void {
889
+ const source = normalizeTagKey(sourceRawTag);
890
+ const target = normalizeTagKey(targetRawTag);
891
+ if (!source || !target || source.toLowerCase() === target.toLowerCase()) {
892
+ return;
893
+ }
894
+ const sourceL = source.toLowerCase();
895
+ const targetL = target.toLowerCase();
896
+ const remapUnique = (list: string[]): string[] => {
897
+ const out: string[] = [];
898
+ const seen = new Set<string>();
899
+ for (const raw of list) {
900
+ const n = normalizeTagKey(raw);
901
+ if (!n) {
902
+ continue;
903
+ }
904
+ const lk = n.toLowerCase();
905
+ const mapped = lk === sourceL ? target : n;
906
+ const mk = mapped.toLowerCase();
907
+ if (seen.has(mk)) {
908
+ continue;
909
+ }
910
+ seen.add(mk);
911
+ out.push(mapped);
912
+ }
913
+ return out;
914
+ };
915
+ p.knownTags = remapUnique((p.knownTags || []) as string[]);
916
+ p.userKnownTags = remapUnique((p.userKnownTags || []) as string[]);
917
+ p.excludedSuggestionTags = remapUnique((p.excludedSuggestionTags || []) as string[]);
918
+ const td = { ...(asRecord(p.tagDescriptions) ?? {}) } as Record<string, string>;
919
+ const sourceDesc = typeof td[sourceL] === "string" ? td[sourceL] : "";
920
+ if (sourceDesc && !td[targetL]) {
921
+ td[targetL] = sourceDesc;
922
+ }
923
+ delete td[sourceL];
924
+ p.tagDescriptions = td;
925
+ eachSessionRecord(p, (sess) => {
926
+ for (const t of [...getActiveTasksArray(sess), ...getTasksArray(sess)]) {
927
+ if (!Array.isArray(t.tags)) {
928
+ continue;
929
+ }
930
+ const mapped = (t.tags as string[]).map((x) => {
931
+ const n = normalizeTagKey(x);
932
+ return n.toLowerCase() === sourceL ? target : n;
933
+ });
934
+ t.tags = normalizeTaskTagsForStorage(mapped, tagNormOpts);
935
+ }
936
+ const at = asRecord(sess.activeTask);
937
+ if (!at || !Array.isArray(at.tags)) {
938
+ return;
939
+ }
940
+ const mapped = (at.tags as string[]).map((x) => {
941
+ const n = normalizeTagKey(x);
942
+ return n.toLowerCase() === sourceL ? target : n;
943
+ });
944
+ at.tags = normalizeTaskTagsForStorage(mapped, tagNormOpts);
945
+ });
946
+ }
947
+
948
+ export function renameProjectEverywhere(
949
+ p: KronosysUpdatePayload,
950
+ sourceRawName: string,
951
+ targetRawName: string,
952
+ opts?: { personalOnly?: boolean },
953
+ ): void {
954
+ const source = normalizeProjectKey(sourceRawName);
955
+ const target = normalizeProjectKey(targetRawName);
956
+ if (!source || !target || source.toLowerCase() === target.toLowerCase()) {
957
+ return;
958
+ }
959
+ const sourceL = source.toLowerCase();
960
+ const targetL = target.toLowerCase();
961
+ const personalOnly = opts?.personalOnly === true;
962
+
963
+ if (personalOnly) {
964
+ const known: string[] = [];
965
+ const seen = new Set<string>();
966
+ for (const raw of (((p as Record<string, unknown>).knownPersonalProjects || []) as string[])) {
967
+ const n = normalizeProjectKey(raw);
968
+ if (!n) {
969
+ continue;
970
+ }
971
+ const mapped = n.toLowerCase() === sourceL ? target : n;
972
+ const lk = mapped.toLowerCase();
973
+ if (seen.has(lk)) {
974
+ continue;
975
+ }
976
+ seen.add(lk);
977
+ known.push(mapped);
978
+ }
979
+ (p as Record<string, unknown>).knownPersonalProjects = known;
980
+ } else {
981
+ const known: string[] = [];
982
+ const seen = new Set<string>();
983
+ for (const raw of (p.knownProjects || []) as string[]) {
984
+ const n = normalizeProjectKey(raw);
985
+ if (!n) {
986
+ continue;
987
+ }
988
+ const mapped = n.toLowerCase() === sourceL ? target : n;
989
+ const lk = mapped.toLowerCase();
990
+ if (seen.has(lk)) {
991
+ continue;
992
+ }
993
+ seen.add(lk);
994
+ known.push(mapped);
995
+ }
996
+ p.knownProjects = known;
997
+ }
998
+
999
+ const pd = { ...(asRecord(p.projectDescriptions) ?? {}) } as Record<string, string>;
1000
+ const sourceDesc = typeof pd[sourceL] === "string" ? pd[sourceL] : "";
1001
+ if (sourceDesc && !pd[targetL]) {
1002
+ pd[targetL] = sourceDesc;
1003
+ }
1004
+ delete pd[sourceL];
1005
+ p.projectDescriptions = pd;
1006
+ eachSessionRecord(p, (sess) => {
1007
+ for (const t of [...getActiveTasksArray(sess), ...getTasksArray(sess)]) {
1008
+ const proj = typeof t.project === "string" ? normalizeProjectKey(t.project) : "";
1009
+ if (proj.toLowerCase() !== sourceL) {
1010
+ continue;
1011
+ }
1012
+ const isPersonal = t.personalProject === true;
1013
+ if (personalOnly !== isPersonal) {
1014
+ continue;
1015
+ }
1016
+ t.project = target;
1017
+ }
1018
+ const at = asRecord(sess.activeTask);
1019
+ if (!at) {
1020
+ return;
1021
+ }
1022
+ const proj = typeof at.project === "string" ? normalizeProjectKey(at.project) : "";
1023
+ if (proj.toLowerCase() === sourceL) {
1024
+ const isPersonal = at.personalProject === true;
1025
+ if (personalOnly === isPersonal) {
1026
+ at.project = target;
1027
+ }
1028
+ }
1029
+ });
1030
+ }
package/server/db.ts CHANGED
@@ -1,11 +1,12 @@
1
- import Database from "better-sqlite3";
1
+ import { DatabaseSync } from "node:sqlite";
2
2
  import * as path from "node:path";
3
3
 
4
4
  import { ensureDataDirectory, resetDataDirectoryCache } from "@/lib/dataDir";
5
+ import { ensureCoreSchema } from "./dbSchema";
5
6
 
6
7
  const DB_NAME = "kronosys.sqlite";
7
8
 
8
- let db: Database.Database | null = null;
9
+ let db: DatabaseSync | null = null;
9
10
 
10
11
  /** Ferme la connexion SQLite (tests ou changement de `TRACE_DATA_DIR` / cache de résolution). */
11
12
  export function resetSqliteConnection(): void {
@@ -20,23 +21,14 @@ export function resetSqliteConnection(): void {
20
21
  resetDataDirectoryCache();
21
22
  }
22
23
 
23
- export function getSqlite(): Database.Database {
24
+ export function getSqlite(): DatabaseSync {
24
25
  if (db) {
25
26
  return db;
26
27
  }
27
28
  const dir = ensureDataDirectory();
28
29
  const file = path.join(dir, DB_NAME);
29
- db = new Database(file);
30
- db.pragma("journal_mode = WAL");
31
- db.exec(`
32
- CREATE TABLE IF NOT EXISTS app_meta (
33
- key TEXT PRIMARY KEY NOT NULL,
34
- value TEXT NOT NULL
35
- );
36
- CREATE TABLE IF NOT EXISTS kv_store (
37
- k TEXT PRIMARY KEY NOT NULL,
38
- v TEXT NOT NULL
39
- );
40
- `);
30
+ db = new DatabaseSync(file);
31
+ db.exec("PRAGMA journal_mode = WAL;");
32
+ ensureCoreSchema(db);
41
33
  return db;
42
34
  }
@@ -0,0 +1,24 @@
1
+ import type { DatabaseSync } from "node:sqlite";
2
+
3
+ export function ensureCoreSchema(db: DatabaseSync): void {
4
+ db.exec(`
5
+ CREATE TABLE IF NOT EXISTS app_meta (
6
+ key TEXT PRIMARY KEY NOT NULL,
7
+ value TEXT NOT NULL
8
+ );
9
+ CREATE TABLE IF NOT EXISTS kv_store (
10
+ k TEXT PRIMARY KEY NOT NULL,
11
+ v TEXT NOT NULL
12
+ );
13
+ CREATE TABLE IF NOT EXISTS user_action_logs (
14
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
15
+ created_at TEXT NOT NULL,
16
+ action_type TEXT NOT NULL,
17
+ ok INTEGER NOT NULL,
18
+ source_ip TEXT,
19
+ user_agent TEXT,
20
+ session_id TEXT,
21
+ payload_json TEXT
22
+ );
23
+ `);
24
+ }
@@ -70,6 +70,11 @@ export function defaultKronosysCfg(): Record<string, unknown> {
70
70
  dashboardDisplayTimeZone: "America/Toronto",
71
71
  /** Affichage des heures : `true` = 24 h, `false` = 12 h (AM/PM). */
72
72
  dashboardUse24HourClock: true,
73
+ /**
74
+ * Gabarit appliqué au libellé de session lors du `newSession` (motif façon POSIX strftime ; voir aussi `%UUID`).
75
+ * Chaîne vide = aucun nom par défaut (champ vide comme aujourd’hui).
76
+ */
77
+ dashboardDefaultSessionNameTemplate: "",
73
78
  scheduleEnabled: false,
74
79
  scheduleDays: [1, 2, 3, 4, 5],
75
80
  scheduleStartTime: "09:00",
@@ -6,17 +6,7 @@ import { getSqlite } from "./db";
6
6
  */
7
7
  const GITLAB_PAT_KV_KEY = "gitlab_secret_pat_v1";
8
8
 
9
- function ensureKv(): void {
10
- getSqlite().exec(`
11
- CREATE TABLE IF NOT EXISTS kv_store (
12
- k TEXT PRIMARY KEY NOT NULL,
13
- v TEXT NOT NULL
14
- );
15
- `);
16
- }
17
-
18
9
  export function readGitlabPatFromStore(): string {
19
- ensureKv();
20
10
  const row = getSqlite().prepare("SELECT v FROM kv_store WHERE k = ?").get(GITLAB_PAT_KV_KEY) as
21
11
  | { v: string }
22
12
  | undefined;
@@ -24,11 +14,9 @@ export function readGitlabPatFromStore(): string {
24
14
  }
25
15
 
26
16
  export function writeGitlabPatToStore(token: string): void {
27
- ensureKv();
28
17
  getSqlite().prepare("INSERT OR REPLACE INTO kv_store (k, v) VALUES (?, ?)").run(GITLAB_PAT_KV_KEY, token);
29
18
  }
30
19
 
31
20
  export function clearGitlabPatFromStore(): void {
32
- ensureKv();
33
21
  getSqlite().prepare("DELETE FROM kv_store WHERE k = ?").run(GITLAB_PAT_KV_KEY);
34
22
  }
@@ -0,0 +1,53 @@
1
+ import type { KronosysUpdatePayload } from "@/lib/kronosysApi";
2
+ import {
3
+ normalizeSessionEndReasonKind,
4
+ normalizeSessionEndReasonNote,
5
+ } from "@/lib/sessionEndReason";
6
+
7
+ import { asRecord } from "./actionTaskSession";
8
+
9
+ /** Copie l’instantané de la session live en tête de `history` (sans vider `current`). */
10
+ export function syncLiveIntoHistory(p: KronosysUpdatePayload): void {
11
+ const cur = asRecord(p.current);
12
+ if (!cur) {
13
+ return;
14
+ }
15
+ const sid = typeof cur.sessionId === "string" ? cur.sessionId.trim() : "";
16
+ if (!sid) {
17
+ return;
18
+ }
19
+ const hist = ([...(p.history || [])] as Record<string, unknown>[]).filter((h) => h.sessionId !== sid);
20
+ const snap: Record<string, unknown> = {
21
+ sessionId: sid,
22
+ sessionName: cur.sessionName ?? "",
23
+ sessionNote: cur.sessionNote ?? "",
24
+ savedAt: new Date().toISOString(),
25
+ createdAt: cur.createdAt ?? cur.startAt ?? null,
26
+ startAt: cur.startAt ?? null,
27
+ endAt: cur.endAt ?? null,
28
+ sessionDurationMinutes: cur.sessionDurationMinutes,
29
+ tasks: cur.tasks ?? [],
30
+ activeTasks: cur.activeTasks ?? [],
31
+ activeTask: cur.activeTask ?? null,
32
+ archived: cur.archived === true,
33
+ };
34
+ if (typeof cur.scheduledStartAt === "string" && cur.scheduledStartAt.trim() !== "") {
35
+ snap.scheduledStartAt = cur.scheduledStartAt;
36
+ }
37
+ if (
38
+ typeof cur.sessionStartOffsetMinutes === "number" &&
39
+ Number.isFinite(cur.sessionStartOffsetMinutes)
40
+ ) {
41
+ snap.sessionStartOffsetMinutes = cur.sessionStartOffsetMinutes;
42
+ }
43
+ const rk = normalizeSessionEndReasonKind(cur.sessionEndReasonKind);
44
+ if (rk) {
45
+ snap.sessionEndReasonKind = rk;
46
+ }
47
+ const rn = normalizeSessionEndReasonNote(cur.sessionEndReasonNote);
48
+ if (rn.length > 0) {
49
+ snap.sessionEndReasonNote = rn;
50
+ }
51
+ hist.unshift(snap);
52
+ p.history = hist;
53
+ }