ธีม
แลก Rotation — ปี 6 swap their rotation group
Built 2026-10-24 from the faculty's PDF ตาราง Rotation ปี 6 Block 1 ปีการศึกษา 2570 (แจ้งนศ. 24-10-69). This file is the map for whoever works on it next, human or AI (Antigravity, Gemini, Claude). Read it end to end before changing anything; every section names the file that owns the thing.
⚠️
docs/is served publicly atsamo.md.kku.ac.th/docs. Never put a student's name, รหัส or GPA in this file.
1. What it does
- A ปี 6 student signs in with their kkumail and opens
/rotation. - They see who they are: name, nickname (from the person registry
public.people— edited at/me, never copied), รหัส, เพศ, current group (A–F), and fill in a contact (free text: LINE, IG, phone — the cohort sees it) and a GPA (private — see §3). - A "แลกยังไง" guide (4 steps, ticked from their own data) tells a first-time visitor where to go; it can be hidden ("วิธีใช้" brings it back).
- Offers to them sit in a banner at the very top — several at once are listed with "ถ้าตอบรับ คุณย้าย A → C" and that group's whole-year plan, so they can compare before choosing one.
- ฉันอยากอยู่กลุ่มนี้ — a switch: nobody can ask them, they ask nobody, their open asks close. Classmates see an "อยู่กลุ่มนี้" tag.
- ตารางของฉัน — their 6 blocks (dates, courses, Sec.01/02).
- หาคนแลก — "แลกล่าสุด" (the last swaps drawn as two people crossing), the 6 × 6 overview, every group's members with ขอแลก next to each person they may ask, and ขอแลกกับทุกคนที่แลกได้ per group (a broadcast). Each person who moved shows "ย้ายมาจาก B · แลกกับ X"; "เพิ่งย้าย" for 24 h.
- The other person accepts → both move instantly; every open page re-reads, FLASHES the rows that moved and announces the pair. Every other open ask that names either of them is closed automatically.
- ความเคลื่อนไหว — filter chips (ทั้งหมด / แลกสำเร็จ / กำลังขอแลก / เกี่ยวกับฉัน). Declines and cancels are visible only to the two people.
- โหมดทดลอง (button "ลองใช้แบบจำลอง", or
/rotation?practice) — the same page against a pretend cohort in the browser: buttons make pretend classmates ask you, ask all at once, broadcast to your group, beat you to an accept, accept your ask, or swap among themselves. Nothing is written anywhere.
The criteria
| # | Rule | Status |
|---|---|---|
| 1 | Same gender (from the PDF prefix: นาย = M, นางสาว/นาง = F) | ENFORCED |
| 2 | GPA: same band (2.55–2.99 · 3.00–3.49 · 3.50–4.00) or at most 0.20 apart | ENFORCED since 0253 (gpa_rule = 'band_or_close'); no GPA = cannot swap, except by an off-criteria ask |
| — | Not "staying" (the person's own choice, 0252) | ENFORCED beside the rule |
| — | Off-criteria ask (0262): a same-gender pair that fails rule 2 may still swap if both agreed outside the page (with ฝ่ายวิชาการ) | ALLOWED, labelled — see below |
rotation_rounds.gpa_rule: off · same_band (stricter) · band_or_close (the faculty's, 2026-10-24) — the admin panel's เกณฑ์ GPA select. To change the rule, change public.rotation_pair_allowed() in a new migration — it is the home of the criteria; the board, the request guard and the accept guard all call it, and the page reads its answer (can_swap). The words the page shows are criteriaTh() in model.js (tested against the check constraint).
Off-criteria asks (0262) are the one exception, and it is deliberate (owner, 2026-10-26). rotation_request(..., p_off_criteria => true) lets a student ask ONE named, same-gender classmate whom rule 2 refuses. The server decides which kind it is, public.rotation_pair_off_criteria_reason(), which answers null whenever rotation_pair_allowed() already says yes:
| Reason | When | Label everyone sees |
|---|---|---|
diff_02_03 | GPAs more than 0.20 and at most 0.30 apart | เกรดห่าง 0.2–0.3 |
missing_gpa | one or both have no GPA | ยังไม่กรอกเกรด |
diff_over_03 | more than 0.30 apart | >0.3 |
Rules: never a broadcast (cannot_broadcast_off_criteria), at most ONE open off-criteria ask per person (too_many_off_criteria), gender and "staying" still enforced. The swap is recorded as rotation_moves.kind = 'off_criteria' with its reason, and the feed and group lists show the gap label on purpose (owner: "i want to show 0.2-0.3 >0.3 etc it's my intention") — the label tells the cohort roughly how far apart two GPAs are. The ask sheet tells the student to talk to the classmate first and shows their KKU Mail (rotation_contact_email, 0265 — one address at a time, same gender only — about half the cohort never filled in a contact). The Thai for each label is written in several places in page.js; change all of them together (grep diff_02_03).
GPA edits are limited to 3 (0262), and recorded (0261). Every change to a GPA writes a row to rotation_gpa_audit (old, new, who, when). rotation_set_gpa refuses the 4th change (ROTATION_GPA_LOCKED); the FIRST fill counts as 1 of the 3 (owner: keep it). The page shows "(แก้ไขแล้ว n/3 ครั้ง)" from me.gpa_edit_count. The count was reset to 0 for everyone before launch (0264). Why: students were changing their GPA dozens of times a minute to work out classmates' GPAs from who became swappable.
2. Files
| Thing | File |
|---|---|
| Schema, RLS, every RPC | supabase/migrations/0250_rotation_swap.sql (read its header), then 0251 (GPA owner-only, nickname, contact), 0252 (staying), 0253 (the GPA rule), 0254 (named GPA viewers), 0255 (wishes + chains), 0256 (named admins), 0257 (removed misused admin moves), 0258 (the board made cheap after the 2026-10-24 outage), 0259 (kick a chain taker), 0260 (has_gpa on the board), 0261 (GPA audit trail), 0262 (off-criteria asks + the 3-edit GPA limit), 0263 (repaired the board 0262 broke), 0264 (audit reset before launch), 0265 (a classmate's KKU Mail) |
| DB proofs (each rolls back) | tools/rotation0250-swaps.sql (§A–§K), rotation0261-audit.sql, rotation0262-off-criteria.sql, rotation0265-contact-email.sql — node tools/db-query.mjs <file> [--dev]. ⚠️ A proof of a NEW branch is not a proof of the board: 0262's proof was green while the board it rewrote failed for everyone. After touching rotation_board(), run tools/browser/rotation-page.mjs |
| Roster import from the PDF | tools/rotation-import.mjs (dry run by default; --commit) |
| The one PDF parser | src/js/rotation/roster-parse.js |
| The timetable (no people) | src/data/rotation-2570.js |
| Timetable → dates (pure) | src/js/rotation/schedule.js + schedule.test.js |
| Board → what the page draws (pure) | src/js/rotation/model.js + model.test.js |
| Practice mode (a pretend cohort; re-states the rules) | src/js/rotation/practice.js + practice.test.js |
| The page (DOM, RPC calls, realtime) | src/js/rotation/page.js |
| Markup frame | src/html/tab-rotation.html |
| Styles | src/css/rotation.css (all rules under .rot-) |
Route /rotation | src/js/main.js PATH_ROUTES + the dynamic import in shown.bs.tab |
| Hidden nav button | src/html/navbar.html pills-rotation-tab |
| Tools launcher entry | src/data/tools.js slug rotation-swap — under ฝ่ายวิชาการ, and on the home page (home: 8) |
| Browser test (two accounts, real swap) | tools/browser/rotation-page.mjs (samo-dev only) |
3. Data model (0250)
rotation_rounds id 'y6-2570' · published · swaps_open · swaps_close_at · gpa_rule · max_pending
rotation_students one row per student: รหัส, name, gender, original_group, current_group,
kkumail, person_id → people (nickname comes from there), gpa (PRIVATE),
contact (cohort-visible), staying
rotation_requests requester → target (a person) OR target NULL = broadcast to to_group
status: pending · accepted · declined · cancelled · superseded
rotation_moves every change of group (2 rows per swap; 'admin' for a hand move)
rotation_events content-free ping per write — what Realtime streams
rotation_gpa_viewers ACCOUNTS that read every GPA (0254). No policy, no grant; rows by SQL onlySecurity shape — do not break it:
- No student can
selectany rotation table exceptrotation_events(which has no content). All reads go throughrotation_board(p_round), which names its columns.gpais returned only asme.gpa(yourself), or on every student to an account listed inrotation_gpa_viewers(board.gpa_viewer = true). Admins andmasterdo NOT see GPAs (owner: "some people abuse it") — they getround.gpa_filled, a count. It is deliberately not a permission:masterinherits every permission. Add a viewer with one SQL row (never commit the address — the repo is public):insert into public.rotation_gpa_viewers (user_id, note) select id, '…' from public.users where lower(btrim(email)) = '<kkumail>';Adding aselectpolicy onrotation_studentswould publish every GPA. - Identity =
rotation_my_row_id(): the roster row whoseperson_idlinks to the signed-in account, or whosekkumailequals the account's email. Never a name. - Every write takes
pg_advisory_xact_lock(hashtext('rotation:'||round)), so two accepts never interleave. That is what makes "two people accept the same broadcast at once" safe: the second seesstatus = 'accepted'→reason: 'taken'. - Admin =
role = 'dev'or a row inrotation_admins(0256). Not a permission:masteranswers yes to every permission, and a master moved students by hand without asking. Add an admin with one SQL row (never commit the address):insert into public.rotation_admins (user_id, note) select id, '…' from public.users where lower(btrim(email)) = '<kkumail>'; - Admins see the GPA edit HISTORY (
rotation_admin_gpa_audit, 0261: old and new value of every edit) even though they do not see GPAs on the board. The owner decided to keep this (2026-10-30). Today the only admin is also the only GPA viewer; before adding a second admin, decide whether they should see it.
RPCs the page calls
| RPC | Who | Returns |
|---|---|---|
rotation_board(p_round) | anyone signed in | {state:'ok', is_admin, round, me, students[], requests[], moves[]} or {state:'signed_out'│'not_in_roster'│'not_published'│'no_round'} |
rotation_set_gpa(p_round, p_gpa) | roster member | {ok, gpa, gpa_band}; null clears |
rotation_set_contact(p_round, p_contact) | roster member | {ok, contact}; ≤ 200 chars, whitespace collapsed |
rotation_set_staying(p_round, p_staying) | roster member | {ok, staying, declined, cancelled} — ON closes asks naming them (declined) and theirs (cancelled) |
rotation_request(p_round, p_target, p_to_group) | roster member | {ok, id} / {ok:false, reason}; p_target null = broadcast |
rotation_accept(p_request) | the target / an eligible member of the broadcast's group | {ok, my_group, requester_group, closed_other_requests} |
rotation_respond_no(p_request) | requester (= cancel) or named target (= decline) | {ok} |
rotation_request(..., p_off_criteria => true) | roster member | as above; one named target only (§1 off-criteria) |
rotation_contact_email(p_round, p_student) | same-gender classmate, or admin | {ok, kkumail} / {ok:false, reason} |
rotation_set_wishes · rotation_chain_propose / _respond / _cancel / _kick | roster member | chains: docs/ROTATION-CHAINS.md |
rotation_admin_update_round(...) | admin | {ok} |
rotation_admin_gpa_audit(p_round) | admin | every GPA edit (old → new, when) |
rotation_admin_move(p_student, p_group) | admin | {ok} — no button since 2026-10-26 (owner: no hand moves); still callable by SQL |
Every reason has Thai in model.js REASON_TH; model.test.js fails if the migration grows a reason with no translation.
4. Edge cases, and what happens
| Case | Behaviour |
|---|---|
| Two people accept the same broadcast at the same moment | Serialised by the round lock; the first swaps, the second gets มีคนตอบรับคำขอนี้ไปก่อนแล้ว |
| A has asks out to B and C; B accepts | A and B swap; A's ask to C (and any ask naming A or B) → superseded |
| Someone moved since the ask was made | Accept returns stale and the ask is closed |
| Ask to a different gender / own group / duplicate | Refused: not_eligible / same_group / duplicate |
| Spamming asks | At most max_pending (15) open asks per person |
| Someone chose "อยู่กลุ่มนี้" | Asks naming them → declined; theirs → cancelled; new asks either way refused (staying / you_staying); a broadcast does not count them |
| The GPA rule is switched on with asks already open | Each is re-checked at accept; a pair that no longer qualifies gets not_eligible |
| A student has no GPA while the rule is on | Nobody may ask them and they may ask nobody; the page tells them to fill it in |
Round closed (switch off, or swaps_close_at passed) | Every write returns closed; the page shows "ดูได้อย่างเดียว" |
| Student never signed in | Still listed (badge ยังไม่เคยเข้าเว็บ); can be asked; sees it when they sign in |
| Realtime socket dies (backgrounded phone, stale token) | Page re-reads every 60 s while visible and on returning to the tab |
| A board read FAILS (server busy, network down) | The last board stays on screen with "เชื่อมต่อไม่ได้ชั่วคราว ข้อมูลอาจไม่ล่าสุด"; retries wait 3 s, 6 s, 12 s… up to 60 s, with jitter (retryDelayMs). Never a fixed short timer: 161 pages retrying every 2–4 s is what kept the 2026-10-24 outage going |
| Off-criteria ask while one is already open | Refused: too_many_off_criteria |
| A 4th GPA change | Refused: ROTATION_GPA_LOCKED |
| Re-importing the PDF | Updates names/kkumail; never resets current_group or gpa |
Every swap, and every chain (up to 3 people, docs/ROTATION-CHAINS.md), is a cycle, so group sizes never change.
5. Going live — done
Opened by the owner on 2026-10-24 (round y6-2570: published, swaps open). The launcher entry is visible under ฝ่ายวิชาการ and on the home page, and the home banner links to it. Students have used it on phones and iPads since.
6. Extending it
- GPA rule: §1. One function, one migration, re-run the proof (§2) — §D of the proof already covers
same_band; add a case for whatever new rule you write. - Next year's round: new
src/data/rotation-25xx.js(same shape; the tests inschedule.test.jscheck it), insert arotation_roundsrow in a migration,node tools/rotation-import.mjs <pdf> --round <id> --commit, and pointpage.js'sROUNDimport at the new file. - Notifications (Discord/email when someone asks you): not built. If added, do it server-side from
rotation_requests, and remember the request list is already public within the cohort, so a notification may say who asked. - Admin bulk tools (export the final groups as CSV): query
rotation_studentsfrom atools/script.rotation_board()does NOT carry GPAs for admins (0254) — only for accounts inrotation_gpa_viewers.
Working rules for an AI editor in this repo
- Run
npm run build && npm testbefore every commit.npm testincludes guards that will fail on a raw colour in CSS (runnode tools/theme-tokens-codemod.mjs --write), ayear:key, an undeclared global, or STATE.md drift. - A schema change is a new numbered migration; never edit 0250 after it is applied. Apply with
node tools/apply-migration.mjs <file> --devfirst, run the proof with--dev, then production. npm run devuses samo-dev, not production.tools/browser/rotation-page.mjsdrives a real swap there (and the stay switch, the iPad layout and practice mode) and cleans up after itself.- Practice mode re-states the swap rules in JS (
practice.js). If you change a rule in SQL, change it there and inpractice.test.jsin the same commit. - The left column must never scroll inside itself — on iPad that hid "คำขอ ของคุณ" below a fold (owner's screenshot). It scrolls with the page.
- The repo is public: the PDF and the roster never go into git.
Chain swaps
E → A → B in one atomic step: docs/ROTATION-CHAINS.md (0255; RPCs rotation_set_wishes, rotation_chain_propose, rotation_chain_respond, rotation_chain_cancel, rotation_chain_kick (0259); proof §J).