diff --git a/spec/whistlegraph-admin-spec.mjs b/spec/whistlegraph-admin-spec.mjs index c68549afe5..818cadc822 100644 --- a/spec/whistlegraph-admin-spec.mjs +++ b/spec/whistlegraph-admin-spec.mjs @@ -244,6 +244,7 @@ describe("Whistlegraph Desk", () => { expect(publicData.createdWorks.fear.title).toBe("Inside My Mouth"); expect(publicData.deletedWorks).toEqual([]); expect(publicData.activeCodes).toEqual(["fear"]); + expect(publicData.materializedWorks.fear).toEqual({ perf: 1, views: 0 }); const renamed = await handler({ httpMethod: "POST", headers, queryStringParameters: { action: "work-rename" }, body: JSON.stringify({ from: "fear", code: "mouth", title: "Inside My Mouth", by: "Alex Freundlich", year: 2026, c: "#b44887" }) }); expect(renamed.statusCode).toBe(200); diff --git a/system/netlify/functions/whistlegraph-admin.mjs b/system/netlify/functions/whistlegraph-admin.mjs index ae82db66b4..3c561c007f 100644 --- a/system/netlify/functions/whistlegraph-admin.mjs +++ b/system/netlify/functions/whistlegraph-admin.mjs @@ -41,7 +41,7 @@ const HEADERS = { "Access-Control-Allow-Methods": "GET, PATCH, POST, DELETE, OPTIONS", }; -let modelCache = { stamp: "", workCodes: new Set(), workByCode: new Map(), postIds: new Set(), postWorks: new Map() }; +let modelCache = { stamp: "", workCodes: new Set(), workByCode: new Map(), postIds: new Set(), postWorks: new Map(), postById: new Map() }; let visualCache = { stamp: "", byId: new Map(), searchable: [], metadata: { count: 0 } }; function loadBaseModel() { @@ -57,6 +57,7 @@ function loadBaseModel() { workByCode: new Map((graphs.works || []).map((work) => [String(work.code), work])), postIds: new Set((posts.posts || []).map((post) => String(post.id))), postWorks: new Map((posts.posts || []).map((post) => [String(post.id), post.works || []])), + postById: new Map((posts.posts || []).map((post) => [String(post.id), post])), }; return modelCache; } @@ -102,11 +103,30 @@ async function readCuration(database, loadModelFn = loadBaseModel) { for (const code of Object.keys(payload.createdWorks)) validCodes.add(code); for (const code of payload.deletedWorks) validCodes.delete(code); const activeCodes = new Set(); + const materialized = new Map(); for (const [postId, baseWorks] of model.postWorks || []) { const assigned = payload.posts[postId]?.works || baseWorks; - for (const code of assigned) if (validCodes.has(code)) activeCodes.add(code); + const post = model.postById?.get(postId) || {}; + for (const code of assigned) if (validCodes.has(code)) { + activeCodes.add(code); + const row = materialized.get(code) || { perf: 0, views: 0, posts: [] }; + row.perf += 1; + row.views += Number(post.views) || 0; + row.posts.push(post); + materialized.set(code, row); + } + } + const materializedWorks = {}; + for (const [code, row] of materialized) { + const work = { ...(model.workByCode?.get(code) || {}), ...(payload.works[code] || {}), ...(payload.createdWorks[code] || {}) }; + const featured = row.posts.find((post) => String(post.id) === String(work.featuredPost || "")); + const hero = featured || row.posts.sort((a, b) => (Number(b.views) || 0) - (Number(a.views) || 0))[0]; + const thumb = hero?.thumb || (hero?.media !== "audio" && hero?.id + ? `https://assets.aesthetic.computer/whistlegraph/index/posts/${hero.id}.jpg` + : null); + materializedWorks[code] = { perf: row.perf, views: row.views, ...(thumb ? { thumb } : {}) }; } - return { ...payload, activeCodes: [...activeCodes] }; + return { ...payload, activeCodes: [...activeCodes], materializedWorks }; } function deployError(message, statusCode = 409) { diff --git a/system/public/whistlegraph.org/index.html b/system/public/whistlegraph.org/index.html index 42dab62bfe..06fd7b19ab 100644 --- a/system/public/whistlegraph.org/index.html +++ b/system/public/whistlegraph.org/index.html @@ -440,7 +440,7 @@ const esc=s=>String(s||"").replace(/[&<>"]/g,c=>({"&":"&","<":"<",">":"&g // the authenticated Whistlegraph Desk layer over that model and survive deploys. const curationLoaded=fetch("/api/whistlegraph-admin?action=curation",{cache:"no-store"}) .then(r=>r.ok?r.json():{works:{},posts:{}}) - .then(data=>(LIVE_CURATION={works:data.works||{},posts:data.posts||{},createdWorks:data.createdWorks||{},deletedWorks:data.deletedWorks||[],trashedWorks:data.trashedWorks||{},activeCodes:data.activeCodes||[]},LIVE_CURATION)) + .then(data=>(LIVE_CURATION={works:data.works||{},posts:data.posts||{},createdWorks:data.createdWorks||{},deletedWorks:data.deletedWorks||[],trashedWorks:data.trashedWorks||{},activeCodes:data.activeCodes||[],materializedWorks:data.materializedWorks||{}},LIVE_CURATION)) .catch(()=>LIVE_CURATION); // A whistlegraph's posts — every published appearance, most-seen first — @@ -679,8 +679,8 @@ Promise.all([ const activeCodes=new Set(curation.activeCodes||[]); const generatedWorks=(data.works||data.graphs.filter(w=>w.kind==="graph")) .filter(work=>!deletedWorks.has(work.code)&&(!activeCodes.size||activeCodes.has(work.code))) - .map(work=>({...work,...(curation.works?.[work.code]||{})})); - const createdWorks=Object.entries(curation.createdWorks||{}).map(([code,work])=>({code,...work,kind:"graph"})); + .map(work=>({...work,...(curation.works?.[work.code]||{}),...(curation.materializedWorks?.[work.code]||{})})); + const createdWorks=Object.entries(curation.createdWorks||{}).map(([code,work])=>({code,...work,...(curation.materializedWorks?.[code]||{}),kind:"graph"})); WORKS=[...generatedWorks,...createdWorks]; CURATED_N=data.curated; BY=Object.fromEntries(WORKS.map(w=>[w.code,w])); LEGACY_BY=Object.fromEntries((data.legacy||[]).map(w=>[w.code,w])); @@ -731,11 +731,11 @@ Promise.all([ selectSection(section,true); })); routeLocation(); - // Reconcile Desk relationship edits immediately after first paint. Deferring - // this to requestIdleCallback left newly created works showing their count - // but no assigned gallery/thumbnail indefinitely in background or busy tabs. + // Counts and thumbnails are materialized by the curation API, so the heavy + // post archive can return to an idle/deferred fetch. Detail routes still load + // it immediately when opened. const reconcilePosts=()=>loadPosts().then(()=>{if(curSection==="works"&&!document.getElementById("detail").classList.contains("on"))renderReset(true)}); - reconcilePosts(); + if("requestIdleCallback" in window)requestIdleCallback(reconcilePosts);else setTimeout(reconcilePosts,0); }); function showDetail(code, keepScroll){