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:
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
@@ -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,29 +310,31 @@ 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>
|
||||
<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",
|
||||
hour12: use12,
|
||||
})}
|
||||
{showLabels && (
|
||||
<div className="text-center">
|
||||
<div className="font-mono text-sm font-semibold tabular-nums text-foreground tracking-tight">
|
||||
{formatTime(time, lang, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: use12,
|
||||
})}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground mt-0.5 tabular-nums">
|
||||
{formatWeekday(time, lang)} · {formatDate(time, lang)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[11px] 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);
|
||||
|
||||
@@ -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) => (
|
||||
<SortableAppIcon
|
||||
key={app.id}
|
||||
app={app}
|
||||
onClick={() => openApp(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>
|
||||
|
||||
Reference in New Issue
Block a user