@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
@@ -1,325 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
-
3
- import {
4
- UNDATED_KEY,
5
- aggregateArchivedExcludedTaskMinutes,
6
- aggregateProjectTaskMinutesByDay,
7
- aggregateReporting,
8
- aggregateReportingByProject,
9
- aggregateTagTaskMinutesByDayAndWeek,
10
- buildTagFilterSet,
11
- collectTasksDeduped,
12
- dayInRange,
13
- localWeekMondayFromDayKey,
14
- maxBucket,
15
- mergeSessionsFromPayload,
16
- sessionWallClockMinutes,
17
- sortedDayKeys,
18
- taskMatchesTags,
19
- } from "./reportingAggregate";
20
- import { calendarDateKeyInTimeZone } from "./dashboardTimeZone";
21
- import type { KronosysUpdatePayload } from "./kronosysApi";
22
-
23
- const TZ = "America/Toronto";
24
-
25
- describe("reportingAggregate basic utils", () => {
26
- it("calendarDateKeyInTimeZone produit le jour attendu pour un instant UTC", () => {
27
- expect(calendarDateKeyInTimeZone("2026-06-15T04:00:00.000Z", TZ)).toBe("2026-06-15");
28
- expect(calendarDateKeyInTimeZone("2026-06-15T03:59:59.999Z", TZ)).toBe("2026-06-14");
29
- });
30
-
31
- it("calendarDateKeyInTimeZone refuse les entrées invalides", () => {
32
- expect(calendarDateKeyInTimeZone(null, TZ)).toBeNull();
33
- expect(calendarDateKeyInTimeZone("", TZ)).toBeNull();
34
- expect(calendarDateKeyInTimeZone("pas-une-date", TZ)).toBeNull();
35
- expect(calendarDateKeyInTimeZone("2026-01-01T00:00:00.000Z", "Not/A_Timezone")).toBeNull();
36
- });
37
- });
38
-
39
- describe("reportingAggregate", () => {
40
- it("mergeSessionsFromPayload fusionne courant, historique et archives", () => {
41
- const payload = {
42
- history: [{ sessionId: "a", archived: false }],
43
- historyArchived: [{ sessionId: "b", archived: true }],
44
- current: { sessionId: "c", archived: false },
45
- } as unknown as KronosysUpdatePayload;
46
- const merged = mergeSessionsFromPayload(payload);
47
- const ids = merged.map((s) => s.sessionId).sort();
48
- expect(ids).toEqual(["a", "b", "c"]);
49
- });
50
-
51
- it("aggregateArchivedExcludedTaskMinutes compte le temps des tâches d’archives non finalisées", () => {
52
- const sessions = [
53
- {
54
- sessionId: "arch-1",
55
- archived: true,
56
- tasks: [
57
- {
58
- isDone: false,
59
- project: "acme",
60
- durationMs: 6 * 60_000,
61
- startTime: "2026-04-23T10:00:00.000Z",
62
- endTime: "2026-04-23T10:06:00.000Z",
63
- tags: ["default"],
64
- subtasks: [],
65
- },
66
- ],
67
- },
68
- ];
69
- const m = aggregateArchivedExcludedTaskMinutes(
70
- sessions,
71
- buildTagFilterSet([]),
72
- "2026-04-23",
73
- "2026-04-23",
74
- TZ,
75
- true
76
- );
77
- expect(m).toBe(6);
78
- });
79
-
80
- it("aggregateReporting agrège l’assiduité (référence, retards, somme, moyenne si en retard)", () => {
81
- const sessions = [
82
- {
83
- sessionId: "on-time",
84
- savedAt: "2026-04-24T12:00:00.000Z",
85
- startAt: "2026-04-24T12:00:00.000Z",
86
- sessionStartOffsetMinutes: 0,
87
- tasks: [{ id: "t", isDone: true, durationMs: 1, tags: ["a"] }],
88
- },
89
- {
90
- sessionId: "late",
91
- savedAt: "2026-04-24T12:00:00.000Z",
92
- startAt: "2026-04-24T12:20:00.000Z",
93
- sessionStartOffsetMinutes: 20,
94
- tasks: [{ id: "t2", isDone: true, durationMs: 1, tags: ["a"] }],
95
- },
96
- {
97
- sessionId: "no-ref",
98
- savedAt: "2026-04-24T12:00:00.000Z",
99
- tasks: [{ id: "t3", isDone: true, durationMs: 1, tags: ["a"] }],
100
- },
101
- ];
102
- const r = aggregateReporting(sessions, buildTagFilterSet([]), "2026-04-24", "2026-04-24", TZ, true);
103
- expect(r.assiduityReferenceSessionCount).toBe(2);
104
- expect(r.assiduityLateSessionCount).toBe(1);
105
- expect(r.assiduityLateMinutesTotal).toBe(20);
106
- expect(r.assiduityAverageLateMinutesWhenLate).toBe(20);
107
- });
108
-
109
- it("aggregateReportingByProject utilise la durée enregistrée sur la tâche parente", () => {
110
- const sessions = [
111
- {
112
- sessionId: "s1",
113
- archived: false,
114
- tasks: [
115
- {
116
- isDone: true,
117
- project: "acme",
118
- durationMs: 6 * 60_000,
119
- startTime: "2026-04-23T10:00:00.000Z",
120
- endTime: "2026-04-23T10:06:00.000Z",
121
- tags: ["default"],
122
- subtasks: [{ done: true, durationMs: 6 * 60_000 }],
123
- },
124
- ],
125
- },
126
- ];
127
- const rows = aggregateReportingByProject(sessions, buildTagFilterSet([]), "2026-04-23", "2026-04-23", TZ, true);
128
- const acme = rows.find((r) => r.projectKey === "acme");
129
- expect(acme?.minutes).toBe(6);
130
- });
131
-
132
- it("aggregateTagTaskMinutesByDayAndWeek compte la durée complète pour chaque étiquette d’une tâche", () => {
133
- const sessions = [
134
- {
135
- sessionId: "s1",
136
- archived: false,
137
- tasks: [
138
- {
139
- isDone: true,
140
- durationMs: 60 * 60_000,
141
- startTime: "2026-04-23T10:00:00.000Z",
142
- endTime: "2026-04-23T11:00:00.000Z",
143
- tags: ["alpha", "beta"],
144
- subtasks: [],
145
- },
146
- ],
147
- },
148
- ];
149
- const { byDay } = aggregateTagTaskMinutesByDayAndWeek(
150
- sessions,
151
- buildTagFilterSet([]),
152
- "2026-04-23",
153
- "2026-04-23",
154
- TZ,
155
- "monday",
156
- "défaut",
157
- true
158
- );
159
- const alpha = byDay.find((r) => r.tagKey === "alpha");
160
- const beta = byDay.find((r) => r.tagKey === "beta");
161
- expect(alpha?.minutes).toBe(60);
162
- expect(beta?.minutes).toBe(60);
163
- });
164
-
165
- it("mergeSessionsFromPayload fusionne courant et historique", () => {
166
- const payload = {
167
- history: [{ sessionId: "a", archived: false }],
168
- historyArchived: [{ sessionId: "b", archived: true }],
169
- current: { sessionId: "c", archived: false },
170
- } as unknown as KronosysUpdatePayload;
171
- const merged = mergeSessionsFromPayload(payload);
172
- const ids = merged.map((s) => s.sessionId).sort();
173
- expect(ids).toEqual(["a", "b", "c"]);
174
- });
175
-
176
- it("collectTasksDeduped fusionne tasks et activeTasks sans doublons", () => {
177
- const s = {
178
- sessionId: "s1",
179
- tasks: [{ id: "t1", name: "T1" }],
180
- activeTasks: [{ id: "t1", name: "T1-active" }, { id: "t2", name: "T2" }],
181
- };
182
- const tasks = collectTasksDeduped(s);
183
- expect(tasks).toHaveLength(2);
184
- expect(tasks.find(t => t.id === "t1")?.name).toBe("T1"); // Priorité à la liste tasks existante
185
- expect(tasks.find(t => t.id === "t2")).toBeDefined();
186
- });
187
-
188
- it("buildTagFilterSet normalise les tags", () => {
189
- const set = buildTagFilterSet(["#Bug", "Feature"]);
190
- expect(set.has("bug")).toBe(true);
191
- expect(set.has("feature")).toBe(true);
192
- expect(set.size).toBe(2);
193
- });
194
-
195
- it("dayInRange respecte les bornes", () => {
196
- expect(dayInRange("2026-04-20", "2026-04-01", "2026-04-30")).toBe(true);
197
- expect(dayInRange("2026-04-20", "2026-04-21", "2026-04-30")).toBe(false);
198
- expect(dayInRange("2026-04-20", null, null)).toBe(true);
199
- expect(dayInRange(null, "2026-04-01", null)).toBe(false);
200
- });
201
-
202
- it("localWeekMondayFromDayKey renvoie le lundi", () => {
203
- // 2026-04-29 est un mercredi
204
- expect(localWeekMondayFromDayKey("2026-04-29")).toBe("2026-04-27");
205
- });
206
-
207
- it("sessionWallClockMinutes utilise la durée persistée si valide", () => {
208
- expect(sessionWallClockMinutes({ sessionId: "x", sessionDurationMinutes: 15 })).toBe(15);
209
- });
210
-
211
- it("sessionWallClockMinutes calcule à partir de start/end si non persisté", () => {
212
- const s = {
213
- sessionId: "x",
214
- startAt: "2026-04-29T10:00:00Z",
215
- endAt: "2026-04-29T10:10:00Z",
216
- };
217
- expect(sessionWallClockMinutes(s)).toBe(10);
218
- });
219
- });
220
-
221
- describe("taskMatchesTags", () => {
222
- it("true si filtre vide", () => {
223
- expect(taskMatchesTags({ tags: ["dev"] }, new Set())).toBe(true);
224
- });
225
-
226
- it("filtre les tâches par tags", () => {
227
- const filter = new Set(["bug"]);
228
- expect(taskMatchesTags({ tags: ["bug"] }, filter)).toBe(true);
229
- expect(taskMatchesTags({ tags: ["feature"] }, filter)).toBe(false);
230
- });
231
-
232
- it("gère le default bucket pour les tâches sans tags", () => {
233
- const filter = new Set(["default"]);
234
- expect(taskMatchesTags({ tags: [] }, filter, true)).toBe(true);
235
- expect(taskMatchesTags({ tags: [] }, filter, false)).toBe(false);
236
- });
237
- });
238
-
239
- describe("aggregations", () => {
240
- const sessions = [
241
- {
242
- sessionId: "s1",
243
- savedAt: "2026-04-29T12:00:00Z",
244
- tasks: [
245
- {
246
- id: "t1",
247
- durationMs: 600000, // 10 min
248
- isDone: true,
249
- project: "acme",
250
- tags: ["dev"],
251
- startTime: "2026-04-29T10:00:00Z",
252
- },
253
- ],
254
- },
255
- {
256
- sessionId: "arch-1",
257
- archived: true,
258
- tasks: [
259
- {
260
- id: "t2",
261
- durationMs: 300000, // 5 min
262
- isDone: false,
263
- tags: ["bug"],
264
- startTime: "2026-04-29T11:00:00Z",
265
- }
266
- ]
267
- }
268
- ];
269
-
270
- it("aggregateReportingByProject regroupe correctement", () => {
271
- const result = aggregateReportingByProject(sessions, new Set(), null, null, TZ, true);
272
- const acme = result.find(r => r.projectKey === "acme");
273
- expect(acme?.minutes).toBe(10);
274
- expect(acme?.taskCount).toBe(1);
275
- });
276
-
277
- it("aggregateProjectTaskMinutesByDay regroupe par projet et jour", () => {
278
- const result = aggregateProjectTaskMinutesByDay(sessions, new Set(), null, null, TZ, true);
279
- expect(result).toHaveLength(1); // Seul t1 (acme) est inclus ; t2 est exclu car archivé+non-terminé
280
- const acmeDay = result.find(r => r.projectKey === "acme");
281
- expect(acmeDay?.minutes).toBe(10);
282
- expect(acmeDay?.day).toBe("2026-04-29");
283
- });
284
-
285
- it("aggregateTagTaskMinutesByDayAndWeek répartit le temps", () => {
286
- const sMulti = [{
287
- sessionId: "sm",
288
- tasks: [{
289
- durationMs: 60000,
290
- tags: ["a", "b"],
291
- startTime: "2026-04-27T10:00:00Z"
292
- }]
293
- }];
294
- const { byDay } = aggregateTagTaskMinutesByDayAndWeek(sMulti, new Set(), null, null, TZ, "monday", "default", true);
295
- expect(byDay).toHaveLength(2);
296
- expect(byDay[0].minutes).toBe(1);
297
- expect(byDay[1].minutes).toBe(1);
298
- });
299
-
300
- it("aggregateReporting calcule les totaux globaux", () => {
301
- const res = aggregateReporting(sessions, new Set(), null, null, TZ, true);
302
- expect(res.taskMinutesTotal).toBe(10); // t2 est exclue car archivée et non terminée
303
- expect(res.taskCountContributing).toBe(1);
304
- expect(res.sessionCountContributing).toBe(2);
305
- });
306
-
307
- it("aggregateArchivedExcludedTaskMinutes compte t2", () => {
308
- const m = aggregateArchivedExcludedTaskMinutes(sessions, new Set(), null, null, TZ, true);
309
- expect(m).toBe(5);
310
- });
311
- });
312
-
313
- describe("helpers", () => {
314
- it("sortedDayKeys combine et trie", () => {
315
- const m1 = { "2026-04-30": 1, "2026-04-28": 1 };
316
- const m2 = { "2026-04-29": 1 };
317
- expect(sortedDayKeys(m1, m2)).toEqual(["2026-04-28", "2026-04-29", "2026-04-30"]);
318
- });
319
-
320
- it("maxBucket trouve le maximum", () => {
321
- const m1 = { a: 10, b: 5 };
322
- const m2 = { c: 20 };
323
- expect(maxBucket([m1, m2])).toBe(20);
324
- });
325
- });
@@ -1,157 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
-
3
- import { computeReportingNonFinalFlags } from "./reportingNonFinalIndicators";
4
- import type { KronosysUpdatePayload } from "./kronosysApi";
5
-
6
- // ---------------------------------------------------------------------------
7
- // Helpers
8
- // ---------------------------------------------------------------------------
9
- function makePayload(overrides: Partial<KronosysUpdatePayload> = {}): KronosysUpdatePayload {
10
- return {
11
- viewType: "dashboard",
12
- cfg: {},
13
- history: [],
14
- historyArchived: [],
15
- inspectingSessionId: null,
16
- knownTags: [],
17
- knownProjects: [],
18
- userKnownTags: [],
19
- excludedSuggestionTags: [],
20
- tagDescriptions: {},
21
- projectDescriptions: {},
22
- gitIdentity: {},
23
- dashboardDataOrigin: "local_next",
24
- ...overrides,
25
- } as KronosysUpdatePayload;
26
- }
27
-
28
- const ALL_TAGS = new Set<string>();
29
- const DATE_FROM = "";
30
- const DATE_TO = "";
31
-
32
- // ---------------------------------------------------------------------------
33
- describe("computeReportingNonFinalFlags", () => {
34
- it("retourne tout false si payload est null", () => {
35
- const flags = computeReportingNonFinalFlags(null, ALL_TAGS, DATE_FROM, DATE_TO, undefined, "America/Toronto");
36
- expect(flags).toEqual({
37
- sessionsNonFinal: false,
38
- projectsNonFinal: false,
39
- tagsNonFinal: false,
40
- });
41
- });
42
-
43
- it("sessionsNonFinal=true si session live ouverte (sessionId non vide, non archivée)", () => {
44
- const p = makePayload({ current: { sessionId: "ses-live" } } as Partial<KronosysUpdatePayload>);
45
- const flags = computeReportingNonFinalFlags(p, ALL_TAGS, DATE_FROM, DATE_TO, undefined, "America/Toronto");
46
- expect(flags.sessionsNonFinal).toBe(true);
47
- });
48
-
49
- it("sessionsNonFinal=false si session live est archivée", () => {
50
- const p = makePayload({ current: { sessionId: "ses-archived", archived: true } } as Partial<KronosysUpdatePayload>);
51
- const flags = computeReportingNonFinalFlags(p, ALL_TAGS, DATE_FROM, DATE_TO, undefined, "America/Toronto");
52
- expect(flags.sessionsNonFinal).toBe(false);
53
- });
54
-
55
- it("sessionsNonFinal=true si tasksByDayActive contient des valeurs > 0", () => {
56
- const p = makePayload();
57
- const flags = computeReportingNonFinalFlags(
58
- p, ALL_TAGS, DATE_FROM, DATE_TO, { "2026-04-29": 2 }, "America/Toronto"
59
- );
60
- expect(flags.sessionsNonFinal).toBe(true);
61
- });
62
-
63
- it("sessionsNonFinal=false si tasksByDayActive est vide", () => {
64
- const p = makePayload();
65
- const flags = computeReportingNonFinalFlags(
66
- p, ALL_TAGS, DATE_FROM, DATE_TO, { "2026-04-29": 0 }, "America/Toronto"
67
- );
68
- expect(flags.sessionsNonFinal).toBe(false);
69
- });
70
-
71
- it("projectsNonFinal=true si tâche non terminée avec @projet dans l'historique", () => {
72
- const p = makePayload({
73
- history: [
74
- {
75
- sessionId: "ses-1",
76
- tasks: [
77
- {
78
- id: "t1",
79
- isDone: false,
80
- project: "acme",
81
- tags: [],
82
- startTime: "2026-04-29T10:00:00Z",
83
- },
84
- ],
85
- },
86
- ],
87
- } as Partial<KronosysUpdatePayload>);
88
- const flags = computeReportingNonFinalFlags(p, ALL_TAGS, DATE_FROM, DATE_TO, undefined, "America/Toronto");
89
- expect(flags.projectsNonFinal).toBe(true);
90
- });
91
-
92
- it("tagsNonFinal=true si tâche non terminée avec #tag dans l'historique", () => {
93
- const p = makePayload({
94
- history: [
95
- {
96
- sessionId: "ses-1",
97
- tasks: [
98
- {
99
- id: "t1",
100
- isDone: false,
101
- project: "",
102
- tags: ["dev"],
103
- startTime: "2026-04-29T10:00:00Z",
104
- },
105
- ],
106
- },
107
- ],
108
- } as Partial<KronosysUpdatePayload>);
109
- const flags = computeReportingNonFinalFlags(p, ALL_TAGS, DATE_FROM, DATE_TO, undefined, "America/Toronto");
110
- expect(flags.tagsNonFinal).toBe(true);
111
- });
112
-
113
- it("projectsNonFinal=false si toutes les tâches sont terminées", () => {
114
- const p = makePayload({
115
- history: [
116
- {
117
- sessionId: "ses-1",
118
- tasks: [
119
- {
120
- id: "t1",
121
- isDone: true,
122
- project: "acme",
123
- tags: ["dev"],
124
- startTime: "2026-04-29T10:00:00Z",
125
- },
126
- ],
127
- },
128
- ],
129
- } as Partial<KronosysUpdatePayload>);
130
- const flags = computeReportingNonFinalFlags(p, ALL_TAGS, DATE_FROM, DATE_TO, undefined, "America/Toronto");
131
- expect(flags.projectsNonFinal).toBe(false);
132
- expect(flags.tagsNonFinal).toBe(false);
133
- });
134
-
135
- it("hors de la plage de dates → non compté", () => {
136
- const p = makePayload({
137
- history: [
138
- {
139
- sessionId: "ses-1",
140
- tasks: [
141
- {
142
- id: "t1",
143
- isDone: false,
144
- project: "acme",
145
- tags: ["dev"],
146
- startTime: "2025-01-01T10:00:00Z",
147
- },
148
- ],
149
- },
150
- ],
151
- } as Partial<KronosysUpdatePayload>);
152
- const flags = computeReportingNonFinalFlags(
153
- p, ALL_TAGS, "2026-04-01", "2026-04-30", undefined, "America/Toronto"
154
- );
155
- expect(flags.projectsNonFinal).toBe(false);
156
- });
157
- });
@@ -1,141 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
-
3
- import {
4
- buildTagWeekDisplayBlocks,
5
- groupTagDayRowsForDisplay,
6
- } from "./reportingTagWeekBreakdown";
7
- import type { TagWeekCalendarRow } from "./reportingWeekLayout";
8
- import type { ReportingTagTimeDayRow } from "./reportingAggregate";
9
-
10
- // ---------------------------------------------------------------------------
11
- // Helpers
12
- // ---------------------------------------------------------------------------
13
- function makeWeekRow(tagKey: string, slots: number[] = [0, 0, 0, 0, 0, 0, 0]): TagWeekCalendarRow {
14
- return {
15
- tagKey,
16
- displayTag: tagKey,
17
- weekStart: "2026-04-20",
18
- slots,
19
- total: slots.reduce((s, v) => s + v, 0),
20
- };
21
- }
22
-
23
- function makeDayRow(tagKey: string, day: string, minutes: number): ReportingTagTimeDayRow {
24
- return { tagKey, displayTag: tagKey, day, minutes };
25
- }
26
-
27
- // ---------------------------------------------------------------------------
28
- // buildTagWeekDisplayBlocks
29
- // ---------------------------------------------------------------------------
30
- describe("buildTagWeekDisplayBlocks", () => {
31
- it("retourne un tableau vide pour une entrée vide", () => {
32
- expect(buildTagWeekDisplayBlocks([])).toEqual([]);
33
- });
34
-
35
- it("étiquette globale → leaf", () => {
36
- const rows = [makeWeekRow("dev")];
37
- const blocks = buildTagWeekDisplayBlocks(rows);
38
- expect(blocks).toHaveLength(1);
39
- expect(blocks[0].kind).toBe("leaf");
40
- });
41
-
42
- it("étiquette fallback (default) placée en dernier", () => {
43
- const rows = [makeWeekRow("default"), makeWeekRow("design")];
44
- const blocks = buildTagWeekDisplayBlocks(rows);
45
- expect(blocks[blocks.length - 1].kind).toBe("leaf");
46
- if (blocks[blocks.length - 1].kind === "leaf") {
47
- expect(blocks[blocks.length - 1].row.tagKey).toBe("default");
48
- }
49
- });
50
-
51
- it("étiquette unique projet#code → leaf (pas de rollup)", () => {
52
- const rows = [makeWeekRow("acme#frontend")];
53
- const blocks = buildTagWeekDisplayBlocks(rows);
54
- expect(blocks).toHaveLength(1);
55
- expect(blocks[0].kind).toBe("leaf");
56
- });
57
-
58
- it("deux étiquettes projet#code du même projet → rollup", () => {
59
- const rows = [makeWeekRow("acme#frontend", [30, 0, 0, 0, 0, 0, 0]), makeWeekRow("acme#backend", [0, 60, 0, 0, 0, 0, 0])];
60
- const blocks = buildTagWeekDisplayBlocks(rows);
61
- expect(blocks).toHaveLength(1);
62
- expect(blocks[0].kind).toBe("rollup");
63
- if (blocks[0].kind === "rollup") {
64
- expect(blocks[0].children).toHaveLength(2);
65
- expect(blocks[0].parentTotal).toBe(90);
66
- expect(blocks[0].parentSlots[0]).toBe(30);
67
- expect(blocks[0].parentSlots[1]).toBe(60);
68
- }
69
- });
70
-
71
- it("deux projets différents → deux rollups distincts", () => {
72
- const rows = [
73
- makeWeekRow("acme#a"),
74
- makeWeekRow("acme#b"),
75
- makeWeekRow("beta#x"),
76
- makeWeekRow("beta#y"),
77
- ];
78
- const blocks = buildTagWeekDisplayBlocks(rows);
79
- const rollups = blocks.filter((b) => b.kind === "rollup");
80
- expect(rollups).toHaveLength(2);
81
- });
82
-
83
- it("les globales sont triées alphabétiquement avant les rollups projet", () => {
84
- const rows = [makeWeekRow("zzz"), makeWeekRow("aaa"), makeWeekRow("proj#x"), makeWeekRow("proj#y")];
85
- const blocks = buildTagWeekDisplayBlocks(rows);
86
- expect(blocks[0].kind).toBe("leaf");
87
- if (blocks[0].kind === "leaf") expect(blocks[0].row.tagKey).toBe("aaa");
88
- expect(blocks[1].kind).toBe("leaf");
89
- if (blocks[1].kind === "leaf") expect(blocks[1].row.tagKey).toBe("zzz");
90
- expect(blocks[2].kind).toBe("rollup");
91
- });
92
- });
93
-
94
- // ---------------------------------------------------------------------------
95
- // groupTagDayRowsForDisplay
96
- // ---------------------------------------------------------------------------
97
- describe("groupTagDayRowsForDisplay", () => {
98
- it("retourne un tableau vide pour entrée vide", () => {
99
- expect(groupTagDayRowsForDisplay([])).toEqual([]);
100
- });
101
-
102
- it("regroupe les lignes par jour et trie les jours", () => {
103
- const rows = [
104
- makeDayRow("dev", "2026-04-22", 30),
105
- makeDayRow("design", "2026-04-21", 60),
106
- makeDayRow("dev", "2026-04-21", 15),
107
- ];
108
- const groups = groupTagDayRowsForDisplay(rows);
109
- expect(groups).toHaveLength(2);
110
- expect(groups[0].day).toBe("2026-04-21");
111
- expect(groups[1].day).toBe("2026-04-22");
112
- });
113
-
114
- it("chaque groupe contient les bons blocs", () => {
115
- const rows = [
116
- makeDayRow("dev", "2026-04-21", 30),
117
- makeDayRow("design", "2026-04-21", 45),
118
- ];
119
- const groups = groupTagDayRowsForDisplay(rows);
120
- expect(groups[0].blocks).toHaveLength(2);
121
- });
122
-
123
- it("projet#code unique dans un groupe → leaf", () => {
124
- const rows = [makeDayRow("acme#frontend", "2026-04-21", 60)];
125
- const groups = groupTagDayRowsForDisplay(rows);
126
- expect(groups[0].blocks[0].kind).toBe("leaf");
127
- });
128
-
129
- it("deux proj#code mêmes projet dans un groupe → rollup", () => {
130
- const rows = [
131
- makeDayRow("acme#frontend", "2026-04-21", 30),
132
- makeDayRow("acme#backend", "2026-04-21", 90),
133
- ];
134
- const groups = groupTagDayRowsForDisplay(rows);
135
- const rollup = groups[0].blocks.find((b) => b.kind === "rollup");
136
- expect(rollup).toBeDefined();
137
- if (rollup?.kind === "rollup") {
138
- expect(rollup.parentMinutes).toBe(120);
139
- }
140
- });
141
- });