@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.
- package/README.md +81 -0
- package/app/api/action/route.ts +16 -0
- package/app/api/backup/route.ts +84 -0
- package/app/api/health/route.ts +22 -0
- package/app/api/state/route.ts +27 -0
- package/app/apple-icon.png +0 -0
- package/app/changelog/page.tsx +122 -0
- package/app/globals.css +210 -0
- package/app/guide/layout.tsx +11 -0
- package/app/guide/page.tsx +278 -0
- package/app/icon.png +0 -0
- package/app/layout.tsx +77 -0
- package/app/licenses/layout.tsx +11 -0
- package/app/licenses/page.tsx +246 -0
- package/app/manifest.ts +32 -0
- package/app/page.tsx +1610 -0
- package/app/reporting/page.tsx +2943 -0
- package/app/settings/layout.tsx +10 -0
- package/app/settings/page.tsx +3518 -0
- package/bin/kronosys.mjs +46 -0
- package/components/KronosysPackageVersionProvider.tsx +19 -0
- package/components/KronosysPayloadProvider.tsx +109 -0
- package/components/PwaRegister.tsx +25 -0
- package/components/SiteLegalFooter.tsx +21 -0
- package/components/ThemeProvider.tsx +78 -0
- package/components/dashboard/AppShellLiveSessionDrawer.tsx +394 -0
- package/components/dashboard/AppShellRouteNav.tsx +131 -0
- package/components/dashboard/AppVersionStamp.tsx +16 -0
- package/components/dashboard/DashboardCollapsibleSection.tsx +57 -0
- package/components/dashboard/DashboardColumnHintsBanner.tsx +159 -0
- package/components/dashboard/DashboardCommandCenter.tsx +470 -0
- package/components/dashboard/DashboardLangGateModal.tsx +118 -0
- package/components/dashboard/DashboardLoadingOverlay.tsx +42 -0
- package/components/dashboard/DashboardSimpleModal.tsx +337 -0
- package/components/dashboard/DashboardSuspenseFallback.tsx +52 -0
- package/components/dashboard/DashboardToastProvider.tsx +64 -0
- package/components/dashboard/DashboardTour.tsx +435 -0
- package/components/dashboard/DeferredDescriptionPopoverWrap.tsx +39 -0
- package/components/dashboard/DeleteSessionModal.tsx +130 -0
- package/components/dashboard/DescriptionTooltipPortaled.tsx +31 -0
- package/components/dashboard/GitIdentityQuickSetupModal.tsx +211 -0
- package/components/dashboard/HeaderIntegrationBadges.tsx +69 -0
- package/components/dashboard/InlineMetricHelpTrigger.tsx +102 -0
- package/components/dashboard/IssuePickerModal.tsx +168 -0
- package/components/dashboard/KronoFocusPanel.tsx +834 -0
- package/components/dashboard/KronosysDatetimePopoverField.tsx +357 -0
- package/components/dashboard/KronosysTimePopoverField.tsx +233 -0
- package/components/dashboard/LanguageMenu.tsx +123 -0
- package/components/dashboard/MongoMirrorSyncLine.tsx +57 -0
- package/components/dashboard/NewSessionScopeModal.tsx +410 -0
- package/components/dashboard/PageRefreshButton.tsx +130 -0
- package/components/dashboard/PlainHelpPopover.tsx +97 -0
- package/components/dashboard/ReportingPageToc.tsx +68 -0
- package/components/dashboard/ReportingTour.tsx +342 -0
- package/components/dashboard/SavedProjectPicker.tsx +92 -0
- package/components/dashboard/SavedTagPicker.tsx +115 -0
- package/components/dashboard/ScrollToTopFab.tsx +41 -0
- package/components/dashboard/SelectedSessionSidebarBlock.tsx +630 -0
- package/components/dashboard/SessionEndReasonEditor.tsx +114 -0
- package/components/dashboard/SessionListPanel.tsx +320 -0
- package/components/dashboard/SessionLocMetricsSection.tsx +128 -0
- package/components/dashboard/SettingsTagsProjectsSection.tsx +993 -0
- package/components/dashboard/SettingsTour.tsx +332 -0
- package/components/dashboard/TagPills.tsx +149 -0
- package/components/dashboard/TagsHelpTrigger.tsx +84 -0
- package/components/dashboard/TaskFocusPanel.tsx +1261 -0
- package/components/dashboard/TaskSessionLiveCard.tsx +832 -0
- package/components/dashboard/TaskSubtasksBlock.tsx +748 -0
- package/components/dashboard/ThemeToggle.test.tsx +26 -0
- package/components/dashboard/ThemeToggle.tsx +36 -0
- package/components/dashboard/UserGuideBodyText.tsx +62 -0
- package/components/dashboard/WorkspaceGitRepoCard.tsx +191 -0
- package/components/dashboard/taskFieldStyles.ts +139 -0
- package/components/dashboard/useAnchoredFloatingPortalStyle.ts +71 -0
- package/components/dashboard/useDescriptionPopoverAfterMs.ts +220 -0
- package/components/dashboard/useKronoFocusLiveSeconds.ts +36 -0
- package/components/dashboard/useSmoothStopwatchMs.ts +25 -0
- package/lib/appShellHeaderClasses.ts +12 -0
- package/lib/backupCsvExport.test.ts +149 -0
- package/lib/backupCsvExport.ts +392 -0
- package/lib/changelogCopy.ts +34 -0
- package/lib/concurrentTaskStartPreference.ts +29 -0
- package/lib/dashboardClockFormat.ts +13 -0
- package/lib/dashboardColumnChrome.ts +3 -0
- package/lib/dashboardColumnHintsStorage.ts +57 -0
- package/lib/dashboardCopy.ts +1831 -0
- package/lib/dashboardDetachedUrlHintStorage.ts +24 -0
- package/lib/dashboardGitIdentityBannerStorage.ts +36 -0
- package/lib/dashboardLangStorage.ts +72 -0
- package/lib/dashboardQuickSearch.ts +476 -0
- package/lib/dashboardQuickSearchQuery.test.ts +63 -0
- package/lib/dashboardQuickSearchQuery.ts +179 -0
- package/lib/dashboardSessionNav.ts +33 -0
- package/lib/dashboardShortcuts.ts +268 -0
- package/lib/dashboardTimeZone.ts +91 -0
- package/lib/dashboardTourStorage.ts +68 -0
- package/lib/dataDir.test.ts +87 -0
- package/lib/dataDir.ts +83 -0
- package/lib/devDataPreferenceFile.ts +55 -0
- package/lib/devDataRuntimeInfo.ts +34 -0
- package/lib/formatIsoShort.test.ts +46 -0
- package/lib/formatIsoShort.ts +29 -0
- package/lib/generatedUserChangelog.ts +34 -0
- package/lib/gitlabIssueSearch.ts +8 -0
- package/lib/kronoFocusDurationHistory.ts +71 -0
- package/lib/kronoFocusRhythm.test.ts +130 -0
- package/lib/kronoFocusRhythm.ts +46 -0
- package/lib/kronoFocusTimerUrgency.test.ts +74 -0
- package/lib/kronoFocusTimerUrgency.ts +24 -0
- package/lib/kronosysApi.ts +143 -0
- package/lib/legacyEditorPayloadKeys.ts +52 -0
- package/lib/legacyKronoFocusStorageKeys.test.ts +29 -0
- package/lib/legacyKronoFocusStorageKeys.ts +32 -0
- package/lib/licensesCopy.ts +128 -0
- package/lib/openPlainTextInNewTab.ts +49 -0
- package/lib/readKronosysPackageVersion.ts +10 -0
- package/lib/reportingAggregate.test.ts +325 -0
- package/lib/reportingAggregate.ts +819 -0
- package/lib/reportingDatePresets.ts +41 -0
- package/lib/reportingMetricHelp.ts +430 -0
- package/lib/reportingNonFinalIndicators.test.ts +157 -0
- package/lib/reportingNonFinalIndicators.ts +102 -0
- package/lib/reportingStrings.ts +491 -0
- package/lib/reportingTagWeekBreakdown.test.ts +141 -0
- package/lib/reportingTagWeekBreakdown.ts +181 -0
- package/lib/reportingWeekLayout.test.ts +239 -0
- package/lib/reportingWeekLayout.ts +313 -0
- package/lib/sessionAssiduity.test.ts +25 -0
- package/lib/sessionAssiduity.ts +33 -0
- package/lib/sessionEndReason.ts +55 -0
- package/lib/sessionEndWarnings.test.ts +200 -0
- package/lib/sessionEndWarnings.ts +125 -0
- package/lib/sessionListMerge.test.ts +101 -0
- package/lib/sessionListMerge.ts +70 -0
- package/lib/sessionTaskSidebarStats.test.ts +24 -0
- package/lib/sessionTaskSidebarStats.ts +54 -0
- package/lib/settingsCopy.ts +1276 -0
- package/lib/taskParsing.test.ts +153 -0
- package/lib/taskParsing.ts +737 -0
- package/lib/theme.ts +15 -0
- package/lib/translucentButtonClasses.ts +34 -0
- package/lib/usageProfile.test.ts +84 -0
- package/lib/usageProfile.ts +52 -0
- package/lib/userGuideCopy.ts +464 -0
- package/lib/workspaceLocDefaults.ts +21 -0
- package/next-env.d.ts +6 -0
- package/next.config.ts +15 -0
- package/package.json +87 -0
- package/postcss.config.mjs +12 -0
- package/public/apple-icon.png +0 -0
- package/public/favicon.ico +0 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/icon-192.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/icon.png +0 -0
- package/public/next.svg +1 -0
- package/public/sw.js +13 -0
- package/public/traceback.png +0 -0
- package/public/vercel.svg +1 -0
- package/public/window.svg +1 -0
- package/server/actionDispatch.test.ts +723 -0
- package/server/actionDispatch.ts +1476 -0
- package/server/actionTaskSession.test.ts +713 -0
- package/server/actionTaskSession.ts +717 -0
- package/server/db.ts +42 -0
- package/server/defaultCfg.ts +87 -0
- package/server/gitlabTokenStore.ts +34 -0
- package/server/kronoFocusHydrate.test.ts +142 -0
- package/server/kronoFocusHydrate.ts +69 -0
- package/server/kronoFocusMigrate.test.ts +53 -0
- package/server/kronoFocusMigrate.ts +78 -0
- package/server/mainTimerHydrate.test.ts +65 -0
- package/server/mainTimerHydrate.ts +53 -0
- package/server/payloadStore.test.ts +78 -0
- package/server/payloadStore.ts +83 -0
- package/server/sessionWallHydrate.test.ts +46 -0
- package/server/sessionWallHydrate.ts +88 -0
- package/tsconfig.json +41 -0
|
@@ -0,0 +1,141 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import type { ReportingTagTimeDayRow } from "@/lib/reportingAggregate";
|
|
2
|
+
import type { TagWeekCalendarRow } from "@/lib/reportingWeekLayout";
|
|
3
|
+
import {
|
|
4
|
+
formatProjectDisplay,
|
|
5
|
+
isFallbackTaskTagKey,
|
|
6
|
+
normalizeProjectKey,
|
|
7
|
+
normalizeTagKey,
|
|
8
|
+
parseProjectScopedTag,
|
|
9
|
+
} from "@/lib/taskParsing";
|
|
10
|
+
|
|
11
|
+
export type TagWeekDisplayLeaf = { kind: "leaf"; row: TagWeekCalendarRow };
|
|
12
|
+
|
|
13
|
+
export type TagWeekDisplayRollup = {
|
|
14
|
+
kind: "rollup";
|
|
15
|
+
projectKeyLower: string;
|
|
16
|
+
displayProject: string;
|
|
17
|
+
weekStart: string;
|
|
18
|
+
parentSlots: number[];
|
|
19
|
+
parentTotal: number;
|
|
20
|
+
children: TagWeekCalendarRow[];
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type TagWeekDisplayBlock = TagWeekDisplayLeaf | TagWeekDisplayRollup;
|
|
24
|
+
|
|
25
|
+
function sumSlots(rows: readonly TagWeekCalendarRow[]): number[] {
|
|
26
|
+
const acc = [0, 0, 0, 0, 0, 0, 0];
|
|
27
|
+
for (const r of rows) {
|
|
28
|
+
for (let i = 0; i < 7; i++) {
|
|
29
|
+
acc[i] += r.slots[i] ?? 0;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return acc;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Prépare les lignes du calendrier hebdo par étiquette : étiquettes globales et sans étiquette telles quelles ;
|
|
37
|
+
* les étiquettes `projet#suffixe` (plusieurs pour le même projet) deviennent un bloc repliable avec total par projet.
|
|
38
|
+
*/
|
|
39
|
+
export function buildTagWeekDisplayBlocks(rows: readonly TagWeekCalendarRow[]): TagWeekDisplayBlock[] {
|
|
40
|
+
const scopedBuckets = new Map<string, TagWeekCalendarRow[]>();
|
|
41
|
+
const globals: TagWeekCalendarRow[] = [];
|
|
42
|
+
let untagged: TagWeekCalendarRow | null = null;
|
|
43
|
+
|
|
44
|
+
for (const row of rows) {
|
|
45
|
+
if (isFallbackTaskTagKey(row.tagKey)) {
|
|
46
|
+
untagged = row;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
const scoped = parseProjectScopedTag(row.tagKey);
|
|
50
|
+
if (!scoped) {
|
|
51
|
+
globals.push(row);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
const pk = normalizeProjectKey(scoped.projectKey).toLowerCase();
|
|
55
|
+
const list = scopedBuckets.get(pk) ?? [];
|
|
56
|
+
list.push(row);
|
|
57
|
+
scopedBuckets.set(pk, list);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
globals.sort((a, b) => normalizeTagKey(a.tagKey).localeCompare(normalizeTagKey(b.tagKey)));
|
|
61
|
+
|
|
62
|
+
const blocks: TagWeekDisplayBlock[] = [];
|
|
63
|
+
for (const r of globals) {
|
|
64
|
+
blocks.push({ kind: "leaf", row: r });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const projKeys = [...scopedBuckets.keys()].sort((a, b) => a.localeCompare(b));
|
|
68
|
+
for (const pk of projKeys) {
|
|
69
|
+
const children = [...scopedBuckets.get(pk)!].sort((a, b) =>
|
|
70
|
+
normalizeTagKey(a.tagKey).localeCompare(normalizeTagKey(b.tagKey))
|
|
71
|
+
);
|
|
72
|
+
if (children.length === 1) {
|
|
73
|
+
blocks.push({ kind: "leaf", row: children[0] });
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const first = parseProjectScopedTag(children[0].tagKey);
|
|
77
|
+
const displayProject = first ? formatProjectDisplay(first.projectKey) : pk;
|
|
78
|
+
const weekStart = children[0].weekStart;
|
|
79
|
+
blocks.push({
|
|
80
|
+
kind: "rollup",
|
|
81
|
+
projectKeyLower: pk,
|
|
82
|
+
displayProject,
|
|
83
|
+
weekStart,
|
|
84
|
+
parentSlots: sumSlots(children),
|
|
85
|
+
parentTotal: children.reduce((s, c) => s + c.total, 0),
|
|
86
|
+
children,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (untagged) {
|
|
91
|
+
blocks.push({ kind: "leaf", row: untagged });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return blocks;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export type TagDayDisplayLeaf = { kind: "leaf"; row: ReportingTagTimeDayRow };
|
|
98
|
+
|
|
99
|
+
export type TagDayDisplayRollup = {
|
|
100
|
+
kind: "rollup";
|
|
101
|
+
projectKeyLower: string;
|
|
102
|
+
displayProject: string;
|
|
103
|
+
day: string;
|
|
104
|
+
parentMinutes: number;
|
|
105
|
+
children: ReportingTagTimeDayRow[];
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
export type TagDayDisplayBlock = TagDayDisplayLeaf | TagDayDisplayRollup;
|
|
109
|
+
|
|
110
|
+
function buildTagDaySegmentBlocks(segment: readonly ReportingTagTimeDayRow[]): TagDayDisplayBlock[] {
|
|
111
|
+
const scopedBuckets = new Map<string, ReportingTagTimeDayRow[]>();
|
|
112
|
+
const globals: ReportingTagTimeDayRow[] = [];
|
|
113
|
+
let untagged: ReportingTagTimeDayRow | null = null;
|
|
114
|
+
|
|
115
|
+
for (const row of segment) {
|
|
116
|
+
if (isFallbackTaskTagKey(row.tagKey)) {
|
|
117
|
+
untagged = row;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
const scoped = parseProjectScopedTag(row.tagKey);
|
|
121
|
+
if (!scoped) {
|
|
122
|
+
globals.push(row);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
const pk = normalizeProjectKey(scoped.projectKey).toLowerCase();
|
|
126
|
+
const list = scopedBuckets.get(pk) ?? [];
|
|
127
|
+
list.push(row);
|
|
128
|
+
scopedBuckets.set(pk, list);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
globals.sort((a, b) => normalizeTagKey(a.tagKey).localeCompare(normalizeTagKey(b.tagKey)));
|
|
132
|
+
|
|
133
|
+
const blocks: TagDayDisplayBlock[] = [];
|
|
134
|
+
for (const r of globals) {
|
|
135
|
+
blocks.push({ kind: "leaf", row: r });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const projKeys = [...scopedBuckets.keys()].sort((a, b) => a.localeCompare(b));
|
|
139
|
+
for (const pk of projKeys) {
|
|
140
|
+
const children = [...scopedBuckets.get(pk)!].sort((a, b) =>
|
|
141
|
+
normalizeTagKey(a.tagKey).localeCompare(normalizeTagKey(b.tagKey))
|
|
142
|
+
);
|
|
143
|
+
if (children.length === 1) {
|
|
144
|
+
blocks.push({ kind: "leaf", row: children[0] });
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
const first = parseProjectScopedTag(children[0].tagKey);
|
|
148
|
+
const displayProject = first ? formatProjectDisplay(first.projectKey) : pk;
|
|
149
|
+
const day = children[0].day;
|
|
150
|
+
const parentMinutes = children.reduce((s, c) => s + c.minutes, 0);
|
|
151
|
+
blocks.push({
|
|
152
|
+
kind: "rollup",
|
|
153
|
+
projectKeyLower: pk,
|
|
154
|
+
displayProject,
|
|
155
|
+
day,
|
|
156
|
+
parentMinutes,
|
|
157
|
+
children,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (untagged) {
|
|
162
|
+
blocks.push({ kind: "leaf", row: untagged });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return blocks;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Regroupe les lignes « par jour » par date, avec blocs repliables par projet pour les `projet#code`. */
|
|
169
|
+
export function groupTagDayRowsForDisplay(
|
|
170
|
+
rows: readonly ReportingTagTimeDayRow[]
|
|
171
|
+
): Array<{ day: string; blocks: TagDayDisplayBlock[] }> {
|
|
172
|
+
const byDay = new Map<string, ReportingTagTimeDayRow[]>();
|
|
173
|
+
for (const r of rows) {
|
|
174
|
+
const list = byDay.get(r.day) ?? [];
|
|
175
|
+
list.push(r);
|
|
176
|
+
byDay.set(r.day, list);
|
|
177
|
+
}
|
|
178
|
+
return [...byDay.keys()]
|
|
179
|
+
.sort()
|
|
180
|
+
.map((day) => ({ day, blocks: buildTagDaySegmentBlocks(byDay.get(day) ?? []) }));
|
|
181
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
addDaysYmd,
|
|
5
|
+
buildKeyedWeekCalendarRows,
|
|
6
|
+
buildProjectWeekCalendarRows,
|
|
7
|
+
buildTagWeekCalendarRows,
|
|
8
|
+
formatWeekRangeLabel,
|
|
9
|
+
localCalendarDaysBetween,
|
|
10
|
+
localWeekStartKeyFromDayKey,
|
|
11
|
+
weekdayColumnLabels,
|
|
12
|
+
weekdayDateColumnHeaders,
|
|
13
|
+
} from "./reportingWeekLayout";
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// addDaysYmd
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
describe("addDaysYmd", () => {
|
|
19
|
+
it("ajoute des jours positifs", () => {
|
|
20
|
+
expect(addDaysYmd("2026-04-29", 1)).toBe("2026-04-30");
|
|
21
|
+
expect(addDaysYmd("2026-04-29", 7)).toBe("2026-05-06");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("soustrait des jours (delta négatif)", () => {
|
|
25
|
+
expect(addDaysYmd("2026-05-01", -1)).toBe("2026-04-30");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("traverse les fins de mois et d'année", () => {
|
|
29
|
+
expect(addDaysYmd("2026-12-31", 1)).toBe("2027-01-01");
|
|
30
|
+
expect(addDaysYmd("2026-02-28", 1)).toBe("2026-03-01");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("delta = 0 retourne la même date", () => {
|
|
34
|
+
expect(addDaysYmd("2026-04-15", 0)).toBe("2026-04-15");
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// localCalendarDaysBetween
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
describe("localCalendarDaysBetween", () => {
|
|
42
|
+
it("même jour → 0", () => {
|
|
43
|
+
expect(localCalendarDaysBetween("2026-04-29", "2026-04-29")).toBe(0);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("différence positive", () => {
|
|
47
|
+
expect(localCalendarDaysBetween("2026-04-20", "2026-04-27")).toBe(7);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("différence négative (fin < début)", () => {
|
|
51
|
+
expect(localCalendarDaysBetween("2026-04-27", "2026-04-20")).toBe(-7);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// localWeekStartKeyFromDayKey
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
describe("localWeekStartKeyFromDayKey", () => {
|
|
59
|
+
it("retourne null pour chaîne vide", () => {
|
|
60
|
+
expect(localWeekStartKeyFromDayKey("", "monday")).toBeNull();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("retourne null pour 'undated'", () => {
|
|
64
|
+
expect(localWeekStartKeyFromDayKey("undated", "monday")).toBeNull();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("retourne null pour format invalide", () => {
|
|
68
|
+
expect(localWeekStartKeyFromDayKey("2026-04", "monday")).toBeNull();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("lundi de la semaine contenant mercredi 2026-04-29 (weekStartsOn=monday)", () => {
|
|
72
|
+
// 2026-04-29 est un mercredi → lundi = 2026-04-27
|
|
73
|
+
expect(localWeekStartKeyFromDayKey("2026-04-29", "monday")).toBe("2026-04-27");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("dimanche de la semaine contenant mercredi 2026-04-29 (weekStartsOn=sunday)", () => {
|
|
77
|
+
// semaine commence dimanche → 2026-04-26
|
|
78
|
+
expect(localWeekStartKeyFromDayKey("2026-04-29", "sunday")).toBe("2026-04-26");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("samedi de la semaine (weekStartsOn=saturday)", () => {
|
|
82
|
+
// semaine commence samedi → 2026-04-25
|
|
83
|
+
expect(localWeekStartKeyFromDayKey("2026-04-29", "saturday")).toBe("2026-04-25");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("le premier jour de la semaine pointe sur lui-même", () => {
|
|
87
|
+
// 2026-04-27 est un lundi
|
|
88
|
+
expect(localWeekStartKeyFromDayKey("2026-04-27", "monday")).toBe("2026-04-27");
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
// weekdayColumnLabels
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
describe("weekdayColumnLabels", () => {
|
|
96
|
+
it("retourne 7 libellés", () => {
|
|
97
|
+
expect(weekdayColumnLabels("monday", "en-CA")).toHaveLength(7);
|
|
98
|
+
expect(weekdayColumnLabels("sunday", "fr-CA")).toHaveLength(7);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("les libellés sont non vides", () => {
|
|
102
|
+
const labels = weekdayColumnLabels("monday", "en-CA");
|
|
103
|
+
for (const l of labels) {
|
|
104
|
+
expect(l.trim().length).toBeGreaterThan(0);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// weekdayDateColumnHeaders
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
describe("weekdayDateColumnHeaders", () => {
|
|
113
|
+
it("retourne 7 en-têtes depuis le weekStartKey", () => {
|
|
114
|
+
const headers = weekdayDateColumnHeaders("2026-04-27", "en-CA");
|
|
115
|
+
expect(headers).toHaveLength(7);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("le premier dateKey correspond au weekStartKey", () => {
|
|
119
|
+
const headers = weekdayDateColumnHeaders("2026-04-27", "en-CA");
|
|
120
|
+
expect(headers[0].dateKey).toBe("2026-04-27");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("le dernier dateKey est weekStart + 6 jours", () => {
|
|
124
|
+
const headers = weekdayDateColumnHeaders("2026-04-27", "en-CA");
|
|
125
|
+
expect(headers[6].dateKey).toBe("2026-05-03");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("chaque en-tête a weekdayShort et calendarDateShort non vides", () => {
|
|
129
|
+
const headers = weekdayDateColumnHeaders("2026-04-27", "fr-CA");
|
|
130
|
+
for (const h of headers) {
|
|
131
|
+
expect(h.weekdayShort.trim().length).toBeGreaterThan(0);
|
|
132
|
+
expect(h.calendarDateShort.trim().length).toBeGreaterThan(0);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
// formatWeekRangeLabel
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
describe("formatWeekRangeLabel", () => {
|
|
141
|
+
it("retourne une chaîne non vide", () => {
|
|
142
|
+
const label = formatWeekRangeLabel("2026-04-27", "fr-CA");
|
|
143
|
+
expect(label.trim().length).toBeGreaterThan(0);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("inclut l'année dans le label", () => {
|
|
147
|
+
const label = formatWeekRangeLabel("2026-04-27", "en-CA");
|
|
148
|
+
expect(label).toContain("2026");
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
// buildKeyedWeekCalendarRows
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
describe("buildKeyedWeekCalendarRows", () => {
|
|
156
|
+
it("retourne un tableau vide pour une entrée vide", () => {
|
|
157
|
+
expect(buildKeyedWeekCalendarRows([], "monday")).toEqual([]);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("ignore les lignes 'undated'", () => {
|
|
161
|
+
const rows = [{ day: "undated", rowKey: "dev", displayLabel: "Dev", minutes: 60 }];
|
|
162
|
+
expect(buildKeyedWeekCalendarRows(rows, "monday")).toEqual([]);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("ignore les lignes avec minutes <= 0", () => {
|
|
166
|
+
const rows = [{ day: "2026-04-27", rowKey: "dev", displayLabel: "Dev", minutes: 0 }];
|
|
167
|
+
expect(buildKeyedWeekCalendarRows(rows, "monday")).toEqual([]);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("agrège correctement deux entrées dans la même semaine pour la même clé", () => {
|
|
171
|
+
const rows = [
|
|
172
|
+
{ day: "2026-04-27", rowKey: "dev", displayLabel: "Dev", minutes: 30 },
|
|
173
|
+
{ day: "2026-04-28", rowKey: "dev", displayLabel: "Dev", minutes: 45 },
|
|
174
|
+
];
|
|
175
|
+
const result = buildKeyedWeekCalendarRows(rows, "monday");
|
|
176
|
+
expect(result).toHaveLength(1);
|
|
177
|
+
expect(result[0].total).toBe(75);
|
|
178
|
+
expect(result[0].slots[0]).toBe(30); // lundi (idx 0)
|
|
179
|
+
expect(result[0].slots[1]).toBe(45); // mardi (idx 1)
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("deux clés différentes → deux lignes", () => {
|
|
183
|
+
const rows = [
|
|
184
|
+
{ day: "2026-04-27", rowKey: "dev", displayLabel: "Dev", minutes: 30 },
|
|
185
|
+
{ day: "2026-04-27", rowKey: "design", displayLabel: "Design", minutes: 20 },
|
|
186
|
+
];
|
|
187
|
+
const result = buildKeyedWeekCalendarRows(rows, "monday");
|
|
188
|
+
expect(result).toHaveLength(2);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("deux semaines différentes → deux lignes pour la même clé", () => {
|
|
192
|
+
const rows = [
|
|
193
|
+
{ day: "2026-04-27", rowKey: "dev", displayLabel: "Dev", minutes: 30 },
|
|
194
|
+
{ day: "2026-05-04", rowKey: "dev", displayLabel: "Dev", minutes: 60 },
|
|
195
|
+
];
|
|
196
|
+
const result = buildKeyedWeekCalendarRows(rows, "monday");
|
|
197
|
+
expect(result).toHaveLength(2);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("la clé vide est triée en dernier (empty key last)", () => {
|
|
201
|
+
const rows = [
|
|
202
|
+
{ day: "2026-04-27", rowKey: "", displayLabel: "", minutes: 10 },
|
|
203
|
+
{ day: "2026-04-27", rowKey: "zzz", displayLabel: "Zzz", minutes: 10 },
|
|
204
|
+
{ day: "2026-04-27", rowKey: "aaa", displayLabel: "Aaa", minutes: 10 },
|
|
205
|
+
];
|
|
206
|
+
const result = buildKeyedWeekCalendarRows(rows, "monday");
|
|
207
|
+
expect(result[result.length - 1].rowKey).toBe("");
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
// buildTagWeekCalendarRows (wrapper)
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
describe("buildTagWeekCalendarRows", () => {
|
|
215
|
+
it("mappe correctement tagKey et displayTag", () => {
|
|
216
|
+
const rows = [
|
|
217
|
+
{ day: "2026-04-27", tagKey: "dev", displayTag: "Développement", minutes: 60 },
|
|
218
|
+
];
|
|
219
|
+
const result = buildTagWeekCalendarRows(rows, "monday");
|
|
220
|
+
expect(result[0].tagKey).toBe("dev");
|
|
221
|
+
expect(result[0].displayTag).toBe("Développement");
|
|
222
|
+
expect(result[0].total).toBe(60);
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
// buildProjectWeekCalendarRows (wrapper)
|
|
228
|
+
// ---------------------------------------------------------------------------
|
|
229
|
+
describe("buildProjectWeekCalendarRows", () => {
|
|
230
|
+
it("mappe correctement projectKey et displayProject", () => {
|
|
231
|
+
const rows = [
|
|
232
|
+
{ day: "2026-04-27", projectKey: "acme", displayProject: "Acme Corp", minutes: 90 },
|
|
233
|
+
];
|
|
234
|
+
const result = buildProjectWeekCalendarRows(rows, "monday");
|
|
235
|
+
expect(result[0].projectKey).toBe("acme");
|
|
236
|
+
expect(result[0].displayProject).toBe("Acme Corp");
|
|
237
|
+
expect(result[0].total).toBe(90);
|
|
238
|
+
});
|
|
239
|
+
});
|