|
3 | 3 |
|
4 | 4 | const ORG = "CausalInferenceLab"; |
5 | 5 | const RIDE_KEY = "cil-hub-ridden"; |
| 6 | + const RECENT_KEY = "cil-hub-recent"; |
6 | 7 | const SVG_NS = "http://www.w3.org/2000/svg"; |
7 | 8 | const reduceMotion = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches; |
8 | 9 |
|
|
26 | 27 | const state = { |
27 | 28 | repos: new Map(), lines: new Map(), lineList: [], levels: [], map: null, routes: [], |
28 | 29 | projects: [], events: [], articles: [], builders: [], growth: null, stats: null, |
29 | | - ridden: new Set(), order: new Map(), route: "all" |
| 30 | + ridden: new Set(), recent: [], order: new Map(), route: "all" |
30 | 31 | }; |
31 | 32 |
|
32 | 33 | function loadRidden() { try { state.ridden = new Set(JSON.parse(localStorage.getItem(RIDE_KEY) || "[]")); } catch (e) { state.ridden = new Set(); } } |
| 34 | + function loadRecent() { try { state.recent = JSON.parse(localStorage.getItem(RECENT_KEY) || "[]").filter((x) => x && x.id); } catch (e) { state.recent = []; } } |
| 35 | + function saveRecent() { try { localStorage.setItem(RECENT_KEY, JSON.stringify(state.recent.slice(0, 6))); } catch (e) { /* storage unavailable */ } } |
33 | 36 | function saveRidden() { try { localStorage.setItem(RIDE_KEY, JSON.stringify([...state.ridden])); } catch (e) { /* storage unavailable */ } } |
34 | 37 |
|
35 | 38 | async function getJSON(path) { |
|
73 | 76 | if (typeof g.stargazers_count === "number") r.stars = g.stargazers_count; |
74 | 77 | if (g.pushed_at) r.pushed = g.pushed_at; |
75 | 78 | }); |
76 | | - renderGrowth(false); renderLadder(); renderShelf(true); |
| 79 | + renderGrowth(false); renderLadder(); renderShelf(true); renderWeek(); |
77 | 80 | } catch (e) { /* offline or rate limited: keep the snapshot */ } |
78 | 81 | } |
79 | 82 |
|
|
251 | 254 | } |
252 | 255 |
|
253 | 256 | const pad2 = (n) => String(n).padStart(2, "0"); |
| 257 | + const DAY = 864e5; |
| 258 | + const ageDays = (iso) => (iso ? Math.max(0, Math.floor((Date.now() - Date.parse(iso)) / DAY)) : null); |
| 259 | + function ago(iso) { |
| 260 | + const d = ageDays(iso); |
| 261 | + if (d == null) return ""; |
| 262 | + if (d < 1) return "오늘"; |
| 263 | + if (d < 7) return `${d}일 전`; |
| 264 | + if (d < 31) return `${Math.floor(d / 7)}주 전`; |
| 265 | + if (d < 365) return `${Math.floor(d / 30)}개월 전`; |
| 266 | + return `${Math.floor(d / 365)}년 전`; |
| 267 | + } |
| 268 | + const learnables = () => [...state.repos.values()].filter((r) => !r.kind && (r.lines || []).length && r.status !== "depot"); |
254 | 269 | const currentRoute = () => state.routes.find((r) => r.id === state.route); |
255 | 270 |
|
256 | 271 | function episodeHTML(r, level, k, top, step) { |
|
260 | 275 | const badges = [ |
261 | 276 | rank >= 0 ? `<span class="b top">TOP ${rank + 1}</span>` : "", |
262 | 277 | r.status === "running" ? `<span class="b live">진행 중</span>` : "", |
| 278 | + r.status !== "running" && ageDays(r.pushed) != null && ageDays(r.pushed) <= 14 ? `<span class="b new">새 업데이트</span>` : "", |
263 | 279 | r.kind === "paid" ? `<span class="b paid">유료</span>` : "", |
264 | 280 | r.kind === "external" ? `<span class="b ext">팀 밖 오픈소스</span>` : "" |
265 | 281 | ].join(""); |
|
277 | 293 | ${state.ridden.has(id) ? `<span class="ep-done" aria-label="학습 완료">✓</span>` : ""} |
278 | 294 | </span> |
279 | 295 | <span class="ep-body"> |
280 | | - <span class="ep-track">${tracks}</span> |
| 296 | + <span class="ep-track">${tracks}${r.pushed && !r.kind ? `<span class="age"> · ${ago(r.pushed)} 업데이트</span>` : ""}</span> |
281 | 297 | <h3>${esc(r.title)}</h3> |
282 | 298 | <span class="sum">${esc(r.summary)}</span> |
283 | 299 | <span class="ep-foot">${people}${r.kind ? "" : `<span class="star">${icons.star}${fmt(r.stars || 0)}</span>`}</span> |
|
307 | 323 | return chap + items.map((r, k) => episodeHTML(r, lv.id, k, top)).join(""); |
308 | 324 | }).join(""); |
309 | 325 | $("#chapters").innerHTML = state.levels.map((lv) => `<button type="button" data-chapter="${lv.id}" style="flex-grow:${byLevel.get(lv.id).length + 1}" aria-current="false"><span class="bar" aria-hidden="true"></span><b>${lv.id}</b> <span class="nm">${esc(lv.name)}</span></button>`).join(""); |
310 | | - $("#route-note").textContent = "L0부터 L4까지 모든 레포입니다. 목표를 고르면 볼 순서대로 추려 드립니다."; |
| 326 | + $("#route-note").textContent = "목표를 고르면 트랙 지도와 커리큘럼에 볼 순서가 표시됩니다."; |
311 | 327 | } |
312 | 328 | wireAvatars(shelf); |
313 | 329 |
|
314 | 330 | const repos = [...state.repos.values()].filter((r) => !r.kind && r.status !== "depot"); |
315 | 331 | const depot = [...state.repos.values()].filter((r) => r.status === "depot"); |
316 | 332 | const learnable = repos.filter((r) => (r.lines || []).length); |
317 | 333 | const done = learnable.filter((r) => state.ridden.has(r.repo)).length; |
318 | | - $("#curriculum-sub").textContent = `팀 레포 ${repos.length}개를 단계 순서로 놓았습니다.`; |
| 334 | + $("#curriculum-sub").textContent = `팀 레포 ${repos.length}개. 트랙 지도로 전체를 보고, 커리큘럼에서 하나씩 열어 보세요.`; |
319 | 335 | $("#n-course").textContent = repos.length; |
320 | 336 | $("#shelf-foot").innerHTML = `${done ? `<span class="done-n">학습 완료 ${done} / ${learnable.length}</span> ` : ""}카드를 누르면 자세한 정보와 링크가 열립니다. 운영·보관 레포: ${depot.map((r) => `<a href="${esc(repoUrl(r))}" target="_blank" rel="noopener">${esc(r.title)}</a>`).join(", ")}`; |
321 | 337 | shelf.scrollLeft = keepScroll ? prevLeft : 0; |
|
404 | 420 | const people = new Map(); |
405 | 421 | const put = (name, cohort, item) => { |
406 | 422 | if (!name) return; |
407 | | - if (!people.has(name)) people.set(name, { name, projects: 0, runners: 0, first: 99, line: null }); |
| 423 | + if (!people.has(name)) people.set(name, { name, projects: 0, runners: 0, first: 99, line: null, latest: null, latestCohort: -1 }); |
408 | 424 | const p = people.get(name); |
409 | 425 | p.projects += 1; p.runners += item.runners || 0; p.first = Math.min(p.first, cohort); |
410 | 426 | if (!p.line && item.line !== "none") p.line = item.line; |
| 427 | + if (cohort >= p.latestCohort) { p.latestCohort = cohort; p.latest = item; } |
411 | 428 | const key = name + "|" + cohort; |
412 | 429 | if (!cells.has(key)) cells.set(key, []); |
413 | 430 | cells.get(key).push(item); |
|
424 | 441 | const totals = new Map(cohorts.map((c) => [c, state.projects.filter((p) => p.cohort === c).reduce((s, p) => s + (p.runners || 0), 0)])); |
425 | 442 | const maxT = Math.max(...totals.values()) || 1; |
426 | 443 | const rows = [...people.values()].sort((a, b) => a.first - b.first || b.projects - a.projects || a.name.localeCompare(b.name, "ko")); |
| 444 | + renderPeople([...rows].sort((a, b) => b.latestCohort - a.latestCohort || b.projects - a.projects)); |
427 | 445 |
|
428 | 446 | const head = `<thead><tr><th scope="col" class="corner">Builder</th>${cohorts.map((c) => `<th scope="col" class="${c === cur ? "cur" : ""}"><b>${c}기</b><span>${esc(shortTerm(terms.get(c)))}</span></th>`).join("")}</tr> |
429 | 447 | <tr class="totals"><th scope="row">기수별 러너</th>${cohorts.map((c) => { const t = totals.get(c); return `<td class="${c === cur ? "cur" : ""}"><span class="tb" aria-hidden="true"><i style="height:${Math.max(6, Math.round((t / maxT) * 100))}%"></i></span><span class="tn">${t ? t : "집계 중"}</span></td>`; }).join("")}</tr></thead>`; |
|
450 | 468 | } |
451 | 469 | function matrixToEnd() { requestAnimationFrame(() => { const sc = $(".matrix-scroll"); sc.scrollLeft = sc.scrollWidth; }); } |
452 | 470 |
|
| 471 | + |
| 472 | + /* ---------- Builder rail (contribution tab) ---------- */ |
| 473 | + function renderPeople(list) { |
| 474 | + const card = (p, copy) => { |
| 475 | + const info = builderInfo(p.name); |
| 476 | + const name = info.linkedin ? `<a href="${esc(info.linkedin)}" target="_blank" rel="noopener"${copy ? ' tabindex="-1"' : ""}>${esc(p.name)}</a>` : esc(p.name); |
| 477 | + const latest = p.latest ? (p.latest.repo && state.repos.get(p.latest.repo) ? state.repos.get(p.latest.repo).title : p.latest.name) : ""; |
| 478 | + return `<li class="pcard" data-line="${p.line || "main"}">${avatarHTML(p.name, "lg")} |
| 479 | + <div class="pc-body"><b class="pc-name">${name}</b><span class="pc-since">${p.first}기부터 Builder</span> |
| 480 | + ${info.intro ? `<p class="pc-intro">${esc(info.intro)}</p>` : ""} |
| 481 | + <span class="pc-stats">프로젝트 <b>${p.projects}</b> · 러너 <b>${fmt(p.runners)}</b></span> |
| 482 | + ${latest ? `<span class="pc-latest">최근 · ${esc(latest)}</span>` : ""}</div></li>`; |
| 483 | + }; |
| 484 | + $("#people-rail").innerHTML = `<div class="people-move" style="--dur:${list.length * 6}s"><ul class="people-track">${list.map((p) => card(p, false)).join("")}</ul><ul class="people-track dup" aria-hidden="true">${list.map((p) => card(p, true)).join("")}</ul></div>`; |
| 485 | + wireAvatars($("#people-rail")); |
| 486 | + } |
| 487 | + |
| 488 | + /* ---------- this week (curriculum tab) ---------- */ |
| 489 | + const weekIndex = () => Math.floor((Date.now() - Date.UTC(2026, 0, 5)) / (7 * DAY)); |
| 490 | + function renderWeek() { |
| 491 | + const list = learnables(); |
| 492 | + if (!list.length) return; |
| 493 | + const ordered = state.levels.flatMap((lv) => list.filter((r) => r.level === lv.id).sort((a, b) => (b.stars || 0) - (a.stars || 0))); |
| 494 | + const n = ordered.length; |
| 495 | + const pick = ordered.at(((weekIndex() % n) + n) % n); |
| 496 | + const lv = state.levels.find((l) => l.id === pick.level); |
| 497 | + const who = (pick.builders || []).join(", "); |
| 498 | + const fresh = list.filter((r) => r.pushed).sort((a, b) => Date.parse(b.pushed) - Date.parse(a.pushed)).slice(0, 4); |
| 499 | + const recent = state.recent.map((x) => state.repos.get(x.id)).filter(Boolean).slice(0, 3); |
| 500 | + const done = list.filter((r) => state.ridden.has(r.repo)).length; |
| 501 | + const cont = recent.length ? `<div class="continue"><span class="cont-label">이어서 보기</span>${recent.map((r) => `<button type="button" class="cont-chip" data-line="${primaryLine(r)}" data-open="${esc(r.repo)}"><i aria-hidden="true"></i>${esc(r.title)}</button>`).join("")} |
| 502 | + <span class="cont-progress">학습 완료 <b>${done}</b> / ${list.length}<span class="bar" aria-hidden="true"><i style="width:${Math.round((done / list.length) * 100)}%"></i></span></span></div>` : ""; |
| 503 | + $("#week").innerHTML = `${cont}<div class="week-grid"> |
| 504 | + <article class="spot" data-line="${primaryLine(pick)}"> |
| 505 | + <p class="spot-label">이번 주의 레포<span>월요일마다 바뀝니다</span></p> |
| 506 | + <h3>${esc(pick.title)}</h3> |
| 507 | + <p class="spot-meta">${lv ? `<span>${lv.id} ${esc(lv.name)}</span>` : ""}${who ? `<span>${esc(who)}</span>` : ""}${pick.stars ? `<span class="star">${icons.star}${fmt(pick.stars)}</span>` : ""}</p> |
| 508 | + <p class="spot-sum">${esc(pick.summary)}</p> |
| 509 | + <div class="spot-cta"><button type="button" class="btn" data-open="${esc(pick.repo)}">자세히 보기</button>${pick.site ? `<a class="btn ghost" href="${esc(pick.site)}" target="_blank" rel="noopener">${esc(pick.siteLabel || "사이트 열기")}</a>` : ""}</div> |
| 510 | + </article> |
| 511 | + <div class="fresh"> |
| 512 | + <p class="spot-label">최근 업데이트<span>매주 월요일 집계</span></p> |
| 513 | + <ol>${fresh.map((r) => `<li data-line="${primaryLine(r)}"><button type="button" data-open="${esc(r.repo)}"><i aria-hidden="true"></i><span class="t">${esc(r.title)}</span><span class="d">${ago(r.pushed)}</span></button></li>`).join("")}</ol> |
| 514 | + </div> |
| 515 | + </div>`; |
| 516 | + } |
| 517 | + |
453 | 518 | /* ---------- map legend ---------- */ |
454 | 519 | function renderVLines() { |
455 | 520 | $("#vlines").innerHTML = state.lineList.map((l) => { |
|
622 | 687 | function openStation(id) { |
623 | 688 | const r = state.repos.get(id); |
624 | 689 | if (!r) return; |
| 690 | + if (r.kind !== "paid") { |
| 691 | + state.recent = [{ id, t: Date.now() }, ...state.recent.filter((x) => x.id !== id)].slice(0, 6); |
| 692 | + saveRecent(); renderWeek(); |
| 693 | + } |
625 | 694 | const line = primaryLine(r); |
626 | 695 | const lv = state.levels.find((l) => l.id === r.level); |
627 | 696 | const lineTags = (r.lines || []).map((lid) => { const l = state.lines.get(lid); return `<span class="tag-line" data-line="${lid}"><span class="num" aria-hidden="true"></span>${esc(l.name)}</span>`; }).join(""); |
|
737 | 806 | } |
738 | 807 |
|
739 | 808 | async function init() { |
740 | | - loadRidden(); |
| 809 | + loadRidden(); loadRecent(); |
741 | 810 | wire(); |
742 | 811 | try { await load(); } catch (err) { fail(err); return; } |
743 | 812 | renderGrowth(); renderLadder(); |
744 | | - renderRouteChips(); renderShelf(); |
| 813 | + renderRouteChips(); renderShelf(); renderWeek(); |
745 | 814 | $("#shelf").addEventListener("scroll", shelfSpy, { passive: true }); |
746 | 815 | renderMatrix(); |
747 | 816 | renderLegend(); renderMap(); wireTabs(); |
|
0 commit comments