Spec — Who a link was issued to, recorded on what it changes¶
An operator who issues a desk link or a scorer link can say who it is for and add a short note, both optional. Whatever that link then changes in a facility workbook carries that label:
- Attendance: a
markedBycolumn in theATTENDANCEtab records who last marked or unmarked each person, what they did and when:In · Ana (Gate A) · 14:32. - Scores: both score cells of a match get a Google Sheets note (the
corner triangle) listing the last five score-entry edits, newest first:
11–7 · Ana (Courts 3–4) · Oct 8 14:32 · was 9–11.
Operator writes from Control Center are labelled Control Center.
Status: implemented. Built in
sage-tools-api3.1.0, Control Center, the desk and scorer pages (lib/v1) and the docs, and merged intomainon 2026-10-09. Written 2026-10-08 againstsage-tools-api3.0.1. It is tested against fakes; the real-Google checks in §10.2 (L1–L7) have not run, so the first event to issue a labelled link is also its first real-workbook use. Divergence: in fixture mode a desk link works like a scorer link (§5.3 gives it only a real payload): it opens the localattendance.htmlwith the same&fixture=and lasts to the end of the event's day in Manila, or of today once that day has passed, so §6 step 3 can follow it. Note: the label shows on desk pages made from_templates/attendance/; the three desk pages of Pickle for Sight, PickleDrive and Piggleball are older inline copies, frozen with their events, and don't show it. The workbook recordsmarkedByeither way.Builds on Score entry and scorer links and Attendance for every event. Read their §0 if a term here is unfamiliar.
0. Read this first¶
You are implementing this with no other context. Everything you need is in this page and the files it names. Read §0 to §3 in full before writing code, then do §4 to §7 in order. §10 is what to report when you finish.
0.1 The system in one paragraph¶
SAGE runs pickleball tournaments. Each event has days, each day has
facilities (venues), and every facility-day has its own Google Sheets
workbook. sage-tools-api (Node/Express on Cloud Run) writes to those workbooks
as a Google service account in exactly two places: a person's attendance in the
ATTENDANCE tab, and a match's two score cells in SCHEDULE. Operators sign in to
Control Center (one HTML file) with one shared login, so the API cannot tell
operators apart. Staff who are not operators use links: a desk link opens the
event's attendance desk page and can only mark attendance for one day; a scorer
link opens the event's scorer page and can only enter scores for one day. A link is
a stateless HMAC-signed token (<base64url JSON payload>.<signature>). The API keeps
no record of issued links, so anything a link should carry must be in its payload.
0.2 Where things are¶
D:\Personal\SAGE is a plain folder holding four git repos:
| Path | What you change there |
|---|---|
sage-tools-api/ |
§4: tokens, the two issue routes, attendance's markedBy, score notes, SheetsClient, tests, version, Changelog |
sage-match-control.github.io/ |
§5: tools/control-center.html (the two issue forms), lib/v1/data/tokens.js, lib/v1/apps/attendance-desk.js, lib/v1/apps/scorer.js, _tests/ |
sage-docs/ |
§7: feature, technical and usage pages |
D:\Personal\SAGE\CLAUDE.md |
§7: two sentences (not in any repo) |
event-data/ and every apps-script/*.gs file are not touched (§8).
0.3 Hard rules¶
- Leave every change uncommitted. No
git add,commit,push, branch, switch or stash. The owner reviews and commits. - Never write to a real workbook, never call the production API, and never
touch the real
events.json. Tests use fakes. Real-Google checks are the owner's (§10.2). - Write each test first and watch it fail, then make it pass
(
sage-tools-api/CLAUDE.mdrule). Never weaken or delete a test. The existing assertions that change on purpose are listed in §4.11; nothing else existing should. sage-tools-api: ESM.mjs, classes with constructor injection, no new dependency, no TypeScript. 4-space indent, double quotes, comments that say why. Obey the dependency rules inD:\Personal\SAGE\CLAUDE.md(enforced bytest/unit/guards/dependency-rules.test.mjs).- Site: no new dependency, 2-space indent, single quotes, match the surrounding
style.
lib/v1changes must be compatible (_templates/CLAUDE.md§8): add, never remove or rename an export; and no module may start importing a new export of another module in this change. A browser can hold a 10-minute-old copy of one file beside a new copy of another. A new import of a missing export kills the page. §5 is written so nothing needs a new export. - Find functions by name (
grep -n "function name("), not by line number. - Line endings: keep whatever each file has today (the files named here are LF).
- Cite this spec from code comments as
sage-docs/docs/specs/.../link-attribution-spec.md, with literally.... - Documentation is present tense: what the system does, not what changed.
- If the code contradicts this spec in a way that blocks you, stop and report it (§10). Don't work around it.
0.4 Facts checked against the code (2026-10-08)¶
sage-tools-api
| Fact | Where |
|---|---|
Desk token payload { exp, scope: "attendance-desk", day }, scorer token { exp, scope: "score-desk", day }, each base64url of UTF-8 JSON, signed by #signScoped(scope, payload). verifyDeskToken/verifyScorerToken return { day, exp } or null |
src/auth/AuthService.mjs |
createAuthMiddleware's requireOperatorOr(kind) sets req.actor = { kind, day } for a scoped token, requireOperator/isOperator set { kind: "operator" } |
src/auth/middleware.mjs |
Token port typedefs: TokenVerifier, DeskTokenIssuer, ScorerTokenIssuer |
src/shared/ports.mjs |
/v1 and /v3 desk-link routes share handlers.deskLink, which calls attendanceService.issueDeskLink(req.params.day) and answers 201 { token, expiresAt, day }. Same for scorer links (handlers.scorerLink, scoreService.issueScorerLink). Neither route reads a body today |
src/attendance/routes.mjs, src/scores/routes.mjs |
The pattern for a /v3 route with an optional JSON body is requireJson({ optional: true }) before the handler, a separate …V3 handler, and a 415 in the @openapi block. See /v3/days/{day}/attendance/reconciliations (reconcileV3) |
src/attendance/routes.mjs, src/server/requireJson.mjs |
The ScopedToken OpenAPI schema is { token, expiresAt, day } |
src/docs/openapiSpec.mjs |
AttendanceService.mark({ day, facilityName, key, present, actor }) reads ATTENDANCE!A1:G, parseTabs it, planMark(row, present, now) → { changed, present, timeIn }, and when changed writes one updateValues range ATTENDANCE!E{r}:F{r} |
src/attendance/AttendanceService.mjs, src/attendance/domain/attendanceTab.mjs |
parseTab throws AttendanceLayoutError unless row 1 is exactly the 7 HEADERS; a test pins that an extra 8th header throws (attendanceTab.test.mjs, "early-extra"). That stays (§1 D8) |
same, and test/unit/attendance/domain/attendanceTab.test.mjs |
formatTimeIn(date) gives yyyy-MM-dd HH:mm in Asia/Manila through an Intl.DateTimeFormat with hourCycle: "h23" |
attendanceTab.mjs |
Column H onward belongs to people: an organizer may keep their own columns there (the desk page shows an optional TShirt Size column wherever it is). Column J holds the "Not yet in" FILTER formula (J1 header, J2 formula spilling down) in every tab the API or a generator creates |
SheetsClient.mjs comment above NOT_YET_IN_RANGE; lib/v1/domain/attendance.js reads columns by header name |
SheetsClient.updateValues refuses any range not matching WRITABLE_RANGE (ATTENDANCE! A–G). writeScores/clearScores refuse any range failing assertScoreRange (one row ≥ 6, team-1 score column 9 + 8k and the next). Reads use the API key; writes the service account's token through #send(url, init, { write }); #assertOk turns a write 403 into "Share it with … as Editor" |
src/clients/SheetsClient.mjs |
ScoreService.submit reads SCHEDULE, finds the match, checks expected, writes or clears the two cells (only when not unchanged), calls syncService.syncDay, logs one line score <day>/<facility> #<n> <c1>:<c2> <prev> -> <new> by=<kind> sync=<ok|failed>, and returns the 200 body |
src/scores/ScoreService.mjs |
at.scoreRange is SCHEDULE!<c1><row>:<c2><row>; previous is { team1Score, team2Score }, each an integer or null |
same |
FakeSheets stands in for SheetsClient in unit and integration tests, recording reads, writes, scoreWrites, scoreClears |
test/helpers/fakeSheets.mjs |
ScoreService.test.mjs pins the log line for operators (… by=operator sync=ok) and a scorer (/ by=scorer sync=ok$/) |
test/unit/scores/ScoreService.test.mjs |
| Version 3.0.1 | package.json |
The Sheets API (Google's v4 reference)
| Fact |
|---|
GET https://sheets.googleapis.com/v4/spreadsheets/{id}?ranges=<A1>&fields=sheets(properties(sheetId),data(rowData(values(note))))&key=<API key> returns { sheets: [ { properties: { sheetId }, data: [ { rowData: [ { values: [ { note? }, … ] } ] } ] } ] } for just the range's tab. Empty parts are omitted, not empty: no note key on a cell without one, no values or rowData when nothing in the range has a note |
POST https://sheets.googleapis.com/v4/spreadsheets/{id}:batchUpdate with { requests: [ { repeatCell: { range: { sheetId, startRowIndex, endRowIndex, startColumnIndex, endColumnIndex }, cell: { note: "<text>" }, fields: "note" } } ] } sets that note on every cell of the range and changes nothing else. Indexes are 0-based, end-exclusive. sheetId here is the tab's numeric ID (its gid), not the spreadsheet ID |
| A note is plain text. API writes fire no onEdit trigger, and the sync reads CSV, which carries no notes: a note never reaches a snapshot |
The site
| Fact | Where |
|---|---|
tokens.js decodes a payload with JSON.parse(atob(…)). atob yields one character per byte, so any non-ASCII name ("Niño") decodes as mojibake today |
lib/v1/data/tokens.js, decodePayload |
Control Center issues links in issueScorerLink() and attConsoleIssueDeskLink(), POSTing with only an Authorization header; renders them in renderScorerLink(day, issued) and attConsoleRenderDeskLink(day, issued), whose result box has a .result-title div. In fixture mode it fakes the token: the scorer one with btoa(JSON.stringify(…)), the desk one as the literal 'fixture.desk-token' |
tools/control-center.html |
Markup: #scorerLinkBtn + #scorerLinkResult in Mission Control's #scorerLinksSection; #attendanceDeskBtn + #attendanceDeskResult in the Attendance tab's .attendance-actions. Text inputs use class="organizer-text-input" (see #orgUsernameInput) |
same |
The desk page sets #dayLabel once to ` · ${dayEntry.label || info.day}`. The scorer page sets dayLabelEl.textContent = · ${label}+ (facility ? · ${facility}: '') whenever the facility changes |
lib/v1/apps/attendance-desk.js, lib/v1/apps/scorer.js |
1. Decisions (settled with the owner, 2026-10-08)¶
| # | Decision |
|---|---|
| D1 | Issuing a desk or scorer link takes two optional fields: Issued to (issuedTo) and Note (note). Each is at most 40 characters after trimming. Only the /v3 issue routes accept them; /v1 is frozen and ignores any body |
| D2 | Both travel inside the signed token (payload keys to and note, present only when given). No server-side record of links. A link issued without them has a byte-identical payload to today's; links issued before this change keep working, unlabelled |
| D3 | The label of a write: operator → Control Center. Desk or scorer link → Ana (Gate A) with both, Ana with only a name, Desk link (Gate A) / Scorer link (Courts 3–4) with only a note, Desk link / Scorer link with neither. No operator name field: every operator write reads Control Center |
| D4 | It is attribution, not authentication: the label says who the link was issued to, not who typed. Anyone holding a forwarded link writes under its label. Docs say so |
| D5 | Attendance: a markedBy column holds the last change to that person: In · <label> · HH:mm or Out · <label> · HH:mm (Manila, 24-hour). Written in the same updateValues call as present/timeIn, only when the mark changes something (an unchanged mark writes nothing, as today) |
| D6 | Where markedBy lives: the column whose row-1 header is exactly markedBy, in H–Z except J. If none, the API claims the first column from H to Z, skipping J, whose row 1 and every data row are blank, and writes the markedBy header into it in the same call. If no column qualifies, the mark is written without markedBy and a warning is logged. It never overwrites a person's own column |
| D7 | The generators and apps-script/ are not touched. A new workbook's ATTENDANCE tab has no markedBy header until the first mark claims one. HEADERS stays the 7 columns A–G |
| D8 | parseTab stays strict on A–G. mark reads ATTENDANCE!A1:Z and gives parseTab only the first 7 cells of each row |
| D9 | Scores: after a save that changed the cells (not unchanged), the API puts the same note on both score cells. The note is score entry's last five lines, newest first, followed by any other text that was in either cell's note (a person's own note is kept, never trimmed) |
| D10 | One line: <t1>–<t2> · <label> · <Mon D HH:mm> plus · was <p1>–<p2> when the cells weren't both empty before; a clear is cleared · <label> · <Mon D HH:mm> · was <p1>–<p2>. A missing side of a half-filled previous score prints ?. En dash –, middle dot ·, Manila time |
| D11 | The note is best effort. It is written after the score write and the publish, before responding. If reading or writing the note fails, the save still succeeds: the failure is logged as a warning and the response is unchanged. The score is what matters |
| D12 | No response body changes on any route except the two /v3 issue routes, which echo issuedTo and note (null when not given). The mark and score responses, and every /v1 body, stay exactly as they are |
| D13 | The desk and scorer pages show the label after the day: · Day 1 · Ana (Gate A). Nothing when the link has neither field |
| D14 | Control Center's two issue buttons each get two optional text inputs above them, cleared after a successful issue so the next link isn't mislabelled. The result box's title carries the label |
2. Overview¶
Control Center ──POST /v3/days/{day}/…/desk-links { issuedTo?, note? }──▶ API
◀── 201 { token, expiresAt, day, issuedTo, note }
token payload: { exp, scope, day, to?, note? } (signed: can't be edited)
desk page ──PUT …/attendance (Bearer token)──▶ middleware: req.actor = { kind:"desk", day, issuedTo, note }
AttendanceService.mark
one updateValues: E:F + markedBy cell (+ header)
scorer page──PUT …/score (Bearer token)──▶ ScoreService.submit
write/clear cells → syncDay → note on both cells (best effort)
3. The label and the two texts (pure functions)¶
3.1 src/shared/issuedLink.mjs (new)¶
shared/ because both features' routes and services use it, and the dependency
rules let anything import shared/*. Imports only ./errors.mjs.
export const ISSUED_FIELD_MAX = 40;
// { issuedTo, note } from an issue request's body. Each is optional; a string is
// trimmed, inner whitespace (newlines, tabs included) collapsed to one space, and
// control characters (\p{Cc}) removed; "" after that means absent (null).
// Throws ValidationError: "issuedTo must be text" / "note must be text" for a
// non-string non-null value; "issuedTo must be at most 40 characters" (or note) when
// longer after cleaning. Length is counted in code points ([...s].length).
// body may be undefined (no body sent) -> { issuedTo: null, note: null }.
export function parseIssuedLink(body) { … }
// The label of whoever made a write (§1 D3).
// actor: { kind: "operator" } | { kind: "desk" | "scorer", day, issuedTo?, note? } | undefined
// undefined or operator -> "Control Center".
export function actorLabel(actor) { … }
actorLabel table (the unit test asserts every row):
| actor | label |
|---|---|
undefined, { kind: "operator" } |
Control Center |
| desk, both | Ana (Gate A) |
desk, issuedTo only |
Ana |
desk, note only |
Desk link (Gate A) |
desk, neither (nulls or keys absent) |
Desk link |
| scorer, neither / note only | Scorer link / Scorer link (Courts 3–4) |
3.2 markedBy (in src/attendance/domain/attendanceTab.mjs)¶
export const MARKED_BY_HEADER = "markedBy";
// "In · Ana (Gate A) · 14:32" / "Out · Control Center · 09:05". Manila, 24-hour.
export function formatMarkedBy(present, label, now) { … }
// Where markedBy goes, given what readValues("ATTENDANCE!A1:Z") returned.
// -> { column: "H", header: false } an existing markedBy header at H
// { column: "K", header: true } claim K: write the header too
// null nowhere to put it (§1 D6)
// Candidates in order: H, I, K, L, … Z (J never). An existing header wins over a
// blank column. A column qualifies for claiming when every row's cell in it is blank
// ("" / undefined / null), row 1 included.
export function markedByColumn(values) { … }
Reuse the Manila formatter formatTimeIn already uses; formatMarkedBy's time is
the HH:mm part.
3.3 src/scores/domain/scoreNote.mjs (new)¶
export const NOTE_LINES_KEPT = 5;
// One line (§1 D10). scores and previous are { team1Score, team2Score }, each an
// integer or null; scores both null means a clear.
// "11–7 · Ana (Courts 3–4) · Oct 8 14:32 · was 9–11"
// "11–7 · Control Center · Oct 8 14:32" (previous both null)
// "cleared · Scorer link · Oct 8 14:40 · was 11–7"
// "11–7 · Ana · Oct 8 14:32 · was 9–?" (half-filled previous)
// Date: en-US short month, day without zero, then HH:mm (h23), Asia/Manila.
export function noteLine({ scores, previous, label, now }) { … }
// True for a line noteLine wrote. Anchored: starts with "<d>–<d> · " or
// "cleared · ", and contains " · <Mon> <D> <HH:mm>" after that.
export function isScoreEntryLine(line) { … }
// The note to write on both cells (§1 D9): line, then up to NOTE_LINES_KEPT - 1 of
// the score-entry lines from existing[0] (team 1 cell's note), then every other
// non-empty line from existing[0] and existing[1] in order, each distinct line once.
// existing: [string, string], "" for no note. Lines split on \n; trailing \r trimmed.
export function buildNote(line, existing) { … }
Score-entry lines are read from team 1's note only (both cells always get the
same text from the API, so team 2's adds nothing but a person's own lines). Domain
rules: no imports at all (Intl is a global, not an import).
4. sage-tools-api¶
4.1 Tokens (src/auth/AuthService.mjs, src/shared/ports.mjs)¶
issueDeskToken({ day, expiresAt, issuedTo = null, note = null })andissueScorerToken(…)the same: the payload is{ exp, scope, day }plusto: issuedToonly when it is a non-empty string andnoteonly when it is. Key order:exp, scope, day, to, note. With neither, the JSON is exactly today's.verifyDeskToken/verifyScorerTokenreturn{ day, exp, issuedTo, note }: eachparsed.to/parsed.notewhen it is a non-empty string, elsenull. Nothing else about verification changes.ports.mjs: the three typedefs gain the optional fields and the new return shape.
4.2 Middleware (src/auth/middleware.mjs)¶
In requireOperatorOr, set
req.actor = { kind, day: scoped.day, issuedTo: scoped.issuedTo ?? null, note: scoped.note ?? null }.
Operators stay { kind: "operator" }.
4.3 The issue routes¶
src/attendance/routes.mjs: the /v3 desk-links route becomes
.post(auth.requireOperator, requireJson({ optional: true }), asyncHandler(handlers.deskLinkV3)).
deskLinkV3 calls attendanceService.issueDeskLink(day, parseIssuedLink(req.body)).
The /v1 route keeps handlers.deskLink, which keeps calling
issueDeskLink(req.params.day) with no second argument, so /v1 answers exactly as
today (its body, if any, is ignored).
src/scores/routes.mjs: the same with scorerLinkV3 and issueScorerLink.
Responses. The services return { token, expiresAt, day, issuedTo, note } (null
for absent). The /v1 handlers must still answer exactly { token, expiresAt, day }:
have them pick those three keys from the service's result.
OpenAPI. Both /v3 @openapi blocks gain:
requestBody:
required: false
content:
application/json:
schema:
type: object
properties:
issuedTo: { type: string, maxLength: 40, description: Who the link is for. Shown on the page it opens and recorded on every change it makes. }
note: { type: string, maxLength: 40, description: A short note, e.g. a gate or courts. Recorded beside issuedTo. }
example: { issuedTo: Ana, note: Gate A }
and a "415" response like reconciliations'. The 400 description adds "issuedTo or
note is not text or longer than 40 characters". Add a schema IssuedScopedToken
(the ScopedToken properties plus issuedTo and note, each
{ type: string, nullable: true }) and point the two /v3 201s at it.
ScopedToken stays as it is (the /v1 blocks may reference it).
4.4 The services' issue methods¶
AttendanceService.issueDeskLink(day, { issuedTo = null, note = null } = {}) passes both
to issueDeskToken and returns them. ScoreService.issueScorerLink the same; its log
line becomes
scorer link issued <day> until <iso> plus for "<label>" when either field is
given (label per §3.1, as a scorer actor).
4.5 SheetsClient (src/clients/SheetsClient.mjs)¶
Attendance allowlist. Add
// The one ATTENDANCE cell outside A:G the API writes: a person's markedBy (or its
// header), in H–Z but never J, which holds the "Not yet in" formula. A single cell
// only. See sage-docs/docs/specs/.../link-attribution-spec.md §1 D6.
const MARKED_BY_CELL = /^ATTENDANCE!([HIK-Z])([1-9]\d*)$/;
updateValues accepts a range matching WRITABLE_RANGE or MARKED_BY_CELL.
Update the comments that say the API never writes past G.
Score notes. Two methods, both behind assertScoreRange(range):
// -> { gid, notes: [team1Note, team2Note] } ("" for no note). A read: API key.
async readScoreNotes(sheetId, range)
// Sets the same note on both cells of a score range; nothing else changes.
async writeScoreNote(sheetId, range, gid, note)
readScoreNotes: theGETin §0.4 withranges=<range>(URL-encoded) and thefieldsmask.gid=sheets[0].properties.sheetId; notes fromsheets[0].data[0].rowData[0].values[0|1].note, any missing part →"". Errors asreadValuesdoes through#send/#assertOk(write: false).writeScoreNote: therepeatCellPOSTin §0.4. Parse the range withSCORE_RANGE:startRowIndex = row - 1,endRowIndex = row,startColumnIndex = columnNumber(c1) - 1,endColumnIndex = columnNumber(c2).typeof note === "string"or throw.write: true, so the 403 message applies.
4.6 AttendanceService.mark¶
- Read
ATTENDANCE!A1:Zinstead ofA1:G. parseTab(values.map(r => (r ?? []).slice(0, 7))). Everything that follows about rows is unchanged.- When
plan.changed: where = markedByColumn(values)- data =
[{ range: E{r}:F{r}, … }]as today, plus, whenwhere:{ range: "ATTENDANCE!<col><r>", values: [[formatMarkedBy(plan.present, actorLabel(actor), this.now())]] }, plus, whenwhere.header:{ range: "ATTENDANCE!<col>1", values: [[MARKED_BY_HEADER]] }. - One
updateValuescall with all of them. - When
whereisnull:this.logger.warn(\attendance ${day}/${facilityName}: no free column for markedBy (H–Z, not J)`)`. - The return value is unchanged.
reconcileDay and everything else in the service keep reading A1:G.
4.7 ScoreService.submit¶
After the syncDay block and the log line, before returning, when !unchanged:
await this.#annotate({ sheetId, range: at.scoreRange, scores: { team1Score, team2Score }, previous, actor, where: `${day}/${facilityName} #${n}` });
#annotate does readScoreNotes, noteLine, buildNote, writeScoreNote, inside one
try/catch. On error: this.logger.warn(\score note ${where} not written: ${err?.message ?? err}`)`.
It never throws.
The log line. by=<kind> gains the label in brackets only for a link with a
name or note: by=scorer[Ana (Courts 3–4)]. Operators and unlabelled links log
exactly as today, so the two pinned log tests still pass unchanged.
4.8 Ports and wiring¶
ports.mjs:AttendanceSheetsis unchanged (stillupdateValues). The Sheets port the score service is typed against gainsreadScoreNotesandwriteScoreNote.src/app.mjs: nothing new is constructed, so no change is expected. Check it.
4.9 Test helpers¶
test/helpers/fakeSheets.mjs:
this.notes = {}keyed`${sheetId}!${cellA1}`(e.g."S1!AG14"),this.noteWrites = []({ sheetId, range, gid, note }),this.noteFailOn = new Set()(sheetIds whose note calls throw, to test D11).readScoreNotes(sheetId, range)→{ gid: 1234, notes: [notes[c1] ?? "", notes[c2] ?? ""] }.writeScoreNote(sheetId, range, gid, note)records, then sets both cells' notes.updateValuesalready writes any column into theATTENDANCEarray. Check that it does for H–Z;readValuesalready handlesA1:Z.
4.10 New tests (write each first; watch it fail)¶
| File | Cases |
|---|---|
test/unit/shared/issuedLink.test.mjs (new) |
parseIssuedLink: no body; {}; both given; trimming and collapsing (" Ana \n Cruz " → "Ana Cruz"); control chars removed; "" and " " → null; 40 code points OK, 41 → ValidationError naming the field; a 40-character name with an emoji counts code points; non-string (5, {}, []) → ValidationError; null → null. actorLabel: every row of §3.1's table |
test/unit/auth/AuthService.test.mjs |
desk and scorer: a token issued without the fields has exactly today's payload JSON; with both, the payload has to and note and verify returns them; with one, the other is null; a non-ASCII name ("Niño") round-trips; a token with a tampered to fails verification; an old-shape token verifies with issuedTo: null, note: null |
test/unit/auth/middleware.test.mjs |
requireOperatorOr("desk") and ("scorer") put issuedTo and note on req.actor; nulls for an unlabelled token |
test/unit/attendance/domain/attendanceTab.test.mjs |
formatMarkedBy In/Out with a fixed Manila time (pick a UTC instant that is a different date in UTC than in Manila). markedByColumn: existing header at H; existing header at L with H blank (existing wins); none → H when H is blank everywhere; H has a header (TShirt Size) → I; H and I taken → K (never J); a column with a blank header but a data cell → skipped; everything H–Z taken → null; values with ragged short rows |
test/unit/scores/domain/scoreNote.test.mjs (new) |
noteLine: each example in §3.3, plus a 0 score (0–11, not treated as empty). isScoreEntryLine: true for each noteLine output, false for "Ask Ref Joy" and "11-7 by hand". buildNote: empty existing; 4 old lines → 5; 5 old lines → newest 5 (oldest dropped); a person's own line in team 1's note kept after score-entry lines; own lines in both notes kept, a duplicate once; \r\n notes |
test/unit/clients/SheetsClient.test.mjs |
updateValues accepts ATTENDANCE!H5, ATTENDANCE!K1, ATTENDANCE!Z9; refuses ATTENDANCE!J5, ATTENDANCE!H5:H6, ATTENDANCE!AA5, SCHEDULE!H5, before any fetch. readScoreNotes: the URL (path, ranges, fields, key), no Authorization; both notes; a response with no data/rowData/values/note → ""s; refuses a non-score range before fetching. writeScoreNote: the URL, Authorization, the exact body for SCHEDULE!AG14:AH14 (startRowIndex 13, endRowIndex 14, startColumnIndex 32, endColumnIndex 34); a 403 gives the share-with message; refuses a non-score range and a non-string note |
test/unit/attendance/AttendanceService.test.mjs |
a desk mark with a label writes E:F and the markedBy cell in one updateValues; on a tab with no markedBy, also the header, at H; on a tab whose H is TShirt Size, at I; on a second mark, no header write; unmark writes Out · …; an operator mark writes Control Center; an unchanged mark writes nothing; no free column → E:F only, and a warning; the mark's read is ATTENDANCE!A1:Z; a tab with a person's own H column still parses (no AttendanceLayoutError); issueDeskLink(day, { issuedTo, note }) returns them and passes them to the issuer |
test/unit/scores/ScoreService.test.mjs |
a save writes the same note on both cells, after the score write; a correction's note lists the new line first and keeps the old; a clear writes a cleared · … · was … line; an unchanged save reads and writes no note; a note failure (noteFailOn) still returns the normal 200 body with sync.ok, logs a warning; the scorer log line with a labelled actor ends by=scorer[Ana (Courts 3–4)] sync=ok; issueScorerLink(day, { issuedTo, note }) returns them, its log line says for "Ana (Courts 3–4)" |
test/integration/attendance-routes.test.mjs |
POST /v3/…/desk-links with { issuedTo, note } → 201 echoing them; with no body → issuedTo: null, note: null; a 41-character issuedTo → 400 problem details; a text/plain body → 415; POST /v1/…/desk-links with the same body → exactly { token, expiresAt, day } and a token whose payload has no to; a PUT with the labelled token writes markedBy |
test/integration/score-routes.test.mjs |
the same four issue cases for scorer links; a PUT with the labelled scorer token leaves a note on both cells |
4.11 Existing tests that change on purpose¶
- Any
AttendanceServicetest asserting the mark's read rangeATTENDANCE!A1:GbecomesA1:Z(the reconcile reads stayA1:G). - Any test asserting
mark'supdateValuesdata is exactly one range: a desk or operator mark now writes two or three ranges (the actor label always exists, somarkedByis always written when a column is free). test/unit/docs/openapiSpec.test.mjs.snapshot: regenerate withnpm run test:snapshots, then read the diff: only the two issue routes and the new schema may change.- Tests asserting the services' issue return value exactly (
{ token, expiresAt, day }) gainissuedTo: null, note: null.
The strict-header test in attendanceTab.test.mjs does not change.
4.12 Version and Changelog¶
package.json → 3.1.0. README.md Changelog entry ### 3.1.0 at the top:
- Desk and scorer links can name who they're for, with a note (
/v3issue routes, optional body{ issuedTo, note }, carried in the signed token). - Attendance marks record
markedBy(In · Ana (Gate A) · 14:32) in a column the API finds or claims in H–Z, never J and never a person's own column;SheetsClient's attendance allowlist adds that one cell. - Score saves put a note on both score cells: the last five edits, newest first, best effort.
/v1unchanged.- Tests: every file in §4.10 and §4.11.
Run npm test after each step and npm run verify at the end. Both must pass.
5. The site¶
5.1 lib/v1/data/tokens.js¶
decodePayload decodes the base64 to bytes and the bytes as UTF-8:
function decodePayload(token){
const part = String(token).split('.')[0].replace(/-/g, '+').replace(/_/g, '/');
const binary = atob(part + '='.repeat((4 - part.length % 4) % 4));
return JSON.parse(new TextDecoder().decode(Uint8Array.from(binary, c => c.charCodeAt(0))));
}
No new export. scorerDecode/deskDecode return the whole payload as today, so
info.to and info.note are there when the token has them. Update the two JSDoc
comments to name to? and note?.
5.2 The desk and scorer pages (lib/v1/apps/attendance-desk.js, scorer.js)¶
Each file gets a local (not exported, not imported) helper:
// "Ana (Gate A)", "Ana", "Gate A", or '' — who this link was issued to (link-attribution-spec.md §1 D13).
function issuedText(info){
const to = typeof info.to === 'string' ? info.to : '';
const note = typeof info.note === 'string' ? info.note : '';
return to && note ? `${to} (${note})` : (to || note);
}
- Desk page:
#dayLabelbecomes` · ${dayEntry.label || info.day}`plus` · ${issuedText(info)}`when non-empty. - Scorer page: the same, appended after the facility in the existing
dayLabelEl.textContent = …line.
textContent only, never innerHTML: the text came from a person.
5.3 Control Center (tools/control-center.html)¶
Markup. Above #scorerLinkBtn, and above #attendanceDeskBtn, add the same kind
of block (ids differ):
<div class="link-issue-fields" id="scorerLinkFields" style="display:none;">
<input id="scorerLinkTo" type="text" class="organizer-text-input" maxlength="40" placeholder="Issued to (optional)" autocomplete="off" aria-label="Issued to, optional" />
<input id="scorerLinkNote" type="text" class="organizer-text-input" maxlength="40" placeholder="Note, e.g. Courts 3–4 (optional)" autocomplete="off" aria-label="Note, optional" />
</div>
The desk one is #deskLinkFields, #deskLinkTo, #deskLinkNote, with the placeholder
Note, e.g. Gate A (optional). Show and hide each block wherever its button is shown
and hidden (find every scorerLinkBtn.style.display / attConsoleDeskBtn.style.display
assignment and mirror it). A small .link-issue-fields rule: the two inputs side by
side with a gap, stacking under 480px, margin below. Use the existing tokens on
:root.
Issuing. In issueScorerLink() and attConsoleIssueDeskLink():
- Read both inputs,
.trim(). Buildfieldswith only the non-empty ones. fetchwithheaders: { 'Authorization': …, 'Content-Type': 'application/json' }andbody: JSON.stringify(fields).- Fixture mode: build the payload
{ exp, scope, day }plusto/notewhen given, and encode it as UTF-8 base64url (TextEncoder, then the bytes to a binary string, thenbtoa, then the URL-safe replacements). The desk fixture token becomes a real payload too (scope'attendance-desk'), plus'.fixture', so the desk page can show the label locally.issued.issuedTo/issued.noteset fromfields(ornull). - On success (after the epoch check passes and the box renders), clear both inputs.
Rendering. In renderScorerLink and attConsoleRenderDeskLink, the title becomes
Scorer link for <day> · <label> / Desk link for <day> · <label> when the response
has issuedTo or note (label as issuedText, written inline), via textContent.
5.4 _tests/¶
_tests/unit/data/data-modules.test.mjs:scorerDecodeanddeskDecodeof a payload withto: 'Niño'andnote: 'Courts 3–4'return them intact; an old-shape token still decodes.- Run
cd sage-match-control.github.io/_tests && npm run verify. The comparison harness must show no difference on any page (no fixture token carries a label).
6. Checking it yourself¶
npm run verifyinsage-tools-api: passes.npm run verifyinsage-match-control.github.io/_tests: passes.- Serve the site locally. Open Control Center with
?fixture=preon an event with desks and one with scorer links in_fixtures/config.json. Issue each link withNiñoandGate A: the title reads… · Niño (Gate A), the inputs clear. Open the link: the page's day line ends· Niño (Gate A). Issue without either: no label anywhere. grep -rn "A1:G" sage-tools-api/src: only the reconcile read and the header write remain.
7. Documentation¶
All present tense.
| File | Add |
|---|---|
sage-docs/docs/features/control-center.md |
Where desk and scorer links are issued: the optional Issued to and Note, what they're for, where they show up (the page, markedBy, the score notes), and that they record who the link was given to, not who typed (§1 D4) |
sage-docs/docs/features/event-attendance.md |
The markedBy column: what it holds, that it's the last change only, how the API picks its column and never overwrites one of yours, Control Center for operator marks |
sage-docs/docs/features/score-entry.md |
The note on both score cells: the line format, last five, a person's own note kept, Control Center for operator saves, notes never reach the public pages |
sage-docs/docs/features/scorer-page.md |
The label after the day |
sage-docs/docs/technical/auth.md |
The to/note payload keys, signed, 40 characters, absent when not given; links issued earlier verify unlabelled |
sage-docs/docs/technical/api.md |
The two /v3 issue routes' optional body and the echoed fields; /v1 unchanged |
sage-docs/docs/technical/event-attendance.md |
markedByColumn's rule, the widened allowlist (one cell in H–Z, not J), why parseTab stays strict |
sage-docs/docs/technical/control-center.md |
The issue fields, fixture tokens with a real payload |
sage-docs/docs/technical/scorer-page.md |
The label in the day line; UTF-8 decoding |
sage-docs/docs/usage/run-the-day.md |
When issuing links, put the person's name and their gate or courts, so the sheet shows who did what |
sage-docs/docs/usage/desk-handout.md, scorer-handout.md |
One sentence: the page shows who the link was issued to; don't pass it on |
sage-docs/docs/changelog.md + mkdocs.yml |
A MINOR entry and version bump, per the changelog's "Releasing a version" |
D:\Personal\SAGE\CLAUDE.md |
In the src/attendance/ layout entry, the write rule becomes "only inside ATTENDANCE!A:G plus one markedBy cell in H–Z (never J), and, for score entry, one match's two SCHEDULE score cells and their note". In src/auth/, scoped tokens may carry to and note |
Do not move this spec out of not-started/ or edit its status line: the owner
does that after §10.2.
8. What doesn't change¶
Every apps-script/*.gs file and both masters; HEADERS and new tabs' layout
(createAttendanceTab); the reconcile; the "Not yet in" list; every /v1 response
body; the mark and score response bodies; the snapshot format and the sync; the live
Worker; events.json; the operator login.
9. Alternatives considered (for the record; don't build these)¶
| Alternative | Why not |
|---|---|
| Store issued links (name, note) server-side and look them up by ID | Needs a store the API doesn't have; the signed payload carries the same data, and can't be edited |
markedBy fixed in column H |
H onward belongs to the organizer's own columns; a fixed H would overwrite one |
Add markedBy to HEADERS and to both generators |
Every existing tab would fail the strict header check, and both masters would need re-pasting. Claiming a column lazily needs neither |
Write the score and its note in one updateCells call |
Atomic, but replaces the tested values:batchUpdate/batchClear path that has yet to run against real workbooks. A note is attribution; it must never put a score at risk |
| Drive API comments instead of notes | Comments can't be reliably anchored to a cell through the API |
| An operator name field in Control Center | Decided against (§1 D3): every operator write is Control Center |
| Notes in the published snapshot | Who entered a score is for operators, not the public |
10. Finishing¶
10.1 Report back¶
git status --shortfrom each of the four repos.npm run verify's summary line fromsage-tools-apiand from the site's_tests/.- §6's checks, each ticked or explained.
- Anything in this spec you found wrong or had to interpret, with what you did.
10.2 The owner's checks (not yours)¶
On a no-traffic Cloud Run revision (--no-traffic --tag link-attribution) with a
scratch event whose facilities are copies of each master:
| # | Check | Pass |
|---|---|---|
| L1 | Issue a desk link with a name and note, mark someone in, then out | markedBy header claimed at H; the row reads In · …, then Out · …; times in Manila |
| L2 | The same on a copy whose ATTENDANCE has a TShirt Size column in H |
markedBy lands in I; H untouched |
| L3 | Issue a scorer link with a name; save a score, correct it, clear it | both score cells show the same note, three lines, newest first; the published snapshot is unaffected |
| L4 | Add a note by hand to a score cell, then save from the scorer page | the hand-written text is still there, below the score-entry lines |
| L5 | Save from Control Center | the line reads Control Center |
| L6 | Save six times on one match | five score-entry lines remain |
| L7 | Open an old-shape (pre-3.1.0) scorer link | works; no label on the page; notes read Scorer link |