SPB Git forge

spb/qc26

Public
10commits 1branches 0releases
2.8 MBsize
maindefault branch
17 days agolast push
Python 51.6% TypeScript 46.7% CSS 1.7%
12.4 KB · 115 lines tsx
Raw Blame History
1"use client";23import Link from "next/link";4import { useEffect, useMemo, useState } from "react";5import { cls, prob } from "@/lib/format";6import { PARTY_IDS, partyVar, partyLabel, partyName } from "@/lib/parties";7import { PartyLogo } from "./party";8import { Badge, Card, ProbabilityBar, SectionHeader } from "./ui";910export interface CompassData { axes: Record<string, { label: string; left: string; right: string }>; questions: { id: string; axis: string; topic: string; topicLabel: string; text: string; poleAgree: string; poleDisagree: string }[]; coverage: Record<string, { positions: number; total: number }> }11interface MatchResult { results: Record<string, { score: number | null; coverage: number; answered: number; axes: Record<string, number>; details: { question_id: string; user: number; party: number; agreement: number; confidence: number; proposal_ids: number[]; rationale: string | null }[] }>; proposals: Record<string, { proposal: string; quote: string; page: number | null; confidence: number; documentId: number; documentTitle: string | null; documentUrl: string | null }> }1213const SCALE = [[-2, "Tout à fait en désaccord"], [-1, "Plutôt en désaccord"], [0, "Neutre"], [1, "Plutôt d'accord"], [2, "Tout à fait d'accord"]] as const;14const IMP = [[1, "Pas important"], [2, "Important"], [3, "Très important"]] as const;15const KEY = "qc26-boussole-v1";1617export function Compass({ data }: { data: CompassData }) {18  const [answers, setAnswers] = useState<Record<string, number>>({});19  const [imp, setImp] = useState<Record<string, number>>({});20  const [res, setRes] = useState<MatchResult | null>(null);21  const [busy, setBusy] = useState(false);22  const [why, setWhy] = useState<string | null>(null);23  const [idx, setIdx] = useState(0);24  useEffect(() => { try { const s = JSON.parse(localStorage.getItem(KEY) || "{}"); if (s.answers) setAnswers(s.answers); if (s.imp) setImp(s.imp); } catch { /* ignore */ } }, []);25  useEffect(() => { localStorage.setItem(KEY, JSON.stringify({ answers, imp })); }, [answers, imp]);26  const qs = data.questions;27  const answered = Object.keys(answers).length;28  const total = qs.length;29  const q = qs[idx];30  const byAxis = useMemo(() => { const m: Record<string, number> = {}; qs.forEach((x) => { m[x.axis] = (m[x.axis] ?? 0) + (answers[x.id] !== undefined ? 1 : 0); }); return m; }, [qs, answers]);31  const submit = async () => {32    setBusy(true);33    try { const r = await fetch("/api/compass/match", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ answers, importance: imp }) }); setRes(await r.json()); window.scrollTo({ top: 0, behavior: "smooth" }); } finally { setBusy(false); }34  };35  const reset = () => { setAnswers({}); setImp({}); setRes(null); setIdx(0); setWhy(null); };36  const noPositions = Object.values(data.coverage).every((c) => c.positions === 0);3738  if (res) {39    const ranking = PARTY_IDS.map((id) => ({ id, ...res.results[id] })).sort((a, b) => (b.score ?? -1) - (a.score ?? -1));40    return (41      <div className="space-y-8">42        <Card className="card-lg">43          <div className="flex items-center justify-between flex-wrap gap-2"><div className="eyebrow">Votre résultat · {answered} réponses</div><button onClick={reset} className="text-[12.5px] link">Recommencer</button></div>44          <div className="mt-4 space-y-4">45            {ranking.map((r) => (46              <div key={r.id}>47                <div className="flex items-center justify-between gap-3 mb-1"><span className="inline-flex items-center gap-2"><PartyLogo id={r.id} height={18} /><b>{partyName(r.id)}</b><span className="text-[11.5px] text-ink-3">{r.coverage} énoncés documentés</span></span><span className="num font-bold text-[22px]" style={{ color: partyVar(r.id) }}>{r.score === null ? "—" : `${Math.round(r.score)} %`}</span></div>48                <ProbabilityBar p={(r.score ?? 0) / 100} color={partyVar(r.id)} showValue={false} height={12} />49              </div>50            ))}51          </div>52          <p className="text-[12.5px] text-ink-3 mt-4">Concordance = 1 − |votre réponse − position du parti| / 4, pondérée par l&apos;importance. Un parti sans position documentée sur un énoncé n&apos;est ni pénalisé ni avantagé sur cet énoncé : regardez la couverture. Ce résultat n&apos;est pas une recommandation de vote.</p>53        </Card>54        <section>55          <SectionHeader eyebrow="Par axe" title="Où vous vous situez" />56          <div className="grid md:grid-cols-2 gap-3">57            {Object.entries(data.axes).map(([axis, a]) => (58              <Card key={axis}><div className="font-semibold text-[14px]">{a.label}</div><div className="flex justify-between text-[11.5px] text-ink-3 mt-1"><span>{a.left}</span><span>{a.right}</span></div>59                <div className="mt-2 space-y-1">{ranking.filter((r) => r.axes[axis] !== undefined).map((r) => (<div key={r.id} className="grid grid-cols-[40px_1fr_44px] items-center gap-2 text-[12.5px]"><b style={{ color: partyVar(r.id) }}>{partyLabel(r.id)}</b><div className="h-[6px] bg-surface-3 rounded-full overflow-hidden"><div className="h-full" style={{ width: `${r.axes[axis]}%`, background: partyVar(r.id) }} /></div><span className="num text-right">{Math.round(r.axes[axis])} %</span></div>))}</div>60              </Card>61            ))}62          </div>63        </section>64        <section>65          <SectionHeader eyebrow="Voir pourquoi" title="Énoncé par énoncé" description="Votre réponse, la position estimée de chaque parti, la source officielle et l'extrait." />66          <div className="space-y-3">67            {qs.filter((x) => answers[x.id] !== undefined).map((x) => (68              <Card key={x.id} pad={false}>69                <button onClick={() => setWhy(why === x.id ? null : x.id)} className="w-full text-left p-4 flex items-start justify-between gap-3"><div><div className="text-[11px] text-ink-3 uppercase tracking-wide">{data.axes[x.axis].label} · {x.topicLabel}</div><div className="font-medium text-[14px] mt-0.5">{x.text}</div><div className="text-[12.5px] text-ink-2 mt-1">Vous : <b>{SCALE.find((s) => s[0] === answers[x.id])?.[1]}</b> · importance {IMP.find((s) => s[0] === (imp[x.id] ?? 2))?.[1]?.toLowerCase()}</div></div><span className="text-ink-3">{why === x.id ? "−" : "+"}</span></button>70                {why === x.id && (71                  <div className="border-t border-border p-4 grid md:grid-cols-5 gap-3">72                    {PARTY_IDS.map((pid) => { const d = res.results[pid].details.find((dd) => dd.question_id === x.id); return (73                      <div key={pid} className="surface-2 rounded-[8px] p-3 text-[12.5px]">74                        <div className="flex items-center justify-between"><b style={{ color: partyVar(pid) }}>{partyLabel(pid)}</b>{d ? <span className="num">{Math.round(d.agreement * 100)} %</span> : <Badge>non documenté</Badge>}</div>75                        {d ? (<><div className="mt-1">Position : <b>{SCALE.find((s) => s[0] === d.party)?.[1]}</b> <span className="text-ink-3">(confiance {Math.round(d.confidence * 100)} %)</span></div>{d.rationale && <div className="text-ink-2 mt-1 leading-relaxed">{d.rationale}</div>}76                          <ul className="mt-2 space-y-1.5">{d.proposal_ids.slice(0, 3).map((pid2) => { const p = res.proposals[String(pid2)]; return p ? (<li key={pid2} className="border-l-2 pl-2" style={{ borderColor: partyVar(pid) }}><div className="italic text-ink-2">« {p.quote.slice(0, 220)}{p.quote.length > 220 ? "…" : ""} »</div><div className="text-[11.5px] text-ink-3 mt-0.5"><Link href={`/documents/${p.documentId}`} className="link">{p.documentTitle ?? "Document"}</Link>{p.page ? ` · p. ${p.page}` : ""} · confiance {Math.round(p.confidence * 100)} %</div></li>) : null; })}</ul></>) : <div className="text-ink-3 mt-1">Aucune proposition officielle ingérée ne permet de situer ce parti sur cet énoncé.</div>}77                      </div>); })}78                  </div>79                )}80              </Card>81            ))}82          </div>83        </section>84      </div>85    );86  }8788  return (89    <div className="grid lg:grid-cols-[1fr_300px] gap-6 items-start">90      <Card className="card-lg">91        {noPositions && <div className="mb-4 rounded-[8px] bg-[rgba(180,83,9,.08)] text-warn text-[13px] p-3">Les positions des partis sont en cours d&apos;extraction depuis les documents officiels. Vous pouvez répondre dès maintenant ; le résultat s&apos;affinera à mesure que les documents sont analysés.</div>}92        <div className="flex items-center justify-between text-[12.5px] text-ink-3"><span>Énoncé {idx + 1} / {total}</span><span className="num">{answered} réponses</span></div>93        <div className="h-[4px] bg-surface-3 rounded-full mt-2 overflow-hidden"><div className="h-full bg-brand transition-[width]" style={{ width: `${((idx + 1) / total) * 100}%` }} /></div>94        {q && (95          <div className="mt-6 fade-up" key={q.id}>96            <div className="text-[11px] text-ink-3 uppercase tracking-wide">{data.axes[q.axis].label} · {q.topicLabel}</div>97            <h2 className="text-[20px] md:text-[24px] font-semibold tracking-tight leading-snug mt-2">{q.text}</h2>98            <div className="grid grid-cols-1 sm:grid-cols-5 gap-2 mt-6">99              {SCALE.map(([v, l]) => (<button key={v} onClick={() => { setAnswers((a) => ({ ...a, [q.id]: v })); setTimeout(() => setIdx((i) => Math.min(total - 1, i + 1)), 160); }} className={cls("rounded-[8px] border px-3 py-3 text-[13px] font-medium transition-colors", answers[q.id] === v ? "bg-brand text-white border-brand" : "border-border hover:bg-surface-2")}>{l}</button>))}100            </div>101            <div className="mt-5 flex items-center gap-2 flex-wrap text-[12.5px]"><span className="text-ink-3 mr-1">Importance :</span>{IMP.map(([v, l]) => (<button key={v} onClick={() => setImp((m) => ({ ...m, [q.id]: v }))} className={cls("rounded-full border px-3 py-1", (imp[q.id] ?? 2) === v ? "bg-accent-soft text-accent border-transparent font-semibold" : "border-border")}>{l}</button>))}</div>102            <div className="mt-6 flex items-center justify-between"><button onClick={() => setIdx((i) => Math.max(0, i - 1))} disabled={idx === 0} className="text-[13px] text-ink-2 disabled:opacity-30">← Précédent</button><div className="flex gap-2"><button onClick={() => setIdx((i) => Math.min(total - 1, i + 1))} className="text-[13px] text-ink-2">Passer →</button><button onClick={submit} disabled={answered < 5 || busy} className="rounded-[8px] bg-brand text-white px-4 py-2 text-[13.5px] font-semibold disabled:opacity-40">{busy ? "Calcul…" : `Voir mon résultat (${answered})`}</button></div></div>103          </div>104        )}105      </Card>106      <div className="space-y-3">107        <Card><div className="eyebrow mb-2">Progression par axe</div><div className="space-y-2">{Object.entries(data.axes).map(([axis, a]) => { const n = qs.filter((x) => x.axis === axis).length; return (<div key={axis} className="text-[12.5px]"><div className="flex justify-between"><span>{a.label}</span><span className="num text-ink-3">{byAxis[axis] ?? 0}/{n}</span></div><div className="h-[4px] bg-surface-3 rounded-full mt-1 overflow-hidden"><div className="h-full bg-accent" style={{ width: `${((byAxis[axis] ?? 0) / n) * 100}%` }} /></div></div>); })}</div></Card>108        <Card><div className="eyebrow mb-2">Couverture documentaire</div><div className="space-y-1.5 text-[12.5px]">{PARTY_IDS.map((id) => (<div key={id} className="flex items-center justify-between"><span className="inline-flex items-center gap-1.5"><span className="w-2 h-2 rounded-full" style={{ background: partyVar(id) }} />{partyLabel(id)}</span><span className="num text-ink-2">{data.coverage[id]?.positions ?? 0} / {data.coverage[id]?.total ?? total} positions</span></div>))}</div><p className="text-[11.5px] text-ink-3 mt-2">Positions extraites des documents officiels ; sans document, pas de position. <Link href="/documents" className="link">Bibliothèque →</Link></p></Card>109        <Card><div className="eyebrow mb-1">Grille de réponse</div><div className="grid grid-cols-8 gap-1 mt-2">{qs.map((x, i) => (<button key={x.id} onClick={() => setIdx(i)} aria-label={`Énoncé ${i + 1}`} className={cls("h-6 rounded-[4px] text-[10px] num", answers[x.id] !== undefined ? "bg-brand text-white" : i === idx ? "bg-accent-soft text-accent" : "bg-surface-2 text-ink-3")}>{i + 1}</button>))}</div></Card>110        <div className="text-[11.5px] text-ink-3 px-1">{prob(answered / total)} des énoncés répondus. Minimum 5 pour un résultat.</div>111      </div>112    </div>113  );114}115