Task #55: Movable, resizable home clock with Latin digits

- Refactor AnalogClockWidget: Latin 1-12 digits (was Roman), `size`
  (px) prop with scale-based interior, optional `showLabels`.
- Add useHomeClockSize (compact|large) and useHomeClockPosition hooks
  in clock.tsx — both persist to localStorage and broadcast a custom
  event so consumers stay in sync within the tab.
- home.tsx: introduce SortableClockTile that participates in the same
  dnd-kit grid as app icons via id `__clock`. App ids now prefixed
  `app-` so they don't collide. Long-press (500ms) toggles
  compact↔large; large = col-span-2 row-span-2. Custom pointer
  handlers chain with dnd-kit's listener so drag still works.
- handleDragEnd computes new app order excluding the clock and only
  fires updateOrder when app order actually changed.
- Remove the standalone AnalogClockWidget block above the apps grid;
  clock now lives inside the grid.
- When homeClockVisible is false, the clock is also excluded from
  sortable items (no dnd-kit warnings).
- e2e test passed: in-grid placement, Latin digits, long-press
  resize, drag-reorder, position persists across reload.
This commit is contained in:
Riyadh
2026-04-21 12:56:33 +00:00
parent f32145d3e7
commit 3734d1e99f
3 changed files with 302 additions and 72 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

+118 -35
View File
@@ -127,21 +127,25 @@ 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.
* Classic analog clock widget for the home screen. Latin digits 1-12,
* 60 minute ticks, double-ring frame, sweeping seconds hand. Uses
* currentColor so it inherits the foreground color and works in any
* theme. The `size` prop controls overall pixel diameter.
*/
export function AnalogClockWidget({
time,
lang,
hour12,
className,
size = 240,
showLabels = true,
}: {
time: Date;
lang: string;
hour12?: boolean | null;
className?: string;
size?: number;
showLabels?: boolean;
}) {
const use12 = resolveClockHour12(hour12);
const hours = time.getHours() % 12;
@@ -152,29 +156,31 @@ export function AnalogClockWidget({
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 r = cx - 4;
const scale = size / 240;
const ROMAN = [
"XII",
"I",
"II",
"III",
"IV",
"V",
"VI",
"VII",
"VIII",
"IX",
"X",
"XI",
const DIGITS = [
"12",
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9",
"10",
"11",
];
const numerals = ROMAN.map((roman, i) => {
const numeralOffset = Math.max(10, 18 * scale);
const numeralFont = Math.max(8, 14 * scale);
const numerals = DIGITS.map((digit, i) => {
const angle = ((i * 30 - 90) * Math.PI) / 180;
const numR = r - 22;
const numR = r - numeralOffset;
const x = cx + Math.cos(angle) * numR;
const y = cy + Math.sin(angle) * numR;
return (
@@ -184,13 +190,13 @@ export function AnalogClockWidget({
y={y}
textAnchor="middle"
dominantBaseline="central"
fontSize="15"
fontFamily="Georgia, 'Times New Roman', serif"
fontWeight="600"
fontSize={numeralFont}
fontFamily="ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif"
fontWeight="700"
fill="currentColor"
opacity={0.85}
opacity={0.9}
>
{roman}
{digit}
</text>
);
});
@@ -198,8 +204,8 @@ export function AnalogClockWidget({
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 outer = r - 2 * scale;
const inner = isHour ? r - 8 * scale : r - 5 * scale;
const x1 = cx + Math.sin(angle) * inner;
const y1 = cy - Math.cos(angle) * inner;
const x2 = cx + Math.sin(angle) * outer;
@@ -304,18 +310,19 @@ export function AnalogClockWidget({
{ticks}
{numerals}
{/* Hour hand */}
{hand(hourAngle, r - 60, 4.5, "currentColor", 0.95, 8)}
{hand(hourAngle, r - 50 * scale, 4.5 * scale, "currentColor", 0.95, 8 * scale)}
{/* Minute hand */}
{hand(minuteAngle, r - 28, 3, "currentColor", 0.85, 10)}
{hand(minuteAngle, r - 22 * scale, 3 * scale, "currentColor", 0.85, 10 * scale)}
{/* Second hand */}
{hand(secondAngle, r - 18, 1.2, "hsl(var(--primary))", 1, 22)}
{hand(secondAngle, r - 14 * scale, 1.2 * scale, "hsl(var(--primary))", 1, 18 * scale)}
{/* Center pin */}
<circle cx={cx} cy={cy} r={5.5} fill="currentColor" />
<circle cx={cx} cy={cy} r={2} fill="hsl(var(--primary))" />
<circle cx={cx} cy={cy} r={4 * scale} fill="currentColor" />
<circle cx={cx} cy={cy} r={1.5 * scale} fill="hsl(var(--primary))" />
</svg>
</div>
{showLabels && (
<div className="text-center">
<div className="font-mono text-base font-semibold tabular-nums text-foreground tracking-tight">
<div className="font-mono text-sm font-semibold tabular-nums text-foreground tracking-tight">
{formatTime(time, lang, {
hour: "2-digit",
minute: "2-digit",
@@ -323,10 +330,11 @@ export function AnalogClockWidget({
hour12: use12,
})}
</div>
<div className="text-[11px] text-muted-foreground mt-0.5 tabular-nums">
<div className="text-[10px] text-muted-foreground mt-0.5 tabular-nums">
{formatWeekday(time, lang)} · {formatDate(time, lang)}
</div>
</div>
)}
</div>
);
}
@@ -376,6 +384,81 @@ export function useHomeClockVisibility(): [boolean, (next: boolean) => void] {
return [visible, setVisible];
}
const HOME_CLOCK_SIZE_KEY = "teaboy:home-clock-size";
const HOME_CLOCK_SIZE_EVENT = "teaboy:home-clock-size-changed";
export type HomeClockSize = "compact" | "large";
function readHomeClockSize(): HomeClockSize {
if (typeof window === "undefined") return "compact";
try {
const v = window.localStorage.getItem(HOME_CLOCK_SIZE_KEY);
return v === "large" ? "large" : "compact";
} catch {
return "compact";
}
}
export function useHomeClockSize(): [HomeClockSize, (next: HomeClockSize) => void] {
const [size, setSizeState] = useState<HomeClockSize>(readHomeClockSize);
useEffect(() => {
const onChange = () => setSizeState(readHomeClockSize());
window.addEventListener(HOME_CLOCK_SIZE_EVENT, onChange);
window.addEventListener("storage", onChange);
return () => {
window.removeEventListener(HOME_CLOCK_SIZE_EVENT, onChange);
window.removeEventListener("storage", onChange);
};
}, []);
const setSize = (next: HomeClockSize) => {
try {
window.localStorage.setItem(HOME_CLOCK_SIZE_KEY, next);
} catch {
/* ignore */
}
setSizeState(next);
window.dispatchEvent(new Event(HOME_CLOCK_SIZE_EVENT));
};
return [size, setSize];
}
const HOME_CLOCK_POS_KEY = "teaboy:home-clock-position";
const HOME_CLOCK_POS_EVENT = "teaboy:home-clock-pos-changed";
function readHomeClockPosition(): number {
if (typeof window === "undefined") return 0;
try {
const v = window.localStorage.getItem(HOME_CLOCK_POS_KEY);
if (v === null) return 0;
const n = Number(v);
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : 0;
} catch {
return 0;
}
}
export function useHomeClockPosition(): [number, (next: number) => void] {
const [pos, setPosState] = useState<number>(readHomeClockPosition);
useEffect(() => {
const onChange = () => setPosState(readHomeClockPosition());
window.addEventListener(HOME_CLOCK_POS_EVENT, onChange);
window.addEventListener("storage", onChange);
return () => {
window.removeEventListener(HOME_CLOCK_POS_EVENT, onChange);
window.removeEventListener("storage", onChange);
};
}, []);
const setPos = (next: number) => {
try {
window.localStorage.setItem(HOME_CLOCK_POS_KEY, String(next));
} catch {
/* ignore */
}
setPosState(next);
window.dispatchEvent(new Event(HOME_CLOCK_POS_EVENT));
};
return [pos, setPos];
}
export function Clock({ style, time, lang, hour12, size = "md" }: ClockProps) {
const resolved = resolveClockStyle(style);
const use12 = resolveClockHour12(hour12);
+169 -22
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useMemo } from "react";
import { useState, useEffect, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "wouter";
import {
@@ -48,11 +48,20 @@ import {
import { CSS } from "@dnd-kit/utilities";
import {
formatDate,
formatTime,
formatWeekday,
formatNumber,
greetingKey,
} from "@/lib/i18n-format";
import { Clock, useNow, AnalogClockWidget, useHomeClockVisibility } from "@/components/clock";
import {
Clock,
useNow,
AnalogClockWidget,
useHomeClockVisibility,
useHomeClockSize,
useHomeClockPosition,
type HomeClockSize,
} from "@/components/clock";
import { ClockStylePicker } from "@/components/clock-style-picker";
type IconName = keyof typeof LucideIcons;
@@ -99,7 +108,7 @@ function AppIconContent({ app }: { app: { nameAr: string; nameEn: string; iconNa
}
function SortableAppIcon({ app, onClick }: { app: App; onClick: () => void }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: app.id });
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: `app-${app.id}` });
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
@@ -122,6 +131,111 @@ function SortableAppIcon({ app, onClick }: { app: App; onClick: () => void }) {
);
}
const CLOCK_TILE_ID = "__clock";
function SortableClockTile({
time,
lang,
hour12,
size,
onToggleSize,
}: {
time: Date;
lang: string;
hour12: boolean | null | undefined;
size: HomeClockSize;
onToggleSize: () => void;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: CLOCK_TILE_ID,
});
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.6 : 1,
zIndex: isDragging ? 20 : "auto",
touchAction: "none",
};
const longPressTimer = useRef<number | null>(null);
const startPos = useRef<{ x: number; y: number } | null>(null);
const clearTimer = () => {
if (longPressTimer.current !== null) {
window.clearTimeout(longPressTimer.current);
longPressTimer.current = null;
}
};
const dndPointerDown = listeners?.onPointerDown as
| ((e: React.PointerEvent) => void)
| undefined;
const onPointerDown = (e: React.PointerEvent) => {
startPos.current = { x: e.clientX, y: e.clientY };
clearTimer();
longPressTimer.current = window.setTimeout(() => {
onToggleSize();
}, 500);
dndPointerDown?.(e);
};
const onPointerMove = (e: React.PointerEvent) => {
if (!startPos.current) return;
const dx = e.clientX - startPos.current.x;
const dy = e.clientY - startPos.current.y;
if (dx * dx + dy * dy > 64) clearTimer();
};
const onPointerUp = () => {
clearTimer();
startPos.current = null;
};
const isLarge = size === "large";
const px = isLarge ? 168 : 78;
const { onPointerDown: _ignored, ...restListeners } = listeners ?? {};
return (
<div
ref={setNodeRef}
style={style}
className={`flex flex-col items-center gap-2 group select-none ${isLarge ? "col-span-2 row-span-2" : ""}`}
{...attributes}
{...restListeners}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
>
<div
className={`icon-tile flex items-center justify-center bg-white/85 dark:bg-white/10 backdrop-blur-md text-foreground group-hover:scale-105 group-active:scale-95 ${
isLarge ? "w-[168px] h-[168px]" : "w-[78px] h-[78px]"
}`}
aria-label="Clock"
>
<AnalogClockWidget
time={time}
lang={lang}
hour12={hour12}
size={px - (isLarge ? 16 : 10)}
showLabels={false}
/>
</div>
<span className="text-[11px] text-foreground/85 font-medium text-center leading-tight max-w-[78px] truncate">
{formatTime(time, lang, {
hour: "2-digit",
minute: "2-digit",
hour12: resolveClockHour12Bool(hour12),
})}
</span>
</div>
);
}
function resolveClockHour12Bool(v: boolean | null | undefined): boolean {
return v ?? false;
}
type StatCardProps = {
label: string;
value: string;
@@ -174,17 +288,42 @@ export default function HomePage() {
useSensor(TouchSensor, { activationConstraint: { delay: 250, tolerance: 5 } }),
);
const [clockSize, setClockSize] = useHomeClockSize();
const [clockPos, setClockPos] = useHomeClockPosition();
const toggleClockSize = () => setClockSize(clockSize === "large" ? "compact" : "large");
const combinedIds = useMemo(() => {
const appIds = (orderedApps ?? []).map((a) => `app-${a.id}`);
if (!homeClockVisible) return appIds;
const safePos = Math.min(Math.max(clockPos, 0), appIds.length);
const out: string[] = [...appIds];
out.splice(safePos, 0, CLOCK_TILE_ID);
return out;
}, [orderedApps, clockPos, homeClockVisible]);
const sortableIds = combinedIds;
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id || !orderedApps) return;
const oldIndex = orderedApps.findIndex((a) => a.id === active.id);
const newIndex = orderedApps.findIndex((a) => a.id === over.id);
const oldIndex = combinedIds.findIndex((id) => id === active.id);
const newIndex = combinedIds.findIndex((id) => id === over.id);
if (oldIndex < 0 || newIndex < 0) return;
const nextCombined = arrayMove(combinedIds, oldIndex, newIndex);
const newClockPos = nextCombined.findIndex((id) => id === CLOCK_TILE_ID);
if (newClockPos !== clockPos) setClockPos(newClockPos);
const newAppOrder = nextCombined
.filter((id) => id !== CLOCK_TILE_ID)
.map((id) => Number(String(id).slice(4)))
.map((id) => orderedApps.find((a) => a.id === id))
.filter((a): a is App => a !== undefined);
const sameAppOrder =
newAppOrder.length === orderedApps.length &&
newAppOrder.every((a, i) => a.id === orderedApps[i].id);
if (sameAppOrder) return;
const previous = orderedApps;
const next = arrayMove(orderedApps, oldIndex, newIndex);
setOrderedApps(next);
setOrderedApps(newAppOrder);
updateOrder.mutate(
{ data: { order: next.map((a) => a.id) } },
{ data: { order: newAppOrder.map((a) => a.id) } },
{
onSuccess: (data) => {
setOrderedApps(data);
@@ -198,7 +337,6 @@ export default function HomePage() {
);
};
const sortableIds = useMemo(() => (orderedApps ?? []).map((a) => a.id), [orderedApps]);
const { data: stats } = useGetHomeStats({ query: { queryKey: getGetHomeStatsQueryKey() } });
const { data: notifications } = useListNotifications({ query: { queryKey: getListNotificationsQueryKey() } });
@@ -313,7 +451,7 @@ export default function HomePage() {
{/* Main Content */}
<div className="flex-1 px-5 sm:px-8 pt-6 pb-32 overflow-y-auto">
{/* Greeting + optional analog clock widget */}
<div className="mb-8 flex items-start justify-between gap-6 flex-wrap">
<div className="mb-8">
<div className="max-w-3xl min-w-0">
<h1 className="text-2xl sm:text-3xl font-bold text-foreground tracking-tight">
{greeting}
@@ -322,15 +460,6 @@ export default function HomePage() {
{dayName} · <span className="tabular-nums">{gregorianDate}</span>
</p>
</div>
{homeClockVisible && (
<div className="hidden md:block shrink-0">
<AnalogClockWidget
time={time}
lang={lang}
hour12={user?.clockHour12 ?? null}
/>
</div>
)}
</div>
{/* Stats Row — admin only */}
@@ -387,14 +516,32 @@ export default function HomePage() {
) : (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={sortableIds} strategy={rectSortingStrategy}>
<div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-7 lg:grid-cols-8 gap-x-3 gap-y-5">
{orderedApps?.map((app) => (
<div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-7 lg:grid-cols-8 gap-x-3 gap-y-5 grid-flow-row-dense">
{combinedIds.map((id) => {
if (id === CLOCK_TILE_ID) {
if (!homeClockVisible) return null;
return (
<SortableClockTile
key={CLOCK_TILE_ID}
time={time}
lang={lang}
hour12={user?.clockHour12 ?? null}
size={clockSize}
onToggleSize={toggleClockSize}
/>
);
}
const appId = Number(String(id).slice(4));
const app = orderedApps?.find((a) => a.id === appId);
if (!app) return null;
return (
<SortableAppIcon
key={app.id}
app={app}
onClick={() => openApp(app)}
/>
))}
);
})}
</div>
</SortableContext>
</DndContext>