@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,993 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
+ import { EyeOff, FolderKanban, Pin, PinOff, Play, Plus, RotateCcw, Save, Tags, Trash2 } from "lucide-react";
5
+ import { postKronosysAction } from "@/lib/kronosysApi";
6
+ import type { KronosysUpdatePayload } from "@/lib/kronosysApi";
7
+ import type { Lang } from "@/lib/dashboardCopy";
8
+ import type { SettingsCopy } from "@/lib/settingsCopy";
9
+ import {
10
+ buildStartTaskFromDraft,
11
+ formatProjectDisplay,
12
+ formatTagDisplay,
13
+ normalizeProjectKey,
14
+ normalizeTagKey,
15
+ parseProjectScopedTag,
16
+ } from "@/lib/taskParsing";
17
+ import { DashboardConfirmModal, DashboardTriActionModal } from "./DashboardSimpleModal";
18
+ import { useDashboardToast } from "@/components/dashboard/DashboardToastProvider";
19
+ import { InlineMetricHelpTrigger } from "./InlineMetricHelpTrigger";
20
+ import {
21
+ readConcurrentTaskStartPreference,
22
+ writeConcurrentTaskStartPreference,
23
+ type ConcurrentTaskStartPreference,
24
+ } from "@/lib/concurrentTaskStartPreference";
25
+
26
+ const TEXTAREA_CLASS =
27
+ "mt-1 w-full max-w-md rounded-lg border border-zinc-300 bg-white px-2.5 py-1.5 text-xs text-zinc-900 outline-none ring-violet-500/30 focus:ring-2 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100";
28
+
29
+ const INLINE_INPUT_CLASS =
30
+ "w-full bg-transparent text-xs text-zinc-600 outline-none placeholder:text-zinc-400 focus:border-b focus:border-violet-500/50 dark:text-zinc-400 dark:placeholder:text-zinc-600 dark:focus:border-violet-400/50";
31
+
32
+ const CARD_CLASS =
33
+ "scroll-mt-24 rounded-xl border border-zinc-200 bg-zinc-50/90 p-4 dark:border-zinc-800 dark:bg-zinc-900/40";
34
+
35
+ const ICON_BTN =
36
+ "inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md border border-zinc-300 bg-white text-zinc-700 shadow-sm transition hover:border-zinc-400 hover:bg-zinc-50 disabled:opacity-40 dark:border-zinc-600 dark:bg-zinc-800/90 dark:text-zinc-200 dark:hover:border-zinc-500 dark:hover:bg-zinc-800";
37
+
38
+ const ICON_BTN_DANGER =
39
+ "inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md border border-red-700/55 bg-white text-red-800 shadow-sm transition hover:border-red-700 hover:bg-red-50 disabled:opacity-40 dark:border-red-800/55 dark:bg-red-950/30 dark:text-red-200 dark:hover:bg-red-950/50";
40
+
41
+ const ICON_BTN_SAVE =
42
+ "inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md border border-zinc-300 bg-white text-zinc-700 shadow-sm transition hover:border-violet-400 hover:bg-violet-50 hover:text-violet-900 disabled:opacity-40 dark:border-zinc-600 dark:bg-zinc-800/90 dark:text-zinc-200 dark:hover:border-violet-500/50 dark:hover:bg-violet-950/40 dark:hover:text-violet-100";
43
+
44
+ const ICON_BTN_PRIMARY =
45
+ "inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md border border-violet-500/50 bg-violet-500/10 text-violet-800 shadow-sm transition hover:border-violet-500/75 hover:bg-violet-500/18 disabled:opacity-40 dark:border-violet-400/45 dark:bg-violet-600/20 dark:text-violet-100 dark:hover:border-violet-400/60 dark:hover:bg-violet-600/30";
46
+
47
+ type LiveActiveTaskShape = { id: string; isDone?: boolean; manualTaskTimerPaused?: boolean };
48
+
49
+ type CurrentSessionShape = {
50
+ sessionId?: string;
51
+ activeTasks?: LiveActiveTaskShape[];
52
+ activeTask?: LiveActiveTaskShape | null;
53
+ };
54
+
55
+ function runningTrackingTasks(live: CurrentSessionShape | null | undefined): LiveActiveTaskShape[] {
56
+ if (!live) {
57
+ return [];
58
+ }
59
+ const raw =
60
+ Array.isArray(live.activeTasks) && live.activeTasks.length > 0
61
+ ? live.activeTasks
62
+ : live.activeTask
63
+ ? [live.activeTask]
64
+ : [];
65
+ return raw.filter((t): t is LiveActiveTaskShape => !!t && !t.isDone && !t.manualTaskTimerPaused);
66
+ }
67
+
68
+ type ConfirmState =
69
+ | null
70
+ | { kind: "unpin"; tag: string }
71
+ | { kind: "exclude"; tag: string }
72
+ | { kind: "purgeTag"; tag: string; fromExcludedList?: boolean }
73
+ | { kind: "purgeProject"; name: string };
74
+
75
+ type TagEditorRowProps = {
76
+ tag: string;
77
+ fieldId: string;
78
+ s: SettingsCopy;
79
+ disabled: boolean;
80
+ localTagDesc: Record<string, string>;
81
+ setLocalTagDesc: React.Dispatch<React.SetStateAction<Record<string, string>>>;
82
+ pinnedKeys: Set<string>;
83
+ post: (body: Record<string, unknown>) => Promise<void>;
84
+ setConfirm: (c: ConfirmState) => void;
85
+ onStartTrackedTask?: () => void;
86
+ };
87
+
88
+ function SettingsTagEditorRow({
89
+ tag,
90
+ fieldId,
91
+ s,
92
+ disabled,
93
+ localTagDesc,
94
+ setLocalTagDesc,
95
+ pinnedKeys,
96
+ post,
97
+ setConfirm,
98
+ onStartTrackedTask,
99
+ }: TagEditorRowProps) {
100
+ const tagKey = normalizeTagKey(tag).toLowerCase();
101
+ const isPinned = pinnedKeys.has(tagKey);
102
+ return (
103
+ <li className="border-b border-zinc-200 py-3 last:border-b-0 dark:border-zinc-800/90">
104
+ <div className="flex flex-col gap-2.5">
105
+ <div className="flex min-w-0 flex-wrap items-center gap-2">
106
+ <span className="font-mono text-sm text-zinc-900 dark:text-zinc-100">{formatTagDisplay(tag)}</span>
107
+ {isPinned ? (
108
+ <span className="rounded border border-violet-500/45 bg-violet-500/10 px-1.5 py-0.5 text-[0.65rem] font-semibold uppercase tracking-wide text-violet-800 dark:text-violet-200">
109
+ {s.tagBadgePinned}
110
+ </span>
111
+ ) : null}
112
+ </div>
113
+ <input
114
+ id={fieldId}
115
+ type="text"
116
+ className={INLINE_INPUT_CLASS}
117
+ value={localTagDesc[tagKey] ?? ""}
118
+ onChange={(e) => setLocalTagDesc((prev) => ({ ...prev, [tagKey]: e.target.value }))}
119
+ disabled={disabled}
120
+ placeholder={s.tagDescriptionLabel}
121
+ spellCheck
122
+ />
123
+ <div className="flex flex-wrap items-center gap-1.5">
124
+ {!isPinned ? (
125
+ <button
126
+ type="button"
127
+ className={ICON_BTN}
128
+ disabled={disabled}
129
+ title={s.tagPinBtn}
130
+ aria-label={s.tagPinBtn}
131
+ onClick={() => void post({ type: "addUserKnownTag", tag })}
132
+ >
133
+ <Pin size={16} strokeWidth={2} aria-hidden />
134
+ </button>
135
+ ) : (
136
+ <button
137
+ type="button"
138
+ className={ICON_BTN}
139
+ disabled={disabled}
140
+ title={s.tagUnpinBtn}
141
+ aria-label={s.tagUnpinBtn}
142
+ onClick={() => setConfirm({ kind: "unpin", tag })}
143
+ >
144
+ <PinOff size={16} strokeWidth={2} aria-hidden />
145
+ </button>
146
+ )}
147
+ <button
148
+ type="button"
149
+ className={ICON_BTN}
150
+ disabled={disabled}
151
+ title={s.tagExcludeBtn}
152
+ aria-label={s.tagExcludeBtn}
153
+ onClick={() => setConfirm({ kind: "exclude", tag })}
154
+ >
155
+ <EyeOff size={16} strokeWidth={2} aria-hidden />
156
+ </button>
157
+ <button
158
+ type="button"
159
+ className={ICON_BTN_DANGER}
160
+ disabled={disabled}
161
+ title={s.tagPurgeAriaLabel}
162
+ aria-label={s.tagPurgeAriaLabel}
163
+ onClick={() => setConfirm({ kind: "purgeTag", tag })}
164
+ >
165
+ <Trash2 size={16} strokeWidth={2} aria-hidden />
166
+ </button>
167
+ <button
168
+ type="button"
169
+ className={ICON_BTN_SAVE}
170
+ disabled={disabled}
171
+ title={s.descriptionSaveBtn}
172
+ aria-label={s.descriptionSaveBtn}
173
+ onClick={() =>
174
+ void post({
175
+ type: "setTagDescription",
176
+ tag,
177
+ description: localTagDesc[tagKey] ?? "",
178
+ })
179
+ }
180
+ >
181
+ <Save size={16} strokeWidth={2} aria-hidden />
182
+ </button>
183
+ {onStartTrackedTask ? (
184
+ <button
185
+ type="button"
186
+ className={ICON_BTN_PRIMARY}
187
+ disabled={disabled}
188
+ title={s.tagStartTrackedTaskBtn}
189
+ aria-label={s.tagStartTrackedTaskBtn}
190
+ onClick={() => void onStartTrackedTask()}
191
+ >
192
+ <Play size={16} strokeWidth={2} className="ml-0.5" fill="currentColor" aria-hidden />
193
+ </button>
194
+ ) : null}
195
+ </div>
196
+ </div>
197
+ </li>
198
+ );
199
+ }
200
+
201
+ export function SettingsTagsProjectsSection({
202
+ s,
203
+ lang: _lang,
204
+ payload,
205
+ saving,
206
+ refresh,
207
+ variant = "settings",
208
+ }: {
209
+ s: SettingsCopy;
210
+ lang: Lang;
211
+ payload: KronosysUpdatePayload | null;
212
+ saving: boolean;
213
+ refresh: (options?: { preserveForm?: boolean; routerInvalidate?: boolean }) => Promise<boolean | void>;
214
+ /** `dashboard` : pas de titre de page ni bordure de section (colonne du tableau de bord). */
215
+ variant?: "settings" | "dashboard";
216
+ }) {
217
+ void _lang;
218
+ const pid = variant === "dashboard" ? "dashboard" : "settings";
219
+ const showPageHeader = variant === "settings";
220
+ const [pinGlobalDraft, setPinGlobalDraft] = useState("");
221
+ /** Brouillon d’épingle Projet#code par clé de projet (minuscules). */
222
+ const [pinScopedByProject, setPinScopedByProject] = useState<Record<string, string>>({});
223
+ const [busy, setBusy] = useState(false);
224
+ const [confirm, setConfirm] = useState<ConfirmState>(null);
225
+ /** Colonne tableau de bord : onglets globaux / projets (paramètres : affichage continu inchangé). */
226
+ const [dashTagsTab, setDashTagsTab] = useState<"global" | "projects">("global");
227
+ const pendingStartRef = useRef<{ name: string; tags: string[]; project?: string } | null>(null);
228
+ const [trackingConflictOpen, setTrackingConflictOpen] = useState(false);
229
+ const [rememberConcurrentChoice, setRememberConcurrentChoice] = useState(false);
230
+ const { pushToast } = useDashboardToast();
231
+
232
+ const knownTags = (payload?.knownTags ?? []) as string[];
233
+ const knownProjects = (payload?.knownProjects ?? []) as string[];
234
+ const userKnownTags = (payload?.userKnownTags ?? []) as string[];
235
+ const excludedRaw = (payload?.excludedSuggestionTags ?? []) as string[];
236
+ const tagDescFromPayload = (payload?.tagDescriptions ?? {}) as Record<string, string>;
237
+ const projectDescFromPayload = (payload?.projectDescriptions ?? {}) as Record<string, string>;
238
+ const tagDescSerialized = JSON.stringify(tagDescFromPayload);
239
+ const projectDescSerialized = JSON.stringify(projectDescFromPayload);
240
+
241
+ const [localTagDesc, setLocalTagDesc] = useState<Record<string, string>>({});
242
+ const [localProjectDesc, setLocalProjectDesc] = useState<Record<string, string>>({});
243
+
244
+ useEffect(() => {
245
+ try {
246
+ setLocalTagDesc(JSON.parse(tagDescSerialized) as Record<string, string>);
247
+ } catch {
248
+ setLocalTagDesc({});
249
+ }
250
+ }, [tagDescSerialized]);
251
+
252
+ useEffect(() => {
253
+ try {
254
+ setLocalProjectDesc(JSON.parse(projectDescSerialized) as Record<string, string>);
255
+ } catch {
256
+ setLocalProjectDesc({});
257
+ }
258
+ }, [projectDescSerialized]);
259
+
260
+ const pinnedKeys = useMemo(
261
+ () => new Set(userKnownTags.map((t) => normalizeTagKey(t).toLowerCase())),
262
+ [userKnownTags]
263
+ );
264
+
265
+ const { globalKnownTags, scopedSections } = useMemo(() => {
266
+ const global: string[] = [];
267
+ const scopedMap = new Map<string, { tags: string[]; displayProject: string }>();
268
+ for (const tag of knownTags) {
269
+ const nk = normalizeTagKey(tag);
270
+ const scoped = parseProjectScopedTag(nk);
271
+ if (!scoped) {
272
+ global.push(tag);
273
+ continue;
274
+ }
275
+ const lk = scoped.projectKey.toLowerCase();
276
+ let entry = scopedMap.get(lk);
277
+ if (!entry) {
278
+ entry = { tags: [], displayProject: scoped.projectKey };
279
+ scopedMap.set(lk, entry);
280
+ }
281
+ const tkl = nk.toLowerCase();
282
+ if (!entry.tags.some((x) => normalizeTagKey(x).toLowerCase() === tkl)) {
283
+ entry.tags.push(tag);
284
+ }
285
+ }
286
+ for (const p of knownProjects) {
287
+ const lk = normalizeProjectKey(p).toLowerCase();
288
+ const entry = scopedMap.get(lk);
289
+ if (entry) {
290
+ entry.displayProject = normalizeProjectKey(p);
291
+ }
292
+ }
293
+ for (const [, entry] of scopedMap) {
294
+ entry.tags.sort((a, b) =>
295
+ normalizeTagKey(a).localeCompare(normalizeTagKey(b), undefined, { sensitivity: "base" })
296
+ );
297
+ }
298
+ global.sort((a, b) =>
299
+ normalizeTagKey(a).localeCompare(normalizeTagKey(b), undefined, { sensitivity: "base" })
300
+ );
301
+ const projectOrderLower: string[] = [];
302
+ const seen = new Set<string>();
303
+ for (const p of knownProjects) {
304
+ const lk = normalizeProjectKey(p).toLowerCase();
305
+ if (!seen.has(lk)) {
306
+ seen.add(lk);
307
+ projectOrderLower.push(lk);
308
+ }
309
+ }
310
+ for (const lk of [...scopedMap.keys()].sort((a, b) => a.localeCompare(b))) {
311
+ if (!seen.has(lk)) {
312
+ seen.add(lk);
313
+ projectOrderLower.push(lk);
314
+ }
315
+ }
316
+ const scopedSections = projectOrderLower.map((lk) => ({
317
+ projectLower: lk,
318
+ displayProject: scopedMap.get(lk)?.displayProject ?? lk,
319
+ tags: scopedMap.get(lk)?.tags ?? [],
320
+ }));
321
+ return { globalKnownTags: global, scopedSections };
322
+ }, [knownTags, knownProjects]);
323
+
324
+ const knownProjectLowerSet = useMemo(
325
+ () => new Set(knownProjects.map((p) => normalizeProjectKey(p).toLowerCase())),
326
+ [knownProjects]
327
+ );
328
+
329
+ const orphanScopedSections = useMemo(
330
+ () => scopedSections.filter((sec) => !knownProjectLowerSet.has(sec.projectLower)),
331
+ [scopedSections, knownProjectLowerSet]
332
+ );
333
+
334
+ const post = async (body: Record<string, unknown>) => {
335
+ setBusy(true);
336
+ try {
337
+ await postKronosysAction(body);
338
+ await refresh({ preserveForm: true });
339
+ } finally {
340
+ setBusy(false);
341
+ }
342
+ };
343
+
344
+ const disabled = saving || busy;
345
+
346
+ const addPinGlobal = async () => {
347
+ const t = pinGlobalDraft.replace(/^#/, "").trim();
348
+ if (!t || parseProjectScopedTag(t)) {
349
+ return;
350
+ }
351
+ await post({ type: "addUserKnownTag", tag: t });
352
+ setPinGlobalDraft("");
353
+ };
354
+
355
+ const addPinScopedForProject = async (canonicalProject: string, projectLower: string) => {
356
+ const raw = (pinScopedByProject[projectLower] ?? "").replace(/^#/, "").trim();
357
+ if (!raw) {
358
+ return;
359
+ }
360
+ const parsed = parseProjectScopedTag(raw);
361
+ let tag: string;
362
+ if (parsed) {
363
+ if (normalizeProjectKey(parsed.projectKey).toLowerCase() !== projectLower) {
364
+ return;
365
+ }
366
+ tag = `${parsed.projectKey}#${parsed.localTag}`;
367
+ } else {
368
+ tag = `${normalizeProjectKey(canonicalProject)}#${raw}`;
369
+ }
370
+ if (!parseProjectScopedTag(tag)) {
371
+ return;
372
+ }
373
+ await post({ type: "addUserKnownTag", tag: normalizeTagKey(tag) });
374
+ setPinScopedByProject((prev) => ({ ...prev, [projectLower]: "" }));
375
+ };
376
+
377
+ const scopedPinDraftIsSubmittable = (projectLower: string) => {
378
+ const raw = (pinScopedByProject[projectLower] ?? "").replace(/^#/, "").trim();
379
+ if (!raw) {
380
+ return false;
381
+ }
382
+ const parsed = parseProjectScopedTag(raw);
383
+ if (parsed) {
384
+ return normalizeProjectKey(parsed.projectKey).toLowerCase() === projectLower;
385
+ }
386
+ return true;
387
+ };
388
+
389
+ const flushPendingStartTask = useCallback(async () => {
390
+ const draft = pendingStartRef.current;
391
+ if (!draft) {
392
+ return;
393
+ }
394
+ pendingStartRef.current = null;
395
+ setBusy(true);
396
+ try {
397
+ await postKronosysAction({
398
+ type: "startTask",
399
+ name: draft.name,
400
+ tags: draft.tags,
401
+ startKronoFocus: false,
402
+ ...(draft.project ? { project: draft.project } : {}),
403
+ });
404
+ await refresh({ preserveForm: true });
405
+ } finally {
406
+ setBusy(false);
407
+ }
408
+ }, [refresh]);
409
+
410
+ const resolveConflictAndStart = useCallback(
411
+ async (mode: ConcurrentTaskStartPreference, persistPreference: boolean) => {
412
+ const draft = pendingStartRef.current;
413
+ if (!draft) {
414
+ setTrackingConflictOpen(false);
415
+ return;
416
+ }
417
+ if (persistPreference) {
418
+ writeConcurrentTaskStartPreference(mode);
419
+ }
420
+ const live = payload?.current as CurrentSessionShape | undefined;
421
+ const running = runningTrackingTasks(live);
422
+ setBusy(true);
423
+ try {
424
+ if (mode !== "parallel") {
425
+ for (const t of running) {
426
+ if (mode === "pause") {
427
+ await postKronosysAction({ type: "setTaskTimerPaused", taskId: t.id, paused: true });
428
+ } else {
429
+ await postKronosysAction({ type: "finishTask", taskId: t.id, shouldCommit: false });
430
+ }
431
+ }
432
+ }
433
+ await postKronosysAction({
434
+ type: "startTask",
435
+ name: draft.name,
436
+ tags: draft.tags,
437
+ startKronoFocus: false,
438
+ ...(draft.project ? { project: draft.project } : {}),
439
+ });
440
+ pendingStartRef.current = null;
441
+ setTrackingConflictOpen(false);
442
+ setRememberConcurrentChoice(false);
443
+ await refresh({ preserveForm: true });
444
+ } finally {
445
+ setBusy(false);
446
+ }
447
+ },
448
+ [payload?.current, refresh]
449
+ );
450
+
451
+ const requestStartTask = useCallback(
452
+ async (draft: { name: string; tags: string[]; project?: string }) => {
453
+ const live = payload?.current as CurrentSessionShape | undefined;
454
+ if (!live?.sessionId?.trim()) {
455
+ pushToast(s.tagStartTaskAutoSessionToast);
456
+ await post({ type: "newSession", sessionScope: undefined });
457
+ pendingStartRef.current = draft;
458
+ await flushPendingStartTask();
459
+ return;
460
+ }
461
+ pendingStartRef.current = draft;
462
+ const running = runningTrackingTasks(live);
463
+ if (running.length > 0) {
464
+ const pref = readConcurrentTaskStartPreference();
465
+ if (pref === "pause") {
466
+ await resolveConflictAndStart("pause", false);
467
+ return;
468
+ }
469
+ if (pref === "finish") {
470
+ await resolveConflictAndStart("finish", false);
471
+ return;
472
+ }
473
+ if (pref === "parallel") {
474
+ await resolveConflictAndStart("parallel", false);
475
+ return;
476
+ }
477
+ setRememberConcurrentChoice(false);
478
+ setTrackingConflictOpen(true);
479
+ } else {
480
+ await flushPendingStartTask();
481
+ }
482
+ },
483
+ [payload?.current, flushPendingStartTask, resolveConflictAndStart, post, pushToast, s.tagStartTaskAutoSessionToast]
484
+ );
485
+
486
+ const requestStartFromTag = useCallback(
487
+ (tag: string) => {
488
+ const token = normalizeTagKey(tag);
489
+ const scoped = parseProjectScopedTag(token);
490
+ const projectForPicker = scoped ? scoped.projectKey : undefined;
491
+ const draft = buildStartTaskFromDraft("", [token], projectForPicker);
492
+ void requestStartTask(draft);
493
+ },
494
+ [requestStartTask]
495
+ );
496
+
497
+ const requestStartFromProject = useCallback(
498
+ (proj: string) => {
499
+ const draft = buildStartTaskFromDraft("", [], normalizeProjectKey(proj));
500
+ void requestStartTask(draft);
501
+ },
502
+ [requestStartTask]
503
+ );
504
+
505
+ const cancelTrackingConflict = useCallback(() => {
506
+ pendingStartRef.current = null;
507
+ setRememberConcurrentChoice(false);
508
+ setTrackingConflictOpen(false);
509
+ }, []);
510
+
511
+ const rootClass =
512
+ variant === "dashboard"
513
+ ? "space-y-5"
514
+ : "scroll-mt-24 space-y-6 border-b border-zinc-200 pb-10 dark:border-zinc-800";
515
+ const rootId = pid === "dashboard" ? "dashboard-tags-projects" : "settings-tags-projects";
516
+ const tagsByProjectScrollId =
517
+ variant === "settings" ? "settings-tags-by-project" : `${pid}-tags-by-project`;
518
+
519
+ const linkedTagsBlock = (
520
+ onStartFromTag: (tag: string) => void,
521
+ sec: { projectLower: string; displayProject: string; tags: string[] },
522
+ anchorSectionId: string | undefined,
523
+ canonicalProject: string,
524
+ opts?: { omitSectionTitle?: boolean }
525
+ ) => (
526
+ <div id={anchorSectionId} className="mt-4 border-t border-zinc-200 pt-4 dark:border-zinc-800">
527
+ {!opts?.omitSectionTitle ? (
528
+ <h4 className="text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
529
+ {s.tocSubTagsByProject}
530
+ </h4>
531
+ ) : null}
532
+ <div className={`flex max-w-md flex-col gap-2 ${opts?.omitSectionTitle ? "" : "mt-3"}`}>
533
+ <label
534
+ className="block text-xs font-medium text-zinc-600 dark:text-zinc-400"
535
+ htmlFor={`${pid}-pin-scoped-${sec.projectLower}`}
536
+ >
537
+ {s.tagsPinScopedFieldLabel}
538
+ </label>
539
+ <input
540
+ id={`${pid}-pin-scoped-${sec.projectLower}`}
541
+ type="text"
542
+ className="w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 outline-none ring-violet-500/30 focus:ring-2 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
543
+ value={pinScopedByProject[sec.projectLower] ?? ""}
544
+ onChange={(e) =>
545
+ setPinScopedByProject((prev) => ({ ...prev, [sec.projectLower]: e.target.value }))
546
+ }
547
+ placeholder={s.tagsPinScopedPlaceholder}
548
+ disabled={disabled}
549
+ autoComplete="off"
550
+ spellCheck={false}
551
+ onKeyDown={(e) => {
552
+ if (e.key === "Enter") {
553
+ e.preventDefault();
554
+ void addPinScopedForProject(canonicalProject, sec.projectLower);
555
+ }
556
+ }}
557
+ />
558
+ <div className="flex">
559
+ <button
560
+ type="button"
561
+ className={ICON_BTN_PRIMARY}
562
+ disabled={disabled || !scopedPinDraftIsSubmittable(sec.projectLower)}
563
+ title={s.tagsPinAddBtn}
564
+ aria-label={s.tagsPinAddBtn}
565
+ onClick={() => void addPinScopedForProject(canonicalProject, sec.projectLower)}
566
+ >
567
+ <Plus size={20} strokeWidth={2} aria-hidden />
568
+ </button>
569
+ </div>
570
+ </div>
571
+ {sec.tags.length === 0 ? (
572
+ <p className="mt-3 text-sm text-zinc-500">{s.tagsByProjectSubEmpty}</p>
573
+ ) : (
574
+ <ul
575
+ className="mt-3"
576
+ aria-label={`${s.tocSubTagsByProject} — ${formatProjectDisplay(sec.displayProject)}`}
577
+ >
578
+ {sec.tags.map((tag, tagIdx) => (
579
+ <SettingsTagEditorRow
580
+ key={`s-${sec.projectLower}-${normalizeTagKey(tag).toLowerCase()}`}
581
+ tag={tag}
582
+ fieldId={`${pid}-scoped-tag-desc-${sec.projectLower}-${tagIdx}`}
583
+ s={s}
584
+ disabled={disabled}
585
+ localTagDesc={localTagDesc}
586
+ setLocalTagDesc={setLocalTagDesc}
587
+ pinnedKeys={pinnedKeys}
588
+ post={post}
589
+ setConfirm={setConfirm}
590
+ onStartTrackedTask={() => void onStartFromTag(tag)}
591
+ />
592
+ ))}
593
+ </ul>
594
+ )}
595
+ </div>
596
+ );
597
+
598
+ return (
599
+ <section id={rootId} className={rootClass}>
600
+ {showPageHeader ? (
601
+ <>
602
+ <div className="flex flex-wrap items-center gap-2">
603
+ <h2 className="text-xs font-semibold uppercase tracking-wide text-zinc-500">{s.sectionTagsProjects}</h2>
604
+ <InlineMetricHelpTrigger
605
+ ariaLabel={s.tagsProjectsHelpAria}
606
+ body={s.tagsProjectsHelpBody}
607
+ preserveLineBreaks
608
+ panelClassName="w-[min(calc(100vw-2rem),26rem)]"
609
+ />
610
+ </div>
611
+ <p className="text-xs leading-relaxed text-zinc-600 dark:text-zinc-400">{s.tagsProjectsIntro}</p>
612
+ </>
613
+ ) : null}
614
+
615
+ {variant === "dashboard" ? (
616
+ <div
617
+ role="tablist"
618
+ aria-label={s.tagsProjectsTabsAriaLabel}
619
+ className="flex shrink-0 gap-1 rounded-lg border border-zinc-200 bg-zinc-100/90 p-1 dark:border-zinc-700 dark:bg-zinc-900/60"
620
+ >
621
+ <button
622
+ type="button"
623
+ role="tab"
624
+ id={`${pid}-tab-global`}
625
+ aria-selected={dashTagsTab === "global"}
626
+ aria-controls={`${pid}-tabpanel-global`}
627
+ aria-label={s.tagsProjectsTabGlobal}
628
+ title={s.tagsProjectsTabGlobal}
629
+ className={`inline-flex min-w-0 flex-1 items-center justify-center rounded-md px-2 py-2 transition sm:px-2.5 ${
630
+ dashTagsTab === "global"
631
+ ? "bg-white text-violet-700 shadow-sm dark:bg-zinc-800 dark:text-violet-300"
632
+ : "text-zinc-500 hover:bg-zinc-200/80 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-800/80 dark:hover:text-zinc-100"
633
+ }`}
634
+ onClick={() => setDashTagsTab("global")}
635
+ >
636
+ <Tags size={20} strokeWidth={2} aria-hidden className="shrink-0" />
637
+ </button>
638
+ <button
639
+ type="button"
640
+ role="tab"
641
+ id={`${pid}-tab-projects`}
642
+ aria-selected={dashTagsTab === "projects"}
643
+ aria-controls={`${pid}-tabpanel-projects`}
644
+ aria-label={s.tagsProjectsTabProjects}
645
+ title={s.tagsProjectsTabProjects}
646
+ className={`inline-flex min-w-0 flex-1 items-center justify-center rounded-md px-2 py-2 transition sm:px-2.5 ${
647
+ dashTagsTab === "projects"
648
+ ? "bg-white text-violet-700 shadow-sm dark:bg-zinc-800 dark:text-violet-300"
649
+ : "text-zinc-500 hover:bg-zinc-200/80 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-800/80 dark:hover:text-zinc-100"
650
+ }`}
651
+ onClick={() => setDashTagsTab("projects")}
652
+ >
653
+ <FolderKanban size={20} strokeWidth={2} aria-hidden className="shrink-0" />
654
+ </button>
655
+ </div>
656
+ ) : null}
657
+
658
+ <div
659
+ role={variant === "dashboard" ? "tabpanel" : undefined}
660
+ id={variant === "dashboard" ? `${pid}-tabpanel-global` : undefined}
661
+ aria-labelledby={variant === "dashboard" ? `${pid}-tab-global` : undefined}
662
+ hidden={variant === "dashboard" && dashTagsTab !== "global"}
663
+ className={variant === "dashboard" ? "space-y-5" : "contents"}
664
+ >
665
+ <div id={`${pid}-tags-global`} className={CARD_CLASS}>
666
+ <h3 className="text-sm font-medium text-zinc-800 dark:text-zinc-100">{s.tagsGlobalHeading}</h3>
667
+ <p className="mt-1 text-xs text-zinc-600 dark:text-zinc-400">{s.tagsGlobalIntro}</p>
668
+ <div className="mt-3 flex max-w-md flex-col gap-2">
669
+ <label className="block text-xs font-medium text-zinc-600 dark:text-zinc-400" htmlFor={`${pid}-pin-global-tag`}>
670
+ {s.tagsPinFieldLabel}
671
+ </label>
672
+ <input
673
+ id={`${pid}-pin-global-tag`}
674
+ type="text"
675
+ className="w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 outline-none ring-violet-500/30 focus:ring-2 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
676
+ value={pinGlobalDraft}
677
+ onChange={(e) => setPinGlobalDraft(e.target.value)}
678
+ placeholder={s.tagsPinPlaceholder}
679
+ disabled={disabled}
680
+ autoComplete="off"
681
+ spellCheck={false}
682
+ onKeyDown={(e) => {
683
+ if (e.key === "Enter") {
684
+ e.preventDefault();
685
+ void addPinGlobal();
686
+ }
687
+ }}
688
+ />
689
+ <div className="flex">
690
+ <button
691
+ type="button"
692
+ className={ICON_BTN_PRIMARY}
693
+ disabled={
694
+ disabled ||
695
+ !pinGlobalDraft.trim() ||
696
+ !!parseProjectScopedTag(pinGlobalDraft.replace(/^#/, "").trim())
697
+ }
698
+ title={s.tagsPinAddBtn}
699
+ aria-label={s.tagsPinAddBtn}
700
+ onClick={() => void addPinGlobal()}
701
+ >
702
+ <Plus size={20} strokeWidth={2} aria-hidden />
703
+ </button>
704
+ </div>
705
+ </div>
706
+
707
+ {globalKnownTags.length === 0 ? (
708
+ <p className="mt-4 text-sm text-zinc-500">{s.tagsEmptyGlobal}</p>
709
+ ) : (
710
+ <ul className="mt-4" aria-label={s.tagsGlobalHeading}>
711
+ {globalKnownTags.map((tag, tagIdx) => (
712
+ <SettingsTagEditorRow
713
+ key={`g-${normalizeTagKey(tag).toLowerCase()}`}
714
+ tag={tag}
715
+ fieldId={`${pid}-global-tag-desc-${tagIdx}`}
716
+ s={s}
717
+ disabled={disabled}
718
+ localTagDesc={localTagDesc}
719
+ setLocalTagDesc={setLocalTagDesc}
720
+ pinnedKeys={pinnedKeys}
721
+ post={post}
722
+ setConfirm={setConfirm}
723
+ onStartTrackedTask={() => void requestStartFromTag(tag)}
724
+ />
725
+ ))}
726
+ </ul>
727
+ )}
728
+ </div>
729
+
730
+ <div id={`${pid}-tags-hidden`} className={CARD_CLASS}>
731
+ <h3 className="text-sm font-medium text-zinc-800 dark:text-zinc-100">{s.tagsHiddenHeading}</h3>
732
+ {excludedRaw.length === 0 ? (
733
+ <p className="mt-3 text-sm text-zinc-500">{s.hiddenTagsEmpty}</p>
734
+ ) : (
735
+ <ul className="mt-3">
736
+ {excludedRaw.map((key) => (
737
+ <li key={key} className="border-b border-zinc-200 py-3 last:border-b-0 dark:border-zinc-800/90">
738
+ <div className="flex flex-col gap-2">
739
+ <span className="font-mono text-sm text-zinc-600 line-through decoration-zinc-500 dark:text-zinc-400">
740
+ {formatTagDisplay(key)}
741
+ </span>
742
+ <div className="flex flex-wrap items-center gap-1.5">
743
+ <button
744
+ type="button"
745
+ className={ICON_BTN}
746
+ disabled={disabled}
747
+ title={s.tagRestoreBtn}
748
+ aria-label={s.tagRestoreBtn}
749
+ onClick={() => void post({ type: "includeTagFromSuggestions", tag: key })}
750
+ >
751
+ <RotateCcw size={16} strokeWidth={2} aria-hidden />
752
+ </button>
753
+ <button
754
+ type="button"
755
+ className={ICON_BTN_DANGER}
756
+ disabled={disabled}
757
+ title={s.tagPurgeHiddenAriaLabel}
758
+ aria-label={s.tagPurgeHiddenAriaLabel}
759
+ onClick={() => setConfirm({ kind: "purgeTag", tag: key, fromExcludedList: true })}
760
+ >
761
+ <Trash2 size={16} strokeWidth={2} aria-hidden />
762
+ </button>
763
+ </div>
764
+ </div>
765
+ </li>
766
+ ))}
767
+ </ul>
768
+ )}
769
+ </div>
770
+ </div>
771
+
772
+ <div
773
+ role={variant === "dashboard" ? "tabpanel" : undefined}
774
+ id={variant === "dashboard" ? `${pid}-tabpanel-projects` : undefined}
775
+ aria-labelledby={variant === "dashboard" ? `${pid}-tab-projects` : undefined}
776
+ hidden={variant === "dashboard" && dashTagsTab !== "projects"}
777
+ className={variant === "dashboard" ? "space-y-5" : "contents"}
778
+ >
779
+ <div id={`${pid}-tags-saved-projects`} className={CARD_CLASS}>
780
+ <div className="flex flex-wrap items-center gap-2">
781
+ <h3 className="text-sm font-medium text-zinc-800 dark:text-zinc-100">{s.projectsHeading}</h3>
782
+ <InlineMetricHelpTrigger
783
+ ariaLabel={s.projectsHelpAria}
784
+ body={`${s.projectsIntro}\n\n${s.projectsHelpBody}`}
785
+ preserveLineBreaks
786
+ panelClassName="w-[min(calc(100vw-2rem),22rem)]"
787
+ />
788
+ </div>
789
+ {knownProjects.length === 0 && orphanScopedSections.length === 0 ? (
790
+ <p className="mt-4 text-sm text-zinc-500">{s.projectsEmpty}</p>
791
+ ) : null}
792
+ {knownProjects.length > 0 ? (
793
+ <ul className="mt-4">
794
+ {knownProjects.map((proj, projIdx) => {
795
+ const pk = normalizeProjectKey(proj).toLowerCase();
796
+ const projectDescFieldId = `${pid}-project-desc-${projIdx}`;
797
+ const sec =
798
+ scopedSections.find((x) => x.projectLower === pk) ?? {
799
+ projectLower: pk,
800
+ displayProject: proj,
801
+ tags: [] as string[],
802
+ };
803
+ const linkedAnchorId = projIdx === 0 ? tagsByProjectScrollId : undefined;
804
+ return (
805
+ <li key={proj} className="border-b border-zinc-200 py-3 last:border-b-0 dark:border-zinc-800/90">
806
+ <div className="flex flex-col gap-2.5">
807
+ <span className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
808
+ {formatProjectDisplay(proj)}
809
+ </span>
810
+ <input
811
+ id={projectDescFieldId}
812
+ type="text"
813
+ className={INLINE_INPUT_CLASS}
814
+ value={localProjectDesc[pk] ?? ""}
815
+ onChange={(e) =>
816
+ setLocalProjectDesc((prev) => ({ ...prev, [pk]: e.target.value }))
817
+ }
818
+ disabled={disabled}
819
+ placeholder={s.projectDescriptionLabel}
820
+ spellCheck
821
+ />
822
+ <div className="flex flex-wrap items-center gap-1.5">
823
+ <button
824
+ type="button"
825
+ className={ICON_BTN_SAVE}
826
+ disabled={disabled}
827
+ title={s.descriptionSaveBtn}
828
+ aria-label={s.descriptionSaveBtn}
829
+ onClick={() =>
830
+ void post({
831
+ type: "setProjectDescription",
832
+ name: proj,
833
+ description: localProjectDesc[pk] ?? "",
834
+ })
835
+ }
836
+ >
837
+ <Save size={16} strokeWidth={2} aria-hidden />
838
+ </button>
839
+ <button
840
+ type="button"
841
+ className={ICON_BTN_DANGER}
842
+ disabled={disabled}
843
+ title={s.projectsRemoveBtn}
844
+ aria-label={s.projectsRemoveBtn}
845
+ onClick={() => setConfirm({ kind: "purgeProject", name: proj })}
846
+ >
847
+ <Trash2 size={16} strokeWidth={2} aria-hidden />
848
+ </button>
849
+ <button
850
+ type="button"
851
+ className={ICON_BTN_PRIMARY}
852
+ disabled={disabled}
853
+ title={s.tagStartTrackedTaskFromProjectBtn}
854
+ aria-label={s.tagStartTrackedTaskFromProjectBtn}
855
+ onClick={() => void requestStartFromProject(proj)}
856
+ >
857
+ <Play size={16} strokeWidth={2} className="ml-0.5" fill="currentColor" aria-hidden />
858
+ </button>
859
+ </div>
860
+ {linkedTagsBlock(requestStartFromTag, sec, linkedAnchorId, proj)}
861
+ </div>
862
+ </li>
863
+ );
864
+ })}
865
+ </ul>
866
+ ) : null}
867
+ {orphanScopedSections.length > 0 ? (
868
+ <div
869
+ className={`${knownProjects.length > 0 ? "mt-6 border-t border-zinc-200 pt-6 dark:border-zinc-800" : "mt-4"}`}
870
+ >
871
+ <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
872
+ <h3 className="text-sm font-medium text-zinc-800 dark:text-zinc-100">{s.tagsByProjectHeading}</h3>
873
+ <InlineMetricHelpTrigger
874
+ ariaLabel={s.tagsByProjectOrphanHelpAria}
875
+ body={s.tagsByProjectOrphanIntro}
876
+ panelClassName="w-[min(calc(100vw-2rem),26rem)]"
877
+ />
878
+ </div>
879
+ <div className="mt-4 space-y-6">
880
+ {orphanScopedSections.map((sec, orIdx) => (
881
+ <div
882
+ key={sec.projectLower}
883
+ className="rounded-lg border border-dashed border-zinc-300/90 bg-white/50 p-3 dark:border-zinc-600/80 dark:bg-zinc-950/35"
884
+ >
885
+ <h4 className="text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
886
+ {formatProjectDisplay(sec.displayProject)}
887
+ </h4>
888
+ {linkedTagsBlock(
889
+ requestStartFromTag,
890
+ sec,
891
+ knownProjects.length === 0 && orIdx === 0 ? tagsByProjectScrollId : undefined,
892
+ sec.displayProject,
893
+ { omitSectionTitle: true }
894
+ )}
895
+ </div>
896
+ ))}
897
+ </div>
898
+ </div>
899
+ ) : null}
900
+ </div>
901
+ </div>
902
+
903
+ <DashboardConfirmModal
904
+ open={confirm?.kind === "unpin"}
905
+ message={s.tagUnpinConfirm}
906
+ cancelLabel={s.dialogCancelBtn}
907
+ confirmLabel={s.dialogConfirmBtn}
908
+ onCancel={() => setConfirm(null)}
909
+ onConfirm={() => {
910
+ const t = confirm?.kind === "unpin" ? confirm.tag : "";
911
+ setConfirm(null);
912
+ if (t) {
913
+ void post({ type: "removeUserKnownTag", tag: t });
914
+ }
915
+ }}
916
+ />
917
+ <DashboardConfirmModal
918
+ open={confirm?.kind === "exclude"}
919
+ message={s.tagExcludeConfirm}
920
+ cancelLabel={s.dialogCancelBtn}
921
+ confirmLabel={s.dialogConfirmBtn}
922
+ confirmVariant="danger"
923
+ onCancel={() => setConfirm(null)}
924
+ onConfirm={() => {
925
+ const t = confirm?.kind === "exclude" ? confirm.tag : "";
926
+ setConfirm(null);
927
+ if (t) {
928
+ void post({ type: "excludeTagFromSuggestions", tag: t });
929
+ }
930
+ }}
931
+ />
932
+ <DashboardConfirmModal
933
+ open={confirm?.kind === "purgeTag"}
934
+ message={
935
+ confirm?.kind === "purgeTag" && confirm.fromExcludedList ? s.tagPurgeHiddenConfirm : s.tagPurgeConfirm
936
+ }
937
+ cancelLabel={s.dialogCancelBtn}
938
+ confirmLabel={s.dialogConfirmBtn}
939
+ confirmVariant="danger"
940
+ onCancel={() => setConfirm(null)}
941
+ onConfirm={() => {
942
+ if (confirm?.kind !== "purgeTag") {
943
+ setConfirm(null);
944
+ return;
945
+ }
946
+ const { tag: t, fromExcludedList } = confirm;
947
+ setConfirm(null);
948
+ if (t) {
949
+ void post({
950
+ type: "purgeTagMetadata",
951
+ tag: t,
952
+ ...(fromExcludedList ? { fromExcludedList: true } : {}),
953
+ });
954
+ }
955
+ }}
956
+ />
957
+ <DashboardConfirmModal
958
+ open={confirm?.kind === "purgeProject"}
959
+ message={s.projectRemoveConfirm}
960
+ cancelLabel={s.dialogCancelBtn}
961
+ confirmLabel={s.dialogConfirmBtn}
962
+ confirmVariant="danger"
963
+ onCancel={() => setConfirm(null)}
964
+ onConfirm={() => {
965
+ const n = confirm?.kind === "purgeProject" ? confirm.name : "";
966
+ setConfirm(null);
967
+ if (n) {
968
+ void post({ type: "purgeProjectMetadata", name: n });
969
+ }
970
+ }}
971
+ />
972
+ <DashboardTriActionModal
973
+ open={trackingConflictOpen}
974
+ title={s.tagStartTaskConflictTitle}
975
+ message={s.tagStartTaskConflictMessage}
976
+ dismissLabel={s.dialogCancelBtn}
977
+ tertiaryLabel={s.tagStartTaskConflictParallelBtn}
978
+ secondaryLabel={s.tagStartTaskConflictPauseBtn}
979
+ primaryLabel={s.tagStartTaskConflictFinishBtn}
980
+ primaryVariant="danger"
981
+ dismissCheckbox={{
982
+ label: s.tagStartTaskConflictDontShowAgain,
983
+ checked: rememberConcurrentChoice,
984
+ onChange: setRememberConcurrentChoice,
985
+ }}
986
+ onDismiss={cancelTrackingConflict}
987
+ onTertiary={() => void resolveConflictAndStart("parallel", rememberConcurrentChoice)}
988
+ onSecondary={() => void resolveConflictAndStart("pause", rememberConcurrentChoice)}
989
+ onPrimary={() => void resolveConflictAndStart("finish", rememberConcurrentChoice)}
990
+ />
991
+ </section>
992
+ );
993
+ }