mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
The dashboard snapshot fires the moment login succeeds instead of waiting for the page chunk (start ~670 to ~200 ms throttled). Guided follow-ups are personalised from the user's snapshot and an answer gets the coach's streamed reaction before the next question. Archived schedule eras can be corrected through a superseding revision that keeps the original as an audit trail, era writes serialise per medication, and duplicate dose times collapse in validation. A rolling medication whose next dose is tomorrow can no longer read as heavily overdue — the card status derives strictly from the served next-due — and the adherence bars keep a fixed geometry in every state. The server-wide assistant key gains its admin card, the one duplicated coach preference collapses onto a single owner, twenty-seven admin and settings card headers move onto the shared pattern with sixty containers normalised, and the remaining hydration warnings are gone.
191 lines
6.3 KiB
TypeScript
191 lines
6.3 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* `<AiQualitySection>` — admin AI quality preview (v1.4.16 phase B5e).
|
|
*
|
|
* Surfaces the aggregator-written helpful-rate per (severity x
|
|
* provider x prompt-version) bucket. The pg-boss `feedback-aggregator`
|
|
* queue writes the underlying summary daily at 04:00 Europe/Berlin
|
|
* to `AppSettings.adminAiInsightsFeedbackSummary`; this component
|
|
* reads it via `GET /api/admin/ai-quality` (admin-gated).
|
|
*
|
|
* Empty-state ("no summary yet") rendered as a quiet message rather
|
|
* than a fake zero row so the operator can tell "no feedback" apart
|
|
* from "all-zeros".
|
|
*
|
|
* Layout: a compact table with one row per bucket. Mobile (<sm) keeps
|
|
* the table — the columns are short enough that horizontal-scroll
|
|
* isn't punishing, and the simpler card-list fallback obscures the
|
|
* provider comparison that's the whole point of the view.
|
|
*/
|
|
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { Sparkles, Loader2 } from "lucide-react";
|
|
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 FeedbackBucket {
|
|
severity: string;
|
|
metricSourceType: string;
|
|
providerType: string;
|
|
promptVersion: string;
|
|
helpful: number;
|
|
notHelpful: number;
|
|
total: number;
|
|
helpfulRate: number;
|
|
}
|
|
|
|
interface FeedbackSummary {
|
|
generatedAt: string;
|
|
windowDays: number;
|
|
buckets: FeedbackBucket[];
|
|
}
|
|
|
|
const SEVERITY_TINT: Record<string, string> = {
|
|
info: "text-dracula-cyan",
|
|
suggestion: "text-dracula-purple",
|
|
important: "text-dracula-orange",
|
|
urgent: "text-dracula-red",
|
|
};
|
|
|
|
export function AiQualitySection() {
|
|
const { t } = useTranslations();
|
|
const fmt = useFormatters();
|
|
|
|
const query = useQuery({
|
|
queryKey: queryKeys.adminAiQuality(),
|
|
queryFn: async () => {
|
|
const data = await apiGet<{ summary: FeedbackSummary | null } | null>(
|
|
"/api/admin/ai-quality",
|
|
);
|
|
return data?.summary ?? null;
|
|
},
|
|
});
|
|
|
|
if (query.isLoading) {
|
|
return (
|
|
<div className="bg-card border-border flex items-center gap-2 rounded-xl border p-4 sm:p-6">
|
|
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
|
<span className="text-muted-foreground text-sm">
|
|
{t("admin.aiQuality.loading")}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (query.isError) {
|
|
return (
|
|
<div
|
|
role="alert"
|
|
className="text-destructive bg-destructive/10 border-destructive/30 rounded-xl border p-4 text-sm"
|
|
>
|
|
{t("admin.aiQuality.loadError")}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const summary = query.data;
|
|
|
|
if (!summary || summary.buckets.length === 0) {
|
|
return (
|
|
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
|
<SettingsCardHeader
|
|
icon={Sparkles}
|
|
title={t("admin.aiQuality.title")}
|
|
/>
|
|
<p className="text-muted-foreground mt-3 pl-7 text-sm">
|
|
{t("admin.aiQuality.empty")}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="bg-card border-border space-y-4 rounded-xl border p-4 sm:p-6">
|
|
<SettingsCardHeader
|
|
icon={Sparkles}
|
|
title={t("admin.aiQuality.title")}
|
|
status={
|
|
<p className="text-muted-foreground text-xs">
|
|
{t("admin.aiQuality.windowLabel", {
|
|
days: String(summary.windowDays),
|
|
})}
|
|
{" · "}
|
|
{t("admin.aiQuality.generatedAtLabel")}{" "}
|
|
{fmt.dateTime(summary.generatedAt)}
|
|
</p>
|
|
}
|
|
/>
|
|
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm" data-slot="ai-quality-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.aiQuality.colSeverity")}
|
|
</th>
|
|
<th className="py-2 pr-3 font-medium">
|
|
{t("admin.aiQuality.colMetric")}
|
|
</th>
|
|
<th className="py-2 pr-3 font-medium">
|
|
{t("admin.aiQuality.colProvider")}
|
|
</th>
|
|
<th className="py-2 pr-3 font-medium">
|
|
{t("admin.aiQuality.colPromptVersion")}
|
|
</th>
|
|
<th className="py-2 pr-3 font-medium tabular-nums">
|
|
{t("admin.aiQuality.colHelpful")}
|
|
</th>
|
|
<th className="py-2 pr-3 font-medium tabular-nums">
|
|
{t("admin.aiQuality.colNotHelpful")}
|
|
</th>
|
|
<th className="py-2 font-medium tabular-nums">
|
|
{t("admin.aiQuality.colHelpfulRate")}
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{summary.buckets.map((bucket, idx) => {
|
|
const ratePct = Math.round(bucket.helpfulRate * 100);
|
|
return (
|
|
<tr
|
|
key={`${bucket.severity}-${bucket.metricSourceType}-${bucket.providerType}-${bucket.promptVersion}-${idx}`}
|
|
className="border-border/40 border-b last:border-0"
|
|
data-slot="ai-quality-bucket"
|
|
>
|
|
<td
|
|
className={`py-2 pr-3 font-medium ${
|
|
SEVERITY_TINT[bucket.severity] ?? ""
|
|
}`}
|
|
>
|
|
{bucket.severity}
|
|
</td>
|
|
<td className="py-2 pr-3">{bucket.metricSourceType}</td>
|
|
<td className="py-2 pr-3">{bucket.providerType}</td>
|
|
<td className="text-muted-foreground py-2 pr-3 text-xs">
|
|
{bucket.promptVersion}
|
|
</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 font-semibold tabular-nums ${helpfulRateColour(
|
|
bucket.helpfulRate,
|
|
)}`}
|
|
>
|
|
{ratePct}%
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|