Full-Stack Group Vacation Planner

Trip Clipper

00 / Design rationale
Central variable
Trip energy level
Geo stack
OSRM / Nominatim / Photon / Leaflet
Dependency model
Zero API keys required

The tension — Group travel has two failure modes: overplanning, which produces rigid itineraries and exhausted travelers, and underplanning, which produces missed opportunities and last-minute conflict. Most tools address neither — they’re just shared lists. Trip Clipper is built around that tension. Every feature either adds structure or enforces restraint, and the line between them is deliberate.

The structure — Energy level is the app’s central variable. Set once at trip creation, it doesn’t just describe the trip — it configures it. Daily time thresholds, amber warnings on overloaded days, and activity caps in Good Enough Mode all derive from that single setting, so the trip’s intended pace enforces itself through the UI rather than through willpower. The workflow is equally opinionated: wizard to idea board to tradeoff board to daily schedule. Each stage gates the next, preventing the common failure of scheduling activities before deciding which ones are worth keeping.

The decisions — The scoring model separates what can be computed from what shouldn’t be. In the Tradeoff Board, budget and proximity scores are auto-calculated from price and Haversine distance; uniqueness and group appeal are left to human judgment. The geo stack — OSRM for routing, Nominatim for geocoding, Photon for autocomplete, Leaflet for maps — was chosen to be fully self-contained: no API keys, no rate limit exposure, no third-party billing. Geographic intelligence that works the same in development as it does in production.

The Problem

Group trip planning is chaos. Activities live in group chats, budgets get lost in spreadsheets, and nobody can agree on a schedule. Most groups either overplan into exhaustion or underplan into missed opportunities — with no tool purpose-built for the messy, collaborative reality of travel with friends or family.

The Solution

Trip Clipper provides a structured workflow from trip setup through daily scheduling. A guided wizard sets energy level, travelers, cities, and budget. The Idea Board lets you browse, rate, and select activities per city — with outlier detection to flag picks far from the cluster. The Tradeoff Board runs a weighted scoring matrix to compare options side-by-side. The Daily Schedule uses drag-and-drop with OSRM-powered route times between stops — and Good Enough Mode prevents overplanning by capping activities per day based on trip energy.

Key Features

Drag-and-Drop Daily Schedule

Powered by @dnd-kit — drag activities into Morning, Afternoon, and Evening slots. Buffer blocks for meals, rest, and transit. OSRM-powered route time estimates between consecutive activities with per-gap drive/walk toggle.

Tradeoff Board & Idea Board

Rate activities across four dimensions, flag geographic outliers, and compare N items side-by-side with visual score bars. Category filtering ensures you only compare like-for-like options.

Good Enough Mode & Overplanning Nudges

Toggle caps activities per day based on trip energy, dims unchecked items at capacity, shows progress banners, and gently nudges when a day has too many activities or transport segments.

Build & Deliver

// In-memory cache: avoids duplicate OSRM calls for the same coordinate pair
const _routeCache = new Map();

export async function fetchRouteTime(lat1, lng1, lat2, lng2, mode = 'driving') {
    const cacheKey = `${lat1},${lng1}-${lat2},${lng2}-${mode}`;
    if (_routeCache.has(cacheKey)) return _routeCache.get(cacheKey);

    // OSRM demo only exposes a driving profile — walking time is estimated
    const url = `https://router.project-osrm.org/route/v1/driving/
                 ${lng1},${lat1};${lng2},${lat2}?overview=false`;
    const data  = await fetch(url).then(r => r.json());
    const route = data.routes[0];

    const driveResult = {
        durationMin: Math.round(route.duration / 60),
        distanceMi:  +(route.distance / 1609.34).toFixed(1),
        mode: 'driving',
    };
    _routeCache.set(driveCacheKey, driveResult);

    if (mode === 'walking') {
        // ~3 mph ≈ 20 min/mile — derived from the routed driving distance
        return { ...driveResult, durationMin: Math.round(driveResult.distanceMi * 20), mode: 'walking' };
    }

    return driveResult;
}
01 / Core workflow

Drag-and-Drop Schedule Builder

@dnd-kit powered scheduling with Morning/Afternoon/Evening slots, buffer blocks for meals and transit, OSRM route time estimates between consecutive activities, daily travel totals with energy-based amber warnings, and smart auto-fill suggestions that cluster unscheduled activities by proximity.

// Budget and proximity are computable from data — uniqueness and group appeal are not
const calculateAutoScores = (item) => {
    const price = parseFloat(item.price) || 0;
    const budgetScore =
        price === 0   ? 10 :
        price <= 20   ?  8 :
        price <= 50   ?  6 :
        price <= 100  ?  4 : 2;

    const dist = getDistance(hotel.lat, hotel.lng, item.lat, item.lng);
    const timeScore =
        dist <= 1.0  ? 10 :
        dist <= 3.0  ?  8 :
        dist <= 10.0 ?  6 :
        dist <= 30.0 ?  4 : 2;

    return { budgetScore, timeScore };
};

// Objective fields are auto-scored; subjective fields come from human ratings
const buildOption = (item) => ({
    scores: {
        budget:     budgetScore,              // computed from price
        time:       timeScore,                // computed from distance to hotel
        uniqueness: item.rating_uniqueness || 5,  // human-rated on Idea Board
        group:      item.rating_group      || 5,  // human-rated on Idea Board
    }
});
02 / Group consensus

Tradeoff Board & Idea Board

The Idea Board surfaces geocoded activities per leg with 1-10 rating sliders and Haversine distance from hotel, and automatically flags outliers far from the cluster. The Tradeoff Board runs a weighted scoring matrix — auto-scoring budget from price and time from distance, manual ratings for uniqueness and group appeal, adjustable weights — with visual score bars, category filtering, and one-click winner selection. Photon-powered place autocomplete with proximity bias for quick address entry.

// Energy level set once at trip creation — configures every behavioral limit downstream
const THRESHOLDS = {
    chill:    { daily: 15, gap: 8,  maxActivities: 2, maxTransits: 1, dailyTimeMin:  30, gapTimeMin: 15 },
    balanced: { daily: 30, gap: 15, maxActivities: 4, maxTransits: 3, dailyTimeMin:  60, gapTimeMin: 30 },
    packed:   { daily: 60, gap: 25, maxActivities: 7, maxTransits: 5, dailyTimeMin: 120, gapTimeMin: 45 },
};

const ENERGY_CAPS = { chill: 2, balanced: 4, packed: 7 };

// DailySchedule — amber warnings fire when daily travel or gap thresholds are exceeded
const limits = THRESHOLDS[tripEnergy] || THRESHOLDS.balanced;

// CityItinerary — Good Enough Mode disables selection once the day is at capacity
const maxPerDay = ENERGY_CAPS[tripEnergy] || 4;
03 / Trip governance

Good Enough Mode & Readiness Checklist

Energy-based activity caps prevent overplanning. Progress banners, capacity dimming, and 'This day is set' indicators enforce trip pacing. Readiness Checklist tracks hotel, activity, logistics, and schedule completeness per leg. Undo system with 5-second countdown toast for all destructive actions.

Architecture

Frontend

React 19, Vite 7, Tailwind CSS 4, @dnd-kit, React Leaflet 5

Backend

Node.js, Express 5, MySQL 8 (mysql2)

Deployment

Leaflet maps, Nominatim geocoding, OSRM routing, Photon autocomplete — all free, no API keys required