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; +}