Phase 6b/7b: dashboard codes editor, decode events, human-readable briefs (AUD-115)

- New /codes dashboard page (behind the session login, linked in the top bar):
  lists every mapping by kind with Add, inline Edit+Save and Delete. Sam can put
  real teacher surnames in without a rebuild (FR-17).
- Decode timetable EVENTS through the same map as periods, so an ensemble reads
  'JAZZ ENSEMBLE - Ensemble Room - Mr KWO' instead of raw codes.
- teacherName splits comma- or space-separated codes ('HHL, OFG').
- SeedCodes is a one-time bootstrap while the table is empty, so a correction or
  a delete is never overwritten or restored.
- Tests: decode Label/Event, the /codes page (save, refuse, csrf, delete, auth),
  store re-seed behaviour. go test ./internal/... passes.
This commit is contained in:
2026-10-16 12:50:31 +11:00
parent 80b54524ea
commit 3e25011399
11 changed files with 433 additions and 28 deletions

View File

@@ -38,11 +38,12 @@ func Period(title string, m store.CodeMap) string {
}
// teacherName decodes a teacher field. Compass sometimes carries several codes
// in the one field ("GT SMH"), so every token is decoded and joined.
// in the one field, separated by spaces ("GT SMH") or commas ("HHL, OFG"), so
// every token is decoded and joined. An unmapped token stays raw (FR-17).
func teacherName(m store.CodeMap, field string) string {
tokens := strings.Fields(field)
if len(tokens) <= 1 {
return codeName(m, store.CodeTeacher, field)
tokens := teacherTokens(field)
if len(tokens) == 0 {
return strings.TrimSpace(field)
}
out := make([]string, 0, len(tokens))
for _, tok := range tokens {
@@ -51,6 +52,43 @@ func teacherName(m store.CodeMap, field string) string {
return strings.Join(out, " / ")
}
// teacherTokens splits a teacher field on commas and whitespace.
func teacherTokens(field string) []string {
return strings.Fields(strings.ReplaceAll(field, ",", " "))
}
// Label renders any Compass timetable label as plain English: a lesson label
// through Period, anything else (an ensemble, a concert, an excursion) through
// Event. It is the decoder the briefs use. An unmapped code stays raw (FR-17).
func Label(title string, m store.CodeMap) string {
if _, _, _, _, ok := period.Split(strings.TrimSpace(title)); ok {
return Period(title, m)
}
return Event(title, m)
}
// Event renders a timetable event label as plain English:
//
// "JUNIOR GUITAR ENSEMBLE - THEATRE - HHL, OFG" -> "JUNIOR GUITAR ENSEMBLE — Theatre — Mr HHL / Mr OFG"
//
// The event name is kept as the school wrote it; the room and teacher codes are
// decoded. A label with no room or teacher is returned with just its name, and
// an unmapped code stays raw (FR-17).
func Event(title string, m store.CodeMap) string {
parts := period.SplitDash(strings.TrimSpace(title))
if len(parts) <= 1 {
return strings.TrimSpace(title)
}
out := []string{parts[0]}
if parts[1] != "" {
out = append(out, codeName(m, store.CodeRoom, parts[1]))
}
if len(parts) >= 3 && parts[2] != "" {
out = append(out, teacherName(m, parts[2]))
}
return strings.Join(out, " — ")
}
// periodName renders the period slot: a word label such as "C" uses the map
// ("Connect"), a number becomes "Period 3", and anything unknown stays raw.
func periodName(slot string, m store.CodeMap) string {

View File

@@ -50,3 +50,31 @@ func TestPeriodLeavesEventsAlone(t *testing.T) {
t.Errorf("Period = %q, want the event unchanged", got)
}
}
// TestLabelDecodesEventsAndLessons proves FR-17 covers timetable events too: an
// ensemble or a concert reads as plain English, its room and teachers decoded,
// while the event name is kept as the school wrote it. A single-part label is
// returned unchanged and an unmapped code stays raw.
func TestLabelDecodesEventsAndLessons(t *testing.T) {
codes := testCodes()
codes.Room["THEATRE"] = "Theatre"
codes.Room["ENS"] = "Ensemble Room"
codes.Room["IM3"] = "Room IM3"
codes.Teacher["HHL"] = "Mr HHL"
codes.Teacher["OFG"] = "Mr OFG"
codes.Teacher["KWO"] = "Mr KWO"
cases := map[string]string{
"1 - 7SCF - H14LAB - KNI": "Period 1 — Year 7 Science — Room H14LAB — Mr KNI",
"JUNIOR GUITAR ENSEMBLE - THEATRE - HHL, OFG": "JUNIOR GUITAR ENSEMBLE — Theatre — Mr HHL / Mr OFG",
"JAZZ ENSEMBLE - ENS - KWO": "JAZZ ENSEMBLE — Ensemble Room — Mr KWO",
"HHL GUITAR E - IM3 - HHL": "HHL GUITAR E — Room IM3 — Mr HHL",
"Curriculum Day": "Curriculum Day",
"Concert - HALL": "Concert — HALL",
}
for raw, want := range cases {
if got := Label(raw, codes); got != want {
t.Errorf("Label(%q) = %q, want %q", raw, got, want)
}
}
}