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:
- operators can declare seasonal base schedules in the UI;
- internal flows (reservation creation, offer generator) can ask “where is this car planned to be on date X?”;
- 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 DESCreturn 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 → PostgresRepository — repositories/vehicle-location-calendar/VehicleLocationCalendarRepository.ts
- Extends
TenantBaseServicefor RLS + tenant scoping. Hides Prisma from the service. - Domain query methods:
listByVehicle(vehicleId, filters?, pagination?)— cursor-paginated, withdateFrom/dateTo/endedBeforefilters.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-requestDataLoaderto coalesce calls during a single GraphQL request (seecreateLocationAtDateLoader).
- Normalises Prisma errors (e.g.
recordNotFound) into theApiErrorcodes the service expects.
Service — services/tenant/vehicle-location-calendar/VehicleLocationCalendarService.ts
- Plain class, dependencies injected (
calendarRepository,vehicleRepository,identity,sideEffects). NoTenantBaseServiceinheritance. - Owns validation:
dateTo >= dateFromifdateTois set. - Owns the resolution algorithm (
resolveWinningEntry). - Owns side-effect orchestration: after every successful
create/update/delete, fires a Billion sync throughsideEffects.fire(...)(see ADR-0016). - Public read methods:
getVehicleLocationAtDate(vehicleId, date)— used by theplannedLocationresolver 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…ResultandDelete…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 incomponents/pages/ViewVehicle/components/ViewTabContainer/index.js.UpcomingCalendarView.js/PastCalendarView.js— entry tables, split ondateTo < 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 inlocalStorageunderlocationCalendar.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
findManyresults are not meaningful. priorityis on the schema but always0today. 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.basefor “where will this car be” need to migrate toplannedLocation(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 asvehicle.baseand 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.