Skip to content

15. Location Calendar — TRA architecture

Date: 2026-05-20

Status

Accepted. Shipped in M1 (TOP-4831, TOP-4832, TOP-4835, TOP-4836) and TOP-5068 (plannedLocation resolver).

Context

We need a per-vehicle, date-bounded schedule of planned locations on the TRA side so that:

  1. operators can declare seasonal base schedules in the UI;
  2. internal flows (reservation creation, offer generator) can ask “where is this car planned to be on date X?”;
  3. the schedule can be mirrored to Billion for search and delivery-price purposes.

The shape and semantics of the schedule on Billion’s side are decided independently (see ADR-0016); this ADR is the TRA-side decision only.

The design doc proposed enforcing non-overlapping entries at the database level (GiST exclusion constraint with inclusive bounds). During implementation that was relaxed: overlapping ranges are useful (e.g. a long base layer with one-off exceptions over the top), and the resolution algorithm makes the winner deterministic without a database constraint. The schema reflects that decision.

This is also the first feature to ship under the new Resolver → Service → Repository pattern described in ADR-0014. It is the PoC the ADR refers to.

Decision

Data model — Postgres via Prisma

model vehicle_location_calendar {
id String @id @default(dbgenerated("snowflake_id()"))
vehicleId String
tenantId String
locationAddressId String // Google Places id
locationData Json // snapshot of the Address (coords, city, country, formatted)
dateFrom DateTime @db.Date
dateTo DateTime? @db.Date // null = indefinite
priority Int @default(0)
createdBy String?
createdAt DateTime @default(now()) @db.Timestamptz(6)
updatedAt DateTime @db.Timestamptz(6)
@@index([vehicleId, dateFrom, dateTo])
@@index([tenantId])
}

Defined in packages/prisma-client/prisma/schema.prisma. Migration: packages/prisma-client/prisma/migrations/20260413141512_add_vehicle_location_calendar/. The migration installs the btree_gist extension (verified available on Neon during TOP-4831) but does not declare a GiST exclusion constraint — overlaps are allowed.

Date columns are @db.Date (no time component). The only DB-level integrity check is dateTo IS NULL OR dateTo >= dateFrom. Tenant scoping is enforced via RLS in the repository layer (TenantBaseService); the tenantId column is denormalised on the row for RLS predicate efficiency.

Resolution algorithm — narrowest span wins

VehicleLocationCalendarService.resolveWinningEntry() (services/tenant/vehicle-location-calendar/VehicleLocationCalendarService.ts):

sort entries by:
1. priority DESC
2. span ASC // (dateTo - dateFrom); null dateTo = Infinity (widest)
3. updatedAt DESC
return entries[0]

If no entry matches, the caller falls back to vehicle.base.

The priority field is on the schema for forward-compatibility (e.g. an explicit “base layer” with priority 0 plus higher-priority exceptions). Today all entries are written with the default 0; the field is reserved.

Layered architecture

The calendar follows ADR-0014 strictly. It is the first feature in the codebase where this pattern lives end-to-end.

Resolver → Service → Repository → Prisma → Postgres

Repository — repositories/vehicle-location-calendar/VehicleLocationCalendarRepository.ts

  • Extends TenantBaseService for RLS + tenant scoping. Hides Prisma from the service.
  • Domain query methods:
    • listByVehicle(vehicleId, filters?, pagination?) — cursor-paginated, with dateFrom / dateTo / endedBefore filters.
    • findById(id), create(data), update(id, data), delete(id) — CRUD.
    • findMatchingDate(vehicleId, date) — all entries covering a single date for a single vehicle.
    • findMatchingDateForVehicles(vehicleIds[], date) — batched form, single Postgres query.
    • findLocationAtDate(vehicleId, date) — uses a per-request DataLoader to coalesce calls during a single GraphQL request (see createLocationAtDateLoader).
  • Normalises Prisma errors (e.g. recordNotFound) into the ApiError codes the service expects.

Service — services/tenant/vehicle-location-calendar/VehicleLocationCalendarService.ts

  • Plain class, dependencies injected (calendarRepository, vehicleRepository, identity, sideEffects). No TenantBaseService inheritance.
  • Owns validation: dateTo >= dateFrom if dateTo is set.
  • Owns the resolution algorithm (resolveWinningEntry).
  • Owns side-effect orchestration: after every successful create / update / delete, fires a Billion sync through sideEffects.fire(...) (see ADR-0016).
  • Public read methods:
    • getVehicleLocationAtDate(vehicleId, date) — used by the plannedLocation resolver and internal callers.
    • getPlannedLocation(vehicleId, date) — same shape but backed by the DataLoader path (used in N+1-prone resolvers like list views).
    • syncActiveEntriesForVehicle(vehicleId) — fires a bulk upsert of all currently-active and future entries; called when a vehicle is published on the marketplace for the first time (TOP-4936).

Resolvers — server/graphql/resolvers/{query,mutation}/vehicleLocationCalendar/*

  • Transport-only. Extract args, call one service method, map to a union member.
  • Mutations use union return types instead of throwing:
    • CreateCalendarEntryResult = CreateCalendarEntrySuccess | CalendarEntryValidationError
    • Same shape for Update…Result and Delete…Result.
  • Unexpected errors are rethrown for the Sentry envelop plugin to catch.

GraphQL surface

Defined in server/graphql/schema/vehicleLocationCalendar.graphql:

type VehicleLocationCalendarEntry {
id: ID!
vehicleId: String!
locationData: Address!
dateFrom: Date!
dateTo: Date
priority: Int!
createdBy: String
createdAt: DateTime!
updatedAt: DateTime!
}
extend type Vehicle {
locationCalendar(input: LocationCalendarInput, first: Int, after: String): VehicleLocationCalendarConnection!
}
extend type Mutation {
createVehicleLocationCalendarEntry(input: CreateVehicleLocationCalendarEntryInput!): CreateCalendarEntryResult!
updateVehicleLocationCalendarEntry(input: UpdateVehicleLocationCalendarEntryInput!): UpdateCalendarEntryResult!
deleteVehicleLocationCalendarEntry(id: ID!): DeleteCalendarEntryResult!
}

server/graphql/schema/vehicle.graphql adds the read-side field on the existing Vehicle type:

type Vehicle {
# ...
currentLocation: Address # real-time, post-checkout (unrelated to calendar)
plannedLocation(at: String!): Address # resolves the calendar; falls back to base
}

plannedLocation returns vehicle.base when no entry covers at. It is implemented as a separate top-level resolver (server/graphql/resolvers/query/vehicleLocationCalendar/plannedLocation.resolver.ts) wired to the Vehicle type and uses the DataLoader path to keep list views ($N$ vehicles × 1 date) at a single SQL round-trip.

UI layer

components/pages/ViewVehicle/LocationCalendar/:

  • index.js — tab entry point. Wired into the vehicle edit page in components/pages/ViewVehicle/components/ViewTabContainer/index.js.
  • UpcomingCalendarView.js / PastCalendarView.js — entry tables, split on dateTo < today.
  • LocationCalendarDialog.js — add/edit dialog with date pickers + Google Places address picker. Triggers the GraphQL mutations.
  • OverlapIndicator.js + overlapUtils.js — soft overlap warning. Overlap is allowed; the indicator surfaces it so the operator knows the resolution rules will apply.
  • LocationCalendarHelp.js — collapsible in-product help that documents the resolution rules (TOP-5071). Expansion state cached in localStorage under locationCalendar.helpExpanded.

Feature flag

PlanFeatureValues.LocationCalendar (constants/features.js). Default value is 'default' (always on). The flag exists because the rollout plan called for a gate, but the feature shipped enabled for all tenants. Removing the flag entirely is fine the next time someone touches that file; not urgent.

Consequences

  • Overlapping entries are a normal case, not an error. UI and callers must use the resolution rules; raw findMany results are not meaningful.
  • priority is on the schema but always 0 today. Any feature that wants explicit overrides (e.g. one-off “this car will be in Milan on April 12 only” over a long base layer) can use it without a migration.
  • All consumers that previously relied solely on vehicle.base for “where will this car be” need to migrate to plannedLocation(at:) to benefit from the calendar. So far that means the reservation row, the offer generator calculator, and the Billion sync. Other call sites (widget, public APIs) were left as vehicle.base and are documented as out of scope.
  • The Resolver → Service → Repository PoC is now considered the target pattern for the codebase; ADR-0014 was upgraded from Proposed to Accepted on the back of this implementation.
  • The historical Design & Estimation v3 doc is partially superseded — specifically the DB-level overlap constraint, the public distance API, and the Billion data shape. The doc is preserved as historical context; new readers should rely on this ADR and ADR-0016.