openapi: 3.0.3
info:
  title: Train Punctuality Registry API
  version: "0.1.0"
  description: >
    Per-stop empirical punctuality statistics for Polish railways, reconstructed from a daily
    T-2 harvest of the PDP operations feed and keyed by stable DatedServiceJourney ids.
    Consumed by my-train-backend.

    Statistics are precomputed nightly; the read endpoints only select and gate. Below the
    configured minimum sample size, a result is returned as `INSUFFICIENT_DATA` with the count,
    never a number built on a sample too thin to trust.
servers:
  - url: https://punctuality-api.local
  - url: http://localhost:8097
security:
  - ApiKeyAuth: []
tags:
  - name: read
    description: Public read API (API-key protected) — the surface my-train-backend calls.
  - name: admin
    description: Operator-only harvest, maintenance and settings (JWT protected).
paths:
  /health:
    get:
      tags: [read]
      summary: Liveness
      security: []
      responses:
        "200":
          description: Service is up
  /api/v1/punctuality/lookup:
    post:
      tags: [read]
      summary: Batch per-stop lookup (PRIMARY)
      description: >
        Answers a whole stop board (30-60 entries) in ONE call. Results preserve request order;
        a key with no data returns INSUFFICIENT_DATA rather than being dropped. The response
        carries an ETag derived from the aggregates' computedAt, which changes once a day.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/LookupRequest"
      responses:
        "200":
          description: One result per requested item, in order
          headers:
            ETag:
              description: computedAt of the aggregates (changes on the nightly recompute)
              schema: { type: string }
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LookupResponse"
        "400": { description: Invalid date or batch too large }
  /api/v1/punctuality/service:
    get:
      tags: [read]
      summary: Whole-route profile for one service
      parameters:
        - { name: operator, in: query, required: true, schema: { type: string } }
        - { name: trainNumber, in: query, required: true, schema: { type: string } }
        - { name: date, in: query, required: true, schema: { type: string, format: date } }
        - { name: window, in: query, required: false, schema: { $ref: "#/components/schemas/Window" } }
      responses:
        "200":
          description: Per-stop stats along the whole route
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LookupResponse"
  /api/v1/punctuality/stop/{stopPlaceId}:
    get:
      tags: [read]
      summary: Station-level summary across all trains
      parameters:
        - { name: stopPlaceId, in: path, required: true, schema: { type: string }, example: "PL:StopPlace:33605" }
        - { name: date, in: query, required: true, schema: { type: string, format: date } }
        - { name: window, in: query, required: false, schema: { $ref: "#/components/schemas/Window" } }
      responses:
        "200":
          description: Arrival and departure summaries for the station
  /api/v1/admin/harvest-runs:
    get:
      tags: [admin]
      summary: List harvest runs
      security: [ { JwtAuth: [] } ]
      parameters:
        - { name: from, in: query, schema: { type: string, format: date } }
        - { name: to, in: query, schema: { type: string, format: date } }
        - { name: status, in: query, schema: { type: string } }
      responses: { "200": { description: Runs, newest first } }
    post:
      tags: [admin]
      summary: Trigger a harvest for a date (fire-and-forget)
      security: [ { JwtAuth: [] } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [date]
              properties:
                date: { type: string, format: date }
                force: { type: boolean, default: false }
      responses: { "202": { description: Harvest started } }
  /api/v1/admin/coverage:
    get:
      tags: [admin]
      summary: Per-day completeness heat map
      security: [ { JwtAuth: [] } ]
      parameters:
        - { name: from, in: query, required: true, schema: { type: string, format: date } }
        - { name: to, in: query, schema: { type: string, format: date } }
      responses: { "200": { description: One entry per day } }
  /api/v1/admin/recompute:
    post:
      tags: [admin]
      summary: Force the aggregate recompute
      security: [ { JwtAuth: [] } ]
      responses: { "200": { description: Row counts + duration } }
  /api/v1/admin/storage:
    get:
      tags: [admin]
      summary: Partition sizes and row counts
      security: [ { JwtAuth: [] } ]
      responses: { "200": { description: Per-partition storage } }
  /api/v1/admin/settings:
    get:
      tags: [admin]
      summary: List runtime settings
      security: [ { JwtAuth: [] } ]
      responses: { "200": { description: All settings } }
components:
  securitySchemes:
    ApiKeyAuth:
      type: http
      scheme: bearer
      description: DB-backed API key as a bearer token.
    JwtAuth:
      type: http
      scheme: bearer
      description: Keycloak JWT (operator).
  schemas:
    Window:
      type: string
      enum: [LAST_30D, LAST_90D, LAST_90D_EXCL_PLANNED, TIMETABLE_CURRENT]
      default: LAST_90D
    EventType:
      type: string
      enum: [ARRIVAL, DEPARTURE]
    LookupRequest:
      type: object
      required: [date, items]
      properties:
        date: { type: string, format: date }
        window: { $ref: "#/components/schemas/Window" }
        items:
          type: array
          maxItems: 200
          items:
            type: object
            required: [operator, trainNumber, stopPlaceId, eventType]
            properties:
              operator: { type: string, example: "IC" }
              trainNumber: { type: string, example: "5400" }
              stopPlaceId: { type: string, example: "PL:StopPlace:33605" }
              eventType: { $ref: "#/components/schemas/EventType" }
    LookupResponse:
      type: object
      properties:
        success: { type: boolean }
        data:
          type: object
          properties:
            computedAt: { type: string, format: date-time, nullable: true }
            results:
              type: array
              items:
                oneOf:
                  - $ref: "#/components/schemas/Stat"
                  - $ref: "#/components/schemas/InsufficientData"
    Stat:
      type: object
      description: A served statistic (sample_size >= minSampleSize).
      properties:
        type: { type: string, enum: [Stat] }
        operator: { type: string }
        trainNumber: { type: string }
        stopPlaceId: { type: string }
        eventType: { $ref: "#/components/schemas/EventType" }
        sampleSize: { type: integer }
        medianDelayMin: { type: number, nullable: true }
        p85DelayMin: { type: number, nullable: true }
        p95DelayMin: { type: number, nullable: true }
        onTimePct: { type: number, nullable: true }
        thresholdMin: { type: integer, description: "5 (regional) or 15 (long-distance)" }
        cancelledPct: { type: number, nullable: true, description: whole-train cancellation rate }
        skippedPct: { type: number, nullable: true, description: this-stop skip rate }
        medianDelayGainMin: { type: number, nullable: true }
        trend: { type: string, nullable: true, enum: [IMPROVING, STABLE, WORSENING] }
        confidence: { type: string, enum: [LOW, MEDIUM, HIGH] }
        windowKey: { $ref: "#/components/schemas/Window" }
        dayType: { type: string, enum: [WORKDAY, SATURDAY, SUNDAY_HOLIDAY] }
        computedAt: { type: string, format: date-time, nullable: true }
    InsufficientData:
      type: object
      description: Fewer than minSampleSize observations — a count, never a number.
      properties:
        type: { type: string, enum: [InsufficientData] }
        operator: { type: string }
        trainNumber: { type: string }
        stopPlaceId: { type: string }
        eventType: { $ref: "#/components/schemas/EventType" }
        sampleSize: { type: integer }
        minSampleSize: { type: integer }
