532 lines
21 KiB
TypeScript
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>
|
|
)}
|
|
</>
|
|
);
|
|
}
|