Last active
April 12, 2026 16:39
-
-
Save malys/1153cf6836fed9bdba44f8c0b9daacc3 to your computer and use it in GitHub Desktop.
[Sure livret] Livrets Info,Plafond & Rendement #sure #userscript
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==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