@nightkatana/kronosys-app 1.0.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +81 -0
- package/app/api/action/route.ts +16 -0
- package/app/api/backup/route.ts +84 -0
- package/app/api/health/route.ts +22 -0
- package/app/api/state/route.ts +27 -0
- package/app/apple-icon.png +0 -0
- package/app/changelog/page.tsx +122 -0
- package/app/globals.css +210 -0
- package/app/guide/layout.tsx +11 -0
- package/app/guide/page.tsx +278 -0
- package/app/icon.png +0 -0
- package/app/layout.tsx +77 -0
- package/app/licenses/layout.tsx +11 -0
- package/app/licenses/page.tsx +246 -0
- package/app/manifest.ts +32 -0
- package/app/page.tsx +1610 -0
- package/app/reporting/page.tsx +2943 -0
- package/app/settings/layout.tsx +10 -0
- package/app/settings/page.tsx +3518 -0
- package/bin/kronosys.mjs +46 -0
- package/components/KronosysPackageVersionProvider.tsx +19 -0
- package/components/KronosysPayloadProvider.tsx +109 -0
- package/components/PwaRegister.tsx +25 -0
- package/components/SiteLegalFooter.tsx +21 -0
- package/components/ThemeProvider.tsx +78 -0
- package/components/dashboard/AppShellLiveSessionDrawer.tsx +394 -0
- package/components/dashboard/AppShellRouteNav.tsx +131 -0
- package/components/dashboard/AppVersionStamp.tsx +16 -0
- package/components/dashboard/DashboardCollapsibleSection.tsx +57 -0
- package/components/dashboard/DashboardColumnHintsBanner.tsx +159 -0
- package/components/dashboard/DashboardCommandCenter.tsx +470 -0
- package/components/dashboard/DashboardLangGateModal.tsx +118 -0
- package/components/dashboard/DashboardLoadingOverlay.tsx +42 -0
- package/components/dashboard/DashboardSimpleModal.tsx +337 -0
- package/components/dashboard/DashboardSuspenseFallback.tsx +52 -0
- package/components/dashboard/DashboardToastProvider.tsx +64 -0
- package/components/dashboard/DashboardTour.tsx +435 -0
- package/components/dashboard/DeferredDescriptionPopoverWrap.tsx +39 -0
- package/components/dashboard/DeleteSessionModal.tsx +130 -0
- package/components/dashboard/DescriptionTooltipPortaled.tsx +31 -0
- package/components/dashboard/GitIdentityQuickSetupModal.tsx +211 -0
- package/components/dashboard/HeaderIntegrationBadges.tsx +69 -0
- package/components/dashboard/InlineMetricHelpTrigger.tsx +102 -0
- package/components/dashboard/IssuePickerModal.tsx +168 -0
- package/components/dashboard/KronoFocusPanel.tsx +834 -0
- package/components/dashboard/KronosysDatetimePopoverField.tsx +357 -0
- package/components/dashboard/KronosysTimePopoverField.tsx +233 -0
- package/components/dashboard/LanguageMenu.tsx +123 -0
- package/components/dashboard/MongoMirrorSyncLine.tsx +57 -0
- package/components/dashboard/NewSessionScopeModal.tsx +410 -0
- package/components/dashboard/PageRefreshButton.tsx +130 -0
- package/components/dashboard/PlainHelpPopover.tsx +97 -0
- package/components/dashboard/ReportingPageToc.tsx +68 -0
- package/components/dashboard/ReportingTour.tsx +342 -0
- package/components/dashboard/SavedProjectPicker.tsx +92 -0
- package/components/dashboard/SavedTagPicker.tsx +115 -0
- package/components/dashboard/ScrollToTopFab.tsx +41 -0
- package/components/dashboard/SelectedSessionSidebarBlock.tsx +630 -0
- package/components/dashboard/SessionEndReasonEditor.tsx +114 -0
- package/components/dashboard/SessionListPanel.tsx +320 -0
- package/components/dashboard/SessionLocMetricsSection.tsx +128 -0
- package/components/dashboard/SettingsTagsProjectsSection.tsx +993 -0
- package/components/dashboard/SettingsTour.tsx +332 -0
- package/components/dashboard/TagPills.tsx +149 -0
- package/components/dashboard/TagsHelpTrigger.tsx +84 -0
- package/components/dashboard/TaskFocusPanel.tsx +1261 -0
- package/components/dashboard/TaskSessionLiveCard.tsx +832 -0
- package/components/dashboard/TaskSubtasksBlock.tsx +748 -0
- package/components/dashboard/ThemeToggle.test.tsx +26 -0
- package/components/dashboard/ThemeToggle.tsx +36 -0
- package/components/dashboard/UserGuideBodyText.tsx +62 -0
- package/components/dashboard/WorkspaceGitRepoCard.tsx +191 -0
- package/components/dashboard/taskFieldStyles.ts +139 -0
- package/components/dashboard/useAnchoredFloatingPortalStyle.ts +71 -0
- package/components/dashboard/useDescriptionPopoverAfterMs.ts +220 -0
- package/components/dashboard/useKronoFocusLiveSeconds.ts +36 -0
- package/components/dashboard/useSmoothStopwatchMs.ts +25 -0
- package/lib/appShellHeaderClasses.ts +12 -0
- package/lib/backupCsvExport.test.ts +149 -0
- package/lib/backupCsvExport.ts +392 -0
- package/lib/changelogCopy.ts +34 -0
- package/lib/concurrentTaskStartPreference.ts +29 -0
- package/lib/dashboardClockFormat.ts +13 -0
- package/lib/dashboardColumnChrome.ts +3 -0
- package/lib/dashboardColumnHintsStorage.ts +57 -0
- package/lib/dashboardCopy.ts +1831 -0
- package/lib/dashboardDetachedUrlHintStorage.ts +24 -0
- package/lib/dashboardGitIdentityBannerStorage.ts +36 -0
- package/lib/dashboardLangStorage.ts +72 -0
- package/lib/dashboardQuickSearch.ts +476 -0
- package/lib/dashboardQuickSearchQuery.test.ts +63 -0
- package/lib/dashboardQuickSearchQuery.ts +179 -0
- package/lib/dashboardSessionNav.ts +33 -0
- package/lib/dashboardShortcuts.ts +268 -0
- package/lib/dashboardTimeZone.ts +91 -0
- package/lib/dashboardTourStorage.ts +68 -0
- package/lib/dataDir.test.ts +87 -0
- package/lib/dataDir.ts +83 -0
- package/lib/devDataPreferenceFile.ts +55 -0
- package/lib/devDataRuntimeInfo.ts +34 -0
- package/lib/formatIsoShort.test.ts +46 -0
- package/lib/formatIsoShort.ts +29 -0
- package/lib/generatedUserChangelog.ts +34 -0
- package/lib/gitlabIssueSearch.ts +8 -0
- package/lib/kronoFocusDurationHistory.ts +71 -0
- package/lib/kronoFocusRhythm.test.ts +130 -0
- package/lib/kronoFocusRhythm.ts +46 -0
- package/lib/kronoFocusTimerUrgency.test.ts +74 -0
- package/lib/kronoFocusTimerUrgency.ts +24 -0
- package/lib/kronosysApi.ts +143 -0
- package/lib/legacyEditorPayloadKeys.ts +52 -0
- package/lib/legacyKronoFocusStorageKeys.test.ts +29 -0
- package/lib/legacyKronoFocusStorageKeys.ts +32 -0
- package/lib/licensesCopy.ts +128 -0
- package/lib/openPlainTextInNewTab.ts +49 -0
- package/lib/readKronosysPackageVersion.ts +10 -0
- package/lib/reportingAggregate.test.ts +325 -0
- package/lib/reportingAggregate.ts +819 -0
- package/lib/reportingDatePresets.ts +41 -0
- package/lib/reportingMetricHelp.ts +430 -0
- package/lib/reportingNonFinalIndicators.test.ts +157 -0
- package/lib/reportingNonFinalIndicators.ts +102 -0
- package/lib/reportingStrings.ts +491 -0
- package/lib/reportingTagWeekBreakdown.test.ts +141 -0
- package/lib/reportingTagWeekBreakdown.ts +181 -0
- package/lib/reportingWeekLayout.test.ts +239 -0
- package/lib/reportingWeekLayout.ts +313 -0
- package/lib/sessionAssiduity.test.ts +25 -0
- package/lib/sessionAssiduity.ts +33 -0
- package/lib/sessionEndReason.ts +55 -0
- package/lib/sessionEndWarnings.test.ts +200 -0
- package/lib/sessionEndWarnings.ts +125 -0
- package/lib/sessionListMerge.test.ts +101 -0
- package/lib/sessionListMerge.ts +70 -0
- package/lib/sessionTaskSidebarStats.test.ts +24 -0
- package/lib/sessionTaskSidebarStats.ts +54 -0
- package/lib/settingsCopy.ts +1276 -0
- package/lib/taskParsing.test.ts +153 -0
- package/lib/taskParsing.ts +737 -0
- package/lib/theme.ts +15 -0
- package/lib/translucentButtonClasses.ts +34 -0
- package/lib/usageProfile.test.ts +84 -0
- package/lib/usageProfile.ts +52 -0
- package/lib/userGuideCopy.ts +464 -0
- package/lib/workspaceLocDefaults.ts +21 -0
- package/next-env.d.ts +6 -0
- package/next.config.ts +15 -0
- package/package.json +87 -0
- package/postcss.config.mjs +12 -0
- package/public/apple-icon.png +0 -0
- package/public/favicon.ico +0 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/icon-192.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/icon.png +0 -0
- package/public/next.svg +1 -0
- package/public/sw.js +13 -0
- package/public/traceback.png +0 -0
- package/public/vercel.svg +1 -0
- package/public/window.svg +1 -0
- package/server/actionDispatch.test.ts +723 -0
- package/server/actionDispatch.ts +1476 -0
- package/server/actionTaskSession.test.ts +713 -0
- package/server/actionTaskSession.ts +717 -0
- package/server/db.ts +42 -0
- package/server/defaultCfg.ts +87 -0
- package/server/gitlabTokenStore.ts +34 -0
- package/server/kronoFocusHydrate.test.ts +142 -0
- package/server/kronoFocusHydrate.ts +69 -0
- package/server/kronoFocusMigrate.test.ts +53 -0
- package/server/kronoFocusMigrate.ts +78 -0
- package/server/mainTimerHydrate.test.ts +65 -0
- package/server/mainTimerHydrate.ts +53 -0
- package/server/payloadStore.test.ts +78 -0
- package/server/payloadStore.ts +83 -0
- package/server/sessionWallHydrate.test.ts +46 -0
- package/server/sessionWallHydrate.ts +88 -0
- package/tsconfig.json +41 -0
|
@@ -0,0 +1,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
|
+
}
|