@nightkatana/kronosys-app 1.0.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. package/README.md +81 -0
  2. package/app/api/action/route.ts +16 -0
  3. package/app/api/backup/route.ts +84 -0
  4. package/app/api/health/route.ts +22 -0
  5. package/app/api/state/route.ts +27 -0
  6. package/app/apple-icon.png +0 -0
  7. package/app/changelog/page.tsx +122 -0
  8. package/app/globals.css +210 -0
  9. package/app/guide/layout.tsx +11 -0
  10. package/app/guide/page.tsx +278 -0
  11. package/app/icon.png +0 -0
  12. package/app/layout.tsx +77 -0
  13. package/app/licenses/layout.tsx +11 -0
  14. package/app/licenses/page.tsx +246 -0
  15. package/app/manifest.ts +32 -0
  16. package/app/page.tsx +1610 -0
  17. package/app/reporting/page.tsx +2943 -0
  18. package/app/settings/layout.tsx +10 -0
  19. package/app/settings/page.tsx +3518 -0
  20. package/bin/kronosys.mjs +46 -0
  21. package/components/KronosysPackageVersionProvider.tsx +19 -0
  22. package/components/KronosysPayloadProvider.tsx +109 -0
  23. package/components/PwaRegister.tsx +25 -0
  24. package/components/SiteLegalFooter.tsx +21 -0
  25. package/components/ThemeProvider.tsx +78 -0
  26. package/components/dashboard/AppShellLiveSessionDrawer.tsx +394 -0
  27. package/components/dashboard/AppShellRouteNav.tsx +131 -0
  28. package/components/dashboard/AppVersionStamp.tsx +16 -0
  29. package/components/dashboard/DashboardCollapsibleSection.tsx +57 -0
  30. package/components/dashboard/DashboardColumnHintsBanner.tsx +159 -0
  31. package/components/dashboard/DashboardCommandCenter.tsx +470 -0
  32. package/components/dashboard/DashboardLangGateModal.tsx +118 -0
  33. package/components/dashboard/DashboardLoadingOverlay.tsx +42 -0
  34. package/components/dashboard/DashboardSimpleModal.tsx +337 -0
  35. package/components/dashboard/DashboardSuspenseFallback.tsx +52 -0
  36. package/components/dashboard/DashboardToastProvider.tsx +64 -0
  37. package/components/dashboard/DashboardTour.tsx +435 -0
  38. package/components/dashboard/DeferredDescriptionPopoverWrap.tsx +39 -0
  39. package/components/dashboard/DeleteSessionModal.tsx +130 -0
  40. package/components/dashboard/DescriptionTooltipPortaled.tsx +31 -0
  41. package/components/dashboard/GitIdentityQuickSetupModal.tsx +211 -0
  42. package/components/dashboard/HeaderIntegrationBadges.tsx +69 -0
  43. package/components/dashboard/InlineMetricHelpTrigger.tsx +102 -0
  44. package/components/dashboard/IssuePickerModal.tsx +168 -0
  45. package/components/dashboard/KronoFocusPanel.tsx +834 -0
  46. package/components/dashboard/KronosysDatetimePopoverField.tsx +357 -0
  47. package/components/dashboard/KronosysTimePopoverField.tsx +233 -0
  48. package/components/dashboard/LanguageMenu.tsx +123 -0
  49. package/components/dashboard/MongoMirrorSyncLine.tsx +57 -0
  50. package/components/dashboard/NewSessionScopeModal.tsx +410 -0
  51. package/components/dashboard/PageRefreshButton.tsx +130 -0
  52. package/components/dashboard/PlainHelpPopover.tsx +97 -0
  53. package/components/dashboard/ReportingPageToc.tsx +68 -0
  54. package/components/dashboard/ReportingTour.tsx +342 -0
  55. package/components/dashboard/SavedProjectPicker.tsx +92 -0
  56. package/components/dashboard/SavedTagPicker.tsx +115 -0
  57. package/components/dashboard/ScrollToTopFab.tsx +41 -0
  58. package/components/dashboard/SelectedSessionSidebarBlock.tsx +630 -0
  59. package/components/dashboard/SessionEndReasonEditor.tsx +114 -0
  60. package/components/dashboard/SessionListPanel.tsx +320 -0
  61. package/components/dashboard/SessionLocMetricsSection.tsx +128 -0
  62. package/components/dashboard/SettingsTagsProjectsSection.tsx +993 -0
  63. package/components/dashboard/SettingsTour.tsx +332 -0
  64. package/components/dashboard/TagPills.tsx +149 -0
  65. package/components/dashboard/TagsHelpTrigger.tsx +84 -0
  66. package/components/dashboard/TaskFocusPanel.tsx +1261 -0
  67. package/components/dashboard/TaskSessionLiveCard.tsx +832 -0
  68. package/components/dashboard/TaskSubtasksBlock.tsx +748 -0
  69. package/components/dashboard/ThemeToggle.test.tsx +26 -0
  70. package/components/dashboard/ThemeToggle.tsx +36 -0
  71. package/components/dashboard/UserGuideBodyText.tsx +62 -0
  72. package/components/dashboard/WorkspaceGitRepoCard.tsx +191 -0
  73. package/components/dashboard/taskFieldStyles.ts +139 -0
  74. package/components/dashboard/useAnchoredFloatingPortalStyle.ts +71 -0
  75. package/components/dashboard/useDescriptionPopoverAfterMs.ts +220 -0
  76. package/components/dashboard/useKronoFocusLiveSeconds.ts +36 -0
  77. package/components/dashboard/useSmoothStopwatchMs.ts +25 -0
  78. package/lib/appShellHeaderClasses.ts +12 -0
  79. package/lib/backupCsvExport.test.ts +149 -0
  80. package/lib/backupCsvExport.ts +392 -0
  81. package/lib/changelogCopy.ts +34 -0
  82. package/lib/concurrentTaskStartPreference.ts +29 -0
  83. package/lib/dashboardClockFormat.ts +13 -0
  84. package/lib/dashboardColumnChrome.ts +3 -0
  85. package/lib/dashboardColumnHintsStorage.ts +57 -0
  86. package/lib/dashboardCopy.ts +1831 -0
  87. package/lib/dashboardDetachedUrlHintStorage.ts +24 -0
  88. package/lib/dashboardGitIdentityBannerStorage.ts +36 -0
  89. package/lib/dashboardLangStorage.ts +72 -0
  90. package/lib/dashboardQuickSearch.ts +476 -0
  91. package/lib/dashboardQuickSearchQuery.test.ts +63 -0
  92. package/lib/dashboardQuickSearchQuery.ts +179 -0
  93. package/lib/dashboardSessionNav.ts +33 -0
  94. package/lib/dashboardShortcuts.ts +268 -0
  95. package/lib/dashboardTimeZone.ts +91 -0
  96. package/lib/dashboardTourStorage.ts +68 -0
  97. package/lib/dataDir.test.ts +87 -0
  98. package/lib/dataDir.ts +83 -0
  99. package/lib/devDataPreferenceFile.ts +55 -0
  100. package/lib/devDataRuntimeInfo.ts +34 -0
  101. package/lib/formatIsoShort.test.ts +46 -0
  102. package/lib/formatIsoShort.ts +29 -0
  103. package/lib/generatedUserChangelog.ts +34 -0
  104. package/lib/gitlabIssueSearch.ts +8 -0
  105. package/lib/kronoFocusDurationHistory.ts +71 -0
  106. package/lib/kronoFocusRhythm.test.ts +130 -0
  107. package/lib/kronoFocusRhythm.ts +46 -0
  108. package/lib/kronoFocusTimerUrgency.test.ts +74 -0
  109. package/lib/kronoFocusTimerUrgency.ts +24 -0
  110. package/lib/kronosysApi.ts +143 -0
  111. package/lib/legacyEditorPayloadKeys.ts +52 -0
  112. package/lib/legacyKronoFocusStorageKeys.test.ts +29 -0
  113. package/lib/legacyKronoFocusStorageKeys.ts +32 -0
  114. package/lib/licensesCopy.ts +128 -0
  115. package/lib/openPlainTextInNewTab.ts +49 -0
  116. package/lib/readKronosysPackageVersion.ts +10 -0
  117. package/lib/reportingAggregate.test.ts +325 -0
  118. package/lib/reportingAggregate.ts +819 -0
  119. package/lib/reportingDatePresets.ts +41 -0
  120. package/lib/reportingMetricHelp.ts +430 -0
  121. package/lib/reportingNonFinalIndicators.test.ts +157 -0
  122. package/lib/reportingNonFinalIndicators.ts +102 -0
  123. package/lib/reportingStrings.ts +491 -0
  124. package/lib/reportingTagWeekBreakdown.test.ts +141 -0
  125. package/lib/reportingTagWeekBreakdown.ts +181 -0
  126. package/lib/reportingWeekLayout.test.ts +239 -0
  127. package/lib/reportingWeekLayout.ts +313 -0
  128. package/lib/sessionAssiduity.test.ts +25 -0
  129. package/lib/sessionAssiduity.ts +33 -0
  130. package/lib/sessionEndReason.ts +55 -0
  131. package/lib/sessionEndWarnings.test.ts +200 -0
  132. package/lib/sessionEndWarnings.ts +125 -0
  133. package/lib/sessionListMerge.test.ts +101 -0
  134. package/lib/sessionListMerge.ts +70 -0
  135. package/lib/sessionTaskSidebarStats.test.ts +24 -0
  136. package/lib/sessionTaskSidebarStats.ts +54 -0
  137. package/lib/settingsCopy.ts +1276 -0
  138. package/lib/taskParsing.test.ts +153 -0
  139. package/lib/taskParsing.ts +737 -0
  140. package/lib/theme.ts +15 -0
  141. package/lib/translucentButtonClasses.ts +34 -0
  142. package/lib/usageProfile.test.ts +84 -0
  143. package/lib/usageProfile.ts +52 -0
  144. package/lib/userGuideCopy.ts +464 -0
  145. package/lib/workspaceLocDefaults.ts +21 -0
  146. package/next-env.d.ts +6 -0
  147. package/next.config.ts +15 -0
  148. package/package.json +87 -0
  149. package/postcss.config.mjs +12 -0
  150. package/public/apple-icon.png +0 -0
  151. package/public/favicon.ico +0 -0
  152. package/public/file.svg +1 -0
  153. package/public/globe.svg +1 -0
  154. package/public/icon-192.png +0 -0
  155. package/public/icon-512.png +0 -0
  156. package/public/icon.png +0 -0
  157. package/public/next.svg +1 -0
  158. package/public/sw.js +13 -0
  159. package/public/traceback.png +0 -0
  160. package/public/vercel.svg +1 -0
  161. package/public/window.svg +1 -0
  162. package/server/actionDispatch.test.ts +723 -0
  163. package/server/actionDispatch.ts +1476 -0
  164. package/server/actionTaskSession.test.ts +713 -0
  165. package/server/actionTaskSession.ts +717 -0
  166. package/server/db.ts +42 -0
  167. package/server/defaultCfg.ts +87 -0
  168. package/server/gitlabTokenStore.ts +34 -0
  169. package/server/kronoFocusHydrate.test.ts +142 -0
  170. package/server/kronoFocusHydrate.ts +69 -0
  171. package/server/kronoFocusMigrate.test.ts +53 -0
  172. package/server/kronoFocusMigrate.ts +78 -0
  173. package/server/mainTimerHydrate.test.ts +65 -0
  174. package/server/mainTimerHydrate.ts +53 -0
  175. package/server/payloadStore.test.ts +78 -0
  176. package/server/payloadStore.ts +83 -0
  177. package/server/sessionWallHydrate.test.ts +46 -0
  178. package/server/sessionWallHydrate.ts +88 -0
  179. package/tsconfig.json +41 -0
package/server/db.ts ADDED
@@ -0,0 +1,42 @@
1
+ import Database from "better-sqlite3";
2
+ import * as path from "node:path";
3
+
4
+ import { ensureDataDirectory, resetDataDirectoryCache } from "@/lib/dataDir";
5
+
6
+ const DB_NAME = "kronosys.sqlite";
7
+
8
+ let db: Database.Database | null = null;
9
+
10
+ /** Ferme la connexion SQLite (tests ou changement de `TRACE_DATA_DIR` / cache de résolution). */
11
+ export function resetSqliteConnection(): void {
12
+ if (db) {
13
+ try {
14
+ db.close();
15
+ } catch {
16
+ /* ignore */
17
+ }
18
+ db = null;
19
+ }
20
+ resetDataDirectoryCache();
21
+ }
22
+
23
+ export function getSqlite(): Database.Database {
24
+ if (db) {
25
+ return db;
26
+ }
27
+ const dir = ensureDataDirectory();
28
+ 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
+ `);
41
+ return db;
42
+ }
@@ -0,0 +1,87 @@
1
+ /** Valeurs par défaut du profil gestionnaire (SQLite, flux local). */
2
+ export function defaultKronosysCfg(): Record<string, unknown> {
3
+ return {
4
+ autoStart: true,
5
+ enabled: true,
6
+ usageProfile: "manager",
7
+ anonymizePaths: false,
8
+ flushIntervalSeconds: 15,
9
+ heartbeatIntervalSeconds: 120,
10
+ maxBufferedEvents: 500,
11
+ historyStoragePath: "",
12
+ historyPartition: "daily",
13
+ sessionExportFormat: "json",
14
+ csvDelimiter: ",",
15
+ sessionStartMode: "continue",
16
+ autoGitSync: false,
17
+ gitRemoteUrl: "",
18
+ /** Jeton enregistré localement (drapeau seul dans le payload). */
19
+ gitlabTokenStored: false,
20
+ /** Jeton fourni par GITLAB_TOKEN sur le processus serveur. */
21
+ gitlabTokenFromEnv: false,
22
+ /** Dernière exécution réussie de « Tester la connexion GitLab » dans les paramètres. */
23
+ gitlabApiVerified: false,
24
+ /** Instance GitLab (origine sans `/api/v4`). Vide = GITLAB_INSTANCE_URL ou https://gitlab.com. */
25
+ gitlabApiBaseUrl: "",
26
+ mongodbEnabled: false,
27
+ mongodbDatabaseName: "kronosys",
28
+ mongodbCollectionName: "sessions",
29
+ mongodbHost: "127.0.0.1",
30
+ mongodbPort: 27017,
31
+ mongodbUsername: "",
32
+ mongodbAuthSource: "admin",
33
+ showWelcomeOnStartup: true,
34
+ dashboardSessionDurationAlertHours: 24,
35
+ dashboardShowKronoFocusInHeader: true,
36
+ dashboardShowKronoFocusInTaskOps: true,
37
+ /** Lorsque `false`, les tâches sans étiquette explicite ne reçoivent pas l’étiquette réservée `default`. */
38
+ taskDefaultTagBucketEnabled: true,
39
+ /** Autorise la correction du `startTime` des tâches depuis le tableau de bord (live + historique). */
40
+ dashboardAllowTaskStartTimeEdit: true,
41
+ /** Autorise la correction du `startAt` des sessions depuis le tableau de bord (live + historique). */
42
+ dashboardAllowSessionStartTimeEdit: true,
43
+ /** Autorise la correction du `endTime` des tâches terminées depuis le tableau de bord. */
44
+ dashboardAllowTaskEndTimeEdit: true,
45
+ /** Autorise la correction du `endAt` des sessions terminées depuis le tableau de bord. */
46
+ dashboardAllowSessionEndTimeEdit: true,
47
+ workspaceLocExcludedDirectoryNames: [
48
+ "node_modules",
49
+ ".git",
50
+ "dist",
51
+ "out",
52
+ "build",
53
+ ".next",
54
+ "coverage",
55
+ "target",
56
+ "__pycache__",
57
+ ".venv",
58
+ "venv",
59
+ "vendor",
60
+ ".turbo",
61
+ ".cache",
62
+ ".idea",
63
+ ],
64
+ workspaceLocExcludedPathPatterns: [],
65
+ localPersistenceDriver: "sqlite",
66
+ localApiPort: 5566,
67
+ dashboardWebUrl: "http://kronosys:5555",
68
+ dashboardWebPort: 5555,
69
+ /** Fuseau IANA pour les dates du tableau de bord et le regroupement des rapports (défaut : heure de l’Est). */
70
+ dashboardDisplayTimeZone: "America/Toronto",
71
+ /** Affichage des heures : `true` = 24 h, `false` = 12 h (AM/PM). */
72
+ dashboardUse24HourClock: true,
73
+ scheduleEnabled: false,
74
+ scheduleDays: [1, 2, 3, 4, 5],
75
+ scheduleStartTime: "09:00",
76
+ scheduleEndTime: "17:00",
77
+ scheduleCreateDailySession: true,
78
+ scheduleTransferTasks: true,
79
+ schedulePauseTasks: true,
80
+ plannedSessions: [],
81
+ /**
82
+ * Mode `next dev` : lorsque `true`, utiliser le même répertoire `v4` qu’en production.
83
+ * Persistance : SQLite + fichier de démarrage (voir `lib/devDataPreferenceFile.ts`).
84
+ */
85
+ developmentUseProductionData: false,
86
+ };
87
+ }
@@ -0,0 +1,34 @@
1
+ import { getSqlite } from "./db";
2
+
3
+ /**
4
+ * Jeton GitLab PAT pour le tableau de bord Next local : stocké dans SQLite (table
5
+ * `kv_store`), jamais dans le JSON du payload exposé par `/api/state`.
6
+ */
7
+ const GITLAB_PAT_KV_KEY = "gitlab_secret_pat_v1";
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
+ export function readGitlabPatFromStore(): string {
19
+ ensureKv();
20
+ const row = getSqlite().prepare("SELECT v FROM kv_store WHERE k = ?").get(GITLAB_PAT_KV_KEY) as
21
+ | { v: string }
22
+ | undefined;
23
+ return typeof row?.v === "string" ? row.v : "";
24
+ }
25
+
26
+ export function writeGitlabPatToStore(token: string): void {
27
+ ensureKv();
28
+ getSqlite().prepare("INSERT OR REPLACE INTO kv_store (k, v) VALUES (?, ?)").run(GITLAB_PAT_KV_KEY, token);
29
+ }
30
+
31
+ export function clearGitlabPatFromStore(): void {
32
+ ensureKv();
33
+ getSqlite().prepare("DELETE FROM kv_store WHERE k = ?").run(GITLAB_PAT_KV_KEY);
34
+ }
@@ -0,0 +1,142 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import type { KronosysUpdatePayload } from "@/lib/kronosysApi";
4
+
5
+ import { hydrateKronoFocusInPayload } from "./kronoFocusHydrate";
6
+
7
+ function basePayload(pm: Record<string, unknown>): KronosysUpdatePayload {
8
+ return {
9
+ viewType: "dashboard",
10
+ current: {
11
+ sessionId: "s1",
12
+ kronoFocus: pm,
13
+ },
14
+ cfg: {},
15
+ } as KronosysUpdatePayload;
16
+ }
17
+
18
+ describe("hydrateKronoFocusInPayload", () => {
19
+ it("ancre une échéance pour un KronoFocus running sans deadline (données héritées)", () => {
20
+ const t0 = 1_000_000_000;
21
+ const p = basePayload({
22
+ mode: "work",
23
+ status: "running",
24
+ timeLeftSeconds: 100,
25
+ });
26
+ const dirty = hydrateKronoFocusInPayload(p, t0);
27
+ expect(dirty).toBe(true);
28
+ const pm = (p.current as Record<string, unknown>).kronoFocus as Record<string, unknown>;
29
+ expect(pm.kronoFocusDeadlineAtMs).toBe(t0 + 100_000);
30
+ expect(pm.timeLeftSeconds).toBe(100);
31
+ });
32
+
33
+ it("décompte les secondes restantes avant l’échéance", () => {
34
+ const t0 = 1_000_000_000;
35
+ const p = basePayload({
36
+ mode: "work",
37
+ status: "running",
38
+ timeLeftSeconds: 9999,
39
+ kronoFocusDeadlineAtMs: t0 + 60_000,
40
+ });
41
+ const dirty = hydrateKronoFocusInPayload(p, t0 + 30_000);
42
+ expect(dirty).toBe(false);
43
+ const pm = (p.current as Record<string, unknown>).kronoFocus as Record<string, unknown>;
44
+ expect(pm.timeLeftSeconds).toBe(30);
45
+ expect(pm.status).toBe("running");
46
+ });
47
+
48
+ it("à l’échéance d’un bloc travail : utilise shortBreakDurationSeconds du payload", () => {
49
+ const t0 = 1_000_000_000;
50
+ const p = basePayload({
51
+ mode: "work",
52
+ status: "running",
53
+ timeLeftSeconds: 10,
54
+ sessionsCompleted: 0,
55
+ shortBreakDurationSeconds: 10 * 60,
56
+ kronoFocusDeadlineAtMs: t0 + 10_000,
57
+ });
58
+ const dirty = hydrateKronoFocusInPayload(p, t0 + 10_000);
59
+ expect(dirty).toBe(true);
60
+ const pm = (p.current as Record<string, unknown>).kronoFocus as Record<string, unknown>;
61
+ expect(pm.status).toBe("paused");
62
+ expect(pm.mode).toBe("break");
63
+ expect(pm.timeLeftSeconds).toBe(10 * 60);
64
+ expect(pm.sessionsCompleted).toBe(1);
65
+ expect(pm.kronoFocusDeadlineAtMs).toBeUndefined();
66
+ });
67
+
68
+ it("à l’échéance d’un bloc travail : pause, pause courte, incrémente sessionsCompleted", () => {
69
+ const t0 = 1_000_000_000;
70
+ const p = basePayload({
71
+ mode: "work",
72
+ status: "running",
73
+ timeLeftSeconds: 10,
74
+ sessionsCompleted: 0,
75
+ kronoFocusDeadlineAtMs: t0 + 10_000,
76
+ });
77
+ const dirty = hydrateKronoFocusInPayload(p, t0 + 10_000);
78
+ expect(dirty).toBe(true);
79
+ const pm = (p.current as Record<string, unknown>).kronoFocus as Record<string, unknown>;
80
+ expect(pm.status).toBe("paused");
81
+ expect(pm.mode).toBe("break");
82
+ expect(pm.timeLeftSeconds).toBe(5 * 60);
83
+ expect(pm.sessionsCompleted).toBe(1);
84
+ expect(pm.kronoFocusDeadlineAtMs).toBeUndefined();
85
+ });
86
+
87
+ it("après 4 cycles travail : pause longue 15 min", () => {
88
+ const t0 = 1_000_000_000;
89
+ const p = basePayload({
90
+ mode: "work",
91
+ status: "running",
92
+ timeLeftSeconds: 1,
93
+ sessionsCompleted: 3,
94
+ kronoFocusDeadlineAtMs: t0 + 1000,
95
+ });
96
+ hydrateKronoFocusInPayload(p, t0 + 1000);
97
+ const pm = (p.current as Record<string, unknown>).kronoFocus as Record<string, unknown>;
98
+ expect(pm.sessionsCompleted).toBe(4);
99
+ expect(pm.mode).toBe("longBreak");
100
+ expect(pm.timeLeftSeconds).toBe(15 * 60);
101
+ });
102
+
103
+ it("après 4 cycles travail : utilise longBreakDurationSeconds du payload", () => {
104
+ const t0 = 1_000_000_000;
105
+ const p = basePayload({
106
+ mode: "work",
107
+ status: "running",
108
+ timeLeftSeconds: 1,
109
+ sessionsCompleted: 3,
110
+ longBreakDurationSeconds: 22 * 60,
111
+ kronoFocusDeadlineAtMs: t0 + 1000,
112
+ });
113
+ hydrateKronoFocusInPayload(p, t0 + 1000);
114
+ const pm = (p.current as Record<string, unknown>).kronoFocus as Record<string, unknown>;
115
+ expect(pm.sessionsCompleted).toBe(4);
116
+ expect(pm.mode).toBe("longBreak");
117
+ expect(pm.timeLeftSeconds).toBe(22 * 60);
118
+ });
119
+
120
+ it("fin d’une pause : retour au travail avec durée par défaut", () => {
121
+ const t0 = 1_000_000_000;
122
+ const p = basePayload({
123
+ mode: "break",
124
+ status: "running",
125
+ timeLeftSeconds: 1,
126
+ sessionsCompleted: 1,
127
+ kronoFocusDeadlineAtMs: t0 + 1000,
128
+ });
129
+ hydrateKronoFocusInPayload(p, t0 + 1000);
130
+ const pm = (p.current as Record<string, unknown>).kronoFocus as Record<string, unknown>;
131
+ expect(pm.mode).toBe("work");
132
+ expect(pm.status).toBe("paused");
133
+ expect(pm.timeLeftSeconds).toBe(25 * 60);
134
+ });
135
+
136
+ it("n’agit pas si pas de session courante ou KronoFocus absent", () => {
137
+ const p: KronosysUpdatePayload = { viewType: "dashboard", cfg: {} } as KronosysUpdatePayload;
138
+ expect(hydrateKronoFocusInPayload(p, 0)).toBe(false);
139
+ const p2 = basePayload({ mode: "work", status: "idle", timeLeftSeconds: 100 });
140
+ expect(hydrateKronoFocusInPayload(p2, 0)).toBe(false);
141
+ });
142
+ });
@@ -0,0 +1,69 @@
1
+ import type { KronosysUpdatePayload } from "@/lib/kronosysApi";
2
+ import { readLongBreakDurationSeconds, readShortBreakDurationSeconds, readWorkDurationSeconds } from "@/lib/kronoFocusRhythm";
3
+
4
+ import { asRecord } from "./actionTaskSession";
5
+
6
+ /**
7
+ * Met à jour le KronoFocus de la session live (`payload.current`) : décompte selon
8
+ * `kronoFocusDeadlineAtMs` quand `status === "running"`, puis passage en pause en fin de phase
9
+ * (aligné sur la logique historique de fin de phase KronoFocus côté collecte).
10
+ *
11
+ * @returns `true` si le stockage doit être réécrit (ancrage d’échéance pour données héritées, ou fin de phase).
12
+ */
13
+ export function hydrateKronoFocusInPayload(p: KronosysUpdatePayload, nowMs = Date.now()): boolean {
14
+ const cur = asRecord(p.current);
15
+ if (!cur) {
16
+ return false;
17
+ }
18
+ const pm = asRecord(cur.kronoFocus);
19
+ if (!pm || pm.status !== "running") {
20
+ return false;
21
+ }
22
+
23
+ let needWrite = false;
24
+ let deadlineMs: number;
25
+ const rawDl = pm.kronoFocusDeadlineAtMs;
26
+ if (typeof rawDl === "number" && Number.isFinite(rawDl)) {
27
+ deadlineMs = rawDl;
28
+ } else {
29
+ const left =
30
+ typeof pm.timeLeftSeconds === "number" && Number.isFinite(pm.timeLeftSeconds)
31
+ ? Math.max(0, Math.floor(pm.timeLeftSeconds))
32
+ : 0;
33
+ deadlineMs = nowMs + left * 1000;
34
+ pm.kronoFocusDeadlineAtMs = deadlineMs;
35
+ needWrite = true;
36
+ }
37
+
38
+ const remaining = Math.max(0, Math.ceil((deadlineMs - nowMs) / 1000));
39
+ pm.timeLeftSeconds = remaining;
40
+
41
+ if (remaining > 0) {
42
+ return needWrite;
43
+ }
44
+
45
+ delete pm.kronoFocusDeadlineAtMs;
46
+ pm.status = "paused";
47
+
48
+ const mode = pm.mode;
49
+ if (mode === "work") {
50
+ const sc =
51
+ typeof pm.sessionsCompleted === "number" && Number.isFinite(pm.sessionsCompleted)
52
+ ? Math.floor(pm.sessionsCompleted)
53
+ : 0;
54
+ const next = sc + 1;
55
+ pm.sessionsCompleted = next;
56
+ if (next % 4 === 0) {
57
+ pm.mode = "longBreak";
58
+ pm.timeLeftSeconds = readLongBreakDurationSeconds(pm);
59
+ } else {
60
+ pm.mode = "break";
61
+ pm.timeLeftSeconds = readShortBreakDurationSeconds(pm);
62
+ }
63
+ } else {
64
+ pm.mode = "work";
65
+ pm.timeLeftSeconds = readWorkDurationSeconds(pm);
66
+ }
67
+
68
+ return true;
69
+ }
@@ -0,0 +1,53 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import type { KronosysUpdatePayload } from "@/lib/kronosysApi";
4
+ import {
5
+ LEGACY_SESSION_TIMER_OBJECT_KEY,
6
+ LEGACY_TASK_CYCLES_KEY,
7
+ LEGACY_TIMER_DEADLINE_MS_KEY,
8
+ } from "@/lib/legacyKronoFocusStorageKeys";
9
+
10
+ import { migrateLegacyKronoFocusPayload } from "./kronoFocusMigrate";
11
+
12
+ describe("migrateLegacyKronoFocusPayload", () => {
13
+ it("fusionne l’objet minuteur hérité sur la session vers kronoFocus et renomme l’échéance", () => {
14
+ const legacyBlock: Record<string, unknown> = {
15
+ mode: "work",
16
+ status: "paused",
17
+ timeLeftSeconds: 120,
18
+ sessionsCompleted: 1,
19
+ };
20
+ legacyBlock[LEGACY_TIMER_DEADLINE_MS_KEY] = 999;
21
+
22
+ const p = {
23
+ viewType: "dashboard",
24
+ current: {
25
+ sessionId: "s1",
26
+ [LEGACY_SESSION_TIMER_OBJECT_KEY]: legacyBlock,
27
+ },
28
+ } as unknown as KronosysUpdatePayload;
29
+
30
+ expect(migrateLegacyKronoFocusPayload(p)).toBe(true);
31
+ const cur = p.current as Record<string, unknown>;
32
+ expect(cur[LEGACY_SESSION_TIMER_OBJECT_KEY]).toBeUndefined();
33
+ const kf = cur.kronoFocus as Record<string, unknown>;
34
+ expect(kf.status).toBe("paused");
35
+ expect(kf.kronoFocusDeadlineAtMs).toBe(999);
36
+ expect(kf[LEGACY_TIMER_DEADLINE_MS_KEY]).toBeUndefined();
37
+ });
38
+
39
+ it("renomme les cycles hérités sur les tâches en kronoFocusCycles", () => {
40
+ const p = {
41
+ viewType: "dashboard",
42
+ current: {
43
+ sessionId: "s1",
44
+ tasks: [{ id: "t1", name: "a", [LEGACY_TASK_CYCLES_KEY]: 3 }],
45
+ },
46
+ } as unknown as KronosysUpdatePayload;
47
+
48
+ expect(migrateLegacyKronoFocusPayload(p)).toBe(true);
49
+ const tasks = (p.current as Record<string, unknown>).tasks as Record<string, unknown>[];
50
+ expect(tasks[0].kronoFocusCycles).toBe(3);
51
+ expect(tasks[0][LEGACY_TASK_CYCLES_KEY]).toBeUndefined();
52
+ });
53
+ });
@@ -0,0 +1,78 @@
1
+ import type { KronosysUpdatePayload } from "@/lib/kronosysApi";
2
+ import {
3
+ LEGACY_SESSION_TIMER_OBJECT_KEY,
4
+ LEGACY_TASK_CYCLES_KEY,
5
+ LEGACY_TIMER_DEADLINE_MS_KEY,
6
+ } from "@/lib/legacyKronoFocusStorageKeys";
7
+
8
+ import { asRecord } from "./actionTaskSession";
9
+
10
+ function migrateTasksInSession(sess: Record<string, unknown>): boolean {
11
+ let changed = false;
12
+ const bumpTask = (t: unknown): void => {
13
+ const tr = asRecord(t);
14
+ if (!tr) {
15
+ return;
16
+ }
17
+ if (LEGACY_TASK_CYCLES_KEY in tr) {
18
+ if (tr.kronoFocusCycles === undefined) {
19
+ const n = tr[LEGACY_TASK_CYCLES_KEY];
20
+ if (typeof n === "number" && Number.isFinite(n)) {
21
+ tr.kronoFocusCycles = Math.floor(n);
22
+ }
23
+ }
24
+ delete tr[LEGACY_TASK_CYCLES_KEY];
25
+ changed = true;
26
+ }
27
+ };
28
+ for (const t of (sess.tasks as unknown[]) || []) {
29
+ bumpTask(t);
30
+ }
31
+ for (const t of (sess.activeTasks as unknown[]) || []) {
32
+ bumpTask(t);
33
+ }
34
+ bumpTask(sess.activeTask);
35
+ return changed;
36
+ }
37
+
38
+ /** Fusionne l’objet minuteur hérité sur la session vers `kronoFocus` et renomme les cycles de tâches. */
39
+ function migrateSessionRecord(sess: Record<string, unknown>): boolean {
40
+ let changed = false;
41
+ const legacyPm = asRecord(sess[LEGACY_SESSION_TIMER_OBJECT_KEY]);
42
+ if (legacyPm && Object.keys(legacyPm).length > 0) {
43
+ const kf = { ...asRecord(sess.kronoFocus), ...legacyPm } as Record<string, unknown>;
44
+ if (LEGACY_TIMER_DEADLINE_MS_KEY in kf) {
45
+ if (kf.kronoFocusDeadlineAtMs === undefined) {
46
+ kf.kronoFocusDeadlineAtMs = kf[LEGACY_TIMER_DEADLINE_MS_KEY];
47
+ }
48
+ delete kf[LEGACY_TIMER_DEADLINE_MS_KEY];
49
+ }
50
+ sess.kronoFocus = kf;
51
+ delete sess[LEGACY_SESSION_TIMER_OBJECT_KEY];
52
+ changed = true;
53
+ }
54
+ if (migrateTasksInSession(sess)) {
55
+ changed = true;
56
+ }
57
+ return changed;
58
+ }
59
+
60
+ /** @returns `true` si le JSON stocké doit être réécrit. */
61
+ export function migrateLegacyKronoFocusPayload(p: KronosysUpdatePayload): boolean {
62
+ let changed = false;
63
+ const cur = asRecord(p.current);
64
+ if (cur && migrateSessionRecord(cur)) {
65
+ changed = true;
66
+ }
67
+ for (const h of (p.history || []) as Record<string, unknown>[]) {
68
+ if (migrateSessionRecord(h)) {
69
+ changed = true;
70
+ }
71
+ }
72
+ for (const h of (p.historyArchived || []) as Record<string, unknown>[]) {
73
+ if (migrateSessionRecord(h)) {
74
+ changed = true;
75
+ }
76
+ }
77
+ return changed;
78
+ }
@@ -0,0 +1,65 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { KronosysUpdatePayload } from "@/lib/kronosysApi";
3
+ import { MAIN_TIMER_SEGMENT_STARTED_AT } from "./actionTaskSession";
4
+ import { materializeRunningMainTimersInPayload } from "./mainTimerHydrate";
5
+
6
+ describe("materializeRunningMainTimersInPayload", () => {
7
+ it("avance durationMs pour une tâche au minuteur principal entre deux appels", () => {
8
+ const t0 = Date.UTC(2026, 0, 10, 12, 0, 0);
9
+ const task = {
10
+ id: "t1",
11
+ name: "Tâche",
12
+ durationMs: 1000,
13
+ isDone: false,
14
+ manualTaskTimerPaused: false,
15
+ subtasks: [],
16
+ [MAIN_TIMER_SEGMENT_STARTED_AT]: new Date(t0).toISOString(),
17
+ };
18
+ const p = {
19
+ viewType: "dashboard",
20
+ cfg: {},
21
+ current: {
22
+ sessionId: "sid",
23
+ tasks: [],
24
+ activeTasks: [task],
25
+ activeTask: task,
26
+ },
27
+ } as unknown as KronosysUpdatePayload;
28
+
29
+ const t1 = t0 + 5000;
30
+ const w1 = materializeRunningMainTimersInPayload(p, t1);
31
+ expect(w1).toBe(true);
32
+ expect(task.durationMs).toBe(6000);
33
+ expect(typeof task[MAIN_TIMER_SEGMENT_STARTED_AT]).toBe("string");
34
+
35
+ const t2 = t1 + 3000;
36
+ const w2 = materializeRunningMainTimersInPayload(p, t2);
37
+ expect(w2).toBe(true);
38
+ expect(task.durationMs).toBe(9000);
39
+ });
40
+
41
+ it("ignore les tâches en pause manuelle", () => {
42
+ const task = {
43
+ id: "t1",
44
+ name: "Tâche",
45
+ durationMs: 1000,
46
+ isDone: false,
47
+ manualTaskTimerPaused: true,
48
+ subtasks: [],
49
+ };
50
+ const p = {
51
+ viewType: "dashboard",
52
+ cfg: {},
53
+ current: {
54
+ sessionId: "sid",
55
+ tasks: [],
56
+ activeTasks: [task],
57
+ activeTask: task,
58
+ },
59
+ } as unknown as KronosysUpdatePayload;
60
+
61
+ const w = materializeRunningMainTimersInPayload(p, Date.UTC(2026, 0, 10, 12, 0, 10));
62
+ expect(w).toBe(false);
63
+ expect(task.durationMs).toBe(1000);
64
+ });
65
+ });
@@ -0,0 +1,53 @@
1
+ import type { KronosysUpdatePayload } from "@/lib/kronosysApi";
2
+ import {
3
+ asRecord,
4
+ ensureTaskParentDurationCoversSubtasksMs,
5
+ forEachTaskRecordInSession,
6
+ MAIN_TIMER_SEGMENT_STARTED_AT,
7
+ } from "./actionTaskSession";
8
+
9
+ /**
10
+ * Fait progresser `durationMs` des tâches au minuteur principal pendant que l’UI n’est pas sur
11
+ * le tableau de bord : chaque lecture d’état (`readPayload`) matérialise le segment courant.
12
+ */
13
+ export function materializeRunningMainTimersInPayload(p: KronosysUpdatePayload, nowMs = Date.now()): boolean {
14
+ const cur = asRecord(p.current);
15
+ if (!cur) {
16
+ return false;
17
+ }
18
+ let needWrite = false;
19
+ forEachTaskRecordInSession(cur, (task) => {
20
+ if (task.isDone === true) {
21
+ return;
22
+ }
23
+ if (task.manualTaskTimerPaused === true) {
24
+ return;
25
+ }
26
+ if (String(task.activeSubtaskTimerId ?? "").trim() !== "") {
27
+ return;
28
+ }
29
+ const raw = task[MAIN_TIMER_SEGMENT_STARTED_AT];
30
+ if (typeof raw !== "string" || raw.trim() === "") {
31
+ task[MAIN_TIMER_SEGMENT_STARTED_AT] = new Date(nowMs).toISOString();
32
+ needWrite = true;
33
+ return;
34
+ }
35
+ const startedMs = Date.parse(raw);
36
+ if (!Number.isFinite(startedMs)) {
37
+ task[MAIN_TIMER_SEGMENT_STARTED_AT] = new Date(nowMs).toISOString();
38
+ needWrite = true;
39
+ return;
40
+ }
41
+ const elapsed = Math.max(0, Math.floor(nowMs - startedMs));
42
+ if (elapsed <= 0) {
43
+ return;
44
+ }
45
+ const parentPrev =
46
+ typeof task.durationMs === "number" && Number.isFinite(task.durationMs) ? Number(task.durationMs) : 0;
47
+ task.durationMs = Math.floor(parentPrev + elapsed);
48
+ task[MAIN_TIMER_SEGMENT_STARTED_AT] = new Date(nowMs).toISOString();
49
+ ensureTaskParentDurationCoversSubtasksMs(task);
50
+ needWrite = true;
51
+ });
52
+ return needWrite;
53
+ }