Skip to content

Instantly share code, notes, and snippets.

@malys
Last active April 12, 2026 16:39
Show Gist options
  • Select an option

  • Save malys/1153cf6836fed9bdba44f8c0b9daacc3 to your computer and use it in GitHub Desktop.

Select an option

Save malys/1153cf6836fed9bdba44f8c0b9daacc3 to your computer and use it in GitHub Desktop.
[Sure livret] Livrets Info,Plafond & Rendement #sure #userscript
// ==UserScript==
// @name Sure Finance: Livrets Info + Favoris
// @namespace https://sure*
// @version 3.0.1
// @description Plafond & rendement des livrets rΓ©glementΓ©s + favoris persistants sur toutes les listes de comptes
// @match https://sure.*
// @match https://sure.*/
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_xmlhttpRequest
// @connect www.service-public.gouv.fr
// @run-at document-idle
// @downloadURL https://gist.githubusercontent.com/malys/1153cf6836fed9bdba44f8c0b9daacc3/raw/userscript.js
// @updateURL https://gist.githubusercontent.com/malys/1153cf6836fed9bdba44f8c0b9daacc3/raw/userscript.js
// ==/UserScript==
// ═══════════════════════════════════════════════════════════════════════════════
// ARCHITECTURE GÉNÉRALE
// ═══════════════════════════════════════════════════════════════════════════════
//
// StorageAdapter ──► abstraction GM_getValue / localStorage (fallback)
// RatesService ──► cache 30j + scraping service-public.gouv.fr
// FavoritesStore ──► CRUD favoris via StorageAdapter
// AccountEnhancer ──► injection badges livrets + Γ©toiles favoris
// DOMWatcher ──► MutationObserver + hook pushState (Turbo + SPA)
// ControlPanel ──► panneau flottant refresh taux
//
// ═══════════════════════════════════════════════════════════════════════════════
'use strict';
// ─────────────────────────────────────────────────────────────────────────────
// STORAGE ADAPTER (GM_setValue / fallback localStorage)
// ─────────────────────────────────────────────────────────────────────────────
const StorageAdapter = {
get(key) {
try {
const val = (typeof GM_getValue !== 'undefined') ? GM_getValue(key, null) : localStorage.getItem(key);
return val ?? null;
} catch (_) { return null; }
},
set(key, value) {
try {
if (typeof GM_setValue !== 'undefined') GM_setValue(key, value);
else localStorage.setItem(key, value);
} catch (_) {}
},
};
// ─────────────────────────────────────────────────────────────────────────────
// CONFIG LIVRETS
// ─────────────────────────────────────────────────────────────────────────────
const LIVRET_CONFIG = {
LA: { label: 'Livret A', plafond: 22950, color: '#2563eb' },
LDD: { label: 'LDDS', plafond: 12000, color: '#7c3aed' },
LEP: { label: "Livret d'Γ‰pargne Populaire", plafond: 10000, color: '#059669' },
LJ: { label: 'Livret Jeune', plafond: 1600, color: '#d97706' },
PEL: { label: 'Plan Γ‰pargne Logement', plafond: 61200, color: '#dc2626' },
};
// ─────────────────────────────────────────────────────────────────────────────
// RATES SERVICE
// ─────────────────────────────────────────────────────────────────────────────
const DEFAULT_RATES = { LA: 1.50, LDD: 1.50, LP: 2.50, LJ: 1.50 };
const RATES_CACHE_KEY = 'livrets_rates_cache_v3';
const RATES_CACHE_TTL = 30 * 24 * 60 * 60 * 1000;
const SOURCE_URL = 'https://www.service-public.gouv.fr/particuliers/vosdroits/F34393';
const RatesService = {
getCached() {
try {
const raw = StorageAdapter.get(RATES_CACHE_KEY);
if (!raw) return null;
const { rates, timestamp } = JSON.parse(raw);
if (Date.now() - timestamp > RATES_CACHE_TTL) return null;
if (rates.LA && rates.LA > 2.0) return null;
if (rates.LP && rates.LP > 3.0) return null;
return rates;
} catch (_) { return null; }
},
setCached(rates) {
StorageAdapter.set(RATES_CACHE_KEY, JSON.stringify({ rates, timestamp: Date.now() }));
},
invalidate() {
StorageAdapter.set(RATES_CACHE_KEY, '');
// Purge legacy keys
['livrets_rates_cache_v1', 'livrets_rates_cache_v2'].forEach(k => {
try { StorageAdapter.set(k, ''); } catch (_) {}
});
},
parseHtml(html) {
const doc = new DOMParser().parseFromString(html, 'text/html');
const table = doc.querySelector('table');
if (!table) return null;
const rates = {};
const rateRx = /(\d+[,.]?\d*)\s*%/;
const KEYWORDS = [
{ key: 'LA', test: t => /livret\s+a\b/i.test(t) && !/livret\s+(jeune|d.pargne|de\s+d)/i.test(t) },
{ key: 'LDD', test: t => /ldds|livret\s+de\s+d.veloppement/i.test(t) },
{ key: 'LP', test: t => /livret\s+d.Γ©pargne\s+populaire|lep\b/i.test(t) },
{ key: 'LJ', test: t => /livret\s+jeune/i.test(t) },
];
for (const row of table.querySelectorAll('tr')) {
const cells = [...row.querySelectorAll('td, th')];
if (cells.length < 6) continue;
const label = cells[0].textContent.trim();
const interets = cells[5].textContent.trim();
for (const { key, test } of KEYWORDS) {
if (test(label)) {
const m = interets.match(rateRx);
if (m) rates[key] = parseFloat(m[1].replace(',', '.'));
break;
}
}
}
if (!rates.LA || !rates.LP) return null;
if (!rates.LDD) rates.LDD = rates.LA;
if (!rates.LJ) rates.LJ = rates.LA;
return rates;
},
fetchRemote() {
return new Promise(resolve => {
if (typeof GM_xmlhttpRequest === 'undefined') return resolve({ ...DEFAULT_RATES });
GM_xmlhttpRequest({
method: 'GET', url: SOURCE_URL, timeout: 10000,
onload(r) {
try {
const parsed = RatesService.parseHtml(r.responseText);
if (!parsed) throw new Error('parse fail');
resolve(parsed);
} catch (_) { resolve({ ...DEFAULT_RATES }); }
},
onerror() { resolve({ ...DEFAULT_RATES }); },
ontimeout() { resolve({ ...DEFAULT_RATES }); },
});
});
},
async get() {
const cached = this.getCached();
if (cached) return cached;
const rates = await this.fetchRemote();
this.setCached(rates);
return rates;
},
};
// ─────────────────────────────────────────────────────────────────────────────
// FAVORITES STORE
// ─────────────────────────────────────────────────────────────────────────────
const FAV_KEY = 'sure_favorite_accounts_v1';
const FavoritesStore = {
getAll() {
try { return JSON.parse(StorageAdapter.get(FAV_KEY) || '[]'); } catch (_) { return []; }
},
has(id) { return this.getAll().includes(id); },
toggle(id) {
const favs = this.getAll();
const next = favs.includes(id) ? favs.filter(f => f !== id) : [...favs, id];
StorageAdapter.set(FAV_KEY, JSON.stringify(next));
return next.includes(id);
},
};
// ─────────────────────────────────────────────────────────────────────────────
// HELPERS
// ─────────────────────────────────────────────────────────────────────────────
function formatEur(n) {
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 }).format(n);
}
function detectLivretType(name) {
const upper = name.trim().toUpperCase();
for (const prefix of Object.keys(LIVRET_CONFIG)) {
if (upper === prefix || upper.startsWith(prefix + ' ') || upper.startsWith(prefix + '_')) return prefix;
}
return null;
}
function parseSolde(linkEl) {
// StratΓ©gie 1 : lire depuis data-time-series-chart-data-value (source la plus fiable,
// disponible mΓͺme quand le sparkline est en timeout β€” contient le solde courant)
const chartEl = linkEl.querySelector('[data-time-series-chart-data-value]');
if (chartEl) {
try {
const json = JSON.parse(chartEl.dataset.timeSeriesChartDataValue);
const amt = json?.trend?.current?.amount ?? json?.values?.at(-1)?.value?.amount;
if (amt != null) {
const v = parseFloat(String(amt).replace(',', '.'));
if (!isNaN(v)) return v;
}
} catch (_) {}
}
// StratΓ©gie 2 : <p> avec privacy-sensitive DANS le <a>, pas dans un parent
// On cherche exclusivement les descendants du linkEl pour Γ©viter de remonter
// dans le <summary> parent qui contient le solde global de la catΓ©gorie
const candidates = [...linkEl.querySelectorAll('.privacy-sensitive')];
for (const el of candidates) {
// VΓ©rifie que l'Γ©lΓ©ment est bien un enfant du linkEl (pas un ancΓͺtre via closest)
if (!linkEl.contains(el)) continue;
const raw = el.textContent.replace(/[€\s\u00a0+]/g, '').replace(',', '.');
const v = parseFloat(raw);
if (!isNaN(v) && v !== 0) return v;
}
return null;
}
// ─────────────────────────────────────────────────────────────────────────────
// SORT CONFIG — critères de tri disponibles pour les non-favoris
// ─────────────────────────────────────────────────────────────────────────────
const SORT_OPTIONS = [
{ key: 'default', label: 'Ordre par dΓ©faut' },
{ key: 'balance', label: 'Solde dΓ©croissant' },
{ key: 'name', label: 'Nom A β†’ Z' },
{ key: 'cap_rem', label: 'Plus d\'espace disponible' },
{ key: 'cap_pct', label: 'Plafond le + rempli (%)' },
{ key: 'cap_abs', label: 'Solde vs plafond (€)' },
];
const SORT_KEY = 'sf_sort_pref';
const SortStore = {
get() { return StorageAdapter.get(SORT_KEY) || 'default'; },
set(v) { StorageAdapter.set(SORT_KEY, v); },
};
// ─────────────────────────────────────────────────────────────────────────────
// ACCOUNT ENHANCER β€” badge livret + Γ©toile favori + tri
//
// DOM patterns couverts :
// A) Sidebar : div.space-y-1 > a[title][href^="/accounts/"]
// β†’ les <a> sont enfants directs du container, PAS de wrapper div par ligne
// B) Dashboard : a[title][href^="/accounts/"] avec sous-Γ©lΓ©ments internes
// β†’ mΓͺme sΓ©lecteur mais <a> est la "row" elle-mΓͺme
// C) Listes dΓ©taillΓ©es : div.pl-12.pr-4.py-3 > a[href^="/accounts/"]
// β†’ la div.pl-12 est le wrapper de ligne
//
// StratΓ©gie d'injection de l'Γ©toile :
// - TOUJOURS injectΓ©e DANS le <a>, comme premier enfant (flex)
// - Jamais dans le parent (Γ©vite les Γ©toiles orphelines dans la sidebar)
// ─────────────────────────────────────────────────────────────────────────────
const AccountEnhancer = {
_rates: null,
async ensureRates() {
if (!this._rates) this._rates = await RatesService.get();
return this._rates;
},
// ── Badge livret ────────────────────────────────────────────────────────────
buildLivretBadge(type, solde, rates) {
const cfg = LIVRET_CONFIG[type];
const rate = rates[type];
const plafond = cfg.plafond;
const over = solde !== null && solde > plafond;
const remaining = !over && solde !== null ? plafond - solde : null;
const excess = over ? solde - plafond : null;
const badge = document.createElement('div');
badge.className = 'sf-livret-badge';
badge.style.cssText = 'display:flex;align-items:center;gap:6px;margin-top:2px;flex-wrap:wrap;';
if (rate !== undefined) {
const pill = document.createElement('span');
pill.title = `Taux net annuel ${cfg.label}`;
pill.style.cssText = `font-size:10px;font-weight:700;padding:1px 6px;border-radius:999px;
background:${cfg.color}18;color:${cfg.color};border:1px solid ${cfg.color}44;
font-family:monospace;white-space:nowrap;`;
pill.textContent = `${rate.toFixed(2).replace('.', ',')}% net`;
badge.appendChild(pill);
}
const pillP = document.createElement('span');
if (over) {
pillP.title = `⚠ Plafond ${formatEur(plafond)} dépassé de ${formatEur(excess)}`;
pillP.style.cssText = 'font-size:10px;font-weight:700;padding:1px 6px;border-radius:999px;background:#fef2f2;color:#dc2626;border:1px solid #fca5a5;white-space:nowrap;';
pillP.textContent = `⚠ Plafond ${formatEur(plafond)}`;
} else {
pillP.title = remaining !== null
? `Plafond ${formatEur(plafond)} β€” encore ${formatEur(remaining)} disponible`
: `Plafond lΓ©gal : ${formatEur(plafond)}`;
pillP.style.cssText = 'font-size:10px;padding:1px 6px;border-radius:999px;background:#f1f5f9;color:#64748b;border:1px solid #e2e8f0;white-space:nowrap;';
pillP.textContent = `Plafond ${formatEur(plafond)}`;
}
badge.appendChild(pillP);
if (over) {
const pillO = document.createElement('span');
pillO.title = `Solde ${formatEur(solde)} β€” dΓ©passe le plafond de ${formatEur(excess)}`;
pillO.style.cssText = 'font-size:10px;font-weight:700;padding:1px 6px;border-radius:999px;background:#dc2626;color:#fff;white-space:nowrap;';
pillO.textContent = `+${formatEur(excess)}`;
badge.appendChild(pillO);
}
return badge;
},
// ── Γ‰toile favori β€” injectΓ©e DANS le <a> comme premier enfant ───────────────
applyFavoriteStar(linkEl, accountId) {
const isFav = FavoritesStore.has(accountId);
// Row visuelle = wrapper div.pl-12 si existe, sinon le <a> lui-mΓͺme
const rowEl = linkEl.closest('div.pl-12') ?? linkEl;
let star = linkEl.querySelector(':scope > .sf-star');
if (!star) {
star = document.createElement('span');
star.className = 'sf-star';
star.style.cssText = [
'cursor:pointer',
'font-size:14px',
'line-height:1',
'flex-shrink:0',
'transition:opacity .15s',
'margin-right:4px',
'align-self:center',
].join(';');
star.addEventListener('click', e => {
e.preventDefault();
e.stopPropagation();
FavoritesStore.toggle(accountId);
AccountEnhancer.refreshAll();
});
// Injection DANS le <a>, avant tout autre contenu
linkEl.prepend(star);
}
if (isFav) {
star.textContent = 'β˜…';
star.style.opacity = '1';
rowEl.style.background = 'rgba(255,215,0,0.12)';
rowEl.style.borderLeft = '3px solid gold';
} else {
star.textContent = 'β˜†';
star.style.opacity = '0.30';
rowEl.style.background = '';
rowEl.style.borderLeft = '';
}
},
// ── Extraction des mΓ©tadonnΓ©es pour le tri ──────────────────────────────────
_meta(linkEl) {
const title = linkEl.getAttribute('title') || '';
const type = detectLivretType(title);
const solde = parseSolde(linkEl) ?? 0;
const plafond = type ? LIVRET_CONFIG[type].plafond : null;
// capPct : % du plafond utilisΓ© (0 si pas de plafond β†’ tri en dernier)
const capPct = plafond ? Math.min(solde / plafond, 9999) : -1;
// capRem : espace restant en € (Infinity si pas de plafond β†’ tri en dernier pour ce critΓ¨re)
const capRem = plafond ? Math.max(plafond - solde, 0) : Infinity;
// capAbs : solde en valeur absolue vs plafond (mΓͺme que solde pour les livrets)
const capAbs = plafond ? solde : -1;
return { title, solde, plafond, capPct, capRem, capAbs };
},
// ── Tri d'un groupe de "rows" (chacune contient ou est un <a>) ──────────────
sortRows(container, rows) {
const favs = FavoritesStore.getAll();
const sortKey = SortStore.get();
const getLink = r => r.matches?.('a[href^="/accounts/"]') ? r : r.querySelector('a[href^="/accounts/"]');
const getId = r => getLink(r)?.getAttribute('href') ?? '';
const scored = rows.map(r => {
const link = getLink(r);
const id = getId(r);
const isFav = favs.includes(id);
const meta = link ? this._meta(link) : { title: '', solde: 0, capPct: -1, capRem: Infinity, capAbs: -1 };
return { r, isFav, meta };
});
scored.sort((a, b) => {
// Favoris toujours en tΓͺte, quel que soit le tri
if (a.isFav !== b.isFav) return a.isFav ? -1 : 1;
// Tri des non-favoris selon prΓ©fΓ©rence
switch (sortKey) {
case 'balance': return b.meta.solde - a.meta.solde;
case 'name': return a.meta.title.localeCompare(b.meta.title, 'fr');
// Plafond le + rempli : capPct desc, -1 (sans plafond) en dernier
case 'cap_pct':
if (a.meta.capPct === -1 && b.meta.capPct === -1) return 0;
if (a.meta.capPct === -1) return 1;
if (b.meta.capPct === -1) return -1;
return b.meta.capPct - a.meta.capPct;
// Plus d'espace dispo : capRem asc (le moins rempli en tΓͺte), sans plafond en dernier
case 'cap_rem':
if (a.meta.capRem === Infinity && b.meta.capRem === Infinity) return 0;
if (a.meta.capRem === Infinity) return 1;
if (b.meta.capRem === Infinity) return -1;
return a.meta.capRem - b.meta.capRem;
// Solde absolu vs plafond : desc, sans plafond en dernier
case 'cap_abs':
if (a.meta.capAbs === -1 && b.meta.capAbs === -1) return 0;
if (a.meta.capAbs === -1) return 1;
if (b.meta.capAbs === -1) return -1;
return b.meta.capAbs - a.meta.capAbs;
default: return 0;
}
});
scored.forEach(({ r }) => container.appendChild(r));
},
// ── Injection principale ────────────────────────────────────────────────────
async enhanceContext(root = document) {
const rates = await this.ensureRates();
// Collecte tous les liens de compte non encore traitΓ©s
const allLinks = [...root.querySelectorAll('a[title][href^="/accounts/"]:not([data-sf-enhanced])')];
// Marquer AVANT toute mutation DOM (coupe la boucle MutationObserver)
allLinks.forEach(link => { link.dataset.sfEnhanced = '1'; });
// Map container β†’ rows pour le tri groupΓ©
const groups = new Map();
allLinks.forEach(link => {
const accountId = link.getAttribute('href');
const title = link.getAttribute('title') || '';
const type = detectLivretType(title);
// Badge livret (seulement si le lien a des sous-Γ©lΓ©ments = pattern B/C)
if (type && link.querySelector('p.text-secondary') && !link.querySelector('.sf-livret-badge')) {
const solde = parseSolde(link);
const sub = link.querySelector('p.text-secondary');
sub.parentElement.insertBefore(this.buildLivretBadge(type, solde, rates), sub.nextSibling);
}
// Γ‰toile dans le <a>
this.applyFavoriteStar(link, accountId);
// DΓ©terminer la "row" et son "container" pour le tri
// Pattern C : div.pl-12 wrapper β†’ container = div.space-y-1 ou similaire
// Pattern A/B : le <a> est la row β†’ container = son parentElement
const rowEl = link.closest('div.pl-12') ?? link;
const contEl = rowEl.parentElement;
if (!contEl) return;
if (!groups.has(contEl)) groups.set(contEl, new Set());
groups.get(contEl).add(rowEl);
});
// Tri par groupe
for (const [container, rowSet] of groups) {
this.sortRows(container, [...rowSet]);
}
},
async refreshAll() {
document.querySelectorAll('[data-sf-enhanced]').forEach(el => delete el.dataset.sfEnhanced);
document.querySelectorAll('.sf-livret-badge').forEach(el => el.remove());
await this.enhanceContext();
},
};
// ─────────────────────────────────────────────────────────────────────────────
// DOM WATCHER β€” MutationObserver + hook pushState (Turbo + SPA)
// ─────────────────────────────────────────────────────────────────────────────
const DOMWatcher = {
_observer: null,
_pending: false,
start() {
// MutationObserver : Turbo Drive / Hotwire
// Throttle via rAF : une seule passe par frame mΓͺme si N mutations arrivent ensemble
this._observer = new MutationObserver(mutations => {
if (this._pending) return;
// N'agit que sur des nœuds AJOUTÉS sans data-sf-enhanced (nœuds neufs du DOM)
const hasNewNodes = mutations.some(m =>
[...m.addedNodes].some(n =>
n.nodeType === 1 &&
!n.dataset?.sfEnhanced &&
(
n.matches?.('a[title][href^="/accounts/"]') ||
n.matches?.('div.pl-12') ||
n.matches?.('div.space-y-1') ||
n.querySelector?.('a[title][href^="/accounts/"]:not([data-sf-enhanced])')
)
)
);
if (!hasNewNodes) return;
this._pending = true;
requestAnimationFrame(() => {
this._pending = false;
AccountEnhancer.enhanceContext();
});
});
this._observer.observe(document.body, { childList: true, subtree: true });
// Hook pushState : SPA navigation (React Router, Next.js…)
const orig = history.pushState.bind(history);
history.pushState = function (...args) {
orig(...args);
setTimeout(() => AccountEnhancer.refreshAll(), 500);
};
window.addEventListener('popstate', () => setTimeout(() => AccountEnhancer.refreshAll(), 500));
},
};
// ─────────────────────────────────────────────────────────────────────────────
// CONTROL PANEL β€” panneau flottant bas-droit
// Β· refresh taux + date cache
// Β· sΓ©lecteur de tri des comptes non-favoris
// ─────────────────────────────────────────────────────────────────────────────
function injectControlPanel() {
if (document.getElementById('sf-ctrl-panel')) return;
const cacheTs = (() => {
try {
const raw = StorageAdapter.get(RATES_CACHE_KEY);
if (!raw) return null;
const { timestamp } = JSON.parse(raw);
return new Date(timestamp).toLocaleDateString('fr-FR');
} catch (_) { return null; }
})();
const panel = document.createElement('div');
panel.id = 'sf-ctrl-panel';
panel.style.cssText = `
position:fixed;bottom:64px;right:16px;z-index:9999;
background:white;border:1px solid #e2e8f0;border-radius:10px;
font-size:11px;color:#475569;
box-shadow:0 2px 8px rgba(0,0,0,.12);
user-select:none;overflow:hidden;
transition:opacity .2s,width .2s;
opacity:.45;width:28px;
`;
// Options de tri en HTML
const sortOptionsHtml = SORT_OPTIONS.map(o =>
`<option value="${o.key}"${SortStore.get() === o.key ? ' selected' : ''}>${o.label}</option>`
).join('');
panel.innerHTML = `
<div class="sf-pill" style="display:flex;align-items:center;gap:6px;padding:5px 8px;cursor:pointer;" title="Sure Finance β€” cliquer pour forcer le refresh des taux">
<span class="sf-icon" style="font-size:14px;line-height:1;">🏦</span>
<span class="sf-ts" style="display:none;white-space:nowrap;">${cacheTs ? 'mΓ j ' + cacheTs : 'fallback'}</span>
</div>
<div class="sf-sort-row" style="display:none;padding:0 8px 6px;border-top:1px solid #f1f5f9;padding-top:6px;">
<label style="font-size:10px;color:#94a3b8;display:block;margin-bottom:3px;">Trier les comptes</label>
<select class="sf-sort-select" style="font-size:11px;border:1px solid #e2e8f0;border-radius:6px;padding:2px 4px;width:100%;background:white;color:#334155;cursor:pointer;">
${sortOptionsHtml}
</select>
</div>
`;
const pill = panel.querySelector('.sf-pill');
const tsSpan = panel.querySelector('.sf-ts');
const sortRow = panel.querySelector('.sf-sort-row');
const select = panel.querySelector('.sf-sort-select');
let open = false;
function openPanel() {
open = true;
panel.style.opacity = '1';
panel.style.width = '190px';
tsSpan.style.display = 'inline';
sortRow.style.display = 'block';
}
function closePanel() {
open = false;
panel.style.opacity = '.45';
panel.style.width = '28px';
tsSpan.style.display = 'none';
sortRow.style.display = 'none';
}
// Clic sur l'icône 🏦 : toggle ouverture/fermeture
const icon = panel.querySelector('.sf-icon');
icon.style.cursor = 'pointer';
icon.addEventListener('click', e => {
e.stopPropagation();
open ? closePanel() : openPanel();
});
// Clic en dehors : ferme
document.addEventListener('click', e => {
if (open && !panel.contains(e.target)) closePanel();
});
// Bouton refresh (clic sur la zone pill hors icΓ΄ne)
const refreshBtn = document.createElement('button');
refreshBtn.textContent = 'β†Ί';
refreshBtn.title = 'Forcer le refresh des taux';
refreshBtn.style.cssText = 'border:none;background:none;cursor:pointer;font-size:13px;color:#64748b;padding:0;line-height:1;';
pill.appendChild(refreshBtn);
refreshBtn.addEventListener('click', async e => {
e.stopPropagation();
refreshBtn.textContent = '⏳';
RatesService.invalidate();
AccountEnhancer._rates = null;
await AccountEnhancer.refreshAll();
refreshBtn.textContent = 'β†Ί';
tsSpan.textContent = 'mΓ j ' + new Date().toLocaleDateString('fr-FR');
});
select.addEventListener('change', () => {
SortStore.set(select.value);
AccountEnhancer.refreshAll();
});
document.body.appendChild(panel);
}
// ─────────────────────────────────────────────────────────────────────────────
// LAYOUT TWEAKS (suppression max-width + dΓ©pliage automatique des <details>)
// ─────────────────────────────────────────────────────────────────────────────
function applyLayoutTweaks() {
document.querySelectorAll('.max-w-4xl, .max-w-5xl').forEach(el => el.classList.remove('max-w-4xl', 'max-w-5xl'));
document.querySelectorAll('details').forEach(d => { d.open = true; });
}
// ─────────────────────────────────────────────────────────────────────────────
// ENTRY POINT
// ─────────────────────────────────────────────────────────────────────────────
(async function main() {
// Attend que le DOM applicatif soit prΓͺt (Turbo Drive peut retarder le rendu)
let attempts = 0;
while (
!document.querySelector('a[title][href^="/accounts/"]') &&
!document.querySelector('div.pl-12.pr-4.py-3') &&
attempts < 20
) {
await new Promise(r => setTimeout(r, 300));
attempts++;
}
applyLayoutTweaks();
await AccountEnhancer.enhanceContext();
DOMWatcher.start();
injectControlPanel();
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment