QuranFlow — Domain & Data Model Specification
Storage-agnostic domain model for the new QuranFlow backend. Every entity, field,
and rule below traces to either the stakeholder feedback synthesis
(FEEDBACK-SYNTHESIS.md), the adjudicated design review
(../mockup/REVIEW.md), the simplification audit
(../mockup/AUDIT.md), or the Sakīna mockup source it was built from
(../mockup/src/*.jsx). Field names are suggestions, not a schema DDL —
the backend team should map them onto whatever store (relational, document,
etc.) they choose. Where mockup and source docs disagree, the mockup's
adjudicated REVIEW.md resolution wins (per that document's own rule).
1. Entity catalogue
User
The single account type; role and per-role fields distinguish behavior.
| Field | Notes |
|---|---|
id |
|
name |
Display name, e.g. "Sarah M." |
role |
y1_student | y2_student | coach | admin. Wishlist: Y1 = submission-centric, Y2 = appointment-centric (FEEDBACK-SYNTHESIS.md "Wishlist requirements"). |
email |
|
timezone |
IANA zone, e.g. America/Toronto. One account = one city (REVIEW.md B3) — no per-device override. All times shown to a user render in this zone; the app never hardcodes EST (AUDIT.md "v3 mockup feedback"). |
level |
Current level, e.g. Level II. Students see content up to their current level; coaches see all levels (FEEDBACK-SYNTHESIS.md wishlist: "Content access"). |
semester_enrollment_id |
FK to the student's current Semester enrollment (below). |
year |
Y1 | Y2 — drives submission-centric vs appointment-centric UI and feature gating (Year-2-only 1:1 rows, REVIEW.md C2). |
coach_id |
FK to User (role coach) — the student's single assigned coach. One coach per student; two-coach model explicitly rejected (REVIEW.md "Out of scope": "Two-coach model for Year 2 — rejected for now"). Changeable via an explicit "Change coach" affordance (REVIEW.md A4), which reassigns this field but does not alter historical CoachNote/Feedback authorship. |
quran_font_preference |
One of a fixed set (Amiri Quran / Amiri / Naskh) + font_size_pt (18–36). From SakFontPicker (sak-extras.jsx). |
notification_preferences |
Per-type opt in/out — see Notification below. |
created_at / onboarded_at |
Relationships: a coach User has many student Users (via coach_id); a
student has one coach at a time but historical Feedback/CoachNote rows may
reference a different coach than the student's current one (substitute
coaches, prior-semester coaches — see SakCoachStudent's intentional
Ust. Rania reference, REVIEW.md A4).
Semester + Week
| Field | Notes |
|---|---|
Semester.id |
|
Semester.label |
e.g. "Fall 2026" |
Semester.start_date |
|
Semester.total_weeks |
Fixed at 15 — the strip is always total={15} (AUDIT.md "Consistency conventions locked": SakStrip total={15}). |
Semester.state |
pre_semester | active | complete. Pre-semester state renders SakTodayCountdown (sak-feedback.jsx F5) instead of an empty Today tab — countdown to start_date, with prep checklist items (meet coach, pick font, watch welcome lesson). |
Week.id |
|
Week.semester_id |
FK |
Week.number |
1–15 |
Week.starts_at / Week.ends_at |
Week boundary; submission deadline is Sunday 11:59 PM student-local time (REVIEW.md A3: "Due Sunday · 11:59 PM", not a hardcoded EST string). |
A student's "current week" is derived (Semester.active + today's date falls
within Week.starts_at..ends_at), not stored redundantly per user.
Lesson / Video
| Field | Notes |
|---|---|
id |
|
week_id |
FK — lessons are weekly. |
level |
Content gating: students see lessons up to their current level; coaches see all. |
title |
e.g. "Idghām with ghunnah" |
part_number |
Chapter/part within the syllabus TOC. |
videos |
Ordered list of 3 Video sub-records: { n, title, duration_sec, transcript } (sak-extras.jsx SakVideoPlayer: hardcoded 3-video chapter). |
| — | |
| VideoWatchProgress | Per (user, video): percent_watched, resumed_at_sec, watched_at (nullable — set once percent_watched >= 90, the mockup's completion threshold, SakVideoPlayer progress >= 90). Copy consistency rule (AUDIT.md wishlist): a video is either "N of M remaining" or "watched" — pick one vocabulary and use it everywhere, never both on the same screen. |
Assignment
| Field | Notes |
|---|---|
id |
|
week_id |
FK — one assignment per week per level. |
surah / ayah_range |
e.g. Al-Mulk 67:1–3. Citation string format is SURAH · CH:V[–V] uppercase mono (SakPassageCard cite prop). |
ayat |
Ordered list of āyah records: { num, text (Arabic), segments } — the same shape SakPassageCard renders and the same object a coach's word-anchored marks attach to (see Feedback.marks below). Scales from 1 to N āyāt; the passage card scrolls internally past ~5 lines rather than growing the screen (sak-feedback.jsx header comment, "LENGTH ROBUSTNESS"). |
rubric_id |
Optional FK — First Assessment / EOC (End of Chapter) rubrics surfaced before review (FEEDBACK-SYNTHESIS.md wishlist: "Assessment rubrics … surfaced before review"). |
Submission
The core Y1 object. One row per attempt (not per week) — a week can have up
to 2 submissions (the weekly attempt limit) plus any number of revoked/re-recorded
takes that don't count, and any number of practice takes attached afterward
(see PracticeTake below).
| Field | Notes |
|---|---|
id |
|
student_id |
FK |
week_id |
FK |
assignment_id |
FK |
attempt_number |
1 or 2 (of the 2/week cap — FEEDBACK-SYNTHESIS.md: "2 submissions/week limit"). A revoked-and-re-recorded submission does not increment this (see revoke window below). |
audio_url |
Student's recording. |
duration_sec |
|
submitted_at |
|
revoke_expires_at |
submitted_at + 5–10 min (exact value configurable; mockup shows a live countdown, e.g. "6:52 left", SakSubmissionPending). While now < revoke_expires_at, the student can revoke-and-re-record without consuming an attempt (FEEDBACK-SYNTHESIS.md: "5–10 min revoke window that does NOT count against the limit"; REVIEW.md A1 — this is the only screen that owns revoke UI, SakSubmissionPending, not the immediately-prior "recording submitted" screen). |
revoked_at |
Nullable — set if the student exercises the revoke window; the submission is voided and does not count toward the weekly cap. |
status |
See state machine in §2. Values used in the mockup's SubStatusPill (sak-extras.jsx): review ("In review"), reviewed ("Feedback ready"), heard, practiced. |
marked_heard_at |
Set when student taps "Mark as heard" (SakSubmissionFeedback). |
Relationships: one Submission has zero-or-one Feedback (coach hasn't
reviewed yet vs has); one Submission may have many PracticeTakes (attached
after feedback, don't consume attempts).
Feedback
Coach's response to a Submission. One-to-one with Submission.
| Field | Notes |
|---|---|
id |
|
submission_id |
FK, unique |
coach_id |
FK |
audio_url |
Coach's spoken feedback (SakSubmissionFeedback's ink card, e.g. "6:48" audio). |
duration_sec |
|
marks |
Word-anchored annotations on the assignment text: list of { ayah_num, word_index (n), tone } where tone is danger | warm (mockup: red = correction needed, gold = praised — PassMark/SAK_MULK_1_3 in sak-feedback.jsx). This is the "visual/annotated feedback synced to audio" coaches ranked #2 (FEEDBACK-SYNTHESIS.md). |
notes |
Ordered list of timestamped notes, each { mark_ref (n), tone, audio_timestamp_sec, title, body, ayah_ref } — stamped automatically at the coach's playhead position while recording (SakCoachReview: "stamped at playhead"). UI collapses to 3 visible + "Show N more" past that (SakFeedbackNotes, collapseAt = 3). |
private_note |
Coach-only text, never shown to the student (SakCoachReview's "Private — just me" tab). |
student_note |
Optional additional text shown to the student alongside the audio (SakCoachReview's "For Sarah" tab). |
pass_fail |
Optional boolean/enum outcome, distinct from the loop-state status on Submission. |
reviewed_at |
When the coach submitted feedback — this is what flips Submission.status review → reviewed. |
review_duration_sec |
Wall-clock time the coach spent in the review screen; feeds TimeLog/attendance auto-logging (SakCoachReview footer: "14:32 on this review · logged automatically" — replaces the manual Time Clock Wizard, FEEDBACK-SYNTHESIS.md coach ask #4). |
PracticeTake
A re-recording the student makes after receiving feedback, to practice a
correction. Does not consume a weekly attempt and is not itself submitted for
new review — it attaches to the existing Feedback thread.
| Field | Notes |
|---|---|
id |
|
feedback_id |
FK — attaches to the feedback thread, not a new submission (REVIEW.md C3: "Attach to feedback", not "Submit to Ust. Amina"). |
correction_ref |
Which Feedback.notes[n] this take addresses (the stepper in SakRecPractice walks corrections one at a time, "Correction 1 of 3"). |
audio_url / duration_sec |
|
recorded_at |
|
| — | No attempt_number, no attempts-dots UI — explicitly out of the weekly cap (REVIEW.md C3). |
Session
Live scheduled events: classes, QRC (Qur'an Reading Circle), Office Hours,
and (for Y2) one-to-one appointments (modeled separately, see Appointment,
because appointments have a distinct per-student booking/attendance lifecycle;
Session covers the shared/group event types).
| Field | Notes |
|---|---|
id |
|
type |
qrc | level_class | office_hours (mockup type chips: "QRC", "Class"/"Level Class", "Hours"/"Office Hours" — sak-schedule.jsx typeColors). |
title |
e.g. "Sisters' Reading Circle", "Idghām review & practice". |
coach_id |
FK — session leader. |
starts_at_utc |
Stored UTC; rendered per-viewer in their own timezone. No per-item zone suffix stored or displayed (REVIEW.md B3: delete every per-item "EST"/"EDT" suffix; one zone banner per screen instead). |
duration_min |
|
recurrence_rule |
e.g. weekly on Tuesday — most Sessions repeat every week of the semester. |
zoom_join_url |
|
level_filter |
Optional — restricts which students see/attend (Level II class vs Level I). |
cohort_size / registered_count |
For the "6 of 8 confirmed" cohort display (SakSessionDetail). |
materials |
"Bring with you" list: { icon, title, sub } — e.g. reading passage, prerequisite video (DetailMatRow, SakSessionDetail). |
agenda |
"What we'll cover" ordered list (DetailListRow). |
SessionRecording (child of Session, one per occurrence):
| Field | Notes |
|---|---|
id |
|
session_id |
FK (a specific occurrence, not the recurrence rule) |
occurred_at |
|
video_url |
|
duration_sec |
|
| — | Organized by week in a calendar/grouped view, not a flat list (FEEDBACK-SYNTHESIS.md wishlist: "Recordings organized in a calendar view"; implemented as SakScheduleRecordings's week-grouped list with a day strip). |
SessionWatchProgress (per user, per SessionRecording): percent_watched
(drives the thumbnail progress bar and "N% in" / "watched" chip,
RecordingRow in sak-schedule.jsx).
Appointment
Year-2-only 1:1 booking. Distinct from Session because it has a per-student
lifecycle (booked → attended/missed → escalation) rather than a shared
roster.
| Field | Notes |
|---|---|
id |
|
student_id |
FK — Y2 only; Y1 accounts never see these (REVIEW.md C2 annotation). |
coach_id |
FK — the student's single assigned coach. |
booking_mode |
recurring | this_week_only — the hybrid model from the teachers' call (FEEDBACK-SYNTHESIS.md "Decisions already made": lock the same weekly slot for all 15 weeks, or book week-to-week). Vocabulary locked to exactly these two words in coach-facing UI (REVIEW.md D1: "Recurring" / "This week only" — not synonyms). |
slot_start_utc |
Stored UTC. 20-minute slots (FEEDBACK-SYNTHESIS.md wishlist: "20-min slots"; SakBookAppointment slot grid). |
duration_min |
Fixed 20. |
week_id |
For this_week_only bookings; null/ignored for recurring (recurring bookings are described relative to the semester, not a single week — cancelling or swapping a specific week's occurrence is handled via AppointmentOccurrence below). |
zoom_join_url |
|
status |
active | cancelled — the recurring booking itself, not a single week's attendance. |
AppointmentOccurrence (one per week, generated from the Appointment
booking or from a one-off this_week_only booking):
| Field | Notes |
|---|---|
id |
|
appointment_id |
FK |
week_id |
FK |
scheduled_at_utc |
Normally inherited from the parent slot; may differ if swapped. |
swapped_from_occurrence_id |
Nullable — set when the student uses "Swap this week's time" (ApptActionRow in sak-appointments.jsx: "Keep your slot for the other weeks"). Swap moves this week's occurrence only; the recurring slot is untouched. |
attendance_status |
scheduled | attended | no_show | cancelled_by_student | cancelled_by_coach. Cancellation is guilt-free and accepted up to the last minute ("Even a minute before is fine — it frees the slot", ApptActionRow). |
cancelled_at |
|
consecutive_absence_count |
Derived/maintained counter across a student's occurrences; 2 consecutive no-shows trigger the auto email escalation (FEEDBACK-SYNTHESIS.md: "auto email escalation after 2 consecutive absences" — see Integration points, Email automation). |
CoachNote
Shared, persistent notes about a student, visible to every coach who has
ever met with that student — not scoped to the currently-assigned coach.
This is what powers SakCoachStudent's "Coach notes" section, explicitly
labeled "visible to all coaches", including the intentional Ust. Rania
substitute-coach example (REVIEW.md A4).
| Field | Notes |
|---|---|
id |
|
student_id |
FK |
author_coach_id |
FK — may not be the student's current assigned coach (substitute/prior coach). |
body |
Free text. |
week_id |
Optional context — "Week 6". |
context_tag |
Optional, e.g. "one-to-one" (distinguishes a note written during an Appointment vs during async Feedback review — SakCoachStudent: "Ust. Rania · Week 6 · one-to-one"). |
created_at |
|
| — | A lightweight derived/rollup view, "Working on" tags (e.g. "Madd length", "Ghunnah hold"), is collated from the coach's last 3 notes — see Derived views §4. |
Message
1:1 async thread between a student and their coach. Text or voice, with a weekly cap and a length guard.
| Field | Notes |
|---|---|
id |
|
thread_id |
One thread per (student, coach) pair — persists across coach reassignment for history, but new messages route to the current coach. |
sender_id |
student or coach User.id |
kind |
text | voice |
body_text |
For text |
audio_url / duration_sec |
For voice — coaches specifically asked for voice replies over the (underused, buggy-truncation) text box (FEEDBACK-SYNTHESIS.md coach ask #5). |
sent_at |
|
read_at |
Nullable |
| — | Weekly cap applies to the student's outbound message count per thread per week (mockup shows "2 of 3 messages left this week", SakMessageThread) — configurable count, resets each Week. Length guard: ~800-char warning on text composition (FEEDBACK-SYNTHESIS.md: "~800-char length warning, possible weekly caps"). Both guardrails are soft (warn, don't hard-block, except the weekly cap itself which does block once exhausted). |
Replaces the old community board; this is 1:1 only (both Y1 and Y2) —
one-to-many broadcast is Announcement, below.
Announcement
One-way board post. Replaces the community board (FEEDBACK-SYNTHESIS.md
decision #2).
| Field | Notes |
|---|---|
id |
|
author_id |
Coach or admin User.id, or a system/"QF Team" author. |
title |
|
body |
|
audience |
everyone | level (+ level_value) | year (+ year_value) — audience filters (FEEDBACK-SYNTHESIS.md: "audience filters by year/level"). Rendered as a chip on each row (AnnRow: "Level II", "Everyone"). |
pinned |
Boolean — pinned posts (e.g. break/schedule-change notices) sort to the top (SakAnnouncements). |
posted_at |
|
| — | AnnouncementRead per (user, announcement): read_at, drives the unread dot and the notif-sheet "Announcements · N pinned" badge count (REVIEW.md C1, B4). |
Business rule (REVIEW.md B4): when an announcement duplicates a fact also
pushed as a Notification (e.g. a schedule change), the copy must be
identical in both places — this is a content-authoring constraint, not a
separate field, but worth encoding as a single source-of-truth: the
Notification row of type announcement should reference the
Announcement.id and render its title, not carry independent copy.
Notification
Per-user delivery record. Types match the mockup's NotifRow type map
(sak-schedule.jsx): feedback, session, deadline, announcement.
| Field | Notes |
|---|---|
id |
|
user_id |
|
type |
feedback | session | deadline | announcement |
title / body |
|
ref_id |
Polymorphic FK to the underlying object (Feedback.id, Session.id/AppointmentOccurrence.id, Week.id for a deadline, Announcement.id). Type-announcement rows deep-link to the board, not to inline content (REVIEW.md B4/C1). |
created_at |
|
read_at |
Nullable |
push_sent_at |
Nullable — when the push notification was actually dispatched (may differ from created_at if batched/delayed by reminder-timing rules). |
NotificationPreference (per user, per type): enabled: boolean.
Announcements default ON (REVIEW.md B4 — the one exception; all other
types' defaults are a product decision left to the backend/product team, but
must be explicit per-type toggles, not one master switch, per
FEEDBACK-SYNTHESIS.md's "per-type preferences" wishlist item and the
mockup's SakNotifSheet/settings screens).
Reminder timing (business rules, not schema): 15-min reminder before student
sessions/appointments (SakWeekAtGlance: "Reminders arrive 15 minutes before
each session"; SakAppointmentDetail: "Reminder 15 minutes before · every
week"); coaches get a 30-min reminder (FEEDBACK-SYNTHESIS.md: "15-min
student / 30-min teacher reminders").
TimeLog / Attendance
Auto-derived, not manually entered — retires the "Time Clock Wizard"
(FEEDBACK-SYNTHESIS.md coach ask #4).
| Field | Notes |
|---|---|
id |
|
coach_id |
|
source |
zoom_session | feedback_review — two distinct auto-log paths. |
ref_id |
FK to the Session occurrence or Feedback.id that generated this entry. |
duration_sec |
For zoom_session: derived from the Zoom join-duration webhook. For feedback_review: wall-clock time spent in the coach review screen (Feedback.review_duration_sec, surfaced live in SakCoachReview: "14:32 on this review · logged automatically"). |
logged_at |
Attendance (derived view over Session/AppointmentOccurrence + Zoom
webhook data): a participant is marked attended if their Zoom join
duration is ≥ 75% of the scheduled duration (FEEDBACK-SYNTHESIS.md coach
ask #4: "auto-log hours/attendance from Zoom duration ≥75%"). This threshold
directly sets AppointmentOccurrence.attendance_status and a parallel
SessionAttendance { user_id, session_occurrence_id, attended: boolean, zoom_duration_pct } record for group sessions — addressing the "100% of the
time currently, we are going off of assumptions" attendance black hole
(FEEDBACK-SYNTHESIS.md top finding #4).
IssueReport
FEEDBACK-SYNTHESIS.md top finding #5: "Report Issue button on every
screen"; SakReportIssue (sak-comms.jsx F4).
| Field | Notes |
|---|---|
id |
|
user_id |
|
kind |
text | voice — "Narrate it instead" is an equal alternative to typing. |
body_text |
For text |
audio_url |
For voice |
screen_context |
Auto-captured: current screen/route name (e.g. "Today"). |
app_version |
Auto-captured, e.g. "2.0.1". |
account_context |
Auto-captured: user id/role snapshot at time of report (for support triage without asking the user to re-explain who they are). |
submitted_at |
|
| — | All context fields are captured automatically; the user only supplies the description (mockup: "Screen, app version and account attach automatically"). |
2. State machines
Submission lifecycle
States (from SubStatusPill, sak-extras.jsx, and REVIEW.md A2's
adjudication): in_review → reviewed → heard → practiced.
| From | To | Trigger | Notes |
|---|---|---|---|
| (none) | in_review |
Student submits audio for an assignment (submitted_at set). |
Starts the revoke window (revoke_expires_at) and the coach's 48-hour reply-window clock (SakSubmissionPending: "Feedback within 48 hours"). |
in_review |
(voided, not a state) | Student revokes within the window (revoked_at set before revoke_expires_at). |
Does not consume attempt_number; student re-records, creating effectively the same attempt slot. |
in_review |
reviewed |
Coach completes Feedback (reviewed_at set). |
Per REVIEW.md A2: "reviewed" = feedback just arrived, not yet practiced. SubStatusPill renders this "Feedback ready" (warm/gold) — visually distinct from in_review's "In review" (muted/ink), a fix mandated in REVIEW.md A2 because the two were "rendering nearly identically." |
reviewed |
heard |
Student taps "Mark as heard" on SakSubmissionFeedback (marked_heard_at set). |
Does not require a PracticeTake — "heard" only asserts the student listened/acknowledged. |
reviewed or heard |
practiced |
Student records and attaches ≥1 PracticeTake to this Feedback ("Attach to feedback", SakRecPracticeReview). |
Practice takes never consume a weekly attempt_number (REVIEW.md C3) and do not create a new Submission row. |
| any | (new Submission) | Student submits attempt 2 of the week, or the following week's assignment. | A new Submission row starts its own in_review state independent of prior weeks. |
Weekly cap: at most 2 Submission rows with a non-voided (revoked_at IS NULL) status per (student, week) pair. Revoked submissions do not count.
Appointment lifecycle
Applies to AppointmentOccurrence.attendance_status, with the parent
Appointment.booking_mode/status governing whether occurrences continue
to be generated.
| From | To | Trigger | Notes |
|---|---|---|---|
| (none) | booked (Appointment created, status: active) |
Student completes SakBookAppointment: chooses recurring (locks one weekly slot for all 15 weeks) or this_week_only (picks a slot for the current week alone). |
Hybrid model per the teachers' call (FEEDBACK-SYNTHESIS.md), resolving the "midnight slot-camping" / "students skipping weeks when their slot was taken" complaints. |
| booked | scheduled (occurrence generated) |
Weekly job materializes an AppointmentOccurrence for the upcoming Week, inheriting the booked slot time. |
For this_week_only, exactly one occurrence is created and the parent Appointment does not recur further. |
scheduled |
reminder sent | T-minus 15 min (student) / T-minus 30 min (coach). | Notification dispatch, not a stored state — see TimeLog/Notification integration. |
scheduled |
attended |
Zoom join-duration webhook reports ≥75% of the 20-min slot for the student. | Resets consecutive_absence_count to 0. |
scheduled |
no_show |
Slot time passes with join duration < 75% (or no join at all). | Increments consecutive_absence_count. |
scheduled |
cancelled_by_student |
Student taps "Can't make it? Cancel this week" — accepted up to the last minute, no penalty ("guilt-free cancel"). | Frees the slot for other students immediately; does not increment consecutive_absence_count (a cancellation is not an absence). |
scheduled (this occurrence only) |
swapped | Student taps "Swap this week's time" — books a different slot for this week only. | Sets swapped_from_occurrence_id; the recurring Appointment slot is unaffected for future weeks. |
no_show (×2 consecutive) |
escalation | consecutive_absence_count reaches 2. |
Fires the auto email escalation to program staff (FEEDBACK-SYNTHESIS.md: "auto email escalation after 2 consecutive absences") — see Integration points, Email automation. Does not change attendance_status itself; it's a side-effecting rule layered on the counter. |
| booked | Appointment.status: cancelled |
Student (or coach) cancels the recurring booking itself, not just one week. | Stops future occurrence generation; historical occurrences are untouched. |
Post-session survey loop (flagged in FEEDBACK-SYNTHESIS.md as a priority
even ahead of the app) is out of scope for this model's stored entities beyond
triggering off attendance_status transitions — see Backlog note in §5/6.
3. Business rules table
| Rule | Source |
|---|---|
| Weekly submission cap: 2 attempts per student per week. | FEEDBACK-SYNTHESIS.md wishlist requirements |
Revoke window: 5–10 min after submission (exact value configurable); revoking does not consume an attempt. SakSubmissionPending is the single owning screen for the countdown UI. |
FEEDBACK-SYNTHESIS.md; REVIEW.md A1 |
| Practice takes attached to feedback never consume a weekly attempt. | REVIEW.md C3 |
Submission status vocabulary is exactly in_review → reviewed → heard → practiced; in_review and reviewed must be visually and textually distinct. |
REVIEW.md A2 |
| Coach reply SLA: 48 hours from submission. | SakSubmissionPending copy ("Feedback within 48 hours"); coach queue's stated promise ("Your 48-hour promise holds", SakCoachQueue) |
| One coach per student (single assigned coach model); two-coach Y2 model rejected. | REVIEW.md A4, "Out of scope" |
| Historical/cross-coach notes remain visible even after a coach change or when a substitute covers a session. | REVIEW.md A4; CoachNote entity |
| All times render in the viewing user's own local timezone; never hardcode EST/EDT. | FEEDBACK-SYNTHESIS.md top finding #3; REVIEW.md A5, B3 |
| No per-item timezone suffix in the UI — one zone banner per screen instead. | REVIEW.md B3 |
Hybrid appointment booking: recurring (lock slot for all 15 weeks) or this_week_only. Vocabulary locked to "Recurring" / "This week only." |
FEEDBACK-SYNTHESIS.md decisions; REVIEW.md D1 |
| Appointment slots are 20 minutes. | FEEDBACK-SYNTHESIS.md wishlist |
| Cancelling an appointment (even a minute before) is accepted with no penalty and frees the slot. | SakAppointmentDetail (ApptActionRow copy) |
| Swapping a week's appointment time does not affect the recurring slot in other weeks. | SakAppointmentDetail |
| Two consecutive missed appointments trigger an automated email escalation. | FEEDBACK-SYNTHESIS.md wishlist |
| Attendance (sessions and appointments) is auto-derived from Zoom join duration ≥75% of scheduled duration — never self-reported. | FEEDBACK-SYNTHESIS.md coach ask #4 |
| Coach hours are logged automatically from feedback-review wall-clock time and Zoom session duration; no manual Time Clock Wizard entry. | FEEDBACK-SYNTHESIS.md coach ask #4; SakCoachReview |
| 1:1 messages: weekly cap on student-sent messages per thread (mockup default 3/week) and an ~800-char length warning on text composition. | FEEDBACK-SYNTHESIS.md decisions |
Announcements replace the community board; are one-way (student cannot reply on the board — must use Message to reach the coach). |
FEEDBACK-SYNTHESIS.md decisions; SakAnnouncements footer copy |
| Announcement audience filters: everyone / level / year. | FEEDBACK-SYNTHESIS.md wishlist |
| Announcements notification toggle defaults ON (the one type that does). | REVIEW.md B4 |
When the same fact is surfaced as both an Announcement and a Notification, the copy must match exactly. |
REVIEW.md B4 |
| Students see content (lessons/levels) up to their current level; coaches see all levels. | FEEDBACK-SYNTHESIS.md wishlist ("Content access") |
| Assessment rubrics (First Assessment, EOC) must be surfaced before a coach begins a review that includes them. | FEEDBACK-SYNTHESIS.md wishlist |
| Recordings are organized in a calendar/week-grouped view, never a flat list. | FEEDBACK-SYNTHESIS.md wishlist; SakScheduleRecordings |
| Report an Issue: screen name, app version, and account context are captured automatically; the user only supplies free-text or voice description. | FEEDBACK-SYNTHESIS.md top finding #5; SakReportIssue |
| Video watch progress ≥90% marks a video "watched"/complete. | SakVideoPlayer (completed = progress >= 90) |
| Pre-semester state: Today shows a countdown + prep checklist instead of an empty/blocked screen. | SakTodayCountdown (F5); FEEDBACK-SYNTHESIS.md wishlist ("semester calendar with pre-semester countdown state") |
| Year-1 accounts never see one-to-one appointment rows/screens (Year-2-only feature). | REVIEW.md C2 |
| A coach's queue defaults to oldest-submission-first, with filters by level and by fewest-submissions-reviewed. | SakCoachQueue (the coaches' own stated ask, FEEDBACK-SYNTHESIS.md "Unified coach workspace") |
4. Derived views the UI needs
Week at a glance (
SakWeekAtGlance): for a given student + week, allSessionoccurrences andAppointmentOccurrences the student is party to, plus the week's submission deadline, grouped by day, in the student's local timezone, each flagged with whether a 15-min reminder is set. Query shape:sessions ∪ appointments ∪ {deadline}filtered tostudent.enrollment ∩ week, sorted bystarts_at_utc, projected intostudent.timezone.Sessions tab — Upcoming (
SakSchedule): same underlying data as Week at a Glance but rendered as a day-by-day timeline for the selected date, with a day-strip covering the current week (± adjacent days). The Sessions tab's calendar-icon action routes to Week at a Glance — same data source, two density levels (REVIEW.md B2 — intentionally not merged into one component, but the spec should document that both read from the same query).Sessions tab — Recordings (
SakScheduleRecordings):SessionRecordingrows for sessions the student was enrolled in, joined toSessionWatchProgress, grouped byWeekdescending (most recent week first), each row showing type chip, title, coach, date, duration, and watched/partial/unwatched state.Coach review queue (
SakCoachQueue): allSubmissions withstatus = in_reviewwherecoach_id = current coach, default sort oldestsubmitted_atfirst, with the exact filter set coaches asked for: "Oldest first" (default), "By level", "Fewest submissions" (students with the fewest submissions this term — the coach questionnaire's "filter by number of submissions" ask, surfacing students at risk of disengagement). Each row surfaces: student name, level, week, wait duration, recording duration, and a flag if wait time exceeds the 48-hour SLA.Submissions archive (
SakSubmissions, student-facing): all of a student'sSubmissions across all semesters, grouped by recency ("This month" vs "Earlier", progressively disclosed past ~4 rows), each showing week, āyah range, days-since, duration, andSubStatusPillstatus. Summary stat header: count since semester start + count currentlyin_review, plus the semester progress strip. Must support multi-semester history (FEEDBACK-SYNTHESIS.mdwishlist: "full multi-semester history").Coach's student record — submission history (
SakCoachStudent): same underlyingSubmissionrows as the student archive, scoped to one student, visible to the assigned coach (and any coach with aCoachNoteon this student)."Working on" tag rollup (
SakCoachStudent): derived, not stored — collate the last 3CoachNote/Feedback.notesentries for a student into short tag phrases (e.g. "Madd length", "Ghunnah hold"). Explicitly called out in the mockup as AI-collated ("from your last 3 notes") — this is the one AI-assist wishlist item beyond feedback summarization that already appears in a screen; treat it as a lightweight summarization call over the same 3-note window, cached per student.Announcements board (
SakAnnouncements): allAnnouncements whereaudience = everyoneOR (audience = levelANDlevel_value = viewer's level) OR (audience = yearANDyear_value = viewer's year), pinned posts first, then reverse-chronological, with filter chips "All / My level / Schedule" (the third filter is a content-category filter, not an audience filter — likely acategoryfield onAnnouncementdistinct fromaudience, worth clarifying with product before implementation).Notifications inbox (
SakNotifSheet): allNotifications for the viewer, with a pinned "Announcements · N pinned" row above an "New · N" unread group, then "Earlier" — grouped byread_at IS NULLthen reverse chronological. Announcement-type rows deep-link to the Announcements board (REVIEW.md B4), not to inline content.Coach schedule (
SakCoachSchedule): the coach's ownSessions andAppointmentOccurrences for a day, each row taggedRecurringorThis week onlyfor 1:1 rows (REVIEW.md D1), with a live "Start" CTA when a session is imminent, in the coach's own timezone (not the student's).Recordings calendar (cross-cutting, feeds both
SakScheduleRecordingsand a coach-side equivalent):SessionRecordinggrouped byWeek, the calendar-view requirement from the wishlist rather than a flat reverse-chronological list.
5. Integration points
Zoom
- Every
Sessionoccurrence andAppointmentOccurrencecarries azoom_join_url(per-occurrence, since recurring meetings may rotate links or use a personal-meeting-room pattern — confirm with IT which Zoom account model is in use). - Join-duration webhook: Zoom's participant join/leave events feed the
Attendancederivation (TimeLog/attendance §1) — percentage of scheduled duration attended, threshold ≥75% for "attended." This webhook is the single source of truth for attendance; no manual RSVP/check-in exists in the mockup. - Recording ingestion: Zoom cloud recording completion webhook creates a
SessionRecordingrow and triggers processing (thumbnail, duration). - Embedded Zoom (in-app join without leaving the app) is explicitly
backlog, not in scope (
FEEDBACK-SYNTHESIS.md"Explicit backlog").
- Every
Push notifications
- Dispatches
Notificationrows per user perNotificationPreference(per-type opt-in/out,announcementdefaulting ON). - Timing rules: 15 minutes before student sessions/appointments; 30 minutes
before coach sessions. Deadline reminders keyed to
Week.ends_at(Sunday 11:59 PM, student-local). - Feedback-ready and message-received notifications fire on
Feedbackcreation andMessagecreation respectively.
- Dispatches
Email automation
- Absence escalation: triggered when
AppointmentOccurrence .consecutive_absence_countreaches 2 for a student — sends an email to program staff (recipient list is a program/admin configuration, not modeled here). This was flagged as a priority even ahead of the app itself (FEEDBACK-SYNTHESIS.mddecisions). - Coach 48-hour SLA breach could analogously trigger an internal alert email, though the mockup only surfaces this as a UI promise/flag in the coach queue, not a confirmed email rule — flag for product confirmation.
- Post-session survey loop ("attended? why not?" with AI-aggregated
trends) is called out as a priority in the IT discussion but has no
corresponding mockup screen; model it as a follow-up
Survey/SurveyResponsepair keyed toAppointmentOccurrence/SessionAttendanceif/when scoped — currently backlog.
- Absence escalation: triggered when
Calendar export (.ics)
- "Add to calendar" / "Add the week to my calendar" / "Add start date to
calendar" actions appear on
SakWeekAtGlance,SakAppointmentDetail,SakSessionDetail(QRC), andSakTodayCountdown. Each should generate an.icsVEVENT (or series, for recurringAppointments) using the same UTCstarts_at/durationfields as the in-app schedule, so the exported calendar and the in-app timezone rendering never disagree — the exact bug class the timezone-math fix (REVIEW.md A5) was created to prevent.
- "Add to calendar" / "Add the week to my calendar" / "Add start date to
calendar" actions appear on
Stripe
- A subscription "passport" per student, per the mockup's Profile →
Subscription screens (
AUDIT.mdper-screen log: "Subscription — Passport card", "Manage subscription — Plan card"). - Model as
Subscription { id, user_id, stripe_customer_id, stripe_subscription_id, plan, status, current_period_end }, with the Manage Subscription screen deep-linking to the Stripe customer portal (mockup dropped a duplicate in-app portal link, keeping one path to Stripe — AUDIT.md: "dropped duplicate Stripe portal link at bottom"). - Payment processing and PCI-relevant data stay entirely on Stripe's side; the backend only stores the customer/subscription IDs and cached status/plan for display.
- A subscription "passport" per student, per the mockup's Profile →
Subscription screens (
6. Explicitly out of scope (backlog)
Carried forward from FEEDBACK-SYNTHESIS.md and REVIEW.md so the schema
doesn't over-build for features not yet committed:
- Two-coach model for Year 2 (rejected for now — single assigned coach only).
- 20 appointment credits per semester (make-up sessions) — not modeled; revisit if scheduling capacity demands it.
- AI feedback summarization and AI post-session-survey trend analysis — beyond the "Working on" tag rollup (§4), which is a small, already-scoped exception.
- Student-centric QRC scheduling algorithm (cost-vs-value unresolved).
- Ready-made lesson asset banks for coaches.
- Peer community channel revival.
- Embedded Zoom (in-app playback without leaving the app).
- Program-strategy items (enrollment policy, email SLA, marketing) — not a data-model concern.