openapi: 3.0.3

info:
  title: Active Travel Tracker API
  version: 1.0.0
  description: |
    Every endpoint here is called with an **API key**: a council- or
    school-scoped bearer token that a Council Administrator issues from
    **API keys** in the console.

    For how to issue and handle a key, see
    [Export your data](/help/en/council-export-and-api).

    ## API keys

    A key is scoped to **one council** or **one school** and is **read-only**:
    it holds the VIEWER role, so it can never satisfy a write or admin check.
    Every endpoint a key can reach is a `GET`. The scope is enforced per
    resource, and a request outside it returns `403` (or `404` when the
    resource does not exist), never data.

    - A **council** key covers that council and everything under it: its
      schools, classes, pupils and clusters.
    - A **school** key covers that school and everything under it. It also
      covers the *dashboard of a cluster its school belongs to*, which is the
      one place a school-scoped caller reads beyond its own school.
    - A key that is not recognised is `401`. It does not fall back to a session.

    The endpoints under *Roster* and *Pupil statistics* are the ones the
    classroom and pupil screens use, and a key reaches them because they
    authorise any caller with a read grant over the resource. They are
    documented because they are reachable, not because they are the intended
    integration surface. They return pupil-level data (pseudonymous display
    names, per-pupil streaks and badges). **Prefer `/api/v1/export` and the
    dashboard endpoints for reporting**, which are aggregate-only.

    ## Dates

    `from` and `to` are `YYYY-MM-DD` and are **Dublin school days, both ends
    inclusive**. A malformed date is `400`. When both are omitted a dashboard
    covers today only; `/api/v1/export` defaults to the last 30 days.

    ## Travel modes

    `WALK`, `CYCLE`, `SCOOTER`, `PARK_STRIDE`, `BUS`, `RAIL`, `CAR_SHARE`,
    `CAR`. The *active-travel rate* counts every mode except `CAR`.

    ## Data lifetime

    These endpoints read live `JourneyLog` rows. After the retention job has
    removed the pupil-level rows of an ended academic year
    (`RETENTION_ROLLUP_DELETE=true`), that year's data is only in the
    `JourneyAggregate` archive and is not returned by these endpoints.
  contact:
    name: Active Travel Tracker
    email: katie@thebikehub.ie
  license:
    name: Proprietary

servers:
  - url: https://tapin.ie
    description: Production

tags:
  - name: Export
    description: The programmatic extract. Aggregate-only.
  - name: Dashboards
    description: The figures behind the console dashboards. Aggregate-only.
  - name: Roster
    description: Schools' classes and pupils. Pupil-level, read-only for a key.
  - name: Pupil statistics
    description: Per-pupil streaks and badges, plus today's class check-ins.

security: []

paths:
  /api/v1/export:
    get:
      tags: [Export]
      operationId: exportJourneys
      summary: Journey counts by date, school, class and mode
      description: |
        The recurring-extract endpoint. One row per date, class and mode, with a
        count of journeys. No pupil appears in the response.

        Pull the morning after: journeys can be amended until 23:59 on the day
        they were recorded. Re-pulling a past period returns the same figures.
        Pass explicit `from` and `to`, because the 30-day default moves.
      security:
        - ApiKey: []
      parameters:
        - name: scope
          in: query
          description: Defaults to the key's own scope. Case-insensitive.
          schema: { type: string, enum: [school, council] }
        - name: id
          in: query
          description: The school or council id. Defaults to the key's own scope id.
          schema: { type: string }
        - $ref: '#/components/parameters/From30'
        - $ref: '#/components/parameters/To'
      responses:
        '200':
          description: Aggregated counts.
          content:
            application/json:
              schema:
                type: object
                required: [meta, data]
                properties:
                  meta:
                    type: object
                    required: [scope, id, from, to, totalRecords, note]
                    properties:
                      scope: { type: string, enum: [school, council] }
                      id: { type: string }
                      from: { type: string, format: date }
                      to: { type: string, format: date }
                      totalRecords:
                        type: integer
                        description: Number of rows in `data`, not the number of journeys.
                      note: { type: string }
                  data:
                    type: array
                    items:
                      type: object
                      required: [date, school, className, yearGroup, mode, count]
                      properties:
                        date: { type: string, format: date }
                        school: { type: string }
                        className: { type: string }
                        yearGroup: { type: string }
                        mode: { $ref: '#/components/schemas/TravelMode' }
                        count: { type: integer }
              example:
                meta:
                  scope: council
                  id: clx0council
                  from: '2026-09-01'
                  to: '2026-09-30'
                  totalRecords: 1
                  note: Data is aggregated by date/school/class/mode. No individual student identifiers are included.
                data:
                  - date: '2026-09-01'
                    school: St. Brigid's NS
                    className: 3rd Class A
                    yearGroup: 3rd
                    mode: WALK
                    count: 18
        '400':
          description: '`scope` is not `school` or `council`, or `from`/`to` is not a `YYYY-MM-DD` date.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '401':
          description: No key, or the key is not valid. Session cookies are not accepted here.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '403':
          description: The key does not cover the requested school or council.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404':
          description: The school or council does not exist.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /api/dashboard/council:
    get:
      tags: [Dashboards]
      operationId: getCouncilDashboard
      summary: Council dashboard
      description: |
        Active-travel figures for a whole council over a period. Requires a
        **council** key for this council; a school key gets `403`.

        Despite the `...Today` field names, the figures cover the whole period
        selected with `from`/`to`.
      security:
        - ApiKey: []
      parameters:
        - name: councilId
          in: query
          required: true
          schema: { type: string }
        - $ref: '#/components/parameters/FromToday'
        - $ref: '#/components/parameters/To'
      responses:
        '200':
          description: The council's figures.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CouncilDashboard' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/dashboard/usage:
    get:
      tags: [Dashboards]
      operationId: getCouncilUsage
      summary: Council usage report
      description: |
        Adoption rather than travel: which schools and classes are checking in,
        which have gone quiet, and a week-by-week series. Requires a **council**
        key. Counts are check-ins, not travel modes.

        The period is compared with the window of the same length immediately
        before it (`summary.prev`).
      security:
        - ApiKey: []
      parameters:
        - name: councilId
          in: query
          required: true
          schema: { type: string }
        - $ref: '#/components/parameters/FromToday'
        - $ref: '#/components/parameters/To'
      responses:
        '200':
          description: The usage report.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/UsageReport' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/dashboard/cluster:
    get:
      tags: [Dashboards]
      operationId: getClusterDashboard
      summary: Cluster dashboard
      description: |
        Comparison across the schools in a cluster. A **council** key for the
        cluster's council can read it, and so can a **school** key for a school
        that is a member of the cluster.
      security:
        - ApiKey: []
      parameters:
        - name: clusterId
          in: query
          required: true
          schema: { type: string }
        - $ref: '#/components/parameters/FromToday'
        - $ref: '#/components/parameters/To'
      responses:
        '200':
          description: The cluster's figures.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ClusterDashboard' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/dashboard/school:
    get:
      tags: [Dashboards]
      operationId: getSchoolDashboard
      summary: School dashboard
      description: |
        A school's figures for a period, with a per-class breakdown. A council
        key for the school's council, or a school key for this school.

        `weeklyTrend` and each class's `trend` always cover the last 10 school
        weekdays, whatever `from`/`to` say.
      security:
        - ApiKey: []
      parameters:
        - name: schoolId
          in: query
          required: true
          schema: { type: string }
        - $ref: '#/components/parameters/FromToday'
        - $ref: '#/components/parameters/To'
      responses:
        '200':
          description: The school's figures.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SchoolDashboard' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/classes:
    get:
      tags: [Roster]
      operationId: listClasses
      summary: A school's classes
      description: |
        The classes of one academic year, ordered by year group. Defaults to the
        current academic year. An unknown or empty year returns `[]`, not an
        error and not another year's classes.
      security:
        - ApiKey: []
      parameters:
        - name: schoolId
          in: query
          required: true
          schema: { type: string }
        - name: academicYearId
          in: query
          description: A past year's roster. Defaults to the current academic year.
          schema: { type: string }
      responses:
        '200':
          description: The classes.
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  required: [id, name, yearGroup, studentCount]
                  properties:
                    id: { type: string }
                    name: { type: string }
                    yearGroup: { type: string }
                    studentCount:
                      type: integer
                      description: Active pupils on the roll.
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/classes/{classId}:
    get:
      tags: [Roster]
      operationId: getClass
      summary: One class, with its school and council branding
      description: |
        Used by the classroom board to theme itself. The class's `parentCode` is
        never returned to an API key; only a signed-in session gets it.
      security:
        - ApiKey: []
      parameters:
        - name: classId
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: The class.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ClassDetail' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/students:
    get:
      tags: [Roster]
      operationId: listStudents
      summary: A class's pupils and what each logged today
      description: |
        Active pupils of one class, ordered by display name, with today's travel
        mode (a Dublin school day) or `null` if they have not checked in.

        **Pupil-level.** Display names are pseudonymous (generated animal names,
        or `Pupil-xxxx` once anonymised), but this is not an aggregate endpoint.
      security:
        - ApiKey: []
      parameters:
        - name: classId
          in: query
          required: true
          schema: { type: string }
      responses:
        '200':
          description: The pupils.
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  required: [id, displayName, todayMode]
                  properties:
                    id: { type: string }
                    displayName: { type: string }
                    todayMode:
                      $ref: '#/components/schemas/TravelModeOrNull'
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/students/{id}:
    get:
      tags: [Roster]
      operationId: getStudent
      summary: One pupil's record
      description: |
        The pupil row with its class. **Pupil-level.** Only active pupils are
        found; a deactivated pupil is `404`.

        The response also carries `statsToken`, a short-lived capability scoped
        to this one pupil, which the accountless `/journey` page uses against
        the *Pupil statistics* endpoints. It is `null` when no signing secret is
        configured.
      security:
        - ApiKey: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: The pupil.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/StudentDetail' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/journey:
    get:
      tags: [Pupil statistics]
      operationId: listTodaysJourneys
      summary: Today's check-ins for a class
      description: |
        Each pupil's journey recorded today (a Dublin school day), one entry per
        pupil who has checked in. **Pupil-level.** Writes to this path (`POST`,
        `DELETE`) are authorised by the school access code only; a key cannot
        make them.
      security:
        - ApiKey: []
      parameters:
        - name: classId
          in: query
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Today's check-ins.
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  required: [studentId, mode, time]
                  properties:
                    studentId: { type: string }
                    mode: { $ref: '#/components/schemas/TravelMode' }
                    time:
                      type: string
                      format: date-time
                      description: When the check-in was recorded.
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/journey/streak:
    get:
      tags: [Pupil statistics]
      operationId: getPupilStreak
      summary: One pupil's streak and active days
      description: |
        **Pupil-level.** The streak is consecutive days ending today on which
        the pupil logged an active mode, looking back at most 365 days.
      security:
        - ApiKey: []
      parameters:
        - $ref: '#/components/parameters/StudentId'
      responses:
        '200':
          description: The pupil's statistics.
          content:
            application/json:
              schema:
                type: object
                required: [studentId, activeDays, streak, activeDaysThisMonth, monthlyTarget]
                properties:
                  studentId: { type: string }
                  activeDays:
                    type: integer
                    description: Active journeys logged, all time.
                  streak:
                    type: integer
                  activeDaysThisMonth:
                    type: integer
                    description: Active days in the current Dublin calendar month.
                  monthlyTarget:
                    type: integer
                    description: Active days needed in a month for the Monthly Hero badge.
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/ServerError' }

  /api/journey/badges:
    get:
      tags: [Pupil statistics]
      operationId: getPupilBadges
      summary: One pupil's badges
      description: '**Pupil-level.** Newest first.'
      security:
        - ApiKey: []
      parameters:
        - $ref: '#/components/parameters/StudentId'
      responses:
        '200':
          description: The pupil's badges.
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  required: [id, type, awardedAt]
                  properties:
                    id: { type: string }
                    type:
                      type: string
                      enum: [FIRST_ACTIVE_DAY, STREAK_5, STREAK_10, STREAK_20, CHALLENGE_WINNER, MONTHLY_HERO]
                    awardedAt: { type: string, format: date-time }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

components:
  securitySchemes:
    ApiKey:
      type: http
      scheme: bearer
      description: |
        A council- or school-scoped, read-only key from **API keys** in the
        console. Shown once at creation; only a hash is stored. Send as
        `Authorization: Bearer <key>`.

  parameters:
    From30:
      name: from
      in: query
      description: First day, inclusive. Defaults to 30 days ago.
      schema: { type: string, format: date }
    FromToday:
      name: from
      in: query
      description: First day, inclusive. Defaults to today.
      schema: { type: string, format: date }
    To:
      name: to
      in: query
      description: Last day, inclusive. Defaults to today.
      schema: { type: string, format: date }
    StudentId:
      name: studentId
      in: query
      required: true
      schema: { type: string }

  responses:
    BadRequest:
      description: A required parameter is missing, or a date is not `YYYY-MM-DD`.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Unauthorized:
      description: No credential, or it is not valid.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Forbidden:
      description: The credential does not cover this resource.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    NotFound:
      description: The resource does not exist.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    ServerError:
      description: Unexpected failure.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }

  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error: { type: string }

    TravelMode:
      type: string
      enum: [WALK, CYCLE, SCOOTER, PARK_STRIDE, BUS, RAIL, CAR_SHARE, CAR]

    TravelModeOrNull:
      type: string
      nullable: true
      enum: [WALK, CYCLE, SCOOTER, PARK_STRIDE, BUS, RAIL, CAR_SHARE, CAR, null]

    ModeMix:
      type: object
      description: Journey count per travel mode. Modes with no journeys are absent.
      additionalProperties: { type: integer }

    SchoolType:
      type: string
      enum: [PRIMARY, SECONDARY]

    SchoolPin:
      type: object
      description: A school an admin has placed on the map.
      required: [name, nameAlt, latitude, longitude, studentCount, activeRate]
      properties:
        name: { type: string }
        nameAlt: { type: string, nullable: true, description: The Irish-language name. }
        latitude: { type: number }
        longitude: { type: number }
        studentCount: { type: integer }
        activeRate: { type: integer, description: Whole-number percentage. }

    CouncilDashboard:
      type: object
      required:
        - totalStudents
        - overallActiveRate
        - schoolCount
        - yearlyTrend
        - primaryVsSecondary
        - topMovers
        - councilKpis
        - councilModeMix
        - schoolLocations
      properties:
        totalStudents: { type: integer }
        overallActiveRate: { type: integer, description: Whole-number percentage. }
        schoolCount: { type: integer }
        yearlyTrend:
          type: array
          description: One entry per month with any journeys, oldest first.
          items:
            type: object
            required: [month, activePct, totalJourneys]
            properties:
              month: { type: string, example: 2026-09 }
              activePct: { type: integer }
              totalJourneys: { type: integer }
        primaryVsSecondary:
          type: object
          required: [primary, secondary]
          properties:
            primary: { $ref: '#/components/schemas/SchoolTypeBreakdown' }
            secondary: { $ref: '#/components/schemas/SchoolTypeBreakdown' }
        topMovers:
          type: array
          description: The five schools with the highest active rate.
          items:
            type: object
            required: [schoolName, schoolNameAlt, activeRate, logsToday]
            properties:
              schoolName: { type: string }
              schoolNameAlt: { type: string, nullable: true }
              activeRate: { type: integer }
              logsToday: { type: integer, description: Journeys in the period. }
        councilKpis:
          type: object
          required:
            - totalStudents
            - overallActiveRate
            - schoolCount
            - co2SavedThisYear
            - kmWalkedThisYear
            - kmCycledThisYear
            - totalJourneysThisYear
          properties:
            totalStudents: { type: integer }
            overallActiveRate: { type: integer }
            schoolCount: { type: integer }
            co2SavedThisYear: { type: number, description: 'Kilograms, modelled from journey counts.' }
            kmWalkedThisYear: { type: number, description: Modelled from journey counts. }
            kmCycledThisYear: { type: number, description: Modelled from journey counts. }
            totalJourneysThisYear: { type: integer }
        councilModeMix: { $ref: '#/components/schemas/ModeMix' }
        schoolLocations:
          type: array
          items: { $ref: '#/components/schemas/SchoolPin' }

    SchoolTypeBreakdown:
      type: object
      required: [schoolCount, totalStudents, activeRate]
      properties:
        schoolCount: { type: integer }
        totalStudents: { type: integer }
        activeRate: { type: integer }

    ClusterDashboard:
      type: object
      required: [schoolSummaries, clusterKpis, weeklyTrend, trend, clusterModeMix, schoolLocations]
      properties:
        schoolSummaries:
          type: array
          items:
            type: object
            required: [schoolName, schoolNameAlt, studentCount, activeRate, rank, modeMix]
            properties:
              schoolName: { type: string }
              schoolNameAlt: { type: string, nullable: true }
              studentCount: { type: integer }
              activeRate: { type: integer }
              rank: { type: integer, description: 1 is the highest active rate. }
              modeMix: { $ref: '#/components/schemas/ModeMix' }
        clusterKpis:
          type: object
          required: [totalStudents, checkedInToday, overallActiveRate, schoolCount, co2SavedThisTerm]
          properties:
            totalStudents: { type: integer }
            checkedInToday: { type: integer, description: Journeys in the period. }
            overallActiveRate: { type: integer }
            schoolCount: { type: integer }
            co2SavedThisTerm: { type: number, description: 'Kilograms, modelled.' }
        weeklyTrend:
          type: array
          description: The last 7 days, independent of `from`/`to`. Days with no journeys are absent.
          items:
            type: object
            required: [date, activePct]
            properties:
              date: { type: string, format: date }
              activePct: { type: integer }
        trend:
          type: array
          description: Monday-anchored weekly buckets across the selected period.
          items:
            type: object
            required: [weekStart, activePct]
            properties:
              weekStart: { type: string, format: date }
              activePct: { type: integer }
        clusterModeMix: { $ref: '#/components/schemas/ModeMix' }
        schoolLocations:
          type: array
          items: { $ref: '#/components/schemas/SchoolPin' }

    SchoolDashboard:
      type: object
      required: [schoolName, schoolNameAlt, kpis, todayMix, classSummaries, weeklyTrend]
      properties:
        schoolName: { type: string, nullable: true }
        schoolNameAlt: { type: string, nullable: true }
        kpis:
          type: object
          required: [checkedIn, total, activePct, co2Saved, kmWalked, kmCycled]
          properties:
            checkedIn:
              type: integer
              description: |
                Check-ins by active pupils, averaged over the days the school
                logged anything. Read it against `total`.
            total: { type: integer, description: Active pupils on the roll. }
            activePct: { type: integer }
            co2Saved: { type: number, description: 'Kilograms, modelled.' }
            kmWalked: { type: number, description: Modelled. }
            kmCycled: { type: number, description: Modelled. }
        todayMix:
          allOf: [{ $ref: '#/components/schemas/ModeMix' }]
          description: Mode mix across the selected period (the name is historical).
        classSummaries:
          type: array
          description: Highest active rate first.
          items:
            type: object
            required: [id, name, yearGroup, studentCount, checkedIn, activeRate, topMode, modeCounts, trend]
            properties:
              id: { type: string }
              name: { type: string }
              yearGroup: { type: string }
              studentCount: { type: integer }
              checkedIn: { type: integer }
              activeRate: { type: integer }
              topMode:
                $ref: '#/components/schemas/TravelModeOrNull'
              modeCounts: { $ref: '#/components/schemas/ModeMix' }
              trend:
                type: array
                description: Active percentage for each of the last 10 school weekdays, oldest first. 0 where there were no journeys.
                items: { type: integer }
        weeklyTrend:
          type: array
          description: The last 10 school weekdays, oldest first.
          items:
            type: object
            required: [date, activePct]
            properties:
              date: { type: string, format: date }
              activePct: { type: integer }

    UsageReport:
      type: object
      required: [summary, weekly, schools, quietClasses]
      properties:
        summary:
          type: object
          required:
            - totalSchools
            - activeSchools
            - everSchools
            - newSchoolsAdded
            - newSchoolsStarted
            - totalClasses
            - activeClasses
            - everClasses
            - newClassesStarted
            - totalStudents
            - totalCheckIns
            - activeDays
            - prev
          properties:
            totalSchools: { type: integer }
            activeSchools: { type: integer, description: Schools with a check-in in the period. }
            everSchools: { type: integer, description: Schools that have ever checked in. }
            newSchoolsAdded: { type: integer, description: Schools created in the period. }
            newSchoolsStarted: { type: integer, description: Schools whose first ever check-in was in the period. }
            totalClasses: { type: integer, description: Classes of the current academic year. }
            activeClasses: { type: integer }
            everClasses: { type: integer }
            newClassesStarted: { type: integer }
            totalStudents: { type: integer }
            totalCheckIns: { type: integer }
            activeDays: { type: integer, description: Distinct days with any check-in. }
            prev:
              type: object
              description: The same measures for the window of equal length immediately before the period.
              required: [activeSchools, activeClasses, checkIns]
              properties:
                activeSchools: { type: integer }
                activeClasses: { type: integer }
                checkIns: { type: integer }
        weekly:
          type: array
          description: Monday-anchored weeks, zero-filled.
          items:
            type: object
            required: [weekOf, checkIns, schools, classes]
            properties:
              weekOf: { type: string, format: date }
              checkIns: { type: integer }
              schools: { type: integer, description: Distinct schools that checked in. }
              classes: { type: integer, description: Distinct classes that checked in. }
        schools:
          type: array
          items:
            type: object
            required:
              - id
              - name
              - nameAlt
              - type
              - addedAt
              - addedInPeriod
              - startedInPeriod
              - stage
              - classes
              - activeClasses
              - students
              - checkIns
              - activeDays
              - lastCheckIn
            properties:
              id: { type: string }
              name: { type: string }
              nameAlt: { type: string, nullable: true }
              type: { $ref: '#/components/schemas/SchoolType' }
              addedAt: { type: string, format: date }
              addedInPeriod: { type: boolean }
              startedInPeriod: { type: boolean }
              stage:
                type: string
                description: |
                  Where the school is in getting going. `active` has check-ins in
                  the period; `quiet` has checked in before but not in the
                  period; `notStarted` has classes but has never checked in;
                  `noClasses` has no classes this year. `new` is reserved and
                  not currently produced.
                enum: [new, noClasses, notStarted, quiet, active]
              classes: { type: integer }
              activeClasses: { type: integer }
              students: { type: integer }
              checkIns: { type: integer }
              activeDays: { type: integer }
              lastCheckIn: { type: string, format: date, nullable: true }
        quietClasses:
          type: array
          description: Every class with no check-ins in the period.
          items:
            type: object
            required: [id, schoolId, schoolName, schoolNameAlt, name, students, lastCheckIn]
            properties:
              id: { type: string }
              schoolId: { type: string }
              schoolName: { type: string }
              schoolNameAlt: { type: string, nullable: true }
              name: { type: string }
              students: { type: integer }
              lastCheckIn: { type: string, format: date, nullable: true }

    ClassDetail:
      type: object
      required: [id, name, yearGroup, schoolId, school, features, branding, councilSlug]
      properties:
        id: { type: string }
        name: { type: string }
        yearGroup: { type: string }
        schoolId: { type: string }
        school:
          type: object
          required: [name]
          properties:
            name: { type: string }
        features:
          type: object
          description: The council's resolved feature switches, by feature name.
          additionalProperties: { type: boolean }
        branding:
          type: object
          required: [accentColor, logoUrl, banner, characters]
          properties:
            accentColor: { type: string, nullable: true }
            logoUrl: { type: string, nullable: true }
            banner:
              type: object
              nullable: true
              description: The council's board banner, if it has one.
              required: [src, alt]
              properties:
                src: { type: string, description: Path of the banner image. }
                alt: { type: string }
            characters:
              type: array
              nullable: true
              description: Image paths of the council's check-in characters, if it has them.
              items: { type: string }
        councilSlug:
          type: string
          nullable: true
          description: Null unless the council has the privacy-notice feature on and has published a notice.

    StudentDetail:
      type: object
      required: [id, displayName, classId, active, createdAt, class, statsToken]
      properties:
        id: { type: string }
        displayName: { type: string }
        classId: { type: string }
        active: { type: boolean, description: Always true; inactive pupils are not found. }
        deactivatedAt: { type: string, format: date-time, nullable: true }
        createdAt: { type: string, format: date-time }
        class:
          type: object
          description: The pupil's class row, with the size of its roll.
          required: [id, name, schoolId, academicYearId, yearGroup, createdAt, _count]
          properties:
            id: { type: string }
            name: { type: string }
            schoolId: { type: string }
            academicYearId: { type: string }
            yearGroup: { type: string }
            parentCode:
              type: string
              nullable: true
              description: The class's parent share-link code.
            createdAt: { type: string, format: date-time }
            _count:
              type: object
              properties:
                students: { type: integer, description: Active pupils on the roll. }
        statsToken:
          type: string
          nullable: true
          description: Short-lived token scoped to this pupil, for the *Pupil statistics* endpoints (`?statsToken=`).
