@nightkatana/kronosys-app 1.0.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. package/README.md +81 -0
  2. package/app/api/action/route.ts +16 -0
  3. package/app/api/backup/route.ts +84 -0
  4. package/app/api/health/route.ts +22 -0
  5. package/app/api/state/route.ts +27 -0
  6. package/app/apple-icon.png +0 -0
  7. package/app/changelog/page.tsx +122 -0
  8. package/app/globals.css +210 -0
  9. package/app/guide/layout.tsx +11 -0
  10. package/app/guide/page.tsx +278 -0
  11. package/app/icon.png +0 -0
  12. package/app/layout.tsx +77 -0
  13. package/app/licenses/layout.tsx +11 -0
  14. package/app/licenses/page.tsx +246 -0
  15. package/app/manifest.ts +32 -0
  16. package/app/page.tsx +1610 -0
  17. package/app/reporting/page.tsx +2943 -0
  18. package/app/settings/layout.tsx +10 -0
  19. package/app/settings/page.tsx +3518 -0
  20. package/bin/kronosys.mjs +46 -0
  21. package/components/KronosysPackageVersionProvider.tsx +19 -0
  22. package/components/KronosysPayloadProvider.tsx +109 -0
  23. package/components/PwaRegister.tsx +25 -0
  24. package/components/SiteLegalFooter.tsx +21 -0
  25. package/components/ThemeProvider.tsx +78 -0
  26. package/components/dashboard/AppShellLiveSessionDrawer.tsx +394 -0
  27. package/components/dashboard/AppShellRouteNav.tsx +131 -0
  28. package/components/dashboard/AppVersionStamp.tsx +16 -0
  29. package/components/dashboard/DashboardCollapsibleSection.tsx +57 -0
  30. package/components/dashboard/DashboardColumnHintsBanner.tsx +159 -0
  31. package/components/dashboard/DashboardCommandCenter.tsx +470 -0
  32. package/components/dashboard/DashboardLangGateModal.tsx +118 -0
  33. package/components/dashboard/DashboardLoadingOverlay.tsx +42 -0
  34. package/components/dashboard/DashboardSimpleModal.tsx +337 -0
  35. package/components/dashboard/DashboardSuspenseFallback.tsx +52 -0
  36. package/components/dashboard/DashboardToastProvider.tsx +64 -0
  37. package/components/dashboard/DashboardTour.tsx +435 -0
  38. package/components/dashboard/DeferredDescriptionPopoverWrap.tsx +39 -0
  39. package/components/dashboard/DeleteSessionModal.tsx +130 -0
  40. package/components/dashboard/DescriptionTooltipPortaled.tsx +31 -0
  41. package/components/dashboard/GitIdentityQuickSetupModal.tsx +211 -0
  42. package/components/dashboard/HeaderIntegrationBadges.tsx +69 -0
  43. package/components/dashboard/InlineMetricHelpTrigger.tsx +102 -0
  44. package/components/dashboard/IssuePickerModal.tsx +168 -0
  45. package/components/dashboard/KronoFocusPanel.tsx +834 -0
  46. package/components/dashboard/KronosysDatetimePopoverField.tsx +357 -0
  47. package/components/dashboard/KronosysTimePopoverField.tsx +233 -0
  48. package/components/dashboard/LanguageMenu.tsx +123 -0
  49. package/components/dashboard/MongoMirrorSyncLine.tsx +57 -0
  50. package/components/dashboard/NewSessionScopeModal.tsx +410 -0
  51. package/components/dashboard/PageRefreshButton.tsx +130 -0
  52. package/components/dashboard/PlainHelpPopover.tsx +97 -0
  53. package/components/dashboard/ReportingPageToc.tsx +68 -0
  54. package/components/dashboard/ReportingTour.tsx +342 -0
  55. package/components/dashboard/SavedProjectPicker.tsx +92 -0
  56. package/components/dashboard/SavedTagPicker.tsx +115 -0
  57. package/components/dashboard/ScrollToTopFab.tsx +41 -0
  58. package/components/dashboard/SelectedSessionSidebarBlock.tsx +630 -0
  59. package/components/dashboard/SessionEndReasonEditor.tsx +114 -0
  60. package/components/dashboard/SessionListPanel.tsx +320 -0
  61. package/components/dashboard/SessionLocMetricsSection.tsx +128 -0
  62. package/components/dashboard/SettingsTagsProjectsSection.tsx +993 -0
  63. package/components/dashboard/SettingsTour.tsx +332 -0
  64. package/components/dashboard/TagPills.tsx +149 -0
  65. package/components/dashboard/TagsHelpTrigger.tsx +84 -0
  66. package/components/dashboard/TaskFocusPanel.tsx +1261 -0
  67. package/components/dashboard/TaskSessionLiveCard.tsx +832 -0
  68. package/components/dashboard/TaskSubtasksBlock.tsx +748 -0
  69. package/components/dashboard/ThemeToggle.test.tsx +26 -0
  70. package/components/dashboard/ThemeToggle.tsx +36 -0
  71. package/components/dashboard/UserGuideBodyText.tsx +62 -0
  72. package/components/dashboard/WorkspaceGitRepoCard.tsx +191 -0
  73. package/components/dashboard/taskFieldStyles.ts +139 -0
  74. package/components/dashboard/useAnchoredFloatingPortalStyle.ts +71 -0
  75. package/components/dashboard/useDescriptionPopoverAfterMs.ts +220 -0
  76. package/components/dashboard/useKronoFocusLiveSeconds.ts +36 -0
  77. package/components/dashboard/useSmoothStopwatchMs.ts +25 -0
  78. package/lib/appShellHeaderClasses.ts +12 -0
  79. package/lib/backupCsvExport.test.ts +149 -0
  80. package/lib/backupCsvExport.ts +392 -0
  81. package/lib/changelogCopy.ts +34 -0
  82. package/lib/concurrentTaskStartPreference.ts +29 -0
  83. package/lib/dashboardClockFormat.ts +13 -0
  84. package/lib/dashboardColumnChrome.ts +3 -0
  85. package/lib/dashboardColumnHintsStorage.ts +57 -0
  86. package/lib/dashboardCopy.ts +1831 -0
  87. package/lib/dashboardDetachedUrlHintStorage.ts +24 -0
  88. package/lib/dashboardGitIdentityBannerStorage.ts +36 -0
  89. package/lib/dashboardLangStorage.ts +72 -0
  90. package/lib/dashboardQuickSearch.ts +476 -0
  91. package/lib/dashboardQuickSearchQuery.test.ts +63 -0
  92. package/lib/dashboardQuickSearchQuery.ts +179 -0
  93. package/lib/dashboardSessionNav.ts +33 -0
  94. package/lib/dashboardShortcuts.ts +268 -0
  95. package/lib/dashboardTimeZone.ts +91 -0
  96. package/lib/dashboardTourStorage.ts +68 -0
  97. package/lib/dataDir.test.ts +87 -0
  98. package/lib/dataDir.ts +83 -0
  99. package/lib/devDataPreferenceFile.ts +55 -0
  100. package/lib/devDataRuntimeInfo.ts +34 -0
  101. package/lib/formatIsoShort.test.ts +46 -0
  102. package/lib/formatIsoShort.ts +29 -0
  103. package/lib/generatedUserChangelog.ts +34 -0
  104. package/lib/gitlabIssueSearch.ts +8 -0
  105. package/lib/kronoFocusDurationHistory.ts +71 -0
  106. package/lib/kronoFocusRhythm.test.ts +130 -0
  107. package/lib/kronoFocusRhythm.ts +46 -0
  108. package/lib/kronoFocusTimerUrgency.test.ts +74 -0
  109. package/lib/kronoFocusTimerUrgency.ts +24 -0
  110. package/lib/kronosysApi.ts +143 -0
  111. package/lib/legacyEditorPayloadKeys.ts +52 -0
  112. package/lib/legacyKronoFocusStorageKeys.test.ts +29 -0
  113. package/lib/legacyKronoFocusStorageKeys.ts +32 -0
  114. package/lib/licensesCopy.ts +128 -0
  115. package/lib/openPlainTextInNewTab.ts +49 -0
  116. package/lib/readKronosysPackageVersion.ts +10 -0
  117. package/lib/reportingAggregate.test.ts +325 -0
  118. package/lib/reportingAggregate.ts +819 -0
  119. package/lib/reportingDatePresets.ts +41 -0
  120. package/lib/reportingMetricHelp.ts +430 -0
  121. package/lib/reportingNonFinalIndicators.test.ts +157 -0
  122. package/lib/reportingNonFinalIndicators.ts +102 -0
  123. package/lib/reportingStrings.ts +491 -0
  124. package/lib/reportingTagWeekBreakdown.test.ts +141 -0
  125. package/lib/reportingTagWeekBreakdown.ts +181 -0
  126. package/lib/reportingWeekLayout.test.ts +239 -0
  127. package/lib/reportingWeekLayout.ts +313 -0
  128. package/lib/sessionAssiduity.test.ts +25 -0
  129. package/lib/sessionAssiduity.ts +33 -0
  130. package/lib/sessionEndReason.ts +55 -0
  131. package/lib/sessionEndWarnings.test.ts +200 -0
  132. package/lib/sessionEndWarnings.ts +125 -0
  133. package/lib/sessionListMerge.test.ts +101 -0
  134. package/lib/sessionListMerge.ts +70 -0
  135. package/lib/sessionTaskSidebarStats.test.ts +24 -0
  136. package/lib/sessionTaskSidebarStats.ts +54 -0
  137. package/lib/settingsCopy.ts +1276 -0
  138. package/lib/taskParsing.test.ts +153 -0
  139. package/lib/taskParsing.ts +737 -0
  140. package/lib/theme.ts +15 -0
  141. package/lib/translucentButtonClasses.ts +34 -0
  142. package/lib/usageProfile.test.ts +84 -0
  143. package/lib/usageProfile.ts +52 -0
  144. package/lib/userGuideCopy.ts +464 -0
  145. package/lib/workspaceLocDefaults.ts +21 -0
  146. package/next-env.d.ts +6 -0
  147. package/next.config.ts +15 -0
  148. package/package.json +87 -0
  149. package/postcss.config.mjs +12 -0
  150. package/public/apple-icon.png +0 -0
  151. package/public/favicon.ico +0 -0
  152. package/public/file.svg +1 -0
  153. package/public/globe.svg +1 -0
  154. package/public/icon-192.png +0 -0
  155. package/public/icon-512.png +0 -0
  156. package/public/icon.png +0 -0
  157. package/public/next.svg +1 -0
  158. package/public/sw.js +13 -0
  159. package/public/traceback.png +0 -0
  160. package/public/vercel.svg +1 -0
  161. package/public/window.svg +1 -0
  162. package/server/actionDispatch.test.ts +723 -0
  163. package/server/actionDispatch.ts +1476 -0
  164. package/server/actionTaskSession.test.ts +713 -0
  165. package/server/actionTaskSession.ts +717 -0
  166. package/server/db.ts +42 -0
  167. package/server/defaultCfg.ts +87 -0
  168. package/server/gitlabTokenStore.ts +34 -0
  169. package/server/kronoFocusHydrate.test.ts +142 -0
  170. package/server/kronoFocusHydrate.ts +69 -0
  171. package/server/kronoFocusMigrate.test.ts +53 -0
  172. package/server/kronoFocusMigrate.ts +78 -0
  173. package/server/mainTimerHydrate.test.ts +65 -0
  174. package/server/mainTimerHydrate.ts +53 -0
  175. package/server/payloadStore.test.ts +78 -0
  176. package/server/payloadStore.ts +83 -0
  177. package/server/sessionWallHydrate.test.ts +46 -0
  178. package/server/sessionWallHydrate.ts +88 -0
  179. package/tsconfig.json +41 -0
@@ -0,0 +1,834 @@
1
+ "use client";
2
+
3
+ import { useEffect, useId, useRef, useState } from "react";
4
+ import { Play, Pause, RotateCcw, Check, X, CircleHelp } from "lucide-react";
5
+ import type { DashboardStrings } from "@/lib/dashboardCopy";
6
+ import {
7
+ clearKronoFocusDurationHistory,
8
+ loadKronoFocusDurationHistory,
9
+ persistKronoFocusDurationHistory,
10
+ pushKronoFocusDurationHistory,
11
+ } from "@/lib/kronoFocusDurationHistory";
12
+ import {
13
+ clampBreakDurationSeconds,
14
+ clampWorkDurationSeconds,
15
+ KRONO_FOCUS_RHYTHM_PRESETS,
16
+ } from "@/lib/kronoFocusRhythm";
17
+ import { getKronoFocusTimerUrgency } from "@/lib/kronoFocusTimerUrgency";
18
+
19
+ import { useKronoFocusLiveSeconds } from "./useKronoFocusLiveSeconds";
20
+
21
+ type KronoFocusPanelState = {
22
+ mode: "work" | "break" | "longBreak";
23
+ status: "idle" | "running" | "paused";
24
+ timeLeftSeconds: number;
25
+ /** Aligné sur le serveur (`readPayload`) — décompte affiché entre deux rafraîchissements. */
26
+ kronoFocusDeadlineAtMs?: number;
27
+ workDurationSeconds?: number;
28
+ shortBreakDurationSeconds?: number;
29
+ longBreakDurationSeconds?: number;
30
+ linkedTaskId?: string;
31
+ linkedTaskName?: string;
32
+ };
33
+
34
+ function truncateLabel(s: string, max: number) {
35
+ const t = s.trim();
36
+ if (t.length <= max) return t;
37
+ return `${t.slice(0, max - 1)}…`;
38
+ }
39
+
40
+ const MAX_WORK_SEC = 8 * 3600;
41
+ /** Durée de travail KronoFocus par défaut (25 min). */
42
+ const DEFAULT_KRONO_FOCUS_WORK_SEC = 25 * 60;
43
+
44
+ /**
45
+ * Bandeau entête : repères proches des boutons du header, avec variantes clair / sombre.
46
+ */
47
+ /** Même gabarit que `AppShellRouteNav` (`size-10`) pour rester sur une ligne avec la nav. */
48
+ const kronoFocusControlBtnBaseHeader =
49
+ "box-border inline-flex size-10 shrink-0 cursor-pointer items-center justify-center rounded-lg border border-zinc-300 bg-white p-0 leading-none outline-none transition hover:border-zinc-400 [&_svg]:pointer-events-none [&_svg]:block [&_svg]:!h-5 [&_svg]:!w-5 [&_svg]:max-h-5 [&_svg]:max-w-5 [&_svg]:shrink-0 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-violet-500/55 dark:border-zinc-700 dark:bg-zinc-900 dark:hover:border-zinc-500";
50
+ const kronoFocusControlNeutralHeader = `${kronoFocusControlBtnBaseHeader} text-zinc-600 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800/80`;
51
+ const kronoFocusControlAccentHeader = `${kronoFocusControlBtnBaseHeader} text-violet-800 hover:bg-violet-50 dark:text-violet-200 dark:hover:bg-violet-950/55`;
52
+
53
+ const kronoFocusControlBtnBaseCard =
54
+ "box-border inline-flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center rounded-md border border-zinc-300 bg-white p-0 leading-none outline-none transition hover:border-zinc-400 [&_svg]:pointer-events-none [&_svg]:block [&_svg]:!h-[18px] [&_svg]:!w-[18px] [&_svg]:max-h-[18px] [&_svg]:max-w-[18px] [&_svg]:shrink-0 sm:h-10 sm:w-10 sm:[&_svg]:!h-5 sm:[&_svg]:!w-5 sm:[&_svg]:max-h-5 sm:[&_svg]:max-w-5 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-violet-500/55 dark:border-zinc-700 dark:bg-zinc-900 dark:hover:border-zinc-500";
55
+ const kronoFocusControlNeutralCard = `${kronoFocusControlBtnBaseCard} text-zinc-600 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800/80`;
56
+ const kronoFocusControlAccentCard = `${kronoFocusControlBtnBaseCard} text-violet-800 hover:bg-violet-50 dark:text-violet-200 dark:hover:bg-violet-950/55`;
57
+
58
+ /** Affichage minuteur et champ de durée : toujours `HH:MM:SS` (temps écoulé, pas une heure du jour). */
59
+ function formatSecondsAsHMS(totalSec: number): string {
60
+ const s = Math.max(0, Math.min(MAX_WORK_SEC, Math.floor(totalSec)));
61
+ const h = Math.floor(s / 3600);
62
+ const m = Math.floor((s % 3600) / 60);
63
+ const sec = s % 60;
64
+ return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}`;
65
+ }
66
+
67
+ /**
68
+ * Parse `HH:MM:SS` ou `HH:MM` (secondes = 0). Espaces autour des segments tolérés.
69
+ */
70
+ function parseTimeInputToSeconds(value: string): number | null {
71
+ const trimmed = value.trim();
72
+ const parts = trimmed.split(":").map((p) => p.trim());
73
+ if (parts.length === 2) {
74
+ const h = Number.parseInt(parts[0], 10);
75
+ const m = Number.parseInt(parts[1], 10);
76
+ if (Number.isNaN(h) || Number.isNaN(m) || m < 0 || m > 59 || h < 0) return null;
77
+ return h * 3600 + m * 60;
78
+ }
79
+ if (parts.length === 3) {
80
+ const h = Number.parseInt(parts[0], 10);
81
+ const m = Number.parseInt(parts[1], 10);
82
+ const sec = Number.parseInt(parts[2], 10);
83
+ if (
84
+ Number.isNaN(h) ||
85
+ Number.isNaN(m) ||
86
+ Number.isNaN(sec) ||
87
+ m < 0 ||
88
+ m > 59 ||
89
+ sec < 0 ||
90
+ sec > 59 ||
91
+ h < 0
92
+ ) {
93
+ return null;
94
+ }
95
+ return h * 3600 + m * 60 + sec;
96
+ }
97
+ return null;
98
+ }
99
+
100
+ const kronoFocusDurationHistoryChipClass =
101
+ "rounded-md border border-zinc-300 bg-white px-2 py-1 font-mono text-[0.65rem] leading-none text-zinc-700 tabular-nums hover:border-zinc-400 hover:bg-zinc-100 dark:border-zinc-600/90 dark:bg-zinc-950/80 dark:text-zinc-300 dark:hover:border-zinc-500 dark:hover:bg-zinc-800";
102
+
103
+ function KronoFocusDurationPopoverFields({
104
+ inputId,
105
+ t,
106
+ draftTime,
107
+ setDraftTime,
108
+ draftShortBreakMin,
109
+ setDraftShortBreakMin,
110
+ draftLongBreakMin,
111
+ setDraftLongBreakMin,
112
+ onPickPreset,
113
+ customHistory,
114
+ onPickDefault,
115
+ onApply,
116
+ onCancel,
117
+ onClearHistory,
118
+ }: {
119
+ inputId: string;
120
+ t: DashboardStrings;
121
+ draftTime: string;
122
+ setDraftTime: (v: string) => void;
123
+ draftShortBreakMin: string;
124
+ setDraftShortBreakMin: (v: string) => void;
125
+ draftLongBreakMin: string;
126
+ setDraftLongBreakMin: (v: string) => void;
127
+ onPickPreset: (preset: (typeof KRONO_FOCUS_RHYTHM_PRESETS)[number]) => void;
128
+ customHistory: number[];
129
+ onPickDefault: () => void;
130
+ onApply: () => void;
131
+ onCancel: () => void;
132
+ onClearHistory: () => void;
133
+ }) {
134
+ const shortId = `${inputId}-short-break-min`;
135
+ const longId = `${inputId}-long-break-min`;
136
+
137
+ return (
138
+ <>
139
+ <div>
140
+ <p className="text-[0.6rem] font-medium uppercase tracking-wide text-zinc-600 dark:text-zinc-500">
141
+ {t.kronoFocusRhythmPresetsHeading}
142
+ </p>
143
+ <div className="mt-1.5 flex flex-wrap gap-1.5">
144
+ {KRONO_FOCUS_RHYTHM_PRESETS.map((preset, i) => (
145
+ <button
146
+ key={preset.id}
147
+ type="button"
148
+ className="rounded-md border border-violet-300/80 bg-violet-50/90 px-2 py-1 text-[0.65rem] font-semibold text-violet-950 hover:bg-violet-100 dark:border-violet-700/60 dark:bg-violet-950/40 dark:text-violet-100 dark:hover:bg-violet-900/45"
149
+ title={t.kronoFocusRhythmPresetTitles[i]}
150
+ aria-label={t.kronoFocusRhythmPresetTitles[i]}
151
+ onClick={() => onPickPreset(preset)}
152
+ >
153
+ {t.kronoFocusRhythmPresetLabels[i]}
154
+ </button>
155
+ ))}
156
+ </div>
157
+ </div>
158
+ <hr className="my-2.5 border-zinc-200 dark:border-zinc-600" />
159
+ <div className="flex items-start justify-between gap-2">
160
+ <label
161
+ htmlFor={inputId}
162
+ className="block flex-1 text-[0.65rem] font-medium uppercase tracking-wide text-zinc-600 dark:text-zinc-400"
163
+ >
164
+ {t.kronoFocusDurationPickerLabel}
165
+ </label>
166
+ <KronoFocusDurationHelpTrigger t={t} />
167
+ </div>
168
+ <input
169
+ id={inputId}
170
+ type="text"
171
+ inputMode="text"
172
+ autoComplete="off"
173
+ spellCheck={false}
174
+ placeholder={t.kronoFocusDurationInputPlaceholder}
175
+ value={draftTime}
176
+ onChange={(e) => setDraftTime(e.target.value)}
177
+ title={t.kronoFocusDurationPickerLabel}
178
+ className="mt-2 w-full rounded-md border border-zinc-300 bg-white px-2 py-1.5 font-mono text-sm text-zinc-900 placeholder:text-zinc-400 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100 dark:placeholder:text-zinc-600"
179
+ />
180
+ <div className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-2">
181
+ <div>
182
+ <label
183
+ htmlFor={shortId}
184
+ className="block text-[0.6rem] font-medium uppercase tracking-wide text-zinc-600 dark:text-zinc-500"
185
+ >
186
+ {t.kronoFocusShortBreakMinutesLabel}
187
+ </label>
188
+ <input
189
+ id={shortId}
190
+ type="text"
191
+ inputMode="numeric"
192
+ autoComplete="off"
193
+ spellCheck={false}
194
+ value={draftShortBreakMin}
195
+ onChange={(e) => setDraftShortBreakMin(e.target.value)}
196
+ className="mt-1 w-full rounded-md border border-zinc-300 bg-white px-2 py-1.5 font-mono text-sm text-zinc-900 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100"
197
+ />
198
+ </div>
199
+ <div>
200
+ <label
201
+ htmlFor={longId}
202
+ className="block text-[0.6rem] font-medium uppercase tracking-wide text-zinc-600 dark:text-zinc-500"
203
+ >
204
+ {t.kronoFocusLongBreakMinutesLabel}
205
+ </label>
206
+ <input
207
+ id={longId}
208
+ type="text"
209
+ inputMode="numeric"
210
+ autoComplete="off"
211
+ spellCheck={false}
212
+ value={draftLongBreakMin}
213
+ onChange={(e) => setDraftLongBreakMin(e.target.value)}
214
+ className="mt-1 w-full rounded-md border border-zinc-300 bg-white px-2 py-1.5 font-mono text-sm text-zinc-900 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100"
215
+ />
216
+ </div>
217
+ </div>
218
+ <p className="mt-1.5 text-[0.6rem] leading-snug text-zinc-500 dark:text-zinc-500">{t.kronoFocusRhythmBreaksMinutesHint}</p>
219
+ <button
220
+ type="button"
221
+ className="mt-2 w-full rounded-md border border-zinc-300 px-2 py-1.5 text-left text-[0.7rem] font-medium text-zinc-600 hover:bg-zinc-100 hover:text-zinc-900 dark:border-zinc-600 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-200"
222
+ title={t.kronoFocusDefaultWorkDuration}
223
+ aria-label={t.kronoFocusDefaultWorkDuration}
224
+ onClick={onPickDefault}
225
+ >
226
+ {t.kronoFocusDefaultWorkDuration}
227
+ </button>
228
+ {customHistory.length > 0 ? (
229
+ <div className="mt-2">
230
+ <div className="mb-1.5 flex items-start justify-between gap-2">
231
+ <p className="min-w-0 flex-1 text-[0.6rem] font-medium uppercase tracking-wide text-zinc-600 dark:text-zinc-500">
232
+ {t.kronoFocusDurationHistoryLabel}
233
+ </p>
234
+ <button
235
+ type="button"
236
+ className="shrink-0 rounded border border-zinc-300 bg-zinc-100 px-1.5 py-0.5 text-[0.6rem] font-medium text-zinc-600 hover:border-zinc-400 hover:bg-zinc-200/80 hover:text-zinc-900 dark:border-zinc-700 dark:bg-zinc-950/60 dark:text-zinc-400 dark:hover:border-zinc-600 dark:hover:bg-zinc-800 dark:hover:text-zinc-200"
237
+ title={t.kronoFocusDurationHistoryClearTitle}
238
+ aria-label={t.kronoFocusDurationHistoryClearTitle}
239
+ onClick={onClearHistory}
240
+ >
241
+ {t.kronoFocusDurationHistoryClear}
242
+ </button>
243
+ </div>
244
+ <div className="flex max-h-[6.5rem] flex-wrap gap-1.5 overflow-y-auto pr-0.5">
245
+ {customHistory.map((sec) => {
246
+ const label = formatSecondsAsHMS(sec);
247
+ return (
248
+ <button
249
+ key={sec}
250
+ type="button"
251
+ className={kronoFocusDurationHistoryChipClass}
252
+ title={label}
253
+ aria-label={t.kronoFocusDurationHistoryPickAria.replace("{time}", label)}
254
+ onClick={() => setDraftTime(label)}
255
+ >
256
+ {label}
257
+ </button>
258
+ );
259
+ })}
260
+ </div>
261
+ </div>
262
+ ) : null}
263
+ <div className="mt-3 flex justify-end gap-2">
264
+ <button
265
+ type="button"
266
+ className="rounded-md border border-zinc-300 p-1.5 text-zinc-600 hover:bg-zinc-100 dark:border-zinc-600 dark:text-zinc-300 dark:hover:bg-zinc-800"
267
+ title={t.kronoFocusCancelDurationEdit}
268
+ aria-label={t.kronoFocusCancelDurationEdit}
269
+ onClick={onCancel}
270
+ >
271
+ <X size={18} strokeWidth={2} aria-hidden />
272
+ </button>
273
+ <button
274
+ type="button"
275
+ className="rounded-md border border-violet-500/45 bg-violet-50 p-1.5 text-violet-900 hover:bg-violet-100/90 dark:border-violet-500/50 dark:bg-violet-950/40 dark:text-violet-100 dark:hover:bg-violet-900/50"
276
+ title={t.kronoFocusApplyDuration}
277
+ aria-label={t.kronoFocusApplyDuration}
278
+ onClick={() => void onApply()}
279
+ >
280
+ <Check size={18} strokeWidth={2} aria-hidden />
281
+ </button>
282
+ </div>
283
+ </>
284
+ );
285
+ }
286
+
287
+ function KronoFocusPanelHelpTrigger({ t }: { t: DashboardStrings }) {
288
+ const subtitle = (t.kronoFocusStandaloneSubtitle ?? "").trim();
289
+ const note = (t.kronoFocusAutoRefreshNote ?? "").trim();
290
+ const hasBody = subtitle.length > 0 || note.length > 0;
291
+
292
+ const [open, setOpen] = useState(false);
293
+ const rootRef = useRef<HTMLDivElement>(null);
294
+ const id = useId();
295
+
296
+ useEffect(() => {
297
+ if (!open) {
298
+ return;
299
+ }
300
+ const onDoc = (e: MouseEvent) => {
301
+ if (!rootRef.current?.contains(e.target as Node)) {
302
+ setOpen(false);
303
+ }
304
+ };
305
+ document.addEventListener("mousedown", onDoc);
306
+ return () => document.removeEventListener("mousedown", onDoc);
307
+ }, [open]);
308
+
309
+ if (!hasBody) {
310
+ return null;
311
+ }
312
+
313
+ return (
314
+ <div className="relative inline-flex shrink-0" ref={rootRef}>
315
+ <button
316
+ type="button"
317
+ className="rounded p-0.5 text-zinc-500 hover:bg-zinc-200/90 hover:text-zinc-800 dark:hover:bg-zinc-800 dark:hover:text-zinc-300"
318
+ aria-label={t.kronoFocusPanelHelpAriaLabel}
319
+ aria-expanded={open ? "true" : "false"}
320
+ aria-controls={`${id}-kronoFocus-panel-help`}
321
+ onClick={() => setOpen((o) => !o)}
322
+ >
323
+ <CircleHelp size={15} strokeWidth={1.75} aria-hidden />
324
+ </button>
325
+ {open ? (
326
+ <div
327
+ id={`${id}-kronoFocus-panel-help`}
328
+ className="absolute left-0 top-full z-[60] mt-1 w-[min(calc(100vw-2rem),18rem)] rounded-lg border border-zinc-200 bg-white p-2.5 text-left shadow-lg dark:border-zinc-600 dark:bg-zinc-900"
329
+ role="region"
330
+ aria-label={t.kronoFocusPanelHelpAriaLabel}
331
+ >
332
+ {subtitle ? (
333
+ <p className="text-[0.7rem] leading-snug text-zinc-700 dark:text-zinc-300">{subtitle}</p>
334
+ ) : null}
335
+ {note ? (
336
+ <p
337
+ className={`text-[0.7rem] leading-snug text-zinc-600 dark:text-zinc-400 ${subtitle ? "mt-2" : ""}`}
338
+ >
339
+ {note}
340
+ </p>
341
+ ) : null}
342
+ </div>
343
+ ) : null}
344
+ </div>
345
+ );
346
+ }
347
+
348
+ function KronoFocusDurationHelpTrigger({ t }: { t: DashboardStrings }) {
349
+ const [open, setOpen] = useState(false);
350
+ const rootRef = useRef<HTMLDivElement>(null);
351
+ const id = useId();
352
+
353
+ useEffect(() => {
354
+ if (!open) {
355
+ return;
356
+ }
357
+ const onDoc = (e: MouseEvent) => {
358
+ if (!rootRef.current?.contains(e.target as Node)) {
359
+ setOpen(false);
360
+ }
361
+ };
362
+ document.addEventListener("mousedown", onDoc);
363
+ return () => document.removeEventListener("mousedown", onDoc);
364
+ }, [open]);
365
+
366
+ return (
367
+ <div className="relative inline-flex shrink-0" ref={rootRef}>
368
+ <button
369
+ type="button"
370
+ className="rounded p-0.5 text-zinc-500 hover:bg-zinc-200/90 hover:text-zinc-800 dark:hover:bg-zinc-800 dark:hover:text-zinc-300"
371
+ aria-label={t.kronoFocusDurationHelpAriaLabel}
372
+ aria-expanded={open ? "true" : "false"}
373
+ aria-controls={`${id}-kronoFocus-duration-help`}
374
+ onClick={() => setOpen((o) => !o)}
375
+ >
376
+ <CircleHelp size={15} strokeWidth={1.75} aria-hidden />
377
+ </button>
378
+ {open ? (
379
+ <div
380
+ id={`${id}-kronoFocus-duration-help`}
381
+ className="absolute right-0 top-full z-[60] mt-1 w-[min(calc(100vw-2rem),14rem)] rounded-lg border border-zinc-200 bg-white p-2.5 text-left shadow-lg dark:border-zinc-600 dark:bg-zinc-900"
382
+ role="region"
383
+ aria-label={t.kronoFocusDurationHelpAriaLabel}
384
+ >
385
+ <p className="text-[0.7rem] leading-snug text-zinc-700 dark:text-zinc-300">{t.kronoFocusDurationHelpBody}</p>
386
+ </div>
387
+ ) : null}
388
+ </div>
389
+ );
390
+ }
391
+
392
+ export function KronoFocusPanel({
393
+ kronoFocus,
394
+ liveActiveTaskIds,
395
+ t,
396
+ post,
397
+ viewingArchive,
398
+ variant = "default",
399
+ }: {
400
+ kronoFocus: KronoFocusPanelState | undefined;
401
+ /** Tâches au minuteur en session live (ancre `#kronosys-active-task-<id>` si le KronoFocus est lié). */
402
+ liveActiveTaskIds?: string[];
403
+ t: DashboardStrings;
404
+ post: (body: Record<string, unknown>) => Promise<void>;
405
+ /** True when the user is browsing a past session — timer UI still targets the live session */
406
+ viewingArchive?: boolean;
407
+ /** `headerBar` : bandeau compact pour l’entête (grands écrans) ; `default` : carte complète */
408
+ variant?: "default" | "headerBar";
409
+ }) {
410
+ const serverSecs = kronoFocus?.timeLeftSeconds ?? DEFAULT_KRONO_FOCUS_WORK_SEC;
411
+ const mode = kronoFocus?.mode ?? "work";
412
+ const status = kronoFocus?.status ?? "idle";
413
+ const displaySecs = useKronoFocusLiveSeconds(serverSecs, status, kronoFocus?.kronoFocusDeadlineAtMs);
414
+ const clockDisplay = formatSecondsAsHMS(displaySecs);
415
+ const canEditWorkDuration =
416
+ !viewingArchive && status === "idle" && mode === "work";
417
+
418
+ const [durationPopoverOpen, setDurationPopoverOpen] = useState(false);
419
+ const [draftTime, setDraftTime] = useState(() => formatSecondsAsHMS(serverSecs));
420
+ const [draftShortBreakMin, setDraftShortBreakMin] = useState("5");
421
+ const [draftLongBreakMin, setDraftLongBreakMin] = useState("15");
422
+ const [customDurationHistory, setCustomDurationHistory] = useState<number[]>([]);
423
+ const popoverRef = useRef<HTMLDivElement>(null);
424
+ const prevKronoFocusStatusRef = useRef(status);
425
+ const [showStartPulse, setShowStartPulse] = useState(false);
426
+
427
+ useEffect(() => {
428
+ setCustomDurationHistory(loadKronoFocusDurationHistory(DEFAULT_KRONO_FOCUS_WORK_SEC));
429
+ }, []);
430
+
431
+ useEffect(() => {
432
+ if (!canEditWorkDuration) {
433
+ setDurationPopoverOpen(false);
434
+ }
435
+ }, [canEditWorkDuration]);
436
+
437
+ useEffect(() => {
438
+ if (!durationPopoverOpen) {
439
+ return;
440
+ }
441
+ const workSec =
442
+ typeof kronoFocus?.workDurationSeconds === "number" && Number.isFinite(kronoFocus.workDurationSeconds)
443
+ ? kronoFocus.workDurationSeconds
444
+ : serverSecs;
445
+ setDraftTime(formatSecondsAsHMS(workSec));
446
+ const sb = kronoFocus?.shortBreakDurationSeconds ?? 5 * 60;
447
+ const lb = kronoFocus?.longBreakDurationSeconds ?? 15 * 60;
448
+ setDraftShortBreakMin(String(Math.max(1, Math.round(sb / 60))));
449
+ setDraftLongBreakMin(String(Math.max(1, Math.round(lb / 60))));
450
+ }, [
451
+ durationPopoverOpen,
452
+ kronoFocus?.workDurationSeconds,
453
+ kronoFocus?.shortBreakDurationSeconds,
454
+ kronoFocus?.longBreakDurationSeconds,
455
+ serverSecs,
456
+ ]);
457
+
458
+ useEffect(() => {
459
+ const prev = prevKronoFocusStatusRef.current;
460
+ if (status === "running" && prev === "idle") {
461
+ setShowStartPulse(true);
462
+ }
463
+ prevKronoFocusStatusRef.current = status;
464
+ }, [status]);
465
+
466
+ useEffect(() => {
467
+ if (!showStartPulse) {
468
+ return;
469
+ }
470
+ const id = window.setTimeout(() => setShowStartPulse(false), 1000);
471
+ return () => window.clearTimeout(id);
472
+ }, [showStartPulse]);
473
+
474
+ useEffect(() => {
475
+ if (!durationPopoverOpen) return;
476
+ const onDocMouseDown = (e: MouseEvent) => {
477
+ const el = popoverRef.current;
478
+ if (el && !el.contains(e.target as Node)) {
479
+ setDurationPopoverOpen(false);
480
+ }
481
+ };
482
+ const onKey = (e: KeyboardEvent) => {
483
+ if (e.key === "Escape") setDurationPopoverOpen(false);
484
+ };
485
+ document.addEventListener("mousedown", onDocMouseDown);
486
+ document.addEventListener("keydown", onKey);
487
+ return () => {
488
+ document.removeEventListener("mousedown", onDocMouseDown);
489
+ document.removeEventListener("keydown", onKey);
490
+ };
491
+ }, [durationPopoverOpen]);
492
+
493
+ const applyRhythmPreset = (preset: (typeof KRONO_FOCUS_RHYTHM_PRESETS)[number]) => {
494
+ void post({
495
+ type: "setKronoFocusDurations",
496
+ workSeconds: preset.workSeconds,
497
+ shortBreakSeconds: preset.shortBreakSeconds,
498
+ longBreakSeconds: preset.longBreakSeconds,
499
+ });
500
+ setCustomDurationHistory((prev) => {
501
+ const next = pushKronoFocusDurationHistory(prev, preset.workSeconds, DEFAULT_KRONO_FOCUS_WORK_SEC);
502
+ persistKronoFocusDurationHistory(next);
503
+ return next;
504
+ });
505
+ setDurationPopoverOpen(false);
506
+ };
507
+
508
+ const applyDraftDuration = () => {
509
+ const parsed = parseTimeInputToSeconds(draftTime);
510
+ const workSeconds =
511
+ parsed !== null
512
+ ? clampWorkDurationSeconds(parsed)
513
+ : clampWorkDurationSeconds(
514
+ typeof kronoFocus?.workDurationSeconds === "number" && Number.isFinite(kronoFocus.workDurationSeconds)
515
+ ? kronoFocus.workDurationSeconds
516
+ : serverSecs,
517
+ );
518
+ const sm = Number.parseInt(draftShortBreakMin.trim(), 10);
519
+ const lm = Number.parseInt(draftLongBreakMin.trim(), 10);
520
+ const shortBreakSeconds = clampBreakDurationSeconds(
521
+ Number.isFinite(sm) && sm >= 1 ? sm * 60 : (kronoFocus?.shortBreakDurationSeconds ?? 5 * 60),
522
+ );
523
+ const longBreakSeconds = clampBreakDurationSeconds(
524
+ Number.isFinite(lm) && lm >= 1 ? lm * 60 : (kronoFocus?.longBreakDurationSeconds ?? 15 * 60),
525
+ );
526
+ void post({
527
+ type: "setKronoFocusDurations",
528
+ workSeconds,
529
+ shortBreakSeconds,
530
+ longBreakSeconds,
531
+ });
532
+ setCustomDurationHistory((prev) => {
533
+ const next = pushKronoFocusDurationHistory(prev, workSeconds, DEFAULT_KRONO_FOCUS_WORK_SEC);
534
+ persistKronoFocusDurationHistory(next);
535
+ return next;
536
+ });
537
+ setDurationPopoverOpen(false);
538
+ };
539
+
540
+ const clearDurationHistory = () => {
541
+ clearKronoFocusDurationHistory();
542
+ setCustomDurationHistory([]);
543
+ };
544
+
545
+ const modeLabel =
546
+ mode === "work" ? t.workMode : mode === "break" ? t.breakMode : t.longBreakMode;
547
+
548
+ const linkedId = kronoFocus?.linkedTaskId;
549
+ const linkedName = kronoFocus?.linkedTaskName?.trim();
550
+ const showTaskLink =
551
+ !!linkedId && (status === "running" || status === "paused");
552
+ const ids = liveActiveTaskIds ?? [];
553
+ const taskHref =
554
+ linkedId && ids.includes(linkedId)
555
+ ? `#kronosys-active-task-${linkedId}`
556
+ : "#kronosys-task-focus";
557
+
558
+ const { blink: timerBlink, urgentHighlight: timerUrgentHighlight } = getKronoFocusTimerUrgency({
559
+ timeLeftSeconds: displaySecs,
560
+ mode,
561
+ status,
562
+ });
563
+
564
+ const clearStartPulse = () => setShowStartPulse(false);
565
+
566
+ const timeSize =
567
+ variant === "headerBar" ? "text-lg xl:text-xl" : "text-3xl sm:text-4xl";
568
+ /** Libellé de phase : même échelle que le minuteur ; bandeau entête = une ligne, troncature serrée. */
569
+ const phaseLabelClassName =
570
+ variant === "headerBar"
571
+ ? `${timeSize} min-w-0 max-w-[8.5rem] shrink truncate text-center font-bold leading-none tracking-tight text-zinc-800 dark:text-zinc-100 sm:max-w-[11rem] xl:max-w-[13rem]`
572
+ : `${timeSize} max-w-[min(100%,24rem)] text-center font-bold leading-none tracking-tight text-zinc-800 dark:text-zinc-100 sm:max-w-[30rem]`;
573
+ /** Largeur stable pour `HH:MM:SS` jusqu’à 8 h (08:00:00). */
574
+ const timeSlotClass =
575
+ variant === "headerBar"
576
+ ? "inline-block min-w-[9ch] text-center tabular-nums font-mono font-bold sm:min-w-[10ch]"
577
+ : "inline-block min-w-[11ch] text-center tabular-nums font-mono font-bold sm:min-w-[12ch]";
578
+ const timeClassName = `${timeSize} ${
579
+ timerUrgentHighlight
580
+ ? "text-violet-600 dark:text-violet-400"
581
+ : "text-zinc-900 dark:text-zinc-50"
582
+ } ${
583
+ showStartPulse
584
+ ? "kronosys-krono-focus-start-pulse"
585
+ : timerBlink
586
+ ? "kronosys-krono-focus-time-blink"
587
+ : ""
588
+ }`;
589
+
590
+ const durationPopoverAlign =
591
+ "left-1/2 top-full z-50 mt-2 w-[min(100vw-2rem,20rem)] -translate-x-1/2";
592
+
593
+ if (variant === "headerBar") {
594
+ return (
595
+ <section
596
+ className="flex w-full min-w-0 items-center justify-center rounded-lg border border-violet-200/90 bg-gradient-to-r from-violet-50/95 via-white to-violet-50/80 px-2 py-1 shadow-sm shadow-violet-900/10 sm:px-3 xl:h-10 xl:min-h-0 xl:py-0.5 dark:border-violet-900/40 dark:from-violet-950/35 dark:via-zinc-900/50 dark:to-violet-950/30 dark:shadow-black/15"
597
+ aria-label={t.kronoFocusTitle}
598
+ >
599
+ <div
600
+ ref={popoverRef}
601
+ className="relative flex w-full min-w-0 max-w-6xl flex-wrap items-center justify-center gap-x-1.5 gap-y-1 sm:gap-x-2 xl:flex-nowrap xl:gap-y-0"
602
+ >
603
+ {viewingArchive ? (
604
+ <span
605
+ className="max-w-[5rem] shrink-0 truncate text-[0.55rem] font-medium leading-none text-violet-900/90 sm:max-w-[7rem] dark:text-violet-200/85"
606
+ title={t.kronoFocusLiveWhileViewingArchive}
607
+ >
608
+ {t.kronoFocusLiveWhileViewingArchive}
609
+ </span>
610
+ ) : null}
611
+ <div className="shrink-0">
612
+ <KronoFocusPanelHelpTrigger t={t} />
613
+ </div>
614
+ <div className={phaseLabelClassName}>{modeLabel}</div>
615
+ {canEditWorkDuration ? (
616
+ <button
617
+ type="button"
618
+ className={`${timeSlotClass} rounded-md px-0.5 py-0 leading-none ${timeClassName} cursor-pointer outline-none transition-colors hover:bg-zinc-200/60 dark:hover:bg-zinc-800/50 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-violet-500/55`}
619
+ title={t.kronoFocusEditDurationTitle}
620
+ aria-label={t.kronoFocusEditDurationTitle}
621
+ aria-expanded={durationPopoverOpen ? "true" : "false"}
622
+ onClick={() => setDurationPopoverOpen((v) => !v)}
623
+ onAnimationEnd={clearStartPulse}
624
+ >
625
+ {clockDisplay}
626
+ </button>
627
+ ) : (
628
+ <div className={`${timeSlotClass} shrink-0 leading-none ${timeClassName}`} onAnimationEnd={clearStartPulse}>
629
+ {clockDisplay}
630
+ </div>
631
+ )}
632
+ {!viewingArchive ? (
633
+ <div className="flex shrink-0 items-center gap-1.5">
634
+ {status === "running" ? (
635
+ <button
636
+ type="button"
637
+ className={kronoFocusControlNeutralHeader}
638
+ title={t.kronoFocusPause}
639
+ aria-label={t.kronoFocusPause}
640
+ onClick={() => void post({ type: "pauseKronoFocus" })}
641
+ >
642
+ <Pause strokeWidth={2} aria-hidden />
643
+ </button>
644
+ ) : (
645
+ <button
646
+ type="button"
647
+ className={kronoFocusControlAccentHeader}
648
+ title={t.kronoFocusStart}
649
+ aria-label={t.kronoFocusStart}
650
+ onClick={() => void post({ type: "startKronoFocus" })}
651
+ >
652
+ <Play strokeWidth={2} aria-hidden />
653
+ </button>
654
+ )}
655
+ <button
656
+ type="button"
657
+ className={kronoFocusControlNeutralHeader}
658
+ title={t.kronoFocusReset}
659
+ aria-label={t.kronoFocusReset}
660
+ onClick={() => void post({ type: "resetKronoFocus" })}
661
+ >
662
+ <RotateCcw strokeWidth={2} aria-hidden />
663
+ </button>
664
+ </div>
665
+ ) : null}
666
+ {showTaskLink ? (
667
+ <div className="hidden min-w-0 max-w-[min(38vw,12rem)] shrink border-l border-violet-200/70 pl-2 sm:block dark:border-violet-800/50">
668
+ <p
669
+ className="min-w-0 truncate text-left text-[0.65rem] leading-none text-zinc-600 dark:text-zinc-400"
670
+ title={`${t.kronoFocusLinkedTaskIntro} ${linkedName || "—"}`}
671
+ >
672
+ <span className="text-zinc-500">{t.kronoFocusLinkedTaskIntro} </span>
673
+ <a
674
+ href={taskHref}
675
+ className="font-medium text-violet-800 underline decoration-violet-500/45 underline-offset-2 hover:text-violet-700 dark:text-violet-400/95 dark:decoration-violet-500/50 dark:hover:text-violet-300"
676
+ >
677
+ {linkedName || "—"}
678
+ </a>
679
+ </p>
680
+ </div>
681
+ ) : null}
682
+
683
+ {durationPopoverOpen && canEditWorkDuration && (
684
+ <div
685
+ className={`absolute ${durationPopoverAlign} rounded-lg border border-zinc-200 bg-white p-3 text-left shadow-xl dark:border-zinc-600 dark:bg-zinc-900`}
686
+ role="dialog"
687
+ aria-label={t.kronoFocusDurationPickerLabel}
688
+ >
689
+ <KronoFocusDurationPopoverFields
690
+ inputId="kronosys-krono-focus-duration-hb"
691
+ t={t}
692
+ draftTime={draftTime}
693
+ setDraftTime={setDraftTime}
694
+ draftShortBreakMin={draftShortBreakMin}
695
+ setDraftShortBreakMin={setDraftShortBreakMin}
696
+ draftLongBreakMin={draftLongBreakMin}
697
+ setDraftLongBreakMin={setDraftLongBreakMin}
698
+ onPickPreset={applyRhythmPreset}
699
+ customHistory={customDurationHistory}
700
+ onPickDefault={() => {
701
+ setDraftTime(formatSecondsAsHMS(DEFAULT_KRONO_FOCUS_WORK_SEC));
702
+ setDraftShortBreakMin("5");
703
+ setDraftLongBreakMin("15");
704
+ }}
705
+ onApply={applyDraftDuration}
706
+ onCancel={() => setDurationPopoverOpen(false)}
707
+ onClearHistory={clearDurationHistory}
708
+ />
709
+ </div>
710
+ )}
711
+ </div>
712
+ </section>
713
+ );
714
+ }
715
+
716
+ return (
717
+ <section
718
+ className="rounded-xl border border-violet-200/80 bg-gradient-to-r from-violet-50/90 via-white to-zinc-50/95 p-5 shadow-sm shadow-violet-900/10 sm:p-6 dark:border-violet-900/35 dark:from-violet-950/40 dark:via-zinc-900/60 dark:to-zinc-900/40 dark:shadow-black/20"
719
+ aria-label={t.kronoFocusTitle}
720
+ >
721
+ <div className="mx-auto flex w-full max-w-5xl flex-col items-center gap-4">
722
+ <div className="flex w-full flex-wrap items-center justify-center gap-3 sm:justify-between">
723
+ <div className="flex min-w-0 flex-wrap items-center justify-center gap-2">
724
+ {viewingArchive ? (
725
+ <p className="max-w-[min(100%,22rem)] text-center text-[0.7rem] leading-snug text-violet-900/85 dark:text-violet-200/75">
726
+ {t.kronoFocusLiveWhileViewingArchive}
727
+ </p>
728
+ ) : null}
729
+ <KronoFocusPanelHelpTrigger t={t} />
730
+ </div>
731
+ {!viewingArchive ? (
732
+ <div className="flex shrink-0 items-center gap-2">
733
+ {status === "running" ? (
734
+ <button
735
+ type="button"
736
+ className={kronoFocusControlNeutralCard}
737
+ title={t.kronoFocusPause}
738
+ aria-label={t.kronoFocusPause}
739
+ onClick={() => void post({ type: "pauseKronoFocus" })}
740
+ >
741
+ <Pause strokeWidth={2} aria-hidden />
742
+ </button>
743
+ ) : (
744
+ <button
745
+ type="button"
746
+ className={kronoFocusControlAccentCard}
747
+ title={t.kronoFocusStart}
748
+ aria-label={t.kronoFocusStart}
749
+ onClick={() => void post({ type: "startKronoFocus" })}
750
+ >
751
+ <Play strokeWidth={2} aria-hidden />
752
+ </button>
753
+ )}
754
+ <button
755
+ type="button"
756
+ className={kronoFocusControlNeutralCard}
757
+ title={t.kronoFocusReset}
758
+ aria-label={t.kronoFocusReset}
759
+ onClick={() => void post({ type: "resetKronoFocus" })}
760
+ >
761
+ <RotateCcw strokeWidth={2} aria-hidden />
762
+ </button>
763
+ </div>
764
+ ) : null}
765
+ </div>
766
+
767
+ <div
768
+ className="relative flex w-full min-w-0 flex-wrap items-center justify-center gap-x-5 gap-y-3"
769
+ ref={popoverRef}
770
+ >
771
+ <div className={`${phaseLabelClassName} text-violet-950 dark:text-violet-100/95`}>{modeLabel}</div>
772
+ {canEditWorkDuration ? (
773
+ <button
774
+ type="button"
775
+ className={`${timeSlotClass} rounded-md px-1 py-0.5 ${timeClassName} cursor-pointer outline-none transition-colors hover:bg-zinc-200/60 dark:hover:bg-zinc-800/50 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-violet-500/55`}
776
+ title={t.kronoFocusEditDurationTitle}
777
+ aria-label={t.kronoFocusEditDurationTitle}
778
+ aria-expanded={durationPopoverOpen ? "true" : "false"}
779
+ onClick={() => setDurationPopoverOpen((v) => !v)}
780
+ onAnimationEnd={clearStartPulse}
781
+ >
782
+ {clockDisplay}
783
+ </button>
784
+ ) : (
785
+ <div className={`${timeSlotClass} ${timeClassName}`} onAnimationEnd={clearStartPulse}>
786
+ {clockDisplay}
787
+ </div>
788
+ )}
789
+
790
+ {durationPopoverOpen && canEditWorkDuration && (
791
+ <div
792
+ className={`absolute ${durationPopoverAlign} rounded-lg border border-zinc-200 bg-white p-3 text-left shadow-xl dark:border-zinc-600 dark:bg-zinc-900`}
793
+ role="dialog"
794
+ aria-label={t.kronoFocusDurationPickerLabel}
795
+ >
796
+ <KronoFocusDurationPopoverFields
797
+ inputId="kronosys-krono-focus-duration"
798
+ t={t}
799
+ draftTime={draftTime}
800
+ setDraftTime={setDraftTime}
801
+ draftShortBreakMin={draftShortBreakMin}
802
+ setDraftShortBreakMin={setDraftShortBreakMin}
803
+ draftLongBreakMin={draftLongBreakMin}
804
+ setDraftLongBreakMin={setDraftLongBreakMin}
805
+ onPickPreset={applyRhythmPreset}
806
+ customHistory={customDurationHistory}
807
+ onPickDefault={() => {
808
+ setDraftTime(formatSecondsAsHMS(DEFAULT_KRONO_FOCUS_WORK_SEC));
809
+ setDraftShortBreakMin("5");
810
+ setDraftLongBreakMin("15");
811
+ }}
812
+ onApply={applyDraftDuration}
813
+ onCancel={() => setDurationPopoverOpen(false)}
814
+ onClearHistory={clearDurationHistory}
815
+ />
816
+ </div>
817
+ )}
818
+ </div>
819
+
820
+ {showTaskLink ? (
821
+ <p className="w-full max-w-3xl text-center text-[0.8rem] leading-snug text-zinc-600 dark:text-zinc-400">
822
+ <span className="text-zinc-500">{t.kronoFocusLinkedTaskIntro} </span>
823
+ <a
824
+ href={taskHref}
825
+ className="font-medium text-violet-800 underline decoration-violet-500/45 underline-offset-2 hover:text-violet-700 dark:text-violet-400/95 dark:decoration-violet-500/50 dark:hover:text-violet-300"
826
+ >
827
+ {truncateLabel(linkedName || "—", 72)}
828
+ </a>
829
+ </p>
830
+ ) : null}
831
+ </div>
832
+ </section>
833
+ );
834
+ }