@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,737 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Détection des `#token` dans un titre ou brouillon :
|
|
3
|
+
* - après un espace ou en début : `… #release`, `#seul`
|
|
4
|
+
* - collé au mot précédent : `mot#tag`, `issue#123`
|
|
5
|
+
*/
|
|
6
|
+
const AUTO_TAG_COMBINED = /(?:^|\s)#([^\s#]+)|(?<=[^\s#])#([^\s#]+)/g;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Détection des `@projet` (mêmes idées que les `#tags`).
|
|
10
|
+
*/
|
|
11
|
+
const AUTO_PROJECT_COMBINED = /(?:^|\s)@([^\s@]+)|(?<=[^\s@])@([^\s@]+)/g;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* `@projet#suffixe` : étiquette liée à un projet (stockée comme `projet#suffixe`, sans `@`).
|
|
15
|
+
* Traitée avant les `@projet` seuls pour ne pas confondre le corps `projet#…` avec un nom de projet.
|
|
16
|
+
*/
|
|
17
|
+
const PROJECT_SCOPED_AT = /(?:^|\s)@([^\s@#]+)#([^\s#]+)/g;
|
|
18
|
+
|
|
19
|
+
/** Extrait les jetons `@projet#suffixe` et retourne le texte restant. */
|
|
20
|
+
export function stripScopedProjectAtTokens(raw: string): {
|
|
21
|
+
core: string;
|
|
22
|
+
scopedTags: string[];
|
|
23
|
+
scopedProjectHint?: string;
|
|
24
|
+
} {
|
|
25
|
+
const scopedTags: string[] = [];
|
|
26
|
+
let scopedProjectHint: string | undefined;
|
|
27
|
+
const seen = new Set<string>();
|
|
28
|
+
const core = raw
|
|
29
|
+
.replace(PROJECT_SCOPED_AT, (_full, g1: string, g2: string) => {
|
|
30
|
+
const proj = (g1 || "").trim();
|
|
31
|
+
const loc = (g2 || "").trim();
|
|
32
|
+
if (!proj || !loc) {
|
|
33
|
+
return "";
|
|
34
|
+
}
|
|
35
|
+
if (!scopedProjectHint) {
|
|
36
|
+
scopedProjectHint = proj;
|
|
37
|
+
}
|
|
38
|
+
const canon = `${proj}#${loc}`;
|
|
39
|
+
const k = canon.toLowerCase();
|
|
40
|
+
if (!seen.has(k)) {
|
|
41
|
+
seen.add(k);
|
|
42
|
+
scopedTags.push(canon);
|
|
43
|
+
}
|
|
44
|
+
return "";
|
|
45
|
+
})
|
|
46
|
+
.replace(/\s+/g, " ")
|
|
47
|
+
.trim();
|
|
48
|
+
return { core, scopedTags, scopedProjectHint };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function parseProjectScopedTag(tag: string): { projectKey: string; localTag: string } | null {
|
|
52
|
+
const t = tag.trim();
|
|
53
|
+
const i = t.indexOf("#");
|
|
54
|
+
if (i <= 0 || i >= t.length - 1) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
const projectKey = t.slice(0, i).trim();
|
|
58
|
+
const localTag = t.slice(i + 1).trim();
|
|
59
|
+
if (!projectKey || !localTag || localTag.includes("#")) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
return { projectKey, localTag };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function isProjectScopedTag(tag: string): boolean {
|
|
66
|
+
return parseProjectScopedTag(tag) !== null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Étiquette « globale » (sans `#` de portée) ou étiquette `@projet#code` compatible avec le projet courant. */
|
|
70
|
+
export function isTagAllowedForProject(tag: string, project: string | null | undefined): boolean {
|
|
71
|
+
const scoped = parseProjectScopedTag(tag);
|
|
72
|
+
if (!scoped) {
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
const p = project?.trim();
|
|
76
|
+
if (!p) {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
return scoped.projectKey.toLowerCase() === normalizeProjectKey(p).toLowerCase();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function filterKnownTagsForProject(knownTags: string[], project: string | null | undefined): string[] {
|
|
83
|
+
return knownTags.filter((t) => isTagAllowedForProject(t, project));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Retire les étiquettes `@projet#code` incompatibles avec le projet assigné (ou sans projet). */
|
|
87
|
+
export function filterTaskTagsForProject(tags: string[], project: string | null | undefined): string[] {
|
|
88
|
+
const p = project?.trim();
|
|
89
|
+
if (!p) {
|
|
90
|
+
return tags.filter((t) => !isProjectScopedTag(t));
|
|
91
|
+
}
|
|
92
|
+
return tags.filter((t) => isTagAllowedForProject(t, p));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Retire les `@projet` simples et retient le premier projet (une seule assignation par tâche). */
|
|
96
|
+
function stripPlainProjectsAndTakeFirst(raw: string): { core: string; project?: string } {
|
|
97
|
+
let first: string | undefined;
|
|
98
|
+
const core = raw
|
|
99
|
+
.replace(AUTO_PROJECT_COMBINED, (_full, g1: string, g2: string) => {
|
|
100
|
+
const body = (g1 || g2 || "").trim();
|
|
101
|
+
if (body && !body.includes("#") && !first) {
|
|
102
|
+
first = normalizeProjectKey(body);
|
|
103
|
+
}
|
|
104
|
+
return "";
|
|
105
|
+
})
|
|
106
|
+
.replace(/\s+/g, " ")
|
|
107
|
+
.trim();
|
|
108
|
+
return { core, project: first };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Retire `@projet#suffixe` puis `@projet` ; fusionne l’indice de projet issu des jetons liés. */
|
|
112
|
+
export function stripProjectsAndTakeFirst(raw: string): { core: string; project?: string; scopedTags: string[] } {
|
|
113
|
+
const { core: c0, scopedTags, scopedProjectHint } = stripScopedProjectAtTokens(raw);
|
|
114
|
+
const { core, project: plainProject } = stripPlainProjectsAndTakeFirst(c0);
|
|
115
|
+
const project = plainProject ?? scopedProjectHint;
|
|
116
|
+
return { core, project, scopedTags };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function normalizeProjectKey(s: string): string {
|
|
120
|
+
return s.replace(/^@+/, "").trim();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Si le brouillon contient au moins un `@`, met à jour le projet (ou null si aucun jeton valide).
|
|
125
|
+
* Sinon ne pas modifier le champ projet côté API (`undefined`).
|
|
126
|
+
*/
|
|
127
|
+
export function resolveProjectForTaskUpdate(rawTitle: string): string | null | undefined {
|
|
128
|
+
if (!/@/.test(rawTitle)) {
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
const { project } = stripProjectsAndTakeFirst(rawTitle);
|
|
132
|
+
return project ?? null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Projet à enregistrer à partir d’une ligne de tâche complète (champ unique avec `#`, `@`, `@p#t`).
|
|
137
|
+
* Aucun `@` dans la chaîne ⇒ aucun projet (`null`). Sinon, premier projet déduit comme à la saisie.
|
|
138
|
+
*/
|
|
139
|
+
export function resolveProjectFromFullTaskLine(rawTitle: string): string | null {
|
|
140
|
+
const t = rawTitle.replace(/\s+/g, " ").trim();
|
|
141
|
+
if (!/@/.test(t)) {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
const { project } = stripProjectsAndTakeFirst(t);
|
|
145
|
+
return project ? normalizeProjectKey(project) : null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Découpe le titre brut pour afficher le texte et les segments `#tag` comme pastilles (mêmes règles que {@link parseTaskWithAutoTags}). */
|
|
149
|
+
export function splitTaskTitleSegments(raw: string): Array<{ kind: "text" | "tag"; value: string }> {
|
|
150
|
+
const re = new RegExp(AUTO_TAG_COMBINED.source, "g");
|
|
151
|
+
const out: Array<{ kind: "text" | "tag"; value: string }> = [];
|
|
152
|
+
let last = 0;
|
|
153
|
+
let m: RegExpExecArray | null;
|
|
154
|
+
while ((m = re.exec(raw)) !== null) {
|
|
155
|
+
if (m.index > last) {
|
|
156
|
+
const text = raw.slice(last, m.index);
|
|
157
|
+
if (text.length > 0) {
|
|
158
|
+
out.push({ kind: "text", value: text });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const body = (m[1] || m[2] || "").trim();
|
|
162
|
+
if (body.length > 0) {
|
|
163
|
+
out.push({ kind: "tag", value: `#${body}` });
|
|
164
|
+
}
|
|
165
|
+
last = m.index + m[0].length;
|
|
166
|
+
}
|
|
167
|
+
if (last < raw.length) {
|
|
168
|
+
const text = raw.slice(last);
|
|
169
|
+
if (text.length > 0) {
|
|
170
|
+
out.push({ kind: "text", value: text });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (out.length === 0 && raw.trim().length > 0) {
|
|
174
|
+
out.push({ kind: "text", value: raw });
|
|
175
|
+
}
|
|
176
|
+
return out;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Étiquettes détectées dans `raw`, dans l’ordre d’apparition (gauche → droite),
|
|
181
|
+
* sans doublon (insensible à la casse) — aligné sur {@link splitTaskTitleSegments}.
|
|
182
|
+
*/
|
|
183
|
+
function mergeDedupedTagLists(primary: string[], secondary: string[]): string[] {
|
|
184
|
+
const seen = new Set<string>();
|
|
185
|
+
const out: string[] = [];
|
|
186
|
+
const add = (t: string) => {
|
|
187
|
+
const n = normalizeTagKey(t).trim();
|
|
188
|
+
if (!n) {
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const k = n.toLowerCase();
|
|
192
|
+
if (seen.has(k)) {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
seen.add(k);
|
|
196
|
+
out.push(n);
|
|
197
|
+
};
|
|
198
|
+
for (const t of primary) {
|
|
199
|
+
add(t);
|
|
200
|
+
}
|
|
201
|
+
for (const t of secondary) {
|
|
202
|
+
add(t);
|
|
203
|
+
}
|
|
204
|
+
return out;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function collectAutoTagsFromRaw(raw: string): string[] {
|
|
208
|
+
const { core: afterScoped, scopedTags } = stripScopedProjectAtTokens(raw);
|
|
209
|
+
const fromHash: string[] = [];
|
|
210
|
+
const seen = new Set<string>();
|
|
211
|
+
for (const segment of splitTaskTitleSegments(afterScoped)) {
|
|
212
|
+
if (segment.kind !== "tag") {
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
const body = segment.value.replace(/^#/, "").trim();
|
|
216
|
+
if (body.length === 0) {
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
const key = body.toLowerCase();
|
|
220
|
+
if (seen.has(key)) {
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
seen.add(key);
|
|
224
|
+
fromHash.push(body);
|
|
225
|
+
}
|
|
226
|
+
return mergeDedupedTagLists(scopedTags, fromHash);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function parseNameAndTagsFromCore(core: string): { name: string; tags: string[] } {
|
|
230
|
+
const tags = collectAutoTagsFromRaw(core);
|
|
231
|
+
const name = core
|
|
232
|
+
.replace(new RegExp(AUTO_TAG_COMBINED.source, "g"), "")
|
|
233
|
+
.replace(/\s+/g, " ")
|
|
234
|
+
.trim();
|
|
235
|
+
return { name, tags };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function parseTaskWithAutoTags(raw: string): { name: string; tags: string[]; project?: string } {
|
|
239
|
+
const { core, project, scopedTags } = stripProjectsAndTakeFirst(raw);
|
|
240
|
+
const { name, tags } = parseNameAndTagsFromCore(core);
|
|
241
|
+
const merged = mergeDedupedTagLists(scopedTags, tags);
|
|
242
|
+
return { name, tags: filterTaskTagsForProject(merged, project ?? null), project };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Interprète une ligne de saisie rapide sous une tâche (`#tag`, `@projet`, `@p#code`, texte seul comme étiquette).
|
|
247
|
+
* Les étiquettes retournées sont déjà filtrées par projet si un `@projet` est détecté dans la même chaîne.
|
|
248
|
+
*/
|
|
249
|
+
export function parseQuickAddTagOrProjectLine(input: string): { tags: string[]; project?: string | null } {
|
|
250
|
+
const v = input.trim();
|
|
251
|
+
if (!v) {
|
|
252
|
+
return { tags: [] };
|
|
253
|
+
}
|
|
254
|
+
/** Seul `@` : retirer le projet (équivalent d’un clic « désactiver » sur la pastille projet enregistrée). */
|
|
255
|
+
if (v === "@") {
|
|
256
|
+
return { tags: [], project: null };
|
|
257
|
+
}
|
|
258
|
+
const parsed = parseTaskWithAutoTags(v);
|
|
259
|
+
const name = parsed.name.replace(/\s+/g, "").trim();
|
|
260
|
+
if (parsed.tags.length === 0 && parsed.project === undefined && name.length > 0) {
|
|
261
|
+
return { tags: [normalizeTagKey(name)], project: undefined };
|
|
262
|
+
}
|
|
263
|
+
if (parsed.project === undefined) {
|
|
264
|
+
return { tags: parsed.tags, project: undefined };
|
|
265
|
+
}
|
|
266
|
+
const proj = parsed.project ? normalizeProjectKey(parsed.project) : null;
|
|
267
|
+
return { tags: parsed.tags, project: proj };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Fusionne plusieurs lignes choisies (ex. valeurs d’un `<select multiple>` : `#tag`, `@projet`, `@`).
|
|
272
|
+
* Les étiquettes sont dédupliquées (casse ignorée). Le dernier jeton qui fixe le projet l’emporte ; `@` seul retire le projet.
|
|
273
|
+
*/
|
|
274
|
+
export function mergeQuickAddFromOptionValues(values: string[]): { tags: string[]; project?: string | null } {
|
|
275
|
+
const seen = new Set<string>();
|
|
276
|
+
const tags: string[] = [];
|
|
277
|
+
let project: string | null | undefined = undefined;
|
|
278
|
+
let projectTouched = false;
|
|
279
|
+
|
|
280
|
+
for (const raw of values) {
|
|
281
|
+
const r = parseQuickAddTagOrProjectLine(raw);
|
|
282
|
+
for (const tg of r.tags) {
|
|
283
|
+
const nk = normalizeTagKey(tg);
|
|
284
|
+
if (!nk) {
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
const k = nk.toLowerCase();
|
|
288
|
+
if (seen.has(k)) {
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
seen.add(k);
|
|
292
|
+
tags.push(nk);
|
|
293
|
+
}
|
|
294
|
+
if (r.project !== undefined) {
|
|
295
|
+
projectTouched = true;
|
|
296
|
+
project = r.project;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const out: { tags: string[]; project?: string | null } = { tags };
|
|
301
|
+
if (projectTouched) {
|
|
302
|
+
out.project = project;
|
|
303
|
+
}
|
|
304
|
+
return out;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Texte de titre pour l’UI : sans segments `#tag` ni `@projet` (pastilles séparées).
|
|
309
|
+
*/
|
|
310
|
+
export function taskTitleForDisplay(raw: string): string {
|
|
311
|
+
const { name, tags } = parseTaskWithAutoTags(raw);
|
|
312
|
+
const n = name.trim();
|
|
313
|
+
if (n.length > 0) {
|
|
314
|
+
return n;
|
|
315
|
+
}
|
|
316
|
+
if (tags.length > 0) {
|
|
317
|
+
return "—";
|
|
318
|
+
}
|
|
319
|
+
return raw.trim() || "—";
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Valeur initiale du champ d’édition du titre (sans `#tags` ni `@projet` : pastilles en dessous).
|
|
324
|
+
* Chaîne vide lorsqu’il n’y a pas de libellé textuel (équivalent du « — » en lecture seule).
|
|
325
|
+
*/
|
|
326
|
+
export function taskTitleEditBaseline(storedName: string): string {
|
|
327
|
+
const { name, tags } = parseTaskWithAutoTags(storedName);
|
|
328
|
+
const n = name.trim();
|
|
329
|
+
if (n.length > 0) {
|
|
330
|
+
return n;
|
|
331
|
+
}
|
|
332
|
+
if (tags.length > 0) {
|
|
333
|
+
return "";
|
|
334
|
+
}
|
|
335
|
+
return storedName.trim();
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Même extraction que {@link parseTaskWithAutoTags}, mais sans rogner le titre :
|
|
340
|
+
* nécessaire pour le champ de saisie contrôlé.
|
|
341
|
+
*/
|
|
342
|
+
export function parseTaskDraftParts(raw: string): { name: string; tags: string[]; project?: string } {
|
|
343
|
+
const { core, project, scopedTags } = stripProjectsAndTakeFirst(raw);
|
|
344
|
+
const fromCore = collectAutoTagsFromRaw(core);
|
|
345
|
+
const tags = mergeDedupedTagLists(scopedTags, fromCore);
|
|
346
|
+
const name = core.replace(new RegExp(AUTO_TAG_COMBINED.source, "g"), "");
|
|
347
|
+
return { name, tags, project };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function appendTagTokenToDraft(s: string, tag: string): string {
|
|
351
|
+
const t = normalizeTagKey(tag).trim();
|
|
352
|
+
if (!t) {
|
|
353
|
+
return s;
|
|
354
|
+
}
|
|
355
|
+
const scoped = parseProjectScopedTag(t);
|
|
356
|
+
if (scoped) {
|
|
357
|
+
const piece = `@${scoped.projectKey}#${scoped.localTag}`;
|
|
358
|
+
const trimmed = s.replace(/\s+$/, "");
|
|
359
|
+
return trimmed ? `${trimmed}${piece}` : piece;
|
|
360
|
+
}
|
|
361
|
+
const trimmedEnd = s.replace(/\s+$/, "");
|
|
362
|
+
return trimmedEnd ? `${trimmedEnd}#${t}` : `#${t}`;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Reconstruit le brouillon (titre + #tags + `@projet#code` + @projet optionnel). */
|
|
366
|
+
export function rebuildTaskDraftCore(name: string, tags: string[], project?: string): string {
|
|
367
|
+
let s = name;
|
|
368
|
+
for (const t of tags) {
|
|
369
|
+
s = appendTagTokenToDraft(s, t);
|
|
370
|
+
}
|
|
371
|
+
const p = project?.trim();
|
|
372
|
+
if (p) {
|
|
373
|
+
const pk = normalizeProjectKey(p);
|
|
374
|
+
const implied = tags.some((tg) => {
|
|
375
|
+
const sc = parseProjectScopedTag(normalizeTagKey(tg));
|
|
376
|
+
return sc && sc.projectKey.toLowerCase() === pk.toLowerCase();
|
|
377
|
+
});
|
|
378
|
+
if (!implied) {
|
|
379
|
+
const trimmed = s.replace(/\s+$/, "");
|
|
380
|
+
s = trimmed ? `${trimmed}@${pk}` : `@${pk}`;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
return s;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Reconstruit la chaîne « titre + #tags + `@p#t` [+ @projet] » alignée sur {@link parseTaskWithAutoTags}. */
|
|
387
|
+
export function rebuildTaskRawString(name: string, tags: string[], project?: string | null): string {
|
|
388
|
+
const n = name.replace(/\s+/g, " ").trim();
|
|
389
|
+
let tagPart = n;
|
|
390
|
+
for (const t of tags) {
|
|
391
|
+
tagPart = appendTagTokenToDraft(tagPart, t);
|
|
392
|
+
}
|
|
393
|
+
const p = project?.trim();
|
|
394
|
+
if (!p) {
|
|
395
|
+
return tagPart;
|
|
396
|
+
}
|
|
397
|
+
const pk = normalizeProjectKey(p);
|
|
398
|
+
const implied = tags.some((tg) => {
|
|
399
|
+
const sc = parseProjectScopedTag(normalizeTagKey(tg));
|
|
400
|
+
return sc && sc.projectKey.toLowerCase() === pk.toLowerCase();
|
|
401
|
+
});
|
|
402
|
+
if (implied) {
|
|
403
|
+
return tagPart;
|
|
404
|
+
}
|
|
405
|
+
const trimmed = tagPart.replace(/\s+$/, "");
|
|
406
|
+
return trimmed ? `${trimmed}@${pk}` : `@${pk}`;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Chaîne pour le champ d’édition : nom stocké sans `#` / `@` + pastilles fusionnées + projet.
|
|
411
|
+
*/
|
|
412
|
+
export function displayRawTaskTitle(
|
|
413
|
+
storedName: string,
|
|
414
|
+
mergedTags: string[],
|
|
415
|
+
mergedProject?: string | null
|
|
416
|
+
): string {
|
|
417
|
+
const { name: stripped } = parseTaskWithAutoTags(storedName);
|
|
418
|
+
const base = stripped.replace(/\s+/g, " ").trim();
|
|
419
|
+
const baseKey = base.toLowerCase();
|
|
420
|
+
const suffixTags = mergedTags.filter((t) => normalizeTagKey(t).toLowerCase() !== baseKey);
|
|
421
|
+
const suffix = suffixTags
|
|
422
|
+
.map((t) => {
|
|
423
|
+
const tk = normalizeTagKey(t);
|
|
424
|
+
const sc = parseProjectScopedTag(tk);
|
|
425
|
+
if (sc) {
|
|
426
|
+
return `@${sc.projectKey}#${sc.localTag}`;
|
|
427
|
+
}
|
|
428
|
+
return `#${tk}`;
|
|
429
|
+
})
|
|
430
|
+
.join(" ");
|
|
431
|
+
let out: string;
|
|
432
|
+
if (!suffix) {
|
|
433
|
+
out = base || storedName.trim();
|
|
434
|
+
} else if (!base) {
|
|
435
|
+
out = suffix;
|
|
436
|
+
} else {
|
|
437
|
+
out = `${base} ${suffix}`;
|
|
438
|
+
}
|
|
439
|
+
const p = mergedProject?.trim();
|
|
440
|
+
if (p) {
|
|
441
|
+
const pk = normalizeProjectKey(p);
|
|
442
|
+
const implied = suffixTags.some((tg) => {
|
|
443
|
+
const sc = parseProjectScopedTag(normalizeTagKey(tg));
|
|
444
|
+
return sc && sc.projectKey.toLowerCase() === pk.toLowerCase();
|
|
445
|
+
});
|
|
446
|
+
if (!implied) {
|
|
447
|
+
const trimmed = out.replace(/\s+$/, "");
|
|
448
|
+
out = trimmed ? `${trimmed}@${pk}` : `@${pk}`;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
return out;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** Ajoute un tag enregistré à la fin du brouillon (`#tag` ou `@projet#code`). */
|
|
455
|
+
export function appendSavedTagToDraft(current: string, tag: string): string {
|
|
456
|
+
const token = normalizeTagKey(tag);
|
|
457
|
+
if (!token) {
|
|
458
|
+
return current;
|
|
459
|
+
}
|
|
460
|
+
const parsed = parseTaskDraftParts(current);
|
|
461
|
+
const nextTags = mergeDedupedTagLists(parsed.tags, [token]);
|
|
462
|
+
const prevCore = rebuildTaskDraftCore(parsed.name, parsed.tags, parsed.project);
|
|
463
|
+
const suff = current.startsWith(prevCore) ? current.slice(prevCore.length) : "";
|
|
464
|
+
const core = rebuildTaskDraftCore(parsed.name, nextTags, parsed.project);
|
|
465
|
+
return core + suff;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** Retire une étiquette du brouillon (titre + jetons parsés), sans toucher au projet implicite. */
|
|
469
|
+
export function removeSavedTagFromDraft(current: string, tag: string): string {
|
|
470
|
+
const token = normalizeTagKey(tag);
|
|
471
|
+
if (!token) {
|
|
472
|
+
return current;
|
|
473
|
+
}
|
|
474
|
+
const key = token.toLowerCase();
|
|
475
|
+
const parsed = parseTaskDraftParts(current);
|
|
476
|
+
const nextTags = parsed.tags.filter((t) => normalizeTagKey(t).toLowerCase() !== key);
|
|
477
|
+
return rebuildTaskRawString(parsed.name, nextTags, parsed.project ?? null);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** Retire le projet (`@projet`) du brouillon sans modifier le libellé ni les étiquettes. */
|
|
481
|
+
export function removeProjectFromDraft(current: string): string {
|
|
482
|
+
const parsed = parseTaskDraftParts(current);
|
|
483
|
+
return rebuildTaskRawString(parsed.name, parsed.tags, null);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** Ajoute un projet connu à la fin du brouillon (`@nom`, sans espace avant `@` si le cœur se termine déjà par du texte). */
|
|
487
|
+
export function appendSavedProjectToDraft(current: string, project: string): string {
|
|
488
|
+
const token = normalizeProjectKey(project);
|
|
489
|
+
if (!token) {
|
|
490
|
+
return current;
|
|
491
|
+
}
|
|
492
|
+
const parsed = parseTaskDraftParts(current);
|
|
493
|
+
if (parsed.project && parsed.project.toLowerCase() === token.toLowerCase()) {
|
|
494
|
+
return current;
|
|
495
|
+
}
|
|
496
|
+
const core = rebuildTaskDraftCore(parsed.name, parsed.tags, token);
|
|
497
|
+
const suff = current.startsWith(core) ? current.slice(core.length) : "";
|
|
498
|
+
return core + suff;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
export function buildStartTaskFromInput(raw: string): { name: string; tags: string[]; project?: string } {
|
|
502
|
+
const trimmed = raw.trim();
|
|
503
|
+
const { name, tags, project } = parseTaskWithAutoTags(trimmed);
|
|
504
|
+
const displayName = name || tags[0] || trimmed;
|
|
505
|
+
return { name: displayName, tags, project };
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Brouillon « en focus » : texte du champ + étiquettes / projet choisis via pastilles (sans les coller dans le titre).
|
|
510
|
+
*/
|
|
511
|
+
export function buildStartTaskFromDraft(
|
|
512
|
+
titleLine: string,
|
|
513
|
+
pickerTags: string[],
|
|
514
|
+
/** `undefined` : projet issu du titre ; chaîne vide : aucun projet (pastille désactivée). */
|
|
515
|
+
pickerProject: string | undefined
|
|
516
|
+
): { name: string; tags: string[]; project?: string } {
|
|
517
|
+
const trimmed = titleLine.trim();
|
|
518
|
+
const parsed = parseTaskWithAutoTags(trimmed);
|
|
519
|
+
const allTags = mergeTagsForDisplay(trimmed, pickerTags);
|
|
520
|
+
const project =
|
|
521
|
+
pickerProject === undefined ? parsed.project : pickerProject.trim() ? pickerProject : undefined;
|
|
522
|
+
const tags = filterTaskTagsForProject(allTags, project ?? null);
|
|
523
|
+
const displayName =
|
|
524
|
+
parsed.name.trim() ||
|
|
525
|
+
(tags.length > 0 ? tags[0] : "") ||
|
|
526
|
+
trimmed ||
|
|
527
|
+
(project ? normalizeProjectKey(project) : "");
|
|
528
|
+
return {
|
|
529
|
+
name: displayName,
|
|
530
|
+
tags,
|
|
531
|
+
...(project ? { project } : {}),
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
export function normalizeTagKey(s: string): string {
|
|
536
|
+
return s.replace(/^#/, "");
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** Étiquette canonique pour une tâche sans aucune étiquette explicite (stable, indépendante de la langue d’affichage). */
|
|
540
|
+
export const DEFAULT_FALLBACK_TASK_TAG = "default";
|
|
541
|
+
|
|
542
|
+
/** Options pour {@link normalizeTaskTagsForStorage}. */
|
|
543
|
+
export type TaskTagsStorageNormalizeOpts = {
|
|
544
|
+
/**
|
|
545
|
+
* Lorsque `false`, une tâche sans étiquette valide après nettoyage reste avec `tags: []`.
|
|
546
|
+
* Lorsque `true` ou absent : comportement historique (`["default"]`).
|
|
547
|
+
*/
|
|
548
|
+
assignDefaultTagBucket?: boolean;
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
/** Lit `cfg.taskDefaultTagBucketEnabled` du payload : `false` désactive le compartiment réservé ; défaut `true`. */
|
|
552
|
+
export function readTaskDefaultTagBucketEnabled(cfg: unknown): boolean {
|
|
553
|
+
const o =
|
|
554
|
+
cfg && typeof cfg === "object" && !Array.isArray(cfg) ? (cfg as Record<string, unknown>) : undefined;
|
|
555
|
+
if (o && o.taskDefaultTagBucketEnabled === false) {
|
|
556
|
+
return false;
|
|
557
|
+
}
|
|
558
|
+
return true;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
export function isFallbackTaskTagKey(tagKey: string): boolean {
|
|
562
|
+
const t = tagKey.trim();
|
|
563
|
+
if (t === "") {
|
|
564
|
+
return true;
|
|
565
|
+
}
|
|
566
|
+
return normalizeTagKey(t).toLowerCase() === DEFAULT_FALLBACK_TASK_TAG;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Déduplique les étiquettes ; si aucune ne subsiste après nettoyage, renvoie `[{@link DEFAULT_FALLBACK_TASK_TAG}]`
|
|
571
|
+
* sauf si {@link TaskTagsStorageNormalizeOpts.assignDefaultTagBucket} vaut `false` (tableau vide).
|
|
572
|
+
*/
|
|
573
|
+
export function normalizeTaskTagsForStorage(
|
|
574
|
+
tags: string[] | null | undefined,
|
|
575
|
+
opts?: TaskTagsStorageNormalizeOpts
|
|
576
|
+
): string[] {
|
|
577
|
+
const raw = Array.isArray(tags) ? tags : [];
|
|
578
|
+
const normalized: string[] = [];
|
|
579
|
+
const seen = new Set<string>();
|
|
580
|
+
for (const t of raw) {
|
|
581
|
+
const token = normalizeTagKey(String(t)).trim();
|
|
582
|
+
if (!token) {
|
|
583
|
+
continue;
|
|
584
|
+
}
|
|
585
|
+
const k = token.toLowerCase();
|
|
586
|
+
if (seen.has(k)) {
|
|
587
|
+
continue;
|
|
588
|
+
}
|
|
589
|
+
seen.add(k);
|
|
590
|
+
normalized.push(token);
|
|
591
|
+
}
|
|
592
|
+
if (normalized.length === 0) {
|
|
593
|
+
if (opts?.assignDefaultTagBucket === false) {
|
|
594
|
+
return [];
|
|
595
|
+
}
|
|
596
|
+
return [DEFAULT_FALLBACK_TASK_TAG];
|
|
597
|
+
}
|
|
598
|
+
return normalized;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* Fusionne les étiquettes du titre et celles du tableau `tags`, sans doublon (insensible à la casse).
|
|
603
|
+
*/
|
|
604
|
+
export function mergeTagsForDisplay(rawName: string, storedTags?: string[] | null): string[] {
|
|
605
|
+
const fromTitle = collectAutoTagsFromRaw(rawName);
|
|
606
|
+
const fromArray = storedTags ?? [];
|
|
607
|
+
const seen = new Set<string>();
|
|
608
|
+
const out: string[] = [];
|
|
609
|
+
const add = (t: string) => {
|
|
610
|
+
const n = normalizeTagKey(t);
|
|
611
|
+
const k = n.toLowerCase();
|
|
612
|
+
if (!n || seen.has(k)) {
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
seen.add(k);
|
|
616
|
+
out.push(n);
|
|
617
|
+
};
|
|
618
|
+
for (const t of fromTitle) {
|
|
619
|
+
add(t);
|
|
620
|
+
}
|
|
621
|
+
for (const t of fromArray) {
|
|
622
|
+
add(t);
|
|
623
|
+
}
|
|
624
|
+
return out;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
export function formatTagDisplay(tag: string): string {
|
|
628
|
+
const trimmed = tag.trim();
|
|
629
|
+
if (!trimmed) {
|
|
630
|
+
return trimmed;
|
|
631
|
+
}
|
|
632
|
+
const scoped = parseProjectScopedTag(trimmed.replace(/^#/, ""));
|
|
633
|
+
if (scoped) {
|
|
634
|
+
return `@${scoped.projectKey}#${scoped.localTag}`;
|
|
635
|
+
}
|
|
636
|
+
const noHash = trimmed.replace(/^#/, "");
|
|
637
|
+
return trimmed.startsWith("#") ? trimmed : `#${noHash}`;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/** Libellé lisible pour une pastille d’étiquette ; `defaultBucketLabel` remplace l’affichage de l’étiquette réservée {@link DEFAULT_FALLBACK_TASK_TAG}. */
|
|
641
|
+
export function formatTagDisplayForTask(tag: string, defaultBucketLabel?: string): string {
|
|
642
|
+
const trimmed = normalizeTagKey(String(tag)).trim();
|
|
643
|
+
if (!trimmed) {
|
|
644
|
+
return "";
|
|
645
|
+
}
|
|
646
|
+
if (trimmed.toLowerCase() === DEFAULT_FALLBACK_TASK_TAG && defaultBucketLabel?.trim()) {
|
|
647
|
+
return defaultBucketLabel.trim();
|
|
648
|
+
}
|
|
649
|
+
return formatTagDisplay(tag);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
export function formatProjectDisplay(project: string): string {
|
|
653
|
+
const trimmed = project.trim();
|
|
654
|
+
if (!trimmed) {
|
|
655
|
+
return trimmed;
|
|
656
|
+
}
|
|
657
|
+
return trimmed.startsWith("@") ? trimmed : `@${normalizeProjectKey(trimmed)}`;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* Durée lisible à partir d’une valeur en **minutes** (souvent fractionnaire, ex. agrégats reporting).
|
|
662
|
+
* Les secondes non nulles sont affichées dès qu’elles apparaissent après arrondi à la seconde la plus proche.
|
|
663
|
+
*/
|
|
664
|
+
export function formatDuration(minutes: number): string {
|
|
665
|
+
const m = Math.max(0, minutes);
|
|
666
|
+
if (m === 0) {
|
|
667
|
+
return "0 min";
|
|
668
|
+
}
|
|
669
|
+
const totalSec = Math.round(m * 60);
|
|
670
|
+
if (totalSec === 0) {
|
|
671
|
+
return "1 s";
|
|
672
|
+
}
|
|
673
|
+
const h = Math.floor(totalSec / 3600);
|
|
674
|
+
const remH = totalSec % 3600;
|
|
675
|
+
const mi = Math.floor(remH / 60);
|
|
676
|
+
const s = remH % 60;
|
|
677
|
+
|
|
678
|
+
if (h > 0) {
|
|
679
|
+
if (mi > 0 && s > 0) {
|
|
680
|
+
return `${h}h ${mi}m ${s} s`;
|
|
681
|
+
}
|
|
682
|
+
if (mi > 0) {
|
|
683
|
+
return `${h}h ${mi}m`;
|
|
684
|
+
}
|
|
685
|
+
if (s > 0) {
|
|
686
|
+
return `${h}h ${s} s`;
|
|
687
|
+
}
|
|
688
|
+
return `${h}h`;
|
|
689
|
+
}
|
|
690
|
+
if (mi > 0) {
|
|
691
|
+
return s > 0 ? `${mi} min ${s} s` : `${mi} min`;
|
|
692
|
+
}
|
|
693
|
+
return `${s} s`;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* Durée murale affichée à partir de **millisecondes** : secondes obtenues par `floor(ms/1000)` pour que
|
|
698
|
+
* l’affichage progresse chaque seconde (ex. minuteur de session live entre deux rafraîchissements API).
|
|
699
|
+
* Toujours inclure les secondes lorsqu’il y a au moins une minute (`5 min 0 s`, `5 min 1 s`, …).
|
|
700
|
+
*/
|
|
701
|
+
export function formatWallDurationMs(ms: number): string {
|
|
702
|
+
const m = Math.max(0, ms);
|
|
703
|
+
const totalSec = Math.floor(m / 1000);
|
|
704
|
+
if (totalSec === 0) {
|
|
705
|
+
return "0 s";
|
|
706
|
+
}
|
|
707
|
+
const h = Math.floor(totalSec / 3600);
|
|
708
|
+
const rem = totalSec % 3600;
|
|
709
|
+
const mi = Math.floor(rem / 60);
|
|
710
|
+
const s = rem % 60;
|
|
711
|
+
if (h > 0) {
|
|
712
|
+
return `${h}h ${mi}m ${s} s`;
|
|
713
|
+
}
|
|
714
|
+
if (mi > 0) {
|
|
715
|
+
return `${mi} min ${s} s`;
|
|
716
|
+
}
|
|
717
|
+
return `${s} s`;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* Chronomètre à partir de millisecondes : minutes (non padées) : secondes . centièmes,
|
|
722
|
+
* ou heures : minutes : secondes . centièmes au-delà d’une heure.
|
|
723
|
+
*/
|
|
724
|
+
export function formatStopwatchMs(ms: number): string {
|
|
725
|
+
const m = Math.max(0, Math.floor(ms));
|
|
726
|
+
const hours = Math.floor(m / 3600000);
|
|
727
|
+
const remH = m % 3600000;
|
|
728
|
+
const minutes = Math.floor(remH / 60000);
|
|
729
|
+
const remM = remH % 60000;
|
|
730
|
+
const seconds = Math.floor(remM / 1000);
|
|
731
|
+
const centis = Math.floor((remM % 1000) / 10);
|
|
732
|
+
const pad2 = (n: number) => String(n).padStart(2, "0");
|
|
733
|
+
if (hours > 0) {
|
|
734
|
+
return `${hours}:${pad2(minutes)}:${pad2(seconds)}.${pad2(centis)}`;
|
|
735
|
+
}
|
|
736
|
+
return `${minutes}:${pad2(seconds)}.${pad2(centis)}`;
|
|
737
|
+
}
|