Files
mybabyguess/apps/web/src/app/predictions/history/page.tsx
T
2026-05-03 21:58:59 +02:00

532 lines
21 KiB
TypeScript

"use client";
import Image from "next/image";
import { useEffect, useState } from "react";
import { MainTabBar } from "@/features/predictions/components/main-tab-bar";
import styles from "@/features/predictions/styles/predictions.module.css";
import historyStyles from "@/features/predictions/styles/history.module.css";
import { getApiUrl, PROJECT_CHANGED_EVENT } from "@/lib/auth";
import { getPredictionActivity } from "@/lib/predictions-client";
import type { PredictionActivity } from "@/types/predictions";
/* ---- helpers ---- */
function formatDate(value: string) {
const date = new Date(value);
return new Intl.DateTimeFormat("fr-FR", {
dateStyle: "medium",
timeStyle: "short",
}).format(date);
}
function formatRelativeDate(value: string) {
const date = new Date(value);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMinutes = Math.floor(diffMs / 60_000);
const diffHours = Math.floor(diffMs / 3_600_000);
const diffDays = Math.floor(diffMs / 86_400_000);
if (diffMinutes < 1) return "À l'instant";
if (diffMinutes < 60) return `Il y a ${diffMinutes} min`;
if (diffHours < 24) return `Il y a ${diffHours}h`;
if (diffDays === 1) return "Hier";
if (diffDays < 7) return `Il y a ${diffDays} jours`;
return formatDate(value);
}
function getDisplayName(user: NonNullable<PredictionActivity["user"]>) {
return user.displayName ?? user.username;
}
function resolveProfileSrc(apiUrl: string, url: string | null | undefined): string | null {
if (!url) return null;
if (url.startsWith("http")) return url;
return `${apiUrl}${url}`;
}
/**
* Parse les valeurs encodées dans le message du serveur.
* Format : "Nom a change CardTitle: Field1: val1 | Field2: val2"
* Format création : "Nom a pronostique CardTitle" (pas de valeurs)
*/
function parseMessageFieldValues(message: string): Array<{ label: string; rawValue: string }> {
// Cherche le séparateur entre le titre de la carte et les valeurs de champs
// ex: "...Sexe du bebe: Sexe: fille" → après le premier ": " on a "Sexe: fille"
const colonIdx = message.indexOf(": ");
if (colonIdx === -1) return [];
const valuesStr = message.slice(colonIdx + 2);
// Évite de parser des messages sans valeurs structurées (pas de ": " interne)
if (!valuesStr.includes(": ")) return [];
return valuesStr
.split(" | ")
.map((part) => {
const idx = part.indexOf(": ");
if (idx === -1) return null;
return { label: part.slice(0, idx), rawValue: part.slice(idx + 2) };
})
.filter((v): v is { label: string; rawValue: string } => v !== null && v.rawValue !== "(vide)" && v.rawValue.trim() !== "");
}
function isSexField(label: string): boolean {
const l = label.toLowerCase();
return l.includes("sexe") || l.includes("sex") || l.includes("genre");
}
function isWeightField(label: string): boolean {
const l = label.toLowerCase();
return l.includes("poid") || l.includes("weight");
}
function isNamesField(label: string): boolean {
const l = label.toLowerCase();
return l.includes("prenom") || l.includes("name");
}
function getMeasureUnit(label: string): string | null {
const l = label.toLowerCase();
if (l.includes("taille") || l.includes("height") || l.includes("périm") || l.includes("cranien") || l.includes("crânien")) return "cm";
return null;
}
/* ---- Composant : valeur structurée depuis message parsé ---- */
function ParsedValueDisplay({ label, rawValue }: { label: string; rawValue: string }) {
if (isWeightField(label)) {
const num = parseFloat(rawValue);
if (!isNaN(num)) {
const kg = num >= 100 ? num / 1000 : num;
return (
<div className={historyStyles.valueRow}>
<span className={historyStyles.fieldLabel}>{label}</span>
<span className={styles.recapWeightBadge}>{kg.toFixed(3)}{"\u00A0"}kg</span>
</div>
);
}
}
if (isSexField(label)) {
const isGarcon = rawValue.toLowerCase().includes("gar") || rawValue.toLowerCase() === "garcon" || rawValue.toLowerCase() === "garçon";
return (
<div className={historyStyles.valueRow}>
<span className={historyStyles.fieldLabel}>{label}</span>
<div
className={`${styles.recapSexAnswer} ${isGarcon ? styles.recapSexAnswerGarcon : styles.recapSexAnswerFille}`}
style={{ padding: "0.3rem 0.65rem", display: "inline-flex" }}
>
<Image
src={isGarcon ? "/images/pictos/choix-garcon.png" : "/images/pictos/choix-fille.png"}
alt=""
width={28}
height={28}
className={styles.recapSexIcon}
/>
<span className={styles.recapSexLabel} style={{ fontSize: "0.95rem" }}>{rawValue}</span>
</div>
</div>
);
}
if (isNamesField(label)) {
const names = rawValue.split(/[,;]+/).map((n) => n.trim()).filter(Boolean);
return (
<div className={historyStyles.valueRow}>
<span className={historyStyles.fieldLabel}>{label}</span>
<div className={styles.recapParticipantNamesList}>
{names.map((name) => (
<span key={name} className={styles.recapParticipantNameChip}>{name}</span>
))}
</div>
</div>
);
}
const num = parseFloat(rawValue);
if (!isNaN(num) && rawValue.trim() !== "") {
const unit = getMeasureUnit(label);
return (
<div className={historyStyles.valueRow}>
<span className={historyStyles.fieldLabel}>{label}</span>
<span className={historyStyles.valuePill}>{rawValue}{unit ? `\u00A0${unit}` : ""}</span>
</div>
);
}
return (
<div className={historyStyles.valueRow}>
<span className={historyStyles.fieldLabel}>{label}</span>
<span className={historyStyles.valuePill}>{rawValue}</span>
</div>
);
}
/* ---- Composant : valeur structurée depuis entry.values (fallback) ---- */
type ActivityEntryValue = NonNullable<PredictionActivity["entry"]>["values"][number];
function EntryValueDisplay({ value }: { value: ActivityEntryValue }) {
const label = value.field.label;
if (isWeightField(label) && value.valueNumber != null) {
const kg = value.valueNumber >= 100 ? value.valueNumber / 1000 : value.valueNumber;
return (
<div className={historyStyles.valueRow}>
<span className={historyStyles.fieldLabel}>{label}</span>
<span className={styles.recapWeightBadge}>{kg.toFixed(3)} kg</span>
</div>
);
}
if (isSexField(label) && value.valueText) {
const isGarcon = value.valueText.toLowerCase().includes("gar");
return (
<div className={historyStyles.valueRow}>
<span className={historyStyles.fieldLabel}>{label}</span>
<div className={`${styles.recapSexAnswer} ${isGarcon ? styles.recapSexAnswerGarcon : styles.recapSexAnswerFille}`} style={{ padding: "0.3rem 0.65rem", display: "inline-flex" }}>
<Image src={isGarcon ? "/images/pictos/choix-garcon.png" : "/images/pictos/choix-fille.png"} alt="" width={28} height={28} className={styles.recapSexIcon} />
<span className={styles.recapSexLabel} style={{ fontSize: "0.95rem" }}>{value.valueText}</span>
</div>
</div>
);
}
if (isNamesField(label) && value.valueText) {
const names = value.valueText.split(/[,;]+/).map((n) => n.trim()).filter(Boolean);
return (
<div className={historyStyles.valueRow}>
<span className={historyStyles.fieldLabel}>{label}</span>
<div className={styles.recapParticipantNamesList}>
{names.map((name) => <span key={name} className={styles.recapParticipantNameChip}>{name}</span>)}
</div>
</div>
);
}
if (value.valueNumber != null) {
const unit = getMeasureUnit(label);
return (
<div className={historyStyles.valueRow}>
<span className={historyStyles.fieldLabel}>{label}</span>
<span className={historyStyles.valuePill}>{value.valueNumber}{unit ? `\u00A0${unit}` : ""}</span>
</div>
);
}
if (value.valueText) {
return (
<div className={historyStyles.valueRow}>
<span className={historyStyles.fieldLabel}>{label}</span>
<span className={historyStyles.valuePill}>{value.valueText}</span>
</div>
);
}
return null;
}
type ActivityGlyphKind =
| "prediction"
| "update"
| "score"
| "validated"
| "lock"
| "unlock"
| "target"
| "card"
| "trash"
| "log";
function getActivityGlyphKind(type: string): ActivityGlyphKind {
switch (type) {
case "PREDICTION_CREATED": return "prediction";
case "PREDICTION_UPDATED": return "update";
case "SCORE_SUGGESTED": return "score";
case "SCORE_VALIDATED": return "validated";
case "GAME_CLOSED": return "lock";
case "GAME_OPENED": return "unlock";
case "OUTCOME_SET": return "target";
case "CARD_CREATED":
case "CARD_UPDATED": return "card";
case "CARD_DELETED": return "trash";
default: return "log";
}
}
function ActivityGlyph({ kind }: Readonly<{ kind: ActivityGlyphKind }>) {
const commonProps = {
fill: "none",
stroke: "currentColor",
strokeWidth: 1.9,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
viewBox: "0 0 24 24",
ariaHidden: true,
};
switch (kind) {
case "prediction":
return (
<svg viewBox={commonProps.viewBox} fill={commonProps.fill} stroke={commonProps.stroke} strokeWidth={commonProps.strokeWidth} strokeLinecap={commonProps.strokeLinecap} strokeLinejoin={commonProps.strokeLinejoin} aria-hidden>
<path d="M4 20l4.3-1.1L18.6 8.6a2.2 2.2 0 0 0-3.1-3.1L5.2 15.8 4 20Z" />
<path d="m13.8 7.2 3 3" />
</svg>
);
case "update":
return (
<svg viewBox={commonProps.viewBox} fill={commonProps.fill} stroke={commonProps.stroke} strokeWidth={commonProps.strokeWidth} strokeLinecap={commonProps.strokeLinecap} strokeLinejoin={commonProps.strokeLinejoin} aria-hidden>
<path d="M19 8a7 7 0 0 0-11.8-2L5 8" />
<path d="M5 4v4h4" />
<path d="M5 16a7 7 0 0 0 11.8 2L19 16" />
<path d="M19 20v-4h-4" />
</svg>
);
case "score":
return (
<svg viewBox={commonProps.viewBox} fill={commonProps.fill} stroke={commonProps.stroke} strokeWidth={commonProps.strokeWidth} strokeLinecap={commonProps.strokeLinecap} strokeLinejoin={commonProps.strokeLinejoin} aria-hidden>
<path d="m12 3 2.5 5.1 5.6.8-4 3.9.9 5.5L12 15.8 7 18.3l.9-5.5-4-3.9 5.6-.8Z" />
</svg>
);
case "validated":
return (
<svg viewBox={commonProps.viewBox} fill={commonProps.fill} stroke={commonProps.stroke} strokeWidth={commonProps.strokeWidth} strokeLinecap={commonProps.strokeLinecap} strokeLinejoin={commonProps.strokeLinejoin} aria-hidden>
<circle cx="12" cy="10" r="5.5" />
<path d="m9.8 10.2 1.5 1.6 3-3.2" />
<path d="M10.2 15.2 8.8 20l3.2-1.9L15.2 20l-1.4-4.8" />
</svg>
);
case "lock":
return (
<svg viewBox={commonProps.viewBox} fill={commonProps.fill} stroke={commonProps.stroke} strokeWidth={commonProps.strokeWidth} strokeLinecap={commonProps.strokeLinecap} strokeLinejoin={commonProps.strokeLinejoin} aria-hidden>
<rect x="5" y="11" width="14" height="9" rx="2.5" />
<path d="M8 11V8.5a4 4 0 1 1 8 0V11" />
</svg>
);
case "unlock":
return (
<svg viewBox={commonProps.viewBox} fill={commonProps.fill} stroke={commonProps.stroke} strokeWidth={commonProps.strokeWidth} strokeLinecap={commonProps.strokeLinecap} strokeLinejoin={commonProps.strokeLinejoin} aria-hidden>
<rect x="5" y="11" width="14" height="9" rx="2.5" />
<path d="M16 11V8.5a4 4 0 0 0-7.7-1.4" />
</svg>
);
case "target":
return (
<svg viewBox={commonProps.viewBox} fill={commonProps.fill} stroke={commonProps.stroke} strokeWidth={commonProps.strokeWidth} strokeLinecap={commonProps.strokeLinecap} strokeLinejoin={commonProps.strokeLinejoin} aria-hidden>
<circle cx="12" cy="12" r="7" />
<circle cx="12" cy="12" r="3" />
<path d="M12 2v3" />
<path d="M22 12h-3" />
<path d="M12 22v-3" />
<path d="M2 12h3" />
</svg>
);
case "card":
return (
<svg viewBox={commonProps.viewBox} fill={commonProps.fill} stroke={commonProps.stroke} strokeWidth={commonProps.strokeWidth} strokeLinecap={commonProps.strokeLinecap} strokeLinejoin={commonProps.strokeLinejoin} aria-hidden>
<rect x="4" y="6" width="12" height="14" rx="2.5" />
<path d="M8 10h4" />
<path d="M8 14h6" />
<path d="M16 9h2.5a1.5 1.5 0 0 1 1.5 1.5V17" />
</svg>
);
case "trash":
return (
<svg viewBox={commonProps.viewBox} fill={commonProps.fill} stroke={commonProps.stroke} strokeWidth={commonProps.strokeWidth} strokeLinecap={commonProps.strokeLinecap} strokeLinejoin={commonProps.strokeLinejoin} aria-hidden>
<path d="M4 7h16" />
<path d="M9 4h6" />
<path d="m6 7 1 12h10l1-12" />
<path d="M10 11v5" />
<path d="M14 11v5" />
</svg>
);
default:
return (
<svg viewBox={commonProps.viewBox} fill={commonProps.fill} stroke={commonProps.stroke} strokeWidth={commonProps.strokeWidth} strokeLinecap={commonProps.strokeLinecap} strokeLinejoin={commonProps.strokeLinejoin} aria-hidden>
<path d="M7 7h10" />
<path d="M7 12h10" />
<path d="M7 17h6" />
</svg>
);
}
}
function getActivityBadgeClass(type: string): string {
if (type === "GAME_CLOSED" || type === "GAME_OPENED") return historyStyles.badgeSystem;
if (type === "SCORE_VALIDATED") return historyStyles.badgeSuccess;
if (type === "SCORE_SUGGESTED") return historyStyles.badgeWarning;
if (type === "PREDICTION_CREATED" || type === "PREDICTION_UPDATED") return historyStyles.badgePrimary;
return historyStyles.badgeMuted;
}
function UserAvatar({ user, apiUrl }: { user: NonNullable<PredictionActivity["user"]>; apiUrl: string }) {
const name = getDisplayName(user);
const initials = name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((p) => p[0]?.toUpperCase() ?? "")
.join("") || "?";
const imageSrc = resolveProfileSrc(apiUrl, user.profileImageUrl);
return (
<div
className={`${styles.recapAvatar} ${styles.recapAvatarSm}`}
style={user.profileBgColor ? { background: user.profileBgColor } : undefined}
aria-hidden="true"
>
{imageSrc ? <img src={imageSrc} alt="" /> : <span>{initials}</span>}
</div>
);
}
export default function PredictionsHistoryPage() {
const apiUrl = getApiUrl();
const [activity, setActivity] = useState<PredictionActivity[]>([]);
const [loading, setLoading] = useState(true);
const [message, setMessage] = useState("");
const [projectRefreshKey, setProjectRefreshKey] = useState(0);
useEffect(() => {
const onProjectChanged = () => {
setProjectRefreshKey((current) => current + 1);
};
window.addEventListener(PROJECT_CHANGED_EVENT, onProjectChanged);
return () => window.removeEventListener(PROJECT_CHANGED_EVENT, onProjectChanged);
}, []);
useEffect(() => {
setLoading(true);
setMessage("");
getPredictionActivity()
.then((payload) => setActivity(payload))
.catch((error) => setMessage(error instanceof Error ? error.message : "Impossible de charger l'historique"))
.finally(() => setLoading(false));
}, [projectRefreshKey]);
return (
<>
<section className={`${styles.recapHero} ${styles.historyHero}`}>
<div className={styles.recapHeroContent}>
<span className={styles.recapHeroEyebrow}>Activité</span>
<h1>Historique des actions</h1>
<p>Toutes les modifications du projet, en temps réel.</p>
<div className={styles.recapHeroStats}>
<div className={styles.recapHeroStatChip}>
<span className={`${styles.recapHeroStatValue} ${historyStyles.heroStatIcon}`}>
<ActivityGlyph kind="prediction" />
</span>
<span className={styles.recapHeroStatLabel}>Pronostics</span>
</div>
<div className={styles.recapHeroStatChip}>
<span className={`${styles.recapHeroStatValue} ${historyStyles.heroStatIcon}`}>
<ActivityGlyph kind="update" />
</span>
<span className={styles.recapHeroStatLabel}>Modifs</span>
</div>
{/* <div className={styles.recapHeroStatChip}>
<span className={styles.recapHeroStatValue}>⭐</span>
<span className={styles.recapHeroStatLabel}>Scores</span>
</div> */}
</div>
</div>
<div className={styles.historyHeroIllustration} aria-hidden="true">
<span className={styles.historyHeroLine} />
<span className={styles.historyHeroDot1} />
<span className={styles.historyHeroDot2} />
<span className={styles.historyHeroDot3} />
</div>
</section>
<MainTabBar />
{!loading && !message && activity.length > 0 && (
<div className={styles.recapSectionHeading} style={{ padding: "0 0.5rem" }}>
<p>{activity.length} événement{activity.length !== 1 ? "s" : ""} enregistré{activity.length !== 1 ? "s" : ""}.</p>
</div>
)}
{loading && (
<div className={styles.loadingScreen}>
<p>Chargement de l'historique…</p>
</div>
)}
{!loading && message && (
<p className={historyStyles.errorMsg}>{message}</p>
)}
{!loading && !message && activity.length === 0 && (
<p className={styles.empty} style={{ margin: "0 0.5rem" }}>Aucun événement pour le moment.</p>
)}
{!loading && !message && activity.length > 0 && (
<div className={historyStyles.timelineList}>
{activity.map((item) => {
// Priorité : parser les valeurs depuis le message (format serveur fiable)
const parsedValues = parseMessageFieldValues(item.message);
// Fallback : entry.values (état courant de l'entrée, peut différer)
const fallbackValues = parsedValues.length === 0 ? (item.entry?.values ?? []) : [];
const hasDisplayValues = parsedValues.length > 0 || fallbackValues.length > 0;
// Extraire le nom de l'auteur depuis le message si item.user est null
// Ex: "Valerie a change X: ..." → "Valerie"
const authorFromMessage = !item.user
? item.message.split(/\s+a\s+(change|pronostique|cree|fixe|suggere|valide)/i)?.[0] ?? null
: null;
const activityGlyphKind = getActivityGlyphKind(item.type);
return (
<article key={item.id} className={historyStyles.timelineCard}>
{/* Left: avatar ou icône système */}
<div className={historyStyles.timelineAvatarCol}>
{item.user ? (
<UserAvatar user={item.user} apiUrl={apiUrl} />
) : (
<div className={historyStyles.systemIcon} aria-hidden="true">
<span className={historyStyles.systemIconGlyph}>
<ActivityGlyph kind={activityGlyphKind} />
</span>
</div>
)}
<div className={historyStyles.timelineLine} aria-hidden="true" />
</div>
{/* Right: contenu */}
<div className={historyStyles.timelineContent}>
<div className={historyStyles.timelineHeader}>
<div className={historyStyles.timelineHeadLeft}>
{(item.user || authorFromMessage) && (
<strong className={historyStyles.userName}>
{item.user ? getDisplayName(item.user) : authorFromMessage}
</strong>
)}
<span className={`${historyStyles.activityLabel} ${getActivityBadgeClass(item.type)}`}>
<span className={historyStyles.activityLabelIcon} aria-hidden="true">
<ActivityGlyph kind={activityGlyphKind} />
</span>
<span className={historyStyles.activityLabelText}>{item.card?.title ?? item.message}</span>
</span>
</div>
<time className={historyStyles.timelineTime} dateTime={item.createdAt} title={formatDate(item.createdAt)}>
{formatRelativeDate(item.createdAt)}
</time>
</div>
{hasDisplayValues && (
<div className={historyStyles.valuesBlock}>
{parsedValues.map((v) => (
<ParsedValueDisplay key={v.label} label={v.label} rawValue={v.rawValue} />
))}
{fallbackValues.map((v) => (
<EntryValueDisplay key={v.fieldId} value={v} />
))}
</div>
)}
</div>
</article>
);
})}
</div>
)}
</>
);
}