@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,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
|
+
}
|