페이지를 찾을 수 없습니다
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 49ddb49..ed755fb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,6 +9,8 @@ ## 내용 더하기 +PR을 올리기 전에 `python3 scripts/check_data.py`를 실행해 주세요. 배포 워크플로도 같은 검사를 하며, 실패하면 배포하지 않습니다. 링크는 https(또는 http)나 사이트 안의 경로만, id는 영문·숫자·하이픈만, 기수·인원은 정수만 허용합니다. 글(`articles/*.md`)의 HTML은 화면에 그릴 때 정리(sanitize)되어 스크립트가 실행되지 않습니다. + 사이트는 빌드 도구 없이 `data/` 폴더의 JSON 파일을 읽어 그립니다. 대부분의 수정은 파일 하나를 고치는 PR이면 됩니다. | 파일 | 고치는 경우 | diff --git a/article.html b/article.html index a88a677..c298e47 100644 --- a/article.html +++ b/article.html @@ -2,12 +2,13 @@ +다른 글
- + + diff --git a/assets/css/hub.css b/assets/css/hub.css index ff2ac4b..0f2aeeb 100644 --- a/assets/css/hub.css +++ b/assets/css/hub.css @@ -55,6 +55,7 @@ body { margin: 0; background: var(--paper); color: var(--ink); font-family: var(--f-body); font-size: 16px; line-height: 1.6; -webkit-font-smoothing: antialiased; word-break: keep-all; + min-height: 100vh; min-height: 100dvh; } img { max-width: 100%; } a { color: var(--accent); text-underline-offset: 3px; } @@ -93,7 +94,7 @@ body::before { content: ""; position: fixed; inset: 0 0 auto; height: env(safe-a .site-nav::-webkit-scrollbar { display: none; } .site-nav a { color: var(--muted); text-decoration: none; padding: 8px 10px; border-radius: 8px; white-space: nowrap; font-size: 15px; font-weight: 500; } .site-nav a:hover { color: var(--ink); background: var(--surface-2); } -.site-nav a[aria-current="true"] { color: var(--ink); background: var(--surface-2); font-weight: 700; } +.site-nav a[aria-current="true"], .site-nav a[aria-current="page"] { color: var(--ink); background: var(--surface-2); font-weight: 700; } @media (max-width: 820px) { .header-row { flex-wrap: wrap; gap: 2px; padding-block: 8px; } .site-nav { margin-left: 0; width: 100%; } @@ -249,7 +250,8 @@ body::before { content: ""; position: fixed; inset: 0 0 auto; height: env(safe-a /* Curriculum shelf */ .goal-row { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px; } .goal-q { font-weight: 700; } -#route-note { margin-top: 10px; min-height: 1.6em; } +#route-note { margin-top: 10px; min-height: 1.6em; scroll-margin-top: 96px; } +#route-note:empty { min-height: 0; margin-top: 0; } .shelf-bar { display: flex; align-items: flex-end; gap: 16px; margin: 8px 0 12px; } .shelf-nav { display: flex; gap: 6px; flex-shrink: 0; } .shelf-nav button { width: 40px; height: 40px; display: grid; place-items: center; border-radius: 50%; border: 1.5px solid var(--rule); background: var(--surface); color: var(--ink); cursor: pointer; } @@ -314,8 +316,8 @@ body::before { content: ""; position: fixed; inset: 0 0 auto; height: env(safe-a .shelf-nav { display: none; } .chap { width: min(66vw, 240px); } .chapters .nm { display: none; } - .goal-row .route-chips { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; margin-inline: calc(-1 * var(--gutter-l)) calc(-1 * var(--gutter-r)); padding-inline: var(--gutter-l) var(--gutter-r); } - .goal-row .route-chips .chip { flex-shrink: 0; } + .goal-row .route-chips { gap: 6px; } + .goal-row .route-chips .chip { font-size: 13.5px; padding: 6px 12px; } } /* This week (curriculum tab): spotlight, recent updates, continue */ @@ -474,12 +476,22 @@ body::before { content: ""; position: fixed; inset: 0 0 auto; height: env(safe-a .event .ppl b { font-family: var(--f-display); font-weight: 800; letter-spacing: -.02em; font-size: 24px; color: var(--ink); } .event .bar { height: 6px; border-radius: 3px; background: var(--surface-2); overflow: hidden; margin-top: 4px; } .event .bar i { display: block; height: 100%; background: var(--accent); } +.event .btn.sm { margin-top: auto; align-self: flex-start; } +@media (max-width: 560px) { + .events { grid-template-columns: minmax(0, 1fr); gap: 8px; } + .event { display: grid; grid-template-columns: 56px minmax(0, 1fr); gap: 2px 12px; padding: 14px; } + .event .ord { grid-row: span 4; font-size: 26px; } + .event .ppl, .event .btn.sm { margin-top: 4px; } + .event .soon-tag { justify-self: start; } +} /* Goals */ .route-chips { display: flex; flex-wrap: wrap; gap: 8px; } .chip { font: inherit; font-size: 14px; font-weight: 600; cursor: pointer; border: 1.5px solid var(--rule); background: var(--surface); color: var(--ink); padding: 7px 14px; border-radius: 999px; min-height: 40px; display: inline-flex; align-items: center; gap: 6px; } .chip:hover { border-color: var(--ink); } .chip[aria-checked="true"] { background: var(--ink); color: var(--surface); border-color: var(--ink); } +.chip.quiz-chip { border-style: dashed; border-color: color-mix(in srgb, var(--accent) 55%, var(--rule)); background: var(--accent-soft); color: var(--accent); } +.chip.quiz-chip:hover { border-style: solid; border-color: var(--accent); } .route-note { color: var(--muted); font-size: 14px; } .map-card { background: var(--surface); border: 1px solid var(--rule); border-radius: 16px; padding: 12px 0 16px; min-width: 0; } @@ -617,6 +629,7 @@ dialog.station::backdrop { background: rgba(10, 14, 20, .55); } .p-timeline li { position: relative; display: grid; grid-template-columns: 52px minmax(0, 1fr); gap: 10px; padding: 8px 0 8px 18px; } .p-timeline li::before { content: ""; position: absolute; left: -7px; top: 14px; width: 12px; height: 12px; border-radius: 50%; background: var(--c); box-shadow: 0 0 0 3px var(--surface); } .p-timeline li.r::before { box-sizing: border-box; background: var(--surface); border: 2px solid var(--c); } +.tl-c { white-space: nowrap; } .tl-c b { display: block; font-size: 15px; font-weight: 800; font-variant-numeric: tabular-nums; } .tl-c span { font-size: 11.5px; color: var(--muted); } .tl-body { display: flex; flex-direction: column; gap: 2px; min-width: 0; font-size: 14px; } @@ -658,14 +671,6 @@ dialog.station::backdrop { background: rgba(10, 14, 20, .55); } .notice { background: var(--surface); border: 1px solid var(--rule); border-radius: 12px; padding: 18px; margin-bottom: 16px; } /* Start-point quiz entry, quiz dialog, finish card, Builder share */ -.start-row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 16px; margin: -2px 0 10px; } -.start-btn { font: inherit; font-size: 14px; font-weight: 600; display: inline-flex; align-items: center; gap: 8px; min-height: 40px; padding: 0 14px; border-radius: 999px; border: 1.5px dashed color-mix(in srgb, var(--accent) 55%, var(--rule)); background: var(--accent-soft); color: var(--ink); cursor: pointer; } -.start-btn b { color: var(--accent); font-weight: 800; } -.start-btn:hover { border-style: solid; border-color: var(--accent); } -.start-btn:focus-visible, .start-game:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; } -.start-game { font-size: 14px; font-weight: 600; color: var(--muted); text-decoration: none; } -.start-game::after { content: " ›"; } -.start-game:hover { color: var(--ink); text-decoration: underline; } .btn.sm { min-height: 36px; padding: 0 12px; font-size: 14px; margin-top: 0; } button.btn { font: inherit; font-weight: 600; cursor: pointer; border: 0; } button.btn.ghost { border: 1.5px solid var(--rule); } @@ -828,6 +833,11 @@ button.btn.sm { font-size: 14px; } .daily { display: grid; grid-template-columns: 220px minmax(0, 1fr); gap: 20px 28px; padding: 20px 24px; border-radius: 18px; background: var(--surface); border: 1.5px solid color-mix(in srgb, var(--accent) 30%, var(--rule)); } .daily-head h2 { font-size: 18px; letter-spacing: -.01em; } .daily-date { font-size: 13px; color: var(--muted); margin-top: 2px; } +.daily-warm { display: flex; flex-wrap: wrap; gap: 4px 12px; margin-top: 12px; font-size: 13.5px; font-weight: 700; } +.daily-warm span { flex-basis: 100%; font-size: 12px; font-weight: 600; color: var(--muted); } +.daily-warm a { text-decoration: none; } +.daily-warm a::after { content: " ›"; } +.daily-warm a:hover { text-decoration: underline; } .daily-streak { display: inline-block; margin-top: 10px; font-size: 13px; font-weight: 700; padding: 3px 10px; border-radius: 999px; background: #FFF1C2; color: #6B4B00; } .daily-streak.quiet { background: var(--surface-2); color: var(--muted); font-weight: 600; } .daily-body { display: grid; gap: 10px; min-width: 0; } @@ -845,7 +855,7 @@ button.btn.sm { font-size: 14px; } .daily-why { font-size: 14.5px; } .daily-more { display: flex; flex-wrap: wrap; gap: 6px 16px; font-size: 14px; font-weight: 700; } .daily-next { font-size: 12.5px; color: var(--muted); } -@media (max-width: 760px) { .daily { grid-template-columns: minmax(0, 1fr); padding: 18px; } .daily-opts button { flex: 1 1 100%; } } +@media (max-width: 760px) { .daily { grid-template-columns: minmax(0, 1fr); padding: 18px; } .daily-warm span { flex-basis: auto; } .daily-opts button { flex: 1 1 100%; } } /* Confounding lab (lab.html) */ .lab { padding-block: 32px 72px; } @@ -900,13 +910,31 @@ button.btn.sm { font-size: 14px; } .lab-legend .dot { width: 10px; height: 10px; border-radius: 50%; } .lab-legend .dot.on { background: var(--l-main); } .lab-legend .dot.off { background: var(--l-exp); } +.lab-legend .ln { width: 16px; border-top: 2px solid var(--muted); } +.lab-legend .ln.dash { border-top-style: dashed; } +.lab-legend { flex-wrap: wrap; gap: 4px 16px; } .lab-learn { margin-top: 28px; max-width: 820px; } -.lab-learn h2 { font-size: 18px; margin-bottom: 10px; } +.lab-learn h2 { font-size: 18px; display: inline; } +.lab-learn summary { cursor: pointer; list-style: none; display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 12px; margin-bottom: 10px; } +.lab-learn summary::-webkit-details-marker { display: none; } +.lab-learn summary::before { content: "▸"; color: var(--muted); } +.lab-learn details[open] summary::before { content: "▾"; } +.lab-learn summary span { font-size: 13px; color: var(--muted); } +.lab-learn details[open] summary span { display: none; } +.lab-learn summary:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; border-radius: 6px; } +.lab > .wrap > .lab-challenge { margin-top: 18px; max-width: 46em; font-size: 15px; } .lab-learn ol { margin: 0 0 18px; padding-left: 20px; display: grid; gap: 10px; font-size: 15px; } .lab-learn a { font-weight: 700; margin-left: 4px; } -@media (max-width: 900px) { .lab-grid { grid-template-columns: minmax(0, 1fr); } } +@media (max-width: 900px) { + .lab-grid { grid-template-columns: minmax(0, 1fr); } + /* keep the chart in view while the sliders move */ + .lab-chart { order: -1; position: sticky; top: var(--header-h, 56px); z-index: 5; padding: 12px 14px; box-shadow: 0 8px 20px rgba(20, 22, 43, .08); } + .lab-chart h2 { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); } + .lab-chart-sub { display: none; } + .lab-legend { margin-top: 4px; font-size: 12px; } +} .toast > span:nth-child(2) { flex: 1; min-width: 0; } -@media (max-width: 900px) { .toast { bottom: auto; top: 72px; transform: translate(-50%, -180%); } .toast.show { transform: translate(-50%, 0); } } +@media (max-width: 900px) { .toast { bottom: auto; top: 96px; transform: translate(-50%, -180%); } .toast.show { transform: translate(-50%, 0); } } .lab-ctl.off input[type=range] { opacity: .45; } .lab-ctl.off small { color: var(--live); font-weight: 600; } .lab-note { font-size: 12.5px; color: var(--muted); } @@ -914,5 +942,10 @@ button.btn.sm { font-size: 14px; } #lab-svg .fit-l.on { stroke: var(--l-main); } #lab-svg .fit-l.off { stroke: var(--l-exp); } #lab-svg .fit-gap { stroke: var(--live); stroke-width: 3; } -#lab-svg .fit-t { fill: var(--live); font-size: 12px; font-weight: 700; paint-order: stroke; stroke: var(--surface); stroke-width: 4px; } +#lab-svg .fit-t { fill: var(--live); font-size: 13px; font-weight: 800; paint-order: stroke; stroke: var(--surface); stroke-width: 5px; stroke-linejoin: round; } @media (max-width: 900px) { .lab-chart { order: -1; } } + +/* way back from a repo to the Builder it was opened from */ +.back-person { font: inherit; font-size: 13.5px; font-weight: 700; align-self: flex-start; margin: 0 0 8px; padding: 4px 10px; border-radius: 999px; border: 1px solid var(--rule); background: var(--surface-2); color: var(--ink); cursor: pointer; } +.back-person:hover { border-color: var(--ink); } +.back-person:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; } diff --git a/assets/js/article.js b/assets/js/article.js index d18bd8c..567d3c6 100644 --- a/assets/js/article.js +++ b/assets/js/article.js @@ -17,16 +17,19 @@ const slug = location.hash.replace(/^#/, "") || (articles[0] && articles[0].slug); const a = articles.find((x) => x.slug === slug); more.innerHTML = articles.filter((x) => x !== a).map((x) => - `${esc(x.title)}
${esc(x.title)}
${esc(md)}`;
+ /* articles come from pull requests: never render their HTML unsanitised */
+ body.innerHTML = window.marked && window.DOMPurify
+ ? window.DOMPurify.sanitize(window.marked.parse(md), { USE_PROFILES: { html: true } })
+ : `${esc(md)}`;
body.querySelectorAll("table").forEach((t) => {
const w = document.createElement("div");
w.className = "table-wrap";
diff --git a/assets/js/ga.js b/assets/js/ga.js
index 4f010a0..a6b941c 100644
--- a/assets/js/ga.js
+++ b/assets/js/ga.js
@@ -1,15 +1,24 @@
-/* Google Analytics 4 for every page of the hub. The first ID is the hub's property;
- the second is the tag the web book already uses, kept so visits can be followed
- across both sites until one property is chosen. Local previews are not counted. */
+/* Google Analytics 4 for every page of the hub: the hub's own property. Local previews are not
+ counted. Incoming utm_* (from shared links or other team sites) reach GA through page_location
+ and are then removed from the address bar so the URL people copy stays clean. */
(() => {
- const IDS = ["G-C5V0X2SGD3", "G-BEBVKYH8HL"];
- if (location.hostname !== "causalinferencelab.github.io") return;
- const s = document.createElement("script");
- s.async = true;
- s.src = `https://www.googletagmanager.com/gtag/js?id=${IDS[0]}`;
- document.head.appendChild(s);
- window.dataLayer = window.dataLayer || [];
- window.gtag = function gtag() { window.dataLayer.push(arguments); };
- window.gtag("js", new Date());
- IDS.forEach((id) => window.gtag("config", id));
+ const ID = "G-C5V0X2SGD3";
+ if (location.hostname === "causalinferencelab.github.io") {
+ const s = document.createElement("script");
+ s.async = true;
+ s.src = `https://www.googletagmanager.com/gtag/js?id=${ID}`;
+ document.head.appendChild(s);
+ window.dataLayer = window.dataLayer || [];
+ window.gtag = function gtag() { window.dataLayer.push(arguments); };
+ window.gtag("js", new Date());
+ window.gtag("config", ID, { page_location: location.href, allow_google_signals: false, allow_ad_personalization_signals: false });
+ }
+ try {
+ const u = new URL(location.href);
+ const drop = [...u.searchParams.keys()].filter((k) => k.startsWith("utm_"));
+ if (drop.length) {
+ drop.forEach((k) => u.searchParams.delete(k));
+ history.replaceState(history.state, "", u.pathname + u.search + u.hash);
+ }
+ } catch (e) { /* old browsers keep the URL as it is */ }
})();
diff --git a/assets/js/game.js b/assets/js/game.js
index 434c541..0376341 100644
--- a/assets/js/game.js
+++ b/assets/js/game.js
@@ -62,7 +62,8 @@
const t = tier(state.score, n, state.right, kinds);
track("game_complete", { score: state.score, total: n, cause_right: state.right.cause, corr_right: state.right.corr });
if (window.hubStamp) { window.hubStamp("game"); if (state.score >= 8) window.hubStamp("sharp"); }
- const chapters = [...new Map(state.wrong.map((q) => [q.chapter.url, q.chapter])).values()];
+ const chNum = (c) => parseInt(c.url.split("/").pop(), 10) || 99;
+ const chapters = [...new Map(state.wrong.map((q) => [q.chapter.url, q.chapter])).values()].sort((x, y) => chNum(x) - chNum(y));
const share = window.hubShareUrl ? window.hubShareUrl("/game.html", "game") : location.href;
root.innerHTML = `
결과
@@ -96,7 +97,8 @@ async function start() { if (!state.qs.length) { const res = await fetch("data/game.json", { cache: "no-cache" }); - state.qs = (await res.json()).questions; + /* links come from data/game.json: keep only https chapter links */ + state.qs = (await res.json()).questions.map((q) => ({ ...q, chapter: { ...q.chapter, url: /^https:\/\//.test(q.chapter && q.chapter.url) ? q.chapter.url : "#" } })); } state.i = 0; state.score = 0; state.wrong = []; state.right = { corr: 0, cause: 0 }; track("game_start", { retry: state.runs > 0 }); diff --git a/assets/js/hub.js b/assets/js/hub.js index 5119f73..7aec156 100644 --- a/assets/js/hub.js +++ b/assets/js/hub.js @@ -22,7 +22,33 @@ const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); const fmt = (n) => Number(n).toLocaleString("ko-KR"); const $ = (sel, root = document) => root.querySelector(sel); - const dot = (d) => d.replace(/-/g, "."); + const dot = (d) => esc(String(d ?? "").replace(/-/g, ".")); + /* Data files come from pull requests: links must be http(s) or a site path, ids plain tokens, + counts whole numbers. scripts/check_data.py enforces the same in CI; this is the second line. */ + const URL_KEYS = new Set(["url", "link", "linkedin", "site", "cover", "href", "builderUrl"]); + const safeUrl = (u) => { + if (typeof u !== "string" || !u) return u; + try { const x = new URL(u, location.href); return /^https?:$/.test(x.protocol) ? u : "#"; } catch (e) { return "#"; } + }; + const TOKEN = /^[A-Za-z0-9_.-]+$/; + const tok = (v, fb = "none") => (typeof v === "string" && TOKEN.test(v) ? v : fb); + const int = (v) => (Number.isFinite(Number(v)) ? Math.trunc(Number(v)) : 0); + function cleanUrls(node) { + if (Array.isArray(node)) node.forEach(cleanUrls); + else if (node && typeof node === "object") Object.keys(node).forEach((k) => { if (URL_KEYS.has(k)) node[k] = safeUrl(node[k]); else cleanUrls(node[k]); }); + return node; + } + function cleanData(d) { + Object.values(d).forEach(cleanUrls); + d.levels.forEach((l) => { l.id = tok(l.id); }); + d.lines.forEach((l) => { l.id = tok(l.id); }); + d.repos.forEach((r) => { r.level = r.level ? tok(r.level, "") : r.level; r.lines = (r.lines || []).filter((x) => TOKEN.test(x)); r.cohorts = (r.cohorts || []).map(int); if (r.stars != null) r.stars = int(r.stars); }); + d.projects.forEach((p) => { p.cohort = int(p.cohort); p.runners = p.runners == null ? null : int(p.runners); }); + d.events.forEach((e) => { e.order = int(e.order); e.year = int(e.year); e.participants = e.participants == null ? null : int(e.participants); }); + d.routes.forEach((r) => { r.id = tok(r.id); }); + d.builders.forEach((b) => { (b.runnerOf || []).forEach((x) => { x.cohort = int(x.cohort); }); }); + return d; + } const track = (name, params) => { if (window.hubTrack) window.hubTrack(name, params); }; const stamp = (id) => { if (window.hubStamp) window.hubStamp(id); }; const shareUrl = (path, medium) => (window.hubShareUrl ? window.hubShareUrl(path, medium) : path); @@ -48,10 +74,10 @@ async function load() { const names = ["repos", "lines", "levels", "map", "routes", "projects", "events", "articles", "builders", "growth", "stats", "featured", "daily"]; const data = await Promise.all(names.map((n) => getJSON(`data/${n}.json`))); - const d = Object.fromEntries(names.map((n, i) => [n, data[i]])); + const d = cleanData(Object.fromEntries(names.map((n, i) => [n, data[i]]))); d.repos.forEach((r) => { const live = d.stats.repos && d.stats.repos[r.repo]; - if (live) { r.stars = live.stars; r.pushed = live.pushed; } + if (live) { r.stars = int(live.stars); r.pushed = typeof live.pushed === "string" ? live.pushed : null; } state.repos.set(r.repo, r); }); state.repos.set(d.map.external.id, { repo: d.map.external.id, kind: "external", status: "external", builders: [], cohorts: [], ...d.map.external }); @@ -327,7 +353,7 @@ return chap + items.map((r, k) => episodeHTML(r, lv.id, k, top)).join(""); }).join(""); $("#chapters").innerHTML = state.levels.map((lv) => ``).join(""); - $("#route-note").textContent = "목표를 고르면 트랙 지도와 커리큘럼에 볼 순서가 표시됩니다."; + $("#route-note").textContent = ""; } wireAvatars(shelf); @@ -375,6 +401,7 @@ function setRoute(id, source) { if (source && id !== state.route) track("select_route", { route: id, source }); state.routeBegin = 0; + $("#week").hidden = id !== "all"; state.route = id; $("#route-chips").querySelectorAll(".chip").forEach((c) => c.setAttribute("aria-checked", String(c.dataset.route === id))); renderShelf(); @@ -384,7 +411,8 @@ function renderRouteChips() { const box = $("#route-chips"); const all = [{ id: "all", label: "전체 보기" }, ...state.routes]; - box.innerHTML = all.map((r) => ``).join(""); + box.innerHTML = all.map((r) => ``).join("") + + ``; box.addEventListener("click", (e) => { const b = e.target.closest("[data-route]"); if (b) setRoute(b.dataset.route, "chip"); @@ -425,6 +453,9 @@ const shortTerm = (t) => (t || "").replace(/^20(\d\d) /, "$1 ").replace("상반기", "상").replace("하반기", "하"); const span = (list) => (list.length ? (list[0] === list[list.length - 1] ? `${list[0]}기` : `${list[0]}–${list[list.length - 1]}기`) : ""); + /* one way to say how long someone has been a Builder, everywhere */ + const tenure = (p) => (p.cohorts.length === 1 ? `${p.first}기 Builder` : `${p.first}기부터 · ${p.cohorts.length}개 기수 (${p.cohorts.join("·")}기)`); + function builderStats() { const cur = currentCohort(); const people = new Map(); @@ -480,13 +511,13 @@ state.people = list; const cohorts = [...new Set(state.projects.map((p) => p.cohort))].sort((a, b) => a - b); const terms = new Map(state.projects.map((p) => [p.cohort, p.term])); - const stripLabel = (p) => `${span(p.cohorts)}${p.cohorts.length > 1 ? ` 중 ${p.cohorts.length}개 기수` : ""}`; + const stripLabel = (p) => tenure(p); $("#top3").innerHTML = list.slice(0, 3).map((p) => { const recent = [...p.items].reverse().slice(0, 2); return `