@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
@@ -0,0 +1,292 @@
1
+ import { sessionWallClockMinutes, type LooseSession } from "@/lib/reportingAggregate";
2
+ import { taskTitleForDisplay } from "@/lib/taskParsing";
3
+
4
+ function asRecord(v: unknown): Record<string, unknown> | undefined {
5
+ return v && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, unknown>) : undefined;
6
+ }
7
+
8
+ function getActiveTasksArray(sess: Record<string, unknown>): Record<string, unknown>[] {
9
+ return Array.isArray(sess.activeTasks) ? ([...sess.activeTasks] as Record<string, unknown>[]) : [];
10
+ }
11
+
12
+ function getTasksArray(sess: Record<string, unknown>): Record<string, unknown>[] {
13
+ return Array.isArray(sess.tasks) ? ([...sess.tasks] as Record<string, unknown>[]) : [];
14
+ }
15
+
16
+ /** Aligné sur `SUBTASK_TIMER_STARTED_AT` / `MAIN_TIMER_SEGMENT_STARTED_AT` côté serveur. */
17
+ const SUBTASK_TIMER_STARTED_AT = "subtaskTimerStartedAt";
18
+ const MAIN_TIMER_SEGMENT_STARTED_AT = "mainTimerSegmentStartedAt";
19
+
20
+ /** Même ordre que `forEachTaskRecordInSession` côté serveur (`server/actionTaskSession.ts`). */
21
+ export function forEachTaskRecordInSessionShape(
22
+ sess: Record<string, unknown>,
23
+ fn: (task: Record<string, unknown>, taskId: string) => void,
24
+ ): void {
25
+ const seen = new Set<string>();
26
+ for (const t of getActiveTasksArray(sess)) {
27
+ const id = String(t.id ?? "");
28
+ if (!id || seen.has(id)) {
29
+ continue;
30
+ }
31
+ seen.add(id);
32
+ fn(t, id);
33
+ }
34
+ const at = asRecord(sess.activeTask);
35
+ if (at) {
36
+ const id = String(at.id ?? "");
37
+ if (id && !seen.has(id)) {
38
+ seen.add(id);
39
+ fn(at, id);
40
+ }
41
+ }
42
+ for (const t of getTasksArray(sess)) {
43
+ const id = String(t.id ?? "");
44
+ if (!id || seen.has(id)) {
45
+ continue;
46
+ }
47
+ seen.add(id);
48
+ fn(t, id);
49
+ }
50
+ }
51
+
52
+ function ensureSubtasksList(task: Record<string, unknown>): unknown[] {
53
+ return Array.isArray(task.subtasks) ? task.subtasks : [];
54
+ }
55
+
56
+ /** Somme des `durationMs` persistées sur les sous-tâches (entiers non négatifs). */
57
+ function sumSubtasksDurationMsStored(task: Record<string, unknown>): number {
58
+ let sum = 0;
59
+ for (const st of ensureSubtasksList(task)) {
60
+ const row = asRecord(st);
61
+ if (!row) {
62
+ continue;
63
+ }
64
+ const d = row.durationMs;
65
+ const v = typeof d === "number" && Number.isFinite(d) ? d : 0;
66
+ sum += Math.max(0, Math.floor(v));
67
+ }
68
+ return sum;
69
+ }
70
+
71
+ function subtasksInflightMs(task: Record<string, unknown>, nowMs: number): number {
72
+ const activeSub = String(task.activeSubtaskTimerId ?? "").trim();
73
+ if (!activeSub) {
74
+ return 0;
75
+ }
76
+ const raw = task[SUBTASK_TIMER_STARTED_AT];
77
+ const startedMs =
78
+ typeof raw === "string" && raw.trim() !== ""
79
+ ? Date.parse(raw.trim())
80
+ : typeof raw === "number" && Number.isFinite(raw)
81
+ ? Number(raw)
82
+ : Number.NaN;
83
+ if (!Number.isFinite(startedMs)) {
84
+ return 0;
85
+ }
86
+ return Math.max(0, Math.floor(nowMs - startedMs));
87
+ }
88
+
89
+ /**
90
+ * Durée minuteur « totale » côté tâche (persistée + segment courant parent ou sous-tâche active),
91
+ * alignée sur les règles serveur : pas de double segment parent + sous-tâche.
92
+ */
93
+ function taskInclusiveTimerMs(task: Record<string, unknown>, nowMs: number): number {
94
+ const stored =
95
+ typeof task.durationMs === "number" && Number.isFinite(task.durationMs)
96
+ ? Math.max(0, Math.floor(Number(task.durationMs)))
97
+ : 0;
98
+ if (String(task.activeSubtaskTimerId ?? "").trim() !== "") {
99
+ return stored + subtasksInflightMs(task, nowMs);
100
+ }
101
+ const rawMain = task[MAIN_TIMER_SEGMENT_STARTED_AT];
102
+ if (typeof rawMain === "string" && rawMain.trim() !== "") {
103
+ const startedMs = Date.parse(rawMain.trim());
104
+ if (Number.isFinite(startedMs)) {
105
+ return stored + Math.max(0, Math.floor(nowMs - startedMs));
106
+ }
107
+ }
108
+ return stored;
109
+ }
110
+
111
+ /** Total temps sous-tâches (persisté + segment actif éventuel). */
112
+ function subtasksTimerMsDisplay(task: Record<string, unknown>, nowMs: number): number {
113
+ return sumSubtasksDurationMsStored(task) + subtasksInflightMs(task, nowMs);
114
+ }
115
+
116
+ function subtaskTitleFor(task: Record<string, unknown>, subId: string): string | undefined {
117
+ const list = task.subtasks;
118
+ if (!Array.isArray(list)) {
119
+ return undefined;
120
+ }
121
+ const row = list.find((s) => asRecord(s) && String(asRecord(s)!.id ?? "") === subId);
122
+ const t = row && asRecord(row)?.title;
123
+ return typeof t === "string" && t.trim() !== "" ? t.trim() : undefined;
124
+ }
125
+
126
+ function optionalNonNegativeMinutesFromLive(raw: unknown): number | null {
127
+ if (typeof raw !== "number" || !Number.isFinite(raw)) {
128
+ return null;
129
+ }
130
+ return Math.max(0, raw);
131
+ }
132
+
133
+ function looseSessionWallStub(sess: Record<string, unknown>): LooseSession {
134
+ return {
135
+ sessionId: typeof sess.sessionId === "string" ? sess.sessionId.trim() || "?" : "?",
136
+ savedAt:
137
+ typeof sess.savedAt === "string" && sess.savedAt.trim() !== "" ? sess.savedAt.trim() : undefined,
138
+ startAt:
139
+ typeof sess.startAt === "string" && sess.startAt.trim() !== ""
140
+ ? sess.startAt.trim()
141
+ : typeof sess.createdAt === "string" && sess.createdAt.trim() !== ""
142
+ ? sess.createdAt.trim()
143
+ : undefined,
144
+ endAt:
145
+ typeof sess.endAt === "string" && sess.endAt.trim() !== "" ? sess.endAt.trim() : undefined,
146
+ sessionDurationMinutes:
147
+ typeof sess.sessionDurationMinutes === "number" && Number.isFinite(sess.sessionDurationMinutes)
148
+ ? sess.sessionDurationMinutes
149
+ : undefined,
150
+ };
151
+ }
152
+
153
+ export type GlobalPausePreviewRow = {
154
+ taskId: string;
155
+ taskTitle: string;
156
+ pauseMainTimer: boolean;
157
+ /** Minuteur de sous-tâche actif : sera arrêté et consolidé. */
158
+ activeSubtaskLabel?: string;
159
+ };
160
+
161
+ export type GlobalPauseActivationPreview = {
162
+ sessionName: string;
163
+ sessionWallWillPause: boolean;
164
+ sessionWallAlreadyPaused: boolean;
165
+ rows: GlobalPausePreviewRow[];
166
+ /** ISO début session (priorité `startAt`, sinon `savedAt`, `createdAt`). */
167
+ sessionStartIso: string;
168
+ /** ISO fin ; vide si session encore ouverte côté client. */
169
+ sessionEndIso: string;
170
+ /** Durée murale (minutes), voir {@link sessionWallClockMinutes}. */
171
+ sessionWallMinutes: number;
172
+ taskCount: number;
173
+ subtaskCount: number;
174
+ /**
175
+ * Temps minuteur sur la partie « tâche » hors attribution sous-tâche (persisté + segments en cours),
176
+ * en millisecondes.
177
+ */
178
+ taskMainTimerMsExclusive: number;
179
+ /** Somme des temps minuteur sous-tâches (persisté + segment actif éventuel), en millisecondes. */
180
+ subtasksTimerMsTotal: number;
181
+ /** Total minuteurs tâches (somme inclusive sur les tâches), identique à exclusif + sous-tâches. */
182
+ taskTimersTotalMs: number;
183
+ /** Minutes de codage IDE sur la session (`codingMinutesSession`), ou null si non suivies. */
184
+ sessionCodingMinutes: number | null;
185
+ /** Minutes actives IDE sur la session (`activeMinutes`), ou null si non suivies. */
186
+ sessionActiveMinutes: number | null;
187
+ };
188
+
189
+ export type GlobalPauseActivationPreviewOpts = {
190
+ /** Point de référence pour les segments en cours (tests). */
191
+ nowMs?: number;
192
+ };
193
+
194
+ /**
195
+ * Indique qu’activer la pause globale n’aurait aucun effet : horloge murale déjà figée
196
+ * et aucun minuteur de tâche ou de sous-tâche à traiter (même critères que la liste d’effets).
197
+ */
198
+ export function isGlobalPauseActivationNoOp(preview: GlobalPauseActivationPreview): boolean {
199
+ return !preview.sessionWallWillPause && preview.rows.length === 0;
200
+ }
201
+
202
+ /**
203
+ * Aperçu de ce que fera `toggleGlobalPauseContext` lorsqu’il active la pause globale
204
+ * (pas lors de la reprise).
205
+ */
206
+ export function buildGlobalPauseActivationPreview(
207
+ live: Record<string, unknown> | null | undefined,
208
+ opts?: GlobalPauseActivationPreviewOpts,
209
+ ): GlobalPauseActivationPreview | null {
210
+ if (!live) {
211
+ return null;
212
+ }
213
+ const nowMs = typeof opts?.nowMs === "number" && Number.isFinite(opts.nowMs) ? opts.nowMs : Date.now();
214
+
215
+ const rawName = typeof live.sessionName === "string" ? live.sessionName.trim() : "";
216
+ const sid = typeof live.sessionId === "string" ? live.sessionId.trim() : "";
217
+ const sessionName = rawName || (sid ? sid.slice(0, 8) : "—");
218
+
219
+ const sessionWallAlreadyPaused = live.isPaused === true;
220
+ const sessionWallWillPause = !sessionWallAlreadyPaused;
221
+
222
+ const rows: GlobalPausePreviewRow[] = [];
223
+ let taskCount = 0;
224
+ let subtaskCount = 0;
225
+ let taskMainTimerMsExclusive = 0;
226
+ let subtasksTimerMsTotal = 0;
227
+
228
+ forEachTaskRecordInSessionShape(live, (task) => {
229
+ taskCount += 1;
230
+ for (const st of ensureSubtasksList(task)) {
231
+ if (asRecord(st)) {
232
+ subtaskCount += 1;
233
+ }
234
+ }
235
+ const subTotal = subtasksTimerMsDisplay(task, nowMs);
236
+ const inclusive = taskInclusiveTimerMs(task, nowMs);
237
+ subtasksTimerMsTotal += subTotal;
238
+ taskMainTimerMsExclusive += Math.max(0, inclusive - subTotal);
239
+
240
+ if (task.isDone === true) {
241
+ return;
242
+ }
243
+ const nameRaw = typeof task.name === "string" ? task.name : "";
244
+ const taskTitle = taskTitleForDisplay(nameRaw).trim() || String(task.id ?? "").slice(0, 8);
245
+ const activeSubtaskId =
246
+ typeof task.activeSubtaskTimerId === "string" ? task.activeSubtaskTimerId.trim() : "";
247
+ const hasSub = activeSubtaskId.length > 0;
248
+ const pauseMainTimer = task.manualTaskTimerPaused !== true;
249
+ const activeSubtaskLabel = hasSub
250
+ ? subtaskTitleFor(task, activeSubtaskId) ?? activeSubtaskId.slice(0, 8)
251
+ : undefined;
252
+
253
+ if (!pauseMainTimer && !hasSub) {
254
+ return;
255
+ }
256
+ rows.push({
257
+ taskId: String(task.id ?? ""),
258
+ taskTitle,
259
+ pauseMainTimer,
260
+ ...(hasSub ? { activeSubtaskLabel } : {}),
261
+ });
262
+ });
263
+
264
+ const startCand =
265
+ (typeof live.startAt === "string" && live.startAt.trim()) ||
266
+ (typeof live.savedAt === "string" && live.savedAt.trim()) ||
267
+ (typeof live.createdAt === "string" && live.createdAt.trim()) ||
268
+ "";
269
+ const endCand = typeof live.endAt === "string" && live.endAt.trim() !== "" ? live.endAt.trim() : "";
270
+
271
+ const wallLoose = looseSessionWallStub(live);
272
+ const sessionCodingMinutes = optionalNonNegativeMinutesFromLive(live.codingMinutesSession);
273
+ const sessionActiveMinutes = optionalNonNegativeMinutesFromLive(live.activeMinutes);
274
+ const taskTimersTotalMs = taskMainTimerMsExclusive + subtasksTimerMsTotal;
275
+
276
+ return {
277
+ sessionName,
278
+ sessionWallWillPause,
279
+ sessionWallAlreadyPaused,
280
+ rows,
281
+ sessionStartIso: startCand.trim(),
282
+ sessionEndIso: endCand.trim(),
283
+ sessionWallMinutes: sessionWallClockMinutes(wallLoose),
284
+ taskCount,
285
+ subtaskCount,
286
+ taskMainTimerMsExclusive,
287
+ subtasksTimerMsTotal,
288
+ taskTimersTotalMs,
289
+ sessionCodingMinutes,
290
+ sessionActiveMinutes,
291
+ };
292
+ }