ธีม
Archived from STATE.md — 2026-07-31
The ทีม SAMO portrait board + ปีการศึกษา archive (migration 0104, plus 0105/0106). Shipped, applied and deployed; moved out of STATE.md to keep it inside the cold-start context budget. Nothing here is in flight.
SHIPPED EARLIER — ทีม SAMO portraits + ปีการศึกษา (0104) — LIVE
Migration 0104 applied, frontend deployed to the VM, Apps Script deployed @47. Nothing from this work is in flight.
What it is
- Public ทีม SAMO (
/team, inside เกี่ยวกับเรา) opens with a docchula-style คณะกรรมการ grid — large 3:4 portrait cards, 4 across, for the ตำแหน่ง flaggedteam_nodes.is_board(seeded to นายกฯ + the 10 อุปนายกฝ่าย = 11). The full searchable spine tree follows, using the SAME card at ~130px. No circular avatars anywhere — one card shape, two sizes. - ปีการศึกษา switcher. The LIVE tree is always the current term. Past terms are a frozen-but-EDITABLE snapshot in
team_terms+team_archive_nodes+team_archive_members, written bypublish_team_term(year)and read byget_public_team_chart(year).get_public_org_chart()is now a one-line delegate to it, so the live projection has exactly ONE body. Admin surface: a third mode in ทีม SAMO ("ปีการศึกษา",src/js/team/terms.js). - Photo pipeline. Browser downscales to a 2400px WebP master (
src/js/image-resize.js), files it in Drive asSAMO_Team/<ปี>/<ฝ่าย>/<ลำดับ>-<ชื่อ>.webp(GASuploadTeamFile), and renders via lh3 option strings=w<W>-h<H>-c-rw— server-side crop to the card aspect plus WebP. Measured on a live file: 520x693 WebP = 37.6 KB, vs 77.6 KB for the uncropped source a CSS crop would need. Per-shapesrcset, so the 130px tree card never fetches the 250px board card's file.photo_focus(top|center|bottom, CHECK-constrained because it reaches CSS) drops the server crop for the cases where a centre crop would cut the head.
Why Drive/lh3 and not the VM (asked and answered with measurements): lh3 is a real image CDN — it resizes, crops and serves WebP. Self-hosting buys ~100ms of connection setup for an nginx location + a sync tool + a deploy step. Storage at 2400px is ~600 KB/photo → all 401 members ≈ 240 MB/year, 12 years ≈ 2.9 GB of a 2 TB quota. The master size costs NOTHING at render — the browser only ever fetches the derivative. Do not "optimise" this by moving to the VM.
Why the archive is separate from the live tree — do not merge them.team_nodes/team_members feed the permission engine (managed_permissions, VS scopes, project seats, passport scopes) through a statement-level recompute trigger. A term_year column on them would mean a 2565 row still resolving to a live grant for someone who left three years ago. The archive tables carry ONLY the columns the public projection publishes, so there is nothing on an archived row for any resolver to read.
0105/0106 — every year is uniform now (this replaced 0104's confusing special case, where the current year always rendered from the live tree): get_public_team_chart(year) resolves published archive → live tree (current year only, as bootstrap) → empty. So a year becomes real the moment it is published, the public page shows exactly what the admin edits, and every published year — current included — is editable via "แก้ไขรายชื่อ/รูป".
Two consequences to keep in mind:
- Once the current year is published, live-tree edits need a re-publish to appear.
team_term_status()detects this (live max(updated_at) > published_at, current year only) and the pane shows "ผังสดเปลี่ยนแล้ว · ควรเผยแพร่ซ้ำ". Do NOT auto-publish on tree edits — it would overwrite hand-corrected archive rows, which is what the archive exists to preserve. publish_team_termrebuilds the archive wholesale, so 0106 makes it carry a photo forward when the live tree has none: live photo > this year's archived photo > null, keyed onteam_archive_members.src_member_id. Without it the re-publish we ourselves prompt for would delete every portrait uploaded through the archive editor. Names/nicknames/positions are still overwritten — that is what re-publish means.
Proofs (re-run after touching any of this):
node tools/team0104-terms.mjs→ 40/40 — snapshot fidelity (0 orphan parents, depth preserved, board flags carried, re-publish replaces), non-public subtrees excluded, projection allow-list, anon reads 0 rows from all three new tables,teampermission works on writes AND reads, RPC fails closed on a null role, anon cannot execute it.- Regression set, all green:
team0089-manage5/5,proj0086-seats24/24,proj0092-seat-parity13/13,prof0095-seat-parity10/10,vs0083-scope16/16,security-sweepsclean, 195 unit tests.
Verified live (anon, against prod): get_public_team_years → [2569 is_current]; get_public_team_chart(null) → 279 nodes / 401 members / 11 board; member keys exactly name,nickname,node_id,photo_focus,photo_url,position; no @, no student_id, no kkumail anywhere in the payload; anon reads 0 rows from team_members, team_terms, team_archive_nodes, team_archive_members. Page renders 11 board cards + 401 tree cards, year picker correctly hidden (only one year exists so far).
Live state right now (verified anonymously against prod after the last deploy): get_public_team_years → [2569 is_current]; get_public_team_chart → source=live, 279 nodes / 401 members / 11 board / 1 photo; no @, student_id or kkumail in the payload; anon on team_term_status → 401. team_terms holds 2569 (current, unpublished) and 2570 (added during testing, unpublished, not current → correctly invisible publicly). Archive tables hold 0 rows — nothing has been published yet, so the live-tree fallback is what is serving.
Open / next for this feature (none blocking):
- Only 1 of 401 members has a photo, so nearly every card renders initials. That is the designed fallback, not a bug. Upload via ทีม SAMO → edit a member → รูปประจำตัว (downscales + files into Drive automatically). To roll the year over: publish 2569, then set 2570 current. The public year picker only appears once ≥2 years are visible (a year is visible if it is published OR is the current term).
is_boardis seeded to the obvious 11 and otherwise uncurated.- The year picker stays hidden until a SECOND year is visible — a year shows publicly only if it is published or is the current term. There is a 2570 term (added during testing, unpublished, not current) which is therefore correctly invisible.
- Deploys no longer break open tabs.
deploy.sh publish()adds assets additively and prunes after 7 days, so the previous build's chunks keep serving; verified live (the prior bundle still 200s after a deploy).build-check.jsalso re-checks on tab-foreground, gated bypageIsIdle()so it never reloads over an open modal or typed-in field.