"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) { 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 (
{label} {kg.toFixed(3)}{"\u00A0"}kg
); } } if (isSexField(label)) { const isGarcon = rawValue.toLowerCase().includes("gar") || rawValue.toLowerCase() === "garcon" || rawValue.toLowerCase() === "garçon"; return (
{label}
{rawValue}
); } if (isNamesField(label)) { const names = rawValue.split(/[,;]+/).map((n) => n.trim()).filter(Boolean); return (
{label}
{names.map((name) => ( {name} ))}
); } const num = parseFloat(rawValue); if (!isNaN(num) && rawValue.trim() !== "") { const unit = getMeasureUnit(label); return (
{label} {rawValue}{unit ? `\u00A0${unit}` : ""}
); } return (
{label} {rawValue}
); } /* ---- Composant : valeur structurée depuis entry.values (fallback) ---- */ type ActivityEntryValue = NonNullable["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 (
{label} {kg.toFixed(3)} kg
); } if (isSexField(label) && value.valueText) { const isGarcon = value.valueText.toLowerCase().includes("gar"); return (
{label}
{value.valueText}
); } if (isNamesField(label) && value.valueText) { const names = value.valueText.split(/[,;]+/).map((n) => n.trim()).filter(Boolean); return (
{label}
{names.map((name) => {name})}
); } if (value.valueNumber != null) { const unit = getMeasureUnit(label); return (
{label} {value.valueNumber}{unit ? `\u00A0${unit}` : ""}
); } if (value.valueText) { return (
{label} {value.valueText}
); } 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 ( ); case "update": return ( ); case "score": return ( ); case "validated": return ( ); case "lock": return ( ); case "unlock": return ( ); case "target": return ( ); case "card": return ( ); case "trash": return ( ); default: return ( ); } } 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; 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 ( ); } export default function PredictionsHistoryPage() { const apiUrl = getApiUrl(); const [activity, setActivity] = useState([]); 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 ( <>
Activité

Historique des actions

Toutes les modifications du projet, en temps réel.

Pronostics
Modifs
{/*
Scores
*/}
{!loading && !message && activity.length > 0 && (

{activity.length} événement{activity.length !== 1 ? "s" : ""} enregistré{activity.length !== 1 ? "s" : ""}.

)} {loading && (

Chargement de l'historique…

)} {!loading && message && (

{message}

)} {!loading && !message && activity.length === 0 && (

Aucun événement pour le moment.

)} {!loading && !message && activity.length > 0 && (
{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 (
{/* Left: avatar ou icône système */}
{item.user ? ( ) : ( )} {/* Right: contenu */}
{(item.user || authorFromMessage) && ( {item.user ? getDisplayName(item.user) : authorFromMessage} )} {item.card?.title ?? item.message}
{hasDisplayValues && (
{parsedValues.map((v) => ( ))} {fallbackValues.map((v) => ( ))}
)}
); })}
)} ); }