Skip to content

แลก 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 at samo.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 ​

#RuleStatus
1Same gender (from the PDF prefix: นาย = M, นางสาว/นาง = F)ENFORCED
2GPA: same band (2.55–2.99 · 3.00–3.49 · 3.50–4.00) or at most 0.20 apartENFORCED 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:

ReasonWhenLabel everyone sees
diff_02_03GPAs more than 0.20 and at most 0.30 apartเกรดห่าง 0.2–0.3
missing_gpaone or both have no GPAยังไม่กรอกเกรด
diff_over_03more 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 ​

ThingFile
Schema, RLS, every RPCsupabase/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 PDFtools/rotation-import.mjs (dry run by default; --commit)
The one PDF parsersrc/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 framesrc/html/tab-rotation.html
Stylessrc/css/rotation.css (all rules under .rot-)
Route /rotationsrc/js/main.js PATH_ROUTES + the dynamic import in shown.bs.tab
Hidden nav buttonsrc/html/navbar.html pills-rotation-tab
Tools launcher entrysrc/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 only

Security shape — do not break it:

  • No student can select any rotation table except rotation_events (which has no content). All reads go through rotation_board(p_round), which names its columns. gpa is returned only as me.gpa (yourself), or on every student to an account listed in rotation_gpa_viewers (board.gpa_viewer = true). Admins and master do NOT see GPAs (owner: "some people abuse it") — they get round.gpa_filled, a count. It is deliberately not a permission: master inherits 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 a select policy on rotation_students would publish every GPA.
  • Identity = rotation_my_row_id(): the roster row whose person_id links to the signed-in account, or whose kkumail equals 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 sees status = 'accepted' → reason: 'taken'.
  • Admin = role = 'dev' or a row in rotation_admins (0256). Not a permission: master answers 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 ​

RPCWhoReturns
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 memberas 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 / _kickroster memberchains: docs/ROTATION-CHAINS.md
rotation_admin_update_round(...)admin{ok}
rotation_admin_gpa_audit(p_round)adminevery 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 ​

CaseBehaviour
Two people accept the same broadcast at the same momentSerialised by the round lock; the first swaps, the second gets มีคนตอบรับคำขอนี้ไปก่อนแล้ว
A has asks out to B and C; B acceptsA and B swap; A's ask to C (and any ask naming A or B) → superseded
Someone moved since the ask was madeAccept returns stale and the ask is closed
Ask to a different gender / own group / duplicateRefused: not_eligible / same_group / duplicate
Spamming asksAt 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 openEach is re-checked at accept; a pair that no longer qualifies gets not_eligible
A student has no GPA while the rule is onNobody 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 inStill 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 openRefused: too_many_off_criteria
A 4th GPA changeRefused: ROTATION_GPA_LOCKED
Re-importing the PDFUpdates 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 in schedule.test.js check it), insert a rotation_rounds row in a migration, node tools/rotation-import.mjs <pdf> --round <id> --commit, and point page.js's ROUND import 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_students from a tools/ script. rotation_board() does NOT carry GPAs for admins (0254) — only for accounts in rotation_gpa_viewers.

Working rules for an AI editor in this repo ​

  • Run npm run build && npm test before every commit. npm test includes guards that will fail on a raw colour in CSS (run node tools/theme-tokens-codemod.mjs --write), a year: 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> --dev first, run the proof with --dev, then production.
  • npm run dev uses samo-dev, not production. tools/browser/rotation-page.mjs drives 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 in practice.test.js in 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).

Working docs. STATE.md is the status file and lives at the repo root, not here.