Sliding-window loading for the grand livre + year-scoped sticky footer

With 5+ years of migrated history, loading every ligne up front took
~40s. The table now only ever holds a date-range window (~18 months
around today by default), extended by 6 months when scrolling near
either edge and trimmed from the far end past a 30-month cap. The
totals footer and "Année" filter can't be answered from a partial
window, so they're backed by two new small endpoints
(LedgerStatsController) instead: per-compte totals for whichever year
is currently scrolled into view, and the distinct list of years with
data.
This commit is contained in:
2026-09-04 16:32:42 +02:00
parent 059d31f63d
commit b6358a7258
5 changed files with 434 additions and 40 deletions
+285 -29
View File
@@ -5,6 +5,17 @@
* JSON:API and renders a spreadsheet-like table of every ligne comptable,
* with filters, month/year grouping, and per-row écart (répartition sum vs
* montant HT) highlighting -- inconsistencies are shown, not hidden.
*
* Sliding window: with 5+ years of migrated history (~1500+ lines), loading
* everything up front took ~40s and made the tab briefly unresponsive.
* Instead of one big fetch, `rows` only ever holds a date-range window
* (initially the ~18 months around today), extended by ~6 months whenever
* the user scrolls near the top or bottom edge (IntersectionObserver on two
* sentinel rows), and trimmed from the far end once the window exceeds
* MAX_LOADED_MONTHS so it stays a genuine sliding buffer, not an
* ever-growing list. The "Année" filter and the totals footer can't be
* computed from a partial window, so they're backed by their own small
* server endpoints (LedgerStatsController) instead.
*/
(function (Drupal, Vue, jQuery) {
'use strict';
@@ -25,12 +36,47 @@
// (field_entree_liee) -- charge/autre/ouverture aren't client-specific.
const LINKABLE_TYPES = ['versement', 'achat', 'hebergement'];
async function fetchAllLignes() {
const WINDOW_MONTHS = 9; // each side of "today", for the initial load
const EXTEND_MONTHS = 6; // increment per scroll-triggered load
const MAX_LOADED_MONTHS = 30; // trim the far end once the window exceeds this
function todayStr() {
return new Date().toISOString().slice(0, 10);
}
function addMonths(dateStr, n) {
const d = new Date(dateStr + 'T00:00:00');
d.setMonth(d.getMonth() + n);
return d.toISOString().slice(0, 10);
}
function monthsBetween(a, b) {
const da = new Date(a + 'T00:00:00');
const db = new Date(b + 'T00:00:00');
return (db.getFullYear() - da.getFullYear()) * 12 + (db.getMonth() - da.getMonth());
}
// Builds the JSON:API URL for a half-open date range [start, end).
function rangeUrl(start, end) {
const filter =
'filter[dateRange][group][conjunction]=AND' +
'&filter[gte][condition][path]=field_date_ligne' +
'&filter[gte][condition][operator]=%3E%3D' +
'&filter[gte][condition][value]=' + start +
'&filter[gte][condition][memberOf]=dateRange' +
'&filter[lt][condition][path]=field_date_ligne' +
'&filter[lt][condition][operator]=%3C' +
'&filter[lt][condition][value]=' + end +
'&filter[lt][condition][memberOf]=dateRange';
return API_BASE + '?include=field_repartition,field_repartition.field_compte,field_client,field_entree_liee'
+ '&page[limit]=50&sort=field_date_ligne,drupal_internal__nid&' + filter;
}
async function fetchLignes(url) {
// sort includes drupal_internal__nid as a tie-breaker: field_date_ligne
// alone is not unique (many lines share a date), and without a unique
// secondary sort key, offset pagination can silently duplicate or skip
// rows across pages.
let url = API_BASE + '?include=field_repartition,field_repartition.field_compte,field_client,field_entree_liee&page[limit]=50&sort=field_date_ligne,drupal_internal__nid';
const allData = [];
const includedMap = new Map();
while (url) {
@@ -47,6 +93,26 @@
return { data: dedup, includedMap };
}
async function fetchClientNames() {
const res = await fetch('/jsonapi/taxonomy_term/client?sort=name&page[limit]=200', { headers: { Accept: 'application/vnd.api+json' } });
if (!res.ok) throw new Error('JSON:API a répondu ' + res.status);
const json = await res.json();
return (json.data || []).map((t) => t.attributes.name).filter(Boolean).sort();
}
async function fetchYearsList() {
const res = await fetch('/lignes/api/annees', { headers: { Accept: 'application/json' } });
if (!res.ok) throw new Error('/lignes/api/annees a répondu ' + res.status);
const json = await res.json();
return json.annees || [];
}
async function fetchYearTotals(annee) {
const res = await fetch('/lignes/api/totaux?annee=' + encodeURIComponent(annee), { headers: { Accept: 'application/json' } });
if (!res.ok) throw new Error('/lignes/api/totaux a répondu ' + res.status);
return res.json();
}
function resolve(includedMap, ref) {
if (!ref) return null;
return includedMap.get(ref.type + ':' + ref.id) || null;
@@ -102,6 +168,8 @@
error: null,
rows: [],
allComptes: ['Sandrine', 'Maud', 'Ouidade', 'Chloé', 'Bachir', 'Valentin', 'EXT.', 'EXT.WEB'],
allClientsList: [],
allYearsList: [],
filterCompte: '',
filterClient: '',
filterType: '',
@@ -110,18 +178,22 @@
onlyErrors: false,
hoverCol: null,
filterEntreeId: null,
// Fenêtre glissante.
windowStart: null,
windowEnd: null,
loadingOlder: false,
loadingNewer: false,
// Footer : totaux de l'année actuellement visible à l'écran (pas
// de la fenêtre chargée), voir detectCurrentYear().
currentYear: null,
currentYearTotals: null,
currentYearLoading: false,
};
},
computed: {
allTypes() {
return Object.entries(TYPE_LABELS).map(([value, label]) => ({ value, label }));
},
allClients() {
return Array.from(new Set(this.rows.map((r) => r.client).filter(Boolean))).sort();
},
allYears() {
return Array.from(new Set(this.rows.map((r) => (r.date || '').slice(0, 4)).filter(Boolean))).sort().reverse();
},
errorCount() {
return this.rows.filter((r) => r.hasError).length;
},
@@ -141,7 +213,9 @@
// positive means still owed ("reste à verser"); negative means more
// was paid out than the entrée allocated ("sur-versé", worth a
// closer look). Precomputed once for all entrée rows rather than
// recomputed per template read.
// recomputed per template read. Limited to the currently loaded
// window -- a sortie linked to an entrée outside it won't be
// counted (accepted trade-off of the sliding window).
reconciliationByEntree() {
const map = new Map();
for (const entreeRow of this.rows) {
@@ -172,20 +246,6 @@
}
return map;
},
footerTotals() {
const parCompte = {};
this.allComptes.forEach((c) => { parCompte[c] = 0; });
let montantHt = 0, montantTtc = 0, ecart = 0;
for (const r of this.filteredRows) {
montantHt += r.montant_ht || 0;
montantTtc += r.montant_ttc || 0;
ecart += r.ecart || 0;
for (const c of this.allComptes) {
if (r.parCompte[c] !== undefined) parCompte[c] += r.parCompte[c];
}
}
return { montant_ht: montantHt, montant_ttc: montantTtc, parCompte, ecart };
},
filteredRows() {
// Drill-down mode: an entrée and only the sorties linked to it,
// ignoring the other filters -- clicking its badge again clears it.
@@ -217,14 +277,14 @@
key = d ? d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') : '?';
label = d ? MONTHS[d.getMonth()] + ' ' + d.getFullYear() : 'Date inconnue';
}
if (!groups.has(key)) groups.set(key, { label, rows: [] });
if (!groups.has(key)) groups.set(key, { label, year: d ? String(d.getFullYear()) : null, rows: [] });
groups.get(key).rows.push(r);
}
const sortedKeys = Array.from(groups.keys()).sort();
const out = [];
for (const key of sortedKeys) {
const g = groups.get(key);
out.push({ isGroup: true, key: 'g-' + key, label: g.label, count: g.rows.length });
out.push({ isGroup: true, key: 'g-' + key, label: g.label, count: g.rows.length, year: g.year });
for (const r of g.rows) out.push(Object.assign({ key: 'r-' + r.id }, r));
}
return out;
@@ -348,7 +408,10 @@
if (showLoading) this.loading = true;
this.error = null;
try {
const { data, includedMap } = await fetchAllLignes();
const today = todayStr();
this.windowStart = addMonths(today, -WINDOW_MONTHS);
this.windowEnd = addMonths(today, WINDOW_MONTHS);
const { data, includedMap } = await fetchLignes(rangeUrl(this.windowStart, this.windowEnd));
this.rows = buildRows(data, includedMap);
} catch (err) {
this.error = err.message;
@@ -356,10 +419,203 @@
if (showLoading) this.loading = false;
}
},
// Post-edit refresh: re-fetch exactly the currently loaded window
// (not a fresh "last 18 months" window) so editing an old line
// doesn't silently reset how far the user had scrolled back.
async reloadWindow() {
if (!this.windowStart || !this.windowEnd) return;
try {
const { data, includedMap } = await fetchLignes(rangeUrl(this.windowStart, this.windowEnd));
this.rows = buildRows(data, includedMap);
} catch (err) {
this.error = err.message;
}
},
async loadOlder() {
// Bypassed while a specific "Année" filter is active -- that mode
// loads exactly one year and nothing else (see enterYearMode).
if (this.loadingOlder || !this.windowStart || this.filterYear) return;
this.loadingOlder = true;
try {
const newStart = addMonths(this.windowStart, -EXTEND_MONTHS);
const { data, includedMap } = await fetchLignes(rangeUrl(newStart, this.windowStart));
const newRows = buildRows(data, includedMap);
this.windowStart = newStart;
if (newRows.length) {
const wrap = this.$refs.tableWrap;
const prevScrollHeight = wrap ? wrap.scrollHeight : 0;
this.rows = [...newRows, ...this.rows];
await this.$nextTick();
// Prepending pushes existing content down -- keep whatever
// the user was looking at in the same visual spot.
if (wrap) wrap.scrollTop += wrap.scrollHeight - prevScrollHeight;
}
// Trim the far (newer) end once the window's grown past the cap
// -- happens below the current scroll position, so no visual
// jump to compensate for.
const maxEnd = addMonths(this.windowStart, MAX_LOADED_MONTHS);
if (this.windowEnd > maxEnd) {
this.rows = this.rows.filter((r) => r.date < maxEnd);
this.windowEnd = maxEnd;
}
} catch (err) {
this.error = err.message;
} finally {
this.loadingOlder = false;
}
},
async loadNewer() {
if (this.loadingNewer || !this.windowEnd || this.filterYear) return;
this.loadingNewer = true;
try {
const newEnd = addMonths(this.windowEnd, EXTEND_MONTHS);
const { data, includedMap } = await fetchLignes(rangeUrl(this.windowEnd, newEnd));
const newRows = buildRows(data, includedMap);
this.windowEnd = newEnd;
if (newRows.length) {
// Appended below the viewport -- no scroll compensation needed.
this.rows = [...this.rows, ...newRows];
}
// Trim the far (older) end once past the cap -- this IS above
// the viewport, so compensate scrollTop the same way loadOlder
// does for its prepend.
const minStart = addMonths(this.windowEnd, -MAX_LOADED_MONTHS);
if (this.windowStart < minStart) {
const wrap = this.$refs.tableWrap;
const prevScrollHeight = wrap ? wrap.scrollHeight : 0;
const prevScrollTop = wrap ? wrap.scrollTop : 0;
this.rows = this.rows.filter((r) => r.date >= minStart);
this.windowStart = minStart;
await this.$nextTick();
if (wrap) wrap.scrollTop = prevScrollTop - (prevScrollHeight - wrap.scrollHeight);
}
} catch (err) {
this.error = err.message;
} finally {
this.loadingNewer = false;
}
},
// Scroll-position based rather than IntersectionObserver: one
// unified scroll handler drives both edge-loading and
// detectCurrentYear() below, instead of maintaining two separate
// observer instances for what's fundamentally the same "where is
// the user looking right now" question.
checkEdges() {
const wrap = this.$refs.tableWrap;
if (!wrap) return;
const threshold = 400;
if (wrap.scrollTop < threshold) this.loadOlder();
if (wrap.scrollTop + wrap.clientHeight > wrap.scrollHeight - threshold) this.loadNewer();
},
// Which year is "at the top" of the visible area right now (just
// under the sticky thead) -- drives the totals footer. Works
// regardless of grouping mode: every rendered row (group header or
// data row) carries a data-year attribute.
detectCurrentYear() {
const wrap = this.$refs.tableWrap;
const table = this.$refs.tableEl;
if (!wrap || !table) return;
const thead = table.querySelector('thead');
const wrapRect = wrap.getBoundingClientRect();
const thresholdY = wrapRect.top + (thead ? thead.getBoundingClientRect().height : 0) + 2;
const rows = table.querySelectorAll('tbody tr[data-year]');
let year = null;
for (const tr of rows) {
const rect = tr.getBoundingClientRect();
if (rect.bottom > thresholdY) {
year = tr.dataset.year;
break;
}
}
if (year && year !== this.currentYear) {
this.currentYear = year;
this.loadCurrentYearTotals();
}
},
onScroll() {
if (this._scrollRaf) return;
this._scrollRaf = requestAnimationFrame(() => {
this._scrollRaf = null;
this.detectCurrentYear();
this.checkEdges();
});
},
async loadCurrentYearTotals() {
if (!this.currentYear) return;
this.currentYearLoading = true;
try {
this.currentYearTotals = await fetchYearTotals(this.currentYear);
} catch (err) {
this.currentYearTotals = null;
} finally {
this.currentYearLoading = false;
}
},
// Picking a specific "Année" bypasses the sliding window entirely --
// load exactly that year (a bounded, small fetch on its own) rather
// than post-filtering whatever happens to be in the current window,
// which could easily be empty for a year the user hasn't scrolled
// to yet.
async enterYearMode(year) {
this.loading = true;
this.error = null;
try {
const start = year + '-01-01';
const end = (parseInt(year, 10) + 1) + '-01-01';
const { data, includedMap } = await fetchLignes(rangeUrl(start, end));
this.rows = buildRows(data, includedMap);
this.windowStart = start;
this.windowEnd = end;
} catch (err) {
this.error = err.message;
} finally {
this.loading = false;
}
await this.$nextTick();
if (this.$refs.tableWrap) this.$refs.tableWrap.scrollTop = 0;
this.detectCurrentYear();
},
// Back to "Toutes" -- resume the normal sliding window, re-centered
// on today rather than trying to resume exactly where the year
// filter was applied from.
async exitYearMode() {
await this.load();
await this.$nextTick();
const wrap = this.$refs.tableWrap;
if (wrap) wrap.scrollTop = wrap.scrollHeight;
this.detectCurrentYear();
},
},
mounted() {
this.load();
jQuery(document).on('dialog:afterclose', () => this.load(false));
watch: {
filterYear(newYear, oldYear) {
if (newYear) {
this.enterYearMode(newYear);
} else if (oldYear) {
this.exitYearMode();
}
},
},
async mounted() {
await this.load();
// Independent of the row window, so the dropdowns don't shrink to
// "whatever happens to be loaded right now".
fetchClientNames().then((names) => { this.allClientsList = names; }).catch(() => {});
fetchYearsList().then((years) => { this.allYearsList = years; }).catch(() => {});
jQuery(document).on('dialog:afterclose', () => this.reloadWindow());
await this.$nextTick();
const wrap = this.$refs.tableWrap;
if (wrap) {
wrap.addEventListener('scroll', this.onScroll, { passive: true });
// Start scrolled to the most recent data (the window is centered
// on "today", so that's the bottom of what's loaded).
wrap.scrollTop = wrap.scrollHeight;
}
this.detectCurrentYear();
this.checkEdges();
},
};