@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,332 @@
1
+ "use client";
2
+
3
+ import {
4
+ useCallback,
5
+ useEffect,
6
+ useLayoutEffect,
7
+ useMemo,
8
+ useRef,
9
+ useState,
10
+ type CSSProperties,
11
+ } from "react";
12
+ import { createPortal } from "react-dom";
13
+ import { markSettingsTourCompleted } from "@/lib/dashboardTourStorage";
14
+ import type { DashboardStrings } from "@/lib/dashboardCopy";
15
+
16
+ /** Anchors for the settings walkthrough steps. */
17
+ const STEP_SELECTORS = [
18
+ "#settings-tour-anchor-intro",
19
+ "#settings-usage-profile",
20
+ "#settings-history",
21
+ "#settings-git",
22
+ "#settings-workspace-loc",
23
+ "#settings-schedule",
24
+ ] as const;
25
+
26
+ const HOLE_PADDING_PX = 10;
27
+ const TOOLTIP_MAX_W = 360;
28
+ const VIEW_MARGIN = 12;
29
+ const TOOLTIP_GAP = 12;
30
+
31
+ function useEscapeDismiss(open: boolean, onDismiss: () => void) {
32
+ useEffect(() => {
33
+ if (!open) {
34
+ return;
35
+ }
36
+ const onKey = (e: KeyboardEvent) => {
37
+ if (e.key === "Escape") {
38
+ e.preventDefault();
39
+ onDismiss();
40
+ }
41
+ };
42
+ document.addEventListener("keydown", onKey);
43
+ return () => document.removeEventListener("keydown", onKey);
44
+ }, [open, onDismiss]);
45
+ }
46
+
47
+ type HoleRect = { top: number; left: number; width: number; height: number };
48
+
49
+ function expandRect(r: DOMRect, pad: number): HoleRect {
50
+ return {
51
+ top: r.top - pad,
52
+ left: r.left - pad,
53
+ width: r.width + 2 * pad,
54
+ height: r.height + 2 * pad,
55
+ };
56
+ }
57
+
58
+ export function SettingsTour({
59
+ open,
60
+ onOpenChange,
61
+ dt,
62
+ }: {
63
+ open: boolean;
64
+ onOpenChange: (open: boolean) => void;
65
+ dt: DashboardStrings;
66
+ }) {
67
+ const [step, setStep] = useState(0);
68
+ const [hole, setHole] = useState<HoleRect | null>(null);
69
+ const [tipStyle, setTipStyle] = useState<CSSProperties>({});
70
+ const panelRef = useRef<HTMLDivElement>(null);
71
+ const primaryBtnRef = useRef<HTMLButtonElement>(null);
72
+
73
+ const steps = useMemo(
74
+ () => [
75
+ { title: dt.settingsTourStep1Title, body: dt.settingsTourStep1Body },
76
+ { title: dt.settingsTourStep2Title, body: dt.settingsTourStep2Body },
77
+ { title: dt.settingsTourStep3Title, body: dt.settingsTourStep3Body },
78
+ { title: dt.settingsTourStep4Title, body: dt.settingsTourStep4Body },
79
+ { title: dt.settingsTourStep5Title, body: dt.settingsTourStep5Body },
80
+ { title: dt.settingsTourStep6Title, body: dt.settingsTourStep6Body },
81
+ ],
82
+ [dt]
83
+ );
84
+
85
+ const total = steps.length;
86
+ const last = step >= total - 1;
87
+ const current = steps[step] ?? steps[0];
88
+ const selector = STEP_SELECTORS[Math.min(step, STEP_SELECTORS.length - 1)];
89
+
90
+ useEffect(() => {
91
+ if (open) {
92
+ setStep(0);
93
+ }
94
+ }, [open]);
95
+
96
+ const finish = useCallback(() => {
97
+ markSettingsTourCompleted();
98
+ onOpenChange(false);
99
+ }, [onOpenChange]);
100
+
101
+ useEscapeDismiss(open, finish);
102
+
103
+ const updateHoleFromDom = useCallback(() => {
104
+ if (!open) {
105
+ setHole(null);
106
+ return;
107
+ }
108
+ const el = document.querySelector(selector);
109
+ if (!el || !(el instanceof HTMLElement)) {
110
+ setHole(null);
111
+ return;
112
+ }
113
+ setHole(expandRect(el.getBoundingClientRect(), HOLE_PADDING_PX));
114
+ }, [open, selector]);
115
+
116
+ useLayoutEffect(() => {
117
+ if (!open) {
118
+ setHole(null);
119
+ return;
120
+ }
121
+ const el = document.querySelector(selector);
122
+ if (el instanceof HTMLElement) {
123
+ el.scrollIntoView({ block: "center", inline: "nearest", behavior: "auto" });
124
+ }
125
+ updateHoleFromDom();
126
+ const raf = requestAnimationFrame(updateHoleFromDom);
127
+ window.addEventListener("scroll", updateHoleFromDom, true);
128
+ window.addEventListener("resize", updateHoleFromDom);
129
+ const observed = el instanceof HTMLElement ? el : null;
130
+ const ro =
131
+ observed && typeof ResizeObserver !== "undefined"
132
+ ? new ResizeObserver(() => updateHoleFromDom())
133
+ : null;
134
+ if (observed && ro) {
135
+ ro.observe(observed);
136
+ }
137
+ return () => {
138
+ cancelAnimationFrame(raf);
139
+ window.removeEventListener("scroll", updateHoleFromDom, true);
140
+ window.removeEventListener("resize", updateHoleFromDom);
141
+ ro?.disconnect();
142
+ };
143
+ }, [open, selector, step, updateHoleFromDom]);
144
+
145
+ useLayoutEffect(() => {
146
+ if (!open) {
147
+ return;
148
+ }
149
+ const vw = typeof window !== "undefined" ? window.innerWidth : 1024;
150
+ const vh = typeof window !== "undefined" ? window.innerHeight : 768;
151
+ const w = Math.min(TOOLTIP_MAX_W, vw - 2 * VIEW_MARGIN);
152
+
153
+ if (!hole) {
154
+ setTipStyle({
155
+ position: "fixed",
156
+ top: "50%",
157
+ left: "50%",
158
+ transform: "translate(-50%, -50%)",
159
+ width: w,
160
+ maxWidth: "calc(100vw - 2rem)",
161
+ zIndex: 212,
162
+ });
163
+ return;
164
+ }
165
+
166
+ const panel = panelRef.current;
167
+ const ph = panel?.getBoundingClientRect().height ?? 220;
168
+
169
+ let top = hole.top + hole.height + TOOLTIP_GAP;
170
+ if (top + ph > vh - VIEW_MARGIN && hole.top - TOOLTIP_GAP - ph >= VIEW_MARGIN) {
171
+ top = hole.top - TOOLTIP_GAP - ph;
172
+ }
173
+ top = Math.max(VIEW_MARGIN, Math.min(top, vh - ph - VIEW_MARGIN));
174
+
175
+ let left = hole.left + hole.width / 2 - w / 2;
176
+ left = Math.max(VIEW_MARGIN, Math.min(left, vw - w - VIEW_MARGIN));
177
+
178
+ setTipStyle({
179
+ position: "fixed",
180
+ top,
181
+ left,
182
+ transform: undefined,
183
+ width: w,
184
+ maxWidth: undefined,
185
+ zIndex: 212,
186
+ });
187
+ }, [open, hole, step, current.title, current.body]);
188
+
189
+ useEffect(() => {
190
+ if (!open) {
191
+ return;
192
+ }
193
+ const t = window.setTimeout(() => primaryBtnRef.current?.focus(), 80);
194
+ return () => window.clearTimeout(t);
195
+ }, [open, step]);
196
+
197
+ if (!open) {
198
+ return null;
199
+ }
200
+
201
+ const progressLabel = dt.settingsTourProgressLabel.replace("{n}", String(step + 1)).replace("{total}", String(total));
202
+
203
+ const secondaryBtn =
204
+ "rounded-lg border border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-800 transition hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-200 dark:hover:bg-zinc-700";
205
+ const primaryBtn =
206
+ "rounded-lg bg-violet-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-violet-700 focus:outline-none focus:ring-2 focus:ring-violet-500 focus:ring-offset-2 dark:bg-violet-500 dark:hover:bg-violet-600 dark:focus:ring-offset-zinc-900";
207
+
208
+ const vw = typeof window !== "undefined" ? window.innerWidth : 0;
209
+ const vh = typeof window !== "undefined" ? window.innerHeight : 0;
210
+
211
+ const fullBackdrop = !hole ? (
212
+ <div className="fixed inset-0 z-[210] bg-black/55" aria-hidden />
213
+ ) : null;
214
+
215
+ const dimPanels =
216
+ hole && vw > 0 && vh > 0
217
+ ? (() => {
218
+ const { top: t, left: l, width: w, height: h } = hole;
219
+ const topH = Math.max(0, t);
220
+ const bottomTop = t + h;
221
+ const bottomH = Math.max(0, vh - bottomTop);
222
+ const leftW = Math.max(0, l);
223
+ const rightLeft = l + w;
224
+ const rightW = Math.max(0, vw - rightLeft);
225
+ return (
226
+ <>
227
+ <div
228
+ className="fixed bg-black/55"
229
+ style={{ top: 0, left: 0, width: vw, height: topH, zIndex: 210 }}
230
+ aria-hidden
231
+ />
232
+ <div
233
+ className="fixed bg-black/55"
234
+ style={{ top: bottomTop, left: 0, width: vw, height: bottomH, zIndex: 210 }}
235
+ aria-hidden
236
+ />
237
+ <div
238
+ className="fixed bg-black/55"
239
+ style={{ top: t, left: 0, width: leftW, height: h, zIndex: 210 }}
240
+ aria-hidden
241
+ />
242
+ <div
243
+ className="fixed bg-black/55"
244
+ style={{ top: t, left: rightLeft, width: rightW, height: h, zIndex: 210 }}
245
+ aria-hidden
246
+ />
247
+ <div
248
+ className="pointer-events-none fixed rounded-xl border-2 border-violet-500 shadow-[0_0_0_1px_rgba(139,92,246,0.35)] dark:border-violet-400 dark:shadow-[0_0_0_1px_rgba(167,139,250,0.35)]"
249
+ style={{
250
+ top: t,
251
+ left: l,
252
+ width: w,
253
+ height: h,
254
+ zIndex: 211,
255
+ }}
256
+ aria-hidden
257
+ />
258
+ </>
259
+ );
260
+ })()
261
+ : null;
262
+
263
+ const node = createPortal(
264
+ <>
265
+ {fullBackdrop}
266
+ {dimPanels}
267
+ <div
268
+ ref={panelRef}
269
+ role="dialog"
270
+ aria-modal="true"
271
+ aria-labelledby="settings-tour-title"
272
+ aria-describedby="settings-tour-body"
273
+ className="rounded-xl border border-zinc-300 bg-white shadow-2xl dark:border-zinc-600 dark:bg-zinc-900"
274
+ style={tipStyle}
275
+ onMouseDown={(e) => e.stopPropagation()}
276
+ >
277
+ <div className="border-b border-zinc-200 px-4 py-3 dark:border-zinc-700">
278
+ <p className="text-xs font-medium uppercase tracking-wide text-violet-600 dark:text-violet-300">
279
+ {progressLabel}
280
+ </p>
281
+ <h2 id="settings-tour-title" className="mt-1 text-base font-semibold text-zinc-900 dark:text-zinc-100">
282
+ {current.title}
283
+ </h2>
284
+ </div>
285
+ <div id="settings-tour-body" className="max-h-[min(42vh,18rem)] overflow-y-auto px-4 py-3">
286
+ <p className="whitespace-pre-wrap text-sm leading-relaxed text-zinc-700 dark:text-zinc-300">{current.body}</p>
287
+ </div>
288
+ <div className="flex flex-wrap items-center justify-between gap-2 border-t border-zinc-200 px-4 py-3 dark:border-zinc-700">
289
+ <div className="flex gap-1.5" role="presentation" aria-hidden>
290
+ {steps.map((_, i) => (
291
+ <span
292
+ key={i}
293
+ className={`h-2 w-2 rounded-full ${i === step ? "bg-violet-500 dark:bg-violet-400" : "bg-zinc-300 dark:bg-zinc-600"}`}
294
+ />
295
+ ))}
296
+ </div>
297
+ <div className="flex flex-wrap items-center justify-end gap-2">
298
+ <button
299
+ type="button"
300
+ className="text-sm text-zinc-500 underline-offset-2 hover:text-zinc-800 hover:underline dark:text-zinc-400 dark:hover:text-zinc-200"
301
+ onClick={finish}
302
+ >
303
+ {dt.settingsTourSkipBtn}
304
+ </button>
305
+ {step > 0 ? (
306
+ <button type="button" className={secondaryBtn} onClick={() => setStep((s) => Math.max(0, s - 1))}>
307
+ {dt.settingsTourBackBtn}
308
+ </button>
309
+ ) : null}
310
+ {last ? (
311
+ <button ref={primaryBtnRef} type="button" className={primaryBtn} onClick={finish}>
312
+ {dt.settingsTourDoneBtn}
313
+ </button>
314
+ ) : (
315
+ <button
316
+ ref={primaryBtnRef}
317
+ type="button"
318
+ className={primaryBtn}
319
+ onClick={() => setStep((s) => Math.min(total - 1, s + 1))}
320
+ >
321
+ {dt.settingsTourNextBtn}
322
+ </button>
323
+ )}
324
+ </div>
325
+ </div>
326
+ </div>
327
+ </>,
328
+ document.body
329
+ );
330
+
331
+ return node;
332
+ }
@@ -0,0 +1,149 @@
1
+ "use client";
2
+
3
+ import { Fragment } from "react";
4
+ import { formatTagDisplayForTask, isProjectScopedTag, normalizeTagKey } from "@/lib/taskParsing";
5
+ import {
6
+ TAG_INLINE_APPLIED_READONLY,
7
+ TAG_INLINE_APPLIED_REMOVABLE,
8
+ TAG_INLINE_APPLIED_SCOPED_READONLY,
9
+ TAG_INLINE_APPLIED_SCOPED_REMOVABLE,
10
+ TAG_INLINE_NEUTRAL_READONLY,
11
+ TAG_INLINE_NEUTRAL_REMOVABLE,
12
+ } from "./taskFieldStyles";
13
+ import { useDescriptionPopoverAfterMs } from "./useDescriptionPopoverAfterMs";
14
+
15
+ function TagPillItem({
16
+ tag,
17
+ chip,
18
+ onRemove,
19
+ description,
20
+ defaultTagBucketLabel,
21
+ }: {
22
+ tag: string;
23
+ chip: string;
24
+ onRemove?: (tag: string) => void;
25
+ description: string | undefined;
26
+ defaultTagBucketLabel?: string;
27
+ }) {
28
+ const { hasDescription, triggerProps, popoverLayer, anchorWrapperProps } =
29
+ useDescriptionPopoverAfterMs(description);
30
+
31
+ const interactive = Boolean(onRemove);
32
+ const affordanceClass =
33
+ interactive ? "" : hasDescription ? "cursor-help" : "";
34
+
35
+ const inner = (
36
+ <span
37
+ role={interactive ? "button" : undefined}
38
+ tabIndex={interactive ? 0 : undefined}
39
+ className={[chip, affordanceClass, "min-w-0 max-w-full"].filter(Boolean).join(" ")}
40
+ {...triggerProps}
41
+ {...(onRemove
42
+ ? {
43
+ onClick: () => onRemove(tag),
44
+ onKeyDown: (e) => {
45
+ if (e.key === "Enter" || e.key === " ") {
46
+ e.preventDefault();
47
+ onRemove(tag);
48
+ }
49
+ },
50
+ }
51
+ : {})}
52
+ >
53
+ <span className="pointer-events-none break-all">
54
+ {formatTagDisplayForTask(tag, defaultTagBucketLabel)}
55
+ </span>
56
+ {interactive ? (
57
+ <span className="pointer-events-none text-[0.7rem] font-normal text-zinc-400 dark:text-zinc-500">×</span>
58
+ ) : null}
59
+ </span>
60
+ );
61
+
62
+ if (!hasDescription) {
63
+ return inner;
64
+ }
65
+
66
+ return (
67
+ <Fragment>
68
+ <span className="relative inline-flex min-w-0 max-w-full" {...anchorWrapperProps}>
69
+ {inner}
70
+ </span>
71
+ {popoverLayer}
72
+ </Fragment>
73
+ );
74
+ }
75
+
76
+ export function TagPills({
77
+ tags,
78
+ onRemove,
79
+ className,
80
+ variant = "default",
81
+ /**
82
+ * Étiquettes `projet#local` : soulignement pointillé (distinct des étiquettes « pleines »).
83
+ */
84
+ differentiateProjectScopedTags = false,
85
+ /** Infobulle : clés = {@link normalizeTagKey} en minuscules (aligné sur l’API). */
86
+ tagDescriptions,
87
+ /** Libellé lisible pour l’étiquette réservée `default` (ex. « défaut » en français). */
88
+ defaultTagBucketLabel,
89
+ }: {
90
+ tags: string[];
91
+ onRemove?: (tag: string) => void;
92
+ /** Remplace les marges par défaut (ex. alignement sur une même ligne que le titre). */
93
+ className?: string;
94
+ /** `applied` : étiquettes actuellement sur la tâche (style texte vert). */
95
+ variant?: "default" | "applied";
96
+ differentiateProjectScopedTags?: boolean;
97
+ tagDescriptions?: Record<string, string>;
98
+ defaultTagBucketLabel?: string;
99
+ }) {
100
+ const chipBase = (tag: string) => {
101
+ const scoped = differentiateProjectScopedTags && isProjectScopedTag(normalizeTagKey(tag));
102
+ if (variant === "applied") {
103
+ if (scoped) {
104
+ return onRemove ? TAG_INLINE_APPLIED_SCOPED_REMOVABLE : TAG_INLINE_APPLIED_SCOPED_READONLY;
105
+ }
106
+ return onRemove ? TAG_INLINE_APPLIED_REMOVABLE : TAG_INLINE_APPLIED_READONLY;
107
+ }
108
+ if (scoped) {
109
+ return onRemove ? TAG_INLINE_APPLIED_SCOPED_REMOVABLE : TAG_INLINE_APPLIED_SCOPED_READONLY;
110
+ }
111
+ return onRemove ? TAG_INLINE_NEUTRAL_REMOVABLE : TAG_INLINE_NEUTRAL_READONLY;
112
+ };
113
+ const describe = (tag: string) => {
114
+ if (!tagDescriptions) {
115
+ return undefined;
116
+ }
117
+ const k = normalizeTagKey(tag).toLowerCase();
118
+ return tagDescriptions[k] ?? tagDescriptions[normalizeTagKey(tag)];
119
+ };
120
+
121
+ const sortedTags = tags.slice().sort((a, b) => {
122
+ const aScoped = differentiateProjectScopedTags && isProjectScopedTag(normalizeTagKey(a));
123
+ const bScoped = differentiateProjectScopedTags && isProjectScopedTag(normalizeTagKey(b));
124
+ if (aScoped && !bScoped) return -1;
125
+ if (!aScoped && bScoped) return 1;
126
+ return a.localeCompare(b);
127
+ });
128
+
129
+ return (
130
+ <div className={className ?? "mt-1 flex flex-wrap items-baseline gap-x-2 gap-y-0.5"}>
131
+ {sortedTags.map((tag, i) => (
132
+ <Fragment key={tag}>
133
+ {i > 0 ? (
134
+ <span className="select-none text-zinc-300 dark:text-zinc-600" aria-hidden>
135
+ ·
136
+ </span>
137
+ ) : null}
138
+ <TagPillItem
139
+ tag={tag}
140
+ chip={chipBase(tag)}
141
+ onRemove={onRemove}
142
+ description={describe(tag)}
143
+ defaultTagBucketLabel={defaultTagBucketLabel}
144
+ />
145
+ </Fragment>
146
+ ))}
147
+ </div>
148
+ );
149
+ }
@@ -0,0 +1,84 @@
1
+ "use client";
2
+
3
+ import { useEffect, useId, useRef, useState, type CSSProperties } from "react";
4
+ import { createPortal } from "react-dom";
5
+ import { CircleHelp } from "lucide-react";
6
+ import type { DashboardStrings } from "@/lib/dashboardCopy";
7
+ import { useAnchoredFloatingPortalStyle } from "./useAnchoredFloatingPortalStyle";
8
+
9
+ const PANEL_PLACEHOLDER_STYLE: CSSProperties = {
10
+ position: "fixed",
11
+ top: 0,
12
+ left: 0,
13
+ width: 384,
14
+ zIndex: 80,
15
+ visibility: "hidden",
16
+ pointerEvents: "none",
17
+ };
18
+
19
+ export function TagsHelpTrigger({ t, compact }: { t: DashboardStrings; compact?: boolean }) {
20
+ const [open, setOpen] = useState(false);
21
+ const triggerRef = useRef<HTMLDivElement>(null);
22
+ const panelRef = useRef<HTMLDivElement>(null);
23
+ const id = useId();
24
+
25
+ const panelStyle = useAnchoredFloatingPortalStyle(open, triggerRef, panelRef, {
26
+ align: "end",
27
+ maxWidthRem: 24,
28
+ });
29
+
30
+ useEffect(() => {
31
+ if (!open) {
32
+ return;
33
+ }
34
+ const onDoc = (e: MouseEvent) => {
35
+ const n = e.target as Node;
36
+ if (!triggerRef.current?.contains(n) && !panelRef.current?.contains(n)) {
37
+ setOpen(false);
38
+ }
39
+ };
40
+ document.addEventListener("mousedown", onDoc);
41
+ return () => document.removeEventListener("mousedown", onDoc);
42
+ }, [open]);
43
+
44
+ const icon = compact ? 11 : 18;
45
+ const mergedStyle = panelStyle ?? (open ? PANEL_PLACEHOLDER_STYLE : undefined);
46
+
47
+ const panel =
48
+ open && typeof document !== "undefined" && mergedStyle
49
+ ? createPortal(
50
+ <div
51
+ ref={panelRef}
52
+ id={`${id}-tags-help`}
53
+ style={mergedStyle}
54
+ className="rounded-lg border border-zinc-200 bg-white p-2.5 text-left shadow-xl dark:border-zinc-600 dark:bg-zinc-900"
55
+ role="region"
56
+ aria-label={t.tagsHelpAriaLabel}
57
+ >
58
+ <p className="text-[0.7rem] leading-snug text-zinc-700 dark:text-zinc-300">{t.tagSyntaxHelp}</p>
59
+ <p className="mt-2 text-[0.7rem] leading-snug text-zinc-700 dark:text-zinc-300">{t.tagSuggestionsHelp}</p>
60
+ <p className="mt-2 text-[0.7rem] leading-snug text-zinc-600 dark:text-zinc-400">{t.tagsHelpWorkflowSummary}</p>
61
+ </div>,
62
+ document.body
63
+ )
64
+ : null;
65
+
66
+ return (
67
+ <div
68
+ className={`relative flex shrink-0 items-center justify-center self-center ${compact ? "h-5 min-w-5" : "h-10"}`}
69
+ ref={triggerRef}
70
+ >
71
+ <button
72
+ type="button"
73
+ className={`text-zinc-500 hover:bg-zinc-200/90 hover:text-zinc-800 dark:hover:bg-zinc-800 dark:hover:text-zinc-300 ${compact ? "flex size-full items-center justify-center rounded-sm p-0" : "rounded-md p-1.5"}`}
74
+ aria-label={t.tagsHelpAriaLabel}
75
+ aria-expanded={open ? "true" : "false"}
76
+ aria-controls={`${id}-tags-help`}
77
+ onClick={() => setOpen((o) => !o)}
78
+ >
79
+ <CircleHelp size={icon} strokeWidth={1.75} aria-hidden />
80
+ </button>
81
+ {panel}
82
+ </div>
83
+ );
84
+ }