Files
HealthLog/src/components/admin/coach-feedback-section.tsx
T
Marc-André Bombeck 86d80d2c04 refactor(admin): migrate section cards onto the shared card primitives
The admin tree hand-rolled its card surfaces and stepped padding at sm:
instead of md:, so it read denser than every other route on a tablet.
Move the sections onto SettingsCard/SettingsCardHeader and the mini-rows
onto ListRow; no behaviour, data, or copy changes.
2026-07-01 23:44:55 +02:00

174 lines
6.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
/**
* `<CoachFeedbackSection>` — admin view onto the v1.4.23 H7 Coach
* helpful/unhelpful aggregate. Reads the same feedback-aggregator row
* that the AI Quality view consumes; this section just slices the
* Coach-only buckets so the operator can see prompt-version × helpful-
* rate × n at a glance.
*
* First useful question this answers in the v1.4.23 first-week window:
* "Is the v1.4.22 Coach prose rewrite (PROMPT_VERSION 4.22.0 → 4.23.x)
* landing well, or did the warm tone overshoot?" If the helpful-rate
* for the new prompt version drops below 50 % within the first 100
* ratings, v1.4.24 walks the persona back.
*
* Empty state when the aggregator hasn't run or no Coach feedback has
* landed in the rolling window. Cross-user aggregation only — the
* admin view never exposes per-user prose.
*/
import { useQuery } from "@tanstack/react-query";
import { Loader2, Sparkles } from "lucide-react";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
import { helpfulRateColour } from "./_shared";
interface CoachFeedbackBucket {
promptVersion: string;
tone: string;
verbosity: string;
helpful: number;
notHelpful: number;
total: number;
helpfulRate: number;
}
interface FeedbackSummary {
generatedAt: string;
windowDays: number;
coachBuckets?: CoachFeedbackBucket[];
}
export function CoachFeedbackSection() {
const { t } = useTranslations();
const fmt = useFormatters();
const query = useQuery({
queryKey: queryKeys.adminCoachFeedback(),
queryFn: async () => {
const data = await apiGet<{ summary: FeedbackSummary | null } | null>(
"/api/admin/ai-quality",
);
return data?.summary ?? null;
},
});
const summary = query.data ?? null;
const buckets = summary?.coachBuckets ?? [];
const hasData = !!summary && buckets.length > 0;
// v1.4.25 W8 — keep the section's outer card + header rendered across
// every query state (loading / error / empty / data) so the heading
// baseline stays at a constant Y-offset. Previously the loading branch
// returned a thin flex-row stub and the error branch a p-4 alert with
// NO heading, which made the entire section snap downward when the
// fetched data resolved — the maintainer reported the visible layout shift on
// /admin/coach-feedback. Mirrors the canonical structure used by
// <SystemStatusSection> (header outside the fetch-state branch).
return (
<SettingsCard className="space-y-4">
<SettingsCardHeader
icon={Sparkles}
title={t("admin.coachFeedback.title")}
status={
hasData && summary ? (
<p className="text-muted-foreground text-xs">
{t("admin.coachFeedback.windowLabel", {
days: String(summary.windowDays),
})}
{" · "}
{t("admin.coachFeedback.generatedAtLabel")}{" "}
{fmt.dateTime(summary.generatedAt)}
</p>
) : null
}
/>
{query.isLoading ? (
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
<span className="text-muted-foreground text-sm">
{t("admin.coachFeedback.loading")}
</span>
</div>
) : query.isError ? (
<div
role="alert"
className="text-destructive bg-destructive/10 border-destructive/30 rounded-md border p-3 text-sm"
>
{t("admin.coachFeedback.loadError")}
</div>
) : !hasData ? (
<p className="text-muted-foreground text-sm">
{t("admin.coachFeedback.empty")}
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm" data-slot="coach-feedback-table">
<thead>
<tr className="text-muted-foreground border-border border-b text-left text-xs tracking-wider uppercase">
<th className="py-2 pr-3 font-medium">
{t("admin.coachFeedback.colPromptVersion")}
</th>
<th className="py-2 pr-3 font-medium">
{t("admin.coachFeedback.colTone")}
</th>
<th className="py-2 pr-3 font-medium">
{t("admin.coachFeedback.colVerbosity")}
</th>
<th className="py-2 pr-3 font-medium tabular-nums">
{t("admin.coachFeedback.colHelpful")}
</th>
<th className="py-2 pr-3 font-medium tabular-nums">
{t("admin.coachFeedback.colNotHelpful")}
</th>
<th className="py-2 pr-3 font-medium tabular-nums">
{t("admin.coachFeedback.colN")}
</th>
<th className="py-2 font-medium tabular-nums">
{t("admin.coachFeedback.colHelpfulRate")}
</th>
</tr>
</thead>
<tbody>
{buckets.map((bucket, idx) => {
const ratePct = Math.round(bucket.helpfulRate * 100);
return (
<tr
key={`${bucket.promptVersion}-${bucket.tone}-${bucket.verbosity}-${idx}`}
className="border-border/40 border-b last:border-0"
data-slot="coach-feedback-bucket"
>
<td className="text-muted-foreground py-2 pr-3 text-xs">
{bucket.promptVersion}
</td>
<td className="py-2 pr-3">{bucket.tone}</td>
<td className="py-2 pr-3">{bucket.verbosity}</td>
<td className="py-2 pr-3 tabular-nums">{bucket.helpful}</td>
<td className="py-2 pr-3 tabular-nums">
{bucket.notHelpful}
</td>
<td className="py-2 pr-3 tabular-nums">{bucket.total}</td>
<td
className={`py-2 font-semibold tabular-nums ${helpfulRateColour(
bucket.helpfulRate,
)}`}
>
{ratePct}%
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</SettingsCard>
);
}