File: /homepages/35/d993672390/htdocs/dermiteseborrheique/app/suspects.php
<?php
define('DS_APP', true);
require_once dirname(__DIR__) . '/includes/init.php';
require_once DS_INCLUDES . 'triggers.php';
$user = auth_require();
// ── Moteur de corrélation ─────────────────────────────────────────────────────
function computeCorrelations(int $userId, int $windowHours = 48): void
{
// Récupérer toutes les entrées avec crises
$crisisEntries = db_fetch_all(
"SELECT id, entry_date FROM journal_entries
WHERE user_id = ? AND is_crisis = 1
ORDER BY entry_date DESC",
[$userId]
);
if (empty($crisisEntries)) return;
// Récupérer tous les items consommés par l'user
$allItems = db_fetch_all(
"SELECT DISTINCT ei.item_id, i.name, i.category
FROM entry_items ei
JOIN journal_entries je ON ei.entry_id = je.id
JOIN items i ON ei.item_id = i.id
WHERE je.user_id = ?",
[$userId]
);
foreach ($allItems as $item) {
$itemId = $item['item_id'];
// Total fois consommé
$totalOccurrences = db_count(
"SELECT COUNT(*) FROM entry_items ei
JOIN journal_entries je ON ei.entry_id = je.id
WHERE je.user_id = ? AND ei.item_id = ?",
[$userId, $itemId]
);
if ($totalOccurrences < 2) continue; // Pas assez de données
// Fois consommé dans la fenêtre précédant une crise
$crisisOccurrences = 0;
foreach ($crisisEntries as $crisis) {
$windowStart = date('Y-m-d', strtotime($crisis['entry_date'] . " -{$windowHours} hours"));
$found = db_count(
"SELECT COUNT(*) FROM entry_items ei
JOIN journal_entries je ON ei.entry_id = je.id
WHERE je.user_id = ? AND ei.item_id = ?
AND je.entry_date BETWEEN ? AND ?",
[$userId, $itemId, $windowStart, $crisis['entry_date']]
);
if ($found > 0) $crisisOccurrences++;
}
// Score = (crises précédées / total crises) * (occurrences avant crise / total occurrences) * 100
$totalCrises = count($crisisEntries);
$crisisRatio = $totalCrises > 0 ? $crisisOccurrences / $totalCrises : 0;
$presenceRatio= $totalOccurrences > 0 ? $crisisOccurrences / $totalOccurrences : 0;
$score = round(($crisisRatio * 0.6 + $presenceRatio * 0.4) * 100, 2);
// Upsert corrélation
db_execute(
"INSERT INTO correlations
(user_id, item_id, score, occurrences_total, occurrences_crisis, time_window_hours)
VALUES (?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
score = VALUES(score),
occurrences_total = VALUES(occurrences_total),
occurrences_crisis = VALUES(occurrences_crisis),
last_computed_at = NOW()",
[$userId, $itemId, $score, $totalOccurrences, $crisisOccurrences, $windowHours]
);
}
}
// ── Moteur de corrélation par ingrédients ────────────────────────────────────
function computeIngredientCorrelations(int $userId, int $windowHours = 48): void
{
// Récupérer toutes les entrées avec crises
$crisisEntries = db_fetch_all(
"SELECT id, entry_date FROM journal_entries
WHERE user_id = ? AND is_crisis = 1
ORDER BY entry_date DESC",
[$userId]
);
if (empty($crisisEntries)) return;
// Récupérer tous les ingrédients des items consommés par l'user
$allIngredients = db_fetch_all(
"SELECT DISTINCT ii.inci_name, ii.common_name, ii.item_id
FROM entry_items ei
JOIN journal_entries je ON ei.entry_id = je.id
JOIN item_ingredients ii ON ei.item_id = ii.item_id
WHERE je.user_id = ?",
[$userId]
);
if (empty($allIngredients)) return;
// Grouper par inci_name
$byInci = [];
foreach ($allIngredients as $ing) {
$inci = $ing['inci_name'];
if (!isset($byInci[$inci])) {
$byInci[$inci] = [
'inci_name' => $inci,
'common_name' => $ing['common_name'],
];
}
}
$totalCrises = count($crisisEntries);
foreach ($byInci as $inci => $ing) {
// Total fois consommé (via items contenant cet ingrédient)
$totalOccurrences = db_count(
"SELECT COUNT(DISTINCT je.id)
FROM journal_entries je
JOIN entry_items ei ON ei.entry_id = je.id
JOIN item_ingredients ii ON ii.item_id = ei.item_id
WHERE je.user_id = ? AND ii.inci_name = ?",
[$userId, $inci]
);
if ($totalOccurrences < 2) continue;
// Fois consommé dans la fenêtre précédant une crise
$crisisOccurrences = 0;
foreach ($crisisEntries as $crisis) {
$windowStart = date('Y-m-d', strtotime($crisis['entry_date'] . " -{$windowHours} hours"));
$found = db_count(
"SELECT COUNT(DISTINCT je.id)
FROM journal_entries je
JOIN entry_items ei ON ei.entry_id = je.id
JOIN item_ingredients ii ON ii.item_id = ei.item_id
WHERE je.user_id = ? AND ii.inci_name = ?
AND je.entry_date BETWEEN ? AND ?",
[$userId, $inci, $windowStart, $crisis['entry_date']]
);
if ($found > 0) $crisisOccurrences++;
}
$crisisRatio = $totalCrises > 0 ? $crisisOccurrences / $totalCrises : 0;
$presenceRatio = $totalOccurrences > 0 ? $crisisOccurrences / $totalOccurrences : 0;
$score = round(($crisisRatio * 0.6 + $presenceRatio * 0.4) * 100, 2);
if ($score < 10) continue; // Ignorer les scores trop faibles
// Upsert dans ingredient_correlations
db_execute(
"INSERT INTO ingredient_correlations
(user_id, inci_name, common_name, score, occurrence_count, total_count, last_computed)
VALUES (?, ?, ?, ?, ?, ?, NOW())
ON DUPLICATE KEY UPDATE
score = VALUES(score),
occurrence_count = VALUES(occurrence_count),
total_count = VALUES(total_count),
last_computed = NOW()",
[
$userId,
$inci,
$ing['common_name'] ?: $inci,
$score,
$crisisOccurrences,
$totalOccurrences,
]
);
}
}
// ── Actions POST ──────────────────────────────────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST' && is_ajax()) {
csrf_verify();
$action = $_POST['action'] ?? '';
if ($action === 'update_status') {
$itemId = (int)($_POST['item_id'] ?? 0);
$status = $_POST['status'] ?? '';
$allowed = ['suspect','confirmed','cleared','ignored'];
if (!in_array($status, $allowed)) {
json_response(['success' => false, 'error' => 'Statut invalide'], 400);
}
db_execute(
"UPDATE correlations SET status = ? WHERE user_id = ? AND item_id = ?",
[$status, $user['id'], $itemId]
);
// Si confirmé → créer une notification
if ($status === 'confirmed') {
$item = db_fetch("SELECT name FROM items WHERE id = ?", [$itemId]);
db_insert(
"INSERT INTO notifications (user_id, type, title, body)
VALUES (?, 'confirmed_trigger', ?, ?)",
[
$user['id'],
'✅ Déclencheur confirmé !',
($item['name'] ?? 'Un item') . ' a été marqué comme déclencheur confirmé.',
]
);
// Badge
$badge = db_fetch("SELECT id FROM badges WHERE slug = 'confirmed_trigger'");
if ($badge) {
db_execute(
"INSERT IGNORE INTO user_badges (user_id, badge_id) VALUES (?,?)",
[$user['id'], $badge['id']]
);
}
}
json_response(['success' => true]);
}
if ($action === 'start_elimination') {
$itemId = (int)($_POST['item_id'] ?? 0);
$duration = max(7, min(90, (int)($_POST['duration'] ?? 21)));
$startDate= date('Y-m-d');
$endDate = date('Y-m-d', strtotime("+{$duration} days"));
// Vérifier pas déjà actif
$existing = db_count(
"SELECT COUNT(*) FROM elimination_goals WHERE user_id = ? AND item_id = ? AND status = 'active'",
[$user['id'], $itemId]
);
if ($existing) {
json_response(['success' => false, 'error' => 'Objectif d\'élimination déjà actif pour cet item.']);
}
db_insert(
"INSERT INTO elimination_goals (user_id, item_id, start_date, end_date) VALUES (?,?,?,?)",
[$user['id'], $itemId, $startDate, $endDate]
);
// Badge
$badge = db_fetch("SELECT id FROM badges WHERE slug = 'first_elim'");
if ($badge) db_execute("INSERT IGNORE INTO user_badges (user_id, badge_id) VALUES (?,?)", [$user['id'], $badge['id']]);
json_response(['success' => true, 'end_date' => $endDate]);
}
if ($action === 'recompute') {
$window = (int)($_POST['window'] ?? 48);
computeCorrelations($user['id'], $window);
json_response(['success' => true]);
}
json_response(['success' => false, 'error' => 'Action inconnue'], 400);
}
// ── Recalcul auto si données obsolètes (> 24h) ───────────────────────────────
$lastComputed = db_fetch(
"SELECT MIN(last_computed_at) as last FROM correlations WHERE user_id = ?",
[$user['id']]
)['last'] ?? null;
$needsRecompute = !$lastComputed || (time() - strtotime($lastComputed)) > 86400;
if ($needsRecompute) {
computeCorrelations($user['id']);
}
// ── Récupération des données ──────────────────────────────────────────────────
$windowHours = (int)($_GET['window'] ?? 48);
if (!in_array($windowHours, [12, 24, 48, 72])) $windowHours = 48;
$statusFilter = $_GET['status'] ?? 'all';
$whereStatus = match($statusFilter) {
'suspect' => "AND c.status = 'suspect'",
'confirmed' => "AND c.status = 'confirmed'",
'cleared' => "AND c.status = 'cleared'",
'ignored' => "AND c.status = 'ignored'",
default => "AND c.status != 'ignored'",
};
$suspects = db_fetch_all(
"SELECT c.*, i.name, i.category, i.brand, i.warning_ingredients,
kt.name_fr as known_trigger, kt.trigger_type, kt.severity as kt_severity
FROM correlations c
JOIN items i ON c.item_id = i.id
LEFT JOIN item_triggers it ON i.id = it.item_id
LEFT JOIN known_triggers kt ON it.trigger_id = kt.id AND kt.ds_specific = 1
WHERE c.user_id = ? {$whereStatus}
GROUP BY c.id
ORDER BY c.score DESC",
[$user['id']]
);
// Graphique évolution peau 60j
$skinEvolution = db_fetch_all(
"SELECT entry_date, skin_score, is_crisis FROM journal_entries
WHERE user_id = ? AND entry_date >= DATE_SUB(CURDATE(), INTERVAL 60 DAY)
ORDER BY entry_date ASC",
[$user['id']]
);
// Stats globales
$totalEntries = db_count("SELECT COUNT(*) FROM journal_entries WHERE user_id = ?", [$user['id']]);
$totalCrises = db_count("SELECT COUNT(*) FROM journal_entries WHERE user_id = ? AND is_crisis = 1", [$user['id']]);
$confirmedCount= db_count("SELECT COUNT(*) FROM correlations WHERE user_id = ? AND status = 'confirmed'", [$user['id']]);
// Suspects ingrédients
$ingredientSuspects = db_fetch_all(
"SELECT ic.*,
COUNT(DISTINCT ii.item_id) as product_count,
GROUP_CONCAT(DISTINCT i.name ORDER BY i.name SEPARATOR ', ') as found_in_products
FROM ingredient_correlations ic
LEFT JOIN item_ingredients ii ON ii.inci_name = ic.inci_name
LEFT JOIN items i ON ii.item_id = i.id
WHERE ic.user_id = ? AND ic.score >= 20
GROUP BY ic.id
ORDER BY ic.score DESC
LIMIT 20",
[$user['id']]
);
// Objectifs d'élimination actifs
$activeGoals = db_fetch_all(
"SELECT eg.*, i.name as item_name,
DATEDIFF(eg.end_date, CURDATE()) as days_left,
DATEDIFF(CURDATE(), eg.start_date) as days_done,
DATEDIFF(eg.end_date, eg.start_date) as total_days
FROM elimination_goals eg
JOIN items i ON eg.item_id = i.id
WHERE eg.user_id = ? AND eg.status = 'active'
ORDER BY eg.start_date DESC",
[$user['id']]
);
// Helpers
$catIcons = ['food'=>'🍽️','drink'=>'🥤','alcohol'=>'🍷','tobacco'=>'🚬','cosmetic'=>'🧴','haircare'=>'💇','medication'=>'💊','supplement'=>'🌿','other'=>'📦'];
function scoreColor(float $score): string {
return match(true) {
$score >= 70 => '#FF6B8A',
$score >= 50 => '#FFB547',
$score >= 30 => '#00D4C8',
default => '#7A8FA0',
};
}
function scoreLabel(float $score): string {
return match(true) {
$score >= 70 => 'Élevé',
$score >= 50 => 'Modéré',
$score >= 30 => 'Faible',
default => 'Très faible',
};
}
$pageTitle = 'Mes Suspects';
$activePage = 'suspects';
ob_start();
?>
<style>
/* ── Suspects styles ── */
.suspects-layout{display:grid;grid-template-columns:1fr 300px;gap:20px;align-items:start;}
/* Toolbar */
.toolbar{
display:flex;align-items:center;gap:10px;flex-wrap:wrap;
margin-bottom:20px;
}
.filter-btn{
background:var(--surface);border:1px solid var(--border);
color:var(--text-mid);padding:7px 14px;border-radius:8px;
text-decoration:none;font-size:0.78rem;font-weight:600;
transition:all 0.2s;
}
.filter-btn:hover,.filter-btn.active{
background:var(--cyan-dim);border-color:rgba(0,212,200,0.3);color:var(--cyan);
}
.window-select{
background:var(--surface);border:1px solid var(--border);
color:var(--text-mid);padding:7px 12px;border-radius:8px;
font-size:0.78rem;font-weight:600;font-family:inherit;
cursor:pointer;margin-left:auto;
}
.window-select:focus{outline:none;border-color:var(--cyan);}
.recompute-btn{
background:var(--surface2);border:1px solid var(--border);
color:var(--text-mid);padding:7px 14px;border-radius:8px;
font-size:0.78rem;font-weight:600;cursor:pointer;font-family:inherit;
transition:all 0.2s;display:flex;align-items:center;gap:6px;
}
.recompute-btn:hover{border-color:var(--cyan);color:var(--cyan);}
/* No data */
.no-data{
background:var(--surface);border:1px solid var(--border);
border-radius:16px;padding:60px 40px;text-align:center;
}
.no-data-icon{font-size:3rem;margin-bottom:16px;}
.no-data-title{font-size:1.1rem;font-weight:700;margin-bottom:8px;}
.no-data-desc{font-size:0.85rem;color:var(--text-mid);line-height:1.6;max-width:400px;margin:0 auto 24px;}
.btn-go{display:inline-flex;align-items:center;gap:6px;background:var(--cyan);color:var(--bg);padding:10px 20px;border-radius:8px;text-decoration:none;font-size:0.85rem;font-weight:800;transition:all 0.2s;}
.btn-go:hover{background:#fff;}
/* Suspect card */
.suspect-card{
background:var(--surface);border:1px solid var(--border);
border-radius:14px;padding:20px;margin-bottom:12px;
transition:border-color 0.2s,transform 0.2s;
position:relative;overflow:hidden;
}
.suspect-card:hover{border-color:var(--border-bright);transform:translateY(-1px);}
.suspect-card.confirmed{border-color:rgba(0,232,122,0.25);background:rgba(0,232,122,0.03);}
.suspect-card.cleared{opacity:0.6;}
.sc-header{display:flex;align-items:flex-start;gap:12px;margin-bottom:14px;}
.sc-icon{
width:40px;height:40px;border-radius:10px;
background:var(--surface2);border:1px solid var(--border);
display:flex;align-items:center;justify-content:center;
font-size:1.2rem;flex-shrink:0;
}
.sc-info{flex:1;min-width:0;}
.sc-name{font-size:0.95rem;font-weight:800;color:var(--text);letter-spacing:-0.01em;}
.sc-brand{font-size:0.75rem;color:var(--text-dim);margin-top:2px;}
.sc-meta{display:flex;align-items:center;gap:8px;margin-top:6px;flex-wrap:wrap;}
.sc-badge{font-size:0.65rem;font-weight:700;padding:2px 8px;border-radius:100px;}
.badge-suspect{background:rgba(255,181,71,0.12);color:var(--amber);}
.badge-confirmed{background:var(--emerald-dim);color:var(--emerald);}
.badge-cleared{background:rgba(255,255,255,0.06);color:var(--text-dim);}
.badge-ignored{background:rgba(255,255,255,0.06);color:var(--text-dim);}
.badge-known{background:rgba(255,107,138,0.1);color:var(--rose);}
.badge-eu{background:rgba(0,212,200,0.1);color:var(--cyan);}
/* Score display */
.sc-score-wrap{display:flex;align-items:center;gap:12px;margin-bottom:12px;}
.sc-score-num{
font-family:'Cabinet Grotesk',sans-serif;
font-size:2.2rem;font-weight:900;letter-spacing:-0.04em;line-height:1;
}
.sc-score-bar-wrap{flex:1;}
.sc-score-label{font-size:0.7rem;color:var(--text-mid);margin-bottom:4px;}
.sc-score-track{height:6px;background:rgba(255,255,255,0.06);border-radius:10px;overflow:hidden;}
.sc-score-fill{height:100%;border-radius:10px;transition:width 0.8s ease;}
.sc-score-sub{font-size:0.7rem;color:var(--text-dim);margin-top:4px;}
/* Stats row */
.sc-stats{display:flex;gap:16px;margin-bottom:14px;}
.sc-stat{text-align:center;}
.sc-stat-val{font-size:1.1rem;font-weight:800;color:var(--text);}
.sc-stat-label{font-size:0.65rem;color:var(--text-dim);text-transform:uppercase;letter-spacing:0.06em;}
/* Known trigger alert */
.sc-known{
background:rgba(255,107,138,0.06);border:1px solid rgba(255,107,138,0.15);
border-radius:8px;padding:8px 12px;margin-bottom:12px;
font-size:0.78rem;color:var(--text-mid);
display:flex;align-items:center;gap:8px;
}
.sc-known strong{color:var(--rose);}
/* Actions */
.sc-actions{display:flex;gap:6px;flex-wrap:wrap;}
.sc-action-btn{
padding:6px 12px;border-radius:7px;border:1px solid var(--border);
background:var(--surface2);color:var(--text-mid);
font-size:0.75rem;font-weight:600;cursor:pointer;
font-family:inherit;transition:all 0.2s;
}
.sc-action-btn:hover{border-color:var(--border-bright);color:var(--text);}
.sc-action-btn.confirm:hover{border-color:rgba(0,232,122,0.4);color:var(--emerald);background:var(--emerald-dim);}
.sc-action-btn.elim:hover{border-color:rgba(0,212,200,0.4);color:var(--cyan);background:var(--cyan-dim);}
.sc-action-btn.ignore:hover{border-color:rgba(255,107,138,0.3);color:var(--rose);}
.sc-action-btn.active-confirm{border-color:rgba(0,232,122,0.4);color:var(--emerald);background:var(--emerald-dim);}
/* Sidebar */
.side-card{background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:18px;margin-bottom:14px;position:sticky;top:80px;}
.side-title{font-size:0.68rem;font-weight:700;color:var(--text-dim);text-transform:uppercase;letter-spacing:0.1em;margin-bottom:14px;}
/* Stats globales */
.global-stat{display:flex;align-items:center;justify-content:space-between;padding:8px 0;border-bottom:1px solid var(--border);}
.global-stat:last-child{border:none;}
.global-stat-label{font-size:0.82rem;color:var(--text-mid);}
.global-stat-val{font-size:1rem;font-weight:800;color:var(--text);}
/* Objectifs élimination */
.goal-card{background:var(--surface2);border:1px solid var(--border);border-radius:10px;padding:12px;margin-bottom:8px;}
.goal-name{font-size:0.82rem;font-weight:700;color:var(--text);margin-bottom:6px;}
.goal-progress{height:4px;background:rgba(255,255,255,0.06);border-radius:10px;overflow:hidden;margin-bottom:4px;}
.goal-fill{height:100%;background:linear-gradient(90deg,var(--cyan),var(--emerald));border-radius:10px;}
.goal-days{font-size:0.7rem;color:var(--text-dim);}
/* Chart mini */
#evolutionChart{width:100%;height:120px;}
/* Elim modal */
.elim-modal{
display:none;position:fixed;inset:0;z-index:200;
align-items:center;justify-content:center;
}
.elim-modal.open{display:flex;}
.elim-backdrop{position:absolute;inset:0;background:rgba(0,0,0,0.7);backdrop-filter:blur(6px);}
.elim-dialog{
position:relative;z-index:1;
background:var(--surface);border:1px solid var(--border-bright);
border-radius:16px;padding:28px;width:min(400px,90vw);
}
.elim-title{font-size:1rem;font-weight:800;margin-bottom:8px;}
.elim-desc{font-size:0.82rem;color:var(--text-mid);margin-bottom:20px;line-height:1.5;}
.elim-duration{display:flex;gap:8px;margin-bottom:20px;}
.dur-btn{flex:1;padding:8px;border-radius:8px;border:1px solid var(--border);background:var(--surface2);color:var(--text-mid);font-size:0.8rem;font-weight:600;cursor:pointer;font-family:inherit;transition:all 0.2s;}
.dur-btn.active{border-color:var(--cyan);color:var(--cyan);background:var(--cyan-dim);}
.elim-actions{display:flex;gap:8px;}
.elim-confirm{flex:1;padding:10px;border-radius:8px;border:none;background:var(--cyan);color:var(--bg);font-weight:800;cursor:pointer;font-family:inherit;font-size:0.85rem;transition:all 0.2s;}
.elim-confirm:hover{background:#fff;}
.elim-cancel{padding:10px 16px;border-radius:8px;border:1px solid var(--border);background:transparent;color:var(--text-mid);cursor:pointer;font-family:inherit;font-size:0.85rem;}
@media(max-width:900px){
.suspects-layout{grid-template-columns:1fr;}
.side-card{position:static;}
}
</style>
<!-- Toolbar -->
<div class="toolbar">
<?php
$filters = ['all'=>'Tous','suspect'=>'Suspects','confirmed'=>'Confirmés','cleared'=>'Éliminés','ignored'=>'Ignorés'];
foreach ($filters as $f => $label):
$active = $statusFilter === $f ? 'active' : '';
?>
<a href="?status=<?= $f ?>&window=<?= $windowHours ?>" class="filter-btn <?= $active ?>">
<?= $label ?>
<?php if ($f === 'confirmed' && $confirmedCount > 0): ?>
<span style="background:var(--emerald);color:var(--bg);border-radius:100px;padding:1px 6px;font-size:0.6rem;margin-left:4px;"><?= $confirmedCount ?></span>
<?php endif; ?>
</a>
<?php endforeach; ?>
<select class="window-select" id="windowSelect" onchange="changeWindow(this.value)">
<option value="12" <?= $windowHours === 12 ? 'selected' : '' ?>>Fenêtre 12h</option>
<option value="24" <?= $windowHours === 24 ? 'selected' : '' ?>>Fenêtre 24h</option>
<option value="48" <?= $windowHours === 48 ? 'selected' : '' ?>>Fenêtre 48h</option>
<option value="72" <?= $windowHours === 72 ? 'selected' : '' ?>>Fenêtre 72h</option>
</select>
<button class="recompute-btn" onclick="recompute()">
🔄 Recalculer
</button>
</div>
<div class="suspects-layout">
<!-- ── Liste suspects ── -->
<div>
<?php if (empty($suspects)): ?>
<div class="no-data">
<div class="no-data-icon"><?= $totalCrises === 0 ? '📔' : '🔬' ?></div>
<div class="no-data-title">
<?= $totalCrises === 0 ? 'Pas encore de crises enregistrées' : 'Aucun suspect dans cette catégorie' ?>
</div>
<div class="no-data-desc">
<?php if ($totalCrises === 0): ?>
Déclarez vos crises dans le journal en cochant "Crise aujourd'hui". L'algorithme analysera ensuite ce que vous avez consommé dans les <?= $windowHours ?>h précédentes.
<?php else: ?>
Essayez une autre catégorie ou une fenêtre temporelle différente.
<?php endif; ?>
</div>
<a href="<?= url('app/journal.php') ?>" class="btn-go">📔 Aller au journal</a>
</div>
<?php else: ?>
<?php foreach ($suspects as $s):
$color = scoreColor((float)$s['score']);
$icon = $catIcons[$s['category']] ?? '📦';
$hasWarn = !empty($s['warning_ingredients']);
$cardCls = $s['status'] === 'confirmed' ? 'confirmed' : ($s['status'] === 'cleared' ? 'cleared' : '');
?>
<div class="suspect-card <?= $cardCls ?>" id="card-<?= $s['item_id'] ?>">
<div class="sc-header">
<div class="sc-icon"><?= $icon ?></div>
<div class="sc-info">
<div class="sc-name"><?= e($s['name']) ?></div>
<?php if ($s['brand']): ?>
<div class="sc-brand"><?= e($s['brand']) ?></div>
<?php endif; ?>
<div class="sc-meta">
<span class="sc-badge badge-<?= $s['status'] ?>">
<?= match($s['status']) {
'confirmed' => '✅ Confirmé',
'cleared' => '✓ Éliminé',
'ignored' => '— Ignoré',
default => '? Suspect',
} ?>
</span>
<?php if ($s['known_trigger']): ?>
<span class="sc-badge <?= $s['trigger_type'] === 'eu_regulated' ? 'badge-eu' : 'badge-known' ?>">
<?= $s['trigger_type'] === 'eu_regulated' ? '🇪🇺 EU' : '🔬 Scientifique' ?>
</span>
<?php endif; ?>
<?php if ($hasWarn): ?>
<span class="sc-badge badge-known">⚠ Ingrédient suspect</span>
<?php endif; ?>
</div>
</div>
<!-- Score -->
<div style="text-align:right;flex-shrink:0;">
<div class="sc-score-num" style="color:<?= $color ?>"><?= round($s['score']) ?><span style="font-size:1rem;opacity:0.5">%</span></div>
<div style="font-size:0.68rem;color:<?= $color ?>;font-weight:700;"><?= scoreLabel((float)$s['score']) ?></div>
</div>
</div>
<!-- Barre de score -->
<div class="sc-score-wrap">
<div class="sc-score-bar-wrap">
<div class="sc-score-label">Score de corrélation (fenêtre <?= $windowHours ?>h)</div>
<div class="sc-score-track">
<div class="sc-score-fill" style="width:<?= min(100, $s['score']) ?>%;background:<?= $color ?>"></div>
</div>
<div class="sc-score-sub">
<?= $s['occurrences_crisis'] ?> crise<?= $s['occurrences_crisis'] > 1 ? 's' : '' ?> précédées
· <?= $s['occurrences_total'] ?> consommation<?= $s['occurrences_total'] > 1 ? 's' : '' ?> au total
</div>
</div>
</div>
<!-- Déclencheur connu -->
<?php if ($s['known_trigger']): ?>
<div class="sc-known">
<?= $s['trigger_type'] === 'eu_regulated' ? '🇪🇺' : '🔬' ?>
<span><strong><?= e($s['known_trigger']) ?></strong> est un déclencheur <?= $s['trigger_type'] === 'eu_regulated' ? 'réglementé par l\'UE' : 'scientifiquement confirmé' ?> pour la dermite séborrhéique.</span>
</div>
<?php endif; ?>
<!-- Actions -->
<div class="sc-actions">
<?php if ($s['status'] !== 'confirmed'): ?>
<button class="sc-action-btn confirm"
onclick="updateStatus(<?= $s['item_id'] ?>, 'confirmed', this)">
✅ Confirmer
</button>
<?php else: ?>
<button class="sc-action-btn active-confirm"
onclick="updateStatus(<?= $s['item_id'] ?>, 'suspect', this)">
✅ Confirmé — Annuler
</button>
<?php endif; ?>
<?php
$hasGoal = db_count(
"SELECT COUNT(*) FROM elimination_goals WHERE user_id = ? AND item_id = ? AND status = 'active'",
[$user['id'], $s['item_id']]
);
?>
<?php if (!$hasGoal): ?>
<button class="sc-action-btn elim"
onclick="openElim(<?= $s['item_id'] ?>, '<?= e(addslashes($s['name'])) ?>')">
🧪 Tester l'élimination
</button>
<?php else: ?>
<span class="sc-action-btn" style="color:var(--cyan);border-color:rgba(0,212,200,0.3);">
🧪 Test en cours
</span>
<?php endif; ?>
<?php if ($s['status'] !== 'cleared'): ?>
<button class="sc-action-btn"
onclick="updateStatus(<?= $s['item_id'] ?>, 'cleared', this)">
✓ Marquer éliminé
</button>
<?php endif; ?>
<button class="sc-action-btn ignore"
onclick="updateStatus(<?= $s['item_id'] ?>, 'ignored', this)"
style="margin-left:auto;">
— Ignorer
</button>
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div><!-- /liste -->
<!-- ── Sidebar ── -->
<div>
<!-- Stats globales -->
<div class="side-card">
<div class="side-title">📊 Mes statistiques</div>
<div class="global-stat">
<span class="global-stat-label">Entrées de journal</span>
<span class="global-stat-val"><?= $totalEntries ?></span>
</div>
<div class="global-stat">
<span class="global-stat-label">Crises enregistrées</span>
<span class="global-stat-val" style="color:var(--rose)"><?= $totalCrises ?></span>
</div>
<div class="global-stat">
<span class="global-stat-label">Suspects identifiés</span>
<span class="global-stat-val" style="color:var(--amber)"><?= count($suspects) ?></span>
</div>
<div class="global-stat">
<span class="global-stat-label">Déclencheurs confirmés</span>
<span class="global-stat-val" style="color:var(--emerald)"><?= $confirmedCount ?></span>
</div>
</div>
<!-- Évolution peau -->
<?php if (!empty($skinEvolution)): ?>
<div class="side-card">
<div class="side-title">📈 Évolution (60j)</div>
<div class="css-chart" style="height:100px;">
<div class="css-bars">
<?php foreach ($skinEvolution as $point):
$h = max(4, round(($point['skin_score'] / 5) * 100));
$col = $point['is_crisis'] ? 'var(--rose)' : 'var(--cyan)';
$dt = new DateTime($point['entry_date']);
?>
<div class="css-bar-wrap" title="<?= $dt->format('d/m') ?> — <?= $point['skin_score'] ?>/5">
<div class="css-bar" style="height:<?= $h ?>%;background:<?= $col ?>;opacity:0.6;"></div>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
<?php endif; ?>
<!-- Objectifs élimination -->
<?php if (!empty($activeGoals)): ?>
<div class="side-card">
<div class="side-title">🧪 Tests d'élimination actifs</div>
<?php foreach ($activeGoals as $goal):
$pct = $goal['total_days'] > 0 ? min(100, round(($goal['days_done'] / $goal['total_days']) * 100)) : 0;
?>
<div class="goal-card">
<div class="goal-name">Sans <?= e($goal['item_name']) ?></div>
<div class="goal-progress">
<div class="goal-fill" style="width:<?= $pct ?>%"></div>
</div>
<div class="goal-days">
Jour <?= $goal['days_done'] ?>/<?= $goal['total_days'] ?>
· <?= max(0, $goal['days_left']) ?> jour<?= $goal['days_left'] > 1 ? 's' : '' ?> restant<?= $goal['days_left'] > 1 ? 's' : '' ?>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<!-- Conseils algo -->
<div class="side-card">
<div class="side-title">💡 Comment ça marche</div>
<p style="font-size:0.78rem;color:var(--text-mid);line-height:1.6;">
L'algorithme compare ce que vous avez consommé dans les <strong style="color:var(--text)"><?= $windowHours ?>h</strong> précédant chaque crise.
Plus un item apparaît souvent avant vos crises, plus son score est élevé.
</p>
<p style="font-size:0.78rem;color:var(--text-mid);line-height:1.6;margin-top:8px;">
Changez la <strong style="color:var(--text)">fenêtre temporelle</strong> pour affiner — certaines réactions sont retardées de 48 à 72h.
</p>
</div>
</div><!-- /sidebar -->
</div><!-- /layout -->
<!-- ── Modal élimination ── -->
<div class="elim-modal" id="elimModal">
<div class="elim-backdrop" onclick="closeElim()"></div>
<div class="elim-dialog">
<div class="elim-title">🧪 Test d'élimination</div>
<div class="elim-desc" id="elimDesc">...</div>
<div class="elim-duration">
<?php foreach ([7=>'1 sem.',14=>'2 sem.',21=>'3 sem.',30=>'1 mois'] as $days => $label): ?>
<button class="dur-btn <?= $days === 21 ? 'active' : '' ?>"
data-days="<?= $days ?>"
onclick="selectDuration(<?= $days ?>, this)">
<?= $label ?>
</button>
<?php endforeach; ?>
</div>
<div class="elim-actions">
<button class="elim-cancel" onclick="closeElim()">Annuler</button>
<button class="elim-confirm" onclick="confirmElim()">Démarrer le test</button>
</div>
</div>
</div>
<div id="toastMsg" style="position:fixed;bottom:24px;right:24px;background:var(--surface);border:1px solid var(--border-bright);border-radius:10px;padding:12px 18px;font-size:0.82rem;font-weight:600;z-index:300;opacity:0;transition:opacity 0.3s;pointer-events:none;"></div>
<script>
const csrf = document.querySelector('meta[name="csrf-token"]').content;
const apiUrl = '<?= url('app/suspects.php') ?>';
// ── Fenêtre temporelle ────────────────────────────────────────────────────────
function changeWindow(val) {
const url = new URL(window.location);
url.searchParams.set('window', val);
window.location = url;
}
// ── Recalculer ────────────────────────────────────────────────────────────────
async function recompute() {
const btn = document.querySelector('.recompute-btn');
btn.innerHTML = '⏳ Calcul en cours...';
btn.disabled = true;
const fd = new FormData();
fd.append('action', 'recompute');
fd.append('window', '<?= $windowHours ?>');
fd.append('_csrf', csrf);
await fetch(apiUrl, {method:'POST',body:fd,headers:{'X-Requested-With':'XMLHttpRequest'}});
window.location.reload();
}
// ── Statut ────────────────────────────────────────────────────────────────────
async function updateStatus(itemId, status, btn) {
const fd = new FormData();
fd.append('action', 'update_status');
fd.append('item_id', itemId);
fd.append('status', status);
fd.append('_csrf', csrf);
const res = await fetch(apiUrl, {method:'POST',body:fd,headers:{'X-Requested-With':'XMLHttpRequest'}});
const data = await res.json();
if (data.success) {
const msgs = {confirmed:'✅ Marqué comme confirmé !',cleared:'✓ Marqué comme éliminé',suspect:'↩ Remis en suspect',ignored:'— Ignoré'};
showToast(msgs[status] || 'Mis à jour');
setTimeout(() => window.location.reload(), 800);
}
}
// ── Élimination ───────────────────────────────────────────────────────────────
let elimItemId = null;
let elimDuration = 21;
function openElim(itemId, itemName) {
elimItemId = itemId;
document.getElementById('elimDesc').innerHTML =
`Arrêtez de consommer <strong>${itemName}</strong> pendant la durée choisie et observez l'évolution de votre peau.`;
document.getElementById('elimModal').classList.add('open');
}
function closeElim() {
document.getElementById('elimModal').classList.remove('open');
}
function selectDuration(days, btn) {
elimDuration = days;
document.querySelectorAll('.dur-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
}
async function confirmElim() {
if (!elimItemId) return;
const fd = new FormData();
fd.append('action', 'start_elimination');
fd.append('item_id', elimItemId);
fd.append('duration', elimDuration);
fd.append('_csrf', csrf);
const res = await fetch(apiUrl, {method:'POST',body:fd,headers:{'X-Requested-With':'XMLHttpRequest'}});
const data = await res.json();
closeElim();
if (data.success) {
showToast(`🧪 Test démarré ! Fin le ${data.end_date}`);
setTimeout(() => window.location.reload(), 1000);
} else {
showToast(data.error || 'Erreur', 'error');
}
}
// ── Toast ─────────────────────────────────────────────────────────────────────
function showToast(msg, type='success') {
const el = document.getElementById('toastMsg');
el.textContent = msg;
el.style.color = type === 'error' ? 'var(--rose)' : 'var(--emerald)';
el.style.opacity = '1';
setTimeout(() => el.style.opacity = '0', 3000);
}
</script>
<?php
// Chart évolution
$chartJson = json_encode(array_map(fn($r) => [
'date' => $r['entry_date'],
'score' => (int)$r['skin_score'],
'crisis' => (bool)$r['is_crisis'],
], $skinEvolution));
$content = ob_get_clean();
$extraHead = '';
$extraScript = "";
require DS_VIEWS . 'layouts/app.php';