diff --git a/services/frontend/src/components/dashboard/stat-card.tsx b/services/frontend/src/components/dashboard/stat-card.tsx
index e18f68e..37de095 100644
--- a/services/frontend/src/components/dashboard/stat-card.tsx
+++ b/services/frontend/src/components/dashboard/stat-card.tsx
@@ -3,6 +3,7 @@
import type { LucideIcon } from "lucide-react";
import { Area, AreaChart, ResponsiveContainer } from "recharts";
import { GlassCard } from "@/components/glass/card";
+import { useMounted } from "@/lib/hooks/use-mounted";
import { cn } from "@/lib/utils";
interface StatCardProps {
@@ -22,6 +23,7 @@ export function StatCard({
sparklineData,
formatter = (v) => (typeof v === "number" ? v.toLocaleString() : v),
}: StatCardProps) {
+ const mounted = useMounted();
const accentColor = {
default: "var(--color-primary)",
danger: "var(--color-destructive)",
@@ -54,9 +56,14 @@ export function StatCard({
{/* Sparkline background */}
- {sparklineData && sparklineData.length > 0 && (
+ {sparklineData && sparklineData.length > 0 && mounted && (
-
+
@@ -23,38 +26,47 @@ export function TopChannelsChart({ data = [] }: TopChannelsChartProps) {
-
-
-
-
-
-
-
-
+ {mounted ? (
+
+
+
+
+
+
+
+
+ ) : (
+
+ )}
);
diff --git a/services/frontend/src/components/voice/activity-timeline.tsx b/services/frontend/src/components/voice/activity-timeline.tsx
index c658657..7ff2691 100644
--- a/services/frontend/src/components/voice/activity-timeline.tsx
+++ b/services/frontend/src/components/voice/activity-timeline.tsx
@@ -1,6 +1,5 @@
"use client";
-import { GlassCard } from "@/components/glass/card";
import {
Bar,
BarChart,
@@ -9,12 +8,16 @@ import {
XAxis,
YAxis,
} from "recharts";
+import { GlassCard } from "@/components/glass/card";
+import { useMounted } from "@/lib/hooks/use-mounted";
interface ActivityTimelineProps {
data?: { user: string; duration: number }[];
}
export function VoiceActivityTimeline({ data = [] }: ActivityTimelineProps) {
+ const mounted = useMounted();
+
return (
@@ -23,39 +26,51 @@ export function VoiceActivityTimeline({ data = [] }: ActivityTimelineProps) {
-
-
-
-
- [`${(Number(value) / 60).toFixed(1)}m`, "Duration"]}
- />
-
-
-
+ {mounted ? (
+
+
+
+
+ [
+ `${(Number(value) / 60).toFixed(1)}m`,
+ "Duration",
+ ]}
+ />
+
+
+
+ ) : (
+
+ )}
);
diff --git a/services/frontend/src/hooks/use-messages.ts b/services/frontend/src/hooks/use-messages.ts
index 4d22794..ecdbe89 100644
--- a/services/frontend/src/hooks/use-messages.ts
+++ b/services/frontend/src/hooks/use-messages.ts
@@ -126,14 +126,17 @@ export function useReview(channelId?: string) {
export function useMessageDetail(id: string | null) {
const detail = useSWR(id ? msgKeys.detail(id) : null, () =>
- messagesApi.getDetail(id!),
+ messagesApi.getDetail(id ?? ""),
);
+ const channelId = id ? detail.data?.channel_id : undefined;
const attachments = useSWR(
- id && detail.data?.channel_id
- ? [...msgKeys.detail(id), "attachments"]
- : null,
+ channelId ? [...msgKeys.detail(id ?? ""), "attachments"] : null,
async () => {
- const res = await messagesApi.getAttachments(detail.data!.channel_id, 10);
+ // Guard: only fetch when we actually have a channel id — a revalidate
+ // can race the detail load and see detail.data === undefined.
+ const cid = detail.data?.channel_id;
+ if (!cid) return [];
+ const res = await messagesApi.getAttachments(cid, 10);
return res.data;
},
);
diff --git a/services/frontend/src/lib/hooks/use-mounted.ts b/services/frontend/src/lib/hooks/use-mounted.ts
new file mode 100644
index 0000000..80d4373
--- /dev/null
+++ b/services/frontend/src/lib/hooks/use-mounted.ts
@@ -0,0 +1,16 @@
+import { useEffect, useState } from "react";
+
+/**
+ * Returns true once the component has mounted on the client. Charts wrapped
+ * in ResponsiveContainer measure their parent during the first layout — if
+ * the container has no size yet (hydration/flex/grid), Recharts logs
+ * "width(-1) and height(-1)" warnings. Deferring the chart render until after
+ * mount guarantees the container has real dimensions.
+ */
+export function useMounted(): boolean {
+ const [mounted, setMounted] = useState(false);
+ useEffect(() => {
+ setMounted(true);
+ }, []);
+ return mounted;
+}