# Teacher Portal — Structure & Design Decisions Design prototype: [`teacher-portal.html`](teacher-portal.html) Source of truth for the "as-is": `SoftLanding.ControlPanel/Components/Pages/Teacher/` in the `API_ControlPanel` repo. Target: the teacher area extracted from the Blazor SSR ControlPanel into a **standalone Blazor WebAssembly PWA**, installable on a phone. Mobile use is a primary requirement, not an afterthought. Decisions taken with the client: **English throughout** · **brand-true modernised** (orange `#FF6B00` kept as accent, not as a full app bar) · **known functional gaps closed and flagged**. --- ## 1. Why the package structure changed The current teacher area has **8 flat navigation entries** that mirror the API endpoints one-to-one: Dashboard, My Profile, My Courses, My Lessons, My Students, My Invites, My Payments, My Applications. That structure is an artefact of how the backend is organised, and it breaks on a phone for one hard reason: **a bottom navigation bar holds at most 5 items** (Material Design; more than 5 makes labels illegible and targets too narrow). Something had to give. Rather than hide three entries behind an anonymous "More", the grouping was rebuilt around **what a teacher actually does**, and the result is 6 destinations — 4 of which fit the bottom bar with room for the overflow menu. | New destination | Absorbs | Why | |---|---|---| | **Today** | Dashboard | Unchanged in purpose, renamed. "Dashboard" describes a widget container; "Today" describes what the teacher opens the app for. | | **Teaching** | My Lessons + My Courses | See §1.1 | | **Students** | My Students | Unchanged. | | **Opportunities** | My Invites + My Applications | See §1.2 | | **Payslips** | My Payments | Renamed: a teacher receives a payslip; "payments" is the office's word for the same object. Demoted to the overflow menu — see §1.3. | | **My Profile** | My Profile | Restructured internally — see §1.4. | ### 1.1 Courses merged into "Teaching" as a segment, not a destination `My Courses` is **entirely read-only** — `ICrudService` passes the Get DTO as its own Post and Patch type, and the only row action is a view dialog. It is reference data: the teacher consults a roster or a weekly schedule, then leaves. Spending one of five bottom-bar slots on a read-only reference table is poor economics when lessons — the thing the teacher touches every working day — sits right next to it. Both are now segments of **Teaching** (`Schedule` | `My courses`), so: - courses stay fully reachable and deep-linkable (`#teaching` + segment), - the course roster is *also* reachable from any lesson, which is how it is actually consulted, - a bottom-bar slot is freed for Opportunities. ### 1.2 Invites + Applications merged into "Opportunities" — the biggest win These two pages are opposite directions of the **same matching process**: - an **invite** is the office asking the teacher to take a course → accept / deny, - an **application** is the teacher offering themselves for a student request → express interest / withdraw. Both are time-sensitive, both are work the teacher is *deciding about*, and both are worthless if not seen promptly. Today they are two separate low-traffic pages with no notification surface, so a pending invite can sit unanswered for days. Merged into one inbox with two segments and **one badge count**, they become a page worth opening. The badge is the only one in the app — it earns attention because it is scarce. ### 1.3 Payslips and Profile moved to the overflow menu Both are consulted rarely — a payslip once a month, a profile a few times a year. They keep full top-level status on desktop (sidebar) and are one tap away on mobile via **More**. This is the `adaptive-navigation` rule: sidebar ≥1024px, bottom bar + overflow below. ### 1.4 Profile: read/edit mode swap replaced by inline section editing Today `MyProfile.razor` swaps the **entire page** between a read-only view and a 4-tab edit form. While editing an address, the teacher can no longer see their own name, capabilities or trainings — and the tab container unmounts inactive panels, which is the documented source of the "edit silently reverts" class of bug in this codebase. Replaced by **three stable segments** (`Personal` | `Availability` | `Qualifications`) where each card carries its own **Edit** button and opens a focused sheet. Consequences: - context is never lost, - each sheet edits one small, independently validatable group, - fields the office owns are marked *"Office maintains this"* instead of silently being absent from the edit form — the teacher can see the field exists and who to ask. **Each profile tab covers exactly one dimension** — corrected after review: | Tab | Dimension | |---|---| | Personal | **Who you are** — identity, contact, address, bank | | Availability | **When you can work** — weekly time slots, absences | | Qualifications | **What you can do** — spoken languages, teaching languages, capabilities, trainings | > **Spoken languages sat under Availability, and that was wrong.** I had organised the tabs along > *edit rights* — Availability held what the teacher may change, Qualifications what the office owns — and > that axis comes straight from the DTO: `MyProfilePatch` bundles address, bank, **spoken languages**, weekly > slots and absences into one self-edit payload. But that bundling is a **permission** concern, not a > semantic one, and the UI should not inherit it. A language you speak is the same kind of fact as one you > are approved to teach: *what you can do*, not *when you can work*. My justification ("it drives the > matching engine") separated nothing either — teaching languages and capabilities drive it just as much. > > Moved to **Qualifications, first card**. Edit rights are expressed per card instead: spoken languages keeps > its **Manage** button, the other three keep their *"Office maintains this"* badge. The tab now demonstrates > something useful — one tab can hold editable and read-only cards, with the badge doing the explaining — > and the intro line names the distinction: what you speak is yours to declare, what you may teach is not. **Availability was promoted out of the profile's fourth tab into its own segment.** Weekly time slots and absences are not personal data — they are *operational* data that drives the office's matching engine, and they change seasonally. Burying them three levels deep understated their importance; the segment now carries an explanatory note saying exactly what they affect. ### 1.5 Attendance promoted from a buried tab to a first-class flow This is the most consequential change. Today recording attendance means: open the calendar → click a lesson → the *view* dialog opens → press Edit → the edit dialog opens → switch to the 4th tab. Five interactions to reach the teacher's most frequent, most time-critical, and billing-relevant task. Attendance is now reachable in **one tap** from three places: the Today dashboard's amber reminder banner, the next-lesson hero card, and the lesson detail sheet. It is its own full-height sheet with per-student rows sized for thumbs. ### 1.6 The attendance warning lives in the navigation, not in a banner The dashboard first carried a large warning block at the top. Revised after review, because a banner is only seen by someone already looking at Today: - **The count sits on the `Today` nav item** (amber badge, sidebar and bottom bar), so an unrecorded lesson is visible from every screen — that is what makes it hard to forget. - **The big block is gone.** In its place a single 44px line sits **directly above the lessons it is about**: *"2 attendances missing"*. It is a button, because an affected lesson can be weeks old and then is not on today's schedule at all — it opens a sheet listing every one of them, oldest first, each with its age (*"11 days ago"*) and a tap straight into its attendance. - **The warning is only about lessons that have taken place.** `attendanceMissing` requires `status === 'Completed'`; a lesson scheduled for later today has nothing to record yet and must not warn. Verified by flipping a scheduled lesson's flag and confirming the count does not move. - **The per-lesson symbol became prominent**: a filled amber circle instead of a small tinted glyph. It marks the one row in a day that needs action, so it has to win against everything else on the row. All three surfaces — badge, line, sheet — read from **one** computation over the lesson data (`missingAttendanceKeys`). Saving an attendance clears that lesson and all three drop together; at zero the badge and the line disappear and the sheet says so. A count kept in markup would have drifted, which is the mistake this file records three times already. --- ## 2. Functional gaps closed (and why each is a defect, not a feature request) | # | As-is behaviour | In this design | Why | |---|---|---|---| | 1 | **Attendance is a boolean switch** (`Present`/`Absent`). `EAttendanceStatus.Excused` exists, is loaded from the lookup, and renders correctly read-only — but **the teacher can never set it**, and any edit to an excused row rewrites it to `Absent`. | Three-way segmented control: **Present / Absent / Excused**. | `Excused` is the status the billing layer treats as *not billable*. A teacher who cannot set it cannot record a timely cancellation, so the student gets charged. This is a money bug, not a UI nicety. | | 2 | Homework can be added and deleted, never edited. `HomeworkPatch` exists but is unused. | Per-entry edit action. | A typo in a homework description currently requires delete + retype. The contract already supports the fix. | | 3 | `MyLessons` has **no filter area at all** — no search, no date, no status, no course. | Course / status / date-range / hide-cancelled filters. | The project's own Definition of Done requires every list to ship with a filter area. This page is the exception, and it is the page with the most rows. | | 4 | Payslip PDF dialog is a stub: *"PDF generation will be implemented with the backend API."* | Real download flow with progress and completion feedback. | Designed so the backend work has a target to hit. | | 5 | `RespondedAt` (`DateTime?`) and all `decimal` money columns fall through `FormatValue`'s non-nullable type checks → raw `ToString()`: unformatted timestamps with seconds, money without currency or fixed decimals. | `dd.MM.yyyy` / `HH:mm`, `CHF 1'679.12`, tabular figures throughout. | Money that does not line up column-to-column is unreadable, and this is a payslip. | | 6 | Column headers mix languages: German on Courses and Invites (`Kurs`, `Zeitraum`, `Eingeladen`, `Beantwortet`), English on Students and Applications, German filter labels on Payments. | English throughout. | Per your decision. | | 7 | `MyInvites` "Eingeladen" column binds `CreatedAt`, not the DTO's own `InvitedAt`. | Shows the invitation date. | Straightforward wrong-field bug. | | 8 | `MyPayments` shows a Search box whose `SearchTerm` only matches `OwnerTypeName` server-side — it can never match anything a teacher would type. | Removed; replaced by period and amount filters. | A control that cannot work is worse than no control. | | 9 | Denying an invite captures no reason. | Optional reason field. | Costs the teacher nothing, and tells the office whether to re-offer. Additive to `TeacherInvitePatch`. | | 10 | Six decimal columns in a payslip table. | Card list with a breakdown sheet. | A 6-column money table cannot be read at 375px. | | 11 | **Room cannot be set by the teacher at all** — read-only on the Get DTO, absent from Post and Patch. | Room select on create and edit, grouped by location, unavailable rooms disabled. | Requested. Needs `RoomId` on `Lessons/Teacher/LessonPost` and `LessonPatch` (§2b). | | 12 | Nothing warns about **overlapping lessons**. | Clash detection in the calendar: red outline on both entries, day-level banner, and a review sheet that says which of the two can be moved. | A teacher assigned to two lessons at once finds out on the day. | | 13 | Submitted **applications are invisible** outside the Opportunities list. | Shown in the calendar in their own colour, with collisions named. | Proposing a slot you already teach is the easiest mistake to make and the most annoying to unwind. | | 14 | **`MyApplications` shows a `Student` column** bound to `InterestGet.StudentName`, so every teacher who can see a request can see the name of a student they have no relationship with. | Requests are **anonymous** until the course is assigned. Identified by a request reference (`Request #2418`) plus language, level, scope and availability. | Privacy: a name is not needed to decide whether you can teach a slot. See §2c. | ### Deliberately *not* changed - **Group-course date/time stays read-only.** The `Schedule` tab appears only for `Private`/`Duo` lessons. That is a correct business rule, not a gap, and the design states it inline. - **Lesson cancellation is not added.** `IsCancelled` is read-only on the teacher's `LessonGet` and there is no patch field. The view sheet keeps the existing *delete* action (Private/Duo only). Whether a teacher may *cancel* rather than *delete* is a **business decision for you**, not a design one — flagged in §4. - **Attendance records still cannot be deleted** (`AttendanceRecordIdsToRemove` is always null today). Left as-is; changing a status covers the real need. - **`unexcused`** — the fourth attendance value named in the calculation spec but absent from the enum — is not invented here. When it is added, it becomes a fourth option in the same control. --- ## 2a. Co-teaching: who holds a lesson A course has one **main teacher** (`Course.TeacherId`) plus **co-teachers** (the `CourseTeacher` join). `LessonTeacher` is a many-to-many join, so a lesson could technically carry several teachers — but `TeachingSalary` is **one figure per lesson** ([`LessonSalaryCalculator`](../../API_ControlPanel/SoftLanding.DataServices/Mapping/Shared/LessonSalaryCalculator.cs)) with no split logic anywhere. That settles the model: > **Exactly one teacher holds a lesson.** Reassigning moves the lesson *and its compensation*. ### The mechanism is a field, not a "claim" button Confirmed with the client: assignment is **free** — any teacher of the course may change it. So the canonical control is a **`Teacher` select on the lesson**, whose options are *all teachers of the course* (main + co-teachers), editable by any of them. Not a one-way claim with an approval flow. | Rule | Behaviour | |---|---| | Default at course creation | The **main teacher** is written onto every lesson created with the course. | | Who may reassign | Any teacher of the course — main or co-teacher. No approval, no request step. | | Time limit | The lesson's **day** must be **today or later**. A lesson earlier today is still reassignable; anything on a past day is not. | | Granularity of that limit | The **day**, not the clock. A lesson that ran at 08:00 can still be moved at 20:00 the same evening. | | Past days | Fixed. A correction there goes through the office. | | Reversibility | Fully symmetric within that window. The previous holder can take it back, a third co-teacher can take it — same field. | | Granularity | **One lesson per action.** No bulk or whole-series reassignment. | A one-tap **"Take over"** shortcut sits on the co-teacher's-lesson sheet. It writes exactly the same field as the select — it exists because on a phone, opening an edit sheet to change a dropdown is three taps for the single most common case. > **Why the day and not the start time.** Teachers commonly tidy up a lesson *after* teaching it, so a > cutoff at the lesson's start time would block the most ordinary case: "we swapped this morning, let me > record it". Extending the window to the end of the day covers that without opening up open-ended > retroactive edits, which would reach back into already-settled payslips. No separate payslip lock is needed: a lesson dated today or later cannot already appear on a settled monthly payslip, so the date rule subsumes it. ### Seeing your co-teachers' lessons Today the teacher's calendar is scoped to `l => l.LessonTeachers.Any(lt => lt.TeacherId == teacherId)` ([`LessonCrudService.cs:60`](../../API_ControlPanel/SoftLanding.DataServices/DbServices/Teacher/LessonCrudService.cs:60)) — lessons of your own course that someone else holds are deliberately **404**, part of the documented IDOR fix. Supporting this feature means widening that scope to a union: *assigned to me* **OR** *a lesson of a course I teach*. That is a real security-surface change and should be reviewed as such, not slipped in. In the UI those lessons appear **greyed out with a dashed border**, labelled with the holder's name and a `Co-teacher` chip: - **Default on** — a co-teacher sees the whole course week unless they opt out. - Toggle lives in the schedule filters; the preference is **persisted in `localStorage`**, not server-side, because it is a per-device view choice that must survive an offline start (PWA). - They carry **no compensation figure and no attendance action** — that belongs to the holder. Showing your salary on a lesson you are not paid for would be actively misleading. - A co-teacher lesson **earlier today** is shown as `Completed` but keeps its take-over button. One on a **past day** shows *"On a past day — ask the office to correct it"* instead. - Visually distinct from *cancelled* (strikethrough + grey fill) so the two never blur. - On **Today** they are listed but excluded from the "Lessons today" KPI and the attendance reminder — Today is about what you owe. ### Access granted by co-teacher membership Confirmed: **course-wide, from the moment you are a co-teacher on the course** — no need to hold a lesson first. A co-teacher sees the course information, the teaching team, and **all students of that course**, with the same rights as the main teacher (including notes and exam records). Rationale: you cannot prepare a substitution for students you are not allowed to look at. Consequence to be aware of: this widens student-data visibility (names, ages, guardian addresses, notes, exam records) to every co-teacher of the course. It is consistent with how notes already work — they are described as visible to the office and the other teachers of that student — but it *is* a widening, and worth a conscious sign-off rather than an implicit one. The Course detail sheet now shows the **teaching team** explicitly, marking the main teacher and you, so it is always visible *why* a course is accessible. ### Deliberately left out - **No notification when a lesson changes hands.** The client's answer was "it is free", and I have not invented an approval or notification flow. But losing a lesson — and its pay — from your schedule without being told is a rough edge; see §4. - **No "offer to a specific co-teacher" (push) direction.** Only pull was requested. It would be the same field, so it stays cheap to add later. --- ## 2b. Room, teacher-on-create, and the calendar's four entry kinds ### Room is now editable — this needs a contract change The teacher can set the **room** on a lesson, both when adding one and when editing. Today they cannot: `Room` is read-only on the teacher's `LessonGet`, and neither `Lessons/Teacher/LessonPost` nor `LessonPatch` carries a `RoomId`. **Both DTOs need the field**, plus the office-side rule that a room under maintenance or out of service cannot be booked (`ERoomStatus`, surfaced here as disabled options labelled *"— under maintenance"* / *"— out of service"*). Rooms are grouped by location, matching the `"{Location} - {Room}"` convention. ### Teacher is chosen at create time, and follows the course `Add lesson` now asks, in order: **Course → Teacher → Room → date/time**. The teacher options are the chosen course's teaching team, so the select is disabled until a course is picked and repopulates on change (wired live in the prototype). It defaults to the person creating the lesson. ### Add is a FAB on mobile Below `lg` the toolbar button becomes a 56×56 floating action button, bottom-right, clear of the bottom nav and the home-gesture area. It appears **only on Teaching** — the view that owns the action — and toasts were moved up so the two never collide. On desktop the FAB is gone and the toolbar button returns. ### The calendar now carries four kinds of entry | Kind | Look | Notes | |---|---|---| | Your lesson | Brand orange, solid | Full width unless overlapped | | Co-teacher's lesson | Grey, **dashed** | §2a | | Your application | **Indigo**, dashed | A time you *proposed*, not a confirmed lesson | | Clash | Red hairline outline + warning icon | Two entries you are assigned to that overlap | A legend sits above the calendar, and every state carries a word or icon as well as its colour — colour is never the only signal. **Overlap layering (week grid).** Overlapping entries are layered by importance, each tier narrower than the one above, so lower tiers stay visible on the right: | Tier | Element width | Visible strip | z | |---|---|---|---| | Your lesson | 54% | 54% (≈59px) | 30 | | Co-teacher | 38% | 24% (≈26px) | 20 | | Application | 36% | 20% (≈22px) | 10 | Two entries of the **same** tier are a genuine clash, so they split the column evenly and both get the red outline. > **A lower tier is covered from the LEFT**, so only its right-hand strip shows. A time label there gets > cut mid-string — `15:00` renders as `:00`, which is worse than no label. So the application tier renders > as a **colour band only**, with its full text in `title` + `aria-label`; the co-teacher tier keeps its > time. The general rule for implementation: *if an entry's visible strip is narrower than its label, drop > the label rather than truncate it.* The agenda view carries every entry in full, which is why the phone > loses nothing. **Applications are free-form.** Unlike lessons they follow no course schedule — they are times the teacher offered — so they can fall anywhere, including on top of existing commitments. That is exactly why they belong in the calendar: the demo shows both of Peter Schmid's proposed slots colliding with the week, and the application sheet names each collision. **Both calendar layers are toggleable and remembered per device** (`localStorage`), default on: *"Show lessons held by my co-teachers"* and *"Show my submitted applications"*. ### One sheet per lesson, not one per kind The first draft had **one** lesson sheet, hardcoded to Français Intensif, opened by all 23 call sites — so clicking the German group course showed the French lesson. Both the lesson and the course sheet are now rendered from two data maps (`LESSONS`, `COURSES`), and every call site names its own entry. Same for the co-teacher sheet (`FOREIGN_LESSONS`, keyed per lesson rather than by the three states `future/today/past`). Verified: all 15 agenda rows and all 14 week-grid events open a sheet whose course *and* time match the row that was clicked; all 5 course cards and 5 table rows open their own course; all 40 data keys referenced in markup exist in the maps. ### A lesson shows its materials and its teaching team — not a link to them The point of the course link was speed: before walking into a room, a teacher wants **which materials the students must have** and **who else teaches this course**. A link is a detour for that, so both are now rendered **inside the lesson sheet**: | In the lesson sheet | Behind *Open course* | |---|---| | Course name, language, level, period | Full student roster | | **Materials** — title + price, per course | Weekly schedule, all lessons | | **Teaching team** — avatar, name, role, `· you` marked | Description, room, capacity, totals | Why those two and not more: materials get checked every session ("did they bring the workbook?"), and the team is who you contact for a swap — which is exactly the co-teaching mechanism in §2a. Everything else is reference material and stays one tap away. `Open course` remains, and opens that lesson's own course. A lesson is never an orphan. Two layout consequences, both verified at 375px: the course name and material titles **wrap rather than truncate** (an edition matters — "English File B2 — Student B…" is not actionable), and the teacher's own row keeps its `· you` marker in brand colour, since bold alone was too weak to find yourself in a three-person team. ### Course details: a Lessons register that reads like a course diary The course sheet is now tabbed — **Overview** | **Lessons** — because the request was to *get up to speed on a course*, and that is a different job from looking up its metadata. The **Lessons** tab lists *Upcoming* as compact rows, then **Previous lessons** newest-first, each card showing **Homework · Grammar · Notes inline** — no tapping. A teacher taking over a course (or returning after a swap) reads top-down and knows where the group stands. A lesson with nothing recorded says so explicitly rather than rendering an empty card. Overview keeps the metadata: stats, teaching team, weekly schedule, materials, roster. ### View *and* edit on every lesson row The rows carried only an edit button, which forced an edit to read anything. Every row (upcoming and previous) now has **View** (read-only sheet) and **Edit**; edit stays reachable from inside the view, so the read path is the default and the write path is never more than one extra tap. This required a data change first: rows previously held display *strings*, so a view button would have reopened the same generic lesson — the exact defect from the last round. Each course now references real lesson **keys** (`lessons: { past: […], upcoming: […] }`), and 10 past lessons with genuine content were added. Verified: every view button on every row of all 5 courses opens the lesson whose date matches its row. **A colleague's lesson is read-only.** Past lessons include ones Claudia Bühler taught. Opening one hides *Your compensation*, *Delete* and *Attendance* — showing your salary on a lesson you were not paid for, or offering to record its attendance, would both be wrong. ### Editing a completed lesson: yes — that is when it gets documented Asked directly. The answer follows from what you told me earlier: teachers edit their lessons **retroactively**. Recording attendance, notes, grammar, homework and travel cost *is* editing a completed lesson — it is the normal case, not an exception. So the edit stays open. But not everything stays open equally long, because two different things live in that sheet: **what was planned** and **what happened**. | | Scheduled | Completed (today or later) | Completed (past day) | |---|---|---|---| | Homework · Grammar · Notes · Travel | ✓ | ✓ | ✓ | | Attendance | — (nothing to record yet) | ✓ | ✓ | | Room | ✓ | ✓ (correction) | ✓ (correction) | | Teacher | ✓ | ✓ | ✗ office (§2a) | | Date & time | ✓ Private/Duo only | ✗ | ✗ | | Delete | ✓ Private/Duo, yours | ✗ | ✗ | The one thing that closes is **date and time**: moving a lesson that already took place is not a correction, it is rewriting history. **Delete** closes for the same reason — afterwards there is a record worth keeping. Room stays open because a wrongly noted room is a factual error, not a rewrite. ### The teacher's edit window (confirmed rules) On top of the per-field table, the whole edit is gated by a window that belongs to the **teacher role**. The office has its own, far wider one. | # | Rule | |---|---| | 1 | You must be a teacher **of the course**. | | 2 | The lesson may be at most **one day old** — today or yesterday. You get the following day to tidy up. | | 3 | **Exception that beats rule 2:** while the **attendance is still missing**, the lesson stays editable **indefinitely**. | | 4 | The **office** may edit far longer — up to and beyond billing. | | 5 | **No payslip lock anywhere.** If a payslip is wrong, the office must be able to correct the lesson behind it. | Rule 1 is **structurally always true in this app** — a teacher only ever sees courses they teach — so it is an API authorisation rule (the same `AccessScope` as §2a), never a state the teacher meets in the UI. Worth saying explicitly so it does not get implemented twice, once as a pointless client check. Rule 3 is the one that matters. Forgetting to record absences must never lock you out of recording them: attendance drives billing, so an unrecorded lesson has to stay reachable however old it is. The prototype demonstrates it with two lessons **one day apart** whose fate differs only by attendance — `Français Intensif 31.07.` is still fully editable because its attendance is missing, while `English Business Communication 30.07.` is locked because it was recorded. The Today banner names both open lessons and offers one button each. Two consequences that follow from rule 3 rather than being invented: - **Attendance may be recorded by any teacher of the course while it is still missing** — not only by the assigned one. If a colleague forgot and is away, someone has to be able to do it. Compensation stays personal regardless. - A locked lesson says **why and who can still change it** (*"More than a day old and attendance recorded — the office can still change it"*), and its row shows a lock reason instead of an Edit icon. A missing button with no explanation reads as a bug. Rule 5 removes the open question I had left standing: no attendance/travel lock at settlement, deliberately. > **Bug: the window gated Edit but not Attendance.** Reported after the first implementation — a locked > lesson still showed its **Attendance** button, and `Delete` had the same hole. Recording attendance *is* > an edit, so a lesson closed for editing was still writable through a second door. > > Cause was ordering: `canAttend` and the delete rule were computed **before** `mayEdit` existed in that > function, so neither could reference it. The window is now computed **first**, and every write affordance > hangs off it — Edit, Attendance, Delete. The two entry functions (`openLessonEdit`, `openAttendance`) also > refuse and explain, so a stale link cannot bypass the rule either; enforcing only at the button is what > allowed this in the first place. > > The invariant now checked over all 23 lessons at both widths: **a locked lesson offers no write affordance > at all**, shows a reason instead, and its functions decline. Open lessons keep exactly what they should — > attendance only where there is something to record (yesterday's, today's, and the forgotten 31.07.), delete > only on your own future Private/Duo lessons. Two consequences in the UI: - The Setup tab **explains its own state** — "this lesson has taken place, so its date and time are fixed; everything you record *about* it stays editable" — instead of silently greying fields. - Editing a **completed** lesson opens on **Notes**, editing a **future** one opens on **Setup**. The sheet lands where the work actually is. Locked fields now also *look* locked (grey fill, muted text, `not-allowed` cursor). A field that cannot be changed but looks editable is worse than one that is absent — the save appears to succeed and drops the edit. > **A third mismatch bug found here, and one I caused.** `lessonEdit` was still a *single static sheet* > hardcoded to "Français Intensif · 11.08.2026", opened by four entry points — the same defect already fixed > for the lesson, course and co-teacher sheets. It is now keyed per lesson. > > Worse: while replacing it I bounded the edit by a distant comment marker instead of the element's own end, > and **deleted four overlays** — `foreignLesson`, `applicationSlot`, `conflictDetail`, `takeOverLesson`. > The overlay count going 27 → 23 is what caught it; they are restored. The lesson for these bulk edits is > the same one as the `att_rows` regex: **bound a replacement by the structure you are replacing, never by a > landmark somewhere after it.** **Settled:** no payslip lock, on purpose (rule 5 above). ### Student sheet: a lesson list with context, and 16 students who were one of three people The student's Lessons tab showed only date / time / duration / status — no course, no subject, no indication whether *this* student was even there. It now shows, per lesson: **course name**, **language + level**, course type, room, duration, who taught it, the lesson status, and **that student's own attendance** (Present / Absent / Excused), newest first, across *all* courses they are in, each row opening the lesson. Overview is derived from the same source: *Learning with you* (deduplicated — two courses can teach the same language at the same level), their courses with status, and the materials of those courses. > **The serious bug this uncovered.** The list and the rosters named **16 distinct students** but only > **3 records existed**. Clicking *"Sarah Zimmermann"* opened **Anna Müller**; *"Luca Brunner"* opened > **Emma Meier**; every roster row for the other 13 opened one of the same three. On a screen showing age, > guardian, contact details, notes and exam results, opening the wrong person is the most damaging form of > the open-the-wrong-thing defect in this whole review — and it was invisible until the lesson list forced > the data to be derived. > > There is now one record per student, rosters carry name slugs, and both lists are rendered from that > single source. Verified: all 17 list rows and all 29 roster entries open the person they name. The old lesson table was also fabricated — it showed Anna Müller in 18:00–20:00 lessons while the rosters put her only in *Français Intensif* (14:00–16:00). Deriving the list removed the contradiction rather than patching it. > **A regression I caused, and the shape lesson behind it.** Rewiring the rosters to name slugs was done > with a regex over `['Name','XX','bg-…','key']`. Attendance exceptions had the **identical 4-element shape** > — so the regex silently rewrote **21 attendance statuses** into student slugs. `ATT_LABEL['michaelFrei']` > is undefined, the destructuring threw, and clicking a completed lesson opened nothing at all: no modal, no > visible error. Reported by the client. > > Exceptions are now `except: [name, status]` — **two** elements, deliberately not the roster shape, so the > two can never be confused by a bulk edit again. The renderer also falls back to a neutral chip on an > unknown status instead of throwing, so bad data degrades rather than killing the sheet. And since the > design already made attendance "whole class, exceptions only", the redundant `present` rows are gone: three > real exceptions remain (`Nina Kaufmann excused`, `David Graf excused`, `Marco Keller excused`) plus the two > customer-reported absences on the EBC lesson. > > The check that now guards this: **every one of the 22 lessons must open** from `openLesson`, from > `openAttendance`, and from every student's lesson row — 17 students, every row clicked. Silent > nothing-happens is exactly what a click-through test catches and a data audit does not. **Data-coherence checks now run over the student area** (they caught six things I would not have spotted by eye): nobody is *"X native · learning X"* — Chloe Roth was a French native in a French B1 course, and five German natives sat in German A1/A2; a child never carries their own contact details, since the guardian is the contact; every student is in at least one course; every roster name resolves to the record with that name. Swiss reality made the language fix easy: a Wyss or Keller is quite plausibly an Italian or French native speaker. The student search is wired too (it was decorative) and matches name, native language, customer and subjects — `techcorp` returns the three TechCorp students. ### Students: a course filter — and the four dead selects next to it **Course** is now the first filter on the Students page, because "show me this course's students" is the question a teacher actually has. Its options come from `COURSES`, so it cannot offer a course that does not exist, and **finished courses are included** — their students are still your students. The same block held four **decorative** selects (native language, customer type, student type, status). Leaving them dead beside a working one is the inconsistency already removed twice (the search boxes), so all five are wired, filter **live** on change, and compose with the search: course `ebc` + `Company` gives 6 of 17; course `ebc` + search `keller` gives Marco Keller alone. `Reset` clears everything, and a **"n active"** chip on the collapsed header says how many filters are on — otherwise a filtered list looks like missing data. Options are derived: native language lists only the five languages students actually have. `Status` needed a field that did not exist, so students carry one (`Active` / `Inactive` / `Invited`) — a filter without data behind it is worse than no filter. The counter distinguishes states the way the course list does: `17 students` when nothing is filtered, `n of 17 students match` when something is. Verified: each of the 6 courses returns exactly its own roster, all five filters match a hand-computed expectation, cards and table stay in sync, and a filtered row still opens the person it names. ### Course list: type column, and a finished-courses filter **Course type** now appears in both the cards and the table, using the *same* colour rule as the calendar legend — orange for Group, violet for Private/Duo — so one glance connects a list entry to its calendar entries. **"Hide finished courses", default on**, persisted per device like the calendar preferences. A teacher's working set is the courses still running; the finished ones are archive. > **`ECourseStatus` has no terminal state.** Its values are `Draft, Created, Planned, Started` — a course > that ended in June is still stored as *Started*. So "finished" cannot be read from the status; it is > derived from `EndDate < today`, which is precisely why this filter is the right mechanism rather than a > status dropdown. The list therefore shows a derived **Ended** chip where the dates say so, while the > stored status stays untouched. **Open question:** should the enum gain a terminal value (`Finished` / > `Completed`)? Without one, "is this course over?" is a date computation everywhere it is asked. The list is now **rendered from `COURSES`** rather than hand-written markup. That was necessary — a filter that hides rows plus a type shown in two layouts would otherwise have meant maintaining the same facts in three places, the exact duplication behind the phantom course and the 6-vs-2 headcount bug. Course dates are stored once as ISO and formatted for display for the same reason. The search box next to it was decorative until now; it is wired, and **diacritic-insensitive** — typing `franc` finds *Français Intensif* and `fur` finds *Deutsch für Anfänger*. A raw substring match failed both, which for a language school's course names is not an edge case. The result count also distinguishes *"1 finished hidden"* (the filter) from *"n of 6 courses match"* (the search); conflating them made an empty search look like the filter had eaten everything. A finished course (`Deutsch A2 Aufbaukurs`, ended 26.06.2026) was added to the sample, with two past lessons carrying real content — a filter that demonstrably does nothing cannot be reviewed. ### Add lesson from the course, with the course preselected The course sheet's footer carries **Add lesson**, which opens the create dialog with that course already chosen — and, following from it, the **teacher** (you, by default) and the **room** (the course's usual one) pre-filled. Three fields fewer to touch. It appears **only on Private and Duo courses**, because that is the existing rule: group schedules belong to the office. Rather than hiding the action silently, a group course's Lessons tab says so — the same treatment as the read-only profile fields. The entry points now differ deliberately: the FAB and the toolbar button open the dialog **unselected** (you are choosing a course), the course sheet opens it **preselected** (you already have one). > **A third phantom found on the way.** The course picker was a hand-written list offering *"Private English > Coaching (English C1)"* — a course that exists nowhere else in the app. It is now derived from `COURSES`, > filtered to Private/Duo, so it cannot offer something that does not exist. Same root cause as the > headcount bugs below: data duplicated in a second place. Consequence worth knowing: the picker currently holds **one** entry, because the sample teacher has one private course. That is honest rather than padded — say the word if you want a Duo course added to the sample so the picker and the *Private / Duo* legend entry both have a second instance. ### The class list is now the single source of every headcount Reported: a course said *Students (6)* and listed 2; the 8-student course listed 4. Cause: the count came from a declared field (`students: 6`) while the list rendered a separate, abbreviated `roster` array. Two copies of the same fact, free to drift. Fixed by **deriving every headcount from the roster** — the count cannot disagree with the list any more — and by filling the rosters to their real size. Chasing it surfaced two more instances of the identical shape, both worse: 1. **The attendance sheet showed the wrong class.** `ATT_LESSONS` still had the old keys `fr` / `ebc` while the call sites had been rewired to `francaisTue` / `ebcTue`, and the lookup fell back to the Français entry. Opening attendance for the *English Business Communication* lesson listed **Français Intensif and its five students**. A teacher would have recorded attendance against the wrong class — and attendance drives billing. `openAttendance(lessonKey)` now reads the lesson, then its course. 2. **The co-teacher sheet** declared `nstud: 8` beside a hand-kept 2-name list, in all four entries. It now derives both from the course, like everything else. Attendance also changed shape as a result, for the better: it **covers the whole class**, and `att_rows` records only the *exceptions* (absent / excused) — everyone else is present by default. That removes the duplicate class list entirely. The EBC lesson's two customer-reported absences pre-fill the sheet, which matches the domain: absences are reported through the customer portal, attendance is the teacher's own record, and the two meet here. Audited across the whole app — 5 courses, 4 co-teacher lessons, 20 lessons: every rendered headcount equals its rendered list, and every exception name exists in its course roster (otherwise it would be dropped without a trace). > **The pattern to watch in these data maps.** All three bugs were the same mistake: a number and a list > stating the same fact in two places. Derive one from the other. > **Bug found by the footer.** Naming the new key map `lessons` collided with the existing numeric > `lessons: 88` count. Duplicate object keys are legal in JS — no syntax error, the object silently > shadowed the number, and both the footer and the *Lessons* stat box rendered `[object Object]`. The count > is now `total`. Worth remembering: a same-name field overwrite in these data maps fails silently. ### The colour key was incomplete, and colour carried two meanings The legend listed 4 entries while the calendar used 6 colours. Two were unexplained: - **violet** — Private / Duo lessons (course type) - **amber** — attendance missing (a *state*, not "currently active" as it read) Colour was encoding course type *and* state on one channel with a key for neither. The legend is now split by **what it encodes**, and covers everything drawn: | Your lessons | Other entries | |---|---| | Group (orange) · Private/Duo (violet) · Attendance missing (amber) · Cancelled (grey, struck) | Co-teacher's lesson (grey dashed) · Your application (indigo dashed) · Clash (red ring) | ### Demo-data coherence Making the sheets data-driven exposed contradictions in my own sample data, all fixed, since a prototype that contradicts itself invites the wrong review feedback: - Français Intensif was `Planned / 01.09.2026–20.12.2026` while August lessons for it were on the calendar → now `Started`, `01.06.2026–18.12.2026`, four afternoons a week (which is what "Intensif" implies). - `English B2` lessons existed with **no course** in *My courses* → added as the fifth course. - The same private course appeared as both *Private German Lesson* and *Privatunterricht Deutsch* → one name. - Lessons sat at times their course's weekly schedule did not contain (EBC on Wednesday, Deutsch on Thursday) → every lesson now falls on one of its course's weekly slots. - The cancelled lesson moved from a slot no course ran in (Tue 18:00, Raum 301) to `English B2` Thu 08:00. - `Lessons today` counted 4 including a colleague's lesson → 3 (mine); `Absences today` 3 → 2, after dropping an absence that belonged to Claudia Bühler's lesson, not mine. ### Why the agenda does NOT put overlaps side by side Asked directly, and the answer is no — the two views have different jobs. At 375px the agenda has 343px of usable width. Two columns would give each row ~166px, three (your lesson + co-teacher + application) ~110px. An agenda row carries a 56px time block plus course name plus `Room · Level · N students` plus a status icon; at 110px none of that survives. That is the same truncation just removed from the week grid — reintroducing it in the view that exists *because* the grid truncates would leave no view with full information. The split is deliberate: **grid = when and how much, agenda = what exactly.** Vertical space is cheap on a phone; horizontal space is not. **But the agenda did under-communicate simultaneity** — you had to read two timestamps and infer it. So overlapping entries are now **bracketed**: a left rail plus a one-line header (`SAME TIME · 14:00 – 16:00`, or `OVERLAPPING · 14:00 – 16:30` when the windows differ), with the member rows kept full width inside. Measured: 329px of 343px per row, so the bracket costs 14px instead of half the width. Two treatments for two meanings, not an inconsistency: | Situation | Treatment | |---|---| | Benign overlap (your lesson + co-teacher, or + your application) | Grey bracket, neutral header | | Real clash (two lessons **you** are assigned to) | Red banner + red outline on both rows + review sheet | A bracket stops claiming an overlap when a filter hides its partner: with one visible member left it collapses to a plain row (verified in both directions for both toggles). **The week grid is desktop-only** and now *enforced*: its toggle is hidden below `lg`, but the panel used to stay active if the window shrank or the device rotated, leaving a 7-column grid on a phone. A resize guard switches back to the agenda below 1024px. --- ## 2c. A student request carries no name until the course is yours **Rule:** a teacher looking at an application — before applying, while applied, and while it is pending — must **not** see the student's name. The name arrives only when the course is assigned to them. This is a change to current behaviour, not just to the design: `MyApplications` today renders a `Student` column bound to `InterestGet.StudentName` (computed from `StudentRef`). Every teacher who can see a matching request can therefore read the name of a student they have no relationship with. Listed as correction 14 above. ### What replaces the name A **request reference** plus everything needed to judge whether the slot is teachable: | Shown | Withheld | |---|---| | `Request #2418` · language · level · request type | Student name | | `Adult learner` / `Child (under 18)` · scope (`group of 4–6`, `one-to-one`) | Customer / guardian name | | Period, lessons per week, lesson length | Contact details | | The student's availability windows | Address, age as a number | | How many teachers have applied | — | `Adult learner` vs `Child (under 18)` is kept deliberately: it is a category, not an identity, and it decides whether a teacher may take the request at all (the `Lessons to children` capability). ### Where the rule is enforced All five application surfaces are name-free, verified programmatically against the full demo student list: - Opportunities → Applications (the request cards) - The *Express interest* sheet - The *Withdraw* confirmation - The calendar entries — visible text **and** `title` / `aria-label` - The *Your proposed time* sheet The rule is **stated in the UI** rather than left as an apparent gap: a note at the top of Opportunities and a line in both application sheets explain that the name follows the assignment. Otherwise the missing name reads as a bug. **A colleague's name is not covered by this.** The proposed-time sheet may say *"Claudia Bühler teaches then"* — that is a co-teacher, and their schedule is already visible to the teacher under §2a. > **Demo-data note.** `Anna Müller` was originally used both as a student and as a co-teacher, which made > the privacy check ambiguous and would have confused reviewers. The co-teacher is now `Claudia Bühler`; > `Anna Müller` appears only in student roles. ### Open point Once a request is assigned, the name must appear — that transition is not modelled in this prototype (there is no "assigned" state for a request here). It belongs to the office workflow: accepting a teacher's application creates the course assignment, and from that moment the ordinary student surfaces (§2a, course-wide access) apply. --- ## 3. Responsive & accessibility rules the prototype holds to Verified in-browser at 375px and 1280px: - **No horizontal scroll on any view at 375px** (checked per view programmatically). - **Every interactive element ≥44px** in its smallest dimension (audited; one 36px avatar button was found and given a 44px tap area around a 36px visual). - **Adaptive navigation**: sidebar ≥1024px, 5-item bottom bar below, with content padding reserving space for the fixed bar. - **Tables become cards** below `lg`. The week calendar grid becomes an **agenda list** — a 7-column time grid is unusable on a phone, so the week view is desktop-only rather than horizontally scrolled. - **16px base body text** so iOS does not auto-zoom form fields; `inputmode` set on numeric fields. - **Tabular figures** (`font-variant-numeric: tabular-nums`) on all money, time, date and count values. - **Status is never colour-only** — every chip carries a word, and cancelled rows also use strikethrough. - **Focus rings preserved** and visible (2px brand outline, never removed). - `prefers-reduced-motion` respected; transitions 180–240ms; Escape closes the top sheet; browser back closes an open sheet before navigating. - Safe-area insets (`env(safe-area-inset-*)`) applied top and bottom for notch and gesture-bar devices. **Not covered yet:** dark mode. Light mode is finished properly rather than shipping two half-done themes. All colours are already semantic tokens in the Tailwind config, so adding a dark variant is a token exercise, not a redesign. --- ## 4. Open questions for you 1. **Cancel vs. delete a lesson.** Today a teacher can delete a Private/Duo lesson but never cancel one. For billing, "cancelled" and "deleted" are not the same event. Which should a teacher be able to do? 2. **Push notifications.** A PWA can push. The obvious candidates are a new course invite and an attendance-still-missing reminder. Worth confirming before the shell is built, since it affects the service worker. 3. **Offline scope.** Recording attendance in a classroom with poor reception is the realistic failure case. Should attendance queue offline and sync later? This is the one place where offline support earns its complexity. 4. **`unexcused`** — the calculation spec names it, the enum does not have it. Add it now or later? 5. **Should losing a lesson be announced?** Reassignment is free and silent by design. A teacher can open the app and find a lesson — and its pay — gone, with no trace of who moved it or when. Cheapest remedy: record the last change on the lesson ("moved to Anna Müller by Peter Schneider, 09.08.") and surface it in the lesson sheet. A push notification would be the fuller answer. 6. **Widened lesson access scope.** Supporting the greyed-out view means relaxing the deliberately IDOR-hardened `AccessScope` on the teacher lesson service (§2a). Worth a security review before implementation, since it is the one place this feature touches a hardened boundary. 7. **Should `ECourseStatus` get a terminal value?** It ends at `Started`, so a course that finished in June still reads as started and "is it over?" has to be recomputed from dates in every view (§2b). 8. **Reassignment window is settled** (§2a): the lesson's day must be today or later. Only remaining detail — a lesson wrongly assigned on a *past* day currently has no in-app route at all; the teacher is told to contact the office. If that turns out to be frequent, the cheap fix is a "request correction" action that notifies the office rather than writing the change. ### Layout convention adopted Contextual info boxes always sit **above** the group of fields they govern; per-field explanations use the plain hint line directly **below** their field. An earlier draft mixed the two in the same tab. --- ## 5. Prototype coverage 6 views · 27 overlays · every button, row, tab, filter and toggle wired to a visible response. Demo data is taken from the existing mock services (`TeacherDashboardMockService`, `TeacherStudentsMockCrudService`, `TeacherPaymentsMockCrudService`, `CoursesMockCrudService`, `TeachersMockCrudService`) so screens show plausible values — real Swiss rates (AHV 5.3%, ALV 1.1%, KTG 0.56%, NBU 1.056%, vacation supplement 8.33%), real room names, real course names. | View | Overlays reachable from it | |---|---| | Today | Attendance, Lesson detail, Lesson edit (4 tabs), Confirm delete, Co-teacher's lesson | | Teaching · Schedule | Lesson create (course→teacher→room), Lesson detail, Lesson edit (incl. Teacher + Room), Attendance, Confirm delete, Co-teacher's lesson, Confirm take-over, Application slot, Clash review | | Teaching · Courses | Course detail (teaching team, roster, weekly schedule, lessons, materials) | | Students | Student detail (4 tabs), Notes, Exam records | | Opportunities · Invites | Invite detail, Accept, Deny (with reason) | | Opportunities · Applications | Express interest (slot proposal with validation), Withdraw | | Payslips | Payslip breakdown, PDF download | | My Profile | Edit address, Edit bank, Weekly availability, Absences, Spoken languages | | Global | Account, More menu, About | The **Express interest** sheet implements the real validation: proposed times must fall inside the student's availability window and start before they end, and the submit button stays disabled until at least one slot is valid. --- ## 6. Next step in the pipeline This file is the input for **`aspi-02-fe-foundation`**, which reads an HTML+Tailwind mockup and produces the implementation inventory, the Tailwind build and the Blazor shell/layout. From there: `aspi-03-dto` → `aspi-04-dto-services` → `aspi-05-blazor-pages` → `aspi-06-dialog-creator`. Note for that step: the target is **plain Blazor + Tailwind, not MudBlazor**. The existing teacher pages are MudBlazor, so the components are rebuilt rather than ported — which is what makes closing the gaps in §2 cheap to do now rather than later.