diff --git a/web/src/components/RadialDay.tsx b/web/src/components/RadialDay.tsx
new file mode 100644
index 0000000..5aa82a3
--- /dev/null
+++ b/web/src/components/RadialDay.tsx
@@ -0,0 +1,257 @@
+import { memo, useMemo, useState } from "react";
+import { motion } from "motion/react";
+import type { Bucket, LiveTarget } from "../lib/types";
+import { arcPath, polar } from "../lib/geometry";
+import { fmtHour, fmtLatency, fmtNumber } from "../lib/format";
+import { useUI } from "../lib/ui";
+
+const CX = 160;
+const CY = 160;
+const R_SEG = 118; // segment centerline radius
+const SEG_W = 17; // segment stroke width
+const GAP_DEG = 2.6;
+const HOUR_MS = 3_600_000;
+
+interface HourSlot {
+ start: number; // unix ms, aligned to the hour
+ hourOfDay: number; // local clock hour 0..23
+ total: number;
+ failed: number;
+ avgLatency: number;
+ isCurrent: boolean;
+}
+
+/** Folds per-target hourly buckets into 24 wall-clock slots. */
+function buildSlots(bucketLists: Bucket[][]): HourSlot[] {
+ const nowHour = Math.floor(Date.now() / HOUR_MS) * HOUR_MS;
+ const slots: HourSlot[] = [];
+ for (let i = 23; i >= 0; i--) {
+ const start = nowHour - i * HOUR_MS;
+ slots.push({
+ start,
+ hourOfDay: new Date(start).getHours(),
+ total: 0,
+ failed: 0,
+ avgLatency: 0,
+ isCurrent: i === 0,
+ });
+ }
+ const byStart = new Map(slots.map((s) => [s.start, s]));
+ for (const buckets of bucketLists) {
+ for (const b of buckets) {
+ const slot = byStart.get(Math.floor(b.start / HOUR_MS) * HOUR_MS);
+ if (!slot) continue;
+ // latency: weighted mean across targets
+ const okHere = b.total - b.failed;
+ const okBefore = slot.total - slot.failed;
+ if (okHere > 0) {
+ slot.avgLatency =
+ (slot.avgLatency * okBefore + b.avg_latency_ms * okHere) /
+ Math.max(1, okBefore + okHere);
+ }
+ slot.total += b.total;
+ slot.failed += b.failed;
+ }
+ }
+ return slots;
+}
+
+function slotColor(s: HourSlot): string {
+ if (s.total === 0) return "var(--color-ink-800)";
+ if (s.failed === s.total) return "var(--color-down)";
+ if (s.failed > 0) return "var(--color-warn)";
+ return "var(--color-up)";
+}
+
+const HourMarks = memo(function HourMarks({ lang }: { lang: "en" | "fa" }) {
+ return (
+
+ {[0, 6, 12, 18].map((h) => {
+ const p = polar(CX, CY, 146, h * 15);
+ return (
+
+ {fmtHour(lang, h)}
+
+ );
+ })}
+ {Array.from({ length: 24 }, (_, h) => {
+ if (h % 6 === 0) return null;
+ const p1 = polar(CX, CY, 143, h * 15);
+ const p2 = polar(CX, CY, 147, h * 15);
+ return (
+
+ );
+ })}
+
+ );
+});
+
+interface RadialDayProps {
+ targets: LiveTarget[];
+ dayBuckets: Record;
+ /** Restrict to a single target id (detail view). */
+ only?: string;
+ size?: number;
+ caption?: boolean;
+}
+
+/**
+ * Circular visualization #3 — the day dial.
+ * A clock face where each of the last 24 hours is one segment placed at
+ * its true wall-clock position, colored by status. A full day, one glance.
+ */
+export function RadialDay({
+ targets,
+ dayBuckets,
+ only,
+ size,
+ caption = true,
+}: RadialDayProps) {
+ const { t, lang, reduced } = useUI();
+ const [hover, setHover] = useState(null);
+
+ const slots = useMemo(() => {
+ const lists = only
+ ? [dayBuckets[only] ?? []]
+ : targets.map((x) => dayBuckets[x.id] ?? []);
+ return buildSlots(lists);
+ }, [targets, dayBuckets, only]);
+
+ const today = new Intl.DateTimeFormat(lang === "fa" ? "fa-IR" : "en-US", {
+ weekday: "short",
+ day: "numeric",
+ month: "short",
+ }).format(new Date());
+
+ const current = slots.find((s) => s.isCurrent)!;
+ const handAngle = ((Date.now() % (24 * HOUR_MS)) / (24 * HOUR_MS)) * 360;
+
+ return (
+
+ {caption && (
+
+ {t.dial}
+
+ {t.systemDay}
+
+ )}
+
+
+
+ );
+}