Add a classic analog clock widget with customizable visibility

Adds a new `AnalogClockWidget` component with Roman numerals and a sweeping second hand, integrated into the home page. The widget's visibility can now be toggled via the `ClockStylePicker` and is persisted in `localStorage`.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 0a1df300-b156-461b-90d5-e9874f25113f
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/YPEna1J
Replit-Helium-Checkpoint-Created: true
This commit is contained in:
riyadhafraa
2026-04-21 06:57:47 +00:00
parent 72cd414208
commit b6219e9bbe
8 changed files with 305 additions and 11 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

@@ -7,13 +7,19 @@ import {
type AuthUser,
type ClockStyle as ClockStyleType,
} from "@workspace/api-client-react";
import { Clock as ClockIcon, Check } from "lucide-react";
import { Clock as ClockIcon, Check, Eye, EyeOff } from "lucide-react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Clock, CLOCK_STYLES, resolveClockStyle, useNow } from "@/components/clock";
import {
Clock,
CLOCK_STYLES,
resolveClockStyle,
useNow,
useHomeClockVisibility,
} from "@/components/clock";
type Props = {
currentStyle: ClockStyleType | null | undefined;
@@ -28,6 +34,7 @@ export function ClockStylePicker({ currentStyle }: Props) {
const updateClockStyle = useUpdateClockStyle();
const active = resolveClockStyle(currentStyle);
const [homeClockVisible, setHomeClockVisible] = useHomeClockVisibility();
const choose = (style: ClockStyleType) => {
const previous = queryClient.getQueryData<AuthUser>(getGetMeQueryKey());
@@ -67,6 +74,37 @@ export function ClockStylePicker({ currentStyle }: Props) {
</button>
</PopoverTrigger>
<PopoverContent className="w-72 p-2" align="end">
<button
type="button"
onClick={() => setHomeClockVisible(!homeClockVisible)}
className="w-full flex items-center justify-between gap-3 px-2 py-2 rounded-lg text-start hover:bg-foreground/5 transition-colors mb-1"
>
<div className="flex items-center gap-2 min-w-0">
{homeClockVisible ? (
<Eye size={16} className="text-primary shrink-0" />
) : (
<EyeOff size={16} className="text-muted-foreground shrink-0" />
)}
<span className="text-xs text-foreground font-medium truncate">
{t("home.clockStyle.showWidget")}
</span>
</div>
<span
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors ${
homeClockVisible ? "bg-primary" : "bg-foreground/15"
}`}
aria-hidden="true"
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${
homeClockVisible
? "translate-x-[18px] rtl:-translate-x-[18px]"
: "translate-x-0.5 rtl:-translate-x-0.5"
}`}
/>
</span>
</button>
<div className="h-px bg-foreground/10 mx-1 mb-1" />
<div className="px-2 py-1.5 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{t("home.clockStyle.label")}
</div>
@@ -119,6 +119,252 @@ function AnalogClock({ time, size = "md" }: { time: Date; size?: "sm" | "md" })
);
}
/**
* Large, classic analog clock widget for the home screen.
* Roman numerals, 60 minute ticks, double-ring frame, sweeping
* seconds hand. Uses currentColor so it inherits the foreground
* color and works in any theme.
*/
export function AnalogClockWidget({
time,
lang,
className,
}: {
time: Date;
lang: string;
className?: string;
}) {
const hours = time.getHours() % 12;
const minutes = time.getMinutes();
const seconds = time.getSeconds();
const hourAngle = (hours + minutes / 60) * 30;
const minuteAngle = (minutes + seconds / 60) * 6;
const secondAngle = seconds * 6;
const size = 240;
const cx = size / 2;
const cy = size / 2;
const r = cx - 8;
const ROMAN = [
"XII",
"I",
"II",
"III",
"IV",
"V",
"VI",
"VII",
"VIII",
"IX",
"X",
"XI",
];
const numerals = ROMAN.map((roman, i) => {
const angle = ((i * 30 - 90) * Math.PI) / 180;
const numR = r - 22;
const x = cx + Math.cos(angle) * numR;
const y = cy + Math.sin(angle) * numR;
return (
<text
key={i}
x={x}
y={y}
textAnchor="middle"
dominantBaseline="central"
fontSize="15"
fontFamily="Georgia, 'Times New Roman', serif"
fontWeight="600"
fill="currentColor"
opacity={0.85}
>
{roman}
</text>
);
});
const ticks = Array.from({ length: 60 }, (_, i) => {
const angle = (i * 6 * Math.PI) / 180;
const isHour = i % 5 === 0;
const outer = r - 4;
const inner = isHour ? r - 11 : r - 7;
const x1 = cx + Math.sin(angle) * inner;
const y1 = cy - Math.cos(angle) * inner;
const x2 = cx + Math.sin(angle) * outer;
const y2 = cy - Math.cos(angle) * outer;
return (
<line
key={i}
x1={x1}
y1={y1}
x2={x2}
y2={y2}
stroke="currentColor"
strokeWidth={isHour ? 1.8 : 0.7}
strokeLinecap="round"
opacity={isHour ? 0.85 : 0.35}
/>
);
});
const hand = (
angle: number,
length: number,
width: number,
color: string,
opacity = 1,
tailLength = 0,
) => {
const rad = (angle * Math.PI) / 180;
const x = cx + Math.sin(rad) * length;
const y = cy - Math.cos(rad) * length;
const tx = cx - Math.sin(rad) * tailLength;
const ty = cy + Math.cos(rad) * tailLength;
return (
<line
x1={tx}
y1={ty}
x2={x}
y2={y}
stroke={color}
strokeWidth={width}
strokeLinecap="round"
opacity={opacity}
/>
);
};
return (
<div
className={`flex flex-col items-center gap-3 ${className ?? ""}`}
dir="ltr"
>
<div className="relative drop-shadow-sm">
<svg
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
className="text-foreground"
role="img"
aria-label="Analog clock"
>
<defs>
<radialGradient id="tbo-clock-face" cx="50%" cy="38%" r="65%">
<stop offset="0%" stopColor="white" stopOpacity="0.95" />
<stop offset="100%" stopColor="white" stopOpacity="0.55" />
</radialGradient>
<radialGradient id="tbo-clock-bezel" cx="50%" cy="50%" r="50%">
<stop offset="85%" stopColor="currentColor" stopOpacity="0" />
<stop offset="100%" stopColor="currentColor" stopOpacity="0.2" />
</radialGradient>
</defs>
{/* Outer bezel ring */}
<circle
cx={cx}
cy={cy}
r={r + 6}
fill="none"
stroke="currentColor"
strokeWidth={1.2}
opacity={0.25}
/>
{/* Face */}
<circle
cx={cx}
cy={cy}
r={r + 2}
fill="url(#tbo-clock-face)"
stroke="currentColor"
strokeWidth={1}
opacity={0.9}
/>
<circle cx={cx} cy={cy} r={r + 2} fill="url(#tbo-clock-bezel)" />
{/* Inner ring */}
<circle
cx={cx}
cy={cy}
r={r - 14}
fill="none"
stroke="currentColor"
strokeWidth={0.5}
opacity={0.18}
/>
{ticks}
{numerals}
{/* Hour hand */}
{hand(hourAngle, r - 60, 4.5, "currentColor", 0.95, 8)}
{/* Minute hand */}
{hand(minuteAngle, r - 28, 3, "currentColor", 0.85, 10)}
{/* Second hand */}
{hand(secondAngle, r - 18, 1.2, "hsl(var(--primary))", 1, 22)}
{/* Center pin */}
<circle cx={cx} cy={cy} r={5.5} fill="currentColor" />
<circle cx={cx} cy={cy} r={2} fill="hsl(var(--primary))" />
</svg>
</div>
<div className="text-center">
<div className="font-mono text-base font-semibold tabular-nums text-foreground tracking-tight">
{formatTime(time, lang, {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
})}
</div>
<div className="text-[11px] text-muted-foreground mt-0.5 tabular-nums">
{formatWeekday(time, lang)} · {formatDate(time, lang)}
</div>
</div>
</div>
);
}
const HOME_CLOCK_STORAGE_KEY = "teaboy:home-clock-visible";
const HOME_CLOCK_EVENT = "teaboy:home-clock-changed";
function readHomeClockVisible(): boolean {
if (typeof window === "undefined") return true;
try {
const v = window.localStorage.getItem(HOME_CLOCK_STORAGE_KEY);
return v === null ? true : v === "1";
} catch {
return true;
}
}
/**
* Small hook to read & persist whether the big home-screen analog
* clock widget is visible. Persists in localStorage and broadcasts
* a custom event so multiple consumers stay in sync within the same
* tab (storage events only fire across tabs).
*/
export function useHomeClockVisibility(): [boolean, (next: boolean) => void] {
const [visible, setVisibleState] = useState<boolean>(readHomeClockVisible);
useEffect(() => {
const onChange = () => setVisibleState(readHomeClockVisible());
window.addEventListener(HOME_CLOCK_EVENT, onChange);
window.addEventListener("storage", onChange);
return () => {
window.removeEventListener(HOME_CLOCK_EVENT, onChange);
window.removeEventListener("storage", onChange);
};
}, []);
const setVisible = (next: boolean) => {
try {
window.localStorage.setItem(HOME_CLOCK_STORAGE_KEY, next ? "1" : "0");
} catch {
/* ignore */
}
setVisibleState(next);
window.dispatchEvent(new Event(HOME_CLOCK_EVENT));
};
return [visible, setVisible];
}
export function Clock({ style, time, lang, size = "md" }: ClockProps) {
const resolved = resolveClockStyle(style);
const dayName = formatWeekday(time, lang);
+1
View File
@@ -80,6 +80,7 @@
},
"clockStyle": {
"label": "نمط الساعة",
"showWidget": "إظهار ساعة الحائط في الشاشة الرئيسية",
"options": {
"full": "كامل (الوقت واليوم والتقويمان الهجري والميلادي)",
"digital": "رقمي مع الثواني",
+1
View File
@@ -80,6 +80,7 @@
},
"clockStyle": {
"label": "Clock style",
"showWidget": "Show wall clock on home screen",
"options": {
"full": "Full (time, weekday, Hijri & Gregorian)",
"digital": "Digital with seconds",
+17 -9
View File
@@ -52,7 +52,7 @@ import {
formatNumber,
greetingKey,
} from "@/lib/i18n-format";
import { Clock, useNow } from "@/components/clock";
import { Clock, useNow, AnalogClockWidget, useHomeClockVisibility } from "@/components/clock";
import { ClockStylePicker } from "@/components/clock-style-picker";
type IconName = keyof typeof LucideIcons;
@@ -226,6 +226,7 @@ export default function HomePage() {
const isAdmin = user?.roles?.includes("admin") ?? false;
const greeting = t(`home.greeting.${greetingKey(time)}`, { name: displayName });
const [homeClockVisible] = useHomeClockVisibility();
const handleLogout = () => {
logout.mutate(undefined, {
@@ -303,14 +304,21 @@ export default function HomePage() {
{/* Main Content */}
<div className="flex-1 px-5 sm:px-8 pt-6 pb-32 overflow-y-auto">
{/* Greeting */}
<div className="mb-6 max-w-3xl">
<h1 className="text-2xl sm:text-3xl font-bold text-foreground tracking-tight">
{greeting}
</h1>
<p className="text-sm text-muted-foreground mt-1">
{dayName} · <span className="tabular-nums">{gregorianDate}</span>
</p>
{/* Greeting + optional analog clock widget */}
<div className="mb-8 flex items-start justify-between gap-6 flex-wrap">
<div className="max-w-3xl min-w-0">
<h1 className="text-2xl sm:text-3xl font-bold text-foreground tracking-tight">
{greeting}
</h1>
<p className="text-sm text-muted-foreground mt-1">
{dayName} · <span className="tabular-nums">{gregorianDate}</span>
</p>
</div>
{homeClockVisible && (
<div className="hidden md:block shrink-0">
<AnalogClockWidget time={time} lang={lang} />
</div>
)}
</div>
{/* Stats Row — admin only */}
Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 412 KiB