mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
fix(dashboard): one skeleton language and a single chart reveal on first load
First load showed bare dark tiles, then the mood chart finished alone and the remaining charts popped in one after another. Every widget now renders a structured silhouette of its final shape, charts mount in parallel but stay behind height-stable skeletons until all visible charts report ready — with a two-second fallback so one slow widget cannot hold the page — and the reveal fades in once, without layout shift. The intake repair script becomes self-contained on plain pg so it actually runs inside the production container, with the runbook updated to the working invocation.
This commit is contained in:
1 parent
51dd614199
commit
9fe5673251
13 files changed
+1166
-246
No files matched your search
@@ -17,7 +17,7 @@ import {
|
||||
ReferenceArea,
|
||||
ReferenceDot,
|
||||
} from "recharts";
|
||||
import { Loader2, SlidersHorizontal } from "lucide-react";
|
||||
import { SlidersHorizontal } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
useState,
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
useCallback,
|
||||
type ComponentType,
|
||||
} from "react";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { RichChartTooltip, type RichTooltipRow } from "./chart-tooltip";
|
||||
import { ChartEmptyState } from "./chart-empty-state";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
@@ -231,6 +232,15 @@ interface HealthChartProps {
|
||||
* thread the same glyph it hands the stat strip.
|
||||
*/
|
||||
titleIcon?: ComponentType<{ className?: string }>;
|
||||
/**
|
||||
* v1.16.0 — fires once the chart's data query has settled (initial
|
||||
* load finished, success or error). The dashboard's shared reveal
|
||||
* gate listens here so every chart cell swaps from skeleton to
|
||||
* content in one frame instead of popping in one after another.
|
||||
* Optional and repeat-safe: the dashboard's `markReady` is
|
||||
* idempotent; non-dashboard mounts simply omit it.
|
||||
*/
|
||||
onDataReady?: () => void;
|
||||
}
|
||||
|
||||
interface ChartDataPoint {
|
||||
@@ -535,6 +545,7 @@ export function HealthChart({
|
||||
valueScale = 1,
|
||||
onVisibleStats,
|
||||
titleIcon,
|
||||
onDataReady,
|
||||
}: HealthChartProps) {
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
const { t, locale } = useTranslations();
|
||||
@@ -879,6 +890,15 @@ export function HealthChart({
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
// v1.16.0 — report the settled data query to the dashboard's shared
|
||||
// reveal gate. `isLoading` only covers the INITIAL fetch (a later
|
||||
// range-tab change creates a new cache entry but the gate has long
|
||||
// latched by then), so this fires exactly when the first paintable
|
||||
// state — chart, empty-window card, or error fallback — is available.
|
||||
useEffect(() => {
|
||||
if (!isLoading) onDataReady?.();
|
||||
}, [isLoading, onDataReady]);
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
if (!data?.length) return data;
|
||||
|
||||
@@ -1521,9 +1541,13 @@ export function HealthChart({
|
||||
) : null}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex h-48 items-center justify-center">
|
||||
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
// v1.16.0 — height-matched skeleton band instead of the former
|
||||
// `h-48` spinner box. The loading state now occupies the exact
|
||||
// box the painted chart will (same `chartHeightClass`), so the
|
||||
// card height never jumps when the data lands — the dashboard's
|
||||
// shared-reveal overlay and every insights mount stay
|
||||
// layout-shift-free.
|
||||
<Skeleton className={`w-full ${chartHeightClass}`} />
|
||||
) : !chartData?.length ? (
|
||||
// v1.4.43 W11-M6 — empty-window state.
|
||||
//
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
* the wrapper itself stays intact.
|
||||
*/
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
@@ -45,9 +45,10 @@ import {
|
||||
Tooltip,
|
||||
ReferenceLine,
|
||||
} from "recharts";
|
||||
import { ArrowDown, ArrowRight, ArrowUp, Loader2, Pill } from "lucide-react";
|
||||
import { ArrowDown, ArrowRight, ArrowUp, Pill } from "lucide-react";
|
||||
import { ComplianceInfoTip } from "@/components/medications/card-parts/compliance-info-tip";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { formatDateShort } from "@/lib/format";
|
||||
@@ -188,12 +189,20 @@ interface MedicationComplianceChartProps {
|
||||
* byte-identical.
|
||||
*/
|
||||
userTimezone?: string;
|
||||
/**
|
||||
* v1.16.0 — fires once the compliance query has settled (initial
|
||||
* load finished). The dashboard's shared reveal gate listens here so
|
||||
* every chart cell swaps from skeleton to content in one frame.
|
||||
* Optional and repeat-safe.
|
||||
*/
|
||||
onDataReady?: () => void;
|
||||
}
|
||||
|
||||
export function MedicationComplianceChart({
|
||||
title,
|
||||
userTimezone = "Europe/Berlin",
|
||||
compareBaseline,
|
||||
onDataReady,
|
||||
}: MedicationComplianceChartProps) {
|
||||
// v1.4.27 R4 RC3 — the prop is accepted so the dashboard can pass
|
||||
// `compareBaseline={compareBaseline}` uniformly across every chart.
|
||||
@@ -245,6 +254,12 @@ export function MedicationComplianceChart({
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
// v1.16.0 — report the settled query to the dashboard's shared
|
||||
// reveal gate (see `onDataReady` prop doc).
|
||||
useEffect(() => {
|
||||
if (!isLoading) onDataReady?.();
|
||||
}, [isLoading, onDataReady]);
|
||||
|
||||
const chartData = useMemo(
|
||||
() =>
|
||||
data
|
||||
@@ -400,9 +415,10 @@ export function MedicationComplianceChart({
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex h-48 items-center justify-center">
|
||||
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
// v1.16.0 — height-matched skeleton band instead of the former
|
||||
// `h-48` spinner box; matches the painted chart body below so
|
||||
// the card never jumps when the data lands.
|
||||
<Skeleton className="h-[var(--chart-height,240px)] w-full md:h-[var(--chart-height-md,280px)]" />
|
||||
) : !hasData ? (
|
||||
<div className="text-muted-foreground flex h-48 items-center justify-center text-sm">
|
||||
{t("charts.noData")}
|
||||
|
||||
@@ -14,9 +14,9 @@ import {
|
||||
ReferenceArea,
|
||||
ReferenceLine,
|
||||
} from "recharts";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useState, useMemo } from "react";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { makeFormatters } from "@/lib/format-locale";
|
||||
@@ -93,6 +93,14 @@ interface MoodChartProps {
|
||||
* "Europe/Berlin" so older mount sites stay byte-identical.
|
||||
*/
|
||||
userTimezone?: string;
|
||||
/**
|
||||
* v1.16.0 — fires once the mood analytics query has settled (initial
|
||||
* load finished). The dashboard's shared reveal gate listens here so
|
||||
* every chart cell swaps from skeleton to content in one frame —
|
||||
* pre-fix the cheap `/api/mood/analytics` read made this chart paint
|
||||
* visibly before its measurement siblings. Optional and repeat-safe.
|
||||
*/
|
||||
onDataReady?: () => void;
|
||||
}
|
||||
|
||||
// --- Constants ---
|
||||
@@ -294,6 +302,7 @@ export function MoodChart({
|
||||
compareBaseline = "none",
|
||||
chartKey,
|
||||
userTimezone = "Europe/Berlin",
|
||||
onDataReady,
|
||||
}: MoodChartProps) {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { t, locale } = useTranslations();
|
||||
@@ -349,6 +358,14 @@ export function MoodChart({
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
// v1.16.0 — report the settled query to the dashboard's shared reveal
|
||||
// gate (see `onDataReady` prop doc). Must run BEFORE the
|
||||
// empty-entries early return below so a no-data mount still unblocks
|
||||
// the row.
|
||||
useEffect(() => {
|
||||
if (!isLoading) onDataReady?.();
|
||||
}, [isLoading, onDataReady]);
|
||||
|
||||
// v1.4.43 W2-CHART-GATE — raw mood-entry count across every day in
|
||||
// the window. `entries[].samples` carries the per-day raw count
|
||||
// straight from the analytics endpoint; summing it produces the
|
||||
@@ -576,9 +593,17 @@ export function MoodChart({
|
||||
// the padding values — makes the three trend cards share one title
|
||||
// baseline and one height. Full (non-mini) mode keeps the Card path.
|
||||
const chartBody = isLoading ? (
|
||||
<div className="flex h-48 items-center justify-center">
|
||||
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
// v1.16.0 — height-matched skeleton band instead of the former
|
||||
// `h-48` spinner box: the loading state occupies the same box the
|
||||
// painted chart will (mini 140 px / full 200-220 px), so the card
|
||||
// never jumps when the data lands.
|
||||
<Skeleton
|
||||
className={
|
||||
mini
|
||||
? "h-[var(--chart-height,140px)] w-full"
|
||||
: "h-[var(--chart-height,200px)] w-full md:h-[var(--chart-height-md,220px)]"
|
||||
}
|
||||
/>
|
||||
) : !chartData?.length ? (
|
||||
<div className="text-muted-foreground flex h-48 items-center justify-center text-sm">
|
||||
{t("charts.noData")}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* v1.16.0 — structured loading silhouette for one dashboard trend tile.
|
||||
*
|
||||
* Mirrors the real `<TrendCard>` chrome row-for-row (heading row with
|
||||
* label + icon slot, headline value row, the `min-h-[18px]` callout
|
||||
* reservation, the bottom 7d / 30d sub-row) so a cold first paint reads
|
||||
* as "content loading" instead of a bare dark card, and the swap to the
|
||||
* real tile causes zero layout shift — every reserved row sits at the
|
||||
* same y-coordinate the loaded tile paints at.
|
||||
*
|
||||
* Pre-fix the tile-strip skeleton and the per-tile `<Suspense>` fallback
|
||||
* both rendered an EMPTY pulsing card (`min-h-[8rem]` + border, no inner
|
||||
* structure); the first-load impression was a grid of blank rectangles.
|
||||
*
|
||||
* Always `aria-hidden` — the strip-level container announces loading via
|
||||
* the chart skeleton's `role="status"` siblings; a per-tile announcement
|
||||
* would spam screen readers with one entry per silhouette. Reduced
|
||||
* motion is honoured by the `<Skeleton>` primitive
|
||||
* (`motion-reduce:animate-none` on every pulsing block).
|
||||
*/
|
||||
export function TrendCardSkeleton({ className }: { className?: string }) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
data-slot="trend-card-skeleton"
|
||||
className={cn(
|
||||
// Same outer chrome as `<TrendCard>` plus the `min-h-[8rem]`
|
||||
// floor the legacy empty silhouette reserved.
|
||||
"bg-card border-border flex h-full min-h-[8rem] w-full min-w-0 flex-col overflow-hidden rounded-xl border p-4 md:p-6",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* Heading row — label + icon (TrendCard pins this row at h-5). */}
|
||||
<div className="flex h-5 items-center justify-between gap-2">
|
||||
<Skeleton className="h-3 w-16" />
|
||||
<Skeleton className="h-4 w-4 shrink-0" />
|
||||
</div>
|
||||
{/* Headline value row — `text-3xl leading-none` ≈ 30 px, plus the
|
||||
unit slot, baseline-aligned exactly like the loaded tile. */}
|
||||
<div className="mt-2 flex items-baseline gap-x-1.5">
|
||||
<Skeleton className="h-[30px] w-20" />
|
||||
<Skeleton className="h-3.5 w-8" />
|
||||
</div>
|
||||
{/* Callout slot reservation (comparison delta / stale hint). */}
|
||||
<div className="mt-1 min-h-[18px]" />
|
||||
{/* 7d / 30d sub-row — the sparkline-equivalent bottom block. */}
|
||||
<div className="mt-auto flex items-baseline gap-x-3 pt-1">
|
||||
<Skeleton className="h-3 w-14" />
|
||||
<Skeleton className="h-3 w-14" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* v1.16.0 — shared reveal gate for the dashboard chart row.
|
||||
*
|
||||
* Pre-fix every chart cell popped in independently: the charts mount
|
||||
* together (once the snapshot resolves and the visibility gates flip),
|
||||
* but each one owns its own data query — `/api/mood/analytics` is a
|
||||
* single cheap read while the measurement charts fan out over
|
||||
* `/api/measurements?…`, so the mood chart reliably painted first and
|
||||
* the rest trickled in one after another. The maintainer flagged the
|
||||
* staggered pop-in as unprofessional.
|
||||
*
|
||||
* The gate keeps every chart MOUNTED from the start (so all data
|
||||
* queries fire in parallel) but visually holds each cell on its
|
||||
* layout-stable `<ChartSkeleton>` until either
|
||||
*
|
||||
* 1. every gated chart has reported its data query settled
|
||||
* (`onDataReady` — the Promise.all moment), or
|
||||
* 2. the reveal timeout elapses (`CHART_REVEAL_TIMEOUT_MS`, 2 s) so a
|
||||
* single slow widget cannot hold the whole row hostage — the late
|
||||
* chart simply finishes on its own skeleton-free cell afterwards.
|
||||
*
|
||||
* Both signals are monotonic: the ready-set only grows and the timeout
|
||||
* only fires once, so after the first 2 s the reveal can never regress
|
||||
* even when a later mutation makes an additional chart visible.
|
||||
*/
|
||||
export const CHART_REVEAL_TIMEOUT_MS = 2_000;
|
||||
|
||||
/**
|
||||
* Pure resolver behind `useDashboardChartReveal` — exported so the gate
|
||||
* logic has direct unit coverage without mounting the page (project
|
||||
* convention: SSR-only tests, no DOM test runner).
|
||||
*/
|
||||
export function resolveChartRevealState(input: {
|
||||
expectedIds: readonly string[];
|
||||
readyIds: ReadonlySet<string>;
|
||||
timedOut: boolean;
|
||||
}): boolean {
|
||||
// Not armed yet — the chart gates only flip once the primary
|
||||
// dashboard query resolves, so an empty id list means there is
|
||||
// nothing to reveal (and nothing rendered that could need it).
|
||||
if (input.expectedIds.length === 0) return false;
|
||||
if (input.timedOut) return true;
|
||||
return input.expectedIds.every((id) => input.readyIds.has(id));
|
||||
}
|
||||
|
||||
export function useDashboardChartReveal(
|
||||
expectedIds: readonly string[],
|
||||
timeoutMs: number = CHART_REVEAL_TIMEOUT_MS,
|
||||
): { revealed: boolean; markReady: (id: string) => void } {
|
||||
const [readyIds, setReadyIds] = useState<ReadonlySet<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
const [timedOut, setTimedOut] = useState(false);
|
||||
|
||||
// Arm the timeout fallback the moment the first chart id shows up
|
||||
// (i.e. when the primary query resolved and the chart row mounts).
|
||||
// The timer deliberately runs to completion even when every chart
|
||||
// reports ready earlier — a fired `timedOut` latches the reveal open
|
||||
// so a chart that becomes visible later (e.g. the first mood entry
|
||||
// logged via quick-add) cannot flip the whole row back to skeletons.
|
||||
const armed = expectedIds.length > 0;
|
||||
useEffect(() => {
|
||||
if (!armed) return undefined;
|
||||
const handle = setTimeout(() => setTimedOut(true), timeoutMs);
|
||||
return () => clearTimeout(handle);
|
||||
}, [armed, timeoutMs]);
|
||||
|
||||
// Idempotent — re-marking a ready id returns the same Set reference
|
||||
// so the repeated `onDataReady` calls from re-rendered charts never
|
||||
// cause a render loop.
|
||||
const markReady = useCallback((id: string) => {
|
||||
setReadyIds((prev) => (prev.has(id) ? prev : new Set(prev).add(id)));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
revealed: resolveChartRevealState({ expectedIds, readyIds, timedOut }),
|
||||
markReady,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* One reveal-gated chart cell. The chart stays mounted underneath
|
||||
* (`visibility: hidden` keeps its layout box, so Recharts'
|
||||
* `ResponsiveContainer` measures real dimensions and the cell height
|
||||
* never jumps at reveal time) while a `<ChartSkeleton>` overlays the
|
||||
* exact same box. On reveal every cell swaps in the same frame with a
|
||||
* short fade-in; `motion-safe:` keeps the swap instant for
|
||||
* reduced-motion users.
|
||||
*/
|
||||
export function DashboardChartCell({
|
||||
revealed,
|
||||
children,
|
||||
}: {
|
||||
revealed: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="relative"
|
||||
data-slot="dashboard-chart-cell"
|
||||
data-revealed={revealed ? "true" : "false"}
|
||||
>
|
||||
<div
|
||||
aria-hidden={revealed ? undefined : true}
|
||||
className={cn(
|
||||
// `space-y-2` preserves the chart-row rhythm the dashboard's
|
||||
// outer cell applied when chart + TrendHint were direct
|
||||
// siblings (pre-gate markup).
|
||||
"space-y-2",
|
||||
revealed
|
||||
? "motion-safe:animate-in motion-safe:fade-in motion-safe:duration-300"
|
||||
: "invisible",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{!revealed && (
|
||||
<ChartSkeleton className="absolute inset-0 overflow-hidden" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { CoachSettingsSheet } from "./coach-settings-sheet";
|
||||
import { HistoryRail } from "./history-rail";
|
||||
import { MessageThread } from "./message-thread";
|
||||
import { MobileRailTray } from "./mobile-rail-tray";
|
||||
import { SelfContextChips } from "./self-context-chips";
|
||||
import { SourcesRail } from "./sources-rail";
|
||||
import { useResettableValue } from "./use-resettable-value";
|
||||
import { useCoachConversation, useSendCoachMessage } from "./use-coach";
|
||||
@@ -220,15 +221,25 @@ export function CoachConversation({
|
||||
</p>
|
||||
}
|
||||
composer={
|
||||
<CoachInput
|
||||
value={inputValue}
|
||||
onChange={setInputValue}
|
||||
onSubmit={() => handleSubmit(inputValue)}
|
||||
onCancel={send.cancel}
|
||||
disabled={send.isStreaming}
|
||||
isStreaming={send.isStreaming}
|
||||
autoFocusOnOpen={autoFocusComposer}
|
||||
/>
|
||||
<div>
|
||||
{/* v1.16.0 — pending self-context questions as tappable
|
||||
chips. Tapping inserts the question into the composer
|
||||
(the user answers it in their own words) and dismisses
|
||||
the chip. Renders nothing when no questions pend. */}
|
||||
<SelfContextChips
|
||||
disabled={send.isStreaming}
|
||||
onPick={(question) => setInputValue(`${question}\n`)}
|
||||
/>
|
||||
<CoachInput
|
||||
value={inputValue}
|
||||
onChange={setInputValue}
|
||||
onSubmit={() => handleSubmit(inputValue)}
|
||||
onCancel={send.cancel}
|
||||
disabled={send.isStreaming}
|
||||
isStreaming={send.isStreaming}
|
||||
autoFocusOnOpen={autoFocusComposer}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
onOpenHistoryTray={() => setHistoryTrayOpen(true)}
|
||||
onOpenSourcesTray={() => setSourcesTrayOpen(true)}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.16.0 — pending self-context questions as composer chips.
|
||||
*
|
||||
* After the user saves Settings → AI → "About me", the server derives
|
||||
* up to 3 clarifying questions (`/api/coach/about-me/questions`). They
|
||||
* surface here, directly above the Coach composer, as quiet tappable
|
||||
* chips: tapping one inserts the question into the composer (so the
|
||||
* user answers it in their own words) and dismisses it; the ✕
|
||||
* dismisses without inserting. No questions → nothing renders, zero
|
||||
* vertical cost.
|
||||
*
|
||||
* Each chip is a `role="group"` of two sibling buttons (insert +
|
||||
* dismiss) — buttons cannot nest, and both actions deserve their own
|
||||
* focus stop.
|
||||
*/
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { MessageCircleQuestion, X } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface SelfContextChipsProps {
|
||||
/** Insert the tapped question into the composer. */
|
||||
onPick: (question: string) => void;
|
||||
/** Hidden while a reply streams so the input value cannot be yanked. */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function SelfContextChips({
|
||||
onPick,
|
||||
disabled = false,
|
||||
}: SelfContextChipsProps) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: queryKeys.coachAboutMeQuestions(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/coach/about-me/questions");
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return (await res.json()).data as { questions: string[] };
|
||||
},
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const dismiss = useMutation({
|
||||
mutationKey: queryKeys.coachAboutMeQuestions(),
|
||||
mutationFn: async (question: string) => {
|
||||
const res = await fetch("/api/coach/about-me/questions", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ question }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return (await res.json()).data as { questions: string[] };
|
||||
},
|
||||
onSuccess: (next) => {
|
||||
queryClient.setQueryData(queryKeys.coachAboutMeQuestions(), next);
|
||||
},
|
||||
});
|
||||
|
||||
const questions = data?.questions ?? [];
|
||||
if (questions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div data-slot="coach-self-context-chips" className="mb-2">
|
||||
<p className="text-muted-foreground mb-1.5 flex items-center gap-1.5 text-[11px]">
|
||||
<MessageCircleQuestion
|
||||
className="text-dracula-purple size-3.5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t("insights.coach.selfContextChipsLabel")}
|
||||
</p>
|
||||
<ul className="flex flex-wrap gap-1.5">
|
||||
{questions.map((question) => (
|
||||
<li
|
||||
key={question}
|
||||
role="group"
|
||||
aria-label={question}
|
||||
className={cn(
|
||||
"border-dracula-purple/30 bg-dracula-purple/10 inline-flex max-w-full items-stretch overflow-hidden rounded-full border",
|
||||
"transition-colors",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
data-slot="coach-self-context-chip"
|
||||
onClick={() => {
|
||||
onPick(question);
|
||||
dismiss.mutate(question);
|
||||
}}
|
||||
title={question}
|
||||
className={cn(
|
||||
"text-foreground min-h-9 min-w-0 truncate py-1.5 pr-1 pl-3 text-left text-xs",
|
||||
"hover:bg-dracula-purple/15 focus-visible:ring-dracula-purple/40 focus-visible:ring-2 focus-visible:outline-none",
|
||||
"disabled:opacity-60",
|
||||
)}
|
||||
>
|
||||
{question}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
aria-label={t("insights.coach.selfContextChipDismiss")}
|
||||
data-slot="coach-self-context-chip-dismiss"
|
||||
onClick={() => dismiss.mutate(question)}
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground flex items-center px-2",
|
||||
"hover:bg-dracula-purple/15 focus-visible:ring-dracula-purple/40 focus-visible:ring-2 focus-visible:outline-none",
|
||||
"disabled:opacity-60",
|
||||
)}
|
||||
>
|
||||
<X className="size-3" aria-hidden="true" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user