perf(faith/calendar): trim yearDays, send pre-filtered feastDots

yearDays was a 365-entry array (one per day in the LY window) with
{iso, name, rank, color, seasonKey} on each — the client only needed
the color (for the needle pin on the currently-selected day; RingView
re-did the feast filter itself). Split into:

- yearDays: {iso, color} — unchanged count, but ~60% smaller per entry
  (drops name, rank, seasonKey)
- feastDots: {iso, name, rank, color} — new, pre-filtered to
  rank > ferial server-side (~150 entries instead of 365)

RingView's `feastDots` derivation shrinks to filtering out just the
currently-selected day, and `activeFeasts` filters `feastDots` by arc
bounds instead of re-scanning yearDays. needleDay's color lookup still
works with the trimmed YearDay.

Also collapses a stray `locals.session ?? (locals.session ?? …)` the
earlier #5 sweep introduced in both calendar page loaders.
This commit is contained in:
2026-04-23 15:37:38 +02:00
parent 4112e38306
commit 076c6efb38
7 changed files with 48 additions and 35 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ Order = impact. Font items + app.html preload intentionally skipped.
- [x] 5. Replace redundant `locals.auth()` with `locals.session` across all routes (68 files, 107 sites — loaders, actions, API endpoints)
- [x] 6. Stream fitness stats loader — muscleHeatmap, nutritionStats, periods, sharedPeriods now stream via `{#await}`. `stats` still awaited (too many chart $deriveds depend on it)
- [x] 7. Muscle-heatmap endpoint — add projection + O(1) bucket math. Overview already had a projection; set-subfield narrowing was attempted but reverted (returned malformed sets). Timeseries cap not feasible: totals are lifetime-scoped.
- [ ] 8. Calendar payload trim — drop `name` from `yearDays`, pre-filter `feastDots` server-side
- [x] 8. Calendar payload trim — `yearDays` narrowed to `{iso, color}` (needle lookup only), new pre-filtered `feastDots` array carries feast-specific metadata. Also fixed a stray double `locals.session ?? (locals.session ?? …)` in both calendar page loaders.
- [ ] 9. History sessions endpoint — slim exercise payload for list view
- [ ] 10. `Cache-Control` headers on stable API endpoints (all_brief, calendar, exercises metadata)
- [ ] 11. Search — debounce 200 ms + server-side pre-normalized `_searchKey`
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "homepage",
"version": "1.46.21",
"version": "1.46.22",
"private": true,
"type": "module",
"scripts": {
+13 -4
View File
@@ -16,14 +16,23 @@ export interface CalendarDay {
rite1962?: Rite1962Detail;
}
// Compact per-day shape returned for the full year so the ring / month-grid
// overview views can render without refetching. Kept small on purpose.
// Compact per-day shape returned for the full window of the liturgical year.
// Kept to the bare minimum needed client-side: the ring needs a color for the
// needle on the selected day (which may be a ferial with no rank metadata),
// everything else goes through the separate `feastDots` array.
export interface YearDay {
iso: string;
color: string; // primary color key (WHITE/RED/...)
}
// Pre-filtered list of days that render a feast dot on the ring — rank > feria
// — with the metadata the ring and side panel need for each. Sent alongside
// YearDay so clients don't have to filter 365 entries themselves.
export interface FeastDot {
iso: string;
name: string;
rank: string;
color: string; // primary color key (WHITE/RED/...)
seasonKey: string | null;
color: string;
}
export interface SeasonArc {
@@ -13,16 +13,17 @@ import {
type Diocese1969,
type Rite
} from '../../../../calendarI18n';
import { seasonColorFor } from '../../../../calendarColors';
import { rankDotSize, seasonColorFor } from '../../../../calendarColors';
import {
getYear,
getYear1962,
isoFor
} from '$lib/server/liturgicalCalendar';
import type { CalendarDay, SeasonArc, YearDay } from '$lib/calendarTypes';
import type { CalendarDay, FeastDot, SeasonArc, YearDay } from '$lib/calendarTypes';
export type {
CalendarDay,
FeastDot,
ProperSection,
Rite1962Commem,
Rite1962Detail,
@@ -234,14 +235,25 @@ export const load: PageServerLoad = async ({ params, url, locals, fetch }) => {
}
}
const yearDays: YearDay[] = sortedYear.map((d, i) => ({
// `yearDays` only carries what the ring's needle-color lookup needs for any
// day (feast or ferial). Feast metadata (name, rank) moves into `feastDots`
// below so the client can iterate it directly without filtering 365 entries.
const yearDays: YearDay[] = sortedYear.map((d) => ({
iso: d.iso,
name: d.name,
rank: d.rank,
color: d.colorKeys[0] ?? 'GREEN',
seasonKey: filledSeasons[i]
color: d.colorKeys[0] ?? 'GREEN'
}));
const feastDots: FeastDot[] = [];
for (const d of sortedYear) {
if (rankDotSize(d.rank) === 0) continue;
feastDots.push({
iso: d.iso,
name: d.name,
rank: d.rank,
color: d.colorKeys[0] ?? 'GREEN'
});
}
const seasonArcs: SeasonArc[] = [];
let cur: SeasonArc | null = null;
for (let i = 0; i < sortedYear.length; i++) {
@@ -282,6 +294,7 @@ export const load: PageServerLoad = async ({ params, url, locals, fetch }) => {
month,
monthDays,
yearDays,
feastDots,
seasonArcs,
windowStart,
windowEnd,
@@ -291,6 +304,6 @@ export const load: PageServerLoad = async ({ params, url, locals, fetch }) => {
todayIso,
selected: selectedEntry,
selectedIso,
session: locals.session ?? (locals.session ?? await locals.auth())
session: locals.session ?? await locals.auth()
};
};
@@ -28,6 +28,7 @@
const month = $derived(data.month);
const monthDays = $derived(data.monthDays);
const yearDays = $derived(data.yearDays);
const feastDots = $derived(data.feastDots);
const seasonArcs = $derived(data.seasonArcs);
const today = $derived(data.today);
const todayIso = $derived(data.todayIso);
@@ -267,6 +268,7 @@
{year}
{liturgicalYear}
{yearDays}
{feastDots}
{seasonArcs}
{todayIso}
{selectedIso}
@@ -1,5 +1,5 @@
<script lang="ts">
import type { YearDay, SeasonArc } from './+page.server';
import type { FeastDot, YearDay, SeasonArc } from './+page.server';
import type { CalendarLang } from '../../../../calendarI18n';
import { litBg, litInk, rankDotSize } from '../../../../calendarColors';
import { Tween, prefersReducedMotion } from 'svelte/motion';
@@ -11,6 +11,7 @@
year,
liturgicalYear,
yearDays,
feastDots: feastDotsProp,
seasonArcs,
todayIso,
selectedIso = null,
@@ -25,6 +26,7 @@
year: number;
liturgicalYear: number;
yearDays: YearDay[];
feastDots: FeastDot[];
seasonArcs: SeasonArc[];
todayIso: string;
selectedIso?: string | null;
@@ -161,20 +163,10 @@
return out;
});
// Feast dots: keep only the highest-ranking feast per ISO date, skip ferias.
// The currently-selected feast is omitted because the static needle pin at
// the top of the ring represents it.
const feastDots = $derived.by(() => {
const byDate = new Map<string, YearDay>();
for (const d of yearDays) {
const size = rankDotSize(d.rank);
if (size === 0) continue;
if (d.iso === needleIso) continue;
const cur = byDate.get(d.iso);
if (!cur || rankDotSize(d.rank) > rankDotSize(cur.rank)) byDate.set(d.iso, d);
}
return [...byDate.values()];
});
// Feast dots come pre-filtered from the server (rank > ferial, one per ISO).
// Only strip the currently-selected day here since the needle pin at the top
// already represents it.
const feastDots = $derived(feastDotsProp.filter((d) => d.iso !== needleIso));
// A season can split into multiple arcs within one gregorian year (e.g.
// ChristmasTide spans both Dec 2531 and Jan 113 of the civil year). Each
@@ -206,11 +198,8 @@
);
const activeFeasts = $derived.by(() => {
if (!active) return [] as YearDay[];
return yearDays.filter(
(d) =>
rankDotSize(d.rank) > 0 && d.iso >= active.start && d.iso <= active.end
);
if (!active) return [] as FeastDot[];
return feastDotsProp.filter((d) => d.iso >= active.start && d.iso <= active.end);
});
$effect(() => {
@@ -96,6 +96,6 @@ export const load: PageServerLoad = async ({ params, url, locals, fetch }) => {
iso,
todayIso,
day1: entry,
session: locals.session ?? (locals.session ?? await locals.auth())
session: locals.session ?? await locals.auth()
};
};