Clock: a "Delphin Uhr" overlay with the school bus and a parents' timer
Einfg on the kiosk keyboard, or Guide (KEY_EPG) on the IR remote, shows a big analog watch over whatever page is open, built from the Delphin Uhr mockup without its swimming dolphin. The overlay stays mounted and is only hidden, so it appears at once. Beside the watch, only while they run: - The bus: on school days, a silent countdown to `general.clock.bus_time` (07:25) that starts `bus_lead_minutes` (60) before. School days skip weekends and the school and public holidays of `general.clock.subdivision` (DE-BY), fetched daily from openholidaysapi.org and cached under the library cache. A badge on the clock says whether there is school today, and why not. - The dolphin timer, set in parent mode (5/10/15/30 minutes, or until a time). It lives in the backend, so a parent's phone can start it for the kiosk. When it runs out the kiosk opens the clock at 00:00 and a soft bell chimes on the device; it clears after a minute, or at the next key, tap or remote press. Parent mode is now a way in rather than a page: `?parentMode=1` adds a 🔧 button next to the ⌨ one, or an "Eltern" tab in the phone's dock, so the URL can stay bookmarked. The timer comes first there, the device settings below it. Unmapped IR buttons are logged at debug level, to find out what irserver calls a new one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
BIN
web/public/clock/bus.webp
Normal file
BIN
web/public/clock/bus.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
BIN
web/public/clock/dolphin-watch.webp
Normal file
BIN
web/public/clock/dolphin-watch.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
156
web/src/App.tsx
156
web/src/App.tsx
@@ -23,7 +23,8 @@ import { BrowseView } from "./components/BrowseView";
|
||||
import { HelpOverlay } from "./components/HelpOverlay";
|
||||
import { MobileBrowse } from "./components/mobile/MobileBrowse";
|
||||
import { MobileDock } from "./components/mobile/MobileDock";
|
||||
import { ParentPanel } from "./components/ParentPanel";
|
||||
import { ClockOverlay } from "./components/ClockOverlay";
|
||||
import { ParentPage, ParentPanel } from "./components/ParentPanel";
|
||||
import { PlayerBar } from "./components/PlayerBar";
|
||||
import { PlayView } from "./components/PlayView";
|
||||
import { RemoteAssignPopup } from "./components/RemoteAssignPopup";
|
||||
@@ -75,14 +76,22 @@ const PODCAST_SKIP_SECONDS = 30;
|
||||
export function App() {
|
||||
const [ui, setUi] = useState<UiState>(initialUiState);
|
||||
const library = useLibrary();
|
||||
const connection = usePlayerState(library.reload);
|
||||
// The remote's Guide key. Only the kiosk on the device shows the clock for it - a
|
||||
// tablet in another room has no business popping one up, same rule as Del.
|
||||
const onRemoteClockToggle = useCallback(() => {
|
||||
if (ON_THE_DEVICE) setUi((previous) => ({ ...previous, showClock: !previous.showClock }));
|
||||
}, []);
|
||||
const connection = usePlayerState(library.reload, onRemoteClockToggle);
|
||||
const [gridRef, columns] = useGridColumns();
|
||||
// A phone gets its own touch layout (components/mobile/); everything else - the
|
||||
// kiosk, a desktop browser, a tablet - keeps the keyboard-first one.
|
||||
const phone = usePhoneLayout();
|
||||
const [parentMode, setParentMode] = useState(
|
||||
// `?parentMode=1` only adds a way into parent mode (the 🔧 button, or the phone's
|
||||
// "Eltern" tab), so the URL can stay bookmarked on a parent's tablet or phone.
|
||||
const [parentAccess] = useState(
|
||||
() => new URLSearchParams(location.search).get("parentMode") === "1",
|
||||
);
|
||||
const [parentPanelOpen, setParentPanelOpen] = useState(false);
|
||||
const [perfOpen, setPerfOpen] = useState(
|
||||
() => PERF_ENABLED && new URLSearchParams(location.search).get("perf") === "1",
|
||||
);
|
||||
@@ -419,6 +428,34 @@ export function App() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// The parents' timer ran out: the kiosk shows the clock with it, chiming 00:00.
|
||||
const ringing = state?.clock.parent_timer?.ringing ?? false;
|
||||
useEffect(() => {
|
||||
if (ringing && ON_THE_DEVICE) setUi((previous) => ({ ...previous, showClock: true }));
|
||||
}, [ringing]);
|
||||
|
||||
// ... and the next key press or tap anywhere silences it. The backend does the same
|
||||
// for the remote and the mouse; this is the kiosk's (and any open tab's) share.
|
||||
useEffect(() => {
|
||||
if (!ringing) return;
|
||||
const silence = () => void api.clearTimer().catch(() => {});
|
||||
window.addEventListener("keydown", silence, true);
|
||||
window.addEventListener("pointerdown", silence, true);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", silence, true);
|
||||
window.removeEventListener("pointerdown", silence, true);
|
||||
};
|
||||
}, [ringing]);
|
||||
|
||||
// The "Eltern" tab only exists on a phone; a window that grows out of the phone
|
||||
// layout while on it goes back to the music, with the sheet open in its place.
|
||||
useEffect(() => {
|
||||
if (!phone && ui.page === "parents") {
|
||||
setUi((previous) => ({ ...previous, page: "music" }));
|
||||
setParentPanelOpen(true);
|
||||
}
|
||||
}, [phone, ui.page]);
|
||||
|
||||
// An abandoned "A" press disarms itself rather than leaving the app half-primed.
|
||||
useEffect(() => {
|
||||
if (!ui.assignPending) return;
|
||||
@@ -659,11 +696,22 @@ export function App() {
|
||||
|
||||
{!phone && <TabRail page={ui.page} onSelect={onSelectPage} showRoom={haConfig != null} />}
|
||||
|
||||
{phone && (ui.page === "room" || (ui.page === "music" && ui.view === "browse")) && (
|
||||
{phone && ui.page === "parents" && parentAccess && (
|
||||
<ParentPage
|
||||
clock={state?.clock ?? null}
|
||||
onOpenPerf={PERF_ENABLED ? () => setPerfOpen(true) : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
{phone &&
|
||||
(ui.page === "room" ||
|
||||
(ui.page === "parents" && parentAccess) ||
|
||||
(ui.page === "music" && ui.view === "browse")) && (
|
||||
<MobileDock
|
||||
page={ui.page}
|
||||
onSelectPage={onSelectPhonePage}
|
||||
showRoom={haConfig != null}
|
||||
showParents={parentAccess}
|
||||
searching={ui.page === "music" && ui.searchOpen}
|
||||
typed={ui.search}
|
||||
onOpenSearch={() => setUi((previous) => ({ ...previous, searchOpen: true }))}
|
||||
@@ -698,55 +746,42 @@ export function App() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{parentMode && <ParentPanel onClose={() => setParentMode(false)} />}
|
||||
<ClockOverlay
|
||||
visible={ui.showClock}
|
||||
clock={state?.clock ?? null}
|
||||
onClose={() => setUi((previous) => ({ ...previous, showClock: false }))}
|
||||
/>
|
||||
|
||||
{PERF_ENABLED && !perfOpen && (
|
||||
<button
|
||||
onClick={() => setPerfOpen(true)}
|
||||
aria-label="Leistungseinstellungen"
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 8,
|
||||
bottom: 8,
|
||||
zIndex: 5,
|
||||
width: 32,
|
||||
height: 32,
|
||||
border: "none",
|
||||
borderRadius: 999,
|
||||
cursor: "pointer",
|
||||
background: "oklch(15% 0.03 var(--hue) / .35)",
|
||||
color: "#fff",
|
||||
opacity: 0.5,
|
||||
}}
|
||||
>
|
||||
⚡
|
||||
</button>
|
||||
{parentAccess && parentPanelOpen && (
|
||||
<ParentPanel clock={state?.clock ?? null} onClose={() => setParentPanelOpen(false)} />
|
||||
)}
|
||||
|
||||
{PERF_ENABLED && perfOpen && <PerfPanel onClose={() => setPerfOpen(false)} />}
|
||||
|
||||
{/* The F1 sheet, reachable without knowing about F1. Not on a phone (no keyboard)
|
||||
or the typing page (TippenApp has its own help and owns every key there). */}
|
||||
{!phone && ui.page !== "typing" && !ui.showHelp && (
|
||||
<button
|
||||
onClick={() => setUi((previous) => ({ ...previous, showHelp: true }))}
|
||||
aria-label="Tastenkürzel"
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: PERF_ENABLED && !perfOpen ? 48 : 8,
|
||||
bottom: 8,
|
||||
zIndex: 5,
|
||||
width: 32,
|
||||
height: 32,
|
||||
border: "none",
|
||||
borderRadius: 999,
|
||||
cursor: "pointer",
|
||||
background: "oklch(15% 0.03 var(--hue) / .35)",
|
||||
color: "#fff",
|
||||
opacity: 0.5,
|
||||
}}
|
||||
>
|
||||
⌨
|
||||
</button>
|
||||
{/* Small tools in the bottom-left corner. Not on a phone: there the dock covers
|
||||
that corner, and parent mode is a tab in it instead. The F1 sheet's button is
|
||||
not on the typing page either (TippenApp has its own help and owns every key). */}
|
||||
{!phone && (
|
||||
<div style={{ position: "absolute", left: 8, bottom: 8, zIndex: 5, display: "flex", gap: 8 }}>
|
||||
{PERF_ENABLED && !perfOpen && (
|
||||
<ToolButton label="Leistungseinstellungen" onClick={() => setPerfOpen(true)}>
|
||||
⚡
|
||||
</ToolButton>
|
||||
)}
|
||||
{ui.page !== "typing" && !ui.showHelp && (
|
||||
<ToolButton
|
||||
label="Tastenkürzel"
|
||||
onClick={() => setUi((previous) => ({ ...previous, showHelp: true }))}
|
||||
>
|
||||
⌨
|
||||
</ToolButton>
|
||||
)}
|
||||
{parentAccess && !parentPanelOpen && (
|
||||
<ToolButton label="Eltern-Einstellungen" onClick={() => setParentPanelOpen(true)}>
|
||||
🔧
|
||||
</ToolButton>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(!state || library.loading) && <Splash error={library.error} />}
|
||||
@@ -765,6 +800,29 @@ export function App() {
|
||||
);
|
||||
}
|
||||
|
||||
/** One of the small round buttons in the bottom-left corner. */
|
||||
function ToolButton({ label, onClick, children }: { label: string; onClick: () => void; children: ReactNode }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
border: "none",
|
||||
borderRadius: 999,
|
||||
cursor: "pointer",
|
||||
background: "oklch(15% 0.03 var(--hue) / .35)",
|
||||
color: "#fff",
|
||||
opacity: 0.5,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** The one fixed round button in each top corner: back (left) and the music/room
|
||||
* toggle (right). Same position and shape everywhere they appear, so "top left" and
|
||||
* "top right" always mean the same thing. */
|
||||
|
||||
@@ -103,6 +103,10 @@ export const api = {
|
||||
/** Someone is at the kiosk: holds off the screen's idle auto-off. */
|
||||
screenActivity: () => post("/screen/activity"),
|
||||
|
||||
/** The parents' countdown: minutes from now, or until a time of day ("HH:MM"). */
|
||||
setTimer: (timer: { minutes: number } | { until: string }) => post("/timer", timer),
|
||||
clearTimer: () => request<void>("/timer", { method: "DELETE" }),
|
||||
|
||||
settings: () => request<Settings>("/settings"),
|
||||
saveSettings: (settings: Settings) =>
|
||||
request<Settings>("/settings", { method: "PUT", body: JSON.stringify(settings) }),
|
||||
|
||||
@@ -90,6 +90,19 @@ export interface PlayerState {
|
||||
volume: number;
|
||||
active_figure: string | null;
|
||||
connected: { firmware: boolean; mqtt: boolean; lirc: boolean };
|
||||
clock: ClockState;
|
||||
}
|
||||
|
||||
/** What the clock overlay shows besides the time. Mirrors `ClockStateOut`. */
|
||||
export interface ClockState {
|
||||
/** `null` when the holiday calendar could not be fetched (weekends are still known). */
|
||||
school_day: boolean | null;
|
||||
/** Why there is no school today: "Wochenende", "Herbstferien", ... */
|
||||
school_day_reason: string | null;
|
||||
/** Today's bus, `null` without school. `time` is "HH:MM". */
|
||||
bus: { time: string; lead_minutes: number } | null;
|
||||
/** The parents' countdown, in wall-clock Unix seconds. */
|
||||
parent_timer: { started_at: number; ends_at: number; ringing: boolean } | null;
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
@@ -106,7 +119,9 @@ export interface Settings {
|
||||
export type ServerMessage =
|
||||
| { type: "state"; state: PlayerState }
|
||||
| { type: "position"; position: number; duration: number }
|
||||
| { type: "library" };
|
||||
| { type: "library" }
|
||||
/** The IR remote's Guide key: show or hide the clock. */
|
||||
| { type: "clock_toggle" };
|
||||
|
||||
export interface HaDevice {
|
||||
entity_id: string;
|
||||
|
||||
187
web/src/components/ClockOverlay.tsx
Normal file
187
web/src/components/ClockOverlay.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
/** The "Delphin Uhr": a big analog wrist watch over whatever page is showing, with the
|
||||
* countdowns that are running right now beside it - the school bus on school-day
|
||||
* mornings, and the parents' timer. Built from `claude-design/Delphin Uhr.dc.html`,
|
||||
* minus the mascot swimming through the scene (too distracting for a clock you read
|
||||
* at a glance).
|
||||
*
|
||||
* Mounted for as long as the app is, and only hidden: Einfg and the remote's Guide key
|
||||
* have to show it at once, not after a first render. The second ticker only runs while
|
||||
* it is on screen. Any click closes it. */
|
||||
|
||||
import { memo } from "react";
|
||||
|
||||
import type { ClockState } from "../api/types";
|
||||
import { useNow } from "../hooks/useNow";
|
||||
import { activeCountdowns, type Countdown, formatCountdown, handAngles } from "../lib/clock";
|
||||
import { SKIN } from "../lib/skins";
|
||||
import { SHOW_DECORATIVE_ANIMATIONS } from "../lib/theme";
|
||||
import { Drift, type DriftField } from "./Drift";
|
||||
|
||||
const BUBBLES: DriftField = [
|
||||
[5, 14, 0.5, 9, 0],
|
||||
[12, 22, 0.5, 12, -3],
|
||||
[22, 10, 0.5, 8, -6],
|
||||
[48, 16, 0.5, 11, -2],
|
||||
[63, 12, 0.5, 10, -7],
|
||||
[78, 24, 0.5, 14, -5],
|
||||
[88, 12, 0.5, 9, -1],
|
||||
[95, 18, 0.5, 13, -9],
|
||||
];
|
||||
|
||||
/** The four colours every ring of the dial cycles through, starting at 12. */
|
||||
const HUES = ["205", "175", "245", "pink"] as const;
|
||||
|
||||
const CARD_IMAGE: Record<Countdown["kind"], { src: string; alt: string }> = {
|
||||
bus: { src: "/clock/bus.webp", alt: "Bus" },
|
||||
timer: { src: SKIN.mascotWatch, alt: "Timer" },
|
||||
};
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
clock: ClockState | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ClockOverlay({ visible, clock, onClose }: Props) {
|
||||
const now = useNow(visible);
|
||||
const countdowns = clock ? activeCountdowns(now, clock) : [];
|
||||
const angles = handAngles(now);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="clock-overlay"
|
||||
hidden={!visible}
|
||||
onClick={onClose}
|
||||
role="dialog"
|
||||
aria-label="Uhr"
|
||||
>
|
||||
{SHOW_DECORATIVE_ANIMATIONS && (
|
||||
<Drift field={BUBBLES} bubbleColor={(alpha) => `oklch(90% 0.02 var(--hue) / ${alpha})`} />
|
||||
)}
|
||||
|
||||
{clock && clock.school_day !== null && <SchoolDayBadge clock={clock} />}
|
||||
|
||||
<div className="clock-watch">
|
||||
<WatchCase />
|
||||
<div className="clock-face">
|
||||
<Dial />
|
||||
<div className="clock-hand clock-hand-hour" style={{ transform: `rotate(${angles.hour}deg)` }}>
|
||||
<div />
|
||||
</div>
|
||||
<div className="clock-hand clock-hand-minute" style={{ transform: `rotate(${angles.minute}deg)` }}>
|
||||
<div />
|
||||
</div>
|
||||
<div className="clock-hand clock-hand-second" style={{ transform: `rotate(${angles.second}deg)` }}>
|
||||
<div />
|
||||
</div>
|
||||
<div className="clock-pin" />
|
||||
<div className="clock-pin-dot" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{countdowns.length > 0 && (
|
||||
<div className="clock-countdowns">
|
||||
{countdowns.map((countdown) => (
|
||||
<CountdownCard key={countdown.kind} countdown={countdown} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SchoolDayBadge({ clock }: { clock: ClockState }) {
|
||||
const text = clock.school_day
|
||||
? "🎒 Schultag"
|
||||
: clock.school_day_reason === "Wochenende"
|
||||
? "🏖️ Wochenende"
|
||||
: `🏖️ Schulfrei${clock.school_day_reason ? ` · ${clock.school_day_reason}` : ""}`;
|
||||
return (
|
||||
<div className="clock-badge" data-school={clock.school_day || undefined}>
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CountdownCard({ countdown }: { countdown: Countdown }) {
|
||||
const image = CARD_IMAGE[countdown.kind];
|
||||
return (
|
||||
<div className="clock-card" data-soon={countdown.soon || undefined} data-ringing={countdown.ringing || undefined}>
|
||||
<div className="clock-card-image">
|
||||
<img src={image.src} alt={image.alt} />
|
||||
</div>
|
||||
<div className="clock-card-body">
|
||||
<div className="clock-card-time">{formatCountdown(countdown.remaining)}</div>
|
||||
<div className="clock-card-bar">
|
||||
<div style={{ width: `${(countdown.fraction * 100).toFixed(2)}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Straps and crown buttons: everything outside the round face. Static. */
|
||||
const WatchCase = memo(function WatchCase() {
|
||||
return (
|
||||
<>
|
||||
<div className="clock-strap" data-pos="top-left" />
|
||||
<div className="clock-strap" data-pos="top-right" />
|
||||
<div className="clock-strap" data-pos="bottom-left" />
|
||||
<div className="clock-strap" data-pos="bottom-right" />
|
||||
<div className="clock-button" style={{ transform: "rotate(60deg) translateY(-38vmin)" }} data-hue="pink" />
|
||||
<div className="clock-button" style={{ transform: "rotate(118deg) translateY(-38vmin)" }} data-hue="205" />
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
/** The bezel dots, coloured hour sections, minute track and numbers. Static, so it is
|
||||
* rendered once and never again on a tick. */
|
||||
const Dial = memo(function Dial() {
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: 48 }, (_, i) => {
|
||||
const angle = i * 7.5;
|
||||
const big = i % 2 === 0;
|
||||
return (
|
||||
<div
|
||||
key={`dot${i}`}
|
||||
className="clock-dot"
|
||||
data-big={big || undefined}
|
||||
data-hue={big ? HUES[(i / 2) % 4] : undefined}
|
||||
style={{ transform: `translate(-50%,-50%) rotate(${angle}deg) translateY(-36.8vmin) rotate(${-angle}deg)` }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<div className="clock-face-inner" />
|
||||
<div className="clock-sections" />
|
||||
<div className="clock-sections-gaps" />
|
||||
<div className="clock-sections-rim" />
|
||||
<div className="clock-center" />
|
||||
<div className="clock-minute-track" />
|
||||
<div className="clock-minute-ticks" />
|
||||
<div className="clock-minute-ticks-major" />
|
||||
<div className="clock-numbers clock-numbers-minutes">
|
||||
{Array.from({ length: 12 }, (_, i) => (
|
||||
<div key={i} style={{ transform: place(i * 30, 29.5) }}>
|
||||
{i === 0 ? 60 : i * 5}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="clock-numbers clock-numbers-hours">
|
||||
{Array.from({ length: 12 }, (_, i) => {
|
||||
const hour = i + 1;
|
||||
return (
|
||||
<div key={hour} data-hue={HUES[hour % 4]} style={{ transform: place(hour * 30, 22.4) }}>
|
||||
{hour}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
/** Centre an element on the face, then push it `radius` vmin out at `angle`, upright. */
|
||||
function place(angle: number, radius: number): string {
|
||||
return `translate(-50%,-50%) rotate(${angle}deg) translateY(-${radius}vmin) rotate(${-angle}deg)`;
|
||||
}
|
||||
@@ -38,6 +38,7 @@ const SECTIONS: Array<[title: string, rows: Array<[caps: string[], label: string
|
||||
[
|
||||
[["A", "0-9"], "Album auf Fernbedienungs-Taste legen"],
|
||||
[["⇧A", "0-9"], "Nur dieses Lied auf die Taste legen"],
|
||||
[["EINFG"], "Uhr an / aus"],
|
||||
[["ENTF"], "Bildschirm aus / an"],
|
||||
[["F1"], "Diese Hilfe"],
|
||||
[["ESC"], "Schließen / Suche löschen"],
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
/** Parent mode: reachable only via `?parentMode=1`, never from the child-facing UI.
|
||||
* That URL only adds a way in - a 🔧 button on the kiosk and desktop (`ParentPanel`, a
|
||||
* sheet), an "Eltern" tab on a phone (`ParentPage`) - so a parent's tablet or phone can
|
||||
* keep it bookmarked. Both show the same `ParentSettings`, the timer first.
|
||||
*
|
||||
* This hides the settings, it does not protect them - there is no login, and the
|
||||
* endpoints behind it are as open as the rest of the API. That matches the device's
|
||||
@@ -6,10 +9,42 @@
|
||||
* implied.
|
||||
*/
|
||||
|
||||
import type { CSSProperties } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { api } from "../api/client";
|
||||
import type { Settings } from "../api/types";
|
||||
import type { ClockState, Settings } from "../api/types";
|
||||
import { useNow } from "../hooks/useNow";
|
||||
import { busWindow, formatTimeOfDay } from "../lib/clock";
|
||||
|
||||
/** The quick-start buttons, in minutes. */
|
||||
const TIMER_PRESETS = [5, 10, 15, 30] as const;
|
||||
|
||||
const PRIMARY_BUTTON: CSSProperties = {
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
background: "var(--accent)",
|
||||
color: "#fff",
|
||||
fontSize: 15,
|
||||
fontWeight: 800,
|
||||
padding: "11px 20px",
|
||||
borderRadius: 999,
|
||||
};
|
||||
|
||||
const SECONDARY_BUTTON: CSSProperties = {
|
||||
...PRIMARY_BUTTON,
|
||||
background: "oklch(88% 0.02 var(--hue))",
|
||||
color: "var(--ink)",
|
||||
};
|
||||
|
||||
const SECTION_TITLE: CSSProperties = { fontSize: 16, fontWeight: 900, color: "var(--ink)" };
|
||||
|
||||
const HINT: CSSProperties = {
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: "oklch(50% 0.03 var(--hue))",
|
||||
lineHeight: 1.5,
|
||||
};
|
||||
|
||||
/** Every setting that is always there; the screen's auto-off is only sometimes. */
|
||||
type AlwaysSetting = Exclude<keyof Settings, "screen_auto_off_minutes">;
|
||||
@@ -64,7 +99,226 @@ function NumberField({
|
||||
);
|
||||
}
|
||||
|
||||
export function ParentPanel({ onClose }: { onClose: () => void }) {
|
||||
/** The kiosk's and a desktop's way in: a sheet over the app. */
|
||||
export function ParentPanel({ clock, onClose }: { clock: ClockState | null; onClose: () => void }) {
|
||||
return (
|
||||
<div className="overlay" style={{ zIndex: 9, background: "oklch(15% 0.03 var(--hue) / .75)" }}>
|
||||
<div className="sheet" style={{ padding: 28, width: 460, maxHeight: "100%", overflow: "auto" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<div style={{ fontSize: 20, fontWeight: 900, color: "var(--ink)", flex: 1 }}>
|
||||
🔧 Eltern-Einstellungen
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Schließen"
|
||||
style={{
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
background: "oklch(88% 0.02 var(--hue))",
|
||||
color: "var(--ink)",
|
||||
fontSize: 18,
|
||||
fontWeight: 900,
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 999,
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ marginTop: 18 }}>
|
||||
<ParentSettings clock={clock} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A phone's way in: the "Eltern" tab, as a page of its own above the dock. A sheet
|
||||
* on a small screen is a page with a worse close button, so it is just a page. */
|
||||
export function ParentPage({ clock, onOpenPerf }: { clock: ClockState | null; onOpenPerf?: () => void }) {
|
||||
return (
|
||||
<div className="m-page">
|
||||
<div className="m-scroll" style={{ padding: "0 var(--m-gutter) calc(var(--m-dock-clearance) + 24px)" }}>
|
||||
<div
|
||||
style={{
|
||||
padding: "calc(16px + env(safe-area-inset-top)) 0 16px",
|
||||
fontSize: 24,
|
||||
fontWeight: 900,
|
||||
color: "var(--paper)",
|
||||
}}
|
||||
>
|
||||
🔧 Eltern
|
||||
</div>
|
||||
<div className="sheet" style={{ padding: 20 }}>
|
||||
<ParentSettings clock={clock} phone />
|
||||
</div>
|
||||
{onOpenPerf && (
|
||||
<button
|
||||
onClick={onOpenPerf}
|
||||
style={{ ...SECONDARY_BUTTON, width: "100%", marginTop: 16, minHeight: 48 }}
|
||||
>
|
||||
⚡ Leistung
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Everything parent mode shows, timer first: the timer is what a parent opens it for
|
||||
* most, and it works on its own, without waiting for the settings to load. */
|
||||
export function ParentSettings({ clock, phone = false }: { clock: ClockState | null; phone?: boolean }) {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 24 }}>
|
||||
<TimerSection clock={clock} phone={phone} />
|
||||
<div style={{ height: 1, background: "oklch(88% 0.02 var(--hue))" }} />
|
||||
<DeviceSettings />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TimerSection({ clock, phone }: { clock: ClockState | null; phone: boolean }) {
|
||||
const [until, setUntil] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const timer = clock?.parent_timer ?? null;
|
||||
const now = useNow(timer !== null);
|
||||
|
||||
const start = async (request: { minutes: number } | { until: string }) => {
|
||||
setError(null);
|
||||
try {
|
||||
await api.setTimer(request);
|
||||
} catch {
|
||||
setError("Timer konnte nicht gestartet werden.");
|
||||
}
|
||||
};
|
||||
|
||||
const stop = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
await api.clearTimer();
|
||||
} catch {
|
||||
setError("Timer konnte nicht gestoppt werden.");
|
||||
}
|
||||
};
|
||||
|
||||
const remaining = timer ? Math.max(0, Math.ceil(timer.ends_at - now.getTime() / 1000)) : 0;
|
||||
const buttonHeight = phone ? 52 : 44;
|
||||
|
||||
return (
|
||||
<section style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<div style={SECTION_TITLE}>🐬 Delfin-Timer</div>
|
||||
|
||||
{timer && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
padding: "12px 14px",
|
||||
borderRadius: 16,
|
||||
background: timer.ringing ? "oklch(90% 0.08 345)" : "oklch(94% 0.03 var(--hue))",
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 28,
|
||||
fontWeight: 900,
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
color: "var(--ink)",
|
||||
}}
|
||||
>
|
||||
{timer.ringing ? "Zeit um!" : `noch ${Math.ceil(remaining / 60)} Min`}
|
||||
</div>
|
||||
<div style={HINT}>
|
||||
{timer.ringing ? "Klingelt gerade" : `läuft bis ${formatTimeOfDay(timer.ends_at)} Uhr`}
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => void stop()} style={{ ...PRIMARY_BUTTON, minHeight: buttonHeight }}>
|
||||
{timer.ringing ? "Ausschalten" : "Stoppen"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: phone ? "1fr 1fr" : "repeat(4, 1fr)",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
{TIMER_PRESETS.map((minutes) => (
|
||||
<button
|
||||
key={minutes}
|
||||
onClick={() => void start({ minutes })}
|
||||
style={{ ...SECONDARY_BUTTON, minHeight: buttonHeight, padding: "8px 0", fontSize: 16 }}
|
||||
>
|
||||
{minutes} Min
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (until) void start({ until });
|
||||
}}
|
||||
style={{ display: "flex", gap: 8, flexWrap: phone ? "wrap" : "nowrap" }}
|
||||
>
|
||||
<input
|
||||
type="time"
|
||||
aria-label="Bis wann"
|
||||
required
|
||||
value={until}
|
||||
onChange={(event) => setUntil(event.target.value)}
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: phone ? "100%" : 0,
|
||||
minHeight: buttonHeight,
|
||||
boxSizing: "border-box",
|
||||
padding: "8px 12px",
|
||||
borderRadius: 12,
|
||||
border: "2px solid oklch(85% 0.02 var(--hue))",
|
||||
fontSize: 18,
|
||||
fontWeight: 800,
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!until}
|
||||
style={{
|
||||
...PRIMARY_BUTTON,
|
||||
minHeight: buttonHeight,
|
||||
flex: phone ? "1 1 100%" : "none",
|
||||
opacity: until ? 1 : 0.5,
|
||||
}}
|
||||
>
|
||||
{until ? `Bis ${until.replace(/^0/, "")} Uhr` : "Bis … starten"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div style={HINT}>{schoolDayLine(clock)}</div>
|
||||
|
||||
{error && <div style={{ fontSize: 14, fontWeight: 700, color: "oklch(55% 0.18 30)" }}>{error}</div>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function schoolDayLine(clock: ClockState | null): string {
|
||||
if (!clock) return "";
|
||||
if (clock.school_day === false) {
|
||||
return `🏖️ Heute schulfrei${clock.school_day_reason ? ` (${clock.school_day_reason})` : ""} – kein Bus-Timer.`;
|
||||
}
|
||||
const bus = clock.bus ? busWindow(clock.bus) : null;
|
||||
const busText = bus ? ` – Bus-Timer ${bus.from}–${bus.to} Uhr.` : ".";
|
||||
return clock.school_day
|
||||
? `🎒 Heute ist Schultag${busText}`
|
||||
: `Ferienkalender nicht erreichbar – Schultag angenommen${busText}`;
|
||||
}
|
||||
|
||||
/** The device settings that live in `config.yml`. */
|
||||
function DeviceSettings() {
|
||||
const [settings, setSettings] = useState<Settings | null>(null);
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -98,115 +352,55 @@ export function ParentPanel({ onClose }: { onClose: () => void }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overlay" style={{ zIndex: 6, background: "oklch(15% 0.03 var(--hue) / .75)" }}>
|
||||
<div className="sheet" style={{ padding: 28, width: 460, maxHeight: "100%", overflow: "auto" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<div style={{ fontSize: 20, fontWeight: 900, color: "var(--ink)", flex: 1 }}>
|
||||
🔧 Eltern-Einstellungen
|
||||
<section style={{ display: "flex", flexDirection: "column", gap: 14 }}>
|
||||
<div style={SECTION_TITLE}>⚙️ Gerät</div>
|
||||
{settings === null ? (
|
||||
<div style={{ color: "oklch(45% 0.03 var(--hue))" }}>{status ?? "Wird geladen …"}</div>
|
||||
) : (
|
||||
<>
|
||||
{FIELDS.map(([key, label, hint, min, max, step]) => (
|
||||
<NumberField
|
||||
key={key}
|
||||
label={label}
|
||||
hint={hint}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={settings[key]}
|
||||
onChange={(value) => setSettings({ ...settings, [key]: value })}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Only when the device has a screen it can switch off by itself. */}
|
||||
{settings.screen_auto_off_minutes !== null && (
|
||||
<NumberField
|
||||
label="Bildschirm aus nach"
|
||||
hint="Minuten ohne Musik und ohne Bedienung, 0 = nie"
|
||||
min={0}
|
||||
max={720}
|
||||
step={1}
|
||||
value={settings.screen_auto_off_minutes}
|
||||
onChange={(value) => setSettings({ ...settings, screen_auto_off_minutes: value })}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div style={{ display: "flex", gap: 10, marginTop: 6, flexWrap: "wrap" }}>
|
||||
<button onClick={() => void save()} disabled={busy} style={PRIMARY_BUTTON}>
|
||||
Speichern
|
||||
</button>
|
||||
<button onClick={() => void rescan()} style={SECONDARY_BUTTON}>
|
||||
Bibliothek neu einlesen
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Schließen"
|
||||
style={{
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
background: "oklch(88% 0.02 var(--hue))",
|
||||
color: "var(--ink)",
|
||||
fontSize: 18,
|
||||
fontWeight: 900,
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 999,
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{settings === null ? (
|
||||
<div style={{ marginTop: 18, color: "oklch(45% 0.03 var(--hue))" }}>Wird geladen …</div>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 14, marginTop: 18 }}>
|
||||
{FIELDS.map(([key, label, hint, min, max, step]) => (
|
||||
<NumberField
|
||||
key={key}
|
||||
label={label}
|
||||
hint={hint}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={settings[key]}
|
||||
onChange={(value) => setSettings({ ...settings, [key]: value })}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Only when the device has a screen it can switch off by itself. */}
|
||||
{settings.screen_auto_off_minutes !== null && (
|
||||
<NumberField
|
||||
label="Bildschirm aus nach"
|
||||
hint="Minuten ohne Musik und ohne Bedienung, 0 = nie"
|
||||
min={0}
|
||||
max={720}
|
||||
step={1}
|
||||
value={settings.screen_auto_off_minutes}
|
||||
onChange={(value) => setSettings({ ...settings, screen_auto_off_minutes: value })}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div style={{ display: "flex", gap: 10, marginTop: 6 }}>
|
||||
<button
|
||||
onClick={() => void save()}
|
||||
disabled={busy}
|
||||
style={{
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
background: "var(--accent)",
|
||||
color: "#fff",
|
||||
fontSize: 15,
|
||||
fontWeight: 800,
|
||||
padding: "11px 20px",
|
||||
borderRadius: 999,
|
||||
}}
|
||||
>
|
||||
Speichern
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void rescan()}
|
||||
style={{
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
background: "oklch(88% 0.02 var(--hue))",
|
||||
color: "var(--ink)",
|
||||
fontSize: 15,
|
||||
fontWeight: 800,
|
||||
padding: "11px 20px",
|
||||
borderRadius: 999,
|
||||
}}
|
||||
>
|
||||
Bibliothek neu einlesen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: "oklch(50% 0.03 var(--hue))",
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
Speichern schreibt in die <code>config.yml</code> auf dem Gerät. Die neue
|
||||
Obergrenze gilt sofort, ohne Neustart.
|
||||
</div>
|
||||
<div style={{ ...HINT, fontSize: 12 }}>
|
||||
Speichern schreibt in die <code>config.yml</code> auf dem Gerät. Die neue
|
||||
Obergrenze gilt sofort, ohne Neustart.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status && (
|
||||
<div style={{ marginTop: 14, fontSize: 14, fontWeight: 700, color: "var(--ink)" }}>
|
||||
{status}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{status && <div style={{ fontSize: 14, fontWeight: 700, color: "var(--ink)" }}>{status}</div>}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,9 @@ interface Props {
|
||||
page: Page;
|
||||
onSelectPage: (page: Page) => void;
|
||||
showRoom: boolean;
|
||||
/** `?parentMode=1`: parent mode is a tab here, in thumb reach, rather than a
|
||||
* floating button. */
|
||||
showParents: boolean;
|
||||
searching: boolean;
|
||||
typed: string;
|
||||
onOpenSearch: () => void;
|
||||
@@ -38,12 +41,14 @@ interface Props {
|
||||
const TABS: { page: Page; label: string }[] = [
|
||||
{ page: "music", label: "Musik" },
|
||||
{ page: "room", label: "Zimmer" },
|
||||
{ page: "parents", label: "Eltern" },
|
||||
];
|
||||
|
||||
export function MobileDock({
|
||||
page,
|
||||
onSelectPage,
|
||||
showRoom,
|
||||
showParents,
|
||||
searching,
|
||||
typed,
|
||||
onOpenSearch,
|
||||
@@ -57,7 +62,9 @@ export function MobileDock({
|
||||
}: Props) {
|
||||
const keyboardInset = useKeyboardInset();
|
||||
const input = useRef<HTMLInputElement | null>(null);
|
||||
const tabs = TABS.filter((tab) => tab.page !== "room" || showRoom);
|
||||
const tabs = TABS.filter(
|
||||
(tab) => (tab.page !== "room" || showRoom) && (tab.page !== "parents" || showParents),
|
||||
);
|
||||
const canSearch = page === "music";
|
||||
|
||||
return (
|
||||
|
||||
21
web/src/hooks/useNow.ts
Normal file
21
web/src/hooks/useNow.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/** The current time, ticking just after each full second while `running` - so a
|
||||
* second hand or a countdown never skips or shows a second twice. Stopped, it keeps
|
||||
* the last value and costs nothing. */
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function useNow(running = true): Date {
|
||||
const [now, setNow] = useState(() => new Date());
|
||||
useEffect(() => {
|
||||
if (!running) return;
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
const tick = () => {
|
||||
const date = new Date();
|
||||
setNow(date);
|
||||
timer = setTimeout(tick, 1000 - date.getMilliseconds() + 5);
|
||||
};
|
||||
tick();
|
||||
return () => clearTimeout(timer);
|
||||
}, [running]);
|
||||
return now;
|
||||
}
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
State arrives over a websocket that only pushes: every command goes out as a plain
|
||||
POST. A reconnect reseeds from `GET /api/state` rather than assuming the UI's copy is
|
||||
still right, and `onLibraryChanged` fires when a rescan has finished.
|
||||
still right, and `onLibraryChanged` fires when a rescan has finished. `onClockToggle`
|
||||
fires when the IR remote's Guide key asks for the clock.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
@@ -24,13 +25,18 @@ export interface Connection {
|
||||
optimistic: (patch: Partial<PlayerState>) => void;
|
||||
}
|
||||
|
||||
export function usePlayerState(onLibraryChanged: () => void): Connection {
|
||||
export function usePlayerState(
|
||||
onLibraryChanged: () => void,
|
||||
onClockToggle: () => void = () => {},
|
||||
): Connection {
|
||||
const [state, setState] = useState<PlayerState | null>(null);
|
||||
const [online, setOnline] = useState(false);
|
||||
|
||||
// Kept in a ref so reconnecting never re-runs the effect that owns the socket.
|
||||
const libraryChanged = useRef(onLibraryChanged);
|
||||
libraryChanged.current = onLibraryChanged;
|
||||
const clockToggle = useRef(onClockToggle);
|
||||
clockToggle.current = onClockToggle;
|
||||
|
||||
useEffect(() => {
|
||||
let socket: WebSocket | null = null;
|
||||
@@ -68,6 +74,8 @@ export function usePlayerState(onLibraryChanged: () => void): Connection {
|
||||
);
|
||||
} else if (message.type === "library") {
|
||||
libraryChanged.current();
|
||||
} else if (message.type === "clock_toggle") {
|
||||
clockToggle.current();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
112
web/src/lib/__tests__/clock.test.ts
Normal file
112
web/src/lib/__tests__/clock.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { ClockState } from "../../api/types";
|
||||
import { activeCountdowns, busWindow, formatCountdown, handAngles } from "../clock";
|
||||
|
||||
const BUS: ClockState = {
|
||||
school_day: true,
|
||||
school_day_reason: null,
|
||||
bus: { time: "07:25", lead_minutes: 60 },
|
||||
parent_timer: null,
|
||||
};
|
||||
|
||||
const at = (hours: number, minutes: number, seconds = 0) => new Date(2026, 9, 5, hours, minutes, seconds);
|
||||
const unix = (date: Date) => date.getTime() / 1000;
|
||||
|
||||
describe("handAngles", () => {
|
||||
it("points every hand at 12 at midnight", () => {
|
||||
expect(handAngles(at(0, 0))).toEqual({ hour: 0, minute: 0, second: 0 });
|
||||
});
|
||||
|
||||
it("creeps the hour hand between the numbers", () => {
|
||||
expect(handAngles(at(3, 30, 15))).toEqual({ hour: 105.125, minute: 181.5, second: 90 });
|
||||
});
|
||||
|
||||
it("goes round twice a day", () => {
|
||||
expect(handAngles(at(15, 0)).hour).toBe(90);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the bus countdown", () => {
|
||||
it("is hidden until an hour before the bus", () => {
|
||||
expect(activeCountdowns(at(6, 24, 59), BUS)).toEqual([]);
|
||||
});
|
||||
|
||||
it("shows for the hour before the bus", () => {
|
||||
const [bus] = activeCountdowns(at(6, 25), BUS);
|
||||
expect(bus).toMatchObject({ kind: "bus", remaining: 3600, fraction: 1, soon: false });
|
||||
});
|
||||
|
||||
it("turns pink for the last ten minutes", () => {
|
||||
expect(activeCountdowns(at(7, 15), BUS)[0]).toMatchObject({ remaining: 600, soon: true });
|
||||
});
|
||||
|
||||
it("is gone the moment the bus leaves", () => {
|
||||
expect(activeCountdowns(at(7, 25), BUS)).toEqual([]);
|
||||
});
|
||||
|
||||
it("is not there without school", () => {
|
||||
expect(activeCountdowns(at(7, 0), { ...BUS, school_day: false, bus: null })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the parents' timer", () => {
|
||||
const started = at(18, 50);
|
||||
const timer = (ringing = false): ClockState => ({
|
||||
...BUS,
|
||||
parent_timer: { started_at: unix(started), ends_at: unix(at(19, 5)), ringing },
|
||||
});
|
||||
|
||||
it("counts down from when it was started", () => {
|
||||
expect(activeCountdowns(at(18, 50), timer())[0]).toMatchObject({
|
||||
kind: "timer",
|
||||
remaining: 900,
|
||||
fraction: 1,
|
||||
});
|
||||
expect(activeCountdowns(at(19, 0), timer())[0]).toMatchObject({ remaining: 300, soon: true });
|
||||
});
|
||||
|
||||
it("waits at zero for the ringing frame instead of vanishing", () => {
|
||||
expect(activeCountdowns(at(19, 5, 2), timer())[0]).toMatchObject({ remaining: 0 });
|
||||
});
|
||||
|
||||
it("shows zero while ringing, however late", () => {
|
||||
expect(activeCountdowns(at(19, 6), timer(true))[0]).toMatchObject({ remaining: 0, ringing: true });
|
||||
});
|
||||
|
||||
it("is gone when the backend never says it rang", () => {
|
||||
expect(activeCountdowns(at(19, 6), timer())).toEqual([]);
|
||||
});
|
||||
|
||||
it("sorts the one ending first on top", () => {
|
||||
const morning: ClockState = {
|
||||
...BUS,
|
||||
parent_timer: { started_at: unix(at(6, 50)), ends_at: unix(at(7, 0)), ringing: false },
|
||||
};
|
||||
expect(activeCountdowns(at(6, 55), morning).map((c) => c.kind)).toEqual(["timer", "bus"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("busWindow", () => {
|
||||
it("states the window without leading zeros", () => {
|
||||
expect(busWindow({ time: "07:25", lead_minutes: 60 })).toEqual({ from: "6:25", to: "7:25" });
|
||||
});
|
||||
|
||||
it("wraps back past midnight", () => {
|
||||
expect(busWindow({ time: "00:10", lead_minutes: 30 })).toEqual({ from: "23:40", to: "0:10" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatCountdown", () => {
|
||||
it("shows hours and minutes, rounded up", () => {
|
||||
expect(formatCountdown(900)).toBe("00:15");
|
||||
expect(formatCountdown(899)).toBe("00:15");
|
||||
expect(formatCountdown(61)).toBe("00:02");
|
||||
expect(formatCountdown(3600)).toBe("01:00");
|
||||
});
|
||||
|
||||
it("stops at zero", () => {
|
||||
expect(formatCountdown(0)).toBe("00:00");
|
||||
expect(formatCountdown(-3)).toBe("00:00");
|
||||
});
|
||||
});
|
||||
@@ -521,3 +521,31 @@ describe("Del", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Einfg", () => {
|
||||
it("toggles the clock, on every page", () => {
|
||||
for (const page of ["music", "room", "typing", "parents"] as const) {
|
||||
expect(press("Insert", { ...initialUiState, page })).toContainEqual({
|
||||
type: "ui",
|
||||
patch: { showClock: true },
|
||||
});
|
||||
expect(press("Insert", { ...initialUiState, page, showClock: true })).toContainEqual({
|
||||
type: "ui",
|
||||
patch: { showClock: false },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("is closed by Esc before anything else", () => {
|
||||
const ui = { ...initialUiState, view: "play" as const, showClock: true };
|
||||
expect(press("Escape", ui)).toEqual([{ type: "ui", patch: { showClock: false } }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the parents page", () => {
|
||||
it("ignores browse keys and escapes back to music", () => {
|
||||
const ui = { ...initialUiState, page: "parents" as const };
|
||||
expect(press("x", ui)).toEqual([]);
|
||||
expect(press("Escape", ui)).toEqual([{ type: "ui", patch: { page: "music" } }]);
|
||||
});
|
||||
});
|
||||
|
||||
104
web/src/lib/clock.ts
Normal file
104
web/src/lib/clock.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/** The clock overlay's arithmetic, kept pure so it is tested without a DOM: where the
|
||||
* hands point, and which countdowns show at a given moment.
|
||||
*
|
||||
* The backend decides *whether* there is a bus today (school days only) and owns the
|
||||
* parents' timer; this only places them on the browser's own clock. */
|
||||
|
||||
import type { ClockState } from "../api/types";
|
||||
|
||||
export interface HandAngles {
|
||||
hour: number;
|
||||
minute: number;
|
||||
second: number;
|
||||
}
|
||||
|
||||
/** Degrees clockwise from 12, each hand moving continuously - the hour hand creeps
|
||||
* between numbers like a real one. */
|
||||
export function handAngles(date: Date): HandAngles {
|
||||
const seconds = date.getHours() * 3600 + date.getMinutes() * 60 + date.getSeconds();
|
||||
return {
|
||||
hour: (seconds / 120) % 360,
|
||||
minute: (seconds / 10) % 360,
|
||||
second: (date.getSeconds() * 6) % 360,
|
||||
};
|
||||
}
|
||||
|
||||
export type CountdownKind = "bus" | "timer";
|
||||
|
||||
export interface Countdown {
|
||||
kind: CountdownKind;
|
||||
/** Whole seconds to go, never below 0. */
|
||||
remaining: number;
|
||||
/** Share of the countdown still to go, 1 at the start and 0 at the end. */
|
||||
fraction: number;
|
||||
/** The last ten minutes: the number and the bar turn pink. */
|
||||
soon: boolean;
|
||||
/** The parents' timer ran out and is chiming. */
|
||||
ringing: boolean;
|
||||
}
|
||||
|
||||
const SOON_SECONDS = 10 * 60;
|
||||
const RINGING_GRACE_SECONDS = 5;
|
||||
|
||||
/** Every countdown that should show at `now`, the one ending first on top. */
|
||||
export function activeCountdowns(now: Date, clock: ClockState): Countdown[] {
|
||||
const out: Countdown[] = [];
|
||||
const nowSeconds = now.getTime() / 1000;
|
||||
|
||||
if (clock.bus) {
|
||||
const [hours = 0, minutes = 0] = clock.bus.time.split(":").map(Number);
|
||||
const target = new Date(now);
|
||||
target.setHours(hours, minutes, 0, 0);
|
||||
const remaining = target.getTime() / 1000 - nowSeconds;
|
||||
const lead = clock.bus.lead_minutes * 60;
|
||||
if (remaining > 0 && remaining <= lead) {
|
||||
out.push(countdown("bus", remaining, lead, false));
|
||||
}
|
||||
}
|
||||
|
||||
const timer = clock.parent_timer;
|
||||
if (timer) {
|
||||
const remaining = timer.ends_at - nowSeconds;
|
||||
// The backend says when it rings; until that frame arrives, a timer at 0 waits at
|
||||
// 0 for a few seconds rather than vanishing for a moment.
|
||||
if (timer.ringing || remaining > -RINGING_GRACE_SECONDS) {
|
||||
out.push(countdown("timer", remaining, timer.ends_at - timer.started_at, timer.ringing));
|
||||
}
|
||||
}
|
||||
|
||||
return out.sort((a, b) => a.remaining - b.remaining);
|
||||
}
|
||||
|
||||
function countdown(kind: CountdownKind, remaining: number, total: number, ringing: boolean): Countdown {
|
||||
const left = ringing ? 0 : Math.max(0, Math.ceil(remaining));
|
||||
return {
|
||||
kind,
|
||||
remaining: left,
|
||||
fraction: total > 0 ? Math.max(0, Math.min(1, left / total)) : 0,
|
||||
soon: ringing || left <= SOON_SECONDS,
|
||||
ringing,
|
||||
};
|
||||
}
|
||||
|
||||
/** Hours and minutes to go, rounded up, as the design has it: "00:15" for a quarter
|
||||
* of an hour. Never minutes and seconds - "14:44" reads as a time of day. */
|
||||
export function formatCountdown(seconds: number): string {
|
||||
const minutes = Math.max(0, Math.ceil(seconds / 60));
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${pad(Math.floor(minutes / 60))}:${pad(minutes % 60)}`;
|
||||
}
|
||||
|
||||
/** "19:30" from Unix seconds, in the browser's time zone. */
|
||||
export function formatTimeOfDay(unixSeconds: number): string {
|
||||
const date = new Date(unixSeconds * 1000);
|
||||
return `${date.getHours()}:${String(date.getMinutes()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/** The bus countdown's window as "6:25" and "7:25", for parent mode to state. */
|
||||
export function busWindow(bus: { time: string; lead_minutes: number }): { from: string; to: string } {
|
||||
const [hours = 0, minutes = 0] = bus.time.split(":").map(Number);
|
||||
const end = hours * 60 + minutes;
|
||||
const start = (((end - bus.lead_minutes) % 1440) + 1440) % 1440;
|
||||
const show = (total: number) => `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")}`;
|
||||
return { from: show(start), to: show(end) };
|
||||
}
|
||||
@@ -28,13 +28,15 @@ export interface UiState {
|
||||
shelfRow: number;
|
||||
/** Which top-level page is showing. Orthogonal to `view`/`search`/`group`/… below,
|
||||
* so switching pages and back leaves the music side exactly as it was. */
|
||||
page: "music" | "room" | "typing";
|
||||
page: "music" | "room" | "typing" | "parents";
|
||||
view: "browse" | "play";
|
||||
openAlbumId: string | null;
|
||||
/** Which track is highlighted in the open album's track list. Only meaningful while
|
||||
* `openAlbumId` is set; reset to a sensible value each time a modal opens. */
|
||||
modalTrackIndex: number;
|
||||
showHelp: boolean;
|
||||
/** The clock overlay, over whichever page is showing. */
|
||||
showClock: boolean;
|
||||
cols: number;
|
||||
/** Armed on the play screen: the next digit assigns what's playing to that remote key
|
||||
* instead of doing whatever it would normally do. `a` arms "album" (the whole album
|
||||
@@ -55,6 +57,7 @@ export const initialUiState: UiState = {
|
||||
openAlbumId: null,
|
||||
modalTrackIndex: 0,
|
||||
showHelp: false,
|
||||
showClock: false,
|
||||
cols: 4,
|
||||
assignPending: null,
|
||||
};
|
||||
@@ -192,13 +195,16 @@ export function browseBackActions(state: UiState): Action[] {
|
||||
|
||||
/** ESC peels one layer off at a time rather than dumping you back at the top. */
|
||||
function escape(state: UiState): Action[] {
|
||||
if (state.showClock) {
|
||||
return [{ type: "ui", patch: { showClock: false } }];
|
||||
}
|
||||
if (state.assignPending) {
|
||||
return [{ type: "ui", patch: { assignPending: null } }];
|
||||
}
|
||||
if (state.showHelp || state.openAlbumId !== null) {
|
||||
return [{ type: "ui", patch: { showHelp: false, openAlbumId: null } }];
|
||||
}
|
||||
if (state.page === "room") {
|
||||
if (state.page === "room" || state.page === "parents") {
|
||||
return [{ type: "ui", patch: { page: "music" } }];
|
||||
}
|
||||
if (state.view === "play") {
|
||||
@@ -263,6 +269,12 @@ export function handleKey(
|
||||
// another room.
|
||||
if (key === "Delete" && !event.ctrlKey && !event.metaKey) return [{ type: "screen" }];
|
||||
|
||||
// Einfg, right next to it: the clock, over whatever is on screen - the typing tab
|
||||
// included, like Del, since no lesson drills it either.
|
||||
if (key === "Insert" && !event.ctrlKey && !event.metaKey) {
|
||||
return [{ type: "pop", freq: 520 }, { type: "ui", patch: { showClock: !state.showClock } }];
|
||||
}
|
||||
|
||||
// The typing tab is a second app mounted alongside this one (TippenApp.tsx) with its
|
||||
// own complete keyboard handling, including its own Escape/F1 bindings. It must own
|
||||
// every key while active - even Shift+media and Space below, which would otherwise
|
||||
@@ -299,11 +311,11 @@ export function handleKey(
|
||||
return [{ type: "pop", freq: 420 }, { type: "remote", digit: key }];
|
||||
}
|
||||
|
||||
// The room page has no browse hierarchy of its own - only ESC (handled below) and
|
||||
// the global transport above (Shift+media, Space) make sense there. Everything else
|
||||
// would otherwise mutate the hidden music state without anything on screen to show
|
||||
// for it.
|
||||
if (state.page === "room" && key !== "Escape") {
|
||||
// The room and parents pages have no browse hierarchy of their own - only ESC
|
||||
// (handled below) and the global transport above (Shift+media, Space) make sense
|
||||
// there. Everything else would otherwise mutate the hidden music state without
|
||||
// anything on screen to show for it.
|
||||
if ((state.page === "room" || state.page === "parents") && key !== "Escape") {
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ export const DINOSAUR: Skin = {
|
||||
mascotName: "Der Dino",
|
||||
mascot: "/dinosaur-mascot.png",
|
||||
mascotRemote: "/dinosaur-remote.png",
|
||||
// No drawing with a watch yet; the plain mascot stands in.
|
||||
mascotWatch: "/dinosaur-mascot.png",
|
||||
mascotFacesLeft: true,
|
||||
mascotMotion: "walk",
|
||||
drift: "leaves",
|
||||
|
||||
@@ -9,6 +9,7 @@ export const DOLPHIN: Skin = {
|
||||
mascotName: "Der Delphin",
|
||||
mascot: "/dolphin-mascot.png",
|
||||
mascotRemote: "/dolphin-remote.png",
|
||||
mascotWatch: "/clock/dolphin-watch.webp",
|
||||
mascotFacesLeft: false,
|
||||
mascotMotion: "swim",
|
||||
drift: "bubbles",
|
||||
|
||||
@@ -27,6 +27,8 @@ export interface Skin {
|
||||
mascot: string;
|
||||
/** The mascot holding a remote, for "Mein Zimmer". */
|
||||
mascotRemote: string;
|
||||
/** The mascot with a watch: the clock overlay's card for the parents' timer. */
|
||||
mascotWatch: string;
|
||||
/** The play view's swimming mascot is animated assuming it faces right; a drawing
|
||||
* that faces left is mirrored so it looks where it is going. */
|
||||
mascotFacesLeft: boolean;
|
||||
|
||||
@@ -113,12 +113,14 @@ export const PAGE_ICON: Record<UiState["page"], string> = {
|
||||
music: "♪",
|
||||
room: "💡",
|
||||
typing: "⌨️",
|
||||
parents: "🔧",
|
||||
};
|
||||
|
||||
export const PAGE_LABEL: Record<UiState["page"], string> = {
|
||||
music: "Musik",
|
||||
room: "Mein Zimmer",
|
||||
typing: "Tippen",
|
||||
parents: "Eltern",
|
||||
};
|
||||
|
||||
/** A shelf/row's frosted background and border, tinted with its group's hue - or,
|
||||
|
||||
@@ -9,6 +9,7 @@ import "./styles/app.css";
|
||||
import "./styles/mobile.css";
|
||||
import "./styles/room.css";
|
||||
import "./styles/tippen.css";
|
||||
import "./styles/clock.css";
|
||||
|
||||
// Before the first render, so nothing ever paints in the wrong skin's colours.
|
||||
applySkin();
|
||||
|
||||
533
web/src/styles/clock.css
Normal file
533
web/src/styles/clock.css
Normal file
@@ -0,0 +1,533 @@
|
||||
/* The clock overlay (ClockOverlay.tsx), ported from `claude-design/Delphin Uhr.dc.html`.
|
||||
Sized in vmin like the design, so the same watch fills a 1080p kiosk and a phone held
|
||||
upright. */
|
||||
|
||||
.clock-overlay {
|
||||
--clock-ink: oklch(28% 0.04 220);
|
||||
--clock-pink: oklch(70% 0.16 340);
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 8;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6vmin;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
oklch(55% 0.07 var(--hue)) 0%,
|
||||
oklch(38% 0.06 var(--hue)) 45%,
|
||||
oklch(20% 0.045 var(--hue)) 100%
|
||||
);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* `display: flex` above would otherwise beat the `hidden` attribute. */
|
||||
.clock-overlay[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Upright screens: the countdowns go under the watch instead of beside it. */
|
||||
@media (orientation: portrait) {
|
||||
.clock-overlay {
|
||||
flex-direction: column;
|
||||
gap: 4vmin;
|
||||
}
|
||||
}
|
||||
|
||||
.clock-badge {
|
||||
position: absolute;
|
||||
top: calc(18px + env(safe-area-inset-top));
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 1.2vmin 3vmin;
|
||||
border-radius: 999px;
|
||||
font-size: max(16px, 3vmin);
|
||||
font-weight: 900;
|
||||
white-space: nowrap;
|
||||
color: var(--clock-ink);
|
||||
background: oklch(97% 0.01 var(--hue) / 0.75);
|
||||
box-shadow: 0 1vmin 3vmin oklch(15% 0.05 var(--hue) / 0.3);
|
||||
}
|
||||
|
||||
.clock-badge[data-school] {
|
||||
background: oklch(92% 0.08 85 / 0.92);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------- the watch */
|
||||
|
||||
.clock-watch {
|
||||
position: relative;
|
||||
flex: none;
|
||||
width: 78vmin;
|
||||
height: 96vmin;
|
||||
filter: drop-shadow(0 3vmin 5vmin oklch(15% 0.05 210 / 0.5));
|
||||
}
|
||||
|
||||
.clock-strap {
|
||||
position: absolute;
|
||||
width: 15vmin;
|
||||
height: 24vmin;
|
||||
box-sizing: border-box;
|
||||
border: 0.55vmin solid var(--clock-ink);
|
||||
--strap: oklch(70% 0.16 340);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--strap) 0%,
|
||||
var(--strap) 70%,
|
||||
oklch(from var(--strap) calc(l - 0.08) c h) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.clock-strap[data-pos^="top"] {
|
||||
top: 0;
|
||||
border-radius: 2.4vmin 2.4vmin 0.8vmin 0.8vmin;
|
||||
transform-origin: 50% 100%;
|
||||
}
|
||||
|
||||
.clock-strap[data-pos^="bottom"] {
|
||||
bottom: 0;
|
||||
border-radius: 0.8vmin 0.8vmin 2.4vmin 2.4vmin;
|
||||
transform-origin: 50% 0%;
|
||||
}
|
||||
|
||||
.clock-strap[data-pos="top-left"] {
|
||||
left: 16vmin;
|
||||
transform: perspective(40vmin) rotateX(10deg) rotate(-8deg);
|
||||
}
|
||||
|
||||
.clock-strap[data-pos="top-right"] {
|
||||
right: 16vmin;
|
||||
--strap: oklch(66% 0.12 205);
|
||||
transform: perspective(40vmin) rotateX(10deg) rotate(8deg);
|
||||
}
|
||||
|
||||
.clock-strap[data-pos="bottom-left"] {
|
||||
left: 16vmin;
|
||||
--strap: oklch(64% 0.11 175);
|
||||
transform: perspective(40vmin) rotateX(-10deg) rotate(8deg);
|
||||
}
|
||||
|
||||
.clock-strap[data-pos="bottom-right"] {
|
||||
right: 16vmin;
|
||||
--strap: oklch(58% 0.12 245);
|
||||
transform: perspective(40vmin) rotateX(-10deg) rotate(-8deg);
|
||||
}
|
||||
|
||||
.clock-button {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 6vmin;
|
||||
height: 10vmin;
|
||||
margin: -5vmin 0 0 -3vmin;
|
||||
box-sizing: border-box;
|
||||
border-radius: 1.6vmin;
|
||||
border: 0.55vmin solid var(--clock-ink);
|
||||
}
|
||||
|
||||
.clock-button[data-hue="pink"] {
|
||||
background: var(--clock-pink);
|
||||
}
|
||||
|
||||
.clock-button[data-hue="205"] {
|
||||
background: oklch(66% 0.12 205);
|
||||
}
|
||||
|
||||
.clock-face {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 9vmin;
|
||||
width: 78vmin;
|
||||
height: 78vmin;
|
||||
box-sizing: border-box;
|
||||
border-radius: 50%;
|
||||
border: 0.6vmin solid var(--clock-ink);
|
||||
background:
|
||||
radial-gradient(
|
||||
circle closest-side,
|
||||
transparent 88.6%,
|
||||
var(--clock-ink) 88.6%,
|
||||
var(--clock-ink) 89.6%,
|
||||
transparent 89.6%
|
||||
),
|
||||
conic-gradient(
|
||||
from 210deg,
|
||||
oklch(94% 0.008 210),
|
||||
oklch(81% 0.015 210),
|
||||
oklch(96% 0.006 210),
|
||||
oklch(79% 0.015 210),
|
||||
oklch(94% 0.008 210)
|
||||
);
|
||||
}
|
||||
|
||||
.clock-face > * {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
/* The bezel's 48 dots: big coloured ones on the 5-minute marks, small grey between. */
|
||||
.clock-dot {
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 1.1vmin;
|
||||
height: 1.1vmin;
|
||||
border-radius: 50%;
|
||||
background: oklch(62% 0.02 220);
|
||||
box-shadow: inset 0 0 0 0.2vmin oklch(30% 0.03 220 / 0.35);
|
||||
}
|
||||
|
||||
.clock-dot[data-big] {
|
||||
width: 1.9vmin;
|
||||
height: 1.9vmin;
|
||||
}
|
||||
|
||||
.clock-dot[data-hue="205"] {
|
||||
background: oklch(64% 0.12 205);
|
||||
}
|
||||
|
||||
.clock-dot[data-hue="175"] {
|
||||
background: oklch(64% 0.12 175);
|
||||
}
|
||||
|
||||
.clock-dot[data-hue="245"] {
|
||||
background: oklch(64% 0.12 245);
|
||||
}
|
||||
|
||||
.clock-dot[data-hue="pink"] {
|
||||
background: var(--clock-pink);
|
||||
}
|
||||
|
||||
/* Every ring below is centred on the face; only the diameter differs. */
|
||||
.clock-face-inner,
|
||||
.clock-sections,
|
||||
.clock-sections-gaps,
|
||||
.clock-sections-rim,
|
||||
.clock-center,
|
||||
.clock-minute-track,
|
||||
.clock-minute-ticks,
|
||||
.clock-minute-ticks-major {
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: var(--d);
|
||||
height: var(--d);
|
||||
margin: calc(var(--d) / -2) 0 0 calc(var(--d) / -2);
|
||||
box-sizing: border-box;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.clock-face-inner {
|
||||
--d: 68.4vmin;
|
||||
background: oklch(99% 0.005 210);
|
||||
box-shadow: inset 0 0.6vmin 1.4vmin oklch(30% 0.03 220 / 0.18);
|
||||
}
|
||||
|
||||
.clock-sections {
|
||||
--d: 55.6vmin;
|
||||
background: conic-gradient(
|
||||
from -15deg,
|
||||
oklch(91% 0.06 205) 0deg 30deg,
|
||||
oklch(91% 0.06 175) 30deg 60deg,
|
||||
oklch(91% 0.06 245) 60deg 90deg,
|
||||
oklch(91% 0.07 345) 90deg 120deg,
|
||||
oklch(91% 0.06 205) 120deg 150deg,
|
||||
oklch(91% 0.06 175) 150deg 180deg,
|
||||
oklch(91% 0.06 245) 180deg 210deg,
|
||||
oklch(91% 0.07 345) 210deg 240deg,
|
||||
oklch(91% 0.06 205) 240deg 270deg,
|
||||
oklch(91% 0.06 175) 270deg 300deg,
|
||||
oklch(91% 0.06 245) 300deg 330deg,
|
||||
oklch(91% 0.07 345) 330deg 360deg
|
||||
);
|
||||
}
|
||||
|
||||
.clock-sections-gaps {
|
||||
--d: 55.6vmin;
|
||||
background: repeating-conic-gradient(
|
||||
from 14.6deg,
|
||||
oklch(99% 0.005 210) 0deg 0.8deg,
|
||||
transparent 0.8deg 30deg
|
||||
);
|
||||
}
|
||||
|
||||
.clock-sections-rim {
|
||||
--d: 55.6vmin;
|
||||
border: 0.4vmin solid var(--clock-ink);
|
||||
}
|
||||
|
||||
.clock-center {
|
||||
--d: 20vmin;
|
||||
background: oklch(99% 0.005 210);
|
||||
box-shadow: 0 0 0 0.4vmin oklch(28% 0.04 220 / 0.25);
|
||||
}
|
||||
|
||||
.clock-minute-track {
|
||||
--d: 66.8vmin;
|
||||
background: conic-gradient(
|
||||
from -15deg,
|
||||
oklch(80% 0.1 205) 0deg 30deg,
|
||||
oklch(80% 0.1 175) 30deg 60deg,
|
||||
oklch(80% 0.1 245) 60deg 90deg,
|
||||
oklch(80% 0.13 345) 90deg 120deg,
|
||||
oklch(80% 0.1 205) 120deg 150deg,
|
||||
oklch(80% 0.1 175) 150deg 180deg,
|
||||
oklch(80% 0.1 245) 180deg 210deg,
|
||||
oklch(80% 0.13 345) 210deg 240deg,
|
||||
oklch(80% 0.1 205) 240deg 270deg,
|
||||
oklch(80% 0.1 175) 270deg 300deg,
|
||||
oklch(80% 0.1 245) 300deg 330deg,
|
||||
oklch(80% 0.13 345) 330deg 360deg
|
||||
);
|
||||
mask-image: radial-gradient(circle closest-side, transparent 82.8%, #000 83.2%);
|
||||
}
|
||||
|
||||
.clock-minute-ticks {
|
||||
--d: 66.8vmin;
|
||||
background: repeating-conic-gradient(
|
||||
from -0.5deg,
|
||||
var(--clock-ink) 0deg 1deg,
|
||||
transparent 1deg 6deg
|
||||
);
|
||||
mask-image: radial-gradient(circle closest-side, transparent 94.6%, #000 95%);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.clock-minute-ticks-major {
|
||||
--d: 66.8vmin;
|
||||
background: repeating-conic-gradient(
|
||||
from -1.3deg,
|
||||
var(--clock-ink) 0deg 2.6deg,
|
||||
transparent 2.6deg 30deg
|
||||
);
|
||||
mask-image: radial-gradient(circle closest-side, transparent 93.2%, #000 93.6%);
|
||||
}
|
||||
|
||||
.clock-numbers {
|
||||
inset: 0;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.clock-numbers > div {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
}
|
||||
|
||||
.clock-numbers-minutes {
|
||||
font-size: 2.3vmin;
|
||||
color: var(--clock-ink);
|
||||
}
|
||||
|
||||
.clock-numbers-hours {
|
||||
font-size: 8.2vmin;
|
||||
letter-spacing: -0.3vmin;
|
||||
-webkit-text-stroke: 0.45vmin var(--clock-ink);
|
||||
paint-order: stroke fill;
|
||||
}
|
||||
|
||||
.clock-numbers-hours > [data-hue="205"] {
|
||||
color: oklch(56% 0.13 205);
|
||||
}
|
||||
|
||||
.clock-numbers-hours > [data-hue="175"] {
|
||||
color: oklch(56% 0.13 175);
|
||||
}
|
||||
|
||||
.clock-numbers-hours > [data-hue="245"] {
|
||||
color: oklch(56% 0.13 245);
|
||||
}
|
||||
|
||||
.clock-numbers-hours > [data-hue="pink"] {
|
||||
color: oklch(60% 0.19 345);
|
||||
}
|
||||
|
||||
/* A hand is a box standing on the centre, rotated about its foot. */
|
||||
.clock-hand {
|
||||
left: 50%;
|
||||
bottom: 50%;
|
||||
transform-origin: 50% 100%;
|
||||
}
|
||||
|
||||
.clock-hand-hour,
|
||||
.clock-hand-minute {
|
||||
filter: drop-shadow(0.35vmin 0 0 var(--clock-ink)) drop-shadow(-0.35vmin 0 0 var(--clock-ink))
|
||||
drop-shadow(0 0.35vmin 0 var(--clock-ink)) drop-shadow(0 -0.35vmin 0 var(--clock-ink))
|
||||
drop-shadow(0 1vmin 1vmin oklch(15% 0.05 210 / 0.3));
|
||||
}
|
||||
|
||||
.clock-hand > div {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.clock-hand-hour {
|
||||
width: 5.4vmin;
|
||||
height: 15vmin;
|
||||
margin-left: -2.7vmin;
|
||||
}
|
||||
|
||||
.clock-hand-hour > div {
|
||||
bottom: -3.5vmin;
|
||||
background: oklch(92% 0.06 345);
|
||||
clip-path: polygon(50% 0%, 100% 22%, 66% 22%, 66% 100%, 34% 100%, 34% 22%, 0% 22%);
|
||||
}
|
||||
|
||||
.clock-hand-minute {
|
||||
width: 5vmin;
|
||||
height: 24vmin;
|
||||
margin-left: -2.5vmin;
|
||||
}
|
||||
|
||||
.clock-hand-minute > div {
|
||||
bottom: -3.5vmin;
|
||||
background: oklch(92% 0.05 200);
|
||||
clip-path: polygon(50% 0%, 100% 14%, 64% 14%, 64% 100%, 36% 100%, 36% 14%, 0% 14%);
|
||||
}
|
||||
|
||||
.clock-hand-second {
|
||||
width: 0.7vmin;
|
||||
height: 27vmin;
|
||||
margin-left: -0.35vmin;
|
||||
}
|
||||
|
||||
.clock-hand-second > div {
|
||||
bottom: -5vmin;
|
||||
border-radius: 1vmin;
|
||||
background: oklch(64% 0.19 345);
|
||||
}
|
||||
|
||||
.clock-pin {
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 4.2vmin;
|
||||
height: 4.2vmin;
|
||||
margin: -2.1vmin 0 0 -2.1vmin;
|
||||
border-radius: 50%;
|
||||
background: oklch(80% 0.1 205);
|
||||
box-shadow: 0 0 0 0.5vmin var(--clock-ink);
|
||||
}
|
||||
|
||||
.clock-pin-dot {
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 1.4vmin;
|
||||
height: 1.4vmin;
|
||||
margin: -0.7vmin 0 0 -0.7vmin;
|
||||
border-radius: 50%;
|
||||
background: var(--clock-ink);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- the countdowns */
|
||||
|
||||
.clock-countdowns {
|
||||
position: relative;
|
||||
flex: 0 1 74vmin;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3vmin;
|
||||
}
|
||||
|
||||
@media (orientation: portrait) {
|
||||
.clock-countdowns {
|
||||
flex: none;
|
||||
width: 88vw;
|
||||
}
|
||||
}
|
||||
|
||||
.clock-card {
|
||||
--clock-bar: oklch(66% 0.12 205);
|
||||
--clock-time: oklch(30% 0.05 210);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3.5vmin;
|
||||
padding: 3vmin 4vmin 3vmin 3vmin;
|
||||
border-radius: 4.5vmin;
|
||||
background: oklch(97% 0.01 210 / 0.68);
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: 0 2vmin 5vmin oklch(15% 0.05 210 / 0.35);
|
||||
}
|
||||
|
||||
[data-blur="off"] .clock-card {
|
||||
backdrop-filter: none;
|
||||
background: oklch(97% 0.01 210 / 0.88);
|
||||
}
|
||||
|
||||
.clock-card[data-soon] {
|
||||
--clock-bar: var(--clock-pink);
|
||||
--clock-time: oklch(58% 0.19 345);
|
||||
}
|
||||
|
||||
.clock-card[data-ringing] {
|
||||
animation: clockRing 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes clockRing {
|
||||
0%,
|
||||
100% {
|
||||
background: oklch(97% 0.01 210 / 0.68);
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
background: oklch(88% 0.09 345 / 0.92);
|
||||
transform: scale(1.03);
|
||||
}
|
||||
}
|
||||
|
||||
.clock-card-image {
|
||||
width: 24vmin;
|
||||
height: 18vmin;
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.clock-card-image > img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
filter: drop-shadow(0 1vmin 1.5vmin oklch(15% 0.05 210 / 0.3));
|
||||
}
|
||||
|
||||
.clock-card-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6vmin;
|
||||
}
|
||||
|
||||
.clock-card-time {
|
||||
font-size: 15vmin;
|
||||
font-weight: 900;
|
||||
line-height: 0.95;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: -0.3vmin;
|
||||
color: var(--clock-time);
|
||||
}
|
||||
|
||||
.clock-card-bar {
|
||||
height: 2vmin;
|
||||
margin-top: 0.8vmin;
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
background: oklch(30% 0.04 210 / 0.15);
|
||||
}
|
||||
|
||||
.clock-card-bar > div {
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background: var(--clock-bar);
|
||||
transition: width 0.25s linear;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.clock-card[data-ringing] {
|
||||
animation: none;
|
||||
background: oklch(88% 0.09 345 / 0.92);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user