Skip to content

Trip Planner State Machine Design

Purpose: Define explicit states, transitions, and invariants for trip lifecycle management.

Status: Design document (not yet implemented)

Inspiration: vault-platform trigger state machine patterns


Overview

Trips currently have a status column with CHECK constraint but no transition logic. Any status can change to any other status, which allows invalid states (e.g., completedplanning).

This document defines: 1. Valid states and their meaning 2. Allowed transitions between states 3. Invariants that must hold 4. Implementation approach


Current State

Schema (from 001_initial_schema.sql)

status TEXT DEFAULT 'planning' CHECK (status IN ('planning', 'booked', 'in_progress', 'completed', 'cancelled'))

Current Behavior

  • Trips created with status: 'planning'
  • Status can be updated to any valid value via PATCH /api/trips/[id]
  • No transition validation
  • No side effects on transition

State Definitions

┌─────────────────────────────────────────────────────────────────────┐
│                         TRIP STATES                                  │
├─────────────────────────────────────────────────────────────────────┤
│                                                                      │
│  ┌──────────┐     ┌──────────┐     ┌──────────┐     ┌──────────┐   │
│  │ planning │────▶│  booked  │────▶│in_progress────▶│completed │   │
│  │          │     │          │     │          │     │          │   │
│  └──────────┘     └──────────┘     └──────────┘     └──────────┘   │
│       │                │                │                           │
│       │                │                │                           │
│       ▼                ▼                ▼                           │
│  ┌──────────────────────────────────────────────────────────────┐  │
│  │                        cancelled                              │  │
│  └──────────────────────────────────────────────────────────────┘  │
│                                                                      │
│  ┌──────────┐                                                       │
│  │ archived │  (Future: completed trips after 90 days)             │
│  └──────────┘                                                       │
│                                                                      │
└─────────────────────────────────────────────────────────────────────┘

State Descriptions

State Description User Sees Editable
planning Initial state, trip is being designed "Planning" Full edit
booked Reservations made, dates confirmed "Booked" Limited edit
in_progress Trip is happening now "In Progress" Add notes only
completed Trip finished "Completed" Read-only
cancelled Trip abandoned "Cancelled" Read-only
archived (Future) Old completed trips Hidden by default Read-only

Transition Rules

Valid Transitions

From To Trigger Validation
planning booked User action Requires: title, start_date, end_date
planning cancelled User action None
booked in_progress Auto or user start_date <= today
booked planning User action None (downgrade allowed)
booked cancelled User action None
in_progress completed Auto or user end_date < today
in_progress cancelled User action None (emergency)
completed archived Auto (90 days) completed_at + 90 days < today

Invalid Transitions (Blocked)

From To Reason
completed planning Trip already happened
completed booked Trip already happened
completed in_progress Trip already happened
cancelled Any except planning Must reactivate first
in_progress planning Trip already started
in_progress booked Trip already started
archived Any Permanent state

Transition Matrix

             planning  booked  in_progress  completed  cancelled  archived
planning        -        ✓         ✗           ✗          ✓         ✗
booked          ✓        -         ✓           ✗          ✓         ✗
in_progress     ✗        ✗         -           ✓          ✓         ✗
completed       ✗        ✗         ✗           -          ✗         ✓
cancelled       ✓        ✗         ✗           ✗          -         ✗
archived        ✗        ✗         ✗           ✗          ✗         -

Invariants

State Invariants

ID Invariant Verification
SM-01 booked requires start_date and end_date Transition validation
SM-02 in_progress requires start_date <= CURRENT_DATE Transition validation
SM-03 completed requires end_date < CURRENT_DATE Transition validation
SM-04 cancelled and completed trips are read-only API enforcement
SM-05 Only forward transitions from in_progress Transition validation
SM-06 completed_at is set when entering completed Trigger or app logic

Data Invariants

ID Invariant Verification
SM-07 end_date >= start_date when both set CHECK constraint
SM-08 Itineraries exist for trips in booked+ states Application warning
SM-09 Locations reference valid itineraries FK constraint

Implementation Approach

Add transition validation in the API route:

// lib/trip-state-machine.ts

export const VALID_TRANSITIONS: Record<string, string[]> = {
  planning: ['booked', 'cancelled'],
  booked: ['planning', 'in_progress', 'cancelled'],
  in_progress: ['completed', 'cancelled'],
  completed: ['archived'],
  cancelled: ['planning'],
  archived: [],
}

export function canTransition(from: string, to: string): boolean {
  return VALID_TRANSITIONS[from]?.includes(to) ?? false
}

export function validateTransition(
  trip: { status: string; start_date?: string; end_date?: string },
  newStatus: string
): { valid: boolean; error?: string } {
  // Check if transition is allowed
  if (!canTransition(trip.status, newStatus)) {
    return {
      valid: false,
      error: `Cannot transition from '${trip.status}' to '${newStatus}'`
    }
  }

  // Validate preconditions
  const today = new Date().toISOString().split('T')[0]

  if (newStatus === 'booked') {
    if (!trip.start_date || !trip.end_date) {
      return { valid: false, error: 'Booked trips require start and end dates' }
    }
  }

  if (newStatus === 'in_progress') {
    if (!trip.start_date || trip.start_date > today) {
      return { valid: false, error: 'Trip cannot start before start_date' }
    }
  }

  if (newStatus === 'completed') {
    if (!trip.end_date || trip.end_date >= today) {
      return { valid: false, error: 'Trip cannot complete before end_date' }
    }
  }

  return { valid: true }
}

Usage in API route:

// app/api/trips/[id]/route.ts

import { validateTransition } from '@/lib/trip-state-machine'

export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
  const { id } = await params
  const supabase = await createClient()

  // ... auth check ...

  const updates = await request.json()

  // If status is being updated, validate transition
  if (updates.status) {
    // Fetch current trip
    const { data: trip } = await supabase
      .from('trips')
      .select('status, start_date, end_date')
      .eq('id', id)
      .single()

    if (!trip) {
      return Response.json({ error: 'Trip not found' }, { status: 404 })
    }

    const validation = validateTransition(trip, updates.status)
    if (!validation.valid) {
      return Response.json({ error: validation.error }, { status: 400 })
    }

    // Add transition metadata
    if (updates.status === 'completed') {
      updates.completed_at = new Date().toISOString()
    }
  }

  // ... continue with update ...
}

Option B: Database-Level Enforcement (Future)

Add a trigger function for stronger guarantees:

-- Migration: add_trip_status_transition_validation.sql

CREATE OR REPLACE FUNCTION trip_planner.validate_trip_status_transition()
RETURNS TRIGGER AS $$
DECLARE
  valid_transitions text[][];
BEGIN
  -- Define valid transitions
  valid_transitions := ARRAY[
    ARRAY['planning', 'booked'],
    ARRAY['planning', 'cancelled'],
    ARRAY['booked', 'planning'],
    ARRAY['booked', 'in_progress'],
    ARRAY['booked', 'cancelled'],
    ARRAY['in_progress', 'completed'],
    ARRAY['in_progress', 'cancelled'],
    ARRAY['completed', 'archived'],
    ARRAY['cancelled', 'planning']
  ];

  -- Skip if status not changing
  IF OLD.status = NEW.status THEN
    RETURN NEW;
  END IF;

  -- Check if transition is valid
  IF NOT ARRAY[ARRAY[OLD.status, NEW.status]] <@ valid_transitions THEN
    RAISE EXCEPTION 'Invalid status transition from % to %', OLD.status, NEW.status;
  END IF;

  -- Validate preconditions
  IF NEW.status = 'booked' AND (NEW.start_date IS NULL OR NEW.end_date IS NULL) THEN
    RAISE EXCEPTION 'Booked trips require start and end dates';
  END IF;

  IF NEW.status = 'in_progress' AND NEW.start_date > CURRENT_DATE THEN
    RAISE EXCEPTION 'Trip cannot start before start_date';
  END IF;

  IF NEW.status = 'completed' AND NEW.end_date >= CURRENT_DATE THEN
    RAISE EXCEPTION 'Trip cannot complete before end_date';
  END IF;

  -- Set completed_at timestamp
  IF NEW.status = 'completed' AND OLD.status != 'completed' THEN
    NEW.completed_at := NOW();
  END IF;

  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trip_status_transition_check
  BEFORE UPDATE ON trip_planner.trips
  FOR EACH ROW
  WHEN (OLD.status IS DISTINCT FROM NEW.status)
  EXECUTE FUNCTION trip_planner.validate_trip_status_transition();
  1. Application-level validation for fast feedback and good error messages
  2. Database trigger as safety net (catches direct SQL updates)
  3. Audit log for transition history

Automatic Transitions

Auto-Start Trip (bookedin_progress)

When start_date is reached and trip is booked:

Option 1: Cron job / scheduled function

// Run daily at midnight
async function autoStartTrips() {
  const today = new Date().toISOString().split('T')[0]

  await supabase
    .from('trips')
    .update({ status: 'in_progress' })
    .eq('status', 'booked')
    .lte('start_date', today)
}

Option 2: Check on page load

// In trip detail page
useEffect(() => {
  if (trip.status === 'booked' && trip.start_date <= today) {
    updateTripStatus(trip.id, 'in_progress')
  }
}, [trip])

Auto-Complete Trip (in_progresscompleted)

When end_date has passed and trip is in_progress:

Same options as auto-start.

Auto-Archive (completedarchived)

After 90 days in completed state:

// Run weekly
async function autoArchiveTrips() {
  const cutoff = new Date()
  cutoff.setDate(cutoff.getDate() - 90)

  await supabase
    .from('trips')
    .update({ status: 'archived' })
    .eq('status', 'completed')
    .lt('completed_at', cutoff.toISOString())
}

UI Considerations

Status Badge Colors

Status Color Icon
planning Blue Pencil
booked Green CalendarCheck
in_progress Orange Plane
completed Gray CheckCircle
cancelled Red XCircle
archived Light Gray Archive

Status Actions

Status Available Actions
planning Edit, Book, Cancel, Delete
booked Edit (limited), Start, Unbook, Cancel
in_progress Add Notes, Complete, Cancel
completed View, Export, Archive
cancelled Reactivate, Delete
archived View only

Edit Restrictions

Status What Can Be Edited
planning Everything
booked Budget, notes, itinerary details (not dates)
in_progress Notes, actual costs, photos
completed Nothing (read-only)
cancelled Nothing (read-only)

Migration Plan

Phase 1: Application Validation (Low Risk)

  1. Add lib/trip-state-machine.ts
  2. Update app/api/trips/[id]/route.ts to validate transitions
  3. Add UI feedback for invalid transitions
  4. Test all transition paths

Phase 2: Schema Enhancement (Medium Risk)

  1. Add completed_at column:

    ALTER TABLE trip_planner.trips
      ADD COLUMN completed_at TIMESTAMPTZ;
    

  2. Add date validation constraint:

    ALTER TABLE trip_planner.trips
      ADD CONSTRAINT trips_date_order
      CHECK (end_date IS NULL OR start_date IS NULL OR end_date >= start_date);
    

Phase 3: Database Trigger (Optional)

  1. Add transition validation trigger
  2. Test with existing data
  3. Add audit table for transition history (if needed)

Phase 4: Automatic Transitions (Optional)

  1. Implement auto-start logic
  2. Implement auto-complete logic
  3. Add user preference for auto-transitions

Testing Checklist

Unit Tests

  • [ ] canTransition() returns correct boolean for all state pairs
  • [ ] validateTransition() enforces date preconditions
  • [ ] Invalid transitions are rejected

Integration Tests

  • [ ] Create trip (starts in planning)
  • [ ] Book trip (requires dates)
  • [ ] Start trip (requires start_date <= today)
  • [ ] Complete trip (requires end_date < today)
  • [ ] Cancel from each state
  • [ ] Reactivate cancelled trip
  • [ ] Cannot go backwards from completed

Edge Cases

  • [ ] Trip with start_date = today
  • [ ] Trip with end_date = today
  • [ ] Timezone handling for date comparisons
  • [ ] Concurrent updates to same trip


This design document should be reviewed before implementation. The MVP approach (Option A) is recommended to validate the UX before adding database-level enforcement.