@nightkatana/kronosys-app 1.0.0-beta.2 → 1.0.0-beta.21

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 (112) hide show
  1. package/README.md +28 -1
  2. package/app/api/action/route.ts +39 -3
  3. package/app/api/action-logs/route.ts +24 -0
  4. package/app/api/backup/route.ts +1 -1
  5. package/app/api/restore/route.ts +145 -0
  6. package/app/changelog/page.tsx +71 -4
  7. package/app/globals.css +127 -0
  8. package/app/guide/page.tsx +61 -15
  9. package/app/implementation/page.tsx +700 -0
  10. package/app/layout.tsx +14 -3
  11. package/app/licenses/page.tsx +99 -37
  12. package/app/logs/page.tsx +258 -0
  13. package/app/manifest.ts +5 -5
  14. package/app/page.tsx +784 -229
  15. package/app/reporting/page.tsx +1266 -474
  16. package/app/settings/page.tsx +252 -18
  17. package/bin/kronosys.mjs +140 -15
  18. package/components/KronosysPayloadProvider.tsx +2 -0
  19. package/components/RouteTransition.tsx +18 -0
  20. package/components/dashboard/AppShellCommandCenterPlaceholder.tsx +17 -0
  21. package/components/dashboard/AppShellHeaderSessionMeta.tsx +210 -0
  22. package/components/dashboard/AppShellHeaderWallClock.tsx +54 -0
  23. package/components/dashboard/AppShellLiveSessionDrawer.tsx +154 -38
  24. package/components/dashboard/AppShellRouteNav.tsx +323 -48
  25. package/components/dashboard/DashboardPauseBackdrop.tsx +50 -0
  26. package/components/dashboard/DashboardSimpleModal.tsx +168 -25
  27. package/components/dashboard/DashboardTour.tsx +115 -29
  28. package/components/dashboard/GlobalPauseConfirmModal.tsx +183 -0
  29. package/components/dashboard/KronosysDatetimePopoverField.tsx +167 -122
  30. package/components/dashboard/KronosysTimePopoverField.tsx +54 -12
  31. package/components/dashboard/NewSessionScopeModal.tsx +211 -20
  32. package/components/dashboard/PlannedTaskBoundaryConflictWatcher.tsx +275 -0
  33. package/components/dashboard/ReportingTour.tsx +87 -21
  34. package/components/dashboard/SavedProjectPicker.tsx +16 -3
  35. package/components/dashboard/SelectedSessionSidebarBlock.tsx +512 -142
  36. package/components/dashboard/SessionListPanel.tsx +327 -44
  37. package/components/dashboard/SettingsTagsProjectsSection.tsx +1073 -264
  38. package/components/dashboard/SettingsTaskTemplatesSection.tsx +316 -0
  39. package/components/dashboard/SettingsTour.tsx +86 -21
  40. package/components/dashboard/TagPills.tsx +14 -1
  41. package/components/dashboard/TaskFocusPanel.tsx +1081 -478
  42. package/components/dashboard/TaskSessionLiveCard.tsx +650 -135
  43. package/components/dashboard/TaskTimelineGanttModal.tsx +601 -0
  44. package/components/dashboard/taskFieldStyles.ts +20 -4
  45. package/components/dashboard/useReportingInteractionState.ts +80 -0
  46. package/lib/appShellHeaderClasses.ts +13 -0
  47. package/lib/businessRulesMatrix.ts +210 -0
  48. package/lib/copyToClipboard.ts +43 -0
  49. package/lib/dashboardCopy.ts +494 -84
  50. package/lib/dashboardQuickSearch.ts +54 -2
  51. package/lib/dashboardTimeZone.ts +109 -0
  52. package/lib/formatAppShellWallClock.ts +66 -0
  53. package/lib/formatSessionNameTemplate.ts +141 -0
  54. package/lib/generatedUserChangelog.ts +177 -6
  55. package/lib/globalPausePreview.ts +292 -0
  56. package/lib/implementationNotes.ts +1188 -0
  57. package/lib/kronosysApi.ts +6 -0
  58. package/lib/kronosysDashboardModalGates.ts +24 -0
  59. package/lib/plannedBoundaryAttention.ts +9 -0
  60. package/lib/plannedBoundaryConflict.ts +23 -0
  61. package/lib/reportingAggregate.ts +517 -75
  62. package/lib/reportingMetricHelp.ts +8 -0
  63. package/lib/reportingStrings.ts +37 -3
  64. package/lib/sessionListMerge.ts +4 -0
  65. package/lib/sessionTaskSidebarStats.ts +182 -21
  66. package/lib/settingsCopy.ts +178 -4
  67. package/lib/taskParsing.ts +360 -103
  68. package/lib/taskTemplateDraft.ts +135 -0
  69. package/lib/taskTimelineGantt.ts +265 -0
  70. package/lib/temporalDisplayPlanned.ts +71 -0
  71. package/lib/userGuideCopy.ts +121 -47
  72. package/next.config.ts +7 -0
  73. package/package.json +12 -24
  74. package/server/actionDispatch.ts +1000 -77
  75. package/server/actionTaskSession.ts +337 -24
  76. package/server/db.ts +7 -15
  77. package/server/dbSchema.ts +24 -0
  78. package/server/defaultCfg.ts +5 -0
  79. package/server/gitlabTokenStore.ts +0 -12
  80. package/server/liveHistorySync.ts +53 -0
  81. package/server/mainTimerHydrate.ts +38 -2
  82. package/server/payloadStore.ts +33 -11
  83. package/server/sessionWallHydrate.ts +66 -3
  84. package/server/userActionLog.ts +126 -0
  85. package/sonar-project.properties +11 -0
  86. package/tsconfig.json +2 -1
  87. package/components/dashboard/IssuePickerModal.tsx +0 -168
  88. package/components/dashboard/ThemeToggle.test.tsx +0 -26
  89. package/lib/backupCsvExport.test.ts +0 -149
  90. package/lib/dashboardQuickSearchQuery.test.ts +0 -63
  91. package/lib/dataDir.test.ts +0 -87
  92. package/lib/formatIsoShort.test.ts +0 -46
  93. package/lib/kronoFocusRhythm.test.ts +0 -130
  94. package/lib/kronoFocusTimerUrgency.test.ts +0 -74
  95. package/lib/legacyKronoFocusStorageKeys.test.ts +0 -29
  96. package/lib/reportingAggregate.test.ts +0 -325
  97. package/lib/reportingNonFinalIndicators.test.ts +0 -157
  98. package/lib/reportingTagWeekBreakdown.test.ts +0 -141
  99. package/lib/reportingWeekLayout.test.ts +0 -239
  100. package/lib/sessionAssiduity.test.ts +0 -25
  101. package/lib/sessionEndWarnings.test.ts +0 -200
  102. package/lib/sessionListMerge.test.ts +0 -101
  103. package/lib/sessionTaskSidebarStats.test.ts +0 -24
  104. package/lib/taskParsing.test.ts +0 -153
  105. package/lib/usageProfile.test.ts +0 -84
  106. package/server/actionDispatch.test.ts +0 -723
  107. package/server/actionTaskSession.test.ts +0 -713
  108. package/server/kronoFocusHydrate.test.ts +0 -142
  109. package/server/kronoFocusMigrate.test.ts +0 -53
  110. package/server/mainTimerHydrate.test.ts +0 -65
  111. package/server/payloadStore.test.ts +0 -78
  112. package/server/sessionWallHydrate.test.ts +0 -46
@@ -1,11 +1,24 @@
1
1
  "use client";
2
2
 
3
3
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
- import { EyeOff, FolderKanban, Pin, PinOff, Play, Plus, RotateCcw, Save, Tags, Trash2 } from "lucide-react";
4
+ import {
5
+ EyeOff,
6
+ FolderKanban,
7
+ Pin,
8
+ PinOff,
9
+ Play,
10
+ Plus,
11
+ Pencil,
12
+ RotateCcw,
13
+ Save,
14
+ Tags,
15
+ Trash2,
16
+ } from "lucide-react";
5
17
  import { postKronosysAction } from "@/lib/kronosysApi";
6
18
  import type { KronosysUpdatePayload } from "@/lib/kronosysApi";
7
19
  import type { Lang } from "@/lib/dashboardCopy";
8
20
  import type { SettingsCopy } from "@/lib/settingsCopy";
21
+ import { SettingsTaskTemplatesSection } from "./SettingsTaskTemplatesSection";
9
22
  import {
10
23
  buildStartTaskFromDraft,
11
24
  formatProjectDisplay,
@@ -14,7 +27,10 @@ import {
14
27
  normalizeTagKey,
15
28
  parseProjectScopedTag,
16
29
  } from "@/lib/taskParsing";
17
- import { DashboardConfirmModal, DashboardTriActionModal } from "./DashboardSimpleModal";
30
+ import {
31
+ DashboardConfirmModal,
32
+ DashboardTriActionModal,
33
+ } from "./DashboardSimpleModal";
18
34
  import { useDashboardToast } from "@/components/dashboard/DashboardToastProvider";
19
35
  import { InlineMetricHelpTrigger } from "./InlineMetricHelpTrigger";
20
36
  import {
@@ -44,7 +60,14 @@ const ICON_BTN_SAVE =
44
60
  const ICON_BTN_PRIMARY =
45
61
  "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
62
 
47
- type LiveActiveTaskShape = { id: string; isDone?: boolean; manualTaskTimerPaused?: boolean };
63
+ const RENAME_IMPACT_CONFIRM_STORAGE_KEY =
64
+ "kronosys.settings.renameImpactConfirm.dismiss.v1";
65
+
66
+ type LiveActiveTaskShape = {
67
+ id: string;
68
+ isDone?: boolean;
69
+ manualTaskTimerPaused?: boolean;
70
+ };
48
71
 
49
72
  type CurrentSessionShape = {
50
73
  sessionId?: string;
@@ -52,7 +75,9 @@ type CurrentSessionShape = {
52
75
  activeTask?: LiveActiveTaskShape | null;
53
76
  };
54
77
 
55
- function runningTrackingTasks(live: CurrentSessionShape | null | undefined): LiveActiveTaskShape[] {
78
+ function runningTrackingTasks(
79
+ live: CurrentSessionShape | null | undefined,
80
+ ): LiveActiveTaskShape[] {
56
81
  if (!live) {
57
82
  return [];
58
83
  }
@@ -60,9 +85,12 @@ function runningTrackingTasks(live: CurrentSessionShape | null | undefined): Liv
60
85
  Array.isArray(live.activeTasks) && live.activeTasks.length > 0
61
86
  ? live.activeTasks
62
87
  : live.activeTask
63
- ? [live.activeTask]
64
- : [];
65
- return raw.filter((t): t is LiveActiveTaskShape => !!t && !t.isDone && !t.manualTaskTimerPaused);
88
+ ? [live.activeTask]
89
+ : [];
90
+ return raw.filter(
91
+ (t): t is LiveActiveTaskShape =>
92
+ !!t && !t.isDone && !t.manualTaskTimerPaused,
93
+ );
66
94
  }
67
95
 
68
96
  type ConfirmState =
@@ -70,7 +98,9 @@ type ConfirmState =
70
98
  | { kind: "unpin"; tag: string }
71
99
  | { kind: "exclude"; tag: string }
72
100
  | { kind: "purgeTag"; tag: string; fromExcludedList?: boolean }
73
- | { kind: "purgeProject"; name: string };
101
+ | { kind: "purgeProject"; name: string }
102
+ | { kind: "renameTag"; sourceTag: string; targetTag: string }
103
+ | { kind: "renameProject"; sourceName: string; targetName: string };
74
104
 
75
105
  type TagEditorRowProps = {
76
106
  tag: string;
@@ -82,6 +112,7 @@ type TagEditorRowProps = {
82
112
  pinnedKeys: Set<string>;
83
113
  post: (body: Record<string, unknown>) => Promise<void>;
84
114
  setConfirm: (c: ConfirmState) => void;
115
+ dismissRenameImpactConfirm: boolean;
85
116
  onStartTrackedTask?: () => void;
86
117
  };
87
118
 
@@ -95,15 +126,76 @@ function SettingsTagEditorRow({
95
126
  pinnedKeys,
96
127
  post,
97
128
  setConfirm,
129
+ dismissRenameImpactConfirm,
98
130
  onStartTrackedTask,
99
131
  }: TagEditorRowProps) {
100
132
  const tagKey = normalizeTagKey(tag).toLowerCase();
101
133
  const isPinned = pinnedKeys.has(tagKey);
134
+ const [renameOpen, setRenameOpen] = useState(false);
135
+ const [renameDraft, setRenameDraft] = useState(normalizeTagKey(tag));
136
+ const canSubmitRename =
137
+ normalizeTagKey(renameDraft) &&
138
+ normalizeTagKey(renameDraft).toLowerCase() !==
139
+ normalizeTagKey(tag).toLowerCase();
140
+
141
+ useEffect(() => {
142
+ setRenameOpen(false);
143
+ setRenameDraft(normalizeTagKey(tag));
144
+ }, [tag]);
102
145
  return (
103
146
  <li className="border-b border-zinc-200 py-3 last:border-b-0 dark:border-zinc-800/90">
104
147
  <div className="flex flex-col gap-2.5">
105
148
  <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>
149
+ {renameOpen ? (
150
+ <input
151
+ type="text"
152
+ className="min-w-[12rem] flex-1 rounded border border-zinc-300 bg-white px-2 py-1 font-mono 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"
153
+ value={renameDraft}
154
+ onChange={(e) => setRenameDraft(e.target.value)}
155
+ onKeyDown={(e) => {
156
+ if (e.key === "Escape") {
157
+ e.preventDefault();
158
+ setRenameDraft(normalizeTagKey(tag));
159
+ setRenameOpen(false);
160
+ return;
161
+ }
162
+ if (e.key !== "Enter") {
163
+ return;
164
+ }
165
+ e.preventDefault();
166
+ const target = normalizeTagKey(renameDraft);
167
+ if (
168
+ !target ||
169
+ target.toLowerCase() === normalizeTagKey(tag).toLowerCase()
170
+ ) {
171
+ return;
172
+ }
173
+ setRenameOpen(false);
174
+ if (dismissRenameImpactConfirm) {
175
+ void post({
176
+ type: "renameTagMetadata",
177
+ sourceTag: tag,
178
+ targetTag: target,
179
+ });
180
+ return;
181
+ }
182
+ setConfirm({
183
+ kind: "renameTag",
184
+ sourceTag: tag,
185
+ targetTag: target,
186
+ });
187
+ }}
188
+ disabled={disabled}
189
+ aria-label={s.tagRenamePrompt}
190
+ placeholder={s.tagRenamePrompt}
191
+ spellCheck={false}
192
+ autoComplete="off"
193
+ />
194
+ ) : (
195
+ <span className="font-mono text-sm text-zinc-900 dark:text-zinc-100">
196
+ {formatTagDisplay(tag)}
197
+ </span>
198
+ )}
107
199
  {isPinned ? (
108
200
  <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
201
  {s.tagBadgePinned}
@@ -115,7 +207,9 @@ function SettingsTagEditorRow({
115
207
  type="text"
116
208
  className={INLINE_INPUT_CLASS}
117
209
  value={localTagDesc[tagKey] ?? ""}
118
- onChange={(e) => setLocalTagDesc((prev) => ({ ...prev, [tagKey]: e.target.value }))}
210
+ onChange={(e) =>
211
+ setLocalTagDesc((prev) => ({ ...prev, [tagKey]: e.target.value }))
212
+ }
119
213
  disabled={disabled}
120
214
  placeholder={s.tagDescriptionLabel}
121
215
  spellCheck
@@ -166,20 +260,81 @@ function SettingsTagEditorRow({
166
260
  </button>
167
261
  <button
168
262
  type="button"
169
- className={ICON_BTN_SAVE}
263
+ className={ICON_BTN}
170
264
  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
- }
265
+ title={s.tagRenameBtn}
266
+ aria-label={s.tagRenameBtn}
267
+ onClick={() => {
268
+ setRenameDraft(normalizeTagKey(tag));
269
+ setRenameOpen(true);
270
+ }}
180
271
  >
181
- <Save size={16} strokeWidth={2} aria-hidden />
272
+ <Pencil size={16} strokeWidth={2} aria-hidden />
182
273
  </button>
274
+ {renameOpen ? (
275
+ <>
276
+ <button
277
+ type="button"
278
+ className={ICON_BTN_SAVE}
279
+ disabled={disabled || !canSubmitRename}
280
+ title={s.descriptionSaveBtn}
281
+ aria-label={s.descriptionSaveBtn}
282
+ onClick={() => {
283
+ const target = normalizeTagKey(renameDraft);
284
+ if (!target) {
285
+ return;
286
+ }
287
+ setRenameOpen(false);
288
+ if (dismissRenameImpactConfirm) {
289
+ void post({
290
+ type: "renameTagMetadata",
291
+ sourceTag: tag,
292
+ targetTag: target,
293
+ });
294
+ return;
295
+ }
296
+ setConfirm({
297
+ kind: "renameTag",
298
+ sourceTag: tag,
299
+ targetTag: target,
300
+ });
301
+ }}
302
+ >
303
+ <Save size={16} strokeWidth={2} aria-hidden />
304
+ </button>
305
+ <button
306
+ type="button"
307
+ className={ICON_BTN}
308
+ disabled={disabled}
309
+ title={s.dialogCancelBtn}
310
+ aria-label={s.dialogCancelBtn}
311
+ onClick={() => {
312
+ setRenameDraft(normalizeTagKey(tag));
313
+ setRenameOpen(false);
314
+ }}
315
+ >
316
+ <RotateCcw size={16} strokeWidth={2} aria-hidden />
317
+ </button>
318
+ </>
319
+ ) : null}
320
+ {!renameOpen ? (
321
+ <button
322
+ type="button"
323
+ className={ICON_BTN_SAVE}
324
+ disabled={disabled}
325
+ title={s.descriptionSaveBtn}
326
+ aria-label={s.descriptionSaveBtn}
327
+ onClick={() =>
328
+ void post({
329
+ type: "setTagDescription",
330
+ tag,
331
+ description: localTagDesc[tagKey] ?? "",
332
+ })
333
+ }
334
+ >
335
+ <Save size={16} strokeWidth={2} aria-hidden />
336
+ </button>
337
+ ) : null}
183
338
  {onStartTrackedTask ? (
184
339
  <button
185
340
  type="button"
@@ -189,7 +344,13 @@ function SettingsTagEditorRow({
189
344
  aria-label={s.tagStartTrackedTaskBtn}
190
345
  onClick={() => void onStartTrackedTask()}
191
346
  >
192
- <Play size={16} strokeWidth={2} className="ml-0.5" fill="currentColor" aria-hidden />
347
+ <Play
348
+ size={16}
349
+ strokeWidth={2}
350
+ className="ml-0.5"
351
+ fill="currentColor"
352
+ aria-hidden
353
+ />
193
354
  </button>
194
355
  ) : null}
195
356
  </div>
@@ -200,7 +361,7 @@ function SettingsTagEditorRow({
200
361
 
201
362
  export function SettingsTagsProjectsSection({
202
363
  s,
203
- lang: _lang,
364
+ lang,
204
365
  payload,
205
366
  saving,
206
367
  refresh,
@@ -210,36 +371,77 @@ export function SettingsTagsProjectsSection({
210
371
  lang: Lang;
211
372
  payload: KronosysUpdatePayload | null;
212
373
  saving: boolean;
213
- refresh: (options?: { preserveForm?: boolean; routerInvalidate?: boolean }) => Promise<boolean | void>;
374
+ refresh: (options?: {
375
+ preserveForm?: boolean;
376
+ routerInvalidate?: boolean;
377
+ }) => Promise<boolean | void>;
214
378
  /** `dashboard` : pas de titre de page ni bordure de section (colonne du tableau de bord). */
215
379
  variant?: "settings" | "dashboard";
216
380
  }) {
217
- void _lang;
218
381
  const pid = variant === "dashboard" ? "dashboard" : "settings";
219
382
  const showPageHeader = variant === "settings";
220
383
  const [pinGlobalDraft, setPinGlobalDraft] = useState("");
384
+ const [projectDraft, setProjectDraft] = useState("");
221
385
  /** Brouillon d’épingle Projet#code par clé de projet (minuscules). */
222
- const [pinScopedByProject, setPinScopedByProject] = useState<Record<string, string>>({});
386
+ const [pinScopedByProject, setPinScopedByProject] = useState<
387
+ Record<string, string>
388
+ >({});
223
389
  const [busy, setBusy] = useState(false);
224
390
  const [confirm, setConfirm] = useState<ConfirmState>(null);
225
391
  /** 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);
392
+ const [dashTagsTab, setDashTagsTab] = useState<
393
+ "global" | "projects" | "templates"
394
+ >("global");
395
+ const [scopeSourceTag, setScopeSourceTag] = useState("");
396
+ const [scopeTargetProjectLower, setScopeTargetProjectLower] = useState("");
397
+ const pendingStartRef = useRef<{
398
+ name: string;
399
+ tags: string[];
400
+ project?: string;
401
+ } | null>(null);
228
402
  const [trackingConflictOpen, setTrackingConflictOpen] = useState(false);
229
- const [rememberConcurrentChoice, setRememberConcurrentChoice] = useState(false);
403
+ const [rememberConcurrentChoice, setRememberConcurrentChoice] =
404
+ useState(false);
405
+ const [dismissRenameImpactConfirm, setDismissRenameImpactConfirm] =
406
+ useState(false);
230
407
  const { pushToast } = useDashboardToast();
231
408
 
409
+ useEffect(() => {
410
+ try {
411
+ setDismissRenameImpactConfirm(
412
+ globalThis.localStorage?.getItem(RENAME_IMPACT_CONFIRM_STORAGE_KEY) ===
413
+ "1",
414
+ );
415
+ } catch {
416
+ setDismissRenameImpactConfirm(false);
417
+ }
418
+ }, []);
419
+
232
420
  const knownTags = (payload?.knownTags ?? []) as string[];
233
421
  const knownProjects = (payload?.knownProjects ?? []) as string[];
234
422
  const userKnownTags = (payload?.userKnownTags ?? []) as string[];
235
423
  const excludedRaw = (payload?.excludedSuggestionTags ?? []) as string[];
236
- const tagDescFromPayload = (payload?.tagDescriptions ?? {}) as Record<string, string>;
237
- const projectDescFromPayload = (payload?.projectDescriptions ?? {}) as Record<string, string>;
424
+ const tagDescFromPayload = (payload?.tagDescriptions ?? {}) as Record<
425
+ string,
426
+ string
427
+ >;
428
+ const projectDescFromPayload = (payload?.projectDescriptions ?? {}) as Record<
429
+ string,
430
+ string
431
+ >;
238
432
  const tagDescSerialized = JSON.stringify(tagDescFromPayload);
239
433
  const projectDescSerialized = JSON.stringify(projectDescFromPayload);
240
434
 
241
435
  const [localTagDesc, setLocalTagDesc] = useState<Record<string, string>>({});
242
- const [localProjectDesc, setLocalProjectDesc] = useState<Record<string, string>>({});
436
+ const [localProjectDesc, setLocalProjectDesc] = useState<
437
+ Record<string, string>
438
+ >({});
439
+ const [projectRenameOpenByKey, setProjectRenameOpenByKey] = useState<
440
+ Record<string, boolean>
441
+ >({});
442
+ const [projectRenameDraftByKey, setProjectRenameDraftByKey] = useState<
443
+ Record<string, string>
444
+ >({});
243
445
 
244
446
  useEffect(() => {
245
447
  try {
@@ -251,20 +453,35 @@ export function SettingsTagsProjectsSection({
251
453
 
252
454
  useEffect(() => {
253
455
  try {
254
- setLocalProjectDesc(JSON.parse(projectDescSerialized) as Record<string, string>);
456
+ setLocalProjectDesc(
457
+ JSON.parse(projectDescSerialized) as Record<string, string>,
458
+ );
255
459
  } catch {
256
460
  setLocalProjectDesc({});
257
461
  }
258
462
  }, [projectDescSerialized]);
259
463
 
464
+ useEffect(() => {
465
+ const nextDraft: Record<string, string> = {};
466
+ for (const proj of knownProjects) {
467
+ const pk = normalizeProjectKey(proj).toLowerCase();
468
+ nextDraft[pk] = normalizeProjectKey(proj);
469
+ }
470
+ setProjectRenameDraftByKey(nextDraft);
471
+ setProjectRenameOpenByKey({});
472
+ }, [knownProjects]);
473
+
260
474
  const pinnedKeys = useMemo(
261
475
  () => new Set(userKnownTags.map((t) => normalizeTagKey(t).toLowerCase())),
262
- [userKnownTags]
476
+ [userKnownTags],
263
477
  );
264
478
 
265
479
  const { globalKnownTags, scopedSections } = useMemo(() => {
266
480
  const global: string[] = [];
267
- const scopedMap = new Map<string, { tags: string[]; displayProject: string }>();
481
+ const scopedMap = new Map<
482
+ string,
483
+ { tags: string[]; displayProject: string }
484
+ >();
268
485
  for (const tag of knownTags) {
269
486
  const nk = normalizeTagKey(tag);
270
487
  const scoped = parseProjectScopedTag(nk);
@@ -292,11 +509,15 @@ export function SettingsTagsProjectsSection({
292
509
  }
293
510
  for (const [, entry] of scopedMap) {
294
511
  entry.tags.sort((a, b) =>
295
- normalizeTagKey(a).localeCompare(normalizeTagKey(b), undefined, { sensitivity: "base" })
512
+ normalizeTagKey(a).localeCompare(normalizeTagKey(b), undefined, {
513
+ sensitivity: "base",
514
+ }),
296
515
  );
297
516
  }
298
517
  global.sort((a, b) =>
299
- normalizeTagKey(a).localeCompare(normalizeTagKey(b), undefined, { sensitivity: "base" })
518
+ normalizeTagKey(a).localeCompare(normalizeTagKey(b), undefined, {
519
+ sensitivity: "base",
520
+ }),
300
521
  );
301
522
  const projectOrderLower: string[] = [];
302
523
  const seen = new Set<string>();
@@ -322,13 +543,34 @@ export function SettingsTagsProjectsSection({
322
543
  }, [knownTags, knownProjects]);
323
544
 
324
545
  const knownProjectLowerSet = useMemo(
325
- () => new Set(knownProjects.map((p) => normalizeProjectKey(p).toLowerCase())),
326
- [knownProjects]
546
+ () =>
547
+ new Set(knownProjects.map((p) => normalizeProjectKey(p).toLowerCase())),
548
+ [knownProjects],
549
+ );
550
+ const knownProjectOptions = useMemo(
551
+ () =>
552
+ knownProjects.map((p) => ({
553
+ lower: normalizeProjectKey(p).toLowerCase(),
554
+ label: normalizeProjectKey(p),
555
+ })),
556
+ [knownProjects],
557
+ );
558
+ const allKnownTagsSorted = useMemo(
559
+ () =>
560
+ [...knownTags].sort((a, b) =>
561
+ normalizeTagKey(a).localeCompare(normalizeTagKey(b), undefined, {
562
+ sensitivity: "base",
563
+ }),
564
+ ),
565
+ [knownTags],
327
566
  );
328
567
 
329
568
  const orphanScopedSections = useMemo(
330
- () => scopedSections.filter((sec) => !knownProjectLowerSet.has(sec.projectLower)),
331
- [scopedSections, knownProjectLowerSet]
569
+ () =>
570
+ scopedSections.filter(
571
+ (sec) => !knownProjectLowerSet.has(sec.projectLower),
572
+ ),
573
+ [scopedSections, knownProjectLowerSet],
332
574
  );
333
575
 
334
576
  const post = async (body: Record<string, unknown>) => {
@@ -336,12 +578,44 @@ export function SettingsTagsProjectsSection({
336
578
  try {
337
579
  await postKronosysAction(body);
338
580
  await refresh({ preserveForm: true });
581
+ } catch (e: unknown) {
582
+ const fallback =
583
+ lang === "fr"
584
+ ? "Impossible d’enregistrer la modification."
585
+ : "Failed to save the change.";
586
+ const detail = e instanceof Error ? e.message.trim() : String(e).trim();
587
+ pushToast(detail ? `${fallback} ${detail}` : fallback);
339
588
  } finally {
340
589
  setBusy(false);
341
590
  }
342
591
  };
343
592
 
344
593
  const disabled = saving || busy;
594
+ const sourceScoped = parseProjectScopedTag(normalizeTagKey(scopeSourceTag));
595
+ const scopeTargetTag = useMemo(() => {
596
+ const source = normalizeTagKey(scopeSourceTag);
597
+ if (!source) {
598
+ return "";
599
+ }
600
+ if (!scopeTargetProjectLower) {
601
+ return sourceScoped ? normalizeTagKey(sourceScoped.localTag) : "";
602
+ }
603
+ const targetProject =
604
+ knownProjectOptions.find((p) => p.lower === scopeTargetProjectLower)
605
+ ?.label ?? scopeTargetProjectLower;
606
+ const local = sourceScoped ? sourceScoped.localTag : source;
607
+ return normalizeTagKey(`${targetProject}#${local}`);
608
+ }, [
609
+ knownProjectOptions,
610
+ scopeSourceTag,
611
+ scopeTargetProjectLower,
612
+ sourceScoped,
613
+ ]);
614
+ const canRunScopeMigration =
615
+ normalizeTagKey(scopeSourceTag) &&
616
+ normalizeTagKey(scopeTargetTag) &&
617
+ normalizeTagKey(scopeSourceTag).toLowerCase() !==
618
+ normalizeTagKey(scopeTargetTag).toLowerCase();
345
619
 
346
620
  const addPinGlobal = async () => {
347
621
  const t = pinGlobalDraft.replace(/^#/, "").trim();
@@ -352,15 +626,38 @@ export function SettingsTagsProjectsSection({
352
626
  setPinGlobalDraft("");
353
627
  };
354
628
 
355
- const addPinScopedForProject = async (canonicalProject: string, projectLower: string) => {
356
- const raw = (pinScopedByProject[projectLower] ?? "").replace(/^#/, "").trim();
629
+ const addProject = async () => {
630
+ const project = normalizeProjectKey(projectDraft);
631
+ if (!project) {
632
+ return;
633
+ }
634
+ if (
635
+ knownProjects.some(
636
+ (p) => normalizeProjectKey(p).toLowerCase() === project.toLowerCase(),
637
+ )
638
+ ) {
639
+ return;
640
+ }
641
+ await post({ type: "rememberKnownProject", name: project });
642
+ setProjectDraft("");
643
+ };
644
+
645
+ const addPinScopedForProject = async (
646
+ canonicalProject: string,
647
+ projectLower: string,
648
+ ) => {
649
+ const raw = (pinScopedByProject[projectLower] ?? "")
650
+ .replace(/^#/, "")
651
+ .trim();
357
652
  if (!raw) {
358
653
  return;
359
654
  }
360
655
  const parsed = parseProjectScopedTag(raw);
361
656
  let tag: string;
362
657
  if (parsed) {
363
- if (normalizeProjectKey(parsed.projectKey).toLowerCase() !== projectLower) {
658
+ if (
659
+ normalizeProjectKey(parsed.projectKey).toLowerCase() !== projectLower
660
+ ) {
364
661
  return;
365
662
  }
366
663
  tag = `${parsed.projectKey}#${parsed.localTag}`;
@@ -375,13 +672,17 @@ export function SettingsTagsProjectsSection({
375
672
  };
376
673
 
377
674
  const scopedPinDraftIsSubmittable = (projectLower: string) => {
378
- const raw = (pinScopedByProject[projectLower] ?? "").replace(/^#/, "").trim();
675
+ const raw = (pinScopedByProject[projectLower] ?? "")
676
+ .replace(/^#/, "")
677
+ .trim();
379
678
  if (!raw) {
380
679
  return false;
381
680
  }
382
681
  const parsed = parseProjectScopedTag(raw);
383
682
  if (parsed) {
384
- return normalizeProjectKey(parsed.projectKey).toLowerCase() === projectLower;
683
+ return (
684
+ normalizeProjectKey(parsed.projectKey).toLowerCase() === projectLower
685
+ );
385
686
  }
386
687
  return true;
387
688
  };
@@ -424,9 +725,17 @@ export function SettingsTagsProjectsSection({
424
725
  if (mode !== "parallel") {
425
726
  for (const t of running) {
426
727
  if (mode === "pause") {
427
- await postKronosysAction({ type: "setTaskTimerPaused", taskId: t.id, paused: true });
728
+ await postKronosysAction({
729
+ type: "setTaskTimerPaused",
730
+ taskId: t.id,
731
+ paused: true,
732
+ });
428
733
  } else {
429
- await postKronosysAction({ type: "finishTask", taskId: t.id, shouldCommit: false });
734
+ await postKronosysAction({
735
+ type: "finishTask",
736
+ taskId: t.id,
737
+ shouldCommit: false,
738
+ });
430
739
  }
431
740
  }
432
741
  }
@@ -445,7 +754,7 @@ export function SettingsTagsProjectsSection({
445
754
  setBusy(false);
446
755
  }
447
756
  },
448
- [payload?.current, refresh]
757
+ [payload?.current, refresh],
449
758
  );
450
759
 
451
760
  const requestStartTask = useCallback(
@@ -480,7 +789,14 @@ export function SettingsTagsProjectsSection({
480
789
  await flushPendingStartTask();
481
790
  }
482
791
  },
483
- [payload?.current, flushPendingStartTask, resolveConflictAndStart, post, pushToast, s.tagStartTaskAutoSessionToast]
792
+ [
793
+ payload?.current,
794
+ flushPendingStartTask,
795
+ resolveConflictAndStart,
796
+ post,
797
+ pushToast,
798
+ s.tagStartTaskAutoSessionToast,
799
+ ],
484
800
  );
485
801
 
486
802
  const requestStartFromTag = useCallback(
@@ -491,7 +807,7 @@ export function SettingsTagsProjectsSection({
491
807
  const draft = buildStartTaskFromDraft("", [token], projectForPicker);
492
808
  void requestStartTask(draft);
493
809
  },
494
- [requestStartTask]
810
+ [requestStartTask],
495
811
  );
496
812
 
497
813
  const requestStartFromProject = useCallback(
@@ -499,7 +815,7 @@ export function SettingsTagsProjectsSection({
499
815
  const draft = buildStartTaskFromDraft("", [], normalizeProjectKey(proj));
500
816
  void requestStartTask(draft);
501
817
  },
502
- [requestStartTask]
818
+ [requestStartTask],
503
819
  );
504
820
 
505
821
  const cancelTrackingConflict = useCallback(() => {
@@ -512,24 +828,34 @@ export function SettingsTagsProjectsSection({
512
828
  variant === "dashboard"
513
829
  ? "space-y-5"
514
830
  : "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";
831
+ const rootId =
832
+ pid === "dashboard" ? "dashboard-tags-projects" : "settings-tags-projects";
516
833
  const tagsByProjectScrollId =
517
- variant === "settings" ? "settings-tags-by-project" : `${pid}-tags-by-project`;
834
+ variant === "settings"
835
+ ? "settings-tags-by-project"
836
+ : `${pid}-tags-by-project`;
518
837
 
519
838
  const linkedTagsBlock = (
520
839
  onStartFromTag: (tag: string) => void,
521
840
  sec: { projectLower: string; displayProject: string; tags: string[] },
522
841
  anchorSectionId: string | undefined,
523
842
  canonicalProject: string,
524
- opts?: { omitSectionTitle?: boolean }
843
+ opts?: { omitSectionTitle?: boolean },
525
844
  ) => (
526
- <div id={anchorSectionId} className="mt-4 border-t border-zinc-200 pt-4 dark:border-zinc-800">
845
+ <div
846
+ id={anchorSectionId}
847
+ className="mt-4 border-t border-zinc-200 pt-4 dark:border-zinc-800"
848
+ >
527
849
  {!opts?.omitSectionTitle ? (
528
850
  <h4 className="text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
529
851
  {s.tocSubTagsByProject}
530
852
  </h4>
531
853
  ) : null}
532
- <div className={`flex max-w-md flex-col gap-2 ${opts?.omitSectionTitle ? "" : "mt-3"}`}>
854
+ <div
855
+ className={`flex max-w-md flex-col gap-2 ${
856
+ opts?.omitSectionTitle ? "" : "mt-3"
857
+ }`}
858
+ >
533
859
  <label
534
860
  className="block text-xs font-medium text-zinc-600 dark:text-zinc-400"
535
861
  htmlFor={`${pid}-pin-scoped-${sec.projectLower}`}
@@ -542,7 +868,10 @@ export function SettingsTagsProjectsSection({
542
868
  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
869
  value={pinScopedByProject[sec.projectLower] ?? ""}
544
870
  onChange={(e) =>
545
- setPinScopedByProject((prev) => ({ ...prev, [sec.projectLower]: e.target.value }))
871
+ setPinScopedByProject((prev) => ({
872
+ ...prev,
873
+ [sec.projectLower]: e.target.value,
874
+ }))
546
875
  }
547
876
  placeholder={s.tagsPinScopedPlaceholder}
548
877
  disabled={disabled}
@@ -559,10 +888,14 @@ export function SettingsTagsProjectsSection({
559
888
  <button
560
889
  type="button"
561
890
  className={ICON_BTN_PRIMARY}
562
- disabled={disabled || !scopedPinDraftIsSubmittable(sec.projectLower)}
891
+ disabled={
892
+ disabled || !scopedPinDraftIsSubmittable(sec.projectLower)
893
+ }
563
894
  title={s.tagsPinAddBtn}
564
895
  aria-label={s.tagsPinAddBtn}
565
- onClick={() => void addPinScopedForProject(canonicalProject, sec.projectLower)}
896
+ onClick={() =>
897
+ void addPinScopedForProject(canonicalProject, sec.projectLower)
898
+ }
566
899
  >
567
900
  <Plus size={20} strokeWidth={2} aria-hidden />
568
901
  </button>
@@ -573,11 +906,15 @@ export function SettingsTagsProjectsSection({
573
906
  ) : (
574
907
  <ul
575
908
  className="mt-3"
576
- aria-label={`${s.tocSubTagsByProject} — ${formatProjectDisplay(sec.displayProject)}`}
909
+ aria-label={`${s.tocSubTagsByProject} — ${formatProjectDisplay(
910
+ sec.displayProject,
911
+ )}`}
577
912
  >
578
913
  {sec.tags.map((tag, tagIdx) => (
579
914
  <SettingsTagEditorRow
580
- key={`s-${sec.projectLower}-${normalizeTagKey(tag).toLowerCase()}`}
915
+ key={`s-${sec.projectLower}-${normalizeTagKey(
916
+ tag,
917
+ ).toLowerCase()}`}
581
918
  tag={tag}
582
919
  fieldId={`${pid}-scoped-tag-desc-${sec.projectLower}-${tagIdx}`}
583
920
  s={s}
@@ -587,6 +924,7 @@ export function SettingsTagsProjectsSection({
587
924
  pinnedKeys={pinnedKeys}
588
925
  post={post}
589
926
  setConfirm={setConfirm}
927
+ dismissRenameImpactConfirm={dismissRenameImpactConfirm}
590
928
  onStartTrackedTask={() => void onStartFromTag(tag)}
591
929
  />
592
930
  ))}
@@ -600,7 +938,9 @@ export function SettingsTagsProjectsSection({
600
938
  {showPageHeader ? (
601
939
  <>
602
940
  <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>
941
+ <h2 className="text-xs font-semibold uppercase tracking-wide text-zinc-500">
942
+ {s.sectionTagsProjects}
943
+ </h2>
604
944
  <InlineMetricHelpTrigger
605
945
  ariaLabel={s.tagsProjectsHelpAria}
606
946
  body={s.tagsProjectsHelpBody}
@@ -608,7 +948,9 @@ export function SettingsTagsProjectsSection({
608
948
  panelClassName="w-[min(calc(100vw-2rem),26rem)]"
609
949
  />
610
950
  </div>
611
- <p className="text-xs leading-relaxed text-zinc-600 dark:text-zinc-400">{s.tagsProjectsIntro}</p>
951
+ <p className="text-xs leading-relaxed text-zinc-600 dark:text-zinc-400">
952
+ {s.tagsProjectsIntro}
953
+ </p>
612
954
  </>
613
955
  ) : null}
614
956
 
@@ -650,7 +992,29 @@ export function SettingsTagsProjectsSection({
650
992
  }`}
651
993
  onClick={() => setDashTagsTab("projects")}
652
994
  >
653
- <FolderKanban size={20} strokeWidth={2} aria-hidden className="shrink-0" />
995
+ <FolderKanban
996
+ size={20}
997
+ strokeWidth={2}
998
+ aria-hidden
999
+ className="shrink-0"
1000
+ />
1001
+ </button>
1002
+ <button
1003
+ type="button"
1004
+ role="tab"
1005
+ id={`${pid}-tab-templates`}
1006
+ aria-selected={dashTagsTab === "templates"}
1007
+ aria-controls={`${pid}-tabpanel-templates`}
1008
+ aria-label={s.tagsProjectsTabTemplates}
1009
+ title={s.tagsProjectsTabTemplates}
1010
+ className={`inline-flex min-w-0 flex-1 items-center justify-center rounded-md px-2 py-2 transition sm:px-2.5 ${
1011
+ dashTagsTab === "templates"
1012
+ ? "bg-white text-violet-700 shadow-sm dark:bg-zinc-800 dark:text-violet-300"
1013
+ : "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"
1014
+ }`}
1015
+ onClick={() => setDashTagsTab("templates")}
1016
+ >
1017
+ <Save size={20} strokeWidth={2} aria-hidden className="shrink-0" />
654
1018
  </button>
655
1019
  </div>
656
1020
  ) : null}
@@ -658,194 +1022,524 @@ export function SettingsTagsProjectsSection({
658
1022
  <div
659
1023
  role={variant === "dashboard" ? "tabpanel" : undefined}
660
1024
  id={variant === "dashboard" ? `${pid}-tabpanel-global` : undefined}
661
- aria-labelledby={variant === "dashboard" ? `${pid}-tab-global` : undefined}
1025
+ aria-labelledby={
1026
+ variant === "dashboard" ? `${pid}-tab-global` : undefined
1027
+ }
662
1028
  hidden={variant === "dashboard" && dashTagsTab !== "global"}
663
1029
  className={variant === "dashboard" ? "space-y-5" : "contents"}
664
1030
  >
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()}
1031
+ <div id={`${pid}-tags-global`} className={CARD_CLASS}>
1032
+ <h3 className="text-sm font-medium text-zinc-800 dark:text-zinc-100">
1033
+ {s.tagsGlobalHeading}
1034
+ </h3>
1035
+ <p className="mt-1 text-xs text-zinc-600 dark:text-zinc-400">
1036
+ {s.tagsGlobalIntro}
1037
+ </p>
1038
+ <div className="mt-3 max-w-md rounded-lg border border-zinc-200 bg-white/60 p-3 dark:border-zinc-700 dark:bg-zinc-900/40">
1039
+ <h4 className="text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
1040
+ {s.tagsScopeToolHeading}
1041
+ </h4>
1042
+ {allKnownTagsSorted.length === 0 ? (
1043
+ <p className="mt-2 text-xs text-zinc-500 dark:text-zinc-400">
1044
+ {s.tagsScopeToolNoTags}
1045
+ </p>
1046
+ ) : (
1047
+ <div className="mt-2 space-y-2">
1048
+ <label className="block text-xs text-zinc-600 dark:text-zinc-400">
1049
+ <span className="mb-1 block">
1050
+ {s.tagsScopeToolSourceLabel}
1051
+ </span>
1052
+ <select
1053
+ className="w-full rounded-lg border border-zinc-300 bg-white px-2.5 py-2 text-xs text-zinc-900 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100"
1054
+ value={scopeSourceTag}
1055
+ onChange={(e) => setScopeSourceTag(e.target.value)}
1056
+ disabled={disabled}
1057
+ >
1058
+ <option value="" />
1059
+ {allKnownTagsSorted.map((tag) => (
1060
+ <option key={tag} value={tag}>
1061
+ {formatTagDisplay(tag)}
1062
+ </option>
1063
+ ))}
1064
+ </select>
1065
+ </label>
1066
+ <label className="block text-xs text-zinc-600 dark:text-zinc-400">
1067
+ <span className="mb-1 block">
1068
+ {s.tagsScopeToolTargetProjectLabel}
1069
+ </span>
1070
+ <select
1071
+ className="w-full rounded-lg border border-zinc-300 bg-white px-2.5 py-2 text-xs text-zinc-900 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100"
1072
+ value={scopeTargetProjectLower}
1073
+ onChange={(e) => setScopeTargetProjectLower(e.target.value)}
1074
+ disabled={disabled}
1075
+ >
1076
+ <option value="">
1077
+ {s.tagsScopeToolTargetGlobalOption}
1078
+ </option>
1079
+ {knownProjectOptions.map((proj) => (
1080
+ <option key={proj.lower} value={proj.lower}>
1081
+ {formatProjectDisplay(proj.label)}
1082
+ </option>
1083
+ ))}
1084
+ </select>
1085
+ </label>
1086
+ <div className="flex flex-wrap gap-1.5">
1087
+ <button
1088
+ type="button"
1089
+ className="rounded-md border border-zinc-300 bg-white px-2.5 py-1.5 text-xs text-zinc-700 hover:bg-zinc-100 disabled:opacity-40 dark:border-zinc-600 dark:bg-zinc-900 dark:text-zinc-200 dark:hover:bg-zinc-800"
1090
+ disabled={disabled || !canRunScopeMigration}
1091
+ onClick={() =>
1092
+ void post({
1093
+ type: "transformKnownTagScope",
1094
+ sourceTag: scopeSourceTag,
1095
+ targetTag: scopeTargetTag,
1096
+ mode: "copy",
1097
+ })
1098
+ }
1099
+ >
1100
+ {s.tagsScopeToolCopyBtn}
1101
+ </button>
1102
+ <button
1103
+ type="button"
1104
+ className="rounded-md border border-violet-500/50 bg-violet-500/10 px-2.5 py-1.5 text-xs text-violet-800 hover:bg-violet-500/20 disabled:opacity-40 dark:border-violet-400/45 dark:bg-violet-600/20 dark:text-violet-100 dark:hover:bg-violet-600/30"
1105
+ disabled={disabled || !canRunScopeMigration}
1106
+ onClick={() =>
1107
+ void post({
1108
+ type: "transformKnownTagScope",
1109
+ sourceTag: scopeSourceTag,
1110
+ targetTag: scopeTargetTag,
1111
+ mode: "move",
1112
+ })
1113
+ }
1114
+ >
1115
+ {s.tagsScopeToolMoveBtn}
1116
+ </button>
1117
+ </div>
1118
+ </div>
1119
+ )}
1120
+ </div>
1121
+ <div className="mt-3 flex max-w-md flex-col gap-2">
1122
+ <label
1123
+ className="block text-xs font-medium text-zinc-600 dark:text-zinc-400"
1124
+ htmlFor={`${pid}-pin-global-tag`}
701
1125
  >
702
- <Plus size={20} strokeWidth={2} aria-hidden />
703
- </button>
1126
+ {s.tagsPinFieldLabel}
1127
+ </label>
1128
+ <input
1129
+ id={`${pid}-pin-global-tag`}
1130
+ type="text"
1131
+ 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"
1132
+ value={pinGlobalDraft}
1133
+ onChange={(e) => setPinGlobalDraft(e.target.value)}
1134
+ placeholder={s.tagsPinPlaceholder}
1135
+ disabled={disabled}
1136
+ autoComplete="off"
1137
+ spellCheck={false}
1138
+ onKeyDown={(e) => {
1139
+ if (e.key === "Enter") {
1140
+ e.preventDefault();
1141
+ void addPinGlobal();
1142
+ }
1143
+ }}
1144
+ />
1145
+ <div className="flex">
1146
+ <button
1147
+ type="button"
1148
+ className={ICON_BTN_PRIMARY}
1149
+ disabled={
1150
+ disabled ||
1151
+ !pinGlobalDraft.trim() ||
1152
+ !!parseProjectScopedTag(
1153
+ pinGlobalDraft.replace(/^#/, "").trim(),
1154
+ )
1155
+ }
1156
+ title={s.tagsPinAddBtn}
1157
+ aria-label={s.tagsPinAddBtn}
1158
+ onClick={() => void addPinGlobal()}
1159
+ >
1160
+ <Plus size={20} strokeWidth={2} aria-hidden />
1161
+ </button>
1162
+ </div>
704
1163
  </div>
705
- </div>
706
1164
 
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>
1165
+ {globalKnownTags.length === 0 ? (
1166
+ <p className="mt-4 text-sm text-zinc-500">{s.tagsEmptyGlobal}</p>
1167
+ ) : (
1168
+ <ul className="mt-4" aria-label={s.tagsGlobalHeading}>
1169
+ {globalKnownTags.map((tag, tagIdx) => (
1170
+ <SettingsTagEditorRow
1171
+ key={`g-${normalizeTagKey(tag).toLowerCase()}`}
1172
+ tag={tag}
1173
+ fieldId={`${pid}-global-tag-desc-${tagIdx}`}
1174
+ s={s}
1175
+ disabled={disabled}
1176
+ localTagDesc={localTagDesc}
1177
+ setLocalTagDesc={setLocalTagDesc}
1178
+ pinnedKeys={pinnedKeys}
1179
+ post={post}
1180
+ setConfirm={setConfirm}
1181
+ dismissRenameImpactConfirm={dismissRenameImpactConfirm}
1182
+ onStartTrackedTask={() => void requestStartFromTag(tag)}
1183
+ />
1184
+ ))}
1185
+ </ul>
1186
+ )}
1187
+ </div>
729
1188
 
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>
1189
+ <div id={`${pid}-tags-hidden`} className={CARD_CLASS}>
1190
+ <h3 className="text-sm font-medium text-zinc-800 dark:text-zinc-100">
1191
+ {s.tagsHiddenHeading}
1192
+ </h3>
1193
+ {excludedRaw.length === 0 ? (
1194
+ <p className="mt-3 text-sm text-zinc-500">{s.hiddenTagsEmpty}</p>
1195
+ ) : (
1196
+ <ul className="mt-3">
1197
+ {excludedRaw.map((key) => (
1198
+ <li
1199
+ key={key}
1200
+ className="border-b border-zinc-200 py-3 last:border-b-0 dark:border-zinc-800/90"
1201
+ >
1202
+ <div className="flex flex-col gap-2">
1203
+ <span className="font-mono text-sm text-zinc-600 line-through decoration-zinc-500 dark:text-zinc-400">
1204
+ {formatTagDisplay(key)}
1205
+ </span>
1206
+ <div className="flex flex-wrap items-center gap-1.5">
1207
+ <button
1208
+ type="button"
1209
+ className={ICON_BTN}
1210
+ disabled={disabled}
1211
+ title={s.tagRestoreBtn}
1212
+ aria-label={s.tagRestoreBtn}
1213
+ onClick={() =>
1214
+ void post({
1215
+ type: "includeTagFromSuggestions",
1216
+ tag: key,
1217
+ })
1218
+ }
1219
+ >
1220
+ <RotateCcw size={16} strokeWidth={2} aria-hidden />
1221
+ </button>
1222
+ <button
1223
+ type="button"
1224
+ className={ICON_BTN_DANGER}
1225
+ disabled={disabled}
1226
+ title={s.tagPurgeHiddenAriaLabel}
1227
+ aria-label={s.tagPurgeHiddenAriaLabel}
1228
+ onClick={() =>
1229
+ setConfirm({
1230
+ kind: "purgeTag",
1231
+ tag: key,
1232
+ fromExcludedList: true,
1233
+ })
1234
+ }
1235
+ >
1236
+ <Trash2 size={16} strokeWidth={2} aria-hidden />
1237
+ </button>
1238
+ </div>
763
1239
  </div>
764
- </div>
765
- </li>
766
- ))}
767
- </ul>
768
- )}
769
- </div>
1240
+ </li>
1241
+ ))}
1242
+ </ul>
1243
+ )}
1244
+ </div>
770
1245
  </div>
771
1246
 
772
1247
  <div
773
1248
  role={variant === "dashboard" ? "tabpanel" : undefined}
774
1249
  id={variant === "dashboard" ? `${pid}-tabpanel-projects` : undefined}
775
- aria-labelledby={variant === "dashboard" ? `${pid}-tab-projects` : undefined}
1250
+ aria-labelledby={
1251
+ variant === "dashboard" ? `${pid}-tab-projects` : undefined
1252
+ }
776
1253
  hidden={variant === "dashboard" && dashTagsTab !== "projects"}
777
1254
  className={variant === "dashboard" ? "space-y-5" : "contents"}
778
1255
  >
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) ?? {
1256
+ <div id={`${pid}-tags-saved-projects`} className={CARD_CLASS}>
1257
+ <div className="flex flex-wrap items-center gap-2">
1258
+ <h3 className="text-sm font-medium text-zinc-800 dark:text-zinc-100">
1259
+ {s.projectsHeading}
1260
+ </h3>
1261
+ <InlineMetricHelpTrigger
1262
+ ariaLabel={s.projectsHelpAria}
1263
+ body={`${s.projectsIntro}\n\n${s.projectsHelpBody}`}
1264
+ preserveLineBreaks
1265
+ panelClassName="w-[min(calc(100vw-2rem),22rem)]"
1266
+ />
1267
+ </div>
1268
+ <div className="mt-3 flex max-w-md flex-col gap-2">
1269
+ <label
1270
+ className="block text-xs font-medium text-zinc-600 dark:text-zinc-400"
1271
+ htmlFor={`${pid}-add-project`}
1272
+ >
1273
+ {s.projectsAddFieldLabel}
1274
+ </label>
1275
+ <input
1276
+ id={`${pid}-add-project`}
1277
+ type="text"
1278
+ 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"
1279
+ value={projectDraft}
1280
+ onChange={(e) => setProjectDraft(e.target.value)}
1281
+ placeholder={s.projectsAddPlaceholder}
1282
+ disabled={disabled}
1283
+ autoComplete="off"
1284
+ spellCheck={false}
1285
+ onKeyDown={(e) => {
1286
+ if (e.key === "Enter") {
1287
+ e.preventDefault();
1288
+ void addProject();
1289
+ }
1290
+ }}
1291
+ />
1292
+ <div className="flex">
1293
+ <button
1294
+ type="button"
1295
+ className={ICON_BTN_PRIMARY}
1296
+ disabled={
1297
+ disabled ||
1298
+ !normalizeProjectKey(projectDraft) ||
1299
+ knownProjects.some(
1300
+ (p) =>
1301
+ normalizeProjectKey(p).toLowerCase() ===
1302
+ normalizeProjectKey(projectDraft).toLowerCase(),
1303
+ )
1304
+ }
1305
+ title={s.projectsAddBtn}
1306
+ aria-label={s.projectsAddBtn}
1307
+ onClick={() => void addProject()}
1308
+ >
1309
+ <Plus size={20} strokeWidth={2} aria-hidden />
1310
+ </button>
1311
+ </div>
1312
+ </div>
1313
+ {knownProjects.length === 0 && orphanScopedSections.length === 0 ? (
1314
+ <p className="mt-4 text-sm text-zinc-500">{s.projectsEmpty}</p>
1315
+ ) : null}
1316
+ {knownProjects.length > 0 ? (
1317
+ <ul className="mt-4">
1318
+ {knownProjects.map((proj, projIdx) => {
1319
+ const pk = normalizeProjectKey(proj).toLowerCase();
1320
+ const projectDescFieldId = `${pid}-project-desc-${projIdx}`;
1321
+ const sec = scopedSections.find(
1322
+ (x) => x.projectLower === pk,
1323
+ ) ?? {
799
1324
  projectLower: pk,
800
1325
  displayProject: proj,
801
1326
  tags: [] as string[],
802
1327
  };
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">
1328
+ const linkedAnchorId =
1329
+ projIdx === 0 ? tagsByProjectScrollId : undefined;
1330
+ return (
1331
+ <li
1332
+ key={proj}
1333
+ className="border-b border-zinc-200 py-3 last:border-b-0 dark:border-zinc-800/90"
1334
+ >
1335
+ <div className="flex flex-col gap-2.5">
1336
+ {projectRenameOpenByKey[pk] ? (
1337
+ <input
1338
+ type="text"
1339
+ className="w-full max-w-sm rounded border border-zinc-300 bg-white px-2 py-1 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"
1340
+ value={
1341
+ projectRenameDraftByKey[pk] ??
1342
+ normalizeProjectKey(proj)
1343
+ }
1344
+ onChange={(e) =>
1345
+ setProjectRenameDraftByKey((prev) => ({
1346
+ ...prev,
1347
+ [pk]: e.target.value,
1348
+ }))
1349
+ }
1350
+ onKeyDown={(e) => {
1351
+ if (e.key === "Escape") {
1352
+ e.preventDefault();
1353
+ setProjectRenameDraftByKey((prev) => ({
1354
+ ...prev,
1355
+ [pk]: normalizeProjectKey(proj),
1356
+ }));
1357
+ setProjectRenameOpenByKey((prev) => ({
1358
+ ...prev,
1359
+ [pk]: false,
1360
+ }));
1361
+ return;
1362
+ }
1363
+ if (e.key !== "Enter") {
1364
+ return;
1365
+ }
1366
+ e.preventDefault();
1367
+ const target = normalizeProjectKey(
1368
+ projectRenameDraftByKey[pk] ?? "",
1369
+ );
1370
+ if (
1371
+ !target ||
1372
+ target.toLowerCase() ===
1373
+ normalizeProjectKey(proj).toLowerCase()
1374
+ ) {
1375
+ return;
1376
+ }
1377
+ if (dismissRenameImpactConfirm) {
1378
+ setProjectRenameOpenByKey((prev) => ({
1379
+ ...prev,
1380
+ [pk]: false,
1381
+ }));
1382
+ void post({
1383
+ type: "renameProjectMetadata",
1384
+ sourceName: proj,
1385
+ targetName: target,
1386
+ });
1387
+ return;
1388
+ }
1389
+ setProjectRenameOpenByKey((prev) => ({
1390
+ ...prev,
1391
+ [pk]: false,
1392
+ }));
1393
+ setConfirm({
1394
+ kind: "renameProject",
1395
+ sourceName: proj,
1396
+ targetName: target,
1397
+ });
1398
+ }}
1399
+ disabled={disabled}
1400
+ spellCheck={false}
1401
+ autoComplete="off"
1402
+ />
1403
+ ) : (
1404
+ <span className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
1405
+ {formatProjectDisplay(proj)}
1406
+ </span>
1407
+ )}
1408
+ <input
1409
+ id={projectDescFieldId}
1410
+ type="text"
1411
+ className={INLINE_INPUT_CLASS}
1412
+ value={localProjectDesc[pk] ?? ""}
1413
+ onChange={(e) =>
1414
+ setLocalProjectDesc((prev) => ({
1415
+ ...prev,
1416
+ [pk]: e.target.value,
1417
+ }))
1418
+ }
1419
+ disabled={disabled}
1420
+ placeholder={s.projectDescriptionLabel}
1421
+ spellCheck
1422
+ />
1423
+ <div className="flex flex-wrap items-center gap-1.5">
1424
+ {!projectRenameOpenByKey[pk] ? (
1425
+ <button
1426
+ type="button"
1427
+ className={ICON_BTN_SAVE}
1428
+ disabled={disabled}
1429
+ title={s.descriptionSaveBtn}
1430
+ aria-label={s.descriptionSaveBtn}
1431
+ onClick={() =>
1432
+ void post({
1433
+ type: "setProjectDescription",
1434
+ name: proj,
1435
+ description: localProjectDesc[pk] ?? "",
1436
+ })
1437
+ }
1438
+ >
1439
+ <Save size={16} strokeWidth={2} aria-hidden />
1440
+ </button>
1441
+ ) : null}
823
1442
  <button
824
1443
  type="button"
825
- className={ICON_BTN_SAVE}
1444
+ className={ICON_BTN_DANGER}
826
1445
  disabled={disabled}
827
- title={s.descriptionSaveBtn}
828
- aria-label={s.descriptionSaveBtn}
1446
+ title={s.projectsRemoveBtn}
1447
+ aria-label={s.projectsRemoveBtn}
829
1448
  onClick={() =>
830
- void post({
831
- type: "setProjectDescription",
832
- name: proj,
833
- description: localProjectDesc[pk] ?? "",
834
- })
1449
+ setConfirm({ kind: "purgeProject", name: proj })
835
1450
  }
836
1451
  >
837
- <Save size={16} strokeWidth={2} aria-hidden />
1452
+ <Trash2 size={16} strokeWidth={2} aria-hidden />
838
1453
  </button>
839
1454
  <button
840
1455
  type="button"
841
- className={ICON_BTN_DANGER}
1456
+ className={ICON_BTN}
842
1457
  disabled={disabled}
843
- title={s.projectsRemoveBtn}
844
- aria-label={s.projectsRemoveBtn}
845
- onClick={() => setConfirm({ kind: "purgeProject", name: proj })}
1458
+ title={s.projectsRenameBtn}
1459
+ aria-label={s.projectsRenameBtn}
1460
+ onClick={() => {
1461
+ setProjectRenameDraftByKey((prev) => ({
1462
+ ...prev,
1463
+ [pk]: normalizeProjectKey(proj),
1464
+ }));
1465
+ setProjectRenameOpenByKey((prev) => ({
1466
+ ...prev,
1467
+ [pk]: true,
1468
+ }));
1469
+ }}
846
1470
  >
847
- <Trash2 size={16} strokeWidth={2} aria-hidden />
1471
+ <Pencil size={16} strokeWidth={2} aria-hidden />
848
1472
  </button>
1473
+ {projectRenameOpenByKey[pk] ? (
1474
+ <>
1475
+ <button
1476
+ type="button"
1477
+ className={ICON_BTN_SAVE}
1478
+ disabled={
1479
+ disabled ||
1480
+ !normalizeProjectKey(
1481
+ projectRenameDraftByKey[pk] ?? "",
1482
+ ) ||
1483
+ normalizeProjectKey(
1484
+ projectRenameDraftByKey[pk] ?? "",
1485
+ ).toLowerCase() ===
1486
+ normalizeProjectKey(proj).toLowerCase()
1487
+ }
1488
+ title={s.descriptionSaveBtn}
1489
+ aria-label={s.descriptionSaveBtn}
1490
+ onClick={() => {
1491
+ const target = normalizeProjectKey(
1492
+ projectRenameDraftByKey[pk] ?? "",
1493
+ );
1494
+ if (!target) {
1495
+ return;
1496
+ }
1497
+ setProjectRenameOpenByKey((prev) => ({
1498
+ ...prev,
1499
+ [pk]: false,
1500
+ }));
1501
+ if (dismissRenameImpactConfirm) {
1502
+ void post({
1503
+ type: "renameProjectMetadata",
1504
+ sourceName: proj,
1505
+ targetName: target,
1506
+ });
1507
+ return;
1508
+ }
1509
+ setConfirm({
1510
+ kind: "renameProject",
1511
+ sourceName: proj,
1512
+ targetName: target,
1513
+ });
1514
+ }}
1515
+ >
1516
+ <Save size={16} strokeWidth={2} aria-hidden />
1517
+ </button>
1518
+ <button
1519
+ type="button"
1520
+ className={ICON_BTN}
1521
+ disabled={disabled}
1522
+ title={s.dialogCancelBtn}
1523
+ aria-label={s.dialogCancelBtn}
1524
+ onClick={() => {
1525
+ setProjectRenameDraftByKey((prev) => ({
1526
+ ...prev,
1527
+ [pk]: normalizeProjectKey(proj),
1528
+ }));
1529
+ setProjectRenameOpenByKey((prev) => ({
1530
+ ...prev,
1531
+ [pk]: false,
1532
+ }));
1533
+ }}
1534
+ >
1535
+ <RotateCcw
1536
+ size={16}
1537
+ strokeWidth={2}
1538
+ aria-hidden
1539
+ />
1540
+ </button>
1541
+ </>
1542
+ ) : null}
849
1543
  <button
850
1544
  type="button"
851
1545
  className={ICON_BTN_PRIMARY}
@@ -854,50 +1548,89 @@ export function SettingsTagsProjectsSection({
854
1548
  aria-label={s.tagStartTrackedTaskFromProjectBtn}
855
1549
  onClick={() => void requestStartFromProject(proj)}
856
1550
  >
857
- <Play size={16} strokeWidth={2} className="ml-0.5" fill="currentColor" aria-hidden />
1551
+ <Play
1552
+ size={16}
1553
+ strokeWidth={2}
1554
+ className="ml-0.5"
1555
+ fill="currentColor"
1556
+ aria-hidden
1557
+ />
858
1558
  </button>
1559
+ </div>
1560
+ {linkedTagsBlock(
1561
+ requestStartFromTag,
1562
+ sec,
1563
+ linkedAnchorId,
1564
+ proj,
1565
+ )}
859
1566
  </div>
860
- {linkedTagsBlock(requestStartFromTag, sec, linkedAnchorId, proj)}
1567
+ </li>
1568
+ );
1569
+ })}
1570
+ </ul>
1571
+ ) : null}
1572
+ {orphanScopedSections.length > 0 ? (
1573
+ <div
1574
+ className={`${
1575
+ knownProjects.length > 0
1576
+ ? "mt-6 border-t border-zinc-200 pt-6 dark:border-zinc-800"
1577
+ : "mt-4"
1578
+ }`}
1579
+ >
1580
+ <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
1581
+ <h3 className="text-sm font-medium text-zinc-800 dark:text-zinc-100">
1582
+ {s.tagsByProjectHeading}
1583
+ </h3>
1584
+ <InlineMetricHelpTrigger
1585
+ ariaLabel={s.tagsByProjectOrphanHelpAria}
1586
+ body={s.tagsByProjectOrphanIntro}
1587
+ panelClassName="w-[min(calc(100vw-2rem),26rem)]"
1588
+ />
1589
+ </div>
1590
+ <div className="mt-4 space-y-6">
1591
+ {orphanScopedSections.map((sec, orIdx) => (
1592
+ <div
1593
+ key={sec.projectLower}
1594
+ 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"
1595
+ >
1596
+ <h4 className="text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
1597
+ {formatProjectDisplay(sec.displayProject)}
1598
+ </h4>
1599
+ {linkedTagsBlock(
1600
+ requestStartFromTag,
1601
+ sec,
1602
+ knownProjects.length === 0 && orIdx === 0
1603
+ ? tagsByProjectScrollId
1604
+ : undefined,
1605
+ sec.displayProject,
1606
+ { omitSectionTitle: true },
1607
+ )}
861
1608
  </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
- ))}
1609
+ ))}
1610
+ </div>
897
1611
  </div>
898
- </div>
899
- ) : null}
1612
+ ) : null}
1613
+ </div>
900
1614
  </div>
1615
+
1616
+ <div
1617
+ role={variant === "dashboard" ? "tabpanel" : undefined}
1618
+ id={variant === "dashboard" ? `${pid}-tabpanel-templates` : undefined}
1619
+ aria-labelledby={
1620
+ variant === "dashboard" ? `${pid}-tab-templates` : undefined
1621
+ }
1622
+ hidden={variant === "dashboard" && dashTagsTab !== "templates"}
1623
+ className={variant === "dashboard" ? "space-y-5" : "contents"}
1624
+ >
1625
+ {payload ? (
1626
+ <SettingsTaskTemplatesSection
1627
+ s={s}
1628
+ lang={lang}
1629
+ payload={payload}
1630
+ saving={saving || busy}
1631
+ refresh={refresh}
1632
+ />
1633
+ ) : null}
901
1634
  </div>
902
1635
 
903
1636
  <DashboardConfirmModal
@@ -932,7 +1665,9 @@ export function SettingsTagsProjectsSection({
932
1665
  <DashboardConfirmModal
933
1666
  open={confirm?.kind === "purgeTag"}
934
1667
  message={
935
- confirm?.kind === "purgeTag" && confirm.fromExcludedList ? s.tagPurgeHiddenConfirm : s.tagPurgeConfirm
1668
+ confirm?.kind === "purgeTag" && confirm.fromExcludedList
1669
+ ? s.tagPurgeHiddenConfirm
1670
+ : s.tagPurgeConfirm
936
1671
  }
937
1672
  cancelLabel={s.dialogCancelBtn}
938
1673
  confirmLabel={s.dialogConfirmBtn}
@@ -954,6 +1689,74 @@ export function SettingsTagsProjectsSection({
954
1689
  }
955
1690
  }}
956
1691
  />
1692
+ <DashboardConfirmModal
1693
+ open={confirm?.kind === "renameTag"}
1694
+ message={s.tagRenameImpactConfirm}
1695
+ cancelLabel={s.dialogCancelBtn}
1696
+ confirmLabel={s.dialogConfirmBtn}
1697
+ confirmVariant="danger"
1698
+ dismissCheckbox={{
1699
+ label: s.renameImpactDontShowAgain,
1700
+ checked: dismissRenameImpactConfirm,
1701
+ onChange: setDismissRenameImpactConfirm,
1702
+ }}
1703
+ onCancel={() => setConfirm(null)}
1704
+ onConfirm={() => {
1705
+ if (confirm?.kind !== "renameTag") {
1706
+ setConfirm(null);
1707
+ return;
1708
+ }
1709
+ const { sourceTag, targetTag } = confirm;
1710
+ if (dismissRenameImpactConfirm) {
1711
+ try {
1712
+ globalThis.localStorage?.setItem(
1713
+ RENAME_IMPACT_CONFIRM_STORAGE_KEY,
1714
+ "1",
1715
+ );
1716
+ } catch {}
1717
+ }
1718
+ setConfirm(null);
1719
+ void post({
1720
+ type: "renameTagMetadata",
1721
+ sourceTag,
1722
+ targetTag,
1723
+ });
1724
+ }}
1725
+ />
1726
+ <DashboardConfirmModal
1727
+ open={confirm?.kind === "renameProject"}
1728
+ message={s.projectRenameImpactConfirm}
1729
+ cancelLabel={s.dialogCancelBtn}
1730
+ confirmLabel={s.dialogConfirmBtn}
1731
+ confirmVariant="danger"
1732
+ dismissCheckbox={{
1733
+ label: s.renameImpactDontShowAgain,
1734
+ checked: dismissRenameImpactConfirm,
1735
+ onChange: setDismissRenameImpactConfirm,
1736
+ }}
1737
+ onCancel={() => setConfirm(null)}
1738
+ onConfirm={() => {
1739
+ if (confirm?.kind !== "renameProject") {
1740
+ setConfirm(null);
1741
+ return;
1742
+ }
1743
+ const { sourceName, targetName } = confirm;
1744
+ if (dismissRenameImpactConfirm) {
1745
+ try {
1746
+ globalThis.localStorage?.setItem(
1747
+ RENAME_IMPACT_CONFIRM_STORAGE_KEY,
1748
+ "1",
1749
+ );
1750
+ } catch {}
1751
+ }
1752
+ setConfirm(null);
1753
+ void post({
1754
+ type: "renameProjectMetadata",
1755
+ sourceName,
1756
+ targetName,
1757
+ });
1758
+ }}
1759
+ />
957
1760
  <DashboardConfirmModal
958
1761
  open={confirm?.kind === "purgeProject"}
959
1762
  message={s.projectRemoveConfirm}
@@ -984,9 +1787,15 @@ export function SettingsTagsProjectsSection({
984
1787
  onChange: setRememberConcurrentChoice,
985
1788
  }}
986
1789
  onDismiss={cancelTrackingConflict}
987
- onTertiary={() => void resolveConflictAndStart("parallel", rememberConcurrentChoice)}
988
- onSecondary={() => void resolveConflictAndStart("pause", rememberConcurrentChoice)}
989
- onPrimary={() => void resolveConflictAndStart("finish", rememberConcurrentChoice)}
1790
+ onTertiary={() =>
1791
+ void resolveConflictAndStart("parallel", rememberConcurrentChoice)
1792
+ }
1793
+ onSecondary={() =>
1794
+ void resolveConflictAndStart("pause", rememberConcurrentChoice)
1795
+ }
1796
+ onPrimary={() =>
1797
+ void resolveConflictAndStart("finish", rememberConcurrentChoice)
1798
+ }
990
1799
  />
991
1800
  </section>
992
1801
  );