convert pdf to md, add OVERVIEW and docs

This commit is contained in:
2026-08-17 22:30:18 +00:00
parent 95d5787868
commit 4df62d2609
44 changed files with 3307 additions and 1 deletions

View File

@@ -0,0 +1,81 @@
---
type: object
cluster: org
universe: live
status: verified
entity: app/models/classroom_enrollment.rb
---
# ClassroomEnrollment
A dated membership of one [student](../identity/student.md) in one
[classroom](classroom.md), with history. **The newer of the app's two roster paths** —
read `../../CONTEXT.md` before changing either.
Verified 2026-08-16 against commit `63732df`.
## Why this shape
It exists because `users.classroom_id` can only say where a student is *now*. A student
who moves classrooms mid-year, or returns to one next year, needs rows — so membership
became a record with `enrolled_at` / `unenrolled_at`, and "current" is simply
`unenrolled_at IS NULL` (`app/models/classroom_enrollment.rb:26-27`). Nothing is deleted
on unenrollment; the row is closed (`:50-53`).
**The `primary` flag is enforced in Ruby only.** `only_one_primary_per_student` does an
`exists?` check before save (`:24,78-86`) and `make_primary!` demotes siblings inside a
transaction (`:36-44`) — but the supporting index is *not* unique. It is a partial index
on `[student_id, primary] WHERE primary = true` (`db/schema.rb:74`), which speeds the
lookup without constraining it. Two concurrent writes can therefore produce two primary
enrollments, and `primary_enrollment` will just take `.first`
(`app/models/student.rb:34`).
`unenroll!` clears `primary` as well as setting the date (`:51`), so unenrolling a
student's primary classroom leaves them with **no** primary at all — `primary_classroom`
then falls back to the legacy `classroom_id` column
(`app/models/student.rb:41-43`), which `unenroll!` never touched.
## Shape
- Table `classroom_enrollments`, `db/schema.rb:64-76`
- `enrolled_at` `null: false`; `unenrolled_at` nullable = still enrolled
- `primary` boolean, default false, `null: false` (`db/schema.rb:68`)
- Scopes: `current`, `historical`, `primary_enrollment`, `for_student`, `for_classroom`
(`:26-30`)
- Writes: `make_primary!` (`:36-44`), `unenroll!` (`:50-53`)
- Validation: `unenrolled_at` must be ≥ `enrolled_at` (`:23,71-76`)
- No uniqueness constraint on `[student_id, classroom_id]` — repeat enrollments in the
same classroom are intentional (`:3-8`)
## Connected to
- **owns:** —
- **owned-by:** [student](../identity/student.md), [classroom](classroom.md)
- **joins:** student ↔ classroom, over time
- **looks-like-but-is-not:** this is **not** `users.classroom_id`. Both are live. A
student can be enrolled here and absent from `Classroom#students`, or the reverse.
## If you change this
- **Hits:** [student](../identity/student.md) — `current_classrooms`,
`primary_enrollment`, `primary_classroom`, `enroll_in!`, `unenroll_from!`;
[classroom](classroom.md)`#current_students` / `#historical_students`;
`ClassroomEnrollmentsController`; `ClassroomFacade`, which builds the teacher's roster
view.
- **Does not hit:** [grade-entry](../gradebook/grade-entry.md). Entries are keyed to
`grade_book_id` + `user_id` (`db/schema.rb:118`) and carry no enrollment reference —
unenrolling a student does **not** remove or hide their gradebook rows, and
`DistributeEarnings` will still pay them.
## Surfaces
| Surface | Role |
|---|---|
| `ClassroomEnrollmentsController` | create, destroy, unenroll |
| `ClassroomFacade` | reads the roster |
| `Student#enroll_in!` / `#unenroll_from!` | writes |
## See
- Source: `app/models/classroom_enrollment.rb`, `db/schema.rb:64-76`
- The trap: `../../CONTEXT.md`

View File

@@ -0,0 +1,83 @@
---
type: object
cluster: org
universe: live
status: verified
entity: app/models/classroom.rb
---
# Classroom
One teacher's class within a [school-year](school-year.md). The unit teachers actually
work in, and **the switch that turns trading on**.
Verified 2026-08-16 against commit `63732df`.
## Why this shape
Two things make this more than a grouping.
**1. `trading_enabled` defaults to `false`** (`db/schema.rb:93`). Order creation validates
it (`app/models/order.rb:26,203-207`), reaching the classroom by delegation through the
user (`app/models/user.rb:26`). A brand-new classroom therefore **cannot trade** until a
teacher flips it via `PATCH /classrooms/:id/toggle_trading` (`config/routes.rb:22`). If
trading "silently doesn't work," check this column first.
**2. Creating a classroom creates its gradebooks** — one per quarter of its school-year,
via `after_create` (`app/models/classroom.rb:29,112-116`). It uses `find_or_create_by!`,
so it is idempotent, but it only runs on create: adding a quarter later does **not**
backfill gradebooks for existing classrooms.
The class also holds **both rosters** (see the trap in `../../CONTEXT.md`):
`students` reads the legacy `users.classroom_id` column (`:20`) while `current_students`
reads [classroom-enrollment](classroom-enrollment.md) (`:74-78`). They can disagree.
## Shape
- Table `classrooms`, `db/schema.rb:88-96` — `name`, `archived`, `trading_enabled`,
`school_year_id`
- `GRADE_RANGE` — a frozen **Array** of levels 5–8, built from `MIN_GRADE`/`MAX_GRADE`
(`:4-6`); middle school only
- Rosters: `has_many :students, -> { kept }` on `classroom_id` (`:20`);
`has_many :enrolled_students, through: :classroom_enrollments` (`:19`)
- `has_many :users, dependent: :nullify` (`:15`) — deleting a classroom orphans users
rather than deleting them
- `has_many :grade_books, dependent: :destroy` (`:23`)
- `has_many :grades, through: :classroom_grades` (`:22`); must have at least one (`:27,118-120`)
- Sorting: `apply_sorting` + three scopes, including `order_by_total_earnings`, which
joins all the way to `portfolio_transactions` (`:41-49`)
- `grades_display` collapses `[5,6,7]` to `"5th-7th"` (`:89-104`)
## Connected to
- **owns:** [grade-book](../gradebook/grade-book.md),
[classroom-enrollment](classroom-enrollment.md)
- **owned-by:** [school-year](school-year.md)
- **joins:** [teacher](../identity/teacher.md) via `teacher_classrooms`,
[grade-level](grade-level.md) via `classroom_grades`
- **looks-like-but-is-not:** `classroom.students` ≠ `classroom.current_students`.
The first is the `classroom_id` column, the second is active enrollments.
## If you change this
- **Hits:** [order](../trading/order.md) — creation is gated on `trading_enabled`;
[grade-book](../gradebook/grade-book.md) via the create cascade and `dependent: :destroy`;
[student](../identity/student.md) rosters, both of them; `Order.for_teacher`
(`app/models/order.rb:40-42`) and `GradeBooksController`, which redirects
non-admins away from archived classrooms (`app/controllers/grade_books_controller.rb:47-50`).
- **Does not hit:** [portfolio](../money/portfolio.md). Portfolios belong to users and
survive `dependent: :nullify` intact — archiving or deleting a classroom never touches
a balance or a holding.
## Surfaces
| Surface | Role |
|---|---|
| `ClassroomsController` | teacher CRUD, `toggle_trading` |
| `Admin::ClassroomsController` | admin CRUD, `toggle_archive` |
| `ClassroomFacade`, `ClassroomPresenter` | read |
| `GradeBooksController` | reads (authorization + archive gate) |
## See
- Source: `app/models/classroom.rb`, `db/schema.rb:88-96`

View File

@@ -0,0 +1,73 @@
---
type: object
cluster: org
universe: live
status: verified
entity: app/models/grade.rb
---
# Grade level — class `Grade`
A school grade level: 5th through 8th. **Not a letter grade.** The class is called
`Grade`; this card is named `grade-level` to keep the two apart.
Verified 2026-08-16 against commit `63732df`.
## Why this shape
A classroom can span several grade levels, so the link is many-to-many through
`classroom_grades` rather than a column on `classrooms`. A classroom must carry at least
one (`app/models/classroom.rb:27,118-120`), and `Classroom#grades_display` collapses a
contiguous set into `"5th-7th"` for display (`app/models/classroom.rb:89-104`).
`Grade` rows are reference data seeded once, not created by users — hence
`dependent: :restrict_with_error` (`app/models/grade.rb:4`): a level in use cannot be
deleted.
**The name collision is the point of this card.** `Grade#level` is `5..8`;
[grade-entry](../gradebook/grade-entry.md)`#math_grade` is `"A+"`…`"F"`. They share the
word "grade" and nothing else — no association, no foreign key, no shared table.
## Shape
- Table `grades`, `db/schema.rb:123-130` — `level` (integer) and `name` (string), both
`null: false` and both uniquely indexed
- `validates :name` uniqueness is `case_sensitive: false`; `:level` uniqueness is plain
(`app/models/grade.rb:7-8`)
- Join table `classroom_grades`, `db/schema.rb:78-86`, unique on
`[classroom_id, grade_id]` (`db/schema.rb:83`). It has a model (`app/models/classroom_grade.rb`)
but no behaviour, so no card.
- `Classroom::GRADE_RANGE` (`app/models/classroom.rb:4-6`) is a **separate** frozen array
of 5–8. The classroom form filters these rows through it —
`Grade.where(level: Classroom::GRADE_RANGE)`
(`app/views/classrooms/_form.html.erb:58`) — so a `Grade` row outside 5–8 exists but is
unselectable. The constant is not derived from the rows and can drift from them.
## Connected to
- **owns:** —
- **owned-by:** —
- **joins:** [classroom](classroom.md), through `classroom_grades`
- **looks-like-but-is-not:** not a letter grade
([grade-entry](../gradebook/grade-entry.md)), and not
[grade-book](../gradebook/grade-book.md).
## If you change this
- **Hits:** [classroom](classroom.md) — validation, `grades_display`, and the classroom
forms; seeds (`db/seeds`), which create these rows.
- **Does not hit:** any earnings. Nothing in `DistributeEarnings` or
[grade-entry](../gradebook/grade-entry.md) reads `Grade` — payouts are computed from
letter grades and attendance only, so adding or renaming a level never changes a
payout.
## Surfaces
| Surface | Role |
|---|---|
| `ClassroomsController`, `Admin::ClassroomsController` | read (form checkboxes) |
| seeds | writes |
## See
- Source: `app/models/grade.rb`, `app/models/classroom_grade.rb`, `db/schema.rb:123-130`

View File

@@ -0,0 +1,68 @@
---
type: object
cluster: org
universe: live
status: verified
entity: app/models/quarter.rb
---
# Quarter
One of four grading periods inside a [school-year](school-year.md). Auto-created in sets
of four; never made by hand.
Verified 2026-08-16 against commit `63732df`.
## Why this shape
`Quarter#previous` is **load-bearing for money**, not just navigation. Improvement
bonuses compare a student's letter grade against the same student's grade in the previous
quarter's gradebook, and `DistributeEarnings` finds it by calling `quarter.previous`
(`app/services/distribute_earnings.rb:35-38`). If `previous` returns `nil`, the
improvement bonus silently pays zero — the run still succeeds.
That is why `previous` and `next` cross **year** boundaries rather than stopping at 1 and
4: quarter 1 reaches back to quarter 4 of the same school's previous year
(`app/models/quarter.rb:20-24,44-58`), matching on `school` and `year`, not on ID order.
So the bonus keeps working across a September rollover — but only if the previous year's
`Year` record exists and its name parses (see [year](year.md)).
## Shape
- Table `quarters`, `db/schema.rb:188-196`; unique on `[school_year_id, number]` (`db/schema.rb:194`)
- `belongs_to :school_year`; FK is `on_delete: :cascade` (`db/schema.rb:420`)
- `has_many :grade_books, dependent: :restrict_with_error` (`:5`)
- `number` — `1..4`, validated for inclusion and uniqueness per school-year (`:7-10`)
- `scope :ordered` by number (`:12`)
- `next` (`:14-18`), `previous` (`:20-24`) — both memoized, both may return `nil`
## Connected to
- **owns:** [grade-book](../gradebook/grade-book.md) (blocks its own deletion)
- **owned-by:** [school-year](school-year.md)
- **joins:** —
- **looks-like-but-is-not:** `quarter.previous` is not "number − 1". At number 1 it is a
**different school-year's** quarter 4, found by school + previous year.
## If you change this
- **Hits:** [grade-book](../gradebook/grade-book.md) — one per quarter per classroom;
the [finalize-gradebook-earnings](../../processes/finalize-gradebook-earnings.md)
movement, specifically the improvement bonus;
[portfolio-transaction](../money/portfolio-transaction.md) amounts, one step further
on, because that bonus becomes a deposit.
- **Does not hit:** [classroom-enrollment](classroom-enrollment.md). Enrollment windows
are plain timestamps (`enrolled_at` / `unenrolled_at`) and are **not** scoped to
quarters — a quarter change does not move anyone on or off a roster.
## Surfaces
| Surface | Role |
|---|---|
| `GradeBooksController` | reads (to label the gradebook) |
| `DistributeEarnings` | reads `previous` |
| `SchoolYear#create_quarters` | writes |
## See
- Source: `app/models/quarter.rb`, `db/schema.rb:188-196`

View File

@@ -0,0 +1,68 @@
---
type: object
cluster: org
universe: live
status: verified
entity: app/models/school_year.rb
---
# SchoolYear
One school's instance of one [year](year.md) — the join that everything academic hangs
from.
Verified 2026-08-16 against commit `63732df`.
## Why this shape
It is a join table that grew behaviour. Creating a `SchoolYear` **auto-creates exactly
four [quarters](quarter.md)** (`app/models/school_year.rb:12,20-24`), which is the first
link in a two-step cascade that ends in gradebooks:
```
SchoolYear created → 4 Quarters → (later) Classroom created → 1 GradeBook per quarter
```
Neither half is optional and neither is done by a controller. If quarters or gradebooks
are ever missing, the cause is almost always that this callback did not run — the object
was built by `insert_all`, a fixture, or a migration that skipped callbacks.
`name` is computed, not stored: `"#{school_name} (#{year_name})"` (`:14-16`).
## Shape
- Table `school_years`, `db/schema.rb:198-206`; unique on `[school_id, year_id]` (`db/schema.rb:203`)
- `belongs_to :school`, `belongs_to :year` (`:4-5`)
- `has_many :classrooms, dependent: :restrict_with_error` (`:6`) — blocks deletion
- `has_many :quarters, dependent: :destroy` (`:7`) — cascades
- `after_create :create_quarters` (`:12`)
## Connected to
- **owns:** [quarter](quarter.md) (creates and destroys them),
[classroom](classroom.md) (blocks its own deletion)
- **owned-by:** [school](school.md), [year](year.md)
- **joins:** school ↔ year
- **looks-like-but-is-not:** not [year](year.md). Deleting a `Year` cascades to
`SchoolYear`; deleting a `School` does not.
## If you change this
- **Hits:** [quarter](quarter.md) directly — the count, numbering, and names of quarters
are decided here; [classroom](classroom.md), which validates its `school_year_id`
(`app/models/classroom.rb:122-124`); [grade-book](../gradebook/grade-book.md) at one
remove, since classrooms create one per quarter.
- **Does not hit:** [grade-entry](../gradebook/grade-entry.md). Entries are created per
student against an existing gradebook, never by this cascade — adding a quarter gives
you empty gradebooks, not populated ones.
## Surfaces
| Surface | Role |
|---|---|
| `Admin::SchoolYearsController` | admin CRUD |
| `SchoolYearPresenter` | reads |
## See
- Source: `app/models/school_year.rb`, `db/schema.rb:198-206`

View File

@@ -0,0 +1,59 @@
---
type: object
cluster: org
universe: live
status: verified
entity: app/models/school.rb
---
# School
A participating school. Little more than a name — it exists to be the thing a
[school-year](school-year.md) attaches to.
Verified 2026-08-16 against commit `63732df`.
## Why this shape
Deliberately thin: the table is `id`, `name`, timestamps (`db/schema.rb:208-212`). All
real structure lives one level down in [school-year](school-year.md), because the same
school recurs every year and nothing about the school itself changes when it does.
Deletion is blocked, not cascaded — `dependent: :restrict_with_error`
(`app/models/school.rb:4`). A school with any history cannot be removed.
## Shape
- Table `schools`, `db/schema.rb:208-212` — `name` only
- `has_many :school_years, dependent: :restrict_with_error` (`:4`)
- `has_many :years, through: :school_years` (`:5`)
- `validates :name, presence: true` (`:7`) — note the column itself is nullable
## Connected to
- **owns:** [school-year](school-year.md)
- **owned-by:** —
- **joins:** [year](year.md), through `school_years`
- **looks-like-but-is-not:** `User#school` is a **delegation through classroom**
(`app/models/user.rb:22-24`), not an association. A user with no classroom has no
school, and that is why the delegate is `allow_nil`.
## If you change this
- **Hits:** [school-year](school-year.md) and everything under it;
`Admin::SchoolsController`; `Portfolio#school_name`, which reaches back up through
user → classroom → school (`app/models/portfolio.rb:9`).
- **Does not hit:** the top-level `SchoolsController` and `app/views/schools/*`. Those
are a **ghost** — no route reaches them (see `../../CONTEXT.md`). Editing them changes
nothing that runs.
## Surfaces
| Surface | Role |
|---|---|
| `Admin::SchoolsController` | admin CRUD (the live one) |
| `SchoolsController` | **none — unrouted ghost** |
## See
- Source: `app/models/school.rb`, `db/schema.rb:208-212`

View File

@@ -0,0 +1,68 @@
---
type: object
cluster: org
universe: live
status: verified
entity: app/models/year.rb
---
# Year
An academic year, identified by the **string** `"2024 - 2025"`. Shared across all schools.
Verified 2026-08-16 against commit `63732df`.
## Why this shape
The whole model hangs on a parsed string. `years` has exactly one meaningful column,
`name` (`db/schema.rb:393-398`) — there is no `start_year` or `end_year` integer. So:
- ordering casts a substring to int in SQL:
`CAST(SUBSTRING(name FROM 1 FOR 4) AS INTEGER)` (`app/models/year.rb:10`)
- `previous_year` / `next_year` do **string arithmetic** on the split halves
(`:21-27`, `:39-41`)
- `current_school_year` builds the expected name from today's date, rolling over in
**July** — months 1–6 belong to the year that started last calendar year (`:12-19`)
The format `"YYYY - YYYY"` — spaces around the hyphen included — is therefore
load-bearing. A record named `"2024-2025"` sorts and navigates wrong without raising.
## Shape
- Table `years`, `db/schema.rb:393-398`; `name` `null: false`, unique index
- `validates :name, presence: true, uniqueness: true` (`:8`)
- `has_many :school_years, dependent: :destroy` (`:5`) — **cascades**, unlike
[school](school.md)
- `has_many :classrooms, through: :school_years` (`:7`)
- `scope :ordered_by_start_year` (`:10`)
- `self.current_school_year(date = Date.current)` returns a **relation**, not a record
(`:12-19`)
## Connected to
- **owns:** [school-year](school-year.md) (destroys them)
- **owned-by:** —
- **joins:** [school](school.md), through `school_years`
- **looks-like-but-is-not:** `Year` is not [school-year](school-year.md). `Year` is the
calendar span shared by every school; `SchoolYear` is one school's instance of it.
## If you change this
- **Hits:** [school-year](school-year.md) — `dependent: :destroy` means deleting a year
deletes school-years, and their [quarters](quarter.md) cascade too
(`db/schema.rb:420`); any admin year dropdown ordering (`:10`);
[quarter](quarter.md)`#next`/`#previous`, which cross year boundaries by calling
`Year#next_year` (`app/models/quarter.rb:30,46`).
- **Does not hit:** [classroom](classroom.md) rows directly. Classrooms belong to a
`school_year`, not a `year` — the `through:` association is read-only convenience.
## Surfaces
| Surface | Role |
|---|---|
| `Admin::SchoolYearsController` | reads for selection |
| `SchoolYearPresenter` | reads |
## See
- Source: `app/models/year.rb`, `db/schema.rb:393-398`