@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
@@ -35,16 +35,20 @@ import { reportingNav } from "@/lib/reportingStrings";
35
35
  import { settingsCopy, type SettingsCopy } from "@/lib/settingsCopy";
36
36
  import {
37
37
  appShellHeaderClassName,
38
- appShellHeaderToolRowClassName,
38
+ appShellHeaderTitleMetaRowClassName,
39
+ appShellHeaderToolbarClassName,
39
40
  } from "@/lib/appShellHeaderClasses";
41
+ import { AppShellCommandCenterPlaceholder } from "@/components/dashboard/AppShellCommandCenterPlaceholder";
42
+ import { AppShellHeaderSessionMeta } from "@/components/dashboard/AppShellHeaderSessionMeta";
43
+ import { AppShellHeaderWallClock } from "@/components/dashboard/AppShellHeaderWallClock";
40
44
  import { workspaceFolderPathStrings } from "@/lib/legacyEditorPayloadKeys";
41
45
  import { showWorkspaceFoldersEmptyMessage } from "@/lib/usageProfile";
42
46
  import { readDashboardUse24HourClockFromCfg } from "@/lib/dashboardClockFormat";
43
47
  import {
44
48
  DASHBOARD_TIME_ZONE_SELECT_OPTIONS,
45
- isValidIanaTimeZone,
46
49
  readDashboardTimeZoneFromCfg,
47
50
  } from "@/lib/dashboardTimeZone";
51
+ import { SESSION_NAME_TEMPLATE_CFG_MAX_LEN } from "@/lib/formatSessionNameTemplate";
48
52
  import { DEFAULT_WORKSPACE_LOC_EXCLUDED_DIRECTORY_NAMES } from "@/lib/workspaceLocDefaults";
49
53
  import { LanguageMenu } from "@/components/dashboard/LanguageMenu";
50
54
  import { ScrollToTopFab } from "@/components/dashboard/ScrollToTopFab";
@@ -65,7 +69,7 @@ import {
65
69
  writeDashboardColumnHintsDismissed,
66
70
  } from "@/lib/dashboardColumnHintsStorage";
67
71
  import { KronosysTimePopoverField } from "@/components/dashboard/KronosysTimePopoverField";
68
- import { Search } from "lucide-react";
72
+ import { FileText, Search } from "lucide-react";
69
73
  import { withDashboardSessionParam } from "@/lib/dashboardSessionNav";
70
74
 
71
75
  type LiveShape = { language?: string };
@@ -102,6 +106,8 @@ type SettingsForm = {
102
106
  dashboardDisplayTimeZone: string;
103
107
  /** `true` : affichage 24 h ; `false` : 12 h (AM/PM). */
104
108
  dashboardUse24HourClock: boolean;
109
+ /** Gabarit du nom des nouvelles sessions (`%UUID`, strftime POSIX). */
110
+ dashboardDefaultSessionNameTemplate: string;
105
111
  /** Seuil d’alerte (heures) pour la durée murale affichée dans le tableau de bord. */
106
112
  dashboardSessionDurationAlertHours: number;
107
113
  dashboardShowKronoFocusInHeader: boolean;
@@ -233,6 +239,10 @@ function cfgToForm(cfg: Record<string, unknown> | undefined): SettingsForm {
233
239
  dashboardWebUrl: str(cfg?.dashboardWebUrl, "http://kronosys:5555"),
234
240
  dashboardDisplayTimeZone: readDashboardTimeZoneFromCfg(cfg),
235
241
  dashboardUse24HourClock: readDashboardUse24HourClockFromCfg(cfg),
242
+ dashboardDefaultSessionNameTemplate: str(
243
+ cfg?.dashboardDefaultSessionNameTemplate,
244
+ "",
245
+ ).slice(0, SESSION_NAME_TEMPLATE_CFG_MAX_LEN),
236
246
  dashboardSessionDurationAlertHours: Math.min(
237
247
  Math.max(num(cfg?.dashboardSessionDurationAlertHours, 24), 1),
238
248
  8760,
@@ -425,8 +435,8 @@ function archivedTaskCount(s: SessionListEntry): number {
425
435
  Array.isArray(s.activeTasks) && s.activeTasks.length > 0
426
436
  ? s.activeTasks.length
427
437
  : s.activeTask
428
- ? 1
429
- : 0;
438
+ ? 1
439
+ : 0;
430
440
  return listed + nActive;
431
441
  }
432
442
 
@@ -514,6 +524,14 @@ function SettingsToc({
514
524
  m(s.sectionTagsProjects, "settings-tags-projects") ||
515
525
  m(s.tocSubTagsByProject, "settings-tags-by-project");
516
526
  const showTagsBlock = showTagsGlobal || showTagsProjects || showTagsByProject;
527
+ const showTaskTemplates = m(
528
+ s.sectionTaskTemplates,
529
+ "settings-task-templates",
530
+ "template",
531
+ "modèle",
532
+ "task template",
533
+ "gabarit",
534
+ );
517
535
  const showDanger = m(s.sectionDangerZone, "settings-danger-zone");
518
536
  const showArchived = m(
519
537
  s.sectionArchivedSessions,
@@ -578,7 +596,21 @@ function SettingsToc({
578
596
  "settings-session-duration-alert",
579
597
  "duration",
580
598
  );
581
- const showWebBlock = showWebTour || showWebDuration;
599
+ const showWebDefaultSessionName =
600
+ m(s.sectionWeb, "settings-web", "dashboard", "api") ||
601
+ m(
602
+ s.tocSubDefaultSessionNameTemplate,
603
+ "settings-default-session-name",
604
+ "default session",
605
+ "session name",
606
+ "%UUID",
607
+ "strftime",
608
+ "gabarit",
609
+ "nom de session",
610
+ ) ||
611
+ m(s.dashboardDefaultSessionNameTemplate, "template", "uuid");
612
+ const showWebBlock =
613
+ showWebTour || showWebDuration || showWebDefaultSessionName;
582
614
  const showLicenses = m(
583
615
  s.licensesPageLink,
584
616
  "/licenses",
@@ -600,6 +632,7 @@ function SettingsToc({
600
632
  showCollection ||
601
633
  showHistory ||
602
634
  showTagsBlock ||
635
+ showTaskTemplates ||
603
636
  showDanger ||
604
637
  showArchived ||
605
638
  showExport ||
@@ -615,7 +648,7 @@ function SettingsToc({
615
648
  return (
616
649
  <nav
617
650
  aria-label={s.tocNavAriaLabel}
618
- className="rounded-xl border border-zinc-200 bg-zinc-50/90 p-4 dark:border-zinc-800 dark:bg-zinc-900/40"
651
+ className="rounded-xl border border-zinc-200 bg-zinc-50/90 p-4 lg:max-h-[calc(100dvh-14rem)] lg:overflow-y-scroll lg:overscroll-contain dark:border-zinc-800 dark:bg-zinc-900/40"
619
652
  >
620
653
  <p className="mb-3 text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-500">
621
654
  {s.tocHeading}
@@ -740,6 +773,13 @@ function SettingsToc({
740
773
  ) : null}
741
774
  </li>
742
775
  ) : null}
776
+ {showTaskTemplates ? (
777
+ <li>
778
+ <a href="#settings-task-templates" className={linkClass}>
779
+ {s.sectionTaskTemplates}
780
+ </a>
781
+ </li>
782
+ ) : null}
743
783
  {showGit ? (
744
784
  <li>
745
785
  <a href="#settings-git" className={linkClass}>
@@ -782,7 +822,7 @@ function SettingsToc({
782
822
  <a href="#settings-web" className={linkClass}>
783
823
  {s.sectionWeb}
784
824
  </a>
785
- {showWebTour || showWebDuration ? (
825
+ {showWebTour || showWebDuration || showWebDefaultSessionName ? (
786
826
  <ul className={subListClass}>
787
827
  {showWebTour ? (
788
828
  <>
@@ -812,6 +852,16 @@ function SettingsToc({
812
852
  </li>
813
853
  </>
814
854
  ) : null}
855
+ {showWebDefaultSessionName ? (
856
+ <li>
857
+ <a
858
+ href="#settings-default-session-name"
859
+ className={linkClass}
860
+ >
861
+ {s.tocSubDefaultSessionNameTemplate}
862
+ </a>
863
+ </li>
864
+ ) : null}
815
865
  {showWebDuration ? (
816
866
  <li>
817
867
  <a
@@ -902,6 +952,10 @@ function SettingsPageContent() {
902
952
  >(null);
903
953
  const [clearHistoryConfirmOpen, setClearHistoryConfirmOpen] = useState(false);
904
954
  const [clearHistoryBusy, setClearHistoryBusy] = useState(false);
955
+ const [restoreConfirmOpen, setRestoreConfirmOpen] = useState(false);
956
+ const [restoreBusy, setRestoreBusy] = useState(false);
957
+ const [restoreFile, setRestoreFile] = useState<File | null>(null);
958
+ const restoreFileInputRef = useRef<HTMLInputElement | null>(null);
905
959
  const [settingsTocFilter, setSettingsTocFilter] = useState("");
906
960
  const [settingsTourOpen, setSettingsTourOpen] = useState(false);
907
961
 
@@ -1137,8 +1191,8 @@ function SettingsPageContent() {
1137
1191
  const gitlabTokenStatusText = gitlabTokenStoredFlag
1138
1192
  ? s.gitlabTokenStoredYes
1139
1193
  : gitlabTokenFromEnvFlag
1140
- ? s.gitlabTokenFromEnvYes
1141
- : s.gitlabTokenNone;
1194
+ ? s.gitlabTokenFromEnvYes
1195
+ : s.gitlabTokenNone;
1142
1196
 
1143
1197
  const headerApiError =
1144
1198
  lang === "fr"
@@ -1507,6 +1561,56 @@ function SettingsPageContent() {
1507
1561
  }
1508
1562
  }, [refresh, router]);
1509
1563
 
1564
+ const restoreBackup = useCallback(async () => {
1565
+ if (!restoreFile) {
1566
+ setSettingsDialogAlert(s.dangerRestoreNoFile);
1567
+ return;
1568
+ }
1569
+ setRestoreBusy(true);
1570
+ try {
1571
+ const fd = new FormData();
1572
+ fd.set("file", restoreFile);
1573
+ const format = restoreFile.name.toLowerCase().endsWith(".json")
1574
+ ? "json"
1575
+ : "sqlite";
1576
+ fd.set("format", format);
1577
+ const res = await fetch("/api/restore", {
1578
+ method: "POST",
1579
+ body: fd,
1580
+ });
1581
+ const text = await res.text();
1582
+ let data: Record<string, unknown> = {};
1583
+ try {
1584
+ data = text ? (JSON.parse(text) as Record<string, unknown>) : {};
1585
+ } catch {
1586
+ data = {};
1587
+ }
1588
+ if (!res.ok || data.ok !== true) {
1589
+ const detail =
1590
+ typeof data.detail === "string"
1591
+ ? data.detail
1592
+ : typeof data.error === "string"
1593
+ ? data.error
1594
+ : text || `HTTP ${res.status}`;
1595
+ setSettingsDialogAlert(
1596
+ `${s.dangerRestoreFailed.replace("{detail}", detail)}`,
1597
+ );
1598
+ return;
1599
+ }
1600
+ setRestoreConfirmOpen(false);
1601
+ setRestoreFile(null);
1602
+ if (restoreFileInputRef.current) {
1603
+ restoreFileInputRef.current.value = "";
1604
+ }
1605
+ await refresh({ preserveForm: false, routerInvalidate: true });
1606
+ setSettingsDialogAlert(
1607
+ format === "json" ? s.dangerRestoreDoneJson : s.dangerRestoreDoneSqlite,
1608
+ );
1609
+ } finally {
1610
+ setRestoreBusy(false);
1611
+ }
1612
+ }, [refresh, restoreFile, s]);
1613
+
1510
1614
  const applyResetToDefaults = useCallback(async () => {
1511
1615
  setResetDefaultsBusy(true);
1512
1616
  setSettingsAck(null);
@@ -1529,7 +1633,7 @@ function SettingsPageContent() {
1529
1633
  return (
1530
1634
  <div className="min-h-screen bg-zinc-100 text-zinc-900 dark:bg-zinc-900 dark:text-zinc-100">
1531
1635
  <header className={appShellHeaderClassName}>
1532
- <div className={appShellHeaderToolRowClassName}>
1636
+ <div className={appShellHeaderTitleMetaRowClassName}>
1533
1637
  <div className="flex min-w-0 flex-col gap-1">
1534
1638
  <Link
1535
1639
  href={withDashboardSessionParam("/", dashboardSessionNavId)}
@@ -1545,12 +1649,18 @@ function SettingsPageContent() {
1545
1649
  <AppVersionStamp ariaLabelTemplate={dt.appVersionAriaLabel} />
1546
1650
  </p>
1547
1651
  </div>
1548
- <div className="flex flex-wrap items-center gap-1.5">
1652
+ <AppShellHeaderSessionMeta payload={payload} dt={dt} />
1653
+ </div>
1654
+ <div className="flex w-full justify-end">
1655
+ <div className={appShellHeaderToolbarClassName}>
1656
+ <AppShellHeaderWallClock lang={lang} dt={dt} />
1657
+ <AppShellCommandCenterPlaceholder />
1549
1658
  <AppShellRouteNav
1550
1659
  current="settings"
1551
1660
  labels={nav}
1552
1661
  navAriaLabel={dt.appShellRouteNavAria}
1553
1662
  dashboardSessionId={dashboardSessionNavId}
1663
+ reserveGlobalPauseSlot
1554
1664
  />
1555
1665
  <ThemeToggle lang={lang} />
1556
1666
  <PageRefreshButton
@@ -2656,8 +2766,8 @@ function SettingsPageContent() {
2656
2766
  gitlabTestFeedback.tone === "ok"
2657
2767
  ? "mt-2 text-sm text-emerald-600 dark:text-emerald-400"
2658
2768
  : gitlabTestFeedback.tone === "warn"
2659
- ? "mt-2 text-sm text-amber-600 dark:text-amber-400"
2660
- : "mt-2 text-sm text-red-600 dark:text-red-400"
2769
+ ? "mt-2 text-sm text-amber-600 dark:text-amber-400"
2770
+ : "mt-2 text-sm text-red-600 dark:text-red-400"
2661
2771
  }
2662
2772
  >
2663
2773
  {gitlabTestFeedback.text}
@@ -2773,8 +2883,8 @@ function SettingsPageContent() {
2773
2883
  {mongoRemoteStatus === "connected"
2774
2884
  ? s.mongoStatusConnected
2775
2885
  : mongoRemoteStatus === "failed"
2776
- ? s.mongoStatusFailed
2777
- : s.mongoStatusPending}
2886
+ ? s.mongoStatusFailed
2887
+ : s.mongoStatusPending}
2778
2888
  {" · "}
2779
2889
  {mongoUriConfigured ? (
2780
2890
  <span className="text-emerald-400/90">
@@ -2810,8 +2920,8 @@ function SettingsPageContent() {
2810
2920
  mongoTestFeedback.tone === "ok"
2811
2921
  ? "text-emerald-400"
2812
2922
  : mongoTestFeedback.tone === "warn"
2813
- ? "text-amber-400"
2814
- : "text-red-300"
2923
+ ? "text-amber-400"
2924
+ : "text-red-300"
2815
2925
  }`}
2816
2926
  >
2817
2927
  {mongoTestFeedback.text}
@@ -3168,6 +3278,36 @@ function SettingsPageContent() {
3168
3278
  </label>
3169
3279
  </div>
3170
3280
  </Field>
3281
+
3282
+ <div
3283
+ id="settings-default-session-name"
3284
+ className="scroll-mt-24"
3285
+ >
3286
+ <Field
3287
+ label={s.dashboardDefaultSessionNameTemplate}
3288
+ description={s.dashboardDefaultSessionNameTemplateDesc}
3289
+ >
3290
+ <input
3291
+ type="text"
3292
+ className={inputClass(formLocked)}
3293
+ value={form.dashboardDefaultSessionNameTemplate}
3294
+ onChange={(e) =>
3295
+ update(
3296
+ "dashboardDefaultSessionNameTemplate",
3297
+ e.target.value.slice(
3298
+ 0,
3299
+ SESSION_NAME_TEMPLATE_CFG_MAX_LEN,
3300
+ ),
3301
+ )
3302
+ }
3303
+ disabled={formLocked}
3304
+ spellCheck={false}
3305
+ autoComplete="off"
3306
+ placeholder="Session %F %UUID"
3307
+ aria-label={s.dashboardDefaultSessionNameTemplate}
3308
+ />
3309
+ </Field>
3310
+ </div>
3171
3311
  </div>
3172
3312
  <div
3173
3313
  id="settings-dashboard-tour"
@@ -3319,6 +3459,12 @@ function SettingsPageContent() {
3319
3459
  archivedPageRows.map((sess) => {
3320
3460
  const label =
3321
3461
  sess.sessionName?.trim() || sess.sessionId.slice(0, 8);
3462
+ const noteRaw =
3463
+ typeof sess.sessionNote === "string"
3464
+ ? sess.sessionNote.trim()
3465
+ : "";
3466
+ const notePreview = noteRaw.replaceAll(/\s+/g, " ");
3467
+ const hasSessionNote = notePreview.length > 0;
3322
3468
  const n = archivedTaskCount(sess);
3323
3469
  const busy = archivedRestoreBusyId === sess.sessionId;
3324
3470
  return (
@@ -3333,6 +3479,19 @@ function SettingsPageContent() {
3333
3479
  <div className="text-[0.7rem] text-zinc-500">
3334
3480
  {n} {sessionTaskCountNoun(n, dt, lang)}
3335
3481
  </div>
3482
+ {hasSessionNote ? (
3483
+ <div
3484
+ className="mt-1 inline-flex min-w-0 max-w-full items-center gap-1 text-[0.7rem] text-zinc-500"
3485
+ title={notePreview}
3486
+ >
3487
+ <FileText
3488
+ size={12}
3489
+ className="shrink-0"
3490
+ aria-hidden
3491
+ />
3492
+ <span className="truncate">{notePreview}</span>
3493
+ </div>
3494
+ ) : null}
3336
3495
  </div>
3337
3496
  <button
3338
3497
  type="button"
@@ -3406,6 +3565,64 @@ function SettingsPageContent() {
3406
3565
  {s.sectionDangerZone}
3407
3566
  </h2>
3408
3567
  <div className="max-w-xl rounded-xl border border-red-300/90 bg-red-50/80 p-4 dark:border-red-900/55 dark:bg-red-950/30">
3568
+ <h3 className="text-sm font-semibold text-red-900 dark:text-red-200">
3569
+ {s.dangerBackupRestoreTitle}
3570
+ </h3>
3571
+ <p className="mt-2 text-xs leading-relaxed text-red-900/85 dark:text-red-200/85">
3572
+ {s.dangerBackupRestoreIntro}
3573
+ </p>
3574
+ <div className="mt-3 flex flex-wrap gap-2">
3575
+ <a
3576
+ href="/api/backup?format=json"
3577
+ download
3578
+ className="rounded-lg border border-red-500/70 px-3 py-1.5 text-xs font-medium text-red-900 hover:bg-red-100 dark:text-red-200 dark:hover:bg-red-900/40"
3579
+ >
3580
+ {s.dangerClearHistoryBackupJson}
3581
+ </a>
3582
+ <a
3583
+ href="/api/backup?format=csv-zip"
3584
+ download
3585
+ className="rounded-lg border border-red-500/70 px-3 py-1.5 text-xs font-medium text-red-900 hover:bg-red-100 dark:text-red-200 dark:hover:bg-red-900/40"
3586
+ >
3587
+ {s.dangerClearHistoryBackupCsvZip}
3588
+ </a>
3589
+ <a
3590
+ href="/api/backup?format=sqlite"
3591
+ download
3592
+ className="rounded-lg border border-red-500/70 px-3 py-1.5 text-xs font-medium text-red-900 hover:bg-red-100 dark:text-red-200 dark:hover:bg-red-900/40"
3593
+ >
3594
+ {s.dangerClearHistoryBackupSqlite}
3595
+ </a>
3596
+ </div>
3597
+ <div className="mt-4 space-y-2">
3598
+ <label className="block text-xs font-medium text-red-900/90 dark:text-red-200/90">
3599
+ {s.dangerRestorePickFile}
3600
+ </label>
3601
+ <input
3602
+ ref={restoreFileInputRef}
3603
+ type="file"
3604
+ accept=".json,.sqlite,.db,application/json,application/vnd.sqlite3"
3605
+ disabled={restoreBusy || clearHistoryBusy}
3606
+ className="block w-full rounded-lg border border-red-300 bg-white px-3 py-2 text-xs text-zinc-800 file:mr-3 file:rounded file:border-0 file:bg-red-100 file:px-2.5 file:py-1 file:text-xs file:font-medium file:text-red-900 dark:border-red-900/60 dark:bg-zinc-950 dark:text-zinc-200 dark:file:bg-red-900/35 dark:file:text-red-200"
3607
+ onChange={(e) => {
3608
+ const f = e.target.files?.[0] ?? null;
3609
+ setRestoreFile(f);
3610
+ }}
3611
+ />
3612
+ <p className="text-[11px] leading-relaxed text-red-900/80 dark:text-red-300/80">
3613
+ {s.dangerRestoreHint}
3614
+ </p>
3615
+ <button
3616
+ type="button"
3617
+ disabled={!restoreFile || restoreBusy || clearHistoryBusy}
3618
+ className="rounded-lg border border-red-600/80 bg-red-700 px-3 py-1.5 text-xs font-medium text-white hover:bg-red-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-red-500 dark:bg-red-800/90 dark:hover:bg-red-700"
3619
+ onClick={() => setRestoreConfirmOpen(true)}
3620
+ >
3621
+ {restoreBusy
3622
+ ? s.dangerRestoreBusy
3623
+ : s.dangerRestoreButton}
3624
+ </button>
3625
+ </div>
3409
3626
  <h3 className="text-sm font-semibold text-red-900 dark:text-red-200">
3410
3627
  {s.dangerClearHistoryTitle}
3411
3628
  </h3>
@@ -3450,6 +3667,23 @@ function SettingsPageContent() {
3450
3667
  onCancel={() => setMongoResyncConfirmOpen(false)}
3451
3668
  onConfirm={() => void executeMongoResync()}
3452
3669
  />
3670
+ <DashboardConfirmModal
3671
+ open={restoreConfirmOpen}
3672
+ title={s.dangerRestoreButton}
3673
+ message={s.dangerRestoreConfirm}
3674
+ cancelLabel={s.dialogCancelBtn}
3675
+ confirmLabel={s.dialogConfirmBtn}
3676
+ confirmVariant="danger"
3677
+ pending={restoreBusy}
3678
+ onCancel={() => {
3679
+ if (!restoreBusy) {
3680
+ setRestoreConfirmOpen(false);
3681
+ }
3682
+ }}
3683
+ onConfirm={() => {
3684
+ void restoreBackup();
3685
+ }}
3686
+ />
3453
3687
  <DashboardConfirmModal
3454
3688
  open={clearHistoryConfirmOpen}
3455
3689
  title={s.dangerClearHistoryTitle}
package/bin/kronosys.mjs CHANGED
@@ -5,21 +5,49 @@
5
5
  * Supported commands:
6
6
  * start -> next start -p 5555 (production, default; auto-build if needed)
7
7
  * dev -> next dev --webpack -H kronosys -p 5555
8
+ * --version, -v, version -> print installed Kronosys version
8
9
  */
9
10
 
10
11
  import { spawn } from "node:child_process";
11
- import { existsSync } from "node:fs";
12
+ import { cpSync, existsSync, mkdirSync, readFileSync } from "node:fs";
13
+ import { homedir } from "node:os";
12
14
  import { dirname, resolve } from "node:path";
13
15
  import { fileURLToPath } from "node:url";
14
16
 
15
17
  const __dirname = dirname(fileURLToPath(import.meta.url));
16
18
  const projectRoot = resolve(__dirname, "..");
19
+ const packageJson = JSON.parse(
20
+ readFileSync(resolve(projectRoot, "package.json"), "utf8"),
21
+ );
22
+ const packageVersion = packageJson.version;
23
+ const inNodeModules =
24
+ projectRoot.includes("/node_modules/") ||
25
+ projectRoot.includes("\\node_modules\\");
26
+ const isWindows = process.platform === "win32";
27
+ const npmBin = process.platform === "win32" ? "npm.cmd" : "npm";
28
+ const npxBin = process.platform === "win32" ? "npx.cmd" : "npx";
29
+ const psBin = process.platform === "win32" ? "powershell.exe" : null;
17
30
 
18
31
  const [, , command = "start", ...rest] = process.argv;
19
32
 
33
+ if (command === "--version" || command === "-v" || command === "version") {
34
+ console.log(packageVersion);
35
+ process.exit(0);
36
+ }
37
+
20
38
  const commands = {
21
- start: ["npx", "next", "start", "-p", "5555", ...rest],
22
- dev: ["npx", "next", "dev", "--webpack", "-H", "kronosys", "-p", "5555", ...rest],
39
+ start: [npxBin, "next", "start", "-p", "5555", ...rest],
40
+ dev: [
41
+ npxBin,
42
+ "next",
43
+ "dev",
44
+ "--webpack",
45
+ "-H",
46
+ "kronosys",
47
+ "-p",
48
+ "5555",
49
+ ...rest,
50
+ ],
23
51
  };
24
52
 
25
53
  if (!commands[command]) {
@@ -28,14 +56,34 @@ if (!commands[command]) {
28
56
  process.exit(1);
29
57
  }
30
58
 
31
- function runCommand(bin, args, env) {
59
+ function runCommand(bin, args, env, cwd) {
32
60
  return new Promise((resolvePromise, rejectPromise) => {
33
- const child = spawn(bin, args, {
34
- cwd: projectRoot,
35
- stdio: "inherit",
36
- shell: false,
37
- env,
38
- });
61
+ const windowsArg = (arg) => `'${String(arg).replaceAll("'", "''")}'`;
62
+
63
+ const child = isWindows
64
+ ? spawn(
65
+ psBin,
66
+ [
67
+ "-NoLogo",
68
+ "-NoProfile",
69
+ "-ExecutionPolicy",
70
+ "Bypass",
71
+ "-Command",
72
+ "& " + [bin, ...args].map(windowsArg).join(" "),
73
+ ],
74
+ {
75
+ cwd,
76
+ stdio: "inherit",
77
+ shell: false,
78
+ env,
79
+ },
80
+ )
81
+ : spawn(bin, args, {
82
+ cwd,
83
+ stdio: "inherit",
84
+ shell: false,
85
+ env,
86
+ });
39
87
 
40
88
  child.on("error", (err) => {
41
89
  rejectPromise(err);
@@ -47,23 +95,100 @@ function runCommand(bin, args, env) {
47
95
  });
48
96
  }
49
97
 
98
+ function prepareRuntimeRoot() {
99
+ const runtimeRoot = resolve(
100
+ homedir(),
101
+ ".kronosys",
102
+ "runtime",
103
+ String(packageVersion),
104
+ );
105
+ const runtimePackageJson = resolve(runtimeRoot, "package.json");
106
+
107
+ if (!existsSync(runtimePackageJson)) {
108
+ mkdirSync(runtimeRoot, { recursive: true });
109
+ cpSync(projectRoot, runtimeRoot, {
110
+ recursive: true,
111
+ force: true,
112
+ filter: (src) => {
113
+ const normalized = src.replaceAll("\\", "/");
114
+ const rel = normalized
115
+ .replace(projectRoot.replaceAll("\\", "/"), "")
116
+ .replace(/^\/+/, "");
117
+ if (!rel) return true;
118
+ if (rel === ".next" || rel.startsWith(".next/")) return false;
119
+ if (rel === "node_modules" || rel.startsWith("node_modules/"))
120
+ return false;
121
+ return true;
122
+ },
123
+ });
124
+ }
125
+
126
+ return runtimeRoot;
127
+ }
128
+
50
129
  async function main() {
130
+ const runRoot = inNodeModules ? prepareRuntimeRoot() : projectRoot;
51
131
  const env = {
52
132
  ...process.env,
53
133
  NODE_ENV: command === "dev" ? "development" : "production",
54
134
  };
55
135
 
56
- // Production mode requires a Next build. For global installs, build once automatically.
57
- if (command === "start" && !existsSync(resolve(projectRoot, ".next", "BUILD_ID"))) {
58
- console.log("[kronosys] No production build found. Running `next build`...");
59
- const buildCode = await runCommand("npx", ["next", "build", "--webpack"], env);
136
+ // Global installs live under node_modules; install runtime deps in copied runtime dir.
137
+ if (inNodeModules && !existsSync(resolve(runRoot, "node_modules"))) {
138
+ console.log("[kronosys] Installing runtime dependencies...");
139
+ const installCode = await runCommand(
140
+ npmBin,
141
+ ["install", "--omit=dev", "--ignore-scripts", "--no-audit", "--no-fund"],
142
+ {
143
+ ...env,
144
+ HUSKY: "0",
145
+ npm_config_cache:
146
+ process.env.npm_config_cache ?? resolve(homedir(), ".npm"),
147
+ },
148
+ runRoot,
149
+ );
150
+ if (installCode !== 0) {
151
+ process.exit(installCode);
152
+ }
153
+
154
+ // Ensure native bindings are present for the current OS/ABI.
155
+ const rebuildCode = await runCommand(
156
+ npmBin,
157
+ ["rebuild", "better-sqlite3", "--no-audit", "--no-fund"],
158
+ {
159
+ ...env,
160
+ HUSKY: "0",
161
+ npm_config_cache:
162
+ process.env.npm_config_cache ?? resolve(homedir(), ".npm"),
163
+ },
164
+ runRoot,
165
+ );
166
+ if (rebuildCode !== 0) {
167
+ process.exit(rebuildCode);
168
+ }
169
+ }
170
+
171
+ // Production mode requires a Next build. Build once on first run.
172
+ if (
173
+ command === "start" &&
174
+ !existsSync(resolve(runRoot, ".next", "BUILD_ID"))
175
+ ) {
176
+ console.log(
177
+ "[kronosys] No production build found. Running `next build`...",
178
+ );
179
+ const buildCode = await runCommand(
180
+ npxBin,
181
+ ["next", "build", "--webpack"],
182
+ env,
183
+ runRoot,
184
+ );
60
185
  if (buildCode !== 0) {
61
186
  process.exit(buildCode);
62
187
  }
63
188
  }
64
189
 
65
190
  const [bin, ...args] = commands[command];
66
- const code = await runCommand(bin, args, env);
191
+ const code = await runCommand(bin, args, env, runRoot);
67
192
  process.exit(code);
68
193
  }
69
194
 
@@ -53,6 +53,8 @@ export function KronosysPayloadProvider({ children }: { children: ReactNode }) {
53
53
  return true;
54
54
  } catch (e: unknown) {
55
55
  lastRefreshOkRef.current = false;
56
+ latestPayloadRef.current = null;
57
+ setPayload(null);
56
58
  setError(e instanceof Error ? e.message : String(e));
57
59
  return false;
58
60
  }
@@ -0,0 +1,18 @@
1
+ "use client";
2
+
3
+ import type { ReactNode } from "react";
4
+ import { usePathname } from "next/navigation";
5
+
6
+ type Props = Readonly<{
7
+ children: ReactNode;
8
+ }>;
9
+
10
+ export function RouteTransition({ children }: Props) {
11
+ const pathname = usePathname();
12
+
13
+ return (
14
+ <div key={pathname} className="kronosys-route-transition-in">
15
+ {children}
16
+ </div>
17
+ );
18
+ }