@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.
- package/README.md +28 -1
- package/app/api/action/route.ts +39 -3
- package/app/api/action-logs/route.ts +24 -0
- package/app/api/backup/route.ts +1 -1
- package/app/api/restore/route.ts +145 -0
- package/app/changelog/page.tsx +71 -4
- package/app/globals.css +127 -0
- package/app/guide/page.tsx +61 -15
- package/app/implementation/page.tsx +700 -0
- package/app/layout.tsx +14 -3
- package/app/licenses/page.tsx +99 -37
- package/app/logs/page.tsx +258 -0
- package/app/manifest.ts +5 -5
- package/app/page.tsx +784 -229
- package/app/reporting/page.tsx +1266 -474
- package/app/settings/page.tsx +252 -18
- package/bin/kronosys.mjs +140 -15
- package/components/KronosysPayloadProvider.tsx +2 -0
- package/components/RouteTransition.tsx +18 -0
- package/components/dashboard/AppShellCommandCenterPlaceholder.tsx +17 -0
- package/components/dashboard/AppShellHeaderSessionMeta.tsx +210 -0
- package/components/dashboard/AppShellHeaderWallClock.tsx +54 -0
- package/components/dashboard/AppShellLiveSessionDrawer.tsx +154 -38
- package/components/dashboard/AppShellRouteNav.tsx +323 -48
- package/components/dashboard/DashboardPauseBackdrop.tsx +50 -0
- package/components/dashboard/DashboardSimpleModal.tsx +168 -25
- package/components/dashboard/DashboardTour.tsx +115 -29
- package/components/dashboard/GlobalPauseConfirmModal.tsx +183 -0
- package/components/dashboard/KronosysDatetimePopoverField.tsx +167 -122
- package/components/dashboard/KronosysTimePopoverField.tsx +54 -12
- package/components/dashboard/NewSessionScopeModal.tsx +211 -20
- package/components/dashboard/PlannedTaskBoundaryConflictWatcher.tsx +275 -0
- package/components/dashboard/ReportingTour.tsx +87 -21
- package/components/dashboard/SavedProjectPicker.tsx +16 -3
- package/components/dashboard/SelectedSessionSidebarBlock.tsx +512 -142
- package/components/dashboard/SessionListPanel.tsx +327 -44
- package/components/dashboard/SettingsTagsProjectsSection.tsx +1073 -264
- package/components/dashboard/SettingsTaskTemplatesSection.tsx +316 -0
- package/components/dashboard/SettingsTour.tsx +86 -21
- package/components/dashboard/TagPills.tsx +14 -1
- package/components/dashboard/TaskFocusPanel.tsx +1081 -478
- package/components/dashboard/TaskSessionLiveCard.tsx +650 -135
- package/components/dashboard/TaskTimelineGanttModal.tsx +601 -0
- package/components/dashboard/taskFieldStyles.ts +20 -4
- package/components/dashboard/useReportingInteractionState.ts +80 -0
- package/lib/appShellHeaderClasses.ts +13 -0
- package/lib/businessRulesMatrix.ts +210 -0
- package/lib/copyToClipboard.ts +43 -0
- package/lib/dashboardCopy.ts +494 -84
- package/lib/dashboardQuickSearch.ts +54 -2
- package/lib/dashboardTimeZone.ts +109 -0
- package/lib/formatAppShellWallClock.ts +66 -0
- package/lib/formatSessionNameTemplate.ts +141 -0
- package/lib/generatedUserChangelog.ts +177 -6
- package/lib/globalPausePreview.ts +292 -0
- package/lib/implementationNotes.ts +1188 -0
- package/lib/kronosysApi.ts +6 -0
- package/lib/kronosysDashboardModalGates.ts +24 -0
- package/lib/plannedBoundaryAttention.ts +9 -0
- package/lib/plannedBoundaryConflict.ts +23 -0
- package/lib/reportingAggregate.ts +517 -75
- package/lib/reportingMetricHelp.ts +8 -0
- package/lib/reportingStrings.ts +37 -3
- package/lib/sessionListMerge.ts +4 -0
- package/lib/sessionTaskSidebarStats.ts +182 -21
- package/lib/settingsCopy.ts +178 -4
- package/lib/taskParsing.ts +360 -103
- package/lib/taskTemplateDraft.ts +135 -0
- package/lib/taskTimelineGantt.ts +265 -0
- package/lib/temporalDisplayPlanned.ts +71 -0
- package/lib/userGuideCopy.ts +121 -47
- package/next.config.ts +7 -0
- package/package.json +12 -24
- package/server/actionDispatch.ts +1000 -77
- package/server/actionTaskSession.ts +337 -24
- package/server/db.ts +7 -15
- package/server/dbSchema.ts +24 -0
- package/server/defaultCfg.ts +5 -0
- package/server/gitlabTokenStore.ts +0 -12
- package/server/liveHistorySync.ts +53 -0
- package/server/mainTimerHydrate.ts +38 -2
- package/server/payloadStore.ts +33 -11
- package/server/sessionWallHydrate.ts +66 -3
- package/server/userActionLog.ts +126 -0
- package/sonar-project.properties +11 -0
- package/tsconfig.json +2 -1
- package/components/dashboard/IssuePickerModal.tsx +0 -168
- package/components/dashboard/ThemeToggle.test.tsx +0 -26
- package/lib/backupCsvExport.test.ts +0 -149
- package/lib/dashboardQuickSearchQuery.test.ts +0 -63
- package/lib/dataDir.test.ts +0 -87
- package/lib/formatIsoShort.test.ts +0 -46
- package/lib/kronoFocusRhythm.test.ts +0 -130
- package/lib/kronoFocusTimerUrgency.test.ts +0 -74
- package/lib/legacyKronoFocusStorageKeys.test.ts +0 -29
- package/lib/reportingAggregate.test.ts +0 -325
- package/lib/reportingNonFinalIndicators.test.ts +0 -157
- package/lib/reportingTagWeekBreakdown.test.ts +0 -141
- package/lib/reportingWeekLayout.test.ts +0 -239
- package/lib/sessionAssiduity.test.ts +0 -25
- package/lib/sessionEndWarnings.test.ts +0 -200
- package/lib/sessionListMerge.test.ts +0 -101
- package/lib/sessionTaskSidebarStats.test.ts +0 -24
- package/lib/taskParsing.test.ts +0 -153
- package/lib/usageProfile.test.ts +0 -84
- package/server/actionDispatch.test.ts +0 -723
- package/server/actionTaskSession.test.ts +0 -713
- package/server/kronoFocusHydrate.test.ts +0 -142
- package/server/kronoFocusMigrate.test.ts +0 -53
- package/server/mainTimerHydrate.test.ts +0 -65
- package/server/payloadStore.test.ts +0 -78
- package/server/sessionWallHydrate.test.ts +0 -46
|
@@ -8,11 +8,16 @@ import {
|
|
|
8
8
|
} from "./taskParsing";
|
|
9
9
|
import type { KronosysUpdatePayload } from "./kronosysApi";
|
|
10
10
|
import { LEGACY_TASK_CYCLES_KEY, LEGACY_TASK_USED_FLAG_KEY } from "./legacyKronoFocusStorageKeys";
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
calendarDateKeyInTimeZone,
|
|
13
|
+
calendarDayStartUtcMsInTimeZone,
|
|
14
|
+
splitMinutesByCalendarDay,
|
|
15
|
+
} from "./dashboardTimeZone";
|
|
12
16
|
import {
|
|
13
17
|
localWeekStartKeyFromDayKey,
|
|
14
18
|
type ReportingWeekStartsOn,
|
|
15
19
|
} from "./reportingWeekLayout";
|
|
20
|
+
import { normalizeSessionEndReasonKind } from "./sessionEndReason";
|
|
16
21
|
|
|
17
22
|
export type LooseTask = {
|
|
18
23
|
id?: string;
|
|
@@ -20,13 +25,33 @@ export type LooseTask = {
|
|
|
20
25
|
endTime?: string;
|
|
21
26
|
durationMs?: number;
|
|
22
27
|
isDone?: boolean;
|
|
28
|
+
manualTaskTimerPaused?: boolean;
|
|
23
29
|
usedKronoFocus?: boolean;
|
|
24
30
|
kronoFocusCycles?: number;
|
|
25
31
|
tags?: string[];
|
|
26
32
|
project?: string | null;
|
|
33
|
+
/** Projet issu d’un jeton `!` (temps personnel), distinct des projets `@` productifs. */
|
|
34
|
+
personalProject?: boolean;
|
|
27
35
|
subtasks?: Array<{ done?: boolean }>;
|
|
28
36
|
};
|
|
29
37
|
|
|
38
|
+
/** Filtre les tâches pour les agrégats « @ projet » vs « ! projet personnel ». */
|
|
39
|
+
export type ReportingTaskProjectScope = "all" | "work" | "personal";
|
|
40
|
+
|
|
41
|
+
export function taskMatchesReportingProjectScope(
|
|
42
|
+
task: LooseTask,
|
|
43
|
+
scope: ReportingTaskProjectScope,
|
|
44
|
+
): boolean {
|
|
45
|
+
const isP = task.personalProject === true;
|
|
46
|
+
if (scope === "all") {
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
if (scope === "work") {
|
|
50
|
+
return !isP;
|
|
51
|
+
}
|
|
52
|
+
return isP;
|
|
53
|
+
}
|
|
54
|
+
|
|
30
55
|
export type LooseSession = {
|
|
31
56
|
sessionId: string;
|
|
32
57
|
savedAt?: string;
|
|
@@ -56,6 +81,8 @@ export type LooseSession = {
|
|
|
56
81
|
* Présent seulement si `scheduledStartAt` a été enregistré.
|
|
57
82
|
*/
|
|
58
83
|
sessionStartOffsetMinutes?: number | null;
|
|
84
|
+
/** Catégorie enregistrée à la clôture de session (voir {@link normalizeSessionEndReasonKind}). */
|
|
85
|
+
sessionEndReasonKind?: string;
|
|
59
86
|
};
|
|
60
87
|
|
|
61
88
|
export function mergeSessionsFromPayload(payload: KronosysUpdatePayload): LooseSession[] {
|
|
@@ -193,7 +220,14 @@ export function localWeekMondayFromDayKey(dayKey: string): string | null {
|
|
|
193
220
|
}
|
|
194
221
|
|
|
195
222
|
/**
|
|
196
|
-
* Durée « murale » de la session : valeur persistée si disponible, sinon écart
|
|
223
|
+
* Durée « murale » de la session : valeur persistée si disponible, sinon écart `startAt` → `endAt` (minutes).
|
|
224
|
+
*
|
|
225
|
+
* Repli de dernier recours : si `endAt` manque et que la durée n’a pas été matérialisée
|
|
226
|
+
* (sessions « orphelines » : restées vivantes en historique sans clôture explicite, héritages
|
|
227
|
+
* de bogues côté hôte), on retombe sur `savedAt - startAt`. `savedAt` = dernier sync de la
|
|
228
|
+
* session ; c’est une borne supérieure raisonnable du temps écoulé tant que la session
|
|
229
|
+
* était active, et ça vaut mieux que de compter 0 minute pour une session qui a clairement
|
|
230
|
+
* eu de l’activité (présence de tâches, durées de tâches non nulles, etc.).
|
|
197
231
|
*/
|
|
198
232
|
export function sessionWallClockMinutes(s: LooseSession): number {
|
|
199
233
|
const persisted = s.sessionDurationMinutes;
|
|
@@ -205,9 +239,191 @@ export function sessionWallClockMinutes(s: LooseSession): number {
|
|
|
205
239
|
if (Number.isFinite(start) && Number.isFinite(end) && end > start) {
|
|
206
240
|
return (end - start) / 60000;
|
|
207
241
|
}
|
|
242
|
+
const saved = s.savedAt ? Date.parse(s.savedAt) : NaN;
|
|
243
|
+
if (Number.isFinite(start) && Number.isFinite(saved) && saved > start) {
|
|
244
|
+
return (saved - start) / 60000;
|
|
245
|
+
}
|
|
208
246
|
return 0;
|
|
209
247
|
}
|
|
210
248
|
|
|
249
|
+
function parseTaskIntervalMs(task: LooseTask): [number, number] | null {
|
|
250
|
+
const start = typeof task.startTime === "string" ? Date.parse(task.startTime) : NaN;
|
|
251
|
+
const end = typeof task.endTime === "string" ? Date.parse(task.endTime) : NaN;
|
|
252
|
+
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) {
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
return [start, end];
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Bornes UTC `[startMs, endMs]` du « segment d’ouverture » de la session. `endMs` privilégie
|
|
260
|
+
* `endAt` (session clôturée), retombe sur `savedAt` (session live → dernier sync ; orpheline →
|
|
261
|
+
* dernière trace en historique). Renvoie `null` si on ne peut pas borner correctement.
|
|
262
|
+
*/
|
|
263
|
+
function sessionWallSpanRangeMs(s: LooseSession): [number, number] | null {
|
|
264
|
+
const startMs = s.startAt ? Date.parse(s.startAt) : NaN;
|
|
265
|
+
if (!Number.isFinite(startMs)) {
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
const endIsoCandidate =
|
|
269
|
+
typeof s.endAt === "string" && s.endAt.trim() !== ""
|
|
270
|
+
? s.endAt
|
|
271
|
+
: (s.savedAt ?? null);
|
|
272
|
+
const endMs = endIsoCandidate ? Date.parse(endIsoCandidate) : NaN;
|
|
273
|
+
if (!Number.isFinite(endMs) || endMs <= startMs) {
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
return [startMs, endMs];
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Minutes « murales » de la session ventilées par jour calendaire local. Le total persisté
|
|
281
|
+
* (`sessionDurationMinutes` quand > 0, sinon `endAt - startAt`) est réparti **au prorata** de la
|
|
282
|
+
* fraction du segment d’ouverture tombant dans chaque jour. Les pauses ne sont donc pas
|
|
283
|
+
* attribuées précisément à leurs jours, mais le total reste correct et le découpage minuit est
|
|
284
|
+
* respecté.
|
|
285
|
+
*
|
|
286
|
+
* Renvoie une map vide quand la session n’a pas de bornes utilisables (orpheline sans `savedAt`).
|
|
287
|
+
*/
|
|
288
|
+
export function sessionWallMinutesByDay(s: LooseSession, timeZone: string): Map<string, number> {
|
|
289
|
+
const span = sessionWallSpanRangeMs(s);
|
|
290
|
+
if (!span) {
|
|
291
|
+
return new Map();
|
|
292
|
+
}
|
|
293
|
+
const [startMs, endMs] = span;
|
|
294
|
+
const spanByDay = splitMinutesByCalendarDay(startMs, endMs, timeZone);
|
|
295
|
+
const totalSpanMin = (endMs - startMs) / 60000;
|
|
296
|
+
if (totalSpanMin <= 0 || spanByDay.size === 0) {
|
|
297
|
+
return new Map();
|
|
298
|
+
}
|
|
299
|
+
const persistedTotal = sessionWallClockMinutes(s);
|
|
300
|
+
if (persistedTotal <= 0) {
|
|
301
|
+
return new Map();
|
|
302
|
+
}
|
|
303
|
+
if (spanByDay.size === 1) {
|
|
304
|
+
/** Cas typique : la session tient dans un seul jour calendaire. On évite l’imprécision
|
|
305
|
+
* arithmétique liée au prorata et on alloue exactement le total persisté. */
|
|
306
|
+
const [k] = spanByDay.keys();
|
|
307
|
+
return new Map([[k, persistedTotal]]);
|
|
308
|
+
}
|
|
309
|
+
const out = new Map<string, number>();
|
|
310
|
+
for (const [day, dayMin] of spanByDay) {
|
|
311
|
+
const ratio = dayMin / totalSpanMin;
|
|
312
|
+
const allocated = persistedTotal * ratio;
|
|
313
|
+
if (allocated > 0) {
|
|
314
|
+
out.set(day, allocated);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return out;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Minutes d’une tâche (`durationMs`) ventilées par jour calendaire local, **au prorata** de la
|
|
322
|
+
* fraction du segment `[startTime, endTime]` tombant dans chaque jour. Pour les tâches qui
|
|
323
|
+
* tiennent dans un seul jour, tout va dans ce jour ; sinon le découpage minuit est respecté.
|
|
324
|
+
*
|
|
325
|
+
* Renvoie `null` si on n’a pas de segment utilisable — l’appelant peut alors retomber sur
|
|
326
|
+
* la stratégie « jour de fin de tâche » (`endTime ?? startTime`).
|
|
327
|
+
*/
|
|
328
|
+
export function taskMinutesByDayInTimeZone(
|
|
329
|
+
task: LooseTask,
|
|
330
|
+
timeZone: string,
|
|
331
|
+
): Map<string, number> | null {
|
|
332
|
+
const interval = parseTaskIntervalMs(task);
|
|
333
|
+
if (!interval) {
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
const [startMs, endMs] = interval;
|
|
337
|
+
const spanByDay = splitMinutesByCalendarDay(startMs, endMs, timeZone);
|
|
338
|
+
if (spanByDay.size === 0) {
|
|
339
|
+
return null;
|
|
340
|
+
}
|
|
341
|
+
const totalSpanMin = (endMs - startMs) / 60000;
|
|
342
|
+
const persistedMin = (task.durationMs ?? 0) / 60000;
|
|
343
|
+
if (persistedMin <= 0 || totalSpanMin <= 0) {
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
if (spanByDay.size === 1) {
|
|
347
|
+
const [k] = spanByDay.keys();
|
|
348
|
+
return new Map([[k, persistedMin]]);
|
|
349
|
+
}
|
|
350
|
+
const out = new Map<string, number>();
|
|
351
|
+
for (const [day, dayMin] of spanByDay) {
|
|
352
|
+
const allocated = persistedMin * (dayMin / totalSpanMin);
|
|
353
|
+
if (allocated > 0) {
|
|
354
|
+
out.set(day, allocated);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return out;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Découpe l’intervalle wall `[startTime, endTime]` d’une tâche aux frontières minuit (jour
|
|
362
|
+
* calendaire local) et renvoie pour chaque jour la liste des sous-intervalles `[start, end]` en
|
|
363
|
+
* millisecondes UTC. Sert à calculer correctement la durée non concurrente (union d’intervalles)
|
|
364
|
+
* par jour quand des tâches se chevauchent et qu’une partie traverse minuit.
|
|
365
|
+
*/
|
|
366
|
+
export function taskIntervalsByDayInTimeZone(
|
|
367
|
+
task: LooseTask,
|
|
368
|
+
timeZone: string,
|
|
369
|
+
): Map<string, Array<[number, number]>> {
|
|
370
|
+
const out = new Map<string, Array<[number, number]>>();
|
|
371
|
+
const interval = parseTaskIntervalMs(task);
|
|
372
|
+
if (!interval) {
|
|
373
|
+
return out;
|
|
374
|
+
}
|
|
375
|
+
const [startMs, endMs] = interval;
|
|
376
|
+
let cursor = startMs;
|
|
377
|
+
/** Garde-fou : même borne que `splitMinutesByCalendarDay`. */
|
|
378
|
+
const maxDays = 366 * 5;
|
|
379
|
+
for (let i = 0; i < maxDays && cursor < endMs; i += 1) {
|
|
380
|
+
const dayKey = calendarDateKeyInTimeZone(new Date(cursor).toISOString(), timeZone);
|
|
381
|
+
if (!dayKey) {
|
|
382
|
+
return new Map();
|
|
383
|
+
}
|
|
384
|
+
const [yStr, mStr, dStr] = dayKey.split("-");
|
|
385
|
+
const y = Number.parseInt(yStr, 10);
|
|
386
|
+
const m = Number.parseInt(mStr, 10);
|
|
387
|
+
const d = Number.parseInt(dStr, 10);
|
|
388
|
+
if (!Number.isFinite(y) || !Number.isFinite(m) || !Number.isFinite(d)) {
|
|
389
|
+
return new Map();
|
|
390
|
+
}
|
|
391
|
+
const nextDayStart = calendarDayStartUtcMsInTimeZone(y, m, d + 1, timeZone);
|
|
392
|
+
const sliceEnd = Math.min(endMs, nextDayStart);
|
|
393
|
+
if (sliceEnd <= cursor) {
|
|
394
|
+
break;
|
|
395
|
+
}
|
|
396
|
+
const list = out.get(dayKey) ?? [];
|
|
397
|
+
list.push([cursor, sliceEnd]);
|
|
398
|
+
out.set(dayKey, list);
|
|
399
|
+
cursor = sliceEnd;
|
|
400
|
+
}
|
|
401
|
+
return out;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function mergeIntervalsToMinutes(intervals: Array<[number, number]>): number {
|
|
405
|
+
if (intervals.length === 0) {
|
|
406
|
+
return 0;
|
|
407
|
+
}
|
|
408
|
+
const sorted = [...intervals].sort((a, b) => a[0] - b[0]);
|
|
409
|
+
let [curStart, curEnd] = sorted[0];
|
|
410
|
+
let totalMs = 0;
|
|
411
|
+
for (let i = 1; i < sorted.length; i += 1) {
|
|
412
|
+
const [s, e] = sorted[i];
|
|
413
|
+
if (s <= curEnd) {
|
|
414
|
+
if (e > curEnd) {
|
|
415
|
+
curEnd = e;
|
|
416
|
+
}
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
totalMs += Math.max(0, curEnd - curStart);
|
|
420
|
+
curStart = s;
|
|
421
|
+
curEnd = e;
|
|
422
|
+
}
|
|
423
|
+
totalMs += Math.max(0, curEnd - curStart);
|
|
424
|
+
return totalMs / 60000;
|
|
425
|
+
}
|
|
426
|
+
|
|
211
427
|
function uniqueTaskTagEntries(
|
|
212
428
|
task: LooseTask,
|
|
213
429
|
fallbackTagDisplay: string
|
|
@@ -275,7 +491,12 @@ export function aggregateTagTaskMinutesByDayAndWeek(
|
|
|
275
491
|
/** Libellé pour l’étiquette réservée « sans étiquette » / `default` (ex. « default » ou « défaut » selon la langue). */
|
|
276
492
|
fallbackTaskTagDisplay: string,
|
|
277
493
|
/** Lorsque `false`, les tâches sans étiquette sont ventilées sous la clé vide (pas `default`). */
|
|
278
|
-
defaultTagBucketEnabled: boolean = true
|
|
494
|
+
defaultTagBucketEnabled: boolean = true,
|
|
495
|
+
/**
|
|
496
|
+
* Limite les tâches comptées : `all` (défaut) = tout, `work` = hors `personalProject`,
|
|
497
|
+
* `personal` = uniquement tâches avec `personalProject: true`.
|
|
498
|
+
*/
|
|
499
|
+
taskProjectScope: ReportingTaskProjectScope = "all",
|
|
279
500
|
): { byDay: ReportingTagTimeDayRow[]; byWeek: ReportingTagTimeWeekRow[] } {
|
|
280
501
|
const byTagDay = new Map<string, Map<string, number>>();
|
|
281
502
|
const byTagWeek = new Map<string, Map<string, number>>();
|
|
@@ -321,37 +542,53 @@ export function aggregateTagTaskMinutesByDayAndWeek(
|
|
|
321
542
|
if (!taskIncludedInReportingTaskMetrics(s, t)) {
|
|
322
543
|
continue;
|
|
323
544
|
}
|
|
324
|
-
|
|
325
|
-
if (!unbounded && !dayInRange(taskDay, dateFrom, dateTo)) {
|
|
545
|
+
if (!taskMatchesReportingProjectScope(t, taskProjectScope)) {
|
|
326
546
|
continue;
|
|
327
547
|
}
|
|
328
|
-
|
|
329
|
-
|
|
548
|
+
|
|
549
|
+
/* Ventilation par jour calendaire (prorata du segment `[startTime, endTime]`) avec repli
|
|
550
|
+
* sur le jour de fin de tâche pour les tâches sans bornes utilisables (durée 0,
|
|
551
|
+
* `startTime`/`endTime` manquants). */
|
|
552
|
+
const splitMinutes = taskMinutesByDayInTimeZone(t, timeZone);
|
|
553
|
+
const distribution: Array<{ day: string; minutes: number }> = (() => {
|
|
554
|
+
if (splitMinutes && splitMinutes.size > 0) {
|
|
555
|
+
return [...splitMinutes].map(([day, minutes]) => ({ day, minutes }));
|
|
556
|
+
}
|
|
557
|
+
const fallbackDay = calendarDateKeyInTimeZone(t.endTime ?? t.startTime, timeZone);
|
|
558
|
+
const bucket = fallbackDay ?? (unbounded ? UNDATED_KEY : null);
|
|
559
|
+
const mins = (t.durationMs ?? 0) / 60000;
|
|
560
|
+
if (!bucket || mins <= 0) {
|
|
561
|
+
return [];
|
|
562
|
+
}
|
|
563
|
+
return [{ day: bucket, minutes: mins }];
|
|
564
|
+
})();
|
|
565
|
+
if (distribution.length === 0) {
|
|
330
566
|
continue;
|
|
331
567
|
}
|
|
332
568
|
|
|
333
|
-
const
|
|
334
|
-
if (
|
|
569
|
+
const inRange = distribution.filter(({ day }) => unbounded || dayInRange(day, dateFrom, dateTo));
|
|
570
|
+
if (inRange.length === 0) {
|
|
335
571
|
continue;
|
|
336
572
|
}
|
|
337
573
|
|
|
338
574
|
const entries = uniqueTaskTagEntries(t, fallbackTaskTagDisplay);
|
|
339
|
-
const
|
|
340
|
-
|
|
341
|
-
?
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
575
|
+
const tagKeyList: Array<{ key: string; display: string }> =
|
|
576
|
+
entries.length === 0
|
|
577
|
+
? [{
|
|
578
|
+
key: defaultTagBucketEnabled ? DEFAULT_FALLBACK_TASK_TAG : "",
|
|
579
|
+
display: fallbackTaskTagDisplay,
|
|
580
|
+
}]
|
|
581
|
+
: entries;
|
|
582
|
+
|
|
583
|
+
for (const { day, minutes } of inRange) {
|
|
584
|
+
if (minutes <= 0) {
|
|
585
|
+
continue;
|
|
349
586
|
}
|
|
350
|
-
|
|
351
|
-
for (const e of
|
|
352
|
-
addDay(e.key, e.display,
|
|
587
|
+
const weekStart = day !== UNDATED_KEY ? localWeekStartKeyFromDayKey(day, weekStartsOn) : null;
|
|
588
|
+
for (const e of tagKeyList) {
|
|
589
|
+
addDay(e.key, e.display, day, minutes);
|
|
353
590
|
if (weekStart) {
|
|
354
|
-
addWeek(e.key, weekStart,
|
|
591
|
+
addWeek(e.key, weekStart, minutes);
|
|
355
592
|
}
|
|
356
593
|
}
|
|
357
594
|
}
|
|
@@ -399,6 +636,8 @@ export type ReportingResult = {
|
|
|
399
636
|
tasksByDayActive: Record<string, number>;
|
|
400
637
|
/** Minutes enregistrées sur les tâches (durationMs), par jour de tâche. */
|
|
401
638
|
taskMinutesByDay: Record<string, number>;
|
|
639
|
+
/** Minutes non concurrentes (union des intervalles start/end), par jour de tâche. */
|
|
640
|
+
nonConcurrentTaskMinutesByDay: Record<string, number>;
|
|
402
641
|
/** Minutes de codage session (codingMinutesSession), par jour de session. */
|
|
403
642
|
sessionCodingMinutesByDay: Record<string, number>;
|
|
404
643
|
/**
|
|
@@ -432,8 +671,22 @@ export type ReportingResult = {
|
|
|
432
671
|
* ou `null` si aucune.
|
|
433
672
|
*/
|
|
434
673
|
assiduityAverageLateMinutesWhenLate: number | null;
|
|
674
|
+
/**
|
|
675
|
+
* Comptage des sessions dans la plage par type de clôture enregistré (`planned` / `early` / …).
|
|
676
|
+
* Clé `unspecified` : pas de catégorie valide (session ouverte, ou clôture sans choix).
|
|
677
|
+
*/
|
|
678
|
+
sessionCountByClosureKind: Record<string, number>;
|
|
435
679
|
};
|
|
436
680
|
|
|
681
|
+
/** Ordre d’affichage des lignes « sessions par type de clôture » dans les rapports. */
|
|
682
|
+
export const REPORTING_SESSION_CLOSURE_DISPLAY_ORDER = [
|
|
683
|
+
"planned",
|
|
684
|
+
"early",
|
|
685
|
+
"overrun",
|
|
686
|
+
"other",
|
|
687
|
+
"unspecified",
|
|
688
|
+
] as const;
|
|
689
|
+
|
|
437
690
|
export function aggregateReporting(
|
|
438
691
|
sessions: LooseSession[],
|
|
439
692
|
selectedTagKeys: Set<string>,
|
|
@@ -447,8 +700,10 @@ export function aggregateReporting(
|
|
|
447
700
|
const tasksByDayDone: Record<string, number> = {};
|
|
448
701
|
const tasksByDayActive: Record<string, number> = {};
|
|
449
702
|
const taskMinutesByDay: Record<string, number> = {};
|
|
703
|
+
const nonConcurrentTaskMinutesByDay: Record<string, number> = {};
|
|
450
704
|
const sessionCodingMinutesByDay: Record<string, number> = {};
|
|
451
705
|
const sessionWallClockMinutesByDay: Record<string, number> = {};
|
|
706
|
+
const taskIntervalsByDay = new Map<string, Array<[number, number]>>();
|
|
452
707
|
|
|
453
708
|
let kronoFocusSessionsCompleted = 0;
|
|
454
709
|
let kronoFocusTasksUsedCount = 0;
|
|
@@ -467,9 +722,27 @@ export function aggregateReporting(
|
|
|
467
722
|
let assiduityReferenceSessionCount = 0;
|
|
468
723
|
let assiduityLateSessionCount = 0;
|
|
469
724
|
let assiduityLateMinutesTotal = 0;
|
|
725
|
+
const sessionCountByClosureKind: Record<string, number> = {};
|
|
470
726
|
|
|
471
727
|
const unbounded = !dateFrom && !dateTo;
|
|
472
728
|
|
|
729
|
+
/**
|
|
730
|
+
* Ajoute des minutes à un agrégat jour-par-jour en respectant le filtre de plage.
|
|
731
|
+
*/
|
|
732
|
+
const addInRange = (
|
|
733
|
+
target: Record<string, number>,
|
|
734
|
+
day: string,
|
|
735
|
+
minutes: number,
|
|
736
|
+
): void => {
|
|
737
|
+
if (minutes <= 0) {
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
if (!unbounded && !dayInRange(day, dateFrom, dateTo)) {
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
target[day] = (target[day] ?? 0) + minutes;
|
|
744
|
+
};
|
|
745
|
+
|
|
473
746
|
for (const s of sessions) {
|
|
474
747
|
const allTasks = collectTasksDeduped(s);
|
|
475
748
|
const matching = allTasks.filter((t) => taskMatchesTags(t, selectedTagKeys, defaultTagBucketEnabled));
|
|
@@ -480,29 +753,23 @@ export function aggregateReporting(
|
|
|
480
753
|
const sessionDay = calendarDateKeyInTimeZone(s.savedAt ?? s.endAt ?? s.startAt, timeZone);
|
|
481
754
|
const sessionRangeOk = unbounded || dayInRange(sessionDay, dateFrom, dateTo);
|
|
482
755
|
|
|
756
|
+
/* Métriques attribuées au jour de clôture (compte de sessions, type de clôture, lignes,
|
|
757
|
+
* assiduité, compteurs KronoFocus) : on continue à utiliser `sessionDay` comme bucket
|
|
758
|
+
* unique — cohérent avec « 1 session = 1 jour de clôture ». */
|
|
483
759
|
if (sessionRangeOk) {
|
|
760
|
+
const rk = normalizeSessionEndReasonKind(s.sessionEndReasonKind);
|
|
761
|
+
const closureBucket = rk || "unspecified";
|
|
762
|
+
sessionCountByClosureKind[closureBucket] =
|
|
763
|
+
(sessionCountByClosureKind[closureBucket] ?? 0) + 1;
|
|
484
764
|
const sessionBucket = sessionDay ?? (unbounded ? UNDATED_KEY : null);
|
|
485
765
|
if (sessionBucket) {
|
|
486
766
|
sessionsByDay[sessionBucket] = (sessionsByDay[sessionBucket] ?? 0) + 1;
|
|
487
|
-
const cm = s.codingMinutesSession ?? 0;
|
|
488
|
-
if (cm > 0) {
|
|
489
|
-
sessionCodingMinutesByDay[sessionBucket] =
|
|
490
|
-
(sessionCodingMinutesByDay[sessionBucket] ?? 0) + cm;
|
|
491
|
-
}
|
|
492
|
-
const wm = sessionWallClockMinutes(s);
|
|
493
|
-
if (wm > 0) {
|
|
494
|
-
sessionWallClockMinutesByDay[sessionBucket] =
|
|
495
|
-
(sessionWallClockMinutesByDay[sessionBucket] ?? 0) + wm;
|
|
496
|
-
}
|
|
497
767
|
}
|
|
498
768
|
sessionCountContributing += 1;
|
|
499
769
|
const sc = s.kronoFocus?.sessionsCompleted;
|
|
500
770
|
if (typeof sc === "number" && sc > 0) {
|
|
501
771
|
kronoFocusSessionsCompleted += sc;
|
|
502
772
|
}
|
|
503
|
-
sessionCodingMinutesTotal += s.codingMinutesSession ?? 0;
|
|
504
|
-
sessionActiveMinutesTotal += s.activeMinutes ?? 0;
|
|
505
|
-
sessionWallClockMinutesTotal += sessionWallClockMinutes(s);
|
|
506
773
|
if (typeof s.linesWrittenTotal === "number") {
|
|
507
774
|
linesWrittenTotalSum += s.linesWrittenTotal;
|
|
508
775
|
}
|
|
@@ -514,7 +781,10 @@ export function aggregateReporting(
|
|
|
514
781
|
}
|
|
515
782
|
mergeTupleMap(locByLanguageAcc, s.locByLanguage);
|
|
516
783
|
mergeTupleMap(codingSignalsAcc, s.codingSignalsByLanguage);
|
|
517
|
-
if (
|
|
784
|
+
if (
|
|
785
|
+
typeof s.sessionStartOffsetMinutes === "number"
|
|
786
|
+
&& Number.isFinite(s.sessionStartOffsetMinutes)
|
|
787
|
+
) {
|
|
518
788
|
assiduityReferenceSessionCount += 1;
|
|
519
789
|
if (s.sessionStartOffsetMinutes > 0) {
|
|
520
790
|
assiduityLateSessionCount += 1;
|
|
@@ -523,30 +793,125 @@ export function aggregateReporting(
|
|
|
523
793
|
}
|
|
524
794
|
}
|
|
525
795
|
|
|
796
|
+
/* Durée murale et codage session : ventilation par jour calendaire local en répartissant
|
|
797
|
+
* le total persisté au prorata du temps mural écoulé chaque jour (sessions à cheval sur
|
|
798
|
+
* minuit). Si le découpage est impossible (orpheline sans `savedAt` valide), on retombe
|
|
799
|
+
* sur l’ancien comportement « tout sur le jour de clôture ». */
|
|
800
|
+
const wallByDay = sessionWallMinutesByDay(s, timeZone);
|
|
801
|
+
if (wallByDay.size > 0) {
|
|
802
|
+
let wallAllocated = 0;
|
|
803
|
+
for (const v of wallByDay.values()) {
|
|
804
|
+
wallAllocated += v;
|
|
805
|
+
}
|
|
806
|
+
const codingTotal = s.codingMinutesSession ?? 0;
|
|
807
|
+
const activeTotal = s.activeMinutes ?? 0;
|
|
808
|
+
for (const [day, dayWallMin] of wallByDay) {
|
|
809
|
+
if (!unbounded && !dayInRange(day, dateFrom, dateTo)) {
|
|
810
|
+
continue;
|
|
811
|
+
}
|
|
812
|
+
sessionWallClockMinutesByDay[day] =
|
|
813
|
+
(sessionWallClockMinutesByDay[day] ?? 0) + dayWallMin;
|
|
814
|
+
sessionWallClockMinutesTotal += dayWallMin;
|
|
815
|
+
const ratio = wallAllocated > 0 ? dayWallMin / wallAllocated : 0;
|
|
816
|
+
if (codingTotal > 0) {
|
|
817
|
+
const allocCoding = codingTotal * ratio;
|
|
818
|
+
if (allocCoding > 0) {
|
|
819
|
+
sessionCodingMinutesByDay[day] =
|
|
820
|
+
(sessionCodingMinutesByDay[day] ?? 0) + allocCoding;
|
|
821
|
+
sessionCodingMinutesTotal += allocCoding;
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
if (activeTotal > 0) {
|
|
825
|
+
sessionActiveMinutesTotal += activeTotal * ratio;
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
} else if (sessionRangeOk && sessionDay) {
|
|
829
|
+
/* Repli : pas de span exploitable (rare). On préserve le comportement historique pour
|
|
830
|
+
* ne pas perdre la session du compte « total » à cause d’un défaut de bornes. */
|
|
831
|
+
const wm = sessionWallClockMinutes(s);
|
|
832
|
+
if (wm > 0) {
|
|
833
|
+
sessionWallClockMinutesByDay[sessionDay] =
|
|
834
|
+
(sessionWallClockMinutesByDay[sessionDay] ?? 0) + wm;
|
|
835
|
+
sessionWallClockMinutesTotal += wm;
|
|
836
|
+
}
|
|
837
|
+
const cm = s.codingMinutesSession ?? 0;
|
|
838
|
+
if (cm > 0) {
|
|
839
|
+
sessionCodingMinutesByDay[sessionDay] =
|
|
840
|
+
(sessionCodingMinutesByDay[sessionDay] ?? 0) + cm;
|
|
841
|
+
sessionCodingMinutesTotal += cm;
|
|
842
|
+
}
|
|
843
|
+
sessionActiveMinutesTotal += s.activeMinutes ?? 0;
|
|
844
|
+
}
|
|
845
|
+
|
|
526
846
|
for (const t of matching) {
|
|
527
847
|
if (!taskIncludedInReportingTaskMetrics(s, t)) {
|
|
528
848
|
continue;
|
|
529
849
|
}
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
const
|
|
535
|
-
|
|
850
|
+
|
|
851
|
+
/* Ventilation des minutes de tâche par jour : prorata du segment `[startTime, endTime]` ;
|
|
852
|
+
* repli sur le jour de fin de tâche si on n’a pas de bornes utilisables (tâche sans
|
|
853
|
+
* `endTime` finie, durée 0, etc.). */
|
|
854
|
+
const taskMinByDay = taskMinutesByDayInTimeZone(t, timeZone);
|
|
855
|
+
const taskCountedDay = (() => {
|
|
856
|
+
if (taskMinByDay) {
|
|
857
|
+
for (const day of taskMinByDay.keys()) {
|
|
858
|
+
if (unbounded || dayInRange(day, dateFrom, dateTo)) {
|
|
859
|
+
return day;
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
return null;
|
|
863
|
+
}
|
|
864
|
+
const fallbackDay = calendarDateKeyInTimeZone(t.endTime ?? t.startTime, timeZone);
|
|
865
|
+
if (!fallbackDay) {
|
|
866
|
+
return unbounded ? UNDATED_KEY : null;
|
|
867
|
+
}
|
|
868
|
+
if (!unbounded && !dayInRange(fallbackDay, dateFrom, dateTo)) {
|
|
869
|
+
return null;
|
|
870
|
+
}
|
|
871
|
+
return fallbackDay;
|
|
872
|
+
})();
|
|
873
|
+
|
|
874
|
+
if (taskCountedDay === null) {
|
|
536
875
|
continue;
|
|
537
876
|
}
|
|
538
877
|
|
|
539
878
|
taskCountContributing += 1;
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
879
|
+
if (t.isDone === true) {
|
|
880
|
+
tasksByDayDone[taskCountedDay] = (tasksByDayDone[taskCountedDay] ?? 0) + 1;
|
|
881
|
+
} else {
|
|
882
|
+
tasksByDayActive[taskCountedDay] = (tasksByDayActive[taskCountedDay] ?? 0) + 1;
|
|
544
883
|
}
|
|
545
884
|
|
|
546
|
-
if (
|
|
547
|
-
|
|
885
|
+
if (taskMinByDay) {
|
|
886
|
+
for (const [day, mins] of taskMinByDay) {
|
|
887
|
+
addInRange(taskMinutesByDay, day, mins);
|
|
888
|
+
if (unbounded || dayInRange(day, dateFrom, dateTo)) {
|
|
889
|
+
taskMinutesTotal += mins;
|
|
890
|
+
}
|
|
891
|
+
}
|
|
548
892
|
} else {
|
|
549
|
-
|
|
893
|
+
/* Repli sans bornes : tout sur le jour de fin (UNDATED si plage ouverte). */
|
|
894
|
+
const mins = (t.durationMs ?? 0) / 60000;
|
|
895
|
+
if (mins > 0) {
|
|
896
|
+
addInRange(taskMinutesByDay, taskCountedDay, mins);
|
|
897
|
+
if (unbounded || dayInRange(taskCountedDay, dateFrom, dateTo)) {
|
|
898
|
+
taskMinutesTotal += mins;
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/* Intervalles non concurrents : on découpe à minuit pour que le merge par jour reflète
|
|
904
|
+
* correctement la fraction tombant dans chaque journée. */
|
|
905
|
+
const splitIntervals = taskIntervalsByDayInTimeZone(t, timeZone);
|
|
906
|
+
for (const [day, intervals] of splitIntervals) {
|
|
907
|
+
if (!unbounded && !dayInRange(day, dateFrom, dateTo)) {
|
|
908
|
+
continue;
|
|
909
|
+
}
|
|
910
|
+
const list = taskIntervalsByDay.get(day) ?? [];
|
|
911
|
+
for (const iv of intervals) {
|
|
912
|
+
list.push(iv);
|
|
913
|
+
}
|
|
914
|
+
taskIntervalsByDay.set(day, list);
|
|
550
915
|
}
|
|
551
916
|
|
|
552
917
|
const tr = t as Record<string, unknown>;
|
|
@@ -566,6 +931,12 @@ export function aggregateReporting(
|
|
|
566
931
|
}
|
|
567
932
|
|
|
568
933
|
const locByLanguageMerged = [...locByLanguageAcc.entries()].sort((a, b) => b[1] - a[1]);
|
|
934
|
+
for (const [bucket, intervals] of taskIntervalsByDay) {
|
|
935
|
+
const unionMinutes = mergeIntervalsToMinutes(intervals);
|
|
936
|
+
if (unionMinutes > 0) {
|
|
937
|
+
nonConcurrentTaskMinutesByDay[bucket] = unionMinutes;
|
|
938
|
+
}
|
|
939
|
+
}
|
|
569
940
|
const codingSignalsByLanguageMerged = [...codingSignalsAcc.entries()].sort((a, b) => b[1] - a[1]);
|
|
570
941
|
const assiduityAverageLateMinutesWhenLate =
|
|
571
942
|
assiduityLateSessionCount > 0 ? assiduityLateMinutesTotal / assiduityLateSessionCount : null;
|
|
@@ -575,6 +946,7 @@ export function aggregateReporting(
|
|
|
575
946
|
tasksByDayDone,
|
|
576
947
|
tasksByDayActive,
|
|
577
948
|
taskMinutesByDay,
|
|
949
|
+
nonConcurrentTaskMinutesByDay,
|
|
578
950
|
sessionCodingMinutesByDay,
|
|
579
951
|
sessionWallClockMinutesByDay,
|
|
580
952
|
kronoFocusSessionsCompleted,
|
|
@@ -595,6 +967,7 @@ export function aggregateReporting(
|
|
|
595
967
|
assiduityLateSessionCount,
|
|
596
968
|
assiduityLateMinutesTotal,
|
|
597
969
|
assiduityAverageLateMinutesWhenLate,
|
|
970
|
+
sessionCountByClosureKind,
|
|
598
971
|
};
|
|
599
972
|
}
|
|
600
973
|
|
|
@@ -625,7 +998,7 @@ function compareProjectKeyForSort(a: string, b: string): number {
|
|
|
625
998
|
}
|
|
626
999
|
|
|
627
1000
|
/**
|
|
628
|
-
* Temps enregistré sur les tâches (`durationMs`) par
|
|
1001
|
+
* Temps enregistré sur les tâches (`durationMs`) par projet (`@` productif ou `!` personnel selon le filtre) et par jour de tâche.
|
|
629
1002
|
*/
|
|
630
1003
|
export function aggregateProjectTaskMinutesByDay(
|
|
631
1004
|
sessions: LooseSession[],
|
|
@@ -633,7 +1006,9 @@ export function aggregateProjectTaskMinutesByDay(
|
|
|
633
1006
|
dateFrom: string | null,
|
|
634
1007
|
dateTo: string | null,
|
|
635
1008
|
timeZone: string,
|
|
636
|
-
defaultTagBucketEnabled: boolean = true
|
|
1009
|
+
defaultTagBucketEnabled: boolean = true,
|
|
1010
|
+
/** Par défaut `work` : agrégat des projets `@` (hors temps personnel `!`). */
|
|
1011
|
+
taskProjectScope: ReportingTaskProjectScope = "work",
|
|
637
1012
|
): ReportingProjectTimeDayRow[] {
|
|
638
1013
|
const byProjDay = new Map<string, Map<string, number>>();
|
|
639
1014
|
const displayByProj = new Map<string, string>();
|
|
@@ -650,30 +1025,49 @@ export function aggregateProjectTaskMinutesByDay(
|
|
|
650
1025
|
if (!taskIncludedInReportingTaskMetrics(s, t)) {
|
|
651
1026
|
continue;
|
|
652
1027
|
}
|
|
653
|
-
|
|
654
|
-
if (!unbounded && !dayInRange(taskDay, dateFrom, dateTo)) {
|
|
655
|
-
continue;
|
|
656
|
-
}
|
|
657
|
-
const taskBucket = taskDay ?? (unbounded ? UNDATED_KEY : null);
|
|
658
|
-
if (!taskBucket) {
|
|
1028
|
+
if (!taskMatchesReportingProjectScope(t, taskProjectScope)) {
|
|
659
1029
|
continue;
|
|
660
1030
|
}
|
|
661
1031
|
|
|
662
|
-
const
|
|
663
|
-
|
|
1032
|
+
const splitMinutes = taskMinutesByDayInTimeZone(t, timeZone);
|
|
1033
|
+
const distribution: Array<{ day: string; minutes: number }> = (() => {
|
|
1034
|
+
if (splitMinutes && splitMinutes.size > 0) {
|
|
1035
|
+
return [...splitMinutes].map(([day, minutes]) => ({ day, minutes }));
|
|
1036
|
+
}
|
|
1037
|
+
const fallbackDay = calendarDateKeyInTimeZone(t.endTime ?? t.startTime, timeZone);
|
|
1038
|
+
const bucket = fallbackDay ?? (unbounded ? UNDATED_KEY : null);
|
|
1039
|
+
const mins = (t.durationMs ?? 0) / 60000;
|
|
1040
|
+
if (!bucket || mins <= 0) {
|
|
1041
|
+
return [];
|
|
1042
|
+
}
|
|
1043
|
+
return [{ day: bucket, minutes: mins }];
|
|
1044
|
+
})();
|
|
1045
|
+
if (distribution.length === 0) {
|
|
664
1046
|
continue;
|
|
665
1047
|
}
|
|
666
1048
|
|
|
667
1049
|
const raw = typeof t.project === "string" ? t.project.trim() : "";
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
if (!inner) {
|
|
671
|
-
inner = new Map();
|
|
672
|
-
byProjDay.set(key, inner);
|
|
1050
|
+
if (taskProjectScope === "personal" && !raw) {
|
|
1051
|
+
continue;
|
|
673
1052
|
}
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
1053
|
+
const key = raw ? normalizeProjectKey(raw).toLowerCase() : "";
|
|
1054
|
+
const displayOpts = taskProjectScope === "personal" ? ({ personal: true } as const) : undefined;
|
|
1055
|
+
for (const { day, minutes } of distribution) {
|
|
1056
|
+
if (!unbounded && !dayInRange(day, dateFrom, dateTo)) {
|
|
1057
|
+
continue;
|
|
1058
|
+
}
|
|
1059
|
+
if (minutes <= 0) {
|
|
1060
|
+
continue;
|
|
1061
|
+
}
|
|
1062
|
+
let inner = byProjDay.get(key);
|
|
1063
|
+
if (!inner) {
|
|
1064
|
+
inner = new Map();
|
|
1065
|
+
byProjDay.set(key, inner);
|
|
1066
|
+
}
|
|
1067
|
+
inner.set(day, (inner.get(day) ?? 0) + minutes);
|
|
1068
|
+
if (raw && !displayByProj.has(key)) {
|
|
1069
|
+
displayByProj.set(key, formatProjectDisplay(raw, displayOpts));
|
|
1070
|
+
}
|
|
677
1071
|
}
|
|
678
1072
|
}
|
|
679
1073
|
}
|
|
@@ -686,7 +1080,8 @@ export function aggregateProjectTaskMinutesByDay(
|
|
|
686
1080
|
displayProject:
|
|
687
1081
|
projectKey === ""
|
|
688
1082
|
? ""
|
|
689
|
-
: (displayByProj.get(projectKey) ??
|
|
1083
|
+
: (displayByProj.get(projectKey) ??
|
|
1084
|
+
formatProjectDisplay(projectKey, taskProjectScope === "personal" ? { personal: true } : undefined)),
|
|
690
1085
|
day,
|
|
691
1086
|
minutes,
|
|
692
1087
|
});
|
|
@@ -747,14 +1142,28 @@ export function aggregateReportingByProject(
|
|
|
747
1142
|
dateFrom: string | null,
|
|
748
1143
|
dateTo: string | null,
|
|
749
1144
|
timeZone: string,
|
|
750
|
-
defaultTagBucketEnabled: boolean = true
|
|
1145
|
+
defaultTagBucketEnabled: boolean = true,
|
|
1146
|
+
/** Par défaut `work` : projets `@` (hors temps personnel `!`). */
|
|
1147
|
+
taskProjectScope: ReportingTaskProjectScope = "work",
|
|
751
1148
|
): ReportingProjectRow[] {
|
|
752
1149
|
const byKey = new Map<string, { label: string; minutes: number; count: number }>();
|
|
753
1150
|
const unbounded = !dateFrom && !dateTo;
|
|
1151
|
+
const displayOpts = taskProjectScope === "personal" ? ({ personal: true } as const) : undefined;
|
|
754
1152
|
|
|
755
|
-
const
|
|
1153
|
+
const bumpMinutes = (key: string, label: string, mins: number): void => {
|
|
1154
|
+
if (mins <= 0) {
|
|
1155
|
+
return;
|
|
1156
|
+
}
|
|
756
1157
|
const cur = byKey.get(key) ?? { label, minutes: 0, count: 0 };
|
|
757
1158
|
cur.minutes += mins;
|
|
1159
|
+
if (label && !cur.label) {
|
|
1160
|
+
cur.label = label;
|
|
1161
|
+
}
|
|
1162
|
+
byKey.set(key, cur);
|
|
1163
|
+
};
|
|
1164
|
+
|
|
1165
|
+
const bumpCount = (key: string, label: string): void => {
|
|
1166
|
+
const cur = byKey.get(key) ?? { label, minutes: 0, count: 0 };
|
|
758
1167
|
cur.count += 1;
|
|
759
1168
|
if (label && !cur.label) {
|
|
760
1169
|
cur.label = label;
|
|
@@ -773,14 +1182,47 @@ export function aggregateReportingByProject(
|
|
|
773
1182
|
if (!taskIncludedInReportingTaskMetrics(s, t)) {
|
|
774
1183
|
continue;
|
|
775
1184
|
}
|
|
776
|
-
|
|
777
|
-
if (!unbounded && !dayInRange(taskDay, dateFrom, dateTo)) {
|
|
1185
|
+
if (!taskMatchesReportingProjectScope(t, taskProjectScope)) {
|
|
778
1186
|
continue;
|
|
779
1187
|
}
|
|
1188
|
+
|
|
1189
|
+
const splitMinutes = taskMinutesByDayInTimeZone(t, timeZone);
|
|
1190
|
+
const distribution: Array<{ day: string; minutes: number }> = (() => {
|
|
1191
|
+
if (splitMinutes && splitMinutes.size > 0) {
|
|
1192
|
+
return [...splitMinutes].map(([day, minutes]) => ({ day, minutes }));
|
|
1193
|
+
}
|
|
1194
|
+
const fallbackDay = calendarDateKeyInTimeZone(t.endTime ?? t.startTime, timeZone);
|
|
1195
|
+
const bucket = fallbackDay ?? (unbounded ? UNDATED_KEY : null);
|
|
1196
|
+
const mins = (t.durationMs ?? 0) / 60000;
|
|
1197
|
+
if (!bucket) {
|
|
1198
|
+
return [];
|
|
1199
|
+
}
|
|
1200
|
+
return [{ day: bucket, minutes: mins }];
|
|
1201
|
+
})();
|
|
1202
|
+
if (distribution.length === 0) {
|
|
1203
|
+
continue;
|
|
1204
|
+
}
|
|
1205
|
+
|
|
780
1206
|
const raw = typeof t.project === "string" ? t.project.trim() : "";
|
|
1207
|
+
if (taskProjectScope === "personal" && !raw) {
|
|
1208
|
+
continue;
|
|
1209
|
+
}
|
|
781
1210
|
const key = raw ? normalizeProjectKey(raw).toLowerCase() : "";
|
|
782
|
-
const
|
|
783
|
-
|
|
1211
|
+
const label = formatProjectDisplay(raw, displayOpts);
|
|
1212
|
+
|
|
1213
|
+
let counted = false;
|
|
1214
|
+
for (const { day, minutes } of distribution) {
|
|
1215
|
+
if (!unbounded && !dayInRange(day, dateFrom, dateTo)) {
|
|
1216
|
+
continue;
|
|
1217
|
+
}
|
|
1218
|
+
bumpMinutes(key, label, minutes);
|
|
1219
|
+
if (!counted) {
|
|
1220
|
+
/* Une tâche traversant minuit n’est comptée qu’une fois dans `taskCount`, sur le
|
|
1221
|
+
* premier jour en plage : conserve la sémantique « 1 tâche = 1 contribution ». */
|
|
1222
|
+
bumpCount(key, label);
|
|
1223
|
+
counted = true;
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
784
1226
|
}
|
|
785
1227
|
}
|
|
786
1228
|
|