@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.
Files changed (179) hide show
  1. package/README.md +81 -0
  2. package/app/api/action/route.ts +16 -0
  3. package/app/api/backup/route.ts +84 -0
  4. package/app/api/health/route.ts +22 -0
  5. package/app/api/state/route.ts +27 -0
  6. package/app/apple-icon.png +0 -0
  7. package/app/changelog/page.tsx +122 -0
  8. package/app/globals.css +210 -0
  9. package/app/guide/layout.tsx +11 -0
  10. package/app/guide/page.tsx +278 -0
  11. package/app/icon.png +0 -0
  12. package/app/layout.tsx +77 -0
  13. package/app/licenses/layout.tsx +11 -0
  14. package/app/licenses/page.tsx +246 -0
  15. package/app/manifest.ts +32 -0
  16. package/app/page.tsx +1610 -0
  17. package/app/reporting/page.tsx +2943 -0
  18. package/app/settings/layout.tsx +10 -0
  19. package/app/settings/page.tsx +3518 -0
  20. package/bin/kronosys.mjs +46 -0
  21. package/components/KronosysPackageVersionProvider.tsx +19 -0
  22. package/components/KronosysPayloadProvider.tsx +109 -0
  23. package/components/PwaRegister.tsx +25 -0
  24. package/components/SiteLegalFooter.tsx +21 -0
  25. package/components/ThemeProvider.tsx +78 -0
  26. package/components/dashboard/AppShellLiveSessionDrawer.tsx +394 -0
  27. package/components/dashboard/AppShellRouteNav.tsx +131 -0
  28. package/components/dashboard/AppVersionStamp.tsx +16 -0
  29. package/components/dashboard/DashboardCollapsibleSection.tsx +57 -0
  30. package/components/dashboard/DashboardColumnHintsBanner.tsx +159 -0
  31. package/components/dashboard/DashboardCommandCenter.tsx +470 -0
  32. package/components/dashboard/DashboardLangGateModal.tsx +118 -0
  33. package/components/dashboard/DashboardLoadingOverlay.tsx +42 -0
  34. package/components/dashboard/DashboardSimpleModal.tsx +337 -0
  35. package/components/dashboard/DashboardSuspenseFallback.tsx +52 -0
  36. package/components/dashboard/DashboardToastProvider.tsx +64 -0
  37. package/components/dashboard/DashboardTour.tsx +435 -0
  38. package/components/dashboard/DeferredDescriptionPopoverWrap.tsx +39 -0
  39. package/components/dashboard/DeleteSessionModal.tsx +130 -0
  40. package/components/dashboard/DescriptionTooltipPortaled.tsx +31 -0
  41. package/components/dashboard/GitIdentityQuickSetupModal.tsx +211 -0
  42. package/components/dashboard/HeaderIntegrationBadges.tsx +69 -0
  43. package/components/dashboard/InlineMetricHelpTrigger.tsx +102 -0
  44. package/components/dashboard/IssuePickerModal.tsx +168 -0
  45. package/components/dashboard/KronoFocusPanel.tsx +834 -0
  46. package/components/dashboard/KronosysDatetimePopoverField.tsx +357 -0
  47. package/components/dashboard/KronosysTimePopoverField.tsx +233 -0
  48. package/components/dashboard/LanguageMenu.tsx +123 -0
  49. package/components/dashboard/MongoMirrorSyncLine.tsx +57 -0
  50. package/components/dashboard/NewSessionScopeModal.tsx +410 -0
  51. package/components/dashboard/PageRefreshButton.tsx +130 -0
  52. package/components/dashboard/PlainHelpPopover.tsx +97 -0
  53. package/components/dashboard/ReportingPageToc.tsx +68 -0
  54. package/components/dashboard/ReportingTour.tsx +342 -0
  55. package/components/dashboard/SavedProjectPicker.tsx +92 -0
  56. package/components/dashboard/SavedTagPicker.tsx +115 -0
  57. package/components/dashboard/ScrollToTopFab.tsx +41 -0
  58. package/components/dashboard/SelectedSessionSidebarBlock.tsx +630 -0
  59. package/components/dashboard/SessionEndReasonEditor.tsx +114 -0
  60. package/components/dashboard/SessionListPanel.tsx +320 -0
  61. package/components/dashboard/SessionLocMetricsSection.tsx +128 -0
  62. package/components/dashboard/SettingsTagsProjectsSection.tsx +993 -0
  63. package/components/dashboard/SettingsTour.tsx +332 -0
  64. package/components/dashboard/TagPills.tsx +149 -0
  65. package/components/dashboard/TagsHelpTrigger.tsx +84 -0
  66. package/components/dashboard/TaskFocusPanel.tsx +1261 -0
  67. package/components/dashboard/TaskSessionLiveCard.tsx +832 -0
  68. package/components/dashboard/TaskSubtasksBlock.tsx +748 -0
  69. package/components/dashboard/ThemeToggle.test.tsx +26 -0
  70. package/components/dashboard/ThemeToggle.tsx +36 -0
  71. package/components/dashboard/UserGuideBodyText.tsx +62 -0
  72. package/components/dashboard/WorkspaceGitRepoCard.tsx +191 -0
  73. package/components/dashboard/taskFieldStyles.ts +139 -0
  74. package/components/dashboard/useAnchoredFloatingPortalStyle.ts +71 -0
  75. package/components/dashboard/useDescriptionPopoverAfterMs.ts +220 -0
  76. package/components/dashboard/useKronoFocusLiveSeconds.ts +36 -0
  77. package/components/dashboard/useSmoothStopwatchMs.ts +25 -0
  78. package/lib/appShellHeaderClasses.ts +12 -0
  79. package/lib/backupCsvExport.test.ts +149 -0
  80. package/lib/backupCsvExport.ts +392 -0
  81. package/lib/changelogCopy.ts +34 -0
  82. package/lib/concurrentTaskStartPreference.ts +29 -0
  83. package/lib/dashboardClockFormat.ts +13 -0
  84. package/lib/dashboardColumnChrome.ts +3 -0
  85. package/lib/dashboardColumnHintsStorage.ts +57 -0
  86. package/lib/dashboardCopy.ts +1831 -0
  87. package/lib/dashboardDetachedUrlHintStorage.ts +24 -0
  88. package/lib/dashboardGitIdentityBannerStorage.ts +36 -0
  89. package/lib/dashboardLangStorage.ts +72 -0
  90. package/lib/dashboardQuickSearch.ts +476 -0
  91. package/lib/dashboardQuickSearchQuery.test.ts +63 -0
  92. package/lib/dashboardQuickSearchQuery.ts +179 -0
  93. package/lib/dashboardSessionNav.ts +33 -0
  94. package/lib/dashboardShortcuts.ts +268 -0
  95. package/lib/dashboardTimeZone.ts +91 -0
  96. package/lib/dashboardTourStorage.ts +68 -0
  97. package/lib/dataDir.test.ts +87 -0
  98. package/lib/dataDir.ts +83 -0
  99. package/lib/devDataPreferenceFile.ts +55 -0
  100. package/lib/devDataRuntimeInfo.ts +34 -0
  101. package/lib/formatIsoShort.test.ts +46 -0
  102. package/lib/formatIsoShort.ts +29 -0
  103. package/lib/generatedUserChangelog.ts +34 -0
  104. package/lib/gitlabIssueSearch.ts +8 -0
  105. package/lib/kronoFocusDurationHistory.ts +71 -0
  106. package/lib/kronoFocusRhythm.test.ts +130 -0
  107. package/lib/kronoFocusRhythm.ts +46 -0
  108. package/lib/kronoFocusTimerUrgency.test.ts +74 -0
  109. package/lib/kronoFocusTimerUrgency.ts +24 -0
  110. package/lib/kronosysApi.ts +143 -0
  111. package/lib/legacyEditorPayloadKeys.ts +52 -0
  112. package/lib/legacyKronoFocusStorageKeys.test.ts +29 -0
  113. package/lib/legacyKronoFocusStorageKeys.ts +32 -0
  114. package/lib/licensesCopy.ts +128 -0
  115. package/lib/openPlainTextInNewTab.ts +49 -0
  116. package/lib/readKronosysPackageVersion.ts +10 -0
  117. package/lib/reportingAggregate.test.ts +325 -0
  118. package/lib/reportingAggregate.ts +819 -0
  119. package/lib/reportingDatePresets.ts +41 -0
  120. package/lib/reportingMetricHelp.ts +430 -0
  121. package/lib/reportingNonFinalIndicators.test.ts +157 -0
  122. package/lib/reportingNonFinalIndicators.ts +102 -0
  123. package/lib/reportingStrings.ts +491 -0
  124. package/lib/reportingTagWeekBreakdown.test.ts +141 -0
  125. package/lib/reportingTagWeekBreakdown.ts +181 -0
  126. package/lib/reportingWeekLayout.test.ts +239 -0
  127. package/lib/reportingWeekLayout.ts +313 -0
  128. package/lib/sessionAssiduity.test.ts +25 -0
  129. package/lib/sessionAssiduity.ts +33 -0
  130. package/lib/sessionEndReason.ts +55 -0
  131. package/lib/sessionEndWarnings.test.ts +200 -0
  132. package/lib/sessionEndWarnings.ts +125 -0
  133. package/lib/sessionListMerge.test.ts +101 -0
  134. package/lib/sessionListMerge.ts +70 -0
  135. package/lib/sessionTaskSidebarStats.test.ts +24 -0
  136. package/lib/sessionTaskSidebarStats.ts +54 -0
  137. package/lib/settingsCopy.ts +1276 -0
  138. package/lib/taskParsing.test.ts +153 -0
  139. package/lib/taskParsing.ts +737 -0
  140. package/lib/theme.ts +15 -0
  141. package/lib/translucentButtonClasses.ts +34 -0
  142. package/lib/usageProfile.test.ts +84 -0
  143. package/lib/usageProfile.ts +52 -0
  144. package/lib/userGuideCopy.ts +464 -0
  145. package/lib/workspaceLocDefaults.ts +21 -0
  146. package/next-env.d.ts +6 -0
  147. package/next.config.ts +15 -0
  148. package/package.json +87 -0
  149. package/postcss.config.mjs +12 -0
  150. package/public/apple-icon.png +0 -0
  151. package/public/favicon.ico +0 -0
  152. package/public/file.svg +1 -0
  153. package/public/globe.svg +1 -0
  154. package/public/icon-192.png +0 -0
  155. package/public/icon-512.png +0 -0
  156. package/public/icon.png +0 -0
  157. package/public/next.svg +1 -0
  158. package/public/sw.js +13 -0
  159. package/public/traceback.png +0 -0
  160. package/public/vercel.svg +1 -0
  161. package/public/window.svg +1 -0
  162. package/server/actionDispatch.test.ts +723 -0
  163. package/server/actionDispatch.ts +1476 -0
  164. package/server/actionTaskSession.test.ts +713 -0
  165. package/server/actionTaskSession.ts +717 -0
  166. package/server/db.ts +42 -0
  167. package/server/defaultCfg.ts +87 -0
  168. package/server/gitlabTokenStore.ts +34 -0
  169. package/server/kronoFocusHydrate.test.ts +142 -0
  170. package/server/kronoFocusHydrate.ts +69 -0
  171. package/server/kronoFocusMigrate.test.ts +53 -0
  172. package/server/kronoFocusMigrate.ts +78 -0
  173. package/server/mainTimerHydrate.test.ts +65 -0
  174. package/server/mainTimerHydrate.ts +53 -0
  175. package/server/payloadStore.test.ts +78 -0
  176. package/server/payloadStore.ts +83 -0
  177. package/server/sessionWallHydrate.test.ts +46 -0
  178. package/server/sessionWallHydrate.ts +88 -0
  179. package/tsconfig.json +41 -0
@@ -0,0 +1,24 @@
1
+ /** Mémorise que l’utilisateur a masqué la note « onglet piloté par l’URL » sur le tableau de bord. */
2
+ export const DETACHED_URL_HINT_DISMISS_KEY = "kronosys_detached_url_hint_dismiss_v1";
3
+
4
+ export function readDashboardDetachedUrlHintDismissed(): boolean {
5
+ if (typeof globalThis.window === "undefined") {
6
+ return false;
7
+ }
8
+ try {
9
+ return globalThis.localStorage.getItem(DETACHED_URL_HINT_DISMISS_KEY) === "1";
10
+ } catch {
11
+ return false;
12
+ }
13
+ }
14
+
15
+ export function writeDashboardDetachedUrlHintDismissed(): void {
16
+ if (typeof globalThis.window === "undefined") {
17
+ return;
18
+ }
19
+ try {
20
+ globalThis.localStorage.setItem(DETACHED_URL_HINT_DISMISS_KEY, "1");
21
+ } catch {
22
+ /* ignore */
23
+ }
24
+ }
@@ -0,0 +1,36 @@
1
+ /** Mémorise que l’utilisateur a masqué la bannière « configurez l’auteur Git » sur le tableau de bord. */
2
+ export const GIT_IDENTITY_BANNER_DISMISS_KEY = "kronosys_git_banner_dismiss_v1";
3
+
4
+ export function readGitIdentityBannerDismissed(): boolean {
5
+ if (typeof globalThis.window === "undefined") {
6
+ return false;
7
+ }
8
+ try {
9
+ return globalThis.localStorage.getItem(GIT_IDENTITY_BANNER_DISMISS_KEY) === "1";
10
+ } catch {
11
+ return false;
12
+ }
13
+ }
14
+
15
+ export function writeGitIdentityBannerDismissed(): void {
16
+ if (typeof globalThis.window === "undefined") {
17
+ return;
18
+ }
19
+ try {
20
+ globalThis.localStorage.setItem(GIT_IDENTITY_BANNER_DISMISS_KEY, "1");
21
+ } catch {
22
+ /* ignore */
23
+ }
24
+ }
25
+
26
+ /** À appeler après une réinitialisation (ex. effacement des données) pour reproposer la bannière si l’identité est vide. */
27
+ export function resetGitIdentityBannerDismissed(): void {
28
+ if (typeof globalThis.window === "undefined") {
29
+ return;
30
+ }
31
+ try {
32
+ globalThis.localStorage.removeItem(GIT_IDENTITY_BANNER_DISMISS_KEY);
33
+ } catch {
34
+ /* ignore */
35
+ }
36
+ }
@@ -0,0 +1,72 @@
1
+ import type { Lang } from "./dashboardCopy";
2
+
3
+ /** Langue UI du tableau de bord (menu, chaînes). Écrite au choix utilisateur après la visite guidée. */
4
+ export const DASHBOARD_LANG_STORAGE_KEY = "kronosys-dashboard-ui-lang";
5
+
6
+ /**
7
+ * Mis à « vrai » lorsque l’utilisateur choisit explicitement une langue (écran d’accueil ou menu).
8
+ * Tant que cette clé est absente et que la visite guidée n’est pas terminée, le tableau de bord
9
+ * affiche d’abord le sélecteur de langue et n’ouvre pas la visite dans une langue imposée.
10
+ */
11
+ export const DASHBOARD_LANG_USER_CHOSEN_KEY = "kronosys.dashboard.lang.userChosen.v1";
12
+
13
+ export function readStoredDashboardLang(): Lang | null {
14
+ if (typeof globalThis.window === "undefined") {
15
+ return null;
16
+ }
17
+ try {
18
+ const s = globalThis.localStorage.getItem(DASHBOARD_LANG_STORAGE_KEY);
19
+ if (s === "fr" || s === "en") {
20
+ return s;
21
+ }
22
+ } catch {
23
+ /* ignore */
24
+ }
25
+ return null;
26
+ }
27
+
28
+ export function writeStoredDashboardLang(lang: Lang): void {
29
+ if (typeof globalThis.window === "undefined") {
30
+ return;
31
+ }
32
+ try {
33
+ globalThis.localStorage.setItem(DASHBOARD_LANG_STORAGE_KEY, lang);
34
+ } catch {
35
+ /* ignore */
36
+ }
37
+ }
38
+
39
+ export function readDashboardLangUserChosen(): boolean {
40
+ if (typeof globalThis.window === "undefined") {
41
+ return false;
42
+ }
43
+ try {
44
+ return globalThis.localStorage.getItem(DASHBOARD_LANG_USER_CHOSEN_KEY) === "1";
45
+ } catch {
46
+ return false;
47
+ }
48
+ }
49
+
50
+ export function writeDashboardLangUserChosen(): void {
51
+ if (typeof globalThis.window === "undefined") {
52
+ return;
53
+ }
54
+ try {
55
+ globalThis.localStorage.setItem(DASHBOARD_LANG_USER_CHOSEN_KEY, "1");
56
+ } catch {
57
+ /* ignore */
58
+ }
59
+ }
60
+
61
+ /** Efface la langue mémorisée et le marqueur de choix explicite (ex. après « tout effacer » dans les paramètres). */
62
+ export function resetDashboardLangPreference(): void {
63
+ if (typeof globalThis.window === "undefined") {
64
+ return;
65
+ }
66
+ try {
67
+ globalThis.localStorage.removeItem(DASHBOARD_LANG_USER_CHOSEN_KEY);
68
+ globalThis.localStorage.removeItem(DASHBOARD_LANG_STORAGE_KEY);
69
+ } catch {
70
+ /* ignore */
71
+ }
72
+ }
@@ -0,0 +1,476 @@
1
+ import type { DashboardStrings } from "@/lib/dashboardCopy";
2
+ import type { SessionListEntry } from "@/components/dashboard/SessionListPanel";
3
+ import {
4
+ formatDuration,
5
+ formatProjectDisplay,
6
+ formatStopwatchMs,
7
+ formatTagDisplay,
8
+ mergeTagsForDisplay,
9
+ normalizeProjectKey,
10
+ normalizeTagKey,
11
+ parseProjectScopedTag,
12
+ parseTaskWithAutoTags,
13
+ taskTitleForDisplay,
14
+ } from "@/lib/taskParsing";
15
+
16
+ export type SearchLiveContext = {
17
+ sessionId: string;
18
+ sessionPaused: boolean;
19
+ };
20
+
21
+ export type SearchTimerState = "active" | "paused" | "off";
22
+
23
+ export type DashboardSearchItem = {
24
+ id: string;
25
+ title: string;
26
+ subtitle?: string;
27
+ /** Projet / étiquettes affichés sous le titre (résultats de recherche). */
28
+ metaLine?: string;
29
+ /**
30
+ * Minuterie + temps enregistré (tâches) ou durée murale (session), pour donner du contexte à la recherche.
31
+ */
32
+ searchTimerLine?: string;
33
+ searchTimerState?: SearchTimerState;
34
+ /** Texte indexé pour le filtre (déjà en minuscules recommandé). */
35
+ haystack: string;
36
+ /**
37
+ * Durée en secondes (tâche : enregistrement de suivi ; session : durée « murale ») pour
38
+ * requêtes du type `12:55 <` ou `0:10 >`.
39
+ */
40
+ durationForSearchSec?: number;
41
+ onSelect: () => void;
42
+ };
43
+
44
+ type TaskLike = {
45
+ id?: string;
46
+ name?: string;
47
+ tags?: unknown;
48
+ project?: unknown;
49
+ isDone?: boolean;
50
+ manualTaskTimerPaused?: boolean;
51
+ /** ISO 8601 côté modèle tâche (début d’enregistrement / entrée passée). */
52
+ startTime?: string;
53
+ endTime?: string;
54
+ /** Durée du suivi enregistrée (ms). */
55
+ durationMs?: number;
56
+ };
57
+
58
+ const pad2 = (n: number) => String(n).padStart(2, "0");
59
+
60
+ function readOptionalIsoString(x: unknown): string | undefined {
61
+ return typeof x === "string" && x.trim() ? x.trim() : undefined;
62
+ }
63
+
64
+ /**
65
+ * Volets texte pour qu’on puisse filtrer par saisies du type `2026-01-15` ou
66
+ * `2026-01-15 14:30:02` (Heure locale et UTC, plus la chaîne source et l’ISO).
67
+ */
68
+ function collectDateSearchFragments(value: string | null | undefined): string {
69
+ if (value == null) {
70
+ return "";
71
+ }
72
+ const t = String(value).trim();
73
+ if (t === "") {
74
+ return "";
75
+ }
76
+ const ms = Date.parse(t);
77
+ if (Number.isNaN(ms)) {
78
+ return t.toLowerCase();
79
+ }
80
+ const d = new Date(ms);
81
+ const ymdU = `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`;
82
+ const ymdL = `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
83
+ const hmsU = `${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())}:${pad2(d.getUTCSeconds())}`;
84
+ const hmsL = `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`;
85
+ const pieces = new Set<string>([t.toLowerCase(), ymdU, ymdL, `${ymdU} ${hmsU}`, `${ymdL} ${hmsL}`]);
86
+ pieces.add(`${ymdU} ${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())}`);
87
+ pieces.add(`${ymdL} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`);
88
+ return [...pieces].join(" ");
89
+ }
90
+
91
+ function dateHayFromFields(...fields: (string | null | undefined)[]): string {
92
+ return fields
93
+ .map((f) => collectDateSearchFragments(f))
94
+ .filter(Boolean)
95
+ .join(" ");
96
+ }
97
+
98
+ function buildTaskSearchIndex(
99
+ rawName: string,
100
+ storedTags: unknown,
101
+ storedProject: unknown
102
+ ): { haystackFragment: string; metaLine: string } {
103
+ const storedTagArr = Array.isArray(storedTags)
104
+ ? storedTags.filter((x): x is string => typeof x === "string")
105
+ : undefined;
106
+ const tagList = mergeTagsForDisplay(rawName, storedTagArr);
107
+ const parsed = parseTaskWithAutoTags(rawName);
108
+ const projRaw =
109
+ (typeof storedProject === "string" && storedProject.trim() ? storedProject.trim() : undefined) ??
110
+ parsed.project;
111
+
112
+ const hayPieces: string[] = [];
113
+ if (projRaw) {
114
+ hayPieces.push(projRaw, normalizeProjectKey(projRaw), formatProjectDisplay(projRaw), `@${normalizeProjectKey(projRaw)}`);
115
+ }
116
+ for (const tag of tagList) {
117
+ hayPieces.push(tag, normalizeTagKey(tag), formatTagDisplay(tag));
118
+ const scoped = parseProjectScopedTag(normalizeTagKey(tag));
119
+ if (scoped) {
120
+ hayPieces.push(
121
+ scoped.projectKey,
122
+ scoped.localTag,
123
+ `${scoped.projectKey}#${scoped.localTag}`,
124
+ `${scoped.projectKey.toLowerCase()}#${scoped.localTag.toLowerCase()}`
125
+ );
126
+ }
127
+ }
128
+
129
+ const metaParts: string[] = [];
130
+ if (projRaw) {
131
+ metaParts.push(formatProjectDisplay(projRaw));
132
+ }
133
+ for (const tag of tagList) {
134
+ metaParts.push(formatTagDisplay(tag));
135
+ }
136
+
137
+ return {
138
+ haystackFragment: hayPieces.join(" ").toLowerCase(),
139
+ metaLine: metaParts.join(" · "),
140
+ };
141
+ }
142
+
143
+ function truncateMetaLine(s: string, maxLen: number): string {
144
+ const t = s.trim();
145
+ if (t.length <= maxLen) {
146
+ return t;
147
+ }
148
+ return `${t.slice(0, Math.max(0, maxLen - 1)).trimEnd()}…`;
149
+ }
150
+
151
+ function taskIdDedupKey(id: string): string {
152
+ return id.trim();
153
+ }
154
+
155
+ function taskDurationSecondsFromRecord(t: TaskLike): number | undefined {
156
+ const ms = t.durationMs;
157
+ if (typeof ms === "number" && Number.isFinite(ms) && ms > 0) {
158
+ return ms / 1000;
159
+ }
160
+ return undefined;
161
+ }
162
+
163
+ function taskRecordedMinutesBestEffort(t: TaskLike): number {
164
+ const ms = t.durationMs;
165
+ if (typeof ms === "number" && Number.isFinite(ms) && ms > 0) {
166
+ return ms / 60000;
167
+ }
168
+ const st = typeof t.startTime === "string" ? Date.parse(t.startTime) : Number.NaN;
169
+ const en = typeof t.endTime === "string" ? Date.parse(t.endTime) : Number.NaN;
170
+ if (Number.isFinite(st) && Number.isFinite(en) && en >= st) {
171
+ return (en - st) / 60000;
172
+ }
173
+ return 0;
174
+ }
175
+
176
+ function taskStopwatchDisplayMs(t: TaskLike): number {
177
+ const dm = typeof t.durationMs === "number" && Number.isFinite(t.durationMs) ? t.durationMs : 0;
178
+ if (t.isDone === true) {
179
+ return Math.max(0, dm);
180
+ }
181
+ if (t.manualTaskTimerPaused !== true && typeof t.startTime === "string" && t.startTime.trim() !== "") {
182
+ const st = Date.parse(t.startTime);
183
+ if (Number.isFinite(st)) {
184
+ return Math.max(0, Date.now() - st);
185
+ }
186
+ }
187
+ return Math.max(0, dm);
188
+ }
189
+
190
+ function taskSearchTimerState(t: TaskLike): SearchTimerState {
191
+ if (t.isDone === true) {
192
+ return "off";
193
+ }
194
+ if (t.manualTaskTimerPaused === true) {
195
+ return "paused";
196
+ }
197
+ if (typeof t.startTime === "string" && t.startTime.trim() !== "") {
198
+ return "active";
199
+ }
200
+ return "off";
201
+ }
202
+
203
+ function buildTaskSearchTimerLine(
204
+ t: TaskLike,
205
+ dt: DashboardStrings
206
+ ): { line: string; state: SearchTimerState; hayFragment: string } {
207
+ const isDone = t.isDone === true;
208
+ const recMin = taskRecordedMinutesBestEffort(t);
209
+ const recStr = formatDuration(Math.max(0, recMin));
210
+ if (isDone) {
211
+ const line = `${dt.dataSearchTaskRecordedTimeLabel} ${recStr}`;
212
+ return { line, state: "off", hayFragment: line.toLowerCase() };
213
+ }
214
+ const swMs = taskStopwatchDisplayMs(t);
215
+ const chrono = formatStopwatchMs(swMs);
216
+ const recPart = formatDuration(
217
+ Math.max(0, (typeof t.durationMs === "number" && Number.isFinite(t.durationMs) ? t.durationMs : 0) / 60000)
218
+ );
219
+ const state = taskSearchTimerState(t);
220
+ const line = `${dt.dataSearchTaskStopwatchLabel} ${chrono} · ${dt.dataSearchTaskRecordedTimeLabel} ${recPart}`;
221
+ return { line, state, hayFragment: line.toLowerCase() };
222
+ }
223
+
224
+ function sessionSearchTimerInfo(
225
+ s: SessionListEntry,
226
+ live: SearchLiveContext | null | undefined,
227
+ dt: DashboardStrings
228
+ ): { line: string; state: SearchTimerState; hayFragment: string } {
229
+ const id = typeof s.sessionId === "string" ? s.sessionId.trim() : "";
230
+ const wallMin =
231
+ typeof s.sessionDurationMinutes === "number" && Number.isFinite(s.sessionDurationMinutes)
232
+ ? s.sessionDurationMinutes
233
+ : 0;
234
+ const wall = formatDuration(Math.max(0, wallMin));
235
+ const line = `${dt.dataSearchSessionWallLabel} ${wall}`;
236
+ const ended = typeof s.endAt === "string" && s.endAt.trim() !== "";
237
+ const isLive = Boolean(live?.sessionId && id && live.sessionId === id);
238
+ let state: SearchTimerState = "off";
239
+ if (isLive && !ended) {
240
+ state = live?.sessionPaused ? "paused" : "active";
241
+ }
242
+ return { line, state, hayFragment: line.toLowerCase() };
243
+ }
244
+
245
+ function sessionWallSecondsForSearch(s: SessionListEntry, tasks: TaskLike[]): number | undefined {
246
+ const m = s.sessionDurationMinutes;
247
+ if (typeof m === "number" && Number.isFinite(m) && m > 0) {
248
+ return m * 60;
249
+ }
250
+ let sum = 0;
251
+ let any = false;
252
+ for (const t of tasks) {
253
+ const sec = taskDurationSecondsFromRecord(t);
254
+ if (sec !== undefined) {
255
+ sum += sec;
256
+ any = true;
257
+ }
258
+ }
259
+ return any ? sum : undefined;
260
+ }
261
+
262
+ function collectTasksFromSessionShape(session: Record<string, unknown> | null | undefined): TaskLike[] {
263
+ if (!session) {
264
+ return [];
265
+ }
266
+ const byId = new Map<string, TaskLike>();
267
+ const add = (t: TaskLike | null | undefined) => {
268
+ if (!t || typeof t.id !== "string" || !t.id.trim()) {
269
+ return;
270
+ }
271
+ const key = taskIdDedupKey(t.id);
272
+ if (!key) {
273
+ return;
274
+ }
275
+ if (!byId.has(key)) {
276
+ byId.set(key, t);
277
+ }
278
+ };
279
+ const tasks = session.tasks;
280
+ if (Array.isArray(tasks)) {
281
+ for (const t of tasks) {
282
+ add(t as TaskLike);
283
+ }
284
+ }
285
+ const activeTasks = session.activeTasks;
286
+ if (Array.isArray(activeTasks)) {
287
+ for (const t of activeTasks) {
288
+ add(t as TaskLike);
289
+ }
290
+ }
291
+ add(session.activeTask as TaskLike | null | undefined);
292
+ return [...byId.values()];
293
+ }
294
+
295
+ function dedupeSearchItemsById(items: DashboardSearchItem[]): DashboardSearchItem[] {
296
+ const seen = new Set<string>();
297
+ return items.filter((it) => {
298
+ if (seen.has(it.id)) {
299
+ return false;
300
+ }
301
+ seen.add(it.id);
302
+ return true;
303
+ });
304
+ }
305
+
306
+ /**
307
+ * Entrées pour la palette Ctrl+K : sessions (historique + archives) et tâches de la session affichée au centre.
308
+ */
309
+ export function buildDashboardQuickSearchItems(opts: {
310
+ history: SessionListEntry[];
311
+ historyArchived: SessionListEntry[];
312
+ sessionCurrent: Record<string, unknown> | null | undefined;
313
+ dt: DashboardStrings;
314
+ /** Session live (pour l’état actif / pause dans les résultats). */
315
+ liveSearchContext?: SearchLiveContext | null;
316
+ onSelectSession: (sessionId: string) => void | Promise<void>;
317
+ scrollToSession: (sessionId: string) => void;
318
+ focusTasksColumn: () => void;
319
+ scrollToTask: (taskId: string) => void;
320
+ }): DashboardSearchItem[] {
321
+ const { history, historyArchived, sessionCurrent, dt, liveSearchContext, onSelectSession, scrollToSession, focusTasksColumn, scrollToTask } =
322
+ opts;
323
+ const items: DashboardSearchItem[] = [];
324
+ const seenSid = new Set<string>();
325
+
326
+ const pushSession = (s: SessionListEntry) => {
327
+ const id = typeof s.sessionId === "string" ? s.sessionId.trim() : "";
328
+ if (!id || seenSid.has(id)) {
329
+ return;
330
+ }
331
+ seenSid.add(id);
332
+ const label = s.sessionName?.trim() || id.slice(0, 8);
333
+ const tasks = collectTasksFromSessionShape(s as unknown as Record<string, unknown>);
334
+ const hayFrags: string[] = [];
335
+ const metaDedup: string[] = [];
336
+ const seenMeta = new Set<string>();
337
+ for (const t of tasks) {
338
+ const rawName = typeof t.name === "string" ? t.name : "";
339
+ const idx = buildTaskSearchIndex(rawName, t.tags, t.project);
340
+ hayFrags.push(idx.haystackFragment);
341
+ if (idx.metaLine) {
342
+ for (const part of idx.metaLine.split(" · ")) {
343
+ const p = part.trim();
344
+ if (!p) {
345
+ continue;
346
+ }
347
+ const k = p.toLowerCase();
348
+ if (seenMeta.has(k)) {
349
+ continue;
350
+ }
351
+ seenMeta.add(k);
352
+ metaDedup.push(p);
353
+ }
354
+ }
355
+ }
356
+ const sessionMeta = metaDedup.length > 0 ? truncateMetaLine(metaDedup.join(" · "), 140) : "";
357
+ const rec = s as unknown as Record<string, unknown>;
358
+ const sessionDateHay = dateHayFromFields(
359
+ readOptionalIsoString(rec.savedAt),
360
+ readOptionalIsoString(rec.startAt),
361
+ readOptionalIsoString(rec.endAt)
362
+ );
363
+ const taskTimeHayFrags: string[] = [];
364
+ for (const t of tasks) {
365
+ taskTimeHayFrags.push(
366
+ dateHayFromFields(
367
+ readOptionalIsoString(t.startTime),
368
+ readOptionalIsoString(t.endTime)
369
+ )
370
+ );
371
+ }
372
+ const sti = sessionSearchTimerInfo(s, liveSearchContext, dt);
373
+ const hay = `${label} ${id} ${hayFrags.join(" ")} ${sessionDateHay} ${taskTimeHayFrags.join(" ")} ${sti.hayFragment}`
374
+ .trim()
375
+ .toLowerCase();
376
+ const sessionDurSec = sessionWallSecondsForSearch(s, tasks);
377
+ items.push({
378
+ id: `search-session-${id}`,
379
+ title: label,
380
+ subtitle: dt.dataSearchKindSession,
381
+ metaLine: sessionMeta || undefined,
382
+ searchTimerLine: sti.line,
383
+ searchTimerState: sti.state,
384
+ haystack: hay,
385
+ durationForSearchSec: sessionDurSec,
386
+ onSelect: () => {
387
+ void onSelectSession(id);
388
+ scrollToSession(id);
389
+ },
390
+ });
391
+ };
392
+
393
+ for (const s of history) {
394
+ pushSession(s);
395
+ }
396
+ for (const s of historyArchived) {
397
+ pushSession(s);
398
+ }
399
+
400
+ const sessionDateHayForTaskRows = sessionCurrent
401
+ ? dateHayFromFields(
402
+ readOptionalIsoString(sessionCurrent.savedAt),
403
+ readOptionalIsoString(sessionCurrent.startAt),
404
+ readOptionalIsoString(sessionCurrent.endAt)
405
+ )
406
+ : "";
407
+
408
+ const pendingTasks: {
409
+ id: string;
410
+ titleKey: string;
411
+ hay: string;
412
+ idx: ReturnType<typeof buildTaskSearchIndex>;
413
+ durationSec: number | undefined;
414
+ timerInfo: ReturnType<typeof buildTaskSearchTimerLine>;
415
+ }[] = [];
416
+ for (const t of collectTasksFromSessionShape(sessionCurrent)) {
417
+ const id =
418
+ typeof t.id === "string" && t.id.trim() ? t.id : "";
419
+ if (!id) {
420
+ continue;
421
+ }
422
+ const rawName = typeof t.name === "string" ? t.name : "";
423
+ const display = taskTitleForDisplay(rawName);
424
+ const idx = buildTaskSearchIndex(rawName, t.tags, t.project);
425
+ const titleKey = (display || id.slice(0, 8)).trim() || id.slice(0, 8);
426
+ const taskTimeHay = dateHayFromFields(
427
+ readOptionalIsoString(t.startTime),
428
+ readOptionalIsoString(t.endTime)
429
+ );
430
+ const timerInfo = buildTaskSearchTimerLine(t, dt);
431
+ const hay = `${rawName} ${display} ${id} ${idx.haystackFragment} ${sessionDateHayForTaskRows} ${taskTimeHay} ${timerInfo.hayFragment}`
432
+ .trim()
433
+ .toLowerCase();
434
+ pendingTasks.push({
435
+ id,
436
+ titleKey,
437
+ hay,
438
+ idx,
439
+ durationSec: taskDurationSecondsFromRecord(t),
440
+ timerInfo,
441
+ });
442
+ }
443
+ const titleKeyCount = new Map<string, number>();
444
+ for (const p of pendingTasks) {
445
+ titleKeyCount.set(p.titleKey, (titleKeyCount.get(p.titleKey) ?? 0) + 1);
446
+ }
447
+ for (const p of pendingTasks) {
448
+ const duplicateTitle = (titleKeyCount.get(p.titleKey) ?? 0) > 1;
449
+ const idKey = taskIdDedupKey(p.id);
450
+ const idPart = dt.dataSearchTaskDistinguishId.replace("{id}", idKey);
451
+ let metaLine: string | undefined;
452
+ if (duplicateTitle) {
453
+ const tagLine = p.idx.metaLine.trim();
454
+ metaLine = tagLine ? `${idPart} · ${truncateMetaLine(tagLine, 120)}` : idPart;
455
+ } else {
456
+ metaLine = p.idx.metaLine.trim() ? truncateMetaLine(p.idx.metaLine, 160) : undefined;
457
+ }
458
+ const taskIdForDom = p.id;
459
+ items.push({
460
+ id: `search-task-${idKey}`,
461
+ title: p.titleKey,
462
+ subtitle: dt.dataSearchKindTask,
463
+ metaLine,
464
+ searchTimerLine: p.timerInfo.line,
465
+ searchTimerState: p.timerInfo.state,
466
+ haystack: p.hay,
467
+ durationForSearchSec: p.durationSec,
468
+ onSelect: () => {
469
+ focusTasksColumn();
470
+ globalThis.requestAnimationFrame(() => scrollToTask(taskIdForDom));
471
+ },
472
+ });
473
+ }
474
+
475
+ return dedupeSearchItemsById(items);
476
+ }
@@ -0,0 +1,63 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ dataSearchItemMatches,
4
+ parseDataSearchQuery,
5
+ } from "@/lib/dashboardQuickSearchQuery";
6
+ import type { DashboardSearchItem } from "@/lib/dashboardQuickSearch";
7
+
8
+ function item(hay: string, dur?: number): DashboardSearchItem {
9
+ return {
10
+ id: "x",
11
+ title: "t",
12
+ haystack: hay,
13
+ durationForSearchSec: dur,
14
+ onSelect: () => {},
15
+ };
16
+ }
17
+
18
+ describe("parseDataSearchQuery", () => {
19
+ it("extrait mm:ss et opérateur (après le temps)", () => {
20
+ const r = parseDataSearchQuery(" 44:33 > ");
21
+ expect(r.textPart).toBe("");
22
+ expect(r.durationPredicates).toEqual([{ op: "gt", seconds: 44 * 60 + 33 }]);
23
+ });
24
+
25
+ it("extrait et laisse du texte", () => {
26
+ const r = parseDataSearchQuery("bug 12:55 <");
27
+ expect(r.textPart).toBe("bug");
28
+ expect(r.durationPredicates).toEqual([{ op: "lt", seconds: 12 * 60 + 55 }]);
29
+ });
30
+
31
+ it("heure pleine h:m:s", () => {
32
+ const r = parseDataSearchQuery("1:0:0 >");
33
+ expect(r.durationPredicates).toEqual([{ op: "gt", seconds: 3600 }]);
34
+ });
35
+
36
+ it("opérateur avant le temps", () => {
37
+ const r = parseDataSearchQuery("> 0:10");
38
+ expect(r.textPart).toBe("");
39
+ expect(r.durationPredicates).toEqual([{ op: "gt", seconds: 10 }]);
40
+ });
41
+ });
42
+
43
+ describe("dataSearchItemMatches", () => {
44
+ it("filtre par durée seule", () => {
45
+ const under = item("x", 40);
46
+ const over = item("x", 90);
47
+ expect(dataSearchItemMatches(under, "1:0 <")).toBe(true);
48
+ expect(dataSearchItemMatches(over, "1:0 <")).toBe(false);
49
+ });
50
+
51
+ it("sans durée enregistrée : échec si prédicat temporel", () => {
52
+ expect(dataSearchItemMatches(item("hello", undefined), "0:1 >")).toBe(false);
53
+ });
54
+
55
+ it("texte + durée", () => {
56
+ const good = item("alpha #tag", 4000);
57
+ const badText = item("beta", 4000);
58
+ const badDur = item("alpha", 10);
59
+ expect(dataSearchItemMatches(good, "alpha 1:0:0 >")).toBe(true);
60
+ expect(dataSearchItemMatches(badText, "alpha 1:0:0 >")).toBe(false);
61
+ expect(dataSearchItemMatches(badDur, "alpha 1:0:0 >")).toBe(false);
62
+ });
63
+ });