openapi: 3.1.0
info:
  title: roamgap API
  version: 0.1.0
  description: |
    Smart Holiday Optimizer — public REST surface.

    **Spec-first.** Every change to this document must accompany a `spec:`-prefixed
    pull request (see [specs/SPEC_FIRST.md](./SPEC_FIRST.md)). The runtime
    contract is enforced by Zod schemas in [specs/domain.ts](./domain.ts);
    JSON Schema for each entity is published at `/api/schemas/{Name}.json`.

    **AI-discoverable.** Bots and AI agents are explicitly welcomed. See
    `/llms.txt` for navigation guidance, `/.well-known/ai-content.json` for
    machine-readable index. Every content page has a Markdown twin reachable
    by appending `.md` to the URL.
  license:
    name: TBD
  contact:
    name: roamgap maintainers
    url: https://roamgap.ike.work

servers:
  - url: https://roamgap.ike.work
    description: Production
  - url: http://localhost:3000
    description: Local dev

tags:
  - name: planner
    description: Annual planner endpoint — the primary product surface.
  - name: optimizer
    description: Trip finder — focused, secondary view.
  - name: holidays
    description: Public-holiday data passthrough and cache.
  - name: league
    description: Holiday league — cross-country ranking + an optimizer PTO-leverage board (HOLIDAY_LEAGUE.md). Proxy of the backend aggregate.
  - name: schemas
    description: Machine-readable JSON Schema for every domain entity.
  - name: meta
    description: Health, version, discovery.

paths:
  /api/year:
    post:
      tags: [planner]
      summary: Build a year plan from an annual PTO budget
      description: |
        Given a country, year, annual PTO budget, and maximum trip length,
        return five alternative non-overlapping year plans. This is the
        **primary** product endpoint — see [PROJECT.md §4.1](./PROJECT.md).

        Each plan applies a different selection strategy (efficiency-packed,
        length-packed, quarterly spread, even spread, single-long). The
        contract is specified in [ALGORITHM.md §11](./ALGORITHM.md).
      operationId: planYear
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AnnualOptimizeInput"
            examples:
              germany-2027-default:
                summary: Germany, 2027, 25 PTO days, exhaust mode
                value:
                  country: DE
                  year: 2027
                  annualPtoBudget: 25
                  maxTripLength: 14
              kenya-2027-selective:
                summary: Kenya, 30 PTO, only the high-efficiency picks
                value:
                  country: KE
                  year: 2027
                  annualPtoBudget: 30
                  maxTripLength: 10
                  minTripLength: 4
                  budgetMode: selective
      responses:
        "200":
          description: Five labelled year plans + metadata
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AnnualOptimizeOutput"
        "400":
          $ref: "#/components/responses/InvalidInput"
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          $ref: "#/components/responses/UpstreamUnavailable"

  /api/trip:
    post:
      tags: [optimizer]
      summary: Compute the best single trip for a fixed PTO budget
      description: |
        Given a country, year, and PTO budget, return the top trips ranked by
        efficiency, length, or date-in-year (`sort`). This focused single-break
        endpoint has no web UI surface as of ADR-008 (the `view=trip` mode was
        removed); it remains available for API consumers. The annual planner at
        `/api/year` is the primary product surface.

        The contract is specified in detail in [specs/ALGORITHM.md §§1–6](./ALGORITHM.md).
        Every edge case named there has a unit test in
        `tests/unit/trip-optimizer.test.ts`.
      operationId: optimizeTrips
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/OptimizeInput"
            examples:
              germany-2026:
                summary: Germany, 2026, 10 PTO days
                value:
                  country: DE
                  year: 2026
                  ptoBudget: 10
              kenya-march-only:
                summary: Kenya, March only, 5 PTO days
                value:
                  country: KE
                  year: 2026
                  ptoBudget: 5
                  filters:
                    months: [3]
      responses:
        "200":
          description: Ranked trips + metadata
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OptimizeOutput"
        "400":
          $ref: "#/components/responses/InvalidInput"
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          $ref: "#/components/responses/UpstreamUnavailable"

  /api/overlap:
    post:
      tags: [overlap]
      summary: Find shared breaks across 1–6 parties (multi-country overlap)
      description: |
        Given 1–6 parties (each a country + optional subdivision + PTO budget +
        optional workweek, plus optional `alsoObserves` extra holiday calendars)
        and a year, return the calendar spans where **every** party can be off
        within their own budget, ranked by the least total leave burned across all
        parties, plus a year-wide three-state heatmap. A party with `alsoObserves`
        takes the **best of both** calendars; a single party is valid only when it
        observes ≥1 extra calendar (the dual-calendar worker — see OVERLAP.md §5.7).

        **Paid feature** ([PAYMENTS.md §10/§20](./PAYMENTS.md)). The route is
        account-only and gated by `@RequirePaidTier('multi_country_overlap')` on
        the backend — anonymous callers get `401`, signed-in-but-not-entitled
        callers get `403 unpaid_tier` (when enforcement is on). The contract is
        [`roamgap-backend/specs/OVERLAP.md`](../../backend/specs/OVERLAP.md); the
        web UX is [MULTI_COUNTRY.md](./MULTI_COUNTRY.md).
      operationId: computeOverlap
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/OverlapRequest"
            examples:
              couple-ke-de:
                summary: Kenya (18 PTO) + Bavaria (25 PTO), 2026
                value:
                  year: 2026
                  parties:
                    - id: you
                      country: KE
                      ptoBudget: 18
                    - id: partner
                      country: DE
                      subdivision: DE-BY
                      ptoBudget: 25
              dual-calendar-worker:
                summary: One person — KE resident, DE employer, "best of both" (25 PTO), 2026
                value:
                  year: 2026
                  parties:
                    - id: you
                      country: KE
                      ptoBudget: 25
                      alsoObserves:
                        - country: DE
      responses:
        "200":
          description: Ranked overlaps + a year-wide heatmap
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OverlapOutput"
        "400":
          $ref: "#/components/responses/InvalidInput"
        "401":
          description: Anonymous Bearer — overlap requires a signed-in account
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
        "403":
          description: Signed in but not entitled (`unpaid_tier`) when enforcement is on
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          $ref: "#/components/responses/UpstreamUnavailable"

  /api/holidays/{country}/{year}:
    get:
      tags: [holidays]
      summary: Public holidays for a country and year
      description: |
        Returns the cached holiday list for `(country, year)`. On cache miss,
        proxies the upstream provider (Nager.Date by default, OpenHolidays
        fallback) and caches the result. Past years never change once fetched.
      operationId: getHolidays
      parameters:
        - name: country
          in: path
          required: true
          schema: { $ref: "#/components/schemas/Iso3166Alpha2" }
        - name: year
          in: path
          required: true
          schema: { $ref: "#/components/schemas/Year" }
      responses:
        "200":
          description: Holiday list
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Holiday" }
        "404":
          description: Country not supported by any configured provider
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }

  /api/holiday-league/{year}:
    get:
      tags: [league]
      summary: Cross-country holiday league for a year
      description: |
        Published proxy of the backend `GET /league/{year}` — the four-board
        cross-country league (most public holidays, most on a working day, most
        lost to a weekend, and best PTO leverage). Structured data for agents;
        the `Dataset` distribution behind `/holiday-league/{year}`. See HOLIDAY_LEAGUE.md.
      operationId: getHolidayLeague
      parameters:
        - name: year
          in: path
          required: true
          schema: { $ref: "#/components/schemas/Year" }
      responses:
        "200":
          description: The four-board league
          content:
            application/json:
              schema: { $ref: "#/components/schemas/HolidayLeague" }
        "400":
          $ref: "#/components/responses/InvalidInput"

  /api/schemas:
    get:
      tags: [schemas]
      summary: List published JSON Schemas
      description: Index of every domain entity. Suitable for AI agents discovering the contract.
      operationId: listSchemas
      responses:
        "200":
          description: Schema index
          content:
            application/json:
              schema:
                type: object
                properties:
                  schemas:
                    type: array
                    items:
                      type: object
                      properties:
                        name: { type: string }
                        url: { type: string, format: uri }

  /api/schemas/{name}.json:
    get:
      tags: [schemas]
      summary: Fetch a single JSON Schema
      operationId: getSchema
      parameters:
        - name: name
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: JSON Schema for the named entity
          content:
            application/schema+json:
              schema: { type: object }
        "404":
          description: Unknown schema
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }

  /api/health:
    get:
      tags: [meta]
      summary: Liveness probe
      operationId: getHealth
      responses:
        "200":
          description: Service is up
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, enum: [ok] }
                  version: { type: string }
                  commit: { type: string, nullable: true }

components:
  responses:
    InvalidInput:
      description: Request failed Zod validation
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ApiError" }
    RateLimited:
      description: Rate limit exceeded
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ApiError" }
    UpstreamUnavailable:
      description: Holiday-data provider is down
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ApiError" }

  schemas:
    Iso3166Alpha2: { type: string, pattern: "^[A-Z]{2}$" }
    IsoDate: { type: string, pattern: "^\\d{4}-\\d{2}-\\d{2}$" }
    Year: { type: integer, minimum: 1900, maximum: 2100 }
    # Detailed schemas are published as standalone JSON files;
    # this document references them by name and the JSON Schema endpoint
    # at /api/schemas/{name}.json is the canonical machine-readable form.
    OptimizeInput:
      $ref: "/api/schemas/OptimizeInput.json"
    OptimizeOutput:
      $ref: "/api/schemas/OptimizeOutput.json"
    OverlapRequest:
      $ref: "/api/schemas/OverlapRequest.json"
    OverlapOutput:
      $ref: "/api/schemas/OverlapOutput.json"
    # Groups (Phase 4½) — the published schemas. The /api/groups/* surface itself
    # is a stateful same-origin proxy/rewrite to the backend (like /billing/*), so
    # it is intentionally NOT enumerated as paths here; the authoritative endpoint
    # contract is roamgap-backend/specs/openapi.yaml + GROUPS.md.
    Group:
      $ref: "/api/schemas/Group.json"
    GroupPreview:
      $ref: "/api/schemas/GroupPreview.json"
    GroupCreate:
      $ref: "/api/schemas/GroupCreate.json"
    GroupJoin:
      $ref: "/api/schemas/GroupJoin.json"
    GroupComment:
      $ref: "/api/schemas/GroupComment.json"
    GroupPoll:
      $ref: "/api/schemas/GroupPoll.json"
    AnnualOptimizeInput:
      $ref: "/api/schemas/AnnualOptimizeInput.json"
    AnnualOptimizeOutput:
      $ref: "/api/schemas/AnnualOptimizeOutput.json"
    Holiday:
      $ref: "/api/schemas/Holiday.json"
    HolidayLeague:
      $ref: "/api/schemas/HolidayLeague.json"
    ApiError:
      $ref: "/api/schemas/ApiError.json"
