feat(clock): bigger footer tabs, session scan counter, wake lock + haptics

Footer buttons gain icons and more padding for easier tapping on a
wall-mounted kiosk phone. The header now shows a running count of
badges scanned since the app was opened. PersonScanner also requests a
screen wake lock while its camera is active (re-acquired on
visibilitychange, since the OS revokes it when backgrounded) and adds
vibration feedback per scan outcome, so a kiosk phone doesn't need to
be watched closely to confirm a scan landed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
DEls 2026-09-12 00:51:53 +02:00
parent a7ac99d934
commit 3ec2db9b2a
2 changed files with 62 additions and 10 deletions

View file

@ -1,6 +1,7 @@
"use client";
import * as React from "react";
import { Truck, User } from "lucide-react";
import { cn } from "@/lib/utils";
import { PersonScanner } from "./person-scanner";
@ -9,6 +10,7 @@ type Tab = "person" | "fleet";
export function ClockView() {
const [tab, setTab] = React.useState<Tab>("person");
const [now, setNow] = React.useState(() => new Date());
const [scanCount, setScanCount] = React.useState(0);
React.useEffect(() => {
const id = setInterval(() => setNow(new Date()), 30_000);
@ -19,19 +21,41 @@ export function ClockView() {
<div className="flex h-dvh flex-col bg-background">
<header className="flex items-center justify-between border-b border-border px-4 py-3">
<span className="text-lg font-bold text-foreground">MEGA Clock</span>
<span className="text-sm text-muted-foreground">
{now.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
</span>
<div className="flex items-center gap-2">
<span
className="rounded-full bg-primary/10 px-2.5 py-1 text-xs font-semibold text-primary"
title="Badges scanned since this app was opened"
>
{scanCount} scans
</span>
<span className="text-sm text-muted-foreground">
{now.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
</span>
</div>
</header>
<main className="flex flex-1 flex-col overflow-hidden">
{tab === "person" ? <PersonScanner /> : <FleetComingSoon />}
{tab === "person" ? (
<PersonScanner onScan={() => setScanCount((c) => c + 1)} />
) : (
<FleetComingSoon />
)}
</main>
<footer className="clock-footer flex border-t-2 border-primary/70 shadow-[0_-4px_12px_-4px_rgba(0,0,0,0.15)]">
<TabButton label="Person" active={tab === "person"} onClick={() => setTab("person")} />
<span aria-hidden className="my-3 w-px bg-border" />
<TabButton label="Fleet" active={tab === "fleet"} onClick={() => setTab("fleet")} />
<TabButton
label="Person"
icon={User}
active={tab === "person"}
onClick={() => setTab("person")}
/>
<span aria-hidden className="my-4 w-px bg-border" />
<TabButton
label="Fleet"
icon={Truck}
active={tab === "fleet"}
onClick={() => setTab("fleet")}
/>
</footer>
</div>
);
@ -39,10 +63,12 @@ export function ClockView() {
function TabButton({
label,
icon: Icon,
active,
onClick,
}: {
label: string;
icon: React.ComponentType<{ className?: string }>;
active: boolean;
onClick: () => void;
}) {
@ -51,10 +77,11 @@ function TabButton({
type="button"
onClick={onClick}
className={cn(
"flex-1 py-4 text-center text-sm font-semibold",
"flex flex-1 flex-col items-center justify-center gap-1 py-6 text-center text-base font-semibold",
active ? "text-primary" : "text-muted-foreground",
)}
>
<Icon className="h-7 w-7" />
{label}
</button>
);

View file

@ -12,7 +12,7 @@ type ScanResult =
| { kind: "out"; employeeName: string; sessionHours: number; dailyTotalHours: number }
| { kind: "error"; message: string };
export function PersonScanner() {
export function PersonScanner({ onScan }: { onScan?: () => void }) {
const videoRef = React.useRef<HTMLVideoElement>(null);
const canvasRef = React.useRef<HTMLCanvasElement>(null);
const lastCodeRef = React.useRef<{ value: string; at: number } | null>(null);
@ -42,6 +42,8 @@ export function PersonScanner() {
}
if (responseBody.direction === "in") {
setResult({ kind: "in", employeeName: responseBody.employeeName });
navigator.vibrate?.(150);
onScan?.();
} else {
setResult({
kind: "out",
@ -49,21 +51,40 @@ export function PersonScanner() {
sessionHours: responseBody.sessionHours,
dailyTotalHours: responseBody.dailyTotalHours,
});
navigator.vibrate?.([100, 80, 100]);
onScan?.();
}
})
.catch(() => {
setResult({ kind: "error", message: "Couldn't reach the server — try again" });
navigator.vibrate?.(300);
})
.finally(() => {
busyRef.current = false;
setTimeout(() => setResult(null), BANNER_MS);
});
}, []);
}, [onScan]);
React.useEffect(() => {
let stream: MediaStream | null = null;
let rafId = 0;
let cancelled = false;
let wakeLock: WakeLockSentinel | null = null;
// A kiosk phone mounted on a wall must not let its screen lock mid-shift.
// The OS can revoke the lock when the tab is backgrounded (app-switch,
// screen-off from a hardware button), so it's re-requested on return.
async function requestWakeLock() {
try {
wakeLock = (await navigator.wakeLock?.request("screen")) ?? null;
} catch {
// Unsupported or denied — not fatal, the screen may just dim on its own.
}
}
function handleVisibilityChange() {
if (document.visibilityState === "visible") requestWakeLock();
}
async function tick() {
if (cancelled) return;
@ -119,14 +140,18 @@ export function PersonScanner() {
if (window.BarcodeDetector) {
detectorRef.current = new window.BarcodeDetector({ formats: ["qr_code"] });
}
await requestWakeLock();
tick();
}
document.addEventListener("visibilitychange", handleVisibilityChange);
start();
return () => {
cancelled = true;
cancelAnimationFrame(rafId);
document.removeEventListener("visibilitychange", handleVisibilityChange);
wakeLock?.release().catch(() => {});
stream?.getTracks().forEach((t) => t.stop());
};
}, [handleDecoded]);