diff --git a/docs/phase-6b7b-notes.md b/docs/phase-6b7b-notes.md index cbb64be..0262c32 100644 --- a/docs/phase-6b7b-notes.md +++ b/docs/phase-6b7b-notes.md @@ -36,18 +36,24 @@ hour moves with the day's first event (07:00 on one page, 06:00 on another). ## 2. The decode map (FR-17) `codes` (migration `005_codes.sql`) is the editable map: `subject`, `room`, -`teacher` and `period`. It is seeded on first read and never overwrites a -correction. An unknown code renders as the **raw code**, never blank. +`teacher` and `period`. It is seeded **once**, while the table is empty, and never +overwrites or restores a row after that, so a correction or a delete sticks. An +unknown code renders as the **raw code**, never blank. + +The same map decodes the timetable **events**, not only the lesson periods, so +an ensemble reads in plain English too: ``` -=== FINN === -PERIODS TODAY -- Period 1 — Year 7 Science — Room H14LAB — Mr KNI (08:55) -... -- Period 4 — Year 7 Physical Education — Gym 4 — Mr LTA (13:26) +EVENTS TODAY +- JAZZ ENSEMBLE — Ensemble Room — Mr KWO (07:45) +- JUNIOR GUITAR ENSEMBLE — Theatre — Mr HHL / Mr OFG (07:45) ``` -Sam edits it without a rebuild: +Sam edits it without a rebuild, **in the dashboard** or by CLI: + +- dashboard: `/codes` (linked as **Codes** in the top bar) lists every mapping by + kind with an Add form per kind, an inline Edit + Save per row, and Delete. The + page sits behind the same session login as the dashboard. ```bash docker exec compass-teams-nhs /app/school codes list @@ -56,9 +62,9 @@ docker exec compass-teams-nhs /app/school codes delete --kind room --code Z9 ``` Teacher rows are placeholders (`KNI` -> `Mr KNI`) because Compass gives only -initials; Sam replaces them with surnames. Real unknown-code fallback from the -live brief: Harry's period 2 teacher field is `GT AGE_13`, and `AGE_13` is not -mapped, so it renders raw (`Mr GT / AGE_13`). +initials; Sam replaces them with surnames in the dashboard. Real unknown-code +fallback from the live brief: Harry's period 2 teacher field is `GT AGE_13`, and +`AGE_13` is not mapped, so it renders raw (`Mr GT / AGE_13`). Seed size: 65 rows — 23 subjects, 22 teachers, 18 rooms, 2 period labels. @@ -223,6 +229,8 @@ internal/parse/daywidget.go the per-child "Today" widget internal/parse/parse.go, calendar.go, importance.go per-child hook, week-calendar filter internal/dashboard/dashboard.go brief buckets + the required 4-section renderers internal/brief/brief.go Finn-first order, codes decoder, new renderers +internal/web/server.go /codes page + save/delete handlers +internal/web/templates/codes.html the dashboard decode-map editor internal/calendar/sync.go periods + due + overdue + events, decoded, once internal/calendar/ha.go Event JSON exposes start/end for evidence cmd/school/{main,parse,calendar,codes,items,notify}.go wiring, codes CLI, purge by page @@ -240,4 +248,5 @@ docker-compose.yml, Makefile image 0.6.2-phase6b7b - The **week calendar** is still not fetched by the daily scan, so a *new* major event is only written if a run fetches that page. The two existing school-wide events remain in the calendar. -- **Teacher names** are placeholders; only Sam can supply the surnames. +- **Teacher names** are placeholders; Sam supplies the surnames in the new + `/codes` dashboard page. Until then they render as initials. diff --git a/internal/brief/brief.go b/internal/brief/brief.go index ad25bb1..cc7f9d7 100644 --- a/internal/brief/brief.go +++ b/internal/brief/brief.go @@ -138,14 +138,15 @@ func (s *Service) ChildrenText(ctx context.Context, child store.Child) (string, return notice + "\n" + text, false, nil } -// decoder loads the editable codes map and returns the lesson-title decoder the -// brief uses (FR-17). An unknown code renders raw, never blank. +// decoder loads the editable codes map and returns the label decoder the brief +// uses (FR-17). It decodes lesson periods and timetable events; an unknown code +// renders raw, never blank. func (s *Service) decoder(ctx context.Context) (func(string) string, error) { codes, err := s.Store.Codes(ctx) if err != nil { return nil, fmt.Errorf("decode map: %w", err) } - return func(title string) string { return decode.Period(title, codes) }, nil + return func(title string) string { return decode.Label(title, codes) }, nil } // CombinedText returns the single brief for Jo and Sam — both boys in one diff --git a/internal/dashboard/dashboard.go b/internal/dashboard/dashboard.go index 5816e11..4c5add1 100644 --- a/internal/dashboard/dashboard.go +++ b/internal/dashboard/dashboard.go @@ -419,7 +419,7 @@ func RenderBrief(child string, b Buckets, decode func(string) string, loc *time. fmt.Fprintf(&sb, "=== %s ===\n", strings.ToUpper(strings.TrimSpace(child))) writeBriefPeriods(&sb, b.PeriodsToday, decode, loc) - writeBriefEvents(&sb, b.EventsToday, loc) + writeBriefEvents(&sb, b.EventsToday, decode, loc) writeBriefWork(&sb, "OVERDUE", b.Overdue, loc) writeBriefFuture(&sb, b, loc) return sb.String() @@ -455,19 +455,25 @@ func writeBriefPeriods(sb *strings.Builder, items []store.Item, decode func(stri } } -// writeBriefEvents lists today's major school events (FR-9b). -func writeBriefEvents(sb *strings.Builder, items []store.Item, loc *time.Location) { +// writeBriefEvents lists today's major school events (FR-9b), decoded through +// the same map as periods so an ensemble or a concert reads as plain English +// (FR-17). Unknown codes stay raw. +func writeBriefEvents(sb *strings.Builder, items []store.Item, decode func(string) string, loc *time.Location) { sb.WriteString("\nEVENTS TODAY\n") if len(items) == 0 { sb.WriteString("- none\n") return } for _, it := range items { + title := strings.TrimSpace(it.Title) + if decode != nil { + title = decode(title) + } when := "" if it.DueAt != nil { when = " (" + it.DueAt.In(loc).Format("15:04") + ")" } - fmt.Fprintf(sb, "- %s%s\n", strings.TrimSpace(it.Title), when) + fmt.Fprintf(sb, "- %s%s\n", title, when) } } diff --git a/internal/decode/decode.go b/internal/decode/decode.go index 6a93267..e1ff78e 100644 --- a/internal/decode/decode.go +++ b/internal/decode/decode.go @@ -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 { diff --git a/internal/decode/decode_test.go b/internal/decode/decode_test.go index 6aac647..a56efe0 100644 --- a/internal/decode/decode_test.go +++ b/internal/decode/decode_test.go @@ -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) + } + } +} diff --git a/internal/store/codes.go b/internal/store/codes.go index 123775c..7451787 100644 --- a/internal/store/codes.go +++ b/internal/store/codes.go @@ -134,10 +134,18 @@ var seedCodes = []struct { {CodePeriod, "RLE GROUP D", "RLE Group D"}, } -// SeedCodes inserts the shipped mappings that are not present yet. It is -// idempotent and never overwrites a value, so Sam's corrections survive every -// start and every scan. +// SeedCodes inserts the shipped mappings once, when the map is still empty. It +// never overwrites a value, so Sam's corrections survive every start and every +// scan — and because it is a one-time bootstrap, a mapping Sam deletes in the +// dashboard stays deleted instead of reappearing on the next read. func (s *Store) SeedCodes(ctx context.Context) error { + var n int + if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM codes`).Scan(&n); err != nil { + return fmt.Errorf("seed codes: count: %w", err) + } + if n > 0 { + return nil + } tx, err := s.db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("seed codes: begin: %w", err) @@ -157,8 +165,9 @@ func (s *Store) SeedCodes(ctx context.Context) error { return nil } -// Codes returns the whole decode map. It seeds the shipped defaults first, so a -// fresh database renders the known captures and a corrected row is kept. +// Codes returns the whole decode map. It seeds the shipped defaults first (once) +// so a fresh database renders the known captures, and a corrected or deleted row +// is kept. func (s *Store) Codes(ctx context.Context) (CodeMap, error) { if err := s.SeedCodes(ctx); err != nil { return CodeMap{}, err diff --git a/internal/web/server.go b/internal/web/server.go index dcfba29..36b9d28 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -79,6 +79,9 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /items/{id}", s.requireAuth(s.handleItemDetail)) mux.HandleFunc("POST /items/{id}/delete", s.requireAuth(s.handleItemDelete)) mux.HandleFunc("POST /items/{id}/calendar", s.requireAuth(s.handleItemCalendarPush)) + mux.HandleFunc("GET /codes", s.requireAuth(s.handleCodesPage)) + mux.HandleFunc("POST /codes", s.requireAuth(s.handleCodeSave)) + mux.HandleFunc("POST /codes/delete", s.requireAuth(s.handleCodeDelete)) mux.HandleFunc("POST /summaries/build", s.requireAuth(s.handleSummaryBuild)) mux.HandleFunc("POST /summaries/send", s.requireAuth(s.handleSendBrief)) mux.HandleFunc("POST /summaries/{id}/send", s.requireAuth(s.handleSummarySend)) @@ -1421,6 +1424,139 @@ func pathID(r *http.Request, name string) (int64, bool) { return v, true } +// ---------- codes (the editable decode map, FR-17) ---------- + +// codeKinds is the page order and the plain-English label for the four decode +// groups. +var codeKinds = []struct { + Kind string + Label string + Hint string +}{ + {store.CodeSubject, "Subjects", "e.g. 7SCF → Year 7 Science. New subject codes appear each term."}, + {store.CodeTeacher, "Teachers", "Compass gives initials only. Put the surname here, e.g. KNI → Mr Knight. Unknown initials stay raw."}, + {store.CodeRoom, "Rooms", "e.g. B11 → Room B11, GYM4 → Gym 4."}, + {store.CodePeriod, "Period labels", "Word slots only, e.g. C → Connect. A number is always “Period N”."}, +} + +// codesData renders the editable decode map (AGENTS.md §4 FR-17). The map is +// data, not code: a correction here changes the very next brief. +type codesData struct { + Title string + Username string + CSRF string + Version string + AppEnv string + Groups []codeGroup + ErrMsg string + Flash string + FlashLevel string +} + +type codeGroup struct { + Kind string + Label string + Hint string + Codes []codeRow +} + +type codeRow struct { + Kind string + Code string + Name string +} + +// handleCodesPage lists every mapping so Sam can correct it in the dashboard. +func (s *Server) handleCodesPage(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + data := codesData{ + Title: "Codes", + Username: s.cfg.AdminUsername, + CSRF: s.auth.EnsureCSRF(w, r), + Version: s.cfg.Version, + AppEnv: s.cfg.AppEnv, + } + // Same flash contract as the dashboard: ?msg=… is the text, ?level=error + // makes it red. + if msg := strings.TrimSpace(r.URL.Query().Get("msg")); msg != "" { + data.Flash = msg + data.FlashLevel = "ok" + } + if strings.TrimSpace(r.URL.Query().Get("level")) == "error" { + data.FlashLevel = "error" + } + + rows, err := s.store.ListCodes(ctx) + if err != nil { + s.log.Error("list codes", "err", err) + data.ErrMsg = "Could not read the codes: " + err.Error() + } + byKind := map[string][]codeRow{} + for _, c := range rows { + byKind[c.Kind] = append(byKind[c.Kind], codeRow{Kind: c.Kind, Code: c.Code, Name: c.Name}) + } + for _, k := range codeKinds { + data.Groups = append(data.Groups, codeGroup{Kind: k.Kind, Label: k.Label, Hint: k.Hint, Codes: byKind[k.Kind]}) + } + s.render(w, r, "codes.html", data) +} + +// handleCodeSave creates or corrects one mapping (FR-17). +func (s *Server) handleCodeSave(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + if !s.auth.CheckCSRF(r) { + s.log.Warn("code save rejected: bad csrf token") + http.Error(w, "session expired", http.StatusForbidden) + return + } + row, err := s.store.SetCode(ctx, + strings.TrimSpace(r.PostFormValue("kind")), + strings.TrimSpace(r.PostFormValue("code")), + strings.TrimSpace(r.PostFormValue("name"))) + if err != nil { + s.redirectAfterWrite(w, r, "/codes?level=error&msg="+url.QueryEscape("Not saved: "+err.Error())) + return + } + s.log.Info("code saved", "kind", row.Kind, "code", row.Code, "name", row.Name) + s.redirectAfterWrite(w, r, "/codes?msg="+url.QueryEscape("Saved "+row.Kind+" "+row.Code+" → "+row.Name)) +} + +// handleCodeDelete removes one mapping, so the code falls back to raw. +func (s *Server) handleCodeDelete(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + if !s.auth.CheckCSRF(r) { + s.log.Warn("code delete rejected: bad csrf token") + http.Error(w, "session expired", http.StatusForbidden) + return + } + kind := strings.TrimSpace(r.PostFormValue("kind")) + code := strings.TrimSpace(r.PostFormValue("code")) + ok, err := s.store.DeleteCode(ctx, kind, code) + switch { + case err != nil: + s.redirectAfterWrite(w, r, "/codes?level=error&msg="+url.QueryEscape("Not removed: "+err.Error())) + case !ok: + s.redirectAfterWrite(w, r, "/codes?level=error&msg="+url.QueryEscape("No such code: "+kind+" "+code)) + default: + s.log.Info("code deleted", "kind", kind, "code", code) + s.redirectAfterWrite(w, r, "/codes?msg="+url.QueryEscape("Removed "+kind+" "+code+"; it renders raw again.")) + } +} + // ---------- partials ---------- func (s *Server) handleScanStatusPartial(w http.ResponseWriter, r *http.Request) { diff --git a/internal/web/server_test.go b/internal/web/server_test.go index 87bd932..50dd0b2 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -875,3 +875,91 @@ func TestMorningEndpointDryRun(t *testing.T) { t.Errorf("a dry run must not record deliveries: %+v", rows) } } + +// TestCodesPageEditsDecodeMap proves FR-17 in the dashboard: Sam can see the +// decode map, correct a teacher initial to a surname, and remove a mapping. +// A corrected or deleted mapping must stick, not reappear from the seed. +func TestCodesPageEditsDecodeMap(t *testing.T) { + ts, st := newTestServer(t) + client := login(t, ts) + + page := getBody(t, client, ts.URL+"/codes") + if !strings.Contains(page, "KNI") || !strings.Contains(page, "Teachers") { + t.Fatalf("codes page does not list the seeded teacher codes") + } + token := csrfToken(t, page) + + // Correct an initial to a surname. + resp, err := client.PostForm(ts.URL+"/codes", url.Values{ + "csrf": {token}, "kind": {"teacher"}, "code": {"KNI"}, "name": {"Mr Knight"}, + }) + if err != nil { + t.Fatalf("save: %v", err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusSeeOther { + t.Fatalf("save status = %d, want 303", resp.StatusCode) + } + codes, err := st.Codes(context.Background()) + if err != nil { + t.Fatalf("codes: %v", err) + } + if codes.Teacher["KNI"] != "Mr Knight" { + t.Errorf("teacher KNI = %q, want Mr Knight", codes.Teacher["KNI"]) + } + + // An empty name is refused and the mapping is unchanged. + resp, err = client.PostForm(ts.URL+"/codes", url.Values{ + "csrf": {token}, "kind": {"teacher"}, "code": {"KNI"}, "name": {""}, + }) + if err != nil { + t.Fatalf("bad save: %v", err) + } + resp.Body.Close() + if loc := resp.Header.Get("Location"); !strings.Contains(loc, "level=error") { + t.Errorf("bad save Location = %q, want an error flash", loc) + } + if codes, _ := st.Codes(context.Background()); codes.Teacher["KNI"] != "Mr Knight" { + t.Errorf("the refused save changed the mapping to %q", codes.Teacher["KNI"]) + } + + // A bad CSRF token is refused. + resp, err = client.PostForm(ts.URL+"/codes", url.Values{ + "csrf": {"wrong"}, "kind": {"teacher"}, "code": {"KNI"}, "name": {"Mr Nope"}, + }) + if err != nil { + t.Fatalf("csrf save: %v", err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusForbidden { + t.Errorf("bad csrf status = %d, want 403", resp.StatusCode) + } + + // Delete returns the code to raw, and it stays deleted. + resp, err = client.PostForm(ts.URL+"/codes/delete", url.Values{ + "csrf": {token}, "kind": {"teacher"}, "code": {"KNI"}, + }) + if err != nil { + t.Fatalf("delete: %v", err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusSeeOther { + t.Fatalf("delete status = %d, want 303", resp.StatusCode) + } + if codes, _ := st.Codes(context.Background()); codes.Teacher["KNI"] != "" { + t.Errorf("teacher KNI still mapped after delete: %q", codes.Teacher["KNI"]) + } + + // The page is behind the session login. + anon := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }} + anonResp, err := anon.Get(ts.URL + "/codes") + if err != nil { + t.Fatalf("anon get: %v", err) + } + anonResp.Body.Close() + if anonResp.StatusCode != http.StatusSeeOther { + t.Errorf("anonymous /codes = %d, want a redirect to login", anonResp.StatusCode) + } +} diff --git a/internal/web/templates/codes.html b/internal/web/templates/codes.html new file mode 100644 index 0000000..7178130 --- /dev/null +++ b/internal/web/templates/codes.html @@ -0,0 +1,88 @@ +{{define "codes.html"}} + + + + + + {{.Title}} — Compass / Teams + + + + + + + +
+
+

Codes

+

Plain-English names for the Compass codes in the briefs. + A code that is not here renders as the raw code — never blank.

+
+ + {{if .ErrMsg}}{{end}} + {{if .Flash}}

{{.Flash}}

{{end}} + + {{range .Groups}} +
+
+

{{.Label}}

+ {{len .Codes}} +
+

{{.Hint}}

+ +
+ + + + + +
+ + {{if .Codes}} + + {{else}} +

No codes yet. Add one above.

+ {{end}} +
+ {{end}} +
+ + + +{{end}} diff --git a/internal/web/templates/dashboard.html b/internal/web/templates/dashboard.html index de4fddc..1c3aa21 100644 --- a/internal/web/templates/dashboard.html +++ b/internal/web/templates/dashboard.html @@ -17,6 +17,7 @@ Compass / Teams