Initial release: collaborative trip planner
Multi-user trip planning web app in a single Docker container. Mullvad-style token accounts, trip sharing via join codes, day-by-day calendar with typed entries (activity, hotel, travel, flight, rental car, immigration, note), multi-leg flight segments with bundled IATA airport dataset, Leaflet/OSM map with per-leg great-circle km (air vs ground), rough km-driven vs rental included-km comparison, cost splitting with settle-up suggestions, flip-clock departure countdown. Node 20 + Express + SQLite (WAL, additive migrations), vanilla JS SPA, 44 API tests.
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
# Dependencies (reinstalled inside the image)
|
||||
node_modules
|
||||
|
||||
# Persisted data — never bake the SQLite file into the image
|
||||
data
|
||||
|
||||
# Version control
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Docs and tests are not needed at runtime
|
||||
docs
|
||||
tests
|
||||
|
||||
# Tooling / agent scaffolding
|
||||
.claude
|
||||
.claude-flow
|
||||
.swarm
|
||||
.mcp.json
|
||||
ruvector.db
|
||||
|
||||
# Local env files
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# The Docker files themselves
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
docker-compose.yml
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
node_modules/
|
||||
/data/
|
||||
*.db
|
||||
*.sqlite
|
||||
.env
|
||||
.env.*
|
||||
npm-debug.log*
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.claude-flow/
|
||||
.claude/
|
||||
.swarm/
|
||||
.mcp.json
|
||||
ruvector.db
|
||||
.playwright-mcp/
|
||||
@@ -0,0 +1,180 @@
|
||||
# Ruflo — Claude Code Configuration
|
||||
|
||||
## Rules
|
||||
|
||||
- Do what has been asked; nothing more, nothing less
|
||||
- NEVER create files unless absolutely necessary — prefer editing existing files
|
||||
- NEVER create documentation files unless explicitly requested
|
||||
- NEVER save working files or tests to root — use `/src`, `/tests`, `/docs`, `/config`, `/scripts`
|
||||
- ALWAYS read a file before editing it
|
||||
- NEVER commit secrets, credentials, or .env files
|
||||
- NEVER add a `Co-Authored-By` trailer to user commits unless this project's `.claude/settings.json` has `attribution.commit` set (#2078). The Claude Code Bash tool may suggest one in its default commit-message template — ignore it. `Co-Authored-By` is semantic authorship attribution under git/GitHub convention; the tool is the facilitator, not a co-author.
|
||||
- Keep files under 500 lines
|
||||
- Validate input at system boundaries
|
||||
|
||||
## Agent Comms (SendMessage-First Coordination)
|
||||
|
||||
Named agents coordinate via `SendMessage`, not polling or shared state.
|
||||
|
||||
```
|
||||
Lead (you) ←→ architect ←→ developer ←→ tester ←→ reviewer
|
||||
(named agents message each other directly)
|
||||
```
|
||||
|
||||
### Spawning a Coordinated Team
|
||||
|
||||
```javascript
|
||||
// ALL agents in ONE message, each knows WHO to message next
|
||||
Agent({ prompt: "Research the codebase. SendMessage findings to 'architect'.",
|
||||
subagent_type: "researcher", name: "researcher", run_in_background: true })
|
||||
Agent({ prompt: "Wait for 'researcher'. Design solution. SendMessage to 'coder'.",
|
||||
subagent_type: "system-architect", name: "architect", run_in_background: true })
|
||||
Agent({ prompt: "Wait for 'architect'. Implement it. SendMessage to 'tester'.",
|
||||
subagent_type: "coder", name: "coder", run_in_background: true })
|
||||
Agent({ prompt: "Wait for 'coder'. Write tests. SendMessage results to 'reviewer'.",
|
||||
subagent_type: "tester", name: "tester", run_in_background: true })
|
||||
Agent({ prompt: "Wait for 'tester'. Review code quality and security.",
|
||||
subagent_type: "reviewer", name: "reviewer", run_in_background: true })
|
||||
|
||||
// Kick off the pipeline
|
||||
SendMessage({ to: "researcher", summary: "Start", message: "[task context]" })
|
||||
```
|
||||
|
||||
### Patterns
|
||||
|
||||
| Pattern | Flow | Use When |
|
||||
|---------|------|----------|
|
||||
| **Pipeline** | A → B → C → D | Sequential dependencies (feature dev) |
|
||||
| **Fan-out** | Lead → A, B, C → Lead | Independent parallel work (research) |
|
||||
| **Supervisor** | Lead ↔ workers | Ongoing coordination (complex refactor) |
|
||||
|
||||
### Rules
|
||||
|
||||
- ALWAYS name agents — `name: "role"` makes them addressable
|
||||
- ALWAYS include comms instructions in prompts — who to message, what to send
|
||||
- Spawn ALL agents in ONE message with `run_in_background: true`
|
||||
- After spawning: STOP, tell user what's running, wait for results
|
||||
- NEVER poll status — agents message back or complete automatically
|
||||
|
||||
## Swarm & Routing
|
||||
|
||||
### Config
|
||||
- **Topology**: hierarchical-mesh (anti-drift)
|
||||
- **Max Agents**: 15
|
||||
- **Memory**: hybrid
|
||||
- **HNSW**: Enabled
|
||||
- **Neural**: Enabled
|
||||
|
||||
```bash
|
||||
npx @claude-flow/cli@latest swarm init --topology hierarchical --max-agents 8 --strategy specialized
|
||||
```
|
||||
|
||||
### Agent Routing
|
||||
|
||||
| Task | Agents | Topology |
|
||||
|------|--------|----------|
|
||||
| Bug Fix | researcher, coder, tester | hierarchical |
|
||||
| Feature | architect, coder, tester, reviewer | hierarchical |
|
||||
| Refactor | architect, coder, reviewer | hierarchical |
|
||||
| Performance | perf-engineer, coder | hierarchical |
|
||||
| Security | security-architect, auditor | hierarchical |
|
||||
|
||||
### When to Swarm
|
||||
- **YES**: 3+ files, new features, cross-module refactoring, API changes, security, performance
|
||||
- **NO**: single file edits, 1-2 line fixes, docs updates, config changes, questions
|
||||
|
||||
### 3-Tier Model Routing
|
||||
|
||||
| Tier | Handler | Use Cases |
|
||||
|------|---------|-----------|
|
||||
| 1 | Agent Booster (WASM) | Simple transforms — skip LLM, use Edit directly |
|
||||
| 2 | Haiku | Simple tasks, low complexity |
|
||||
| 3 | Sonnet/Opus | Architecture, security, complex reasoning |
|
||||
|
||||
## Memory & Learning
|
||||
|
||||
### Before Any Task
|
||||
```bash
|
||||
npx @claude-flow/cli@latest memory search --query "[task keywords]" --namespace patterns
|
||||
npx @claude-flow/cli@latest hooks route --task "[task description]"
|
||||
```
|
||||
|
||||
### After Success
|
||||
```bash
|
||||
npx @claude-flow/cli@latest memory store --namespace patterns --key "[name]" --value "[what worked]"
|
||||
npx @claude-flow/cli@latest hooks post-task --task-id "[id]" --success true --store-results true
|
||||
```
|
||||
|
||||
### MCP Tools (use `ToolSearch("keyword")` to discover)
|
||||
|
||||
| Category | Key Tools |
|
||||
|----------|-----------|
|
||||
| **Memory** | `memory_store`, `memory_search`, `memory_search_unified` |
|
||||
| **Bridge** | `memory_import_claude`, `memory_bridge_status` |
|
||||
| **Swarm** | `swarm_init`, `swarm_status`, `swarm_health` |
|
||||
| **Agents** | `agent_spawn`, `agent_list`, `agent_status` |
|
||||
| **Hooks** | `hooks_route`, `hooks_post-task`, `hooks_worker-dispatch` |
|
||||
| **Security** | `aidefence_scan`, `aidefence_is_safe`, `aidefence_has_pii` |
|
||||
| **Hive-Mind** | `hive-mind_init`, `hive-mind_consensus`, `hive-mind_spawn` |
|
||||
|
||||
### Background Workers
|
||||
|
||||
| Worker | When |
|
||||
|--------|------|
|
||||
| `audit` | After security changes |
|
||||
| `optimize` | After performance work |
|
||||
| `testgaps` | After adding features |
|
||||
| `map` | Every 5+ file changes |
|
||||
| `document` | After API changes |
|
||||
|
||||
```bash
|
||||
npx @claude-flow/cli@latest hooks worker dispatch --trigger audit
|
||||
```
|
||||
|
||||
## Agents
|
||||
|
||||
**Core**: `coder`, `reviewer`, `tester`, `planner`, `researcher`
|
||||
**Architecture**: `system-architect`, `backend-dev`, `mobile-dev`
|
||||
**Security**: `security-architect`, `security-auditor`
|
||||
**Performance**: `performance-engineer`, `perf-analyzer`
|
||||
**Coordination**: `hierarchical-coordinator`, `mesh-coordinator`, `adaptive-coordinator`
|
||||
**GitHub**: `pr-manager`, `code-review-swarm`, `issue-tracker`, `release-manager`
|
||||
|
||||
Any string works as a custom agent type.
|
||||
|
||||
## Build & Test
|
||||
|
||||
- ALWAYS run tests after code changes
|
||||
- ALWAYS verify build succeeds before committing
|
||||
|
||||
```bash
|
||||
npm run build && npm test
|
||||
```
|
||||
|
||||
## CLI Quick Reference
|
||||
|
||||
```bash
|
||||
npx @claude-flow/cli@latest init --wizard # Setup
|
||||
npx @claude-flow/cli@latest swarm init --v3-mode # Start swarm
|
||||
npx @claude-flow/cli@latest memory search --query "" # Vector search
|
||||
npx @claude-flow/cli@latest hooks route --task "" # Route to agent
|
||||
npx @claude-flow/cli@latest doctor --fix # Diagnostics
|
||||
npx @claude-flow/cli@latest security scan # Security scan
|
||||
npx @claude-flow/cli@latest performance benchmark # Benchmarks
|
||||
```
|
||||
|
||||
26 commands, 140+ subcommands. Use `--help` on any command for details.
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
claude mcp add claude-flow -- npx -y ruflo@latest mcp start
|
||||
npx ruflo@latest doctor --fix
|
||||
```
|
||||
|
||||
> The background `daemon` is optional. It runs interval workers that each spawn
|
||||
> a headless `claude` session, so it consumes tokens continuously. Start it only
|
||||
> if you want those sweeps: `npx ruflo@latest daemon start` (self-stops after 12h
|
||||
> by default; `--ttl 0` to disable, `daemon status --all` to audit running daemons).
|
||||
|
||||
**Agent tool** handles execution (agents, files, code, git). **MCP tools** handle coordination (swarm, memory, hooks). **CLI** is the same via Bash.
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
# Trip Plan — production image
|
||||
# node:20-slim (Debian/glibc) so better-sqlite3 prebuilt binaries load correctly.
|
||||
FROM node:20-slim
|
||||
|
||||
ENV NODE_ENV=production \
|
||||
PORT=3000 \
|
||||
DATA_DIR=/app/data
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install production dependencies first for better layer caching.
|
||||
COPY package*.json ./
|
||||
RUN npm ci --omit=dev && npm cache clean --force
|
||||
|
||||
# Application code.
|
||||
COPY src/ ./src/
|
||||
COPY public/ ./public/
|
||||
|
||||
# Data directory for the SQLite file (volume-mounted in compose).
|
||||
# Owned by the unprivileged `node` user that ships with the base image.
|
||||
RUN mkdir -p /app/data && chown -R node:node /app
|
||||
|
||||
USER node
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
# Any HTTP response (including 401) from the API means the server is up.
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD node -e "fetch('http://localhost:3000/api/auth/me').then(()=>process.exit(0)).catch(()=>process.exit(1))"
|
||||
|
||||
CMD ["node", "src/server/index.js"]
|
||||
@@ -0,0 +1,116 @@
|
||||
# Trip Plan
|
||||
|
||||
A self-hosted, multi-user web tool for collaboratively planning trips. Multiple people log in to the same instance, create trips with a name and date range, and fill in a day-by-day calendar together — flights (with multi-leg connections), hotels, border crossings, drives, rental cars, and activities. Each trip is drawn on an interactive map with the route between stops, per-leg distances, cost splitting between members, and an overall summary. It runs as a single Docker container with a SQLite database.
|
||||
|
||||
## Features
|
||||
|
||||
- **Accounts without passwords** — Mullvad-style: one click generates a random account number (shown once, stored only as a hash). No email, no password, no personal data.
|
||||
- **Sharing via join codes** — every trip has a short code; anyone with an account enters it to become a co-editor. The owner can regenerate the code to stop further joins.
|
||||
- **Day-by-day calendar** — a real calendar grid; click a day to add any number of entries (activity, hotel, travel, flight, rental car, immigration, note).
|
||||
- **Multi-leg flights** — type `CNX-BKK-DXB-FRA` and it expands into segments with per-leg flight number and times; every airport becomes a stop on the map (bundled offline IATA airport database).
|
||||
- **Rental cars** — brand/model/type, booking ref, pickup/dropoff with dates and times, and included km — compared against the trip's estimated driven km.
|
||||
- **Locations** — geocoded via OpenStreetMap (Nominatim, English results); lat/lng stored per entry.
|
||||
- **Map** — Leaflet + OpenStreetMap; stops plotted in order, air legs dashed / ground legs solid, per-leg great-circle km.
|
||||
- **Costs & splitting** — price per entry with "split equally", "everyone pays their own", or "payer's expense"; per-person share/paid/net table and minimal settle-up transfers, in the trip's currency.
|
||||
- **Summary** — days/nights, entry counts, total km, **≈ km driven** (rental-car figure) vs km flown, and locations visited.
|
||||
|
||||
## Deployment (Docker + external Caddy for TLS)
|
||||
|
||||
The app serves plain HTTP on port 3000 and is designed to sit behind a reverse
|
||||
proxy that terminates TLS — e.g. an external [Caddy](https://caddyserver.com/).
|
||||
|
||||
### 1. Run the container
|
||||
|
||||
Using the prebuilt image from the registry:
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
services:
|
||||
tripplan:
|
||||
image: git.b4l.co.th/grabowski/trip-plan:latest
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
SESSION_SECRET: "<long random string — e.g. openssl rand -hex 32>"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
# No public port mapping needed when Caddy shares a Docker network with
|
||||
# the app (recommended). For a host-level Caddy, map localhost only:
|
||||
ports:
|
||||
- "127.0.0.1:3000:3000"
|
||||
```
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
(Or build locally instead of pulling: clone this repo and use `build: .` — the
|
||||
checked-in `docker-compose.yml` does exactly that.)
|
||||
|
||||
### 2. Point Caddy at it
|
||||
|
||||
If Caddy runs on the same host (host-level Caddy, app bound to `127.0.0.1:3000`):
|
||||
|
||||
```caddyfile
|
||||
trips.example.com {
|
||||
reverse_proxy 127.0.0.1:3000
|
||||
}
|
||||
```
|
||||
|
||||
If Caddy runs as a container, attach both services to a shared Docker network
|
||||
and proxy by service name instead:
|
||||
|
||||
```caddyfile
|
||||
trips.example.com {
|
||||
reverse_proxy tripplan:3000
|
||||
}
|
||||
```
|
||||
|
||||
That's all — Caddy obtains and renews the certificate automatically; the app
|
||||
needs no TLS configuration of its own. Session cookies are `SameSite=Lax` and
|
||||
`httpOnly`, and work as-is behind the proxy.
|
||||
|
||||
### 3. Updating
|
||||
|
||||
```bash
|
||||
docker compose pull && docker compose up -d
|
||||
```
|
||||
|
||||
Schema migrations run automatically at startup; existing data in `./data` is
|
||||
preserved (back up that directory to back up all trips).
|
||||
|
||||
## Quick start (local, no proxy)
|
||||
|
||||
```bash
|
||||
docker compose up -d # builds the image from source
|
||||
# open http://localhost:3000 and create the first account
|
||||
```
|
||||
|
||||
> Before exposing the app beyond localhost, set your own random `SESSION_SECRET`
|
||||
> in `docker-compose.yml`.
|
||||
|
||||
## Local development
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm start # http://localhost:3000
|
||||
npm test # API tests (node:test + supertest)
|
||||
```
|
||||
|
||||
`npm run dev` starts the server with `--watch` for auto-reload.
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `PORT` | `3000` | HTTP port the server listens on. |
|
||||
| `DATA_DIR` | `./data` (`/app/data` in Docker) | Directory holding the SQLite database file. |
|
||||
| `SESSION_SECRET` | dev value (warns) | Secret used to sign session cookies. **Set this in production.** |
|
||||
|
||||
## Data
|
||||
|
||||
All state lives in a single SQLite file under `DATA_DIR`. In Docker this is `/app/data`, mounted from `./data` on the host. No external database or services are required (geocoding calls OpenStreetMap's Nominatim, which needs no API key; airport lookups use a bundled offline dataset).
|
||||
|
||||
## Documentation
|
||||
|
||||
- [docs/PROJECT_OVERVIEW.md](docs/PROJECT_OVERVIEW.md) — architecture, data model, and design decisions.
|
||||
- [docs/API.md](docs/API.md) — the REST API contract.
|
||||
@@ -0,0 +1,13 @@
|
||||
services:
|
||||
tripplan:
|
||||
build: .
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
# SQLite database persists on the host across container rebuilds.
|
||||
- ./data:/app/data
|
||||
environment:
|
||||
# CHANGE THIS: set a long random string before exposing the app.
|
||||
# e.g. `openssl rand -hex 32`
|
||||
SESSION_SECRET: "change-me-to-a-long-random-secret"
|
||||
restart: unless-stopped
|
||||
+215
@@ -0,0 +1,215 @@
|
||||
# Trip Plan — API Contract
|
||||
|
||||
All endpoints are JSON over REST, prefixed with `/api`. This document is the **binding contract** between backend and frontend. Do not deviate; extend only by agreement.
|
||||
|
||||
## Conventions
|
||||
|
||||
- Dates: `YYYY-MM-DD` strings. Times: `HH:MM` 24h strings (optional fields).
|
||||
- Auth: cookie session (`cookie-session`). All endpoints except `account` (create) / `login` require auth → otherwise `401 {"error":"unauthorized"}`.
|
||||
- Identity is Mullvad-style: an account is a random **account token** (the only credential — no username/password). Users additionally have a non-unique, editable `display_name` for humans.
|
||||
- Trip access: user must be a member of the trip → otherwise `404 {"error":"not found"}` (don't leak existence).
|
||||
- Errors: `4xx/5xx` with body `{"error": "<human readable message>"}`.
|
||||
- IDs are integers.
|
||||
|
||||
## Server layout
|
||||
|
||||
- `src/server/app.js` — builds and **exports** the Express app (`export function createApp(dbPath)` and `export default` a ready app is fine, but `createApp` must exist for tests).
|
||||
- `src/server/index.js` — reads env (`PORT` default 3000, `DATA_DIR` default `./data`, `SESSION_SECRET` default dev value with console warning), ensures DATA_DIR exists, starts listener, serves `public/` statically.
|
||||
- `src/server/db.js` — `better-sqlite3` connection + schema creation (idempotent `CREATE TABLE IF NOT EXISTS`).
|
||||
- `src/server/routes/` — `auth.js`, `trips.js`, `entries.js`, `geocode.js`.
|
||||
- `src/server/util/distance.js` — `haversineKm(lat1, lng1, lat2, lng2)` returns km (number).
|
||||
|
||||
## Data model (SQLite)
|
||||
|
||||
```sql
|
||||
users (id INTEGER PK, token_hash TEXT UNIQUE NOT NULL, -- sha256 hex of the account token
|
||||
display_name TEXT NOT NULL, -- auto-generated, editable, NOT unique
|
||||
created_at TEXT DEFAULT current_timestamp)
|
||||
trips (id INTEGER PK, name TEXT NOT NULL, start_date TEXT NOT NULL, end_date TEXT NOT NULL,
|
||||
owner_id INTEGER NOT NULL REFERENCES users(id),
|
||||
currency TEXT NOT NULL DEFAULT 'USD', -- 3-letter code, display only (no FX)
|
||||
join_code TEXT UNIQUE NOT NULL, -- share code for joining the trip
|
||||
created_at TEXT DEFAULT current_timestamp)
|
||||
trip_members (trip_id INTEGER REFERENCES trips(id), user_id INTEGER REFERENCES users(id),
|
||||
role TEXT NOT NULL DEFAULT 'editor', -- 'owner' | 'editor'
|
||||
PRIMARY KEY (trip_id, user_id))
|
||||
entries (id INTEGER PK, trip_id INTEGER NOT NULL REFERENCES trips(id),
|
||||
date TEXT NOT NULL, type TEXT NOT NULL,
|
||||
title TEXT NOT NULL, details TEXT DEFAULT '',
|
||||
start_time TEXT, end_time TEXT,
|
||||
location_name TEXT, lat REAL, lng REAL,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
price REAL, -- null = no cost tracked
|
||||
paid_by INTEGER REFERENCES users(id), -- who paid (null = unassigned)
|
||||
split_mode TEXT NOT NULL DEFAULT 'equal', -- 'equal' | 'own' | 'payer'
|
||||
segments TEXT, -- JSON array, flight entries only (see below)
|
||||
created_at TEXT DEFAULT current_timestamp)
|
||||
entry_participants (entry_id INTEGER REFERENCES entries(id), user_id INTEGER REFERENCES users(id),
|
||||
PRIMARY KEY (entry_id, user_id))
|
||||
-- no rows for an entry = "all trip members participate" (dynamic default)
|
||||
```
|
||||
|
||||
Entry `type` ∈ `flight | immigration | travel | hotel | activity | rental | note`.
|
||||
|
||||
### Rental car details
|
||||
|
||||
`rental`-type entries can carry structured rental data in `entries.rental` (TEXT, JSON — same storage pattern as flight segments). `null`/absent = plain entry.
|
||||
|
||||
```json
|
||||
"rental": {
|
||||
"brand": "Toyota", "model": "Yaris Cross", "car_type": "SUV",
|
||||
"booking_ref": "RC-889231", "included_km": 1500,
|
||||
"pickup": { "date": "2026-08-01", "time": "09:00", "location_name": "CNX Airport",
|
||||
"lat": 18.77, "lng": 98.96 },
|
||||
"dropoff": { "date": "2026-08-07", "time": "18:00", "location_name": "Chiang Mai Old Town",
|
||||
"lat": 18.79, "lng": 98.98 }
|
||||
}
|
||||
```
|
||||
|
||||
Validation (POST/PATCH entries): only allowed when `type === 'rental'` (else 400); all fields optional; `brand`/`model` ≤60, `car_type` ≤40, `booking_ref` ≤60 chars; `included_km` null (= unlimited/unknown) or number ≥ 0; `pickup`/`dropoff` objects with `date` (YYYY-MM-DD, required within the object), optional `time` HH:MM, `location_name` ≤120, lat/lng both-or-neither in range. PATCH `rental: null` clears. Entry JSON always includes `rental` (parsed or null). The entry's own `date` should be the pickup date (frontend sets this); the entry's price/split fields carry the rental cost as usual.
|
||||
|
||||
Summary impact: `summary.rentals` counts rental entries; `summary.includedKm` = sum of non-null `included_km` across rentals, or null when no rental specifies one — the frontend pairs it with `kmDriven` ("≈ 520 km driven / 1,500 km included"). Rental pickup/dropoff locations do NOT feed the route (use the entry's normal location field for a map point if wanted).
|
||||
|
||||
### Flight segments
|
||||
|
||||
Flight entries can carry structured **segments** for multi-leg itineraries (e.g. CNX→BKK→DXB→FRA = 3 segments). Stored as JSON in `entries.segments`, returned parsed. `null`/absent = no segments (plain entry).
|
||||
|
||||
```json
|
||||
"segments": [
|
||||
{ "flight_no": "TG103", "dep_time": "10:30", "arr_time": "11:45",
|
||||
"from": {"code": "CNX", "name": "Chiang Mai Intl", "lat": 18.77, "lng": 98.96},
|
||||
"to": {"code": "BKK", "name": "Suvarnabhumi", "lat": 13.68, "lng": 100.75} },
|
||||
{ "flight_no": "EK385", "dep_time": "13:05", "arr_time": "17:10",
|
||||
"from": {"code": "BKK", "name": "Suvarnabhumi", "lat": 13.68, "lng": 100.75},
|
||||
"to": {"code": "DXB", "name": "Dubai Intl", "lat": 25.25, "lng": 55.36} }
|
||||
]
|
||||
```
|
||||
|
||||
Validation (POST/PATCH entries): only allowed when `type === 'flight'` (else 400); array of 1–8 objects; each needs `from` and `to` with `code` (2–4 uppercase alphanumerics); `name` optional ≤80 chars; `lat`/`lng` both-or-neither, same ranges as entry lat/lng; `flight_no` optional ≤12 chars; `dep_time`/`arr_time` optional `HH:MM`. PATCH with `segments: null` clears them.
|
||||
|
||||
**Route expansion**: an entry whose segments have coordinates contributes airport stops to `/route` instead of its own lat/lng — `from` of the first segment, then `to` of every segment, in order (airports without coords are skipped; consecutive duplicate coords are collapsed). Stops derived this way have `"kind": "airport"` and include `"code"`. So CNX-BKK-DXB-FRA yields 4 stops and 3 measured legs on the map.
|
||||
|
||||
### Airports lookup
|
||||
|
||||
`GET /api/airports?q=<query>` → `200 {results: [{code, name, city, country, lat, lng}]}` (max 8; requires auth).
|
||||
|
||||
Backed by a bundled dataset at `src/server/data/airports.json` generated from the public-domain OurAirports data (filter: has IATA code + scheduled service; keep code/name/municipality/iso_country/lat/lng). Matching: exact/prefix IATA code match first (case-insensitive), then name/city substring. This makes `CNX` → Chiang Mai Intl instant and offline.
|
||||
|
||||
### Cost semantics
|
||||
|
||||
- Only entries with non-null `price` count toward costs.
|
||||
- **Effective participants** of an entry = its `entry_participants` rows, or **all current trip members** if it has none.
|
||||
- `split_mode`:
|
||||
- `equal` — `price` is the TOTAL; split equally among effective participants ("rental car 50/50"). `paid_by` is credited with having paid the total.
|
||||
- `own` — `price` is PER PERSON; each effective participant owes and pays their own ("everyone pays their own flight"). No debt created. Effective total = price × participant count.
|
||||
- `payer` — personal expense: `paid_by` owes and pays the whole price alone (requires `paid_by`; validation error otherwise).
|
||||
|
||||
## Endpoints
|
||||
|
||||
### Auth (Mullvad-style account tokens)
|
||||
|
||||
| Method & path | Body | Response |
|
||||
|---|---|---|
|
||||
| `POST /api/auth/account` | `{}` (empty) | `201 {user: {id, display_name}, token: "XXXX-XXXX-XXXX-XXXX"}` — creates an account, logs it in. The raw token is returned ONLY here, never again. |
|
||||
| `POST /api/auth/login` | `{token}` | `200 {user}`; `401` on unknown token |
|
||||
| `POST /api/auth/logout` | — | `204` |
|
||||
| `GET /api/auth/me` | — | `200 {user: {id, display_name}}` or `401` |
|
||||
| `PATCH /api/auth/me` | `{display_name}` | `200 {user}`; validates 1–40 chars after trim |
|
||||
|
||||
Token rules:
|
||||
- 16 chars from the unambiguous alphabet `ABCDEFGHJKMNPQRSTUVWXYZ23456789` (no I/L/O/0/1), generated with `crypto.randomBytes`, displayed grouped `XXXX-XXXX-XXXX-XXXX`.
|
||||
- Login normalizes input: strip dashes/spaces, uppercase, then compare `sha256(token)` against `users.token_hash`. Only the hash is stored.
|
||||
- `display_name` is auto-generated at account creation as `adjective-animal` (e.g. `brave-otter`) from two small built-in word lists; not unique; user-editable via PATCH.
|
||||
|
||||
User JSON shape everywhere: `{id, display_name}`.
|
||||
|
||||
### Trips
|
||||
|
||||
| Method & path | Body | Response |
|
||||
|---|---|---|
|
||||
| `GET /api/trips` | — | `200 {trips: [{id, name, start_date, end_date, owner_id, currency, role, member_count, entry_count}]}` (trips where user is member, newest first) |
|
||||
| `POST /api/trips` | `{name, start_date, end_date, currency?}` | `201 {trip}`; validates: name non-empty ≤120 chars, valid dates, `end_date >= start_date`, range ≤ 365 days, currency (if given) matches `^[A-Z]{3}$` (default `USD`). Creator becomes member with role `owner`. |
|
||||
| `GET /api/trips/:id` | — | `200 {trip: {id, name, start_date, end_date, owner_id, currency, join_code}, members: [{id, display_name, role}], entries: [entry…]}` entries ordered by `(date, sort_order, id)` |
|
||||
| `PATCH /api/trips/:id` | any of `{name, start_date, end_date, currency}` | `200 {trip}` (same validation; entries outside new range are kept) |
|
||||
| `DELETE /api/trips/:id` | — | `204` — owner only, else `403`. Deletes members + entries too. |
|
||||
| `POST /api/trips/join` | `{code}` | `200 {trip}` — joins the trip with that join_code as `editor` (idempotent: already a member → still `200 {trip}`); `404 {"error":"not found"}` on unknown code. Code normalized like tokens (strip dashes/spaces, uppercase). |
|
||||
| `POST /api/trips/:id/join-code` | — | `200 {trip}` — regenerates join_code, owner only (`403` otherwise) |
|
||||
| `DELETE /api/trips/:id/members/:userId` | — | `204` — owner only (owner cannot remove self) |
|
||||
|
||||
`join_code`: 8 chars, same alphabet as account tokens, generated at trip creation, displayed grouped `XXXX-XXXX`. Members see it in trip JSON (it's how they invite others); it is NOT a credential — it only grants membership of that one trip.
|
||||
|
||||
### Entries
|
||||
|
||||
| Method & path | Body | Response |
|
||||
|---|---|---|
|
||||
| `POST /api/trips/:id/entries` | `{date, type, title, details?, start_time?, end_time?, location_name?, lat?, lng?, sort_order?, price?, paid_by?, split_mode?, participants?, segments?}` | `201 {entry}`; validates type enum, date format, title non-empty ≤200 chars; lat/lng must both be present or both absent, lat ∈ [-90,90], lng ∈ [-180,180]; price null or number ≥ 0; paid_by null or a trip member's user id; split_mode ∈ `equal\|own\|payer` (`payer` requires paid_by); participants null/[] (= all members) or array of trip-member user ids; segments per "Flight segments" above |
|
||||
| `PATCH /api/entries/:id` | any subset of the above | `200 {entry}` (member of the entry's trip required; `participants` replaces the whole set) |
|
||||
| `DELETE /api/entries/:id` | — | `204` (also deletes its entry_participants rows) |
|
||||
|
||||
Entry JSON shape (always full row): `{id, trip_id, date, type, title, details, start_time, end_time, location_name, lat, lng, sort_order, price, paid_by, split_mode, participants, segments}` where `participants` is an array of user ids (`[]` = all members) and `segments` is the parsed array or `null`.
|
||||
|
||||
### Route & summary (computed)
|
||||
|
||||
`GET /api/trips/:id/route` →
|
||||
|
||||
```json
|
||||
{
|
||||
"stops": [ {"entryId": 1, "date": "2026-08-01", "type": "flight",
|
||||
"title": "BKK → CNX", "location_name": "Chiang Mai", "lat": 18.79, "lng": 98.98} ],
|
||||
"legs": [ {"fromEntryId": 1, "toEntryId": 4, "km": 587.3, "mode": "ground"} ],
|
||||
"totalKm": 587.3,
|
||||
"summary": {
|
||||
"days": 10, "nights": 9,
|
||||
"flights": 2, "flightSegments": 4, "hotels": 3, "travelLegs": 1, "activities": 4,
|
||||
"rentals": 1, "includedKm": 1500,
|
||||
"kmAir": 0, "kmDriven": 587.3,
|
||||
"locations": ["Bangkok", "Chiang Mai"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `stops` = entries having lat/lng, ordered by `(date, sort_order, id)`; flight entries with coordinate-bearing segments are expanded into airport stops instead (see "Flight segments" — `kind: "airport"`, includes `code`).
|
||||
- `legs` = consecutive stop pairs; skip zero-distance pairs (< 0.05 km) — still include the stop, just no leg.
|
||||
- Every leg has `mode`: `"air"` when BOTH endpoints are `kind:"airport"` stops expanded from the SAME flight entry (i.e. actual flight segments); `"ground"` for everything else (hotel→airport transfers, city-to-city drives). `summary.kmAir` / `summary.kmDriven` are the per-mode sums (1 decimal). `kmDriven` is the rough rental-car figure: great-circle, so real road km will be somewhat higher.
|
||||
- `days` = inclusive count from start_date to end_date; `nights = days - 1` (0 for single-day trips).
|
||||
- `locations` = unique `location_name` values in stop order.
|
||||
- km rounded to 1 decimal.
|
||||
|
||||
### Costs & splitting (computed)
|
||||
|
||||
`GET /api/trips/:id/costs` →
|
||||
|
||||
```json
|
||||
{
|
||||
"currency": "USD",
|
||||
"totalCost": 1450.0,
|
||||
"byType": { "flight": 800.0, "travel": 300.0, "hotel": 350.0 },
|
||||
"perUser": [
|
||||
{ "userId": 1, "displayName": "brave-otter", "share": 725.0, "paid": 950.0, "net": 225.0 },
|
||||
{ "userId": 2, "displayName": "calm-heron", "share": 725.0, "paid": 500.0, "net": -225.0 }
|
||||
],
|
||||
"settlements": [ { "fromUserId": 2, "toUserId": 1, "amount": 225.0 } ],
|
||||
"unassigned": 0.0
|
||||
}
|
||||
```
|
||||
|
||||
Computation (see Cost semantics above):
|
||||
|
||||
- `share` = what the user's part of the trip costs; `paid` = what they actually paid; `net = paid - share` (positive → others owe them).
|
||||
- `equal` with `paid_by` null: the cost still counts into shares and `byType`, but nobody is credited as payer — accumulate that amount into `unassigned` (frontend shows a hint to assign payers).
|
||||
- `own`: adds `price` to each participant's `share` AND `paid` (self-paid, no debt).
|
||||
- `settlements`: minimal-transfer greedy — repeatedly match the largest debtor with the largest creditor until all nets are settled; amounts rounded to 2 decimals, drop transfers < 0.01.
|
||||
- `totalCost` = sum of effective totals of all priced entries; `byType` groups the same by entry type.
|
||||
- All money values rounded to 2 decimals in the response.
|
||||
|
||||
### Geocoding proxy
|
||||
|
||||
`GET /api/geocode?q=<query>` → `200 {results: [{name, lat, lng}]}` (max 5).
|
||||
|
||||
Proxies `https://nominatim.openstreetmap.org/search?format=jsonv2&limit=5&accept-language=en&q=…` server-side with header `User-Agent: trip-plan-app/0.1 (self-hosted)` (`accept-language=en` so results come back in Latin script, not the local language). Map Nominatim's `display_name`→`name`, parse lat/lon to numbers. On upstream failure return `502 {"error":"geocoding unavailable"}`. Cache identical queries in-memory for 10 minutes.
|
||||
|
||||
## Frontend contract notes
|
||||
|
||||
- SPA served from `public/`; all non-`/api` GETs fall back to `public/index.html` is NOT required — a single `index.html` with hash-based routing (`#/login`, `#/trips`, `#/trip/:id`) is the expected design, so no server-side fallback is needed.
|
||||
- Leaflet 1.9.x via unpkg CDN in `index.html`.
|
||||
- Session cookie is httpOnly; frontend detects auth state via `GET /api/auth/me` on load.
|
||||
@@ -0,0 +1,133 @@
|
||||
# Trip Plan — Project Overview
|
||||
|
||||
A self-hosted, multi-user web tool for collaboratively planning trips. Runs as a single Docker container.
|
||||
|
||||
## Core Idea
|
||||
|
||||
Multiple people log in to the same instance. Anyone can create a trip by picking a name and a date range. The tool generates a day-by-day calendar for that range, and every member of the trip can fill in what happens on each day — flights, hotel stays, border crossings, drives, activities. The trip is visualized on an interactive map with the route drawn between stops, distances per leg, and an overall summary (days, nights, flights, total km).
|
||||
|
||||
## Features
|
||||
|
||||
### 1. Users & Access (privacy-friendly, Mullvad-style)
|
||||
- **No registration form, no email, no password.** Click "Create account" and you get a random account number (e.g. `7K2M-9QX4-TT8B-3WPF`) — that token IS your login. It's shown once; only its hash is stored server-side.
|
||||
- Every account gets an auto-generated, editable **display name** (e.g. `brave-otter`) so members and cost splits stay human-readable.
|
||||
- Trips are shared via a **join code**: every trip has a short code (e.g. `M4TH-8RK2`); anyone with an account can enter it under "Join a trip" to become an editor. The owner can regenerate the code to cut off further joins.
|
||||
|
||||
### 2. Trips
|
||||
- Create a trip with **name + start date + end date**.
|
||||
- Editing the date range regenerates the calendar (existing day entries outside the new range are kept but flagged).
|
||||
- Trip list dashboard showing all trips you're a member of.
|
||||
|
||||
### 3. Day-by-Day Calendar
|
||||
- Calendar grid generated from the trip's date range (weeks as rows, like a real calendar).
|
||||
- Click any day to open the day editor and add **entries**:
|
||||
|
||||
| Entry type | Typical fields |
|
||||
|---|---|
|
||||
| ✈️ Flight | flight no., from/to airports, departure/arrival time |
|
||||
| 🛂 Immigration / border | location, notes (visa, documents) |
|
||||
| 🚗 Travel / transfer | mode (car/train/bus/boat), from → to |
|
||||
| 🏨 Hotel stay | hotel name, check-in/check-out, booking ref |
|
||||
| 📍 Activity / sightseeing | place, time, notes |
|
||||
| 📝 Note | free text |
|
||||
|
||||
- Every entry can have a **location** (searched via OpenStreetMap geocoding — type "Chiang Mai" and pick from suggestions; lat/lng stored automatically).
|
||||
- Entries show as compact chips inside the day cell; multiple entries per day, ordered.
|
||||
|
||||
### 4. Map Visualization
|
||||
- Interactive map (Leaflet + OpenStreetMap tiles — free, no API key required, unlike Google Maps).
|
||||
- All located entries plotted as markers, numbered in chronological order.
|
||||
- Route polyline connecting the stops in order.
|
||||
- **Distance per leg** (km, great-circle) shown on the route and in a leg-by-leg list.
|
||||
|
||||
### 5. Costs & Splitting
|
||||
- Every entry can carry a **price** (flights, hotels, car rental, train tickets, activities…), in the trip's currency (one currency per trip, no FX conversion).
|
||||
- Each priced entry records **who paid** and how it's **split**:
|
||||
- `equal` — total split equally among selected participants (e.g. rental car 50/50)
|
||||
- `own` — price is per person, everyone pays their own (e.g. flights)
|
||||
- `payer` — personal expense of the payer
|
||||
- **Cost summary** per trip: total cost, breakdown by type, per-user share vs. paid vs. net balance, and **settle-up suggestions** (who transfers how much to whom).
|
||||
|
||||
### 6. Summary
|
||||
- Total days and nights.
|
||||
- Number of flights, hotel stays, travel legs.
|
||||
- **Total distance in km** across the whole trip.
|
||||
- Countries/locations visited (from entry locations).
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Choice | Why |
|
||||
|---|---|---|
|
||||
| Backend | Node.js 20 + Express | Small, ubiquitous, one runtime in the container |
|
||||
| Database | SQLite (better-sqlite3) | Zero-config single file; persisted via Docker volume |
|
||||
| Auth | cookie sessions + bcrypt | Simple and adequate for a self-hosted tool |
|
||||
| Frontend | Vanilla JS SPA (no build step) | Keeps image small and the stack simple |
|
||||
| Map | Leaflet + OpenStreetMap | Free, no API key, offline-friendly tiles options |
|
||||
| Geocoding | Nominatim (proxied server-side) | Free location search, no key |
|
||||
| Packaging | Dockerfile + docker-compose | `docker compose up` and you're running |
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
trip_plan/
|
||||
├── src/server/ # Express app
|
||||
│ ├── index.js # bootstrap, static serving
|
||||
│ ├── db.js # SQLite schema + connection
|
||||
│ ├── auth.js # session middleware
|
||||
│ ├── routes/ # auth, trips, entries, geocode
|
||||
│ └── util/distance.js # haversine km calc
|
||||
├── public/ # SPA served statically
|
||||
│ ├── index.html
|
||||
│ ├── css/styles.css
|
||||
│ └── js/ # api client, views (trips, calendar, map, summary)
|
||||
├── tests/ # API tests (node:test + supertest)
|
||||
├── data/ # SQLite file (gitignored, volume-mounted)
|
||||
├── docs/ # this file + API.md
|
||||
├── Dockerfile
|
||||
└── docker-compose.yml
|
||||
```
|
||||
|
||||
### Data Model
|
||||
|
||||
```
|
||||
users (id, username, password_hash, created_at)
|
||||
trips (id, name, start_date, end_date, owner_id, created_at)
|
||||
trip_members (trip_id, user_id, role) -- owner | editor
|
||||
entries (id, trip_id, date, type, title, details,
|
||||
start_time, end_time, location_name, lat, lng, sort_order)
|
||||
```
|
||||
|
||||
Route + km are **derived**: located entries ordered by (date, sort_order) form the route; each consecutive pair is a leg with haversine distance.
|
||||
|
||||
### API (REST, JSON, under /api)
|
||||
|
||||
- `POST /api/auth/account` (create token account) · `POST /api/auth/login` · `POST /api/auth/logout` · `GET/PATCH /api/auth/me`
|
||||
- `GET/POST /api/trips` · `GET/PATCH/DELETE /api/trips/:id`
|
||||
- `POST /api/trips/join` (by join code) · `POST /api/trips/:id/join-code` (regenerate)
|
||||
- `GET/POST /api/trips/:id/entries` · `PATCH/DELETE /api/entries/:id`
|
||||
- `GET /api/trips/:id/route` (legs + km + summary, computed server-side)
|
||||
- `GET /api/geocode?q=...` (Nominatim proxy)
|
||||
|
||||
## Running It
|
||||
|
||||
```bash
|
||||
# development
|
||||
npm install
|
||||
npm start # http://localhost:3000
|
||||
|
||||
# production
|
||||
docker compose up -d # builds image, mounts ./data for the SQLite file
|
||||
```
|
||||
|
||||
## Non-Goals (for now)
|
||||
|
||||
- Real-time collaborative editing (last-write-wins is fine for this scale)
|
||||
- Driving-route distances via a routing engine (great-circle km first; OSRM could be added later)
|
||||
- Email invites / password reset (admin resets via CLI)
|
||||
|
||||
## Build Plan
|
||||
|
||||
1. **Scaffold** — repo, package.json, this doc, API contract. ✅
|
||||
2. **Swarm build** (parallel agents): backend API + DB · frontend SPA (calendar, map, summary) · Docker/infra + tests.
|
||||
3. **Integrate & verify** — run tests, boot server, smoke-test in browser, build Docker image.
|
||||
4. Initial commit.
|
||||
Generated
+1610
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "trip-plan",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Self-hosted multi-user trip planning web tool",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node src/server/index.js",
|
||||
"dev": "node --watch src/server/index.js",
|
||||
"test": "node --test tests/"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^11.5.0",
|
||||
"cookie-session": "^2.1.0",
|
||||
"express": "^4.19.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"supertest": "^7.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/* Split-flap flip-clock countdown. Cards stay dark in the light theme — that
|
||||
is the airport/clock aesthetic. */
|
||||
|
||||
.countdown-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.4rem 0 0.2rem;
|
||||
}
|
||||
.countdown-caption {
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.18em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.flipclock {
|
||||
--w: 46px;
|
||||
--h: 66px;
|
||||
--fs: 46px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.fc-group { display: flex; flex-direction: column; align-items: center; gap: 0.35rem; }
|
||||
.fc-digits { display: flex; gap: 3px; }
|
||||
.fc-label {
|
||||
font-size: 0.62rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
.fc-sep {
|
||||
font-size: calc(var(--fs) * 0.7);
|
||||
font-weight: 800;
|
||||
color: var(--text-faint);
|
||||
line-height: var(--h);
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
/* One flip card = two static halves + two animating flip halves. */
|
||||
.fc-card {
|
||||
position: relative;
|
||||
width: var(--w);
|
||||
height: var(--h);
|
||||
perspective: 320px;
|
||||
filter: drop-shadow(0 3px 5px rgba(15, 23, 42, 0.28));
|
||||
}
|
||||
.fc-face {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: calc(var(--h) / 2);
|
||||
overflow: hidden;
|
||||
background: #1e2430;
|
||||
}
|
||||
.fc-face b {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: var(--h);
|
||||
line-height: var(--h);
|
||||
text-align: center;
|
||||
font-size: var(--fs);
|
||||
font-weight: 700;
|
||||
color: #f1f5f9;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.fc-top { top: 0; border-radius: 7px 7px 0 0; border-bottom: 1px solid rgba(0, 0, 0, 0.45); }
|
||||
.fc-top b { top: 0; }
|
||||
.fc-bottom { bottom: 0; border-radius: 0 0 7px 7px; }
|
||||
.fc-bottom b { bottom: 0; }
|
||||
|
||||
.fc-flip { display: none; backface-visibility: hidden; }
|
||||
.fc-flip-top { top: 0; border-radius: 7px 7px 0 0; border-bottom: 1px solid rgba(0, 0, 0, 0.45); transform-origin: bottom; }
|
||||
.fc-flip-top b { top: 0; }
|
||||
.fc-flip-bottom { bottom: 0; border-radius: 0 0 7px 7px; transform-origin: top; transform: rotateX(90deg); }
|
||||
.fc-flip-bottom b { bottom: 0; }
|
||||
|
||||
.fc-flipping .fc-flip { display: block; }
|
||||
.fc-flipping .fc-flip-top { animation: fc-top 0.28s ease-in forwards; }
|
||||
.fc-flipping .fc-flip-bottom { animation: fc-bottom 0.28s ease-in 0.28s forwards; }
|
||||
@keyframes fc-top { to { transform: rotateX(-90deg); } }
|
||||
@keyframes fc-bottom { from { transform: rotateX(90deg); } to { transform: rotateX(0deg); } }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.fc-flipping .fc-flip-top,
|
||||
.fc-flipping .fc-flip-bottom { animation: none; }
|
||||
.fc-flip { display: none !important; }
|
||||
}
|
||||
|
||||
/* Ongoing / completed badge (no clock) */
|
||||
.countdown-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 999px;
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.countdown-badge.ongoing { background: linear-gradient(135deg, var(--brand), var(--accent)); color: #fff; }
|
||||
.countdown-badge.done { background: var(--surface-2); color: var(--text-muted); border: 1px solid var(--border); }
|
||||
.cd-badge-icon { font-size: 1.1rem; }
|
||||
|
||||
/* Responsive: shrink digits on narrow screens */
|
||||
@media (max-width: 640px) {
|
||||
.flipclock { --w: 34px; --h: 50px; --fs: 34px; gap: 0.35rem; }
|
||||
}
|
||||
@media (max-width: 380px) {
|
||||
.flipclock { --w: 26px; --h: 40px; --fs: 26px; }
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
/* Trip Plan — SPA styles. Light theme, CSS custom-property palette. */
|
||||
|
||||
:root {
|
||||
--brand: #2563eb;
|
||||
--brand-dark: #1d4ed8;
|
||||
--brand-soft: #eff4ff;
|
||||
--accent: #0ea5e9;
|
||||
|
||||
--bg: #f4f6fb;
|
||||
--surface: #ffffff;
|
||||
--surface-2: #f8fafc;
|
||||
--border: #e5e9f2;
|
||||
--border-strong: #d4dbe8;
|
||||
|
||||
--text: #1e293b;
|
||||
--text-muted: #64748b;
|
||||
--text-faint: #94a3b8;
|
||||
|
||||
--danger: #dc2626;
|
||||
--danger-soft: #fef2f2;
|
||||
--success: #059669;
|
||||
--warn: #d97706;
|
||||
|
||||
--radius: 14px;
|
||||
--radius-sm: 9px;
|
||||
--shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.06), 0 1px 3px rgba(15, 23, 42, 0.05);
|
||||
--shadow-md: 0 4px 14px rgba(15, 23, 42, 0.08);
|
||||
--shadow-lg: 0 18px 48px rgba(15, 23, 42, 0.22);
|
||||
|
||||
--font: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body { height: 100%; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--font);
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
body.no-scroll { overflow: hidden; }
|
||||
|
||||
h1, h2, h3 { margin: 0; font-weight: 700; line-height: 1.25; }
|
||||
h1 { font-size: 1.6rem; }
|
||||
h2 { font-size: 1.2rem; }
|
||||
h3 { font-size: 1rem; }
|
||||
p { margin: 0; }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
|
||||
.muted { color: var(--text-muted); font-size: 0.9rem; }
|
||||
.hint { font-size: 0.82rem; }
|
||||
|
||||
/* ---------- Buttons & inputs ---------- */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--border-strong);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
padding: 0.55rem 1rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, box-shadow 0.15s, border-color 0.15s, transform 0.05s;
|
||||
}
|
||||
.btn:hover { background: var(--surface-2); }
|
||||
.btn:active { transform: translateY(1px); }
|
||||
.btn:disabled { opacity: 0.6; cursor: default; }
|
||||
.btn-primary { background: var(--brand); border-color: var(--brand); color: #fff; }
|
||||
.btn-primary:hover { background: var(--brand-dark); }
|
||||
.btn-ghost { border-color: transparent; background: transparent; }
|
||||
.btn-ghost:hover { background: var(--surface-2); }
|
||||
.btn-danger-ghost { border-color: transparent; background: transparent; color: var(--danger); }
|
||||
.btn-danger-ghost:hover { background: var(--danger-soft); }
|
||||
.btn-block { width: 100%; }
|
||||
.btn-sm { padding: 0.4rem 0.7rem; font-size: 0.85rem; }
|
||||
.link-btn {
|
||||
border: none; background: none; color: var(--brand);
|
||||
font-weight: 600; cursor: pointer; padding: 0; font-size: inherit;
|
||||
}
|
||||
.link-btn:hover { text-decoration: underline; }
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: 0.55rem 0.7rem;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.95rem;
|
||||
font-family: inherit;
|
||||
color: var(--text);
|
||||
background: var(--surface);
|
||||
}
|
||||
.input:focus { outline: none; border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-soft); }
|
||||
.input-sm { padding: 0.4rem 0.6rem; font-size: 0.88rem; }
|
||||
textarea.input { resize: vertical; }
|
||||
|
||||
.field { display: flex; flex-direction: column; gap: 0.3rem; }
|
||||
.field-grow { flex: 1; }
|
||||
.field-label { font-size: 0.78rem; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.03em; }
|
||||
.form-row { display: flex; gap: 0.8rem; flex-wrap: wrap; }
|
||||
.form-row > .field { flex: 1; min-width: 140px; }
|
||||
.form-actions { display: flex; gap: 0.6rem; justify-content: flex-end; margin-top: 0.4rem; }
|
||||
.form-error { color: var(--danger); font-size: 0.85rem; min-height: 1rem; margin: 0.2rem 0; }
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-sm);
|
||||
padding: 1.1rem 1.2rem;
|
||||
}
|
||||
|
||||
/* ---------- Top nav ---------- */
|
||||
.topnav {
|
||||
position: sticky; top: 0; z-index: 20;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 0.7rem 1.4rem;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
backdrop-filter: blur(8px);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.brand { display: flex; align-items: center; gap: 0.5rem; font-weight: 800; font-size: 1.1rem; }
|
||||
.brand-mark { font-size: 1.3rem; }
|
||||
.brand-mark-lg { font-size: 2.4rem; }
|
||||
.nav-right { display: flex; align-items: center; gap: 0.8rem; }
|
||||
.nav-user { display: flex; align-items: center; gap: 0.45rem; font-weight: 600; font-size: 0.9rem; }
|
||||
.nav-avatar, .member-avatar {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 1.7rem; height: 1.7rem; border-radius: 50%;
|
||||
background: var(--brand); color: #fff; font-size: 0.8rem; font-weight: 700;
|
||||
}
|
||||
|
||||
/* ---------- Layout ---------- */
|
||||
.view { display: block; }
|
||||
.page { max-width: 1180px; margin: 0 auto; padding: 1.4rem; display: flex; flex-direction: column; gap: 1.2rem; }
|
||||
.page-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
|
||||
.section-head { margin-bottom: 0.8rem; }
|
||||
.form-slot:empty { display: none; }
|
||||
|
||||
/* ---------- Auth ---------- */
|
||||
.auth-wrap { min-height: 100vh; display: grid; place-items: center; padding: 1.5rem;
|
||||
background: radial-gradient(1200px 600px at 50% -10%, #dbe7ff 0%, var(--bg) 55%); }
|
||||
.auth-card { width: 100%; max-width: 400px; padding: 1.8rem; }
|
||||
.auth-head { text-align: center; margin-bottom: 1.3rem; }
|
||||
.auth-head h1 { margin: 0.4rem 0 0.2rem; }
|
||||
.auth-tabs { display: flex; gap: 0.3rem; background: var(--surface-2); padding: 0.25rem; border-radius: var(--radius-sm); margin-bottom: 1.1rem; }
|
||||
.auth-tab { flex: 1; border: none; background: none; padding: 0.5rem; border-radius: var(--radius-sm); cursor: pointer; font-weight: 600; color: var(--text-muted); }
|
||||
.auth-tab.active { background: var(--surface); color: var(--text); box-shadow: var(--shadow-sm); }
|
||||
.auth-form { display: flex; flex-direction: column; gap: 0.9rem; }
|
||||
.auth-toggle { text-align: center; margin-top: 1rem; font-size: 0.9rem; color: var(--text-muted); }
|
||||
.auth-panel { display: flex; flex-direction: column; gap: 0.9rem; }
|
||||
.auth-lead { color: var(--text-muted); font-size: 0.92rem; }
|
||||
.token-input { font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; letter-spacing: 0.08em; text-transform: uppercase; }
|
||||
.token-reveal { gap: 1rem; }
|
||||
.token-warning { background: #fffbeb; border: 1px solid #fde68a; color: #92400e; font-size: 0.85rem; padding: 0.7rem 0.85rem; border-radius: var(--radius-sm); line-height: 1.45; }
|
||||
.token-box { font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size: 1.35rem; font-weight: 700; letter-spacing: 0.1em; text-align: center; padding: 0.9rem; background: var(--surface-2); border: 2px dashed var(--border-strong); border-radius: var(--radius-sm); user-select: all; word-break: break-all; }
|
||||
.token-actions { display: flex; justify-content: center; }
|
||||
|
||||
/* Nav inline name edit */
|
||||
.nav-name { font-weight: 600; }
|
||||
.nav-edit { border: none; background: none; cursor: pointer; color: var(--text-faint); font-size: 0.85rem; padding: 0 0.15rem; }
|
||||
.nav-edit:hover { color: var(--brand); }
|
||||
.nav-name-input { width: 150px; }
|
||||
.page-head-actions { display: flex; gap: 0.5rem; align-items: center; }
|
||||
.empty-actions { display: flex; gap: 0.5rem; justify-content: center; }
|
||||
.trip-card-currency { margin-left: auto; font-weight: 700; font-size: 0.72rem; letter-spacing: 0.03em; color: var(--text-faint); }
|
||||
.join-form { display: flex; flex-direction: column; gap: 0.8rem; }
|
||||
|
||||
/* Join-code row on the trip header */
|
||||
.joincode-row { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; padding-top: 0.8rem; border-top: 1px solid var(--border); }
|
||||
.joincode-chip { font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-weight: 700; letter-spacing: 0.08em; background: var(--brand-soft); color: var(--brand-dark); padding: 0.25rem 0.6rem; border-radius: var(--radius-sm); user-select: all; }
|
||||
|
||||
/* ---------- Trip list ---------- */
|
||||
.trip-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 1rem; }
|
||||
.trip-card { display: flex; flex-direction: column; gap: 0.6rem; cursor: pointer; transition: transform 0.12s, box-shadow 0.12s; }
|
||||
.trip-card:hover { transform: translateY(-3px); box-shadow: var(--shadow-md); border-color: var(--border-strong); }
|
||||
.trip-card-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 0.6rem; }
|
||||
.trip-card-name { font-size: 1.1rem; }
|
||||
.trip-card-dates { color: var(--text-muted); font-size: 0.9rem; display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; }
|
||||
.trip-card-countdown { font-size: 0.72rem; font-weight: 700; color: var(--brand-dark); background: var(--brand-soft); padding: 0.1rem 0.45rem; border-radius: 999px; }
|
||||
.trip-card-meta { display: flex; gap: 1rem; font-size: 0.85rem; color: var(--text-muted); margin-top: auto; }
|
||||
.role-badge { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.04em; font-weight: 700; padding: 0.15rem 0.5rem; border-radius: 999px; }
|
||||
.role-owner { background: #fef3c7; color: #92400e; }
|
||||
.role-editor { background: var(--brand-soft); color: var(--brand-dark); }
|
||||
.new-trip-form { display: flex; flex-direction: column; gap: 0.8rem; }
|
||||
|
||||
/* ---------- Empty / loading / error ---------- */
|
||||
.loading { display: flex; align-items: center; gap: 0.6rem; justify-content: center; padding: 3rem; color: var(--text-muted); }
|
||||
.spinner { width: 1.1rem; height: 1.1rem; border: 2px solid var(--border-strong); border-top-color: var(--brand); border-radius: 50%; animation: spin 0.8s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.empty-state { text-align: center; padding: 3rem 1.5rem; color: var(--text-muted); display: flex; flex-direction: column; gap: 0.6rem; align-items: center; }
|
||||
.empty-icon { font-size: 2.6rem; }
|
||||
.empty-state h3 { color: var(--text); }
|
||||
.error-box { text-align: center; padding: 2rem; color: var(--danger); display: flex; flex-direction: column; gap: 0.8rem; align-items: center; }
|
||||
|
||||
/* ---------- Trip header ---------- */
|
||||
.trip-header { display: flex; flex-direction: column; gap: 1rem; }
|
||||
.trip-header-top { display: flex; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
|
||||
.back-link { font-size: 0.85rem; color: var(--brand); font-weight: 600; }
|
||||
.back-link:hover { text-decoration: underline; }
|
||||
.trip-title { margin-top: 0.3rem; }
|
||||
.trip-subtitle { margin-top: 0.2rem; }
|
||||
.trip-header-actions { display: flex; gap: 0.5rem; align-items: flex-start; }
|
||||
.members-row { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; padding-top: 0.8rem; border-top: 1px solid var(--border); }
|
||||
.members-label { font-size: 0.82rem; font-weight: 600; color: var(--text-muted); }
|
||||
.member-chip { display: inline-flex; align-items: center; gap: 0.4rem; background: var(--surface-2); border: 1px solid var(--border); border-radius: 999px; padding: 0.25rem 0.6rem 0.25rem 0.3rem; font-size: 0.85rem; font-weight: 600; }
|
||||
.member-owner { background: #fffbeb; border-color: #fde68a; }
|
||||
.member-remove { border: none; background: none; cursor: pointer; color: var(--text-faint); font-size: 1rem; line-height: 1; padding: 0; }
|
||||
.member-remove:hover { color: var(--danger); }
|
||||
.invite-form { display: flex; gap: 0.4rem; margin-left: auto; }
|
||||
.invite-form .input { width: 180px; }
|
||||
.trip-edit { display: flex; flex-direction: column; gap: 0.8rem; padding-top: 0.9rem; border-top: 1px solid var(--border); }
|
||||
|
||||
/* ---------- Calendar ---------- */
|
||||
.legend { display: flex; flex-wrap: wrap; gap: 0.7rem; margin-bottom: 0.9rem; }
|
||||
.legend-item { display: inline-flex; align-items: center; gap: 0.35rem; font-size: 0.78rem; color: var(--text-muted); }
|
||||
.legend-dot { width: 0.7rem; height: 0.7rem; border-radius: 50%; display: inline-block; }
|
||||
.calendar-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 6px; }
|
||||
.cal-weekday { text-align: center; font-size: 0.72rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-faint); padding-bottom: 0.2rem; }
|
||||
.cal-day { min-height: 92px; background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 0.35rem; display: flex; flex-direction: column; gap: 0.25rem; }
|
||||
.cal-day.clickable { cursor: pointer; transition: border-color 0.12s, box-shadow 0.12s; }
|
||||
.cal-day.clickable:hover { border-color: var(--brand); box-shadow: 0 0 0 2px var(--brand-soft); }
|
||||
.cal-day.cal-out { background: repeating-linear-gradient(45deg, #f1f5f9, #f1f5f9 8px, #eef2f7 8px, #eef2f7 16px); color: var(--text-faint); }
|
||||
.cal-day-head { display: flex; align-items: center; gap: 0.3rem; }
|
||||
.cal-daynum { font-weight: 700; font-size: 0.82rem; }
|
||||
.cal-out .cal-daynum { color: var(--text-faint); font-weight: 600; }
|
||||
.cal-month { font-size: 0.68rem; font-weight: 700; text-transform: uppercase; color: var(--brand); }
|
||||
.cal-flag { margin-left: auto; color: var(--warn); font-size: 0.75rem; }
|
||||
.cal-chips { display: flex; flex-direction: column; gap: 3px; overflow: hidden; }
|
||||
.cal-chip { display: flex; align-items: center; gap: 0.25rem; font-size: 0.72rem; padding: 0.12rem 0.35rem; border-radius: 6px; background: color-mix(in srgb, var(--chip) 14%, white); border-left: 3px solid var(--chip); color: #334155; }
|
||||
.chip-icon { flex-shrink: 0; }
|
||||
.chip-text { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
/* ---------- Detail grid (map + summary) ---------- */
|
||||
.detail-grid { display: grid; grid-template-columns: 1.7fr 1fr; gap: 1.2rem; align-items: start; }
|
||||
.map-section, .summary-section { display: flex; flex-direction: column; }
|
||||
.leaflet-map { height: 420px; width: 100%; border-radius: var(--radius-sm); overflow: hidden; border: 1px solid var(--border); z-index: 0; }
|
||||
.map-empty { text-align: center; padding: 3rem 1rem; color: var(--text-muted); display: flex; flex-direction: column; gap: 0.4rem; align-items: center; }
|
||||
|
||||
.map-pin-wrap { background: none; border: none; }
|
||||
.map-pin { display: inline-flex; align-items: center; justify-content: center; width: 26px; height: 26px; border-radius: 50%; color: #fff; font-weight: 700; font-size: 0.8rem; box-shadow: 0 2px 6px rgba(0,0,0,0.35); border: 2px solid #fff; }
|
||||
.km-label { background: rgba(37, 99, 235, 0.92); color: #fff; font-size: 0.7rem; font-weight: 700; padding: 0.1rem 0.4rem; border-radius: 999px; white-space: nowrap; box-shadow: 0 1px 3px rgba(0,0,0,0.3); }
|
||||
.map-popup strong { font-size: 0.95rem; }
|
||||
.map-popup-sub { color: #64748b; font-size: 0.8rem; margin-top: 0.15rem; }
|
||||
.map-popup-loc { font-size: 0.82rem; margin-top: 0.15rem; }
|
||||
|
||||
.leg-list, .leg-list-empty { margin-top: 1rem; }
|
||||
.leg-list h3 { margin-bottom: 0.5rem; }
|
||||
.leg-row { display: flex; align-items: center; gap: 0.6rem; padding: 0.4rem 0; border-bottom: 1px solid var(--border); font-size: 0.86rem; }
|
||||
.leg-index { flex-shrink: 0; width: 1.4rem; height: 1.4rem; display: inline-flex; align-items: center; justify-content: center; background: var(--brand-soft); color: var(--brand-dark); border-radius: 50%; font-weight: 700; font-size: 0.75rem; }
|
||||
.leg-path { flex: 1; overflow: hidden; text-overflow: ellipsis; }
|
||||
.leg-km { font-weight: 700; color: var(--brand-dark); white-space: nowrap; }
|
||||
|
||||
/* ---------- Summary ---------- */
|
||||
.stat-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.6rem; }
|
||||
.stat-tile { display: flex; flex-direction: column; align-items: center; gap: 0.1rem; padding: 0.7rem 0.4rem; background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--radius-sm); }
|
||||
.stat-icon { font-size: 1.1rem; }
|
||||
.stat-value { font-size: 1.35rem; font-weight: 800; }
|
||||
.stat-label { font-size: 0.72rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.03em; }
|
||||
.km-block { margin: 1rem 0; }
|
||||
.total-km { padding: 0.9rem; text-align: center; background: linear-gradient(135deg, var(--brand), var(--accent)); color: #fff; border-radius: var(--radius-sm); display: flex; flex-direction: column; }
|
||||
.total-km-value { font-size: 1.8rem; font-weight: 800; }
|
||||
.total-km-label { font-size: 0.78rem; opacity: 0.9; }
|
||||
.km-breakdown { display: flex; flex-direction: column; gap: 0.3rem; margin-top: 0.6rem; }
|
||||
.km-row { display: flex; align-items: baseline; gap: 0.5rem; font-size: 0.9rem; }
|
||||
.km-row-icon { flex-shrink: 0; }
|
||||
.km-row-value { font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||
.km-row-label { font-size: 0.82rem; }
|
||||
.leg-mode { flex-shrink: 0; font-size: 0.85rem; }
|
||||
.loc-block h3 { margin-bottom: 0.5rem; }
|
||||
.loc-order { margin: 0; padding-left: 1.2rem; display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.9rem; }
|
||||
|
||||
/* ---------- Slide-over day editor ---------- */
|
||||
.overlay { position: fixed; inset: 0; background: rgba(15, 23, 42, 0.45); z-index: 50; display: flex; justify-content: flex-end; animation: fade 0.15s ease; }
|
||||
@keyframes fade { from { opacity: 0; } to { opacity: 1; } }
|
||||
.slideover { width: min(460px, 100%); height: 100%; background: var(--surface); box-shadow: var(--shadow-lg); padding: 1.3rem; overflow-y: auto; display: flex; flex-direction: column; gap: 1rem; animation: slidein 0.2s ease; }
|
||||
@keyframes slidein { from { transform: translateX(30px); opacity: 0.4; } to { transform: none; opacity: 1; } }
|
||||
.slideover-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 0.6rem; }
|
||||
.icon-btn { border: none; background: var(--surface-2); border-radius: 8px; width: 2rem; height: 2rem; cursor: pointer; font-size: 1rem; display: inline-flex; align-items: center; justify-content: center; color: var(--text-muted); }
|
||||
.icon-btn:hover { background: var(--border); color: var(--text); }
|
||||
.icon-btn.danger:hover { background: var(--danger-soft); color: var(--danger); }
|
||||
|
||||
.entry-list { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.entry-empty { padding: 0.6rem 0; }
|
||||
.entry-row { display: flex; gap: 0.6rem; align-items: flex-start; padding: 0.6rem; border: 1px solid var(--border); border-left: 4px solid var(--chip); border-radius: var(--radius-sm); background: var(--surface-2); }
|
||||
.entry-icon { font-size: 1.1rem; }
|
||||
.entry-body { flex: 1; min-width: 0; }
|
||||
.entry-title { font-weight: 600; }
|
||||
.entry-sub { font-size: 0.8rem; }
|
||||
.entry-details { font-size: 0.84rem; color: var(--text-muted); margin-top: 0.2rem; white-space: pre-wrap; }
|
||||
.entry-actions { display: flex; gap: 0.25rem; }
|
||||
|
||||
.entry-form { display: flex; flex-direction: column; gap: 0.8rem; padding-top: 1rem; border-top: 1px solid var(--border); }
|
||||
.loc-field { position: relative; }
|
||||
.loc-results { display: flex; flex-direction: column; border: 1px solid var(--border); border-radius: var(--radius-sm); margin-top: 0.3rem; overflow: hidden; }
|
||||
.loc-results:empty { display: none; }
|
||||
.loc-result { text-align: left; border: none; background: var(--surface); padding: 0.5rem 0.6rem; cursor: pointer; font-size: 0.85rem; border-bottom: 1px solid var(--border); }
|
||||
.loc-result:last-child { border-bottom: none; }
|
||||
.loc-result:hover { background: var(--brand-soft); }
|
||||
.loc-empty { padding: 0.5rem 0.6rem; font-size: 0.85rem; }
|
||||
.loc-selected:empty { display: none; }
|
||||
.loc-chip { display: inline-flex; align-items: center; gap: 0.4rem; margin-top: 0.4rem; background: var(--brand-soft); color: var(--brand-dark); padding: 0.3rem 0.6rem; border-radius: 999px; font-size: 0.85rem; font-weight: 600; }
|
||||
.loc-clear { border: none; background: none; cursor: pointer; color: var(--brand-dark); font-size: 1rem; line-height: 1; padding: 0; }
|
||||
|
||||
/* ---------- Currency + prices ---------- */
|
||||
.field-currency, .field-price { flex: 0 0 110px; min-width: 90px; }
|
||||
.input-currency { text-transform: uppercase; }
|
||||
.currency-tag { display: inline-block; font-weight: 700; font-size: 0.7rem; letter-spacing: 0.04em; background: var(--surface-2); border: 1px solid var(--border); color: var(--text-muted); padding: 0.05rem 0.4rem; border-radius: 5px; }
|
||||
.chip-price { margin-left: auto; font-size: 0.66rem; font-weight: 700; background: rgba(15,23,42,0.06); color: #334155; padding: 0 0.25rem; border-radius: 4px; white-space: nowrap; }
|
||||
.entry-title-row { display: flex; align-items: baseline; justify-content: space-between; gap: 0.5rem; }
|
||||
.entry-price { font-size: 0.82rem; font-weight: 700; color: var(--brand-dark); white-space: nowrap; }
|
||||
.entry-cost { font-size: 0.78rem; margin-top: 0.15rem; }
|
||||
|
||||
/* ---------- Cost subsection in the day editor ---------- */
|
||||
.cost-heading { font-size: 0.78rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.03em; color: var(--text-muted); margin-top: 0.3rem; }
|
||||
.cost-section { display: flex; flex-direction: column; gap: 0.7rem; }
|
||||
.cost-detail { display: flex; flex-direction: column; gap: 0.7rem; transition: opacity 0.15s; }
|
||||
.cost-detail.disabled { opacity: 0.45; pointer-events: none; }
|
||||
.participants { display: flex; flex-wrap: wrap; gap: 0.4rem; }
|
||||
.part-item { display: inline-flex; align-items: center; gap: 0.35rem; background: var(--surface-2); border: 1px solid var(--border); border-radius: 999px; padding: 0.2rem 0.6rem 0.2rem 0.45rem; font-size: 0.85rem; cursor: pointer; }
|
||||
.part-check { accent-color: var(--brand); cursor: pointer; }
|
||||
|
||||
/* ---------- Costs panel ---------- */
|
||||
.detail-side { display: flex; flex-direction: column; gap: 1.2rem; }
|
||||
.costs-section { display: flex; flex-direction: column; }
|
||||
.costs-empty { text-align: center; padding: 2.4rem 1rem; color: var(--text-muted); display: flex; flex-direction: column; gap: 0.3rem; align-items: center; }
|
||||
.costs-total { margin-bottom: 0.9rem; padding: 0.9rem; text-align: center; background: linear-gradient(135deg, #0f766e, #059669); color: #fff; border-radius: var(--radius-sm); display: flex; flex-direction: column; }
|
||||
.costs-total-value { font-size: 1.7rem; font-weight: 800; }
|
||||
.costs-total-label { font-size: 0.78rem; opacity: 0.9; }
|
||||
.costs-warn { background: #fffbeb; border: 1px solid #fde68a; color: #92400e; font-size: 0.82rem; padding: 0.5rem 0.7rem; border-radius: var(--radius-sm); margin-bottom: 0.9rem; }
|
||||
.cost-block { margin-top: 1rem; }
|
||||
.cost-block h3 { margin-bottom: 0.5rem; }
|
||||
.bytype-row { display: flex; align-items: center; gap: 0.5rem; padding: 0.3rem 0; font-size: 0.88rem; }
|
||||
.bytype-dot { width: 0.7rem; height: 0.7rem; border-radius: 3px; flex-shrink: 0; }
|
||||
.bytype-name { flex: 1; }
|
||||
.bytype-amount { font-weight: 700; }
|
||||
.cost-table { width: 100%; border-collapse: collapse; font-size: 0.86rem; }
|
||||
.cost-table th { text-align: left; font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.03em; color: var(--text-faint); padding: 0.3rem 0.4rem; border-bottom: 1px solid var(--border); }
|
||||
.cost-table td { padding: 0.4rem 0.4rem; border-bottom: 1px solid var(--border); }
|
||||
.cost-table .num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.net-pos { color: var(--success); font-weight: 700; }
|
||||
.net-neg { color: var(--danger); font-weight: 700; }
|
||||
.net-zero { color: var(--text-muted); }
|
||||
.settle-row { display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0; border-bottom: 1px solid var(--border); font-size: 0.88rem; }
|
||||
.settle-from { font-weight: 600; }
|
||||
.settle-arrow { color: var(--brand); font-weight: 700; }
|
||||
.settle-to { font-weight: 600; }
|
||||
.settle-amount { margin-left: auto; font-weight: 700; color: var(--brand-dark); white-space: nowrap; }
|
||||
|
||||
/* ---------- Flight route builder (day editor) ---------- */
|
||||
.flight-section { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.seg-quick { display: flex; gap: 0.5rem; align-items: flex-end; }
|
||||
.seg-quick-input { font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; text-transform: uppercase; letter-spacing: 0.06em; }
|
||||
.seg-list { display: flex; flex-direction: column; gap: 0.6rem; }
|
||||
.seg-row { border: 1px solid var(--border); border-left: 3px solid var(--brand); border-radius: var(--radius-sm); padding: 0.6rem; background: var(--surface-2); display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.seg-row-head { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.seg-num { flex-shrink: 0; width: 1.4rem; height: 1.4rem; display: inline-flex; align-items: center; justify-content: center; background: var(--brand-soft); color: var(--brand-dark); border-radius: 50%; font-weight: 700; font-size: 0.75rem; }
|
||||
.seg-flightno { flex: 1; text-transform: uppercase; }
|
||||
.seg-airports { display: flex; align-items: flex-start; gap: 0.4rem; }
|
||||
.seg-ap { flex: 1; position: relative; display: flex; flex-direction: column; gap: 0.15rem; }
|
||||
.seg-code { text-transform: uppercase; letter-spacing: 0.05em; font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; }
|
||||
.seg-ap.ap-unresolved .seg-code { border-color: var(--danger); box-shadow: 0 0 0 2px var(--danger-soft); }
|
||||
.seg-arrow { color: var(--brand); font-weight: 700; align-self: center; padding-top: 0.3rem; }
|
||||
.ap-name { font-size: 0.72rem; color: var(--text-muted); min-height: 0.9rem; padding-left: 0.1rem; }
|
||||
.ap-results { position: absolute; top: 100%; left: 0; right: 0; z-index: 5; background: var(--surface); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); box-shadow: var(--shadow-md); overflow: hidden; max-height: 220px; overflow-y: auto; }
|
||||
.ap-results:empty { display: none; }
|
||||
.ap-result { display: flex; gap: 0.5rem; align-items: baseline; width: 100%; text-align: left; border: none; background: var(--surface); padding: 0.45rem 0.6rem; cursor: pointer; border-bottom: 1px solid var(--border); font-size: 0.82rem; }
|
||||
.ap-result:last-child { border-bottom: none; }
|
||||
.ap-result:hover { background: var(--brand-soft); }
|
||||
.ap-code { font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-weight: 700; color: var(--brand-dark); flex-shrink: 0; }
|
||||
.ap-desc { color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ap-empty { padding: 0.5rem 0.6rem; font-size: 0.82rem; }
|
||||
.seg-times { display: flex; gap: 0.6rem; }
|
||||
.seg-time { display: flex; align-items: center; gap: 0.35rem; font-size: 0.78rem; color: var(--text-muted); }
|
||||
.seg-time input { width: auto; }
|
||||
|
||||
/* Flight segments on the day-editor entry rows */
|
||||
.entry-segments { display: flex; flex-direction: column; gap: 0.1rem; margin-top: 0.25rem; }
|
||||
.entry-seg { display: flex; gap: 0.5rem; font-size: 0.8rem; font-variant-numeric: tabular-nums; }
|
||||
.seg-flight { font-weight: 700; color: var(--brand-dark); }
|
||||
|
||||
.stat-sub { font-size: 0.66rem; color: var(--brand-dark); font-weight: 700; }
|
||||
|
||||
/* ---------- Rental details (day editor) ---------- */
|
||||
.rental-section { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.rental-details { display: flex; flex-direction: column; gap: 0.7rem; }
|
||||
.rental-blocks { display: flex; gap: 0.8rem; flex-wrap: wrap; }
|
||||
.rental-block { flex: 1; min-width: 200px; border: 1px solid var(--border); border-left: 3px solid #0891b2; border-radius: var(--radius-sm); padding: 0.6rem; background: var(--surface-2); display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.rental-block-title { font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.03em; color: var(--text-muted); }
|
||||
.entry-rental { font-size: 0.8rem; margin-top: 0.2rem; }
|
||||
|
||||
/* Secondary "dropoff" calendar chip */
|
||||
.cal-chip-dropoff { background: transparent; border: 1px dashed var(--chip); border-left: 3px dashed var(--chip); color: var(--text-muted); cursor: pointer; font-style: italic; }
|
||||
.cal-chip-dropoff:hover { background: color-mix(in srgb, var(--chip) 10%, white); }
|
||||
|
||||
/* Over-budget driven distance */
|
||||
.km-row.km-over .km-row-value { color: var(--danger); }
|
||||
.km-row.km-over .km-row-icon { filter: grayscale(0.2); }
|
||||
|
||||
/* ---------- Toasts ---------- */
|
||||
.toast-host { position: fixed; bottom: 1.2rem; right: 1.2rem; z-index: 100; display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.toast { padding: 0.7rem 1rem; border-radius: var(--radius-sm); color: #fff; font-size: 0.88rem; font-weight: 600; box-shadow: var(--shadow-md); opacity: 0; transform: translateY(10px); transition: opacity 0.25s, transform 0.25s; max-width: 320px; }
|
||||
.toast.show { opacity: 1; transform: none; }
|
||||
.toast-error { background: var(--danger); }
|
||||
.toast-success { background: var(--success); }
|
||||
|
||||
/* ---------- Responsive ---------- */
|
||||
@media (max-width: 1024px) {
|
||||
.detail-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.page { padding: 1rem; }
|
||||
.cal-day { min-height: 76px; }
|
||||
.cal-chip .chip-text { display: none; }
|
||||
.cal-chip { justify-content: center; }
|
||||
.stat-grid { grid-template-columns: repeat(3, 1fr); }
|
||||
.invite-form { margin-left: 0; width: 100%; }
|
||||
.invite-form .input { flex: 1; width: auto; }
|
||||
.trip-header-actions { width: 100%; }
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.calendar-grid { gap: 3px; }
|
||||
.cal-weekday { font-size: 0.6rem; }
|
||||
.cal-day { min-height: 60px; padding: 0.2rem; }
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="light" />
|
||||
<title>Trip Plan</title>
|
||||
|
||||
<!-- Leaflet (map) from unpkg CDN. Classic scripts run before the deferred
|
||||
ES module below, so window.L is ready by the time app.js executes. -->
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
||||
crossorigin=""
|
||||
/>
|
||||
<script
|
||||
src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
|
||||
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
|
||||
crossorigin=""
|
||||
></script>
|
||||
|
||||
<link rel="stylesheet" href="./css/styles.css" />
|
||||
<link rel="stylesheet" href="./css/flipclock.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="./js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,83 @@
|
||||
// Thin fetch wrapper for the Trip Plan REST API (see docs/API.md).
|
||||
// Same-origin, cookies included. Server errors ({error}) become thrown Errors
|
||||
// whose message is the server's human-readable text and `.status` the code.
|
||||
|
||||
async function request(method, path, body) {
|
||||
const opts = {
|
||||
method,
|
||||
credentials: 'same-origin',
|
||||
headers: {},
|
||||
};
|
||||
if (body !== undefined) {
|
||||
opts.headers['Content-Type'] = 'application/json';
|
||||
opts.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(path, opts);
|
||||
} catch (networkErr) {
|
||||
const err = new Error('Network error — is the server running?');
|
||||
err.cause = networkErr;
|
||||
err.status = 0;
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (res.status === 204) return null;
|
||||
|
||||
const text = await res.text();
|
||||
let data = null;
|
||||
if (text) {
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch {
|
||||
data = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const message = (data && data.error) || `Request failed (${res.status})`;
|
||||
const err = new Error(message);
|
||||
err.status = res.status;
|
||||
err.body = data;
|
||||
throw err;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
const get = (p) => request('GET', p);
|
||||
const post = (p, b) => request('POST', p, b);
|
||||
const patch = (p, b) => request('PATCH', p, b);
|
||||
const del = (p) => request('DELETE', p);
|
||||
|
||||
export const api = {
|
||||
auth: {
|
||||
// Mullvad-style: create an account (server returns the one-time token).
|
||||
createAccount: () => post('/api/auth/account', {}),
|
||||
login: (token) => post('/api/auth/login', { token }),
|
||||
logout: () => post('/api/auth/logout'),
|
||||
me: () => get('/api/auth/me'),
|
||||
updateMe: (patchBody) => patch('/api/auth/me', patchBody),
|
||||
},
|
||||
trips: {
|
||||
list: () => get('/api/trips'),
|
||||
create: (payload) => post('/api/trips', payload),
|
||||
get: (id) => get(`/api/trips/${id}`),
|
||||
update: (id, patchBody) => patch(`/api/trips/${id}`, patchBody),
|
||||
remove: (id) => del(`/api/trips/${id}`),
|
||||
join: (code) => post('/api/trips/join', { code }),
|
||||
regenerateJoinCode: (id) => post(`/api/trips/${id}/join-code`, {}),
|
||||
removeMember: (id, userId) => del(`/api/trips/${id}/members/${userId}`),
|
||||
route: (id) => get(`/api/trips/${id}/route`),
|
||||
costs: (id) => get(`/api/trips/${id}/costs`),
|
||||
},
|
||||
entries: {
|
||||
create: (tripId, payload) => post(`/api/trips/${tripId}/entries`, payload),
|
||||
update: (id, patchBody) => patch(`/api/entries/${id}`, patchBody),
|
||||
remove: (id) => del(`/api/entries/${id}`),
|
||||
},
|
||||
geocode: (q) => get(`/api/geocode?q=${encodeURIComponent(q)}`),
|
||||
airports: (q) => get(`/api/airports?q=${encodeURIComponent(q)}`),
|
||||
};
|
||||
|
||||
export default api;
|
||||
@@ -0,0 +1,171 @@
|
||||
// App bootstrap: auth check, top nav, and a tiny hash router.
|
||||
// Routes: #/login, #/trips, #/trip/:id
|
||||
import { api } from './api.js';
|
||||
import { el, clear, mount, toast, loading } from './dom.js';
|
||||
import { renderAuth } from './views/auth.js';
|
||||
import { renderTrips } from './views/trips.js';
|
||||
import { renderTripDetail } from './views/tripDetail.js';
|
||||
|
||||
const state = { user: null };
|
||||
|
||||
function root() {
|
||||
return document.getElementById('app');
|
||||
}
|
||||
|
||||
function navigate(hash) {
|
||||
if (location.hash === hash) route();
|
||||
else location.hash = hash;
|
||||
}
|
||||
|
||||
// Parse "#/trip/5" -> { name: 'trip', params: { id: '5' } }
|
||||
function parseHash() {
|
||||
const raw = (location.hash || '').replace(/^#/, '');
|
||||
const parts = raw.split('/').filter(Boolean);
|
||||
if (parts.length === 0) return { name: 'trips', params: {} };
|
||||
if (parts[0] === 'login') return { name: 'login', params: {} };
|
||||
if (parts[0] === 'trips') return { name: 'trips', params: {} };
|
||||
if (parts[0] === 'trip' && parts[1]) return { name: 'trip', params: { id: parts[1] } };
|
||||
return { name: 'trips', params: {} };
|
||||
}
|
||||
|
||||
function renderNav() {
|
||||
const nav = el(
|
||||
'header',
|
||||
{ class: 'topnav' },
|
||||
el(
|
||||
'a',
|
||||
{ class: 'brand', href: '#/trips' },
|
||||
el('span', { class: 'brand-mark' }, '🧭'),
|
||||
el('span', { class: 'brand-name' }, 'Trip Plan'),
|
||||
),
|
||||
el(
|
||||
'div',
|
||||
{ class: 'nav-right' },
|
||||
state.user ? renderUserArea() : null,
|
||||
state.user ? el('button', { class: 'btn btn-ghost', onClick: onLogout }, 'Log out') : null,
|
||||
),
|
||||
);
|
||||
return nav;
|
||||
}
|
||||
|
||||
// Current user's display name with an inline pencil-edit (PATCH /api/auth/me).
|
||||
function renderUserArea() {
|
||||
const area = el('span', { class: 'nav-user' });
|
||||
|
||||
function initial() {
|
||||
return (state.user.display_name || '?').charAt(0).toUpperCase();
|
||||
}
|
||||
|
||||
function showDisplay() {
|
||||
mount(
|
||||
area,
|
||||
el('span', { class: 'nav-avatar' }, initial()),
|
||||
el('span', { class: 'nav-name' }, state.user.display_name || 'me'),
|
||||
el('button', { class: 'nav-edit', title: 'Edit name', type: 'button', onClick: showEdit }, '✎'),
|
||||
);
|
||||
}
|
||||
|
||||
function showEdit() {
|
||||
const input = el('input', {
|
||||
class: 'input input-sm nav-name-input',
|
||||
type: 'text',
|
||||
maxlength: '40',
|
||||
value: state.user.display_name || '',
|
||||
'aria-label': 'Display name',
|
||||
});
|
||||
async function save() {
|
||||
const dn = input.value.trim();
|
||||
if (!dn) return showDisplay();
|
||||
try {
|
||||
const data = await api.auth.updateMe({ display_name: dn });
|
||||
state.user = data.user;
|
||||
showDisplay();
|
||||
} catch (err) {
|
||||
toast(err.message);
|
||||
}
|
||||
}
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); save(); }
|
||||
else if (e.key === 'Escape') showDisplay();
|
||||
});
|
||||
mount(
|
||||
area,
|
||||
input,
|
||||
el('button', { class: 'btn btn-sm btn-primary', type: 'button', onClick: save }, 'Save'),
|
||||
el('button', { class: 'btn btn-sm btn-ghost', type: 'button', onClick: showDisplay }, 'Cancel'),
|
||||
);
|
||||
input.focus();
|
||||
input.select();
|
||||
}
|
||||
|
||||
showDisplay();
|
||||
return area;
|
||||
}
|
||||
|
||||
async function onLogout() {
|
||||
try {
|
||||
await api.auth.logout();
|
||||
} catch (e) {
|
||||
// Even if the request fails, drop local state.
|
||||
}
|
||||
state.user = null;
|
||||
navigate('#/login');
|
||||
}
|
||||
|
||||
function render() {
|
||||
const view = parseHash();
|
||||
|
||||
// Defensively remove any body-level overlay (e.g. an open day editor) so it
|
||||
// can never orphan on top of a freshly rendered view.
|
||||
document.querySelectorAll('.overlay').forEach((n) => n.remove());
|
||||
document.body.classList.remove('no-scroll');
|
||||
|
||||
// Auth guards.
|
||||
if (!state.user && view.name !== 'login') {
|
||||
navigate('#/login');
|
||||
return;
|
||||
}
|
||||
if (state.user && view.name === 'login') {
|
||||
navigate('#/trips');
|
||||
return;
|
||||
}
|
||||
|
||||
const container = root();
|
||||
clear(container);
|
||||
if (state.user) container.appendChild(renderNav());
|
||||
|
||||
const viewEl = el('main', { class: 'view', id: 'view' });
|
||||
container.appendChild(viewEl);
|
||||
|
||||
const ctx = { state, navigate, refresh: render };
|
||||
|
||||
if (view.name === 'login') renderAuth(viewEl, ctx);
|
||||
else if (view.name === 'trips') renderTrips(viewEl, ctx);
|
||||
else if (view.name === 'trip') renderTripDetail(viewEl, ctx, view.params.id);
|
||||
}
|
||||
|
||||
// Exposed so views can update the current user after login/register.
|
||||
function route() {
|
||||
render();
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
const container = root();
|
||||
clear(container);
|
||||
container.appendChild(loading('Starting Trip Plan…'));
|
||||
|
||||
try {
|
||||
const data = await api.auth.me();
|
||||
state.user = data && data.user ? data.user : null;
|
||||
} catch (e) {
|
||||
state.user = null; // 401 is expected when logged out.
|
||||
}
|
||||
|
||||
window.addEventListener('hashchange', route);
|
||||
|
||||
// render()'s guards redirect a signed-out visitor to #/login and a
|
||||
// signed-in one away from it, so a single render() is enough here.
|
||||
render();
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
@@ -0,0 +1,91 @@
|
||||
// Tiny DOM helpers — no framework, just ergonomic element creation.
|
||||
|
||||
// el('div', { class: 'x', onClick: fn }, child, child, ...)
|
||||
// Attrs: class, dataset(obj), style(obj), html(innerHTML), on<Event>(fn),
|
||||
// boolean true -> present attribute, anything else -> setAttribute.
|
||||
export function el(tag, attrs = {}, ...children) {
|
||||
const node = document.createElement(tag);
|
||||
for (const [key, val] of Object.entries(attrs || {})) {
|
||||
if (val == null || val === false) continue;
|
||||
if (key === 'class') node.className = val;
|
||||
else if (key === 'dataset') Object.assign(node.dataset, val);
|
||||
else if (key === 'style' && typeof val === 'object') {
|
||||
// Custom properties (--x) must go through setProperty; plain assignment
|
||||
// silently drops them.
|
||||
for (const [prop, pv] of Object.entries(val)) {
|
||||
if (pv == null) continue;
|
||||
if (prop.startsWith('--')) node.style.setProperty(prop, pv);
|
||||
else node.style[prop] = pv;
|
||||
}
|
||||
}
|
||||
else if (key === 'html') node.innerHTML = val;
|
||||
else if (key === 'value') node.value = val;
|
||||
else if (key.startsWith('on') && typeof val === 'function')
|
||||
node.addEventListener(key.slice(2).toLowerCase(), val);
|
||||
else if (val === true) node.setAttribute(key, '');
|
||||
else node.setAttribute(key, val);
|
||||
}
|
||||
appendAll(node, children);
|
||||
return node;
|
||||
}
|
||||
|
||||
function appendAll(node, children) {
|
||||
for (const child of children.flat(Infinity)) {
|
||||
if (child == null || child === false || child === true) continue;
|
||||
node.appendChild(
|
||||
typeof child === 'string' || typeof child === 'number'
|
||||
? document.createTextNode(String(child))
|
||||
: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function clear(node) {
|
||||
while (node.firstChild) node.removeChild(node.firstChild);
|
||||
return node;
|
||||
}
|
||||
|
||||
export function mount(container, ...children) {
|
||||
clear(container);
|
||||
appendAll(container, children);
|
||||
return container;
|
||||
}
|
||||
|
||||
export function loading(text = 'Loading…') {
|
||||
return el('div', { class: 'loading' }, el('span', { class: 'spinner' }), text);
|
||||
}
|
||||
|
||||
export function errorBox(message, onRetry) {
|
||||
return el(
|
||||
'div',
|
||||
{ class: 'error-box' },
|
||||
el('p', {}, message || 'Something went wrong.'),
|
||||
onRetry ? el('button', { class: 'btn', onClick: onRetry }, 'Retry') : null,
|
||||
);
|
||||
}
|
||||
|
||||
export function emptyState(title, subtitle, action) {
|
||||
return el(
|
||||
'div',
|
||||
{ class: 'empty-state' },
|
||||
el('div', { class: 'empty-icon' }, '🧭'),
|
||||
el('h3', {}, title),
|
||||
subtitle ? el('p', {}, subtitle) : null,
|
||||
action || null,
|
||||
);
|
||||
}
|
||||
|
||||
let toastHost = null;
|
||||
export function toast(message, type = 'error', ms = 4200) {
|
||||
if (!toastHost) {
|
||||
toastHost = el('div', { class: 'toast-host' });
|
||||
document.body.appendChild(toastHost);
|
||||
}
|
||||
const node = el('div', { class: `toast toast-${type}` }, message);
|
||||
toastHost.appendChild(node);
|
||||
requestAnimationFrame(() => node.classList.add('show'));
|
||||
setTimeout(() => {
|
||||
node.classList.remove('show');
|
||||
setTimeout(() => node.remove(), 300);
|
||||
}, ms);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
// Shared formatting helpers and the canonical entry-type palette.
|
||||
// The type config here is the single source of truth for icon + color,
|
||||
// reused by the calendar chips, map markers/popups, and the day editor.
|
||||
|
||||
// Order here drives the day-editor type picker and the calendar legend, so it
|
||||
// is roughly most-common-first with Activity as the default for new entries.
|
||||
export const ENTRY_TYPES = {
|
||||
activity: { label: 'Activity', icon: '📍', color: '#059669' },
|
||||
hotel: { label: 'Hotel', icon: '🏨', color: '#db2777' },
|
||||
travel: { label: 'Travel', icon: '🚗', color: '#d97706' },
|
||||
flight: { label: 'Flight', icon: '✈️', color: '#2563eb' },
|
||||
rental: { label: 'Rental car', icon: '🚙', color: '#0891b2' },
|
||||
immigration: { label: 'Immigration', icon: '🛂', color: '#7c3aed' },
|
||||
note: { label: 'Note', icon: '📝', color: '#64748b' },
|
||||
};
|
||||
|
||||
export const ENTRY_TYPE_LIST = Object.entries(ENTRY_TYPES).map(([value, meta]) => ({
|
||||
value,
|
||||
...meta,
|
||||
}));
|
||||
|
||||
export function typeInfo(type) {
|
||||
return ENTRY_TYPES[type] || { label: type || 'Entry', icon: '•', color: '#64748b' };
|
||||
}
|
||||
|
||||
// Split modes with the human labels the day-editor select shows.
|
||||
export const SPLIT_MODES = [
|
||||
{ value: 'equal', label: 'Split equally' },
|
||||
{ value: 'own', label: 'Everyone pays their own (price per person)' },
|
||||
{ value: 'payer', label: "Payer's own expense" },
|
||||
];
|
||||
|
||||
export function splitModeLabel(mode) {
|
||||
const found = SPLIT_MODES.find((m) => m.value === mode);
|
||||
return found ? found.label : mode;
|
||||
}
|
||||
|
||||
// Account tokens / join codes: strip separators + uppercase, or regroup for
|
||||
// display. Works whether the server sends the value raw or already grouped.
|
||||
export function normalizeCode(str) {
|
||||
return String(str || '').replace(/[^A-Za-z0-9]/g, '').toUpperCase();
|
||||
}
|
||||
|
||||
export function groupCode(str, size = 4) {
|
||||
const raw = normalizeCode(str);
|
||||
const groups = raw.match(new RegExp(`.{1,${size}}`, 'g'));
|
||||
return groups ? groups.join('-') : raw;
|
||||
}
|
||||
|
||||
// Money as "1,234.56 USD". `compact` drops trailing zeros ("1,200 THB").
|
||||
export function formatMoney(amount, currency = 'USD', { compact = false } = {}) {
|
||||
const n = Number(amount) || 0;
|
||||
const s = n.toLocaleString(undefined, compact
|
||||
? { maximumFractionDigits: 2 }
|
||||
: { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
return `${s} ${currency}`;
|
||||
}
|
||||
|
||||
// Parse a YYYY-MM-DD string as a *local* date (avoid UTC off-by-one).
|
||||
export function parseYMD(str) {
|
||||
const [y, m, d] = String(str).split('-').map(Number);
|
||||
return new Date(y, (m || 1) - 1, d || 1);
|
||||
}
|
||||
|
||||
export function ymd(date) {
|
||||
const y = date.getFullYear();
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(date.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
export function addDays(date, n) {
|
||||
const d = new Date(date);
|
||||
d.setDate(d.getDate() + n);
|
||||
return d;
|
||||
}
|
||||
|
||||
export function daysBetweenInclusive(startStr, endStr) {
|
||||
const a = parseYMD(startStr);
|
||||
const b = parseYMD(endStr);
|
||||
return Math.round((b - a) / 86400000) + 1;
|
||||
}
|
||||
|
||||
export function eachDay(startStr, endStr) {
|
||||
const out = [];
|
||||
let d = parseYMD(startStr);
|
||||
const end = parseYMD(endStr);
|
||||
while (d <= end) {
|
||||
out.push(ymd(d));
|
||||
d = addDays(d, 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Monday-based start of the week containing `date`.
|
||||
export function startOfWeekMon(date) {
|
||||
const d = new Date(date);
|
||||
const offset = (d.getDay() + 6) % 7; // 0 = Monday
|
||||
return addDays(d, -offset);
|
||||
}
|
||||
|
||||
export function formatDate(str, opts = { month: 'short', day: 'numeric' }) {
|
||||
return parseYMD(str).toLocaleDateString(undefined, opts);
|
||||
}
|
||||
|
||||
export function formatFullDate(str) {
|
||||
return parseYMD(str).toLocaleDateString(undefined, {
|
||||
weekday: 'long',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
export function formatRange(startStr, endStr) {
|
||||
const s = parseYMD(startStr);
|
||||
const e = parseYMD(endStr);
|
||||
const sameYear = s.getFullYear() === e.getFullYear();
|
||||
const sOpts = sameYear
|
||||
? { month: 'short', day: 'numeric' }
|
||||
: { month: 'short', day: 'numeric', year: 'numeric' };
|
||||
const eOpts = { month: 'short', day: 'numeric', year: 'numeric' };
|
||||
return `${s.toLocaleDateString(undefined, sOpts)} – ${e.toLocaleDateString(undefined, eOpts)}`;
|
||||
}
|
||||
|
||||
export function formatTimeRange(start, end) {
|
||||
if (start && end) return `${start}–${end}`;
|
||||
return start || end || '';
|
||||
}
|
||||
|
||||
export function pluralize(n, one, many) {
|
||||
return `${n} ${n === 1 ? one : many || one + 's'}`;
|
||||
}
|
||||
|
||||
// "CNX→BKK→DXB→FRA" from a flight entry's segments array.
|
||||
export function flightChain(segments) {
|
||||
if (!Array.isArray(segments) || segments.length === 0) return '';
|
||||
const codes = [segments[0]?.from?.code, ...segments.map((s) => s?.to?.code)].filter(Boolean);
|
||||
return codes.join('→');
|
||||
}
|
||||
|
||||
export function hasSegments(entry) {
|
||||
return entry && Array.isArray(entry.segments) && entry.segments.length > 0;
|
||||
}
|
||||
|
||||
export function hasRental(entry) {
|
||||
return entry && entry.rental && typeof entry.rental === 'object';
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
// Login view — Mullvad-style account tokens (no username/password).
|
||||
// Two panels: create a new account (one-time token reveal) or log in with an
|
||||
// existing account number.
|
||||
import { api } from '../api.js';
|
||||
import { el, mount, toast } from '../dom.js';
|
||||
import { groupCode, normalizeCode } from '../format.js';
|
||||
|
||||
export function renderAuth(container, ctx) {
|
||||
let mode = 'create'; // 'create' | 'login'
|
||||
|
||||
function draw() {
|
||||
const card = el(
|
||||
'div',
|
||||
{ class: 'auth-card card' },
|
||||
el(
|
||||
'div',
|
||||
{ class: 'auth-head' },
|
||||
el('div', { class: 'brand-mark brand-mark-lg' }, '🧭'),
|
||||
el('h1', {}, 'Trip Plan'),
|
||||
el('p', { class: 'muted' }, 'Plan trips together, day by day.'),
|
||||
),
|
||||
el(
|
||||
'div',
|
||||
{ class: 'auth-tabs' },
|
||||
tab('Create account', mode === 'create', () => setMode('create')),
|
||||
tab('Log in', mode === 'login', () => setMode('login')),
|
||||
),
|
||||
mode === 'create' ? createPanel() : loginPanel(),
|
||||
);
|
||||
mount(container, el('div', { class: 'auth-wrap' }, card));
|
||||
}
|
||||
|
||||
function setMode(next) {
|
||||
if (mode !== next) {
|
||||
mode = next;
|
||||
draw();
|
||||
}
|
||||
}
|
||||
|
||||
function tab(label, active, onClick) {
|
||||
return el('button', { class: `auth-tab${active ? ' active' : ''}`, type: 'button', onClick }, label);
|
||||
}
|
||||
|
||||
// ----- Create account -----
|
||||
function createPanel() {
|
||||
const errorEl = el('p', { class: 'form-error' });
|
||||
const createBtn = el('button', { class: 'btn btn-primary btn-block', type: 'button' }, 'Create account');
|
||||
|
||||
async function onCreate() {
|
||||
errorEl.textContent = '';
|
||||
createBtn.disabled = true;
|
||||
createBtn.textContent = 'Creating…';
|
||||
try {
|
||||
const data = await api.auth.createAccount();
|
||||
showToken(data.token, data.user);
|
||||
} catch (err) {
|
||||
errorEl.textContent = err.message;
|
||||
createBtn.disabled = false;
|
||||
createBtn.textContent = 'Create account';
|
||||
}
|
||||
}
|
||||
createBtn.addEventListener('click', onCreate);
|
||||
|
||||
return el(
|
||||
'div',
|
||||
{ class: 'auth-panel' },
|
||||
el('p', { class: 'auth-lead' }, 'No email, no password. We generate a private account number — it is your only key to your trips.'),
|
||||
errorEl,
|
||||
createBtn,
|
||||
);
|
||||
}
|
||||
|
||||
function showToken(token, user) {
|
||||
const grouped = groupCode(token, 4);
|
||||
const copyBtn = el('button', { class: 'btn', type: 'button' }, '📋 Copy');
|
||||
copyBtn.addEventListener('click', async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(grouped);
|
||||
toast('Account number copied', 'success');
|
||||
} catch {
|
||||
toast('Copy failed — select and copy it manually');
|
||||
}
|
||||
});
|
||||
|
||||
const panel = el(
|
||||
'div',
|
||||
{ class: 'auth-panel token-reveal' },
|
||||
el('div', { class: 'token-warning' },
|
||||
el('strong', {}, 'This is your only credential.'),
|
||||
' Save it now — it will never be shown again. Anyone with it can access your trips.'),
|
||||
el('div', { class: 'token-box' }, grouped),
|
||||
el('div', { class: 'token-actions' }, copyBtn),
|
||||
el(
|
||||
'button',
|
||||
{
|
||||
class: 'btn btn-primary btn-block',
|
||||
type: 'button',
|
||||
onClick: () => {
|
||||
ctx.state.user = user;
|
||||
ctx.navigate('#/trips');
|
||||
},
|
||||
},
|
||||
"I've saved it — continue",
|
||||
),
|
||||
);
|
||||
|
||||
mount(container, el('div', { class: 'auth-wrap' }, el('div', { class: 'auth-card card' },
|
||||
el('div', { class: 'auth-head' },
|
||||
el('div', { class: 'brand-mark brand-mark-lg' }, '🎉'),
|
||||
el('h1', {}, 'Account created'),
|
||||
el('p', { class: 'muted' }, `You're ${user.display_name}. You can rename yourself later.`)),
|
||||
panel,
|
||||
)));
|
||||
}
|
||||
|
||||
// ----- Log in -----
|
||||
function loginPanel() {
|
||||
const errorEl = el('p', { class: 'form-error' });
|
||||
const input = el('input', {
|
||||
class: 'input token-input',
|
||||
type: 'text',
|
||||
autocomplete: 'off',
|
||||
autocapitalize: 'characters',
|
||||
spellcheck: 'false',
|
||||
placeholder: 'XXXX-XXXX-XXXX-XXXX',
|
||||
'aria-label': 'Account number',
|
||||
});
|
||||
const submitBtn = el('button', { class: 'btn btn-primary btn-block', type: 'submit' }, 'Log in');
|
||||
|
||||
async function onSubmit(e) {
|
||||
e.preventDefault();
|
||||
errorEl.textContent = '';
|
||||
const token = normalizeCode(input.value);
|
||||
if (token.length < 8) {
|
||||
errorEl.textContent = 'Enter your full account number.';
|
||||
return;
|
||||
}
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Signing in…';
|
||||
try {
|
||||
const data = await api.auth.login(token);
|
||||
ctx.state.user = data.user;
|
||||
ctx.navigate('#/trips');
|
||||
} catch (err) {
|
||||
errorEl.textContent = err.message;
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Log in';
|
||||
}
|
||||
}
|
||||
|
||||
return el(
|
||||
'form',
|
||||
{ class: 'auth-panel auth-form', onSubmit },
|
||||
el('label', { class: 'field' }, el('span', { class: 'field-label' }, 'Account number'), input),
|
||||
el('p', { class: 'hint muted' }, 'Dashes, spaces and letter case do not matter.'),
|
||||
errorEl,
|
||||
submitBtn,
|
||||
);
|
||||
}
|
||||
|
||||
draw();
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
// Calendar grid for the trip's date range. Real weeks as rows (Mon–Sun
|
||||
// columns); days outside the range are greyed. Each in-range day shows its
|
||||
// entries as compact, type-coloured chips. Clicking a day opens the editor.
|
||||
import { el } from '../dom.js';
|
||||
import {
|
||||
ENTRY_TYPES,
|
||||
typeInfo,
|
||||
parseYMD,
|
||||
ymd,
|
||||
addDays,
|
||||
startOfWeekMon,
|
||||
formatMoney,
|
||||
flightChain,
|
||||
hasSegments,
|
||||
hasRental,
|
||||
} from '../format.js';
|
||||
|
||||
const WEEKDAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
|
||||
export function renderCalendar(tctx) {
|
||||
const { trip, entries } = tctx.trip;
|
||||
const currency = trip.currency || 'USD';
|
||||
|
||||
// Group entries by date for quick per-cell lookup.
|
||||
const byDate = new Map();
|
||||
// Derived "dropoff" chips: a rental whose dropoff day differs from its
|
||||
// (pickup) entry date gets a secondary chip on the dropoff day.
|
||||
const dropoffByDate = new Map();
|
||||
for (const entry of entries) {
|
||||
if (!byDate.has(entry.date)) byDate.set(entry.date, []);
|
||||
byDate.get(entry.date).push(entry);
|
||||
if (hasRental(entry)) {
|
||||
const dropDate = entry.rental.dropoff && entry.rental.dropoff.date;
|
||||
if (dropDate && dropDate !== entry.date) {
|
||||
if (!dropoffByDate.has(dropDate)) dropoffByDate.set(dropDate, []);
|
||||
dropoffByDate.get(dropDate).push(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rangeStart = parseYMD(trip.start_date);
|
||||
const rangeEnd = parseYMD(trip.end_date);
|
||||
const gridStart = startOfWeekMon(rangeStart);
|
||||
|
||||
const section = el(
|
||||
'section',
|
||||
{ class: 'card calendar-section' },
|
||||
el(
|
||||
'div',
|
||||
{ class: 'section-head' },
|
||||
el('h2', {}, 'Calendar'),
|
||||
el('p', { class: 'muted' }, 'Click a day to add or edit entries.'),
|
||||
),
|
||||
legend(),
|
||||
);
|
||||
|
||||
const grid = el('div', { class: 'calendar-grid' });
|
||||
for (const label of WEEKDAYS) {
|
||||
grid.appendChild(el('div', { class: 'cal-weekday' }, label));
|
||||
}
|
||||
|
||||
// Walk whole weeks from gridStart until we've passed the range end.
|
||||
let cursor = gridStart;
|
||||
let guard = 0;
|
||||
while (cursor <= rangeEnd && guard < 400) {
|
||||
for (let i = 0; i < 7; i++) {
|
||||
grid.appendChild(dayCell(cursor, rangeStart, rangeEnd, byDate, dropoffByDate, tctx, currency));
|
||||
cursor = addDays(cursor, 1);
|
||||
}
|
||||
guard += 7;
|
||||
}
|
||||
|
||||
section.appendChild(grid);
|
||||
return section;
|
||||
}
|
||||
|
||||
function dayCell(date, rangeStart, rangeEnd, byDate, dropoffByDate, tctx, currency) {
|
||||
const key = ymd(date);
|
||||
const inRange = date >= rangeStart && date <= rangeEnd;
|
||||
const dayEntries = byDate.get(key) || [];
|
||||
const dropoffs = dropoffByDate.get(key) || [];
|
||||
const isFirstOfMonth = date.getDate() === 1;
|
||||
|
||||
const cell = el('div', {
|
||||
class: `cal-day${inRange ? '' : ' cal-out'}${dayEntries.length || dropoffs.length ? ' cal-has' : ''}`,
|
||||
});
|
||||
|
||||
cell.appendChild(
|
||||
el(
|
||||
'div',
|
||||
{ class: 'cal-day-head' },
|
||||
el('span', { class: 'cal-daynum' }, String(date.getDate())),
|
||||
isFirstOfMonth
|
||||
? el('span', { class: 'cal-month' }, date.toLocaleDateString(undefined, { month: 'short' }))
|
||||
: null,
|
||||
!inRange && (dayEntries.length || dropoffs.length)
|
||||
? el('span', { class: 'cal-flag', title: 'Outside the trip date range' }, '⚠')
|
||||
: null,
|
||||
),
|
||||
);
|
||||
|
||||
const chips = el('div', { class: 'cal-chips' });
|
||||
for (const entry of dayEntries) chips.appendChild(chip(entry, currency));
|
||||
// Secondary dropoff chips: clicking opens the pickup day where the entry lives.
|
||||
for (const entry of dropoffs) chips.appendChild(dropoffChip(entry, tctx));
|
||||
cell.appendChild(chips);
|
||||
|
||||
// In-range days are always clickable; out-of-range days only when they
|
||||
// hold entries or a derived dropoff chip.
|
||||
if (inRange || dayEntries.length || dropoffs.length) {
|
||||
cell.classList.add('clickable');
|
||||
cell.tabIndex = 0;
|
||||
cell.setAttribute('role', 'button');
|
||||
cell.addEventListener('click', () => tctx.openDay(key));
|
||||
cell.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
tctx.openDay(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return cell;
|
||||
}
|
||||
|
||||
function chip(entry, currency) {
|
||||
const info = typeInfo(entry.type);
|
||||
const hasPrice = entry.price != null;
|
||||
const chain = hasSegments(entry) ? flightChain(entry.segments) : '';
|
||||
const car = hasRental(entry) ? [entry.rental.brand, entry.rental.model].filter(Boolean).join(' ') : '';
|
||||
const label = chain || car || entry.title;
|
||||
return el(
|
||||
'div',
|
||||
{
|
||||
class: 'cal-chip',
|
||||
style: { '--chip': info.color },
|
||||
title: `${info.label}: ${chain ? `${entry.title} (${chain})` : entry.title}${hasPrice ? ` · ${formatMoney(entry.price, currency, { compact: true })}` : ''}`,
|
||||
},
|
||||
el('span', { class: 'chip-icon' }, info.icon),
|
||||
el('span', { class: 'chip-text' }, label),
|
||||
hasPrice
|
||||
? el('span', { class: 'chip-price' }, formatMoney(entry.price, currency, { compact: true }))
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
// Secondary, outlined chip shown on a rental's dropoff day. Clicking opens the
|
||||
// PICKUP day's editor (the day the entry actually lives on).
|
||||
function dropoffChip(entry, tctx) {
|
||||
const info = typeInfo(entry.type);
|
||||
const car = [entry.rental.brand, entry.rental.model].filter(Boolean).join(' ');
|
||||
const node = el(
|
||||
'div',
|
||||
{
|
||||
class: 'cal-chip cal-chip-dropoff',
|
||||
style: { '--chip': info.color },
|
||||
role: 'button',
|
||||
tabindex: '0',
|
||||
title: `Rental dropoff${car ? `: ${car}` : ''} — opens the pickup day`,
|
||||
},
|
||||
el('span', { class: 'chip-icon' }, info.icon),
|
||||
el('span', { class: 'chip-text' }, 'dropoff'),
|
||||
);
|
||||
const open = (e) => { e.stopPropagation(); tctx.openDay(entry.date); };
|
||||
node.addEventListener('click', open);
|
||||
node.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open(e); }
|
||||
});
|
||||
return node;
|
||||
}
|
||||
|
||||
function legend() {
|
||||
const wrap = el('div', { class: 'legend' });
|
||||
for (const [type, info] of Object.entries(ENTRY_TYPES)) {
|
||||
wrap.appendChild(
|
||||
el(
|
||||
'span',
|
||||
{ class: 'legend-item', 'data-type': type },
|
||||
el('span', { class: 'legend-dot', style: { background: info.color } }),
|
||||
el('span', {}, `${info.icon} ${info.label}`),
|
||||
),
|
||||
);
|
||||
}
|
||||
return wrap;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// Cost & splitting subsection for the day editor. Self-contained so dayEditor
|
||||
// stays small. read() returns the cost fields for the entry payload:
|
||||
// { price: null } — no cost
|
||||
// { price, paid_by, split_mode, participants } — cost set
|
||||
// { error } — validation message
|
||||
import { el } from '../dom.js';
|
||||
import { SPLIT_MODES } from '../format.js';
|
||||
|
||||
export function createCostForm({ members, currency }) {
|
||||
const priceInput = el('input', { class: 'input', type: 'number', min: '0', step: '0.01', placeholder: '0.00' });
|
||||
const payerSelect = el(
|
||||
'select',
|
||||
{ class: 'input' },
|
||||
el('option', { value: '' }, '— unassigned —'),
|
||||
...members.map((m) => el('option', { value: String(m.id) }, m.display_name)),
|
||||
);
|
||||
const modeSelect = el(
|
||||
'select',
|
||||
{ class: 'input' },
|
||||
...SPLIT_MODES.map((m) => el('option', { value: m.value }, m.label)),
|
||||
);
|
||||
const participantChecks = members.map((m) =>
|
||||
el('input', { type: 'checkbox', class: 'part-check', value: String(m.id), checked: true }),
|
||||
);
|
||||
const participantsBox = el(
|
||||
'div',
|
||||
{ class: 'participants' },
|
||||
...members.map((m, i) =>
|
||||
el('label', { class: 'part-item' }, participantChecks[i], el('span', {}, m.display_name)),
|
||||
),
|
||||
);
|
||||
const costDetail = el(
|
||||
'div',
|
||||
{ class: 'cost-detail' },
|
||||
el('div', { class: 'form-row' },
|
||||
el('label', { class: 'field' }, el('span', { class: 'field-label' }, 'Paid by'), payerSelect),
|
||||
el('label', { class: 'field field-grow' }, el('span', { class: 'field-label' }, 'Split'), modeSelect)),
|
||||
el('div', { class: 'field' }, el('span', { class: 'field-label' }, 'Participants'), participantsBox),
|
||||
);
|
||||
const node = el(
|
||||
'div',
|
||||
{ class: 'cost-section' },
|
||||
el('div', { class: 'form-row' },
|
||||
el('label', { class: 'field field-price' }, el('span', { class: 'field-label' }, `Price (${currency})`), priceInput)),
|
||||
costDetail,
|
||||
);
|
||||
|
||||
// Dim the payer/split/participants controls until a price is entered.
|
||||
function syncVisibility() {
|
||||
costDetail.classList.toggle('disabled', priceInput.value.trim() === '');
|
||||
}
|
||||
priceInput.addEventListener('input', syncVisibility);
|
||||
|
||||
function read() {
|
||||
const priceRaw = priceInput.value.trim();
|
||||
if (priceRaw === '') return { price: null };
|
||||
const price = Number(priceRaw);
|
||||
if (!Number.isFinite(price) || price < 0) return { error: 'Price must be a number ≥ 0.' };
|
||||
const split_mode = modeSelect.value;
|
||||
const paid_by = payerSelect.value ? Number(payerSelect.value) : null;
|
||||
if (split_mode === 'payer' && paid_by == null) {
|
||||
return { error: "Choose who paid for a payer's own expense." };
|
||||
}
|
||||
const checked = participantChecks.filter((c) => c.checked).map((c) => Number(c.value));
|
||||
if (checked.length === 0) {
|
||||
return { error: 'Select at least one participant (or clear the price to drop the cost).' };
|
||||
}
|
||||
// [] means "all trip members"; only send an explicit list for a subset.
|
||||
const participants = checked.length === members.length ? [] : checked;
|
||||
return { price, paid_by, split_mode, participants };
|
||||
}
|
||||
|
||||
function prefill(entry) {
|
||||
priceInput.value = entry.price != null ? String(entry.price) : '';
|
||||
payerSelect.value = entry.paid_by != null ? String(entry.paid_by) : '';
|
||||
modeSelect.value = entry.split_mode || 'equal';
|
||||
const parts = Array.isArray(entry.participants) ? entry.participants : [];
|
||||
for (const cb of participantChecks) {
|
||||
cb.checked = parts.length === 0 || parts.includes(Number(cb.value));
|
||||
}
|
||||
syncVisibility();
|
||||
}
|
||||
|
||||
syncVisibility();
|
||||
return { node, read, prefill };
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
// Costs & splitting panel from GET /api/trips/:id/costs.
|
||||
// Total + currency, breakdown by entry type, a per-user share/paid/net table
|
||||
// (net colored green when owed to them, red when they owe), a settle-up list,
|
||||
// and a hint when some priced entries have no payer assigned.
|
||||
import { el } from '../dom.js';
|
||||
import { typeInfo, formatMoney } from '../format.js';
|
||||
|
||||
export function renderCosts(tctx) {
|
||||
const costs = tctx.costs || {};
|
||||
const currency = costs.currency || (tctx.trip.trip && tctx.trip.trip.currency) || 'USD';
|
||||
const total = costs.totalCost || 0;
|
||||
const perUser = costs.perUser || [];
|
||||
const byType = costs.byType || {};
|
||||
const settlements = costs.settlements || [];
|
||||
const unassigned = costs.unassigned || 0;
|
||||
|
||||
const money = (n) => formatMoney(n, currency);
|
||||
|
||||
const nameById = new Map(perUser.map((u) => [u.userId, u.displayName]));
|
||||
const memberName = (id) => {
|
||||
if (nameById.has(id)) return nameById.get(id);
|
||||
const m = (tctx.trip.members || []).find((x) => x.id === id);
|
||||
return m ? m.display_name : `user ${id}`;
|
||||
};
|
||||
|
||||
const section = el('section', { class: 'card costs-section' });
|
||||
section.appendChild(
|
||||
el(
|
||||
'div',
|
||||
{ class: 'section-head' },
|
||||
el('h2', {}, 'Costs'),
|
||||
el('p', { class: 'muted' }, 'Who owes whom, split across the trip.'),
|
||||
),
|
||||
);
|
||||
|
||||
const hasCosts = total > 0 || perUser.some((u) => u.share || u.paid);
|
||||
if (!hasCosts) {
|
||||
section.appendChild(
|
||||
el(
|
||||
'div',
|
||||
{ class: 'costs-empty' },
|
||||
el('div', { class: 'empty-icon' }, '💰'),
|
||||
el('p', {}, 'No costs tracked yet.'),
|
||||
el('p', { class: 'muted' }, 'Add a price to an entry to start splitting expenses.'),
|
||||
),
|
||||
);
|
||||
return section;
|
||||
}
|
||||
|
||||
// Total.
|
||||
section.appendChild(
|
||||
el(
|
||||
'div',
|
||||
{ class: 'costs-total' },
|
||||
el('span', { class: 'costs-total-value' }, money(total)),
|
||||
el('span', { class: 'costs-total-label' }, 'total trip cost'),
|
||||
),
|
||||
);
|
||||
|
||||
if (unassigned > 0) {
|
||||
section.appendChild(
|
||||
el(
|
||||
'div',
|
||||
{ class: 'costs-warn' },
|
||||
`⚠ ${money(unassigned)} of priced entries have no payer assigned — assign payers to settle them.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Breakdown by type.
|
||||
const typeKeys = Object.keys(byType);
|
||||
if (typeKeys.length) {
|
||||
const bt = el('div', { class: 'cost-block' }, el('h3', {}, 'By type'));
|
||||
for (const type of typeKeys) {
|
||||
const info = typeInfo(type);
|
||||
bt.appendChild(
|
||||
el(
|
||||
'div',
|
||||
{ class: 'bytype-row' },
|
||||
el('span', { class: 'bytype-dot', style: { background: info.color } }),
|
||||
el('span', { class: 'bytype-name' }, `${info.icon} ${info.label}`),
|
||||
el('span', { class: 'bytype-amount' }, money(byType[type])),
|
||||
),
|
||||
);
|
||||
}
|
||||
section.appendChild(bt);
|
||||
}
|
||||
|
||||
// Per-user table.
|
||||
if (perUser.length) {
|
||||
const table = el(
|
||||
'table',
|
||||
{ class: 'cost-table' },
|
||||
el(
|
||||
'thead',
|
||||
{},
|
||||
el(
|
||||
'tr',
|
||||
{},
|
||||
el('th', {}, 'Member'),
|
||||
el('th', { class: 'num' }, 'Share'),
|
||||
el('th', { class: 'num' }, 'Paid'),
|
||||
el('th', { class: 'num' }, 'Net'),
|
||||
),
|
||||
),
|
||||
el(
|
||||
'tbody',
|
||||
{},
|
||||
...perUser.map((u) => {
|
||||
const net = u.net || 0;
|
||||
const netClass = net > 0.004 ? 'net-pos' : net < -0.004 ? 'net-neg' : 'net-zero';
|
||||
const netText = `${net > 0 ? '+' : ''}${money(net)}`;
|
||||
return el(
|
||||
'tr',
|
||||
{},
|
||||
el('td', {}, u.displayName),
|
||||
el('td', { class: 'num' }, money(u.share || 0)),
|
||||
el('td', { class: 'num' }, money(u.paid || 0)),
|
||||
el('td', { class: `num ${netClass}` }, netText),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
section.appendChild(el('div', { class: 'cost-block' }, el('h3', {}, 'Per person'), table));
|
||||
}
|
||||
|
||||
// Settle-up list.
|
||||
const settleBlock = el('div', { class: 'cost-block' }, el('h3', {}, 'Settle up'));
|
||||
if (!settlements.length) {
|
||||
settleBlock.appendChild(el('p', { class: 'muted' }, 'All square — no transfers needed.'));
|
||||
} else {
|
||||
for (const s of settlements) {
|
||||
settleBlock.appendChild(
|
||||
el(
|
||||
'div',
|
||||
{ class: 'settle-row' },
|
||||
el('span', { class: 'settle-from' }, memberName(s.fromUserId)),
|
||||
el('span', { class: 'settle-arrow' }, '→'),
|
||||
el('span', { class: 'settle-to' }, memberName(s.toUserId)),
|
||||
el('span', { class: 'settle-amount' }, money(s.amount)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
section.appendChild(settleBlock);
|
||||
|
||||
return section;
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
// Slide-over panel for a single day: lists existing entries (edit/delete) and
|
||||
// a form to add/update one, including a debounced geocode-backed location
|
||||
// autocomplete. On any change it calls tctx.refreshTrip() so the calendar,
|
||||
// map and summary update; while open it registers tctx._onModalRefresh so
|
||||
// this panel re-renders itself from the freshly fetched trip data too.
|
||||
import { api } from '../api.js';
|
||||
import { el, clear, mount, toast } from '../dom.js';
|
||||
import {
|
||||
ENTRY_TYPE_LIST,
|
||||
splitModeLabel,
|
||||
typeInfo,
|
||||
formatFullDate,
|
||||
formatTimeRange,
|
||||
formatMoney,
|
||||
flightChain,
|
||||
hasSegments,
|
||||
hasRental,
|
||||
} from '../format.js';
|
||||
import { createFlightRoute, renderSegmentLines } from './segments.js';
|
||||
import { createRentalDetails, renderRentalLine } from './rental.js';
|
||||
import { createCostForm } from './costForm.js';
|
||||
|
||||
export function openDayEditor(tctx, date) {
|
||||
// A form-state object for the entry currently being added/edited.
|
||||
let editing = null; // entry id being edited, or null for a new entry
|
||||
let loc = null; // { name, lat, lng } | null
|
||||
|
||||
const members = () => tctx.trip.members || [];
|
||||
const currency = () => (tctx.trip.trip && tctx.trip.trip.currency) || 'USD';
|
||||
const memberName = (id) => {
|
||||
const m = members().find((x) => x.id === id);
|
||||
return m ? m.display_name : `user ${id}`;
|
||||
};
|
||||
|
||||
const overlay = el('div', { class: 'overlay' });
|
||||
const panel = el('aside', { class: 'slideover', role: 'dialog', 'aria-modal': 'true' });
|
||||
overlay.appendChild(panel);
|
||||
document.body.appendChild(overlay);
|
||||
document.body.classList.add('no-scroll');
|
||||
|
||||
function close() {
|
||||
tctx._onModalRefresh = null;
|
||||
document.body.classList.remove('no-scroll');
|
||||
overlay.remove();
|
||||
document.removeEventListener('keydown', onKey);
|
||||
window.removeEventListener('hashchange', close);
|
||||
}
|
||||
|
||||
function onKey(e) {
|
||||
if (e.key === 'Escape') close();
|
||||
}
|
||||
document.addEventListener('keydown', onKey);
|
||||
// Self-close on any navigation so the overlay never orphans over another view.
|
||||
window.addEventListener('hashchange', close);
|
||||
overlay.addEventListener('click', (e) => {
|
||||
if (e.target === overlay) close();
|
||||
});
|
||||
|
||||
// Re-render this panel whenever the underlying trip data changes.
|
||||
tctx._onModalRefresh = () => draw();
|
||||
|
||||
function entriesForDate() {
|
||||
return (tctx.trip.entries || []).filter((e) => e.date === date);
|
||||
}
|
||||
|
||||
function draw() {
|
||||
const dayEntries = entriesForDate();
|
||||
|
||||
const header = el(
|
||||
'div',
|
||||
{ class: 'slideover-head' },
|
||||
el(
|
||||
'div',
|
||||
{},
|
||||
el('h2', {}, formatFullDate(date)),
|
||||
el('p', { class: 'muted' }, dayEntries.length
|
||||
? `${dayEntries.length} ${dayEntries.length === 1 ? 'entry' : 'entries'}`
|
||||
: 'No entries yet'),
|
||||
),
|
||||
el('button', { class: 'icon-btn', title: 'Close', onClick: close }, '×'),
|
||||
);
|
||||
|
||||
const list = el('div', { class: 'entry-list' });
|
||||
if (!dayEntries.length) {
|
||||
list.appendChild(el('p', { class: 'muted entry-empty' }, 'Nothing planned for this day yet.'));
|
||||
} else {
|
||||
for (const entry of dayEntries) list.appendChild(entryRow(entry));
|
||||
}
|
||||
|
||||
mount(panel, header, list, formSection(dayEntries));
|
||||
panel.scrollTop = 0;
|
||||
}
|
||||
|
||||
function entryRow(entry) {
|
||||
const info = typeInfo(entry.type);
|
||||
const time = formatTimeRange(entry.start_time, entry.end_time);
|
||||
const hasPrice = entry.price != null;
|
||||
const flight = hasSegments(entry);
|
||||
return el(
|
||||
'div',
|
||||
{ class: 'entry-row', style: { '--chip': info.color } },
|
||||
el('span', { class: 'entry-icon' }, info.icon),
|
||||
el(
|
||||
'div',
|
||||
{ class: 'entry-body' },
|
||||
el(
|
||||
'div',
|
||||
{ class: 'entry-title-row' },
|
||||
el('span', { class: 'entry-title' }, entry.title),
|
||||
hasPrice
|
||||
? el('span', { class: 'entry-price' }, formatMoney(entry.price, currency(), { compact: true }))
|
||||
: null,
|
||||
),
|
||||
el(
|
||||
'div',
|
||||
{ class: 'entry-sub muted' },
|
||||
info.label,
|
||||
time ? ` · ${time}` : '',
|
||||
flight ? ` · ✈️ ${flightChain(entry.segments)}` : '',
|
||||
!flight && entry.location_name ? ` · 📍 ${entry.location_name}` : '',
|
||||
),
|
||||
flight ? renderSegmentLines(entry.segments) : null,
|
||||
hasRental(entry) ? renderRentalLine(entry.rental) : null,
|
||||
hasPrice
|
||||
? el(
|
||||
'div',
|
||||
{ class: 'entry-cost muted' },
|
||||
`💰 ${splitModeLabel(entry.split_mode)}`,
|
||||
entry.paid_by != null ? ` · paid by ${memberName(entry.paid_by)}` : ' · no payer set',
|
||||
)
|
||||
: null,
|
||||
entry.details ? el('div', { class: 'entry-details' }, entry.details) : null,
|
||||
),
|
||||
el(
|
||||
'div',
|
||||
{ class: 'entry-actions' },
|
||||
el('button', { class: 'icon-btn', title: 'Edit', onClick: () => startEdit(entry) }, '✎'),
|
||||
el('button', { class: 'icon-btn danger', title: 'Delete', onClick: () => onDelete(entry) }, '🗑'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function startEdit(entry) {
|
||||
editing = entry.id;
|
||||
loc = entry.lat != null && entry.lng != null
|
||||
? { name: entry.location_name || '', lat: entry.lat, lng: entry.lng }
|
||||
: null;
|
||||
draw();
|
||||
// Populate fields from the entry after (re)draw.
|
||||
fields.type.value = entry.type;
|
||||
fields.title.value = entry.title;
|
||||
fields.details.value = entry.details || '';
|
||||
fields.start.value = entry.start_time || '';
|
||||
fields.end.value = entry.end_time || '';
|
||||
fields.cost.prefill(entry);
|
||||
// Flight segments (load() triggers the flight-route onChange -> UI sync).
|
||||
fields.flightRoute.load(Array.isArray(entry.segments) ? entry.segments : []);
|
||||
fields.rentalDetails.load(entry.rental || null);
|
||||
renderLoc();
|
||||
panel.querySelector('.entry-form').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
fields.title.focus();
|
||||
}
|
||||
|
||||
// Field references for the current form render, so edit can populate them.
|
||||
let fields = {};
|
||||
|
||||
function formSection(dayEntries) {
|
||||
const typeSelect = el(
|
||||
'select',
|
||||
{ class: 'input' },
|
||||
...ENTRY_TYPE_LIST.map((t) => el('option', { value: t.value }, `${t.icon} ${t.label}`)),
|
||||
);
|
||||
const titleInput = el('input', { class: 'input', type: 'text', maxlength: '200', placeholder: 'Title (e.g. Flight BKK → CNX)' });
|
||||
const detailsInput = el('textarea', { class: 'input', rows: '2', placeholder: 'Details (optional)' });
|
||||
const startInput = el('input', { class: 'input', type: 'time' });
|
||||
const endInput = el('input', { class: 'input', type: 'time' });
|
||||
|
||||
const locWrap = el('div', { class: 'loc-field' });
|
||||
const locInput = el('input', {
|
||||
class: 'input',
|
||||
type: 'text',
|
||||
placeholder: 'Search a place (OpenStreetMap)…',
|
||||
autocomplete: 'off',
|
||||
});
|
||||
const locResults = el('div', { class: 'loc-results' });
|
||||
const locSelected = el('div', { class: 'loc-selected' });
|
||||
locWrap.append(locInput, locResults, locSelected);
|
||||
const locationField = el('label', { class: 'field' }, el('span', { class: 'field-label' }, 'Location'), locWrap);
|
||||
|
||||
// ----- Flight route subsection (shown only for flight entries) -----
|
||||
// The sub-modules fire onChange during construction (load of initial
|
||||
// state), before the section elements below exist — gate until wired.
|
||||
let typeUIReady = false;
|
||||
const flightRoute = createFlightRoute({ onChange: () => { if (typeUIReady) syncTypeUI(); } });
|
||||
const flightSection = el(
|
||||
'div',
|
||||
{ class: 'flight-section' },
|
||||
el('div', { class: 'cost-heading' }, 'Flight route (optional)'),
|
||||
el('p', { class: 'hint muted' }, 'Add legs for a multi-stop flight; airports plot the route on the map.'),
|
||||
flightRoute.node,
|
||||
);
|
||||
|
||||
// ----- Rental details subsection (shown only for rental entries) -----
|
||||
const rentalDetails = createRentalDetails({ entryDate: date, onChange: () => { if (typeUIReady) syncTypeUI(); } });
|
||||
const rentalSection = el(
|
||||
'div',
|
||||
{ class: 'rental-section' },
|
||||
el('div', { class: 'cost-heading' }, 'Rental details'),
|
||||
rentalDetails.node,
|
||||
);
|
||||
|
||||
// Show the flight route for flights and the rental block for rentals; hide
|
||||
// the generic location field only once flight segments exist (route then
|
||||
// comes from the airport coords). Rentals keep the location field — their
|
||||
// pickup/dropoff are plain text and don't feed the route.
|
||||
function syncTypeUI() {
|
||||
const type = typeSelect.value;
|
||||
flightSection.style.display = type === 'flight' ? '' : 'none';
|
||||
rentalSection.style.display = type === 'rental' ? '' : 'none';
|
||||
locationField.style.display = type === 'flight' && flightRoute.hasSegments() ? 'none' : '';
|
||||
}
|
||||
typeSelect.addEventListener('change', syncTypeUI);
|
||||
typeUIReady = true;
|
||||
syncTypeUI();
|
||||
|
||||
// ----- Cost subsection (self-contained module) -----
|
||||
const costForm = createCostForm({ members: members(), currency: currency() });
|
||||
|
||||
fields = {
|
||||
type: typeSelect, title: titleInput, details: detailsInput, start: startInput, end: endInput,
|
||||
locInput, locResults, locSelected,
|
||||
cost: costForm, flightRoute, rentalDetails,
|
||||
};
|
||||
|
||||
wireGeocode(locInput, locResults);
|
||||
|
||||
const errorEl = el('p', { class: 'form-error' });
|
||||
const submitBtn = el('button', { class: 'btn btn-primary', type: 'submit' }, editing ? 'Save entry' : 'Add entry');
|
||||
|
||||
async function onSubmit(e) {
|
||||
e.preventDefault();
|
||||
errorEl.textContent = '';
|
||||
const title = titleInput.value.trim();
|
||||
if (!title) return (errorEl.textContent = 'Title is required.');
|
||||
|
||||
const payload = {
|
||||
date,
|
||||
type: typeSelect.value,
|
||||
title,
|
||||
details: detailsInput.value.trim(),
|
||||
start_time: startInput.value || null,
|
||||
end_time: endInput.value || null,
|
||||
location_name: loc ? loc.name : null,
|
||||
lat: loc ? loc.lat : null,
|
||||
lng: loc ? loc.lng : null,
|
||||
};
|
||||
if (!editing) {
|
||||
payload.sort_order = dayEntries.length;
|
||||
}
|
||||
|
||||
// Flight segments (flight entries only). Clear them otherwise so changing
|
||||
// an entry's type away from flight drops any prior segments.
|
||||
if (typeSelect.value === 'flight') {
|
||||
const res = flightRoute.read();
|
||||
if (res.error) return (errorEl.textContent = res.error);
|
||||
payload.segments = res.segments; // array or null
|
||||
// When segments exist, the route comes from the airports — clear the
|
||||
// generic location so it doesn't add a stray stop.
|
||||
if (res.segments) {
|
||||
payload.location_name = null;
|
||||
payload.lat = null;
|
||||
payload.lng = null;
|
||||
}
|
||||
} else {
|
||||
payload.segments = null;
|
||||
}
|
||||
|
||||
// Rental details (rental entries only). The entry's own date follows the
|
||||
// pickup date so it lives on the pickup day.
|
||||
if (typeSelect.value === 'rental') {
|
||||
const res = rentalDetails.read();
|
||||
if (res.error) return (errorEl.textContent = res.error);
|
||||
payload.rental = res.rental; // object or null
|
||||
if (res.pickupDate) payload.date = res.pickupDate;
|
||||
} else {
|
||||
payload.rental = null;
|
||||
}
|
||||
|
||||
// Cost fields: price is the toggle. When set, send the full cost set;
|
||||
// when blank, send price:null (clears any prior cost) and omit the rest.
|
||||
const costRes = costForm.read();
|
||||
if (costRes.error) return (errorEl.textContent = costRes.error);
|
||||
payload.price = costRes.price != null ? costRes.price : null;
|
||||
if (costRes.price != null) {
|
||||
payload.paid_by = costRes.paid_by;
|
||||
payload.split_mode = costRes.split_mode;
|
||||
payload.participants = costRes.participants;
|
||||
}
|
||||
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Saving…';
|
||||
try {
|
||||
if (editing) await api.entries.update(editing, payload);
|
||||
else await api.entries.create(tctx.tripId, payload);
|
||||
toast(editing ? 'Entry updated' : 'Entry added', 'success');
|
||||
editing = null;
|
||||
loc = null;
|
||||
await tctx.refreshTrip(); // triggers _onModalRefresh -> draw()
|
||||
} catch (err) {
|
||||
errorEl.textContent = err.message;
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = editing ? 'Save entry' : 'Add entry';
|
||||
}
|
||||
}
|
||||
|
||||
const cancelEdit = editing
|
||||
? el('button', {
|
||||
class: 'btn btn-ghost',
|
||||
type: 'button',
|
||||
onClick: () => { editing = null; loc = null; draw(); },
|
||||
}, 'Cancel edit')
|
||||
: null;
|
||||
|
||||
const form = el(
|
||||
'form',
|
||||
{ class: 'entry-form', onSubmit },
|
||||
el('h3', {}, editing ? 'Edit entry' : 'Add entry'),
|
||||
el(
|
||||
'div',
|
||||
{ class: 'form-row' },
|
||||
el('label', { class: 'field' }, el('span', { class: 'field-label' }, 'Type'), typeSelect),
|
||||
el('label', { class: 'field field-grow' }, el('span', { class: 'field-label' }, 'Title'), titleInput),
|
||||
),
|
||||
el('label', { class: 'field' }, el('span', { class: 'field-label' }, 'Details'), detailsInput),
|
||||
el(
|
||||
'div',
|
||||
{ class: 'form-row' },
|
||||
el('label', { class: 'field' }, el('span', { class: 'field-label' }, 'Start time'), startInput),
|
||||
el('label', { class: 'field' }, el('span', { class: 'field-label' }, 'End time'), endInput),
|
||||
),
|
||||
flightSection,
|
||||
rentalSection,
|
||||
locationField,
|
||||
el('div', { class: 'cost-heading' }, 'Cost (optional)'),
|
||||
costForm.node,
|
||||
errorEl,
|
||||
el('div', { class: 'form-actions' }, cancelEdit, submitBtn),
|
||||
);
|
||||
|
||||
renderLoc();
|
||||
syncTypeUI();
|
||||
return form;
|
||||
}
|
||||
|
||||
function renderLoc() {
|
||||
const box = fields.locSelected;
|
||||
if (!box) return;
|
||||
clear(box);
|
||||
if (loc) {
|
||||
box.appendChild(
|
||||
el(
|
||||
'div',
|
||||
{ class: 'loc-chip' },
|
||||
el('span', {}, `📍 ${loc.name}`),
|
||||
el('button', {
|
||||
class: 'loc-clear',
|
||||
type: 'button',
|
||||
title: 'Clear location',
|
||||
onClick: () => { loc = null; fields.locInput.value = ''; renderLoc(); },
|
||||
}, '×'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function wireGeocode(input, resultsBox) {
|
||||
let timer = null;
|
||||
let seq = 0;
|
||||
input.addEventListener('input', () => {
|
||||
const q = input.value.trim();
|
||||
clearTimeout(timer);
|
||||
if (q.length < 2) {
|
||||
clear(resultsBox);
|
||||
return;
|
||||
}
|
||||
timer = setTimeout(async () => {
|
||||
const mySeq = ++seq;
|
||||
resultsBox.classList.add('loading');
|
||||
try {
|
||||
const data = await api.geocode(q);
|
||||
if (mySeq !== seq) return; // a newer query superseded this one
|
||||
showResults(data.results || [], resultsBox);
|
||||
} catch (err) {
|
||||
if (mySeq !== seq) return;
|
||||
clear(resultsBox);
|
||||
toast(err.message || 'Location search failed');
|
||||
} finally {
|
||||
resultsBox.classList.remove('loading');
|
||||
}
|
||||
}, 400);
|
||||
});
|
||||
}
|
||||
|
||||
function showResults(results, resultsBox) {
|
||||
clear(resultsBox);
|
||||
if (!results.length) {
|
||||
resultsBox.appendChild(el('div', { class: 'loc-empty muted' }, 'No matches'));
|
||||
return;
|
||||
}
|
||||
for (const r of results) {
|
||||
resultsBox.appendChild(
|
||||
el(
|
||||
'button',
|
||||
{
|
||||
class: 'loc-result',
|
||||
type: 'button',
|
||||
onClick: () => {
|
||||
loc = { name: r.name, lat: r.lat, lng: r.lng };
|
||||
fields.locInput.value = '';
|
||||
clear(resultsBox);
|
||||
renderLoc();
|
||||
},
|
||||
},
|
||||
el('span', { class: 'loc-result-name' }, r.name),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function onDelete(entry) {
|
||||
if (!window.confirm(`Delete "${entry.title}"?`)) return;
|
||||
try {
|
||||
await api.entries.remove(entry.id);
|
||||
toast('Entry deleted', 'success');
|
||||
if (editing === entry.id) { editing = null; loc = null; }
|
||||
await tctx.refreshTrip();
|
||||
} catch (err) {
|
||||
toast(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
draw();
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
// Split-flap flip-clock countdown to a trip's start. Vanilla JS + CSS, no
|
||||
// library. The static top/bottom halves ALWAYS show the current digit (so the
|
||||
// number is correct even if the flip animation is interrupted); the flip
|
||||
// layers are a cosmetic overlay.
|
||||
//
|
||||
// Lifecycle: only one countdown is ever mounted, so the 1s interval id lives in
|
||||
// module state. renderCountdown clears any previous timer before starting a new
|
||||
// one (so refreshTrip's re-render never leaks or double-ticks), and a hashchange
|
||||
// handler stops it on navigation away.
|
||||
import { el, clear } from '../dom.js';
|
||||
import { parseYMD, ymd, daysBetweenInclusive } from '../format.js';
|
||||
|
||||
const UNITS = [
|
||||
{ key: 'days', label: 'Days' },
|
||||
{ key: 'hours', label: 'Hours' },
|
||||
{ key: 'mins', label: 'Min' },
|
||||
{ key: 'secs', label: 'Sec' },
|
||||
];
|
||||
|
||||
let activeTimer = null;
|
||||
let activeHashHandler = null;
|
||||
|
||||
export function stopCountdown() {
|
||||
if (activeTimer) {
|
||||
clearInterval(activeTimer);
|
||||
activeTimer = null;
|
||||
}
|
||||
if (activeHashHandler) {
|
||||
window.removeEventListener('hashchange', activeHashHandler);
|
||||
activeHashHandler = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function renderCountdown(tctx) {
|
||||
stopCountdown();
|
||||
const trip = tctx.trip.trip;
|
||||
const section = el('section', { class: 'countdown-section' });
|
||||
|
||||
function paint() {
|
||||
const state = computeState(trip);
|
||||
if (state.kind === 'future') {
|
||||
buildClock(section, trip, tctx);
|
||||
} else if (state.kind === 'ongoing') {
|
||||
clear(section);
|
||||
section.appendChild(el('div', { class: 'countdown-badge ongoing' },
|
||||
el('span', { class: 'cd-badge-icon' }, '✈'),
|
||||
el('span', {}, `Day ${state.dayN} of ${state.total}`)));
|
||||
} else {
|
||||
clear(section);
|
||||
section.appendChild(el('div', { class: 'countdown-badge done' },
|
||||
el('span', { class: 'cd-badge-icon' }, '🏁'),
|
||||
el('span', {}, 'Trip completed')));
|
||||
}
|
||||
}
|
||||
|
||||
paint();
|
||||
return section;
|
||||
}
|
||||
|
||||
function computeState(trip) {
|
||||
const today = ymd(new Date());
|
||||
if (today < trip.start_date) return { kind: 'future' };
|
||||
if (today <= trip.end_date) {
|
||||
return {
|
||||
kind: 'ongoing',
|
||||
dayN: daysBetweenInclusive(trip.start_date, today),
|
||||
total: daysBetweenInclusive(trip.start_date, trip.end_date),
|
||||
};
|
||||
}
|
||||
return { kind: 'past' };
|
||||
}
|
||||
|
||||
function buildClock(section, trip, tctx) {
|
||||
const target = parseYMD(trip.start_date); // local midnight of the start day
|
||||
clear(section);
|
||||
|
||||
const groups = {};
|
||||
const row = el('div', { class: 'flipclock' });
|
||||
UNITS.forEach((u, i) => {
|
||||
if (i > 0) row.appendChild(el('div', { class: 'fc-sep' }, ':'));
|
||||
const digitsWrap = el('div', { class: 'fc-digits' });
|
||||
// days can be 3 wide; the rest are 2. Digit cards are created lazily to
|
||||
// match the current width so a 3-digit day count still renders.
|
||||
groups[u.key] = { wrap: digitsWrap, cards: [] };
|
||||
row.appendChild(
|
||||
el('div', { class: 'fc-group' }, digitsWrap, el('div', { class: 'fc-label' }, u.label)),
|
||||
);
|
||||
});
|
||||
|
||||
section.appendChild(el('p', { class: 'countdown-caption' }, 'until departure'));
|
||||
section.appendChild(row);
|
||||
|
||||
function values() {
|
||||
const totalSec = Math.max(0, Math.floor((target.getTime() - Date.now()) / 1000));
|
||||
return {
|
||||
days: Math.floor(totalSec / 86400),
|
||||
hours: Math.floor((totalSec % 86400) / 3600),
|
||||
mins: Math.floor((totalSec % 3600) / 60),
|
||||
secs: totalSec % 60,
|
||||
totalSec,
|
||||
};
|
||||
}
|
||||
|
||||
function update() {
|
||||
const v = values();
|
||||
setGroup(groups.days, v.days, Math.max(2, String(v.days).length));
|
||||
setGroup(groups.hours, v.hours, 2);
|
||||
setGroup(groups.mins, v.mins, 2);
|
||||
setGroup(groups.secs, v.secs, 2);
|
||||
if (v.totalSec <= 0) {
|
||||
// Departure reached — swap to the "ongoing" badge (no network needed).
|
||||
stopCountdown();
|
||||
renderInto(section, tctx);
|
||||
}
|
||||
}
|
||||
|
||||
update();
|
||||
activeTimer = setInterval(update, 1000);
|
||||
activeHashHandler = stopCountdown;
|
||||
window.addEventListener('hashchange', activeHashHandler);
|
||||
}
|
||||
|
||||
// Re-run the whole countdown paint (used at the zero-crossing).
|
||||
function renderInto(section, tctx) {
|
||||
const trip = tctx.trip.trip;
|
||||
const state = computeState(trip);
|
||||
clear(section);
|
||||
if (state.kind === 'ongoing') {
|
||||
section.appendChild(el('div', { class: 'countdown-badge ongoing' },
|
||||
el('span', { class: 'cd-badge-icon' }, '✈'),
|
||||
el('span', {}, `Day ${state.dayN} of ${state.total}`)));
|
||||
} else {
|
||||
section.appendChild(el('div', { class: 'countdown-badge done' },
|
||||
el('span', { class: 'cd-badge-icon' }, '🏁'),
|
||||
el('span', {}, 'Trip completed')));
|
||||
}
|
||||
}
|
||||
|
||||
function setGroup(group, value, width) {
|
||||
const str = String(value).padStart(width, '0');
|
||||
// Rebuild the card set if the digit count changed (e.g. 100 -> 99 days).
|
||||
if (group.cards.length !== str.length) {
|
||||
clear(group.wrap);
|
||||
group.cards = [];
|
||||
for (const ch of str) {
|
||||
const card = makeCard(ch);
|
||||
group.cards.push(card);
|
||||
group.wrap.appendChild(card.node);
|
||||
}
|
||||
return;
|
||||
}
|
||||
str.split('').forEach((ch, i) => setDigit(group.cards[i], ch));
|
||||
}
|
||||
|
||||
function makeCard(digit) {
|
||||
const top = face('fc-top', digit);
|
||||
const bottom = face('fc-bottom', digit);
|
||||
const flipTop = face('fc-flip fc-flip-top', digit);
|
||||
const flipBottom = face('fc-flip fc-flip-bottom', digit);
|
||||
const node = el('div', { class: 'fc-card' }, top, bottom, flipTop, flipBottom);
|
||||
const card = { node, value: digit, top, bottom, flipTop, flipBottom };
|
||||
card.node.addEventListener('animationend', (e) => {
|
||||
if (e.animationName === 'fc-bottom') card.node.classList.remove('fc-flipping');
|
||||
});
|
||||
return card;
|
||||
}
|
||||
|
||||
function face(cls, digit) {
|
||||
return el('div', { class: `fc-face ${cls}` }, el('b', {}, digit));
|
||||
}
|
||||
|
||||
function setDigit(card, next) {
|
||||
if (card.value === next) return;
|
||||
const prev = card.value;
|
||||
card.value = next;
|
||||
// Static halves show the new digit immediately (correctness guaranteed).
|
||||
digitText(card.top, next);
|
||||
digitText(card.bottom, next);
|
||||
// Cosmetic flip: old top falls, new bottom rises.
|
||||
digitText(card.flipTop, prev);
|
||||
digitText(card.flipBottom, next);
|
||||
card.node.classList.remove('fc-flipping');
|
||||
void card.node.offsetWidth; // restart the animation
|
||||
card.node.classList.add('fc-flipping');
|
||||
}
|
||||
|
||||
function digitText(face, ch) {
|
||||
face.firstChild.textContent = ch;
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
// Leaflet map: numbered markers for located stops, a polyline through them,
|
||||
// per-leg km labels, and a leg-by-leg list. Graceful empty state when the
|
||||
// trip has no located entries yet. Uses the /route response from tctx.route.
|
||||
import { el } from '../dom.js';
|
||||
import { typeInfo } from '../format.js';
|
||||
|
||||
export function renderMap(tctx) {
|
||||
const route = tctx.route || { stops: [], legs: [], totalKm: 0 };
|
||||
const stops = route.stops || [];
|
||||
|
||||
const section = el('section', { class: 'card map-section' });
|
||||
section.appendChild(
|
||||
el(
|
||||
'div',
|
||||
{ class: 'section-head' },
|
||||
el('h2', {}, 'Map'),
|
||||
el('p', { class: 'muted' }, stops.length
|
||||
? `${stops.length} located ${stops.length === 1 ? 'stop' : 'stops'} · ${fmtKm(route.totalKm)} km total`
|
||||
: 'Add a location to an entry to see it here.'),
|
||||
),
|
||||
);
|
||||
|
||||
if (!stops.length) {
|
||||
section.appendChild(
|
||||
el(
|
||||
'div',
|
||||
{ class: 'map-empty' },
|
||||
el('div', { class: 'empty-icon' }, '🗺️'),
|
||||
el('p', {}, 'No located entries yet.'),
|
||||
el('p', { class: 'muted' }, 'Open a day, add an entry, and give it a location to plot it on the map.'),
|
||||
),
|
||||
);
|
||||
return section;
|
||||
}
|
||||
|
||||
const mapDiv = el('div', { class: 'leaflet-map', id: `map-${tctx.tripId}` });
|
||||
section.appendChild(mapDiv);
|
||||
section.appendChild(legList(route, stops));
|
||||
|
||||
// Leaflet needs the container attached with a real size, so init on the
|
||||
// next tick after this section is mounted into the page.
|
||||
setTimeout(() => initMap(mapDiv, route, stops), 0);
|
||||
|
||||
return section;
|
||||
}
|
||||
|
||||
function initMap(mapDiv, route, stops) {
|
||||
const L = window.L;
|
||||
if (!L) {
|
||||
mapDiv.appendChild(el('p', { class: 'muted' }, 'Map library failed to load.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const map = L.map(mapDiv, { scrollWheelZoom: true });
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors',
|
||||
maxZoom: 19,
|
||||
}).addTo(map);
|
||||
|
||||
// Geometry is driven by stop ORDER, not entryId: a multi-leg flight expands
|
||||
// into several airport stops that all share the same entryId, so keying by
|
||||
// entryId would collapse them.
|
||||
const points = stops.map((s) => [s.lat, s.lng]);
|
||||
stops.forEach((stop, i) => {
|
||||
const info = typeInfo(stop.type);
|
||||
L.marker(points[i], { icon: numberedIcon(L, i + 1, info.color) })
|
||||
.addTo(map)
|
||||
.bindPopup(popupHtml(stop, info));
|
||||
});
|
||||
|
||||
// One polyline per measured leg, styled by mode (air = dashed blue, ground =
|
||||
// solid teal), each with a km label at its midpoint. Legs are matched to
|
||||
// consecutive non-coincident stop pairs (skip <0.05km, the server's rule).
|
||||
const legs = route.legs || [];
|
||||
let li = 0;
|
||||
for (let i = 0; i < stops.length - 1 && li < legs.length; i++) {
|
||||
const a = points[i];
|
||||
const b = points[i + 1];
|
||||
if (haversineKm(a[0], a[1], b[0], b[1]) < 0.05) continue;
|
||||
const leg = legs[li];
|
||||
li += 1;
|
||||
const air = leg.mode === 'air';
|
||||
L.polyline([a, b], {
|
||||
color: air ? '#2563eb' : '#0f766e',
|
||||
weight: 3,
|
||||
opacity: 0.75,
|
||||
dashArray: air ? '6 6' : null,
|
||||
}).addTo(map);
|
||||
const mid = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
|
||||
L.marker(mid, { icon: kmLabel(L, leg.km), interactive: false }).addTo(map);
|
||||
}
|
||||
|
||||
if (points.length === 1) {
|
||||
map.setView(points[0], 10);
|
||||
} else {
|
||||
map.fitBounds(L.latLngBounds(points).pad(0.2));
|
||||
}
|
||||
map.invalidateSize();
|
||||
}
|
||||
|
||||
// Great-circle km — mirrors the server rule for skipping zero-distance legs.
|
||||
function haversineKm(lat1, lng1, lat2, lng2) {
|
||||
const R = 6371;
|
||||
const toRad = (d) => (d * Math.PI) / 180;
|
||||
const dLat = toRad(lat2 - lat1);
|
||||
const dLng = toRad(lng2 - lng1);
|
||||
const a = Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
|
||||
return 2 * R * Math.asin(Math.min(1, Math.sqrt(a)));
|
||||
}
|
||||
|
||||
function numberedIcon(L, n, color) {
|
||||
return L.divIcon({
|
||||
className: 'map-pin-wrap',
|
||||
html: `<span class="map-pin" style="background:${color}">${n}</span>`,
|
||||
iconSize: [26, 26],
|
||||
iconAnchor: [13, 26],
|
||||
popupAnchor: [0, -24],
|
||||
});
|
||||
}
|
||||
|
||||
function kmLabel(L, km) {
|
||||
return L.divIcon({
|
||||
className: 'km-label-wrap',
|
||||
html: `<span class="km-label">${fmtKm(km)} km</span>`,
|
||||
iconSize: [0, 0],
|
||||
});
|
||||
}
|
||||
|
||||
// Built from server-provided fields; escape to keep the popup injection-safe.
|
||||
function popupHtml(stop, info) {
|
||||
const isAirport = stop.kind === 'airport' && stop.code;
|
||||
const heading = isAirport ? `${info.icon} ${esc(stop.code)}` : `${info.icon} ${esc(stop.title)}`;
|
||||
const sub = isAirport
|
||||
? `${esc(info.label)} · ${esc(stop.date)}${stop.title ? ` · ${esc(stop.title)}` : ''}`
|
||||
: `${esc(info.label)} · ${esc(stop.date)}`;
|
||||
const locIcon = isAirport ? '✈️' : '📍';
|
||||
return (
|
||||
`<div class="map-popup">` +
|
||||
`<strong>${heading}</strong>` +
|
||||
`<div class="map-popup-sub">${sub}</div>` +
|
||||
(stop.location_name ? `<div class="map-popup-loc">${locIcon} ${esc(stop.location_name)}</div>` : '') +
|
||||
`</div>`
|
||||
);
|
||||
}
|
||||
|
||||
function stopLabel(stop) {
|
||||
if (stop.kind === 'airport' && stop.code) return stop.code;
|
||||
return stop.location_name || stop.title || '?';
|
||||
}
|
||||
|
||||
function legList(route, stops) {
|
||||
const legs = route.legs || [];
|
||||
if (!legs.length) return el('div', { class: 'leg-list-empty muted' }, 'A single stop — no legs to measure yet.');
|
||||
|
||||
const wrap = el('div', { class: 'leg-list' }, el('h3', {}, 'Legs'));
|
||||
let li = 0;
|
||||
let shown = 0;
|
||||
for (let i = 0; i < stops.length - 1 && li < legs.length; i++) {
|
||||
const a = stops[i];
|
||||
const b = stops[i + 1];
|
||||
if (haversineKm(a.lat, a.lng, b.lat, b.lng) < 0.05) continue;
|
||||
const leg = legs[li++];
|
||||
shown += 1;
|
||||
const air = leg.mode === 'air';
|
||||
wrap.appendChild(
|
||||
el(
|
||||
'div',
|
||||
{ class: 'leg-row' },
|
||||
el('span', { class: 'leg-index' }, String(shown)),
|
||||
el('span', { class: 'leg-mode', title: air ? 'Flight' : 'Ground' }, air ? '✈️' : '🚗'),
|
||||
el('span', { class: 'leg-path' }, stopLabel(a), ' → ', stopLabel(b)),
|
||||
el('span', { class: 'leg-km' }, `${fmtKm(leg.km)} km`),
|
||||
),
|
||||
);
|
||||
}
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function fmtKm(n) {
|
||||
return (Math.round((Number(n) || 0) * 10) / 10).toLocaleString();
|
||||
}
|
||||
|
||||
function esc(str) {
|
||||
return String(str == null ? '' : str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
// Rental-car details subsection used inside the day editor for rental entries,
|
||||
// plus a compact one-line renderer for the entry list. Pickup/dropoff use
|
||||
// plain-text location names only (they do not feed the route), so there is no
|
||||
// geocode/lat-lng UI here. Kept separate so dayEditor.js stays small.
|
||||
import { el } from '../dom.js';
|
||||
import { formatDate } from '../format.js';
|
||||
|
||||
// createRentalDetails({ initial, entryDate, onChange }) ->
|
||||
// { node, read(), load(rental) }
|
||||
// read() -> { rental: {...} | null, pickupDate } or { error }.
|
||||
export function createRentalDetails({ initial = null, entryDate, onChange = () => {} } = {}) {
|
||||
const brandI = textInput('Brand (e.g. Toyota)', 60);
|
||||
const modelI = textInput('Model (e.g. Yaris Cross)', 60);
|
||||
const carTypeI = textInput('Type (e.g. SUV)', 40);
|
||||
const bookingI = textInput('Booking ref', 60);
|
||||
const includedI = el('input', { class: 'input', type: 'number', min: '0', step: '1', placeholder: 'e.g. 1500' });
|
||||
|
||||
const pDate = el('input', { class: 'input', type: 'date' });
|
||||
const pTime = el('input', { class: 'input', type: 'time' });
|
||||
const pLoc = textInput('Location (free text)', 120);
|
||||
const dDate = el('input', { class: 'input', type: 'date' });
|
||||
const dTime = el('input', { class: 'input', type: 'time' });
|
||||
const dLoc = textInput('Location (free text)', 120);
|
||||
|
||||
for (const inp of [brandI, modelI, carTypeI, bookingI, includedI, pDate, pTime, pLoc, dDate, dTime, dLoc]) {
|
||||
inp.addEventListener('input', onChange);
|
||||
}
|
||||
|
||||
const node = el(
|
||||
'div',
|
||||
{ class: 'rental-details' },
|
||||
el('div', { class: 'form-row' },
|
||||
field('Brand', brandI), field('Model', modelI)),
|
||||
el('div', { class: 'form-row' },
|
||||
field('Car type', carTypeI), field('Booking ref', bookingI),
|
||||
field('Included km', includedI)),
|
||||
el('div', { class: 'rental-blocks' },
|
||||
rentalBlock('Pickup', pDate, pTime, pLoc),
|
||||
rentalBlock('Dropoff', dDate, dTime, dLoc)),
|
||||
);
|
||||
|
||||
function read() {
|
||||
const brand = brandI.value.trim();
|
||||
const model = modelI.value.trim();
|
||||
const car_type = carTypeI.value.trim();
|
||||
const booking_ref = bookingI.value.trim();
|
||||
const includedRaw = includedI.value.trim();
|
||||
const pickupDate = pDate.value || entryDate;
|
||||
const dropoffDate = dDate.value || pickupDate;
|
||||
|
||||
const anyContent = brand || model || car_type || booking_ref || includedRaw ||
|
||||
pTime.value || pLoc.value.trim() || dTime.value || dLoc.value.trim();
|
||||
if (!anyContent) return { rental: null, pickupDate };
|
||||
|
||||
let included_km = null;
|
||||
if (includedRaw !== '') {
|
||||
const n = Number(includedRaw);
|
||||
if (!Number.isFinite(n) || n < 0) return { error: 'Included km must be a number ≥ 0.' };
|
||||
included_km = n;
|
||||
}
|
||||
|
||||
const rental = {};
|
||||
if (brand) rental.brand = brand;
|
||||
if (model) rental.model = model;
|
||||
if (car_type) rental.car_type = car_type;
|
||||
if (booking_ref) rental.booking_ref = booking_ref;
|
||||
if (included_km != null) rental.included_km = included_km;
|
||||
rental.pickup = { date: pickupDate };
|
||||
if (pTime.value) rental.pickup.time = pTime.value;
|
||||
if (pLoc.value.trim()) rental.pickup.location_name = pLoc.value.trim();
|
||||
rental.dropoff = { date: dropoffDate };
|
||||
if (dTime.value) rental.dropoff.time = dTime.value;
|
||||
if (dLoc.value.trim()) rental.dropoff.location_name = dLoc.value.trim();
|
||||
return { rental, pickupDate };
|
||||
}
|
||||
|
||||
function load(rental) {
|
||||
const r = rental || {};
|
||||
brandI.value = r.brand || '';
|
||||
modelI.value = r.model || '';
|
||||
carTypeI.value = r.car_type || '';
|
||||
bookingI.value = r.booking_ref || '';
|
||||
includedI.value = r.included_km != null ? String(r.included_km) : '';
|
||||
const p = r.pickup || {};
|
||||
pDate.value = p.date || entryDate || '';
|
||||
pTime.value = p.time || '';
|
||||
pLoc.value = p.location_name || '';
|
||||
const d = r.dropoff || {};
|
||||
dDate.value = d.date || p.date || entryDate || '';
|
||||
dTime.value = d.time || '';
|
||||
dLoc.value = d.location_name || '';
|
||||
onChange();
|
||||
}
|
||||
|
||||
// Initial values (defaults: both dates to the entry's day).
|
||||
load(initial || { pickup: { date: entryDate }, dropoff: { date: entryDate } });
|
||||
|
||||
return { node, read, load };
|
||||
}
|
||||
|
||||
// "🚙 Toyota Yaris Cross · pickup 09:00 CNX Airport → dropoff 7 Aug 18:00 · 1,500 km included · RC-889231"
|
||||
export function renderRentalLine(rental) {
|
||||
const car = [rental.brand, rental.model].filter(Boolean).join(' ') || 'Rental car';
|
||||
const p = rental.pickup || {};
|
||||
const d = rental.dropoff || {};
|
||||
const pickup = ['pickup', p.time, p.location_name].filter(Boolean).join(' ');
|
||||
const dropoff = ['dropoff', d.date ? formatDate(d.date) : null, d.time, d.location_name].filter(Boolean).join(' ');
|
||||
const bits = [`🚙 ${car}`, `${pickup} → ${dropoff}`];
|
||||
if (rental.included_km != null) bits.push(`${Number(rental.included_km).toLocaleString()} km included`);
|
||||
if (rental.booking_ref) bits.push(rental.booking_ref);
|
||||
return el('div', { class: 'entry-rental muted' }, bits.join(' · '));
|
||||
}
|
||||
|
||||
function textInput(placeholder, maxlength) {
|
||||
return el('input', { class: 'input', type: 'text', maxlength: String(maxlength), placeholder });
|
||||
}
|
||||
|
||||
function field(label, input) {
|
||||
return el('label', { class: 'field field-grow' }, el('span', { class: 'field-label' }, label), input);
|
||||
}
|
||||
|
||||
function rentalBlock(title, dateInput, timeInput, locInput) {
|
||||
return el(
|
||||
'div',
|
||||
{ class: 'rental-block' },
|
||||
el('h4', { class: 'rental-block-title' }, title),
|
||||
el('div', { class: 'form-row' },
|
||||
field('Date', dateInput), field('Time', timeInput)),
|
||||
field('Location', locInput),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
// Flight-route builder used inside the day editor for flight entries.
|
||||
// Provides a quick "CNX-BKK-DXB-FRA" expander plus editable per-leg rows with
|
||||
// airport-code autocomplete backed by GET /api/airports. Kept as its own
|
||||
// module so dayEditor.js stays small.
|
||||
import { el, clear, toast } from '../dom.js';
|
||||
import { api } from '../api.js';
|
||||
import { formatTimeRange } from '../format.js';
|
||||
|
||||
const CODE_RE = /^[A-Z0-9]{2,4}$/;
|
||||
|
||||
// Per-segment display lines for an entry row: "TG103 CNX→BKK 10:30–11:45".
|
||||
export function renderSegmentLines(segments) {
|
||||
return el(
|
||||
'div',
|
||||
{ class: 'entry-segments' },
|
||||
...segments.map((s) => {
|
||||
const legTime = formatTimeRange(s.dep_time, s.arr_time);
|
||||
const codes = `${s.from?.code || '?'}→${s.to?.code || '?'}`;
|
||||
return el(
|
||||
'div',
|
||||
{ class: 'entry-seg muted' },
|
||||
s.flight_no ? el('span', { class: 'seg-flight' }, s.flight_no) : null,
|
||||
el('span', {}, codes),
|
||||
legTime ? el('span', {}, legTime) : null,
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// createFlightRoute({ initialSegments, onChange }) ->
|
||||
// { node, read(), hasSegments() }
|
||||
// read() returns { segments: [...] | null } or { error: 'message' }.
|
||||
export function createFlightRoute({ initialSegments = null, onChange = () => {} } = {}) {
|
||||
const rows = []; // { state:{flight_no,dep_time,arr_time,from,to}, node, refs }
|
||||
const listEl = el('div', { class: 'seg-list' });
|
||||
|
||||
const quickInput = el('input', {
|
||||
class: 'input seg-quick-input',
|
||||
type: 'text',
|
||||
placeholder: 'CNX-BKK-DXB-FRA',
|
||||
autocomplete: 'off',
|
||||
spellcheck: 'false',
|
||||
'aria-label': 'Airport code chain',
|
||||
});
|
||||
const buildBtn = el('button', { class: 'btn btn-sm', type: 'button' }, 'Build route');
|
||||
buildBtn.addEventListener('click', () => buildFromChain(quickInput.value));
|
||||
|
||||
const addBtn = el('button', { class: 'btn btn-sm btn-ghost', type: 'button' }, '+ Add leg');
|
||||
addBtn.addEventListener('click', () => {
|
||||
const prev = rows[rows.length - 1];
|
||||
addRow(prev ? { from: { ...prev.state.to } } : {});
|
||||
renumber();
|
||||
onChange();
|
||||
});
|
||||
|
||||
const node = el(
|
||||
'div',
|
||||
{ class: 'flight-route' },
|
||||
el('div', { class: 'seg-quick' },
|
||||
el('label', { class: 'field field-grow' },
|
||||
el('span', { class: 'field-label' }, 'Quick route (airport codes)'),
|
||||
quickInput),
|
||||
buildBtn),
|
||||
listEl,
|
||||
addBtn,
|
||||
);
|
||||
|
||||
function emptyAirport() {
|
||||
return { code: '', name: '', lat: null, lng: null };
|
||||
}
|
||||
|
||||
function addRow(seg = {}) {
|
||||
const state = {
|
||||
flight_no: seg.flight_no || '',
|
||||
dep_time: seg.dep_time || '',
|
||||
arr_time: seg.arr_time || '',
|
||||
from: { ...emptyAirport(), ...(seg.from || {}) },
|
||||
to: { ...emptyAirport(), ...(seg.to || {}) },
|
||||
};
|
||||
const { rowNode, refs } = buildRowDom(state);
|
||||
const row = { state, node: rowNode, refs };
|
||||
rows.push(row);
|
||||
listEl.appendChild(rowNode);
|
||||
refreshRowLabels(row);
|
||||
return row;
|
||||
}
|
||||
|
||||
function removeRow(row) {
|
||||
const i = rows.indexOf(row);
|
||||
if (i === -1) return;
|
||||
rows.splice(i, 1);
|
||||
row.node.remove();
|
||||
renumber();
|
||||
onChange();
|
||||
}
|
||||
|
||||
function renumber() {
|
||||
rows.forEach((r, i) => { r.refs.num.textContent = String(i + 1); });
|
||||
}
|
||||
|
||||
function buildRowDom(state) {
|
||||
const num = el('span', { class: 'seg-num' }, '1');
|
||||
const flightNo = el('input', { class: 'input input-sm seg-flightno', type: 'text', maxlength: '12', placeholder: 'Flight no.', value: state.flight_no });
|
||||
flightNo.addEventListener('input', () => { state.flight_no = flightNo.value; });
|
||||
|
||||
const from = buildAirportField(state.from, 'From');
|
||||
const to = buildAirportField(state.to, 'To');
|
||||
|
||||
const dep = el('input', { class: 'input input-sm', type: 'time', 'aria-label': 'Departure time' });
|
||||
dep.value = state.dep_time || '';
|
||||
dep.addEventListener('input', () => { state.dep_time = dep.value; });
|
||||
const arr = el('input', { class: 'input input-sm', type: 'time', 'aria-label': 'Arrival time' });
|
||||
arr.value = state.arr_time || '';
|
||||
arr.addEventListener('input', () => { state.arr_time = arr.value; });
|
||||
|
||||
const removeBtn = el('button', { class: 'icon-btn danger', type: 'button', title: 'Remove leg' }, '×');
|
||||
|
||||
const rowNode = el(
|
||||
'div',
|
||||
{ class: 'seg-row' },
|
||||
el('div', { class: 'seg-row-head' }, num, flightNo, removeBtn),
|
||||
el('div', { class: 'seg-airports' }, from.field, el('span', { class: 'seg-arrow' }, '→'), to.field),
|
||||
el('div', { class: 'seg-times' },
|
||||
el('label', { class: 'seg-time' }, el('span', {}, 'Dep'), dep),
|
||||
el('label', { class: 'seg-time' }, el('span', {}, 'Arr'), arr)),
|
||||
);
|
||||
|
||||
const refs = { num, from, to };
|
||||
removeBtn.addEventListener('click', () => {
|
||||
const row = rows.find((r) => r.node === rowNode);
|
||||
if (row) removeRow(row);
|
||||
});
|
||||
return { rowNode, refs };
|
||||
}
|
||||
|
||||
// One airport code input + autocomplete dropdown + resolved-name line.
|
||||
function buildAirportField(airportState, label) {
|
||||
const input = el('input', {
|
||||
class: 'input input-sm seg-code',
|
||||
type: 'text',
|
||||
maxlength: '4',
|
||||
placeholder: label === 'From' ? 'From (e.g. CNX)' : 'To (e.g. BKK)',
|
||||
autocomplete: 'off',
|
||||
spellcheck: 'false',
|
||||
'aria-label': `${label} airport code`,
|
||||
});
|
||||
input.value = airportState.code || '';
|
||||
const results = el('div', { class: 'ap-results' });
|
||||
const nameLine = el('span', { class: 'ap-name' });
|
||||
const field = el('div', { class: 'seg-ap' }, input, results, nameLine);
|
||||
|
||||
let timer = null;
|
||||
let seq = 0;
|
||||
input.addEventListener('input', () => {
|
||||
const val = input.value.toUpperCase();
|
||||
// Manual edit clears any previously resolved coordinates.
|
||||
airportState.code = val;
|
||||
airportState.name = '';
|
||||
airportState.lat = null;
|
||||
airportState.lng = null;
|
||||
setNameLine(nameLine, airportState);
|
||||
clearTimeout(timer);
|
||||
const q = input.value.trim();
|
||||
if (q.length < 2) { clear(results); return; }
|
||||
timer = setTimeout(async () => {
|
||||
const mine = ++seq;
|
||||
try {
|
||||
const data = await api.airports(q);
|
||||
if (mine !== seq) return;
|
||||
showAirportResults(results, data.results || [], (ap) => {
|
||||
applyAirport(airportState, ap);
|
||||
input.value = ap.code;
|
||||
clear(results);
|
||||
setNameLine(nameLine, airportState);
|
||||
onChange();
|
||||
});
|
||||
} catch (err) {
|
||||
if (mine !== seq) return;
|
||||
clear(results);
|
||||
}
|
||||
}, 300);
|
||||
});
|
||||
|
||||
return { field, input, nameLine, state: airportState };
|
||||
}
|
||||
|
||||
function refreshRowLabels(row) {
|
||||
setNameLine(row.refs.from.nameLine, row.state.from);
|
||||
setNameLine(row.refs.to.nameLine, row.state.to);
|
||||
}
|
||||
|
||||
// Quick-build: split codes, make N-1 legs, resolve each code to coords.
|
||||
async function buildFromChain(raw) {
|
||||
const codes = String(raw || '')
|
||||
.split(/[\s,>\-]+/)
|
||||
.map((s) => s.trim().toUpperCase())
|
||||
.filter(Boolean);
|
||||
if (codes.length < 2) {
|
||||
toast('Enter at least two airport codes, e.g. CNX-BKK');
|
||||
return;
|
||||
}
|
||||
if (codes.length - 1 > 8) {
|
||||
toast('A flight can have at most 8 legs.');
|
||||
return;
|
||||
}
|
||||
// Replace existing rows.
|
||||
rows.slice().forEach((r) => { r.node.remove(); });
|
||||
rows.length = 0;
|
||||
for (let i = 0; i < codes.length - 1; i++) {
|
||||
addRow({ from: { code: codes[i] }, to: { code: codes[i + 1] } });
|
||||
}
|
||||
renumber();
|
||||
onChange();
|
||||
|
||||
// Resolve unique codes once, then apply to every matching field.
|
||||
const unique = [...new Set(codes)];
|
||||
const resolved = {};
|
||||
await Promise.all(unique.map(async (code) => {
|
||||
resolved[code] = await resolveCode(code);
|
||||
}));
|
||||
for (const row of rows) {
|
||||
applyResolved(row.state.from, resolved[row.state.from.code], row.refs.from);
|
||||
applyResolved(row.state.to, resolved[row.state.to.code], row.refs.to);
|
||||
}
|
||||
onChange();
|
||||
}
|
||||
|
||||
function applyResolved(airportState, ap, ref) {
|
||||
if (ap) {
|
||||
applyAirport(airportState, ap);
|
||||
ref.input.value = ap.code;
|
||||
ref.field.classList.remove('ap-unresolved');
|
||||
} else {
|
||||
// Keep the typed code but flag that it has no coordinates.
|
||||
ref.field.classList.add('ap-unresolved');
|
||||
}
|
||||
setNameLine(ref.nameLine, airportState);
|
||||
}
|
||||
|
||||
async function resolveCode(code) {
|
||||
try {
|
||||
const data = await api.airports(code);
|
||||
const list = data.results || [];
|
||||
return list.find((r) => (r.code || '').toUpperCase() === code) || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function read() {
|
||||
if (rows.length === 0) return { segments: null };
|
||||
if (rows.length > 8) return { error: 'A flight can have at most 8 legs.' };
|
||||
const segments = [];
|
||||
for (const r of rows) {
|
||||
const fromCode = (r.state.from.code || '').trim().toUpperCase();
|
||||
const toCode = (r.state.to.code || '').trim().toUpperCase();
|
||||
if (!CODE_RE.test(fromCode) || !CODE_RE.test(toCode)) {
|
||||
return { error: 'Every leg needs a valid from and to airport code (2–4 characters).' };
|
||||
}
|
||||
const seg = { from: airportOut(r.state.from, fromCode), to: airportOut(r.state.to, toCode) };
|
||||
if (r.state.flight_no.trim()) seg.flight_no = r.state.flight_no.trim();
|
||||
if (r.state.dep_time) seg.dep_time = r.state.dep_time;
|
||||
if (r.state.arr_time) seg.arr_time = r.state.arr_time;
|
||||
segments.push(seg);
|
||||
}
|
||||
return { segments };
|
||||
}
|
||||
|
||||
// Replace all rows from a segments array (used when editing an entry).
|
||||
function load(segments) {
|
||||
rows.slice().forEach((r) => { r.node.remove(); });
|
||||
rows.length = 0;
|
||||
if (Array.isArray(segments)) {
|
||||
for (const seg of segments) addRow(seg);
|
||||
}
|
||||
renumber();
|
||||
onChange();
|
||||
}
|
||||
|
||||
// Prefill from an existing entry's segments (edit).
|
||||
if (Array.isArray(initialSegments)) {
|
||||
for (const seg of initialSegments) addRow(seg);
|
||||
renumber();
|
||||
}
|
||||
|
||||
return { node, read, load, hasSegments: () => rows.length > 0 };
|
||||
}
|
||||
|
||||
function applyAirport(airportState, ap) {
|
||||
airportState.code = (ap.code || '').toUpperCase();
|
||||
airportState.name = ap.name || '';
|
||||
airportState.lat = ap.lat != null ? ap.lat : null;
|
||||
airportState.lng = ap.lng != null ? ap.lng : null;
|
||||
}
|
||||
|
||||
function airportOut(airportState, code) {
|
||||
const out = { code };
|
||||
if (airportState.name) out.name = airportState.name;
|
||||
if (airportState.lat != null && airportState.lng != null) {
|
||||
out.lat = airportState.lat;
|
||||
out.lng = airportState.lng;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function setNameLine(nameLine, airportState) {
|
||||
if (airportState.name) {
|
||||
nameLine.textContent = airportState.name;
|
||||
nameLine.classList.remove('muted');
|
||||
} else if (airportState.code) {
|
||||
nameLine.textContent = 'no coordinates — pick from the list';
|
||||
nameLine.classList.add('muted');
|
||||
} else {
|
||||
nameLine.textContent = '';
|
||||
}
|
||||
}
|
||||
|
||||
function showAirportResults(box, results, onPick) {
|
||||
clear(box);
|
||||
if (!results.length) {
|
||||
box.appendChild(el('div', { class: 'ap-empty muted' }, 'No airports found'));
|
||||
return;
|
||||
}
|
||||
for (const r of results) {
|
||||
const meta = [r.city, r.country].filter(Boolean).join(', ');
|
||||
box.appendChild(
|
||||
el('button', { class: 'ap-result', type: 'button', onClick: () => onPick(r) },
|
||||
el('span', { class: 'ap-code' }, r.code),
|
||||
el('span', { class: 'ap-desc' }, `${r.name}${meta ? ` — ${meta}` : ''}`)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
// Summary panel built from the /route response's `summary` block:
|
||||
// days, nights, flights, hotels, travel legs, activities, total km, and the
|
||||
// list of locations in visit order.
|
||||
import { el } from '../dom.js';
|
||||
|
||||
export function renderSummary(tctx) {
|
||||
const route = tctx.route || {};
|
||||
const s = route.summary || {
|
||||
days: 0, nights: 0, flights: 0, flightSegments: 0, hotels: 0, travelLegs: 0, activities: 0, locations: [],
|
||||
};
|
||||
const totalKm = route.totalKm || 0;
|
||||
// Show the leg count under the Flights tile only when a flight has segments.
|
||||
const flightSub = s.flightSegments > 0
|
||||
? `${s.flightSegments} ${s.flightSegments === 1 ? 'leg' : 'legs'}`
|
||||
: '';
|
||||
|
||||
const section = el('section', { class: 'card summary-section' });
|
||||
section.appendChild(
|
||||
el(
|
||||
'div',
|
||||
{ class: 'section-head' },
|
||||
el('h2', {}, 'Summary'),
|
||||
el('p', { class: 'muted' }, 'Trip at a glance.'),
|
||||
),
|
||||
);
|
||||
|
||||
const tiles = el(
|
||||
'div',
|
||||
{ class: 'stat-grid' },
|
||||
tile('🗓️', s.days, s.days === 1 ? 'Day' : 'Days'),
|
||||
tile('🌙', s.nights, s.nights === 1 ? 'Night' : 'Nights'),
|
||||
tile('✈️', s.flights, 'Flights', flightSub),
|
||||
tile('🏨', s.hotels, 'Hotels'),
|
||||
tile('🚗', s.travelLegs, 'Travel legs'),
|
||||
tile('📍', s.activities, 'Activities'),
|
||||
s.rentals > 0 ? tile('🚙', s.rentals, s.rentals === 1 ? 'Rental' : 'Rentals') : null,
|
||||
);
|
||||
section.appendChild(tiles);
|
||||
|
||||
const kmDriven = s.kmDriven || 0;
|
||||
const kmAir = s.kmAir || 0;
|
||||
const includedKm = s.includedKm != null ? s.includedKm : null;
|
||||
const kmBlock = el(
|
||||
'div',
|
||||
{ class: 'km-block' },
|
||||
el(
|
||||
'div',
|
||||
{ class: 'total-km' },
|
||||
el('span', { class: 'total-km-value' }, fmtKm(totalKm)),
|
||||
el('span', { class: 'total-km-label' }, 'km total (great-circle)'),
|
||||
),
|
||||
);
|
||||
const breakdown = el('div', { class: 'km-breakdown' });
|
||||
if (kmDriven > 0) {
|
||||
// Rental allowance (includedKm) is paired with the rough driven figure;
|
||||
// flag red when the rough estimate already exceeds the included allowance.
|
||||
const over = includedKm != null && kmDriven > includedKm;
|
||||
const drivenText = includedKm != null
|
||||
? `≈ ${fmtKm(kmDriven)} km / ${fmtKm(includedKm)} incl.`
|
||||
: `≈ ${fmtKm(kmDriven)} km`;
|
||||
breakdown.appendChild(kmRow('🚗', drivenText, 'driven',
|
||||
over
|
||||
? 'Rough great-circle estimate already exceeds the included allowance — real road km will be higher'
|
||||
: 'Rough great-circle estimate — not routed driving distance',
|
||||
over ? 'km-over' : ''));
|
||||
}
|
||||
if (kmAir > 0) {
|
||||
breakdown.appendChild(kmRow('✈️', `${fmtKm(kmAir)} km`, 'flown',
|
||||
'Great-circle distance between airports'));
|
||||
}
|
||||
if (breakdown.childElementCount) kmBlock.appendChild(breakdown);
|
||||
section.appendChild(kmBlock);
|
||||
|
||||
const locations = s.locations || [];
|
||||
const locBlock = el('div', { class: 'loc-block' }, el('h3', {}, 'Locations in order'));
|
||||
if (!locations.length) {
|
||||
locBlock.appendChild(el('p', { class: 'muted' }, 'No located stops yet.'));
|
||||
} else {
|
||||
const ol = el('ol', { class: 'loc-order' });
|
||||
for (const name of locations) ol.appendChild(el('li', {}, name));
|
||||
locBlock.appendChild(ol);
|
||||
}
|
||||
section.appendChild(locBlock);
|
||||
|
||||
return section;
|
||||
}
|
||||
|
||||
function kmRow(icon, valueText, label, title, cls = '') {
|
||||
return el(
|
||||
'div',
|
||||
{ class: `km-row${cls ? ` ${cls}` : ''}`, title },
|
||||
el('span', { class: 'km-row-icon' }, icon),
|
||||
el('span', { class: 'km-row-value' }, valueText),
|
||||
el('span', { class: 'km-row-label muted' }, label),
|
||||
);
|
||||
}
|
||||
|
||||
function tile(icon, value, label, sub) {
|
||||
return el(
|
||||
'div',
|
||||
{ class: 'stat-tile' },
|
||||
el('span', { class: 'stat-icon' }, icon),
|
||||
el('span', { class: 'stat-value' }, String(value ?? 0)),
|
||||
el('span', { class: 'stat-label' }, label),
|
||||
sub ? el('span', { class: 'stat-sub' }, sub) : null,
|
||||
);
|
||||
}
|
||||
|
||||
function fmtKm(n) {
|
||||
return (Math.round((Number(n) || 0) * 10) / 10).toLocaleString();
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
// Orchestrates the trip page: header + calendar, then map + summary.
|
||||
// Owns the shared trip context passed to the calendar, map, summary and
|
||||
// day-editor sub-views: { state, navigate, tripId, trip, route, refreshTrip,
|
||||
// openDay }. After any mutation, refreshTrip() re-fetches the trip and its
|
||||
// derived /route data so all three panels stay in sync.
|
||||
import { api } from '../api.js';
|
||||
import { el, clear, mount, loading, errorBox, toast } from '../dom.js';
|
||||
import { formatRange, daysBetweenInclusive, pluralize, groupCode } from '../format.js';
|
||||
import { renderCalendar } from './calendar.js';
|
||||
import { renderMap } from './map.js';
|
||||
import { renderSummary } from './summary.js';
|
||||
import { renderCosts } from './costs.js';
|
||||
import { renderCountdown } from './flipclock.js';
|
||||
import { openDayEditor } from './dayEditor.js';
|
||||
|
||||
export function renderTripDetail(container, ctx, id) {
|
||||
const tctx = {
|
||||
...ctx,
|
||||
tripId: id,
|
||||
trip: null,
|
||||
route: null,
|
||||
costs: null,
|
||||
_onModalRefresh: null,
|
||||
};
|
||||
|
||||
mount(container, loading('Loading trip…'));
|
||||
init();
|
||||
|
||||
async function load() {
|
||||
const [trip, route, costs] = await Promise.all([
|
||||
api.trips.get(id),
|
||||
api.trips.route(id),
|
||||
api.trips.costs(id),
|
||||
]);
|
||||
tctx.trip = trip;
|
||||
tctx.route = route;
|
||||
tctx.costs = costs;
|
||||
}
|
||||
|
||||
tctx.refreshTrip = async () => {
|
||||
try {
|
||||
await load();
|
||||
draw();
|
||||
if (typeof tctx._onModalRefresh === 'function') tctx._onModalRefresh();
|
||||
} catch (err) {
|
||||
toast(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
tctx.openDay = (date) => openDayEditor(tctx, date);
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
await load();
|
||||
draw();
|
||||
} catch (err) {
|
||||
clear(container);
|
||||
if (err.status === 404) {
|
||||
toast('Trip not found (or you are not a member).');
|
||||
ctx.navigate('#/trips');
|
||||
return;
|
||||
}
|
||||
mount(container, errorBox(err.message, init));
|
||||
}
|
||||
}
|
||||
|
||||
function draw() {
|
||||
const page = el('div', { class: 'page' });
|
||||
page.appendChild(renderHeader());
|
||||
page.appendChild(renderCountdown(tctx));
|
||||
page.appendChild(renderCalendar(tctx));
|
||||
page.appendChild(
|
||||
el(
|
||||
'div',
|
||||
{ class: 'detail-grid' },
|
||||
renderMap(tctx),
|
||||
el('div', { class: 'detail-side' }, renderSummary(tctx), renderCosts(tctx)),
|
||||
),
|
||||
);
|
||||
mount(container, page);
|
||||
}
|
||||
|
||||
function renderHeader() {
|
||||
const { trip, members } = tctx.trip;
|
||||
const isOwner = trip.owner_id === tctx.state.user.id;
|
||||
const dayCount = daysBetweenInclusive(trip.start_date, trip.end_date);
|
||||
|
||||
const header = el('div', { class: 'trip-header card' });
|
||||
|
||||
const titleRow = el(
|
||||
'div',
|
||||
{ class: 'trip-header-top' },
|
||||
el(
|
||||
'div',
|
||||
{},
|
||||
el(
|
||||
'a',
|
||||
{ class: 'back-link', href: '#/trips' },
|
||||
'← All trips',
|
||||
),
|
||||
el('h1', { class: 'trip-title' }, trip.name),
|
||||
el(
|
||||
'p',
|
||||
{ class: 'trip-subtitle muted' },
|
||||
'📅 ', formatRange(trip.start_date, trip.end_date),
|
||||
' · ', pluralize(dayCount, 'day', 'days'),
|
||||
' · ', el('span', { class: 'currency-tag' }, trip.currency || 'USD'),
|
||||
),
|
||||
),
|
||||
el(
|
||||
'div',
|
||||
{ class: 'trip-header-actions' },
|
||||
el('button', { class: 'btn btn-ghost', onClick: () => toggleEdit(header) }, '✎ Edit'),
|
||||
isOwner
|
||||
? el('button', { class: 'btn btn-danger-ghost', onClick: onDelete }, 'Delete')
|
||||
: null,
|
||||
),
|
||||
);
|
||||
|
||||
const membersRow = el(
|
||||
'div',
|
||||
{ class: 'members-row' },
|
||||
el('span', { class: 'members-label' }, 'Members:'),
|
||||
...members.map((m) =>
|
||||
el(
|
||||
'span',
|
||||
{ class: `member-chip${m.role === 'owner' ? ' member-owner' : ''}` },
|
||||
el('span', { class: 'member-avatar' }, (m.display_name || '?').charAt(0).toUpperCase()),
|
||||
m.display_name,
|
||||
isOwner && m.role !== 'owner'
|
||||
? el('button', {
|
||||
class: 'member-remove',
|
||||
title: `Remove ${m.display_name}`,
|
||||
onClick: () => onRemoveMember(m),
|
||||
}, '×')
|
||||
: null,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
header.appendChild(titleRow);
|
||||
header.appendChild(joinCodeRow(trip, isOwner));
|
||||
header.appendChild(membersRow);
|
||||
return header;
|
||||
}
|
||||
|
||||
// Share code for inviting others — display only, not a credential.
|
||||
function joinCodeRow(trip, isOwner) {
|
||||
const grouped = groupCode(trip.join_code, 4);
|
||||
const copyBtn = el('button', { class: 'btn btn-sm', type: 'button', title: 'Copy join code' }, '📋 Copy');
|
||||
copyBtn.addEventListener('click', async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(grouped);
|
||||
toast('Join code copied', 'success');
|
||||
} catch {
|
||||
toast('Copy failed — select and copy it manually');
|
||||
}
|
||||
});
|
||||
|
||||
async function onRegenerate() {
|
||||
if (!window.confirm('Regenerate the join code? The old code will stop working immediately.')) return;
|
||||
try {
|
||||
await api.trips.regenerateJoinCode(tctx.tripId);
|
||||
toast('Join code regenerated', 'success');
|
||||
await tctx.refreshTrip();
|
||||
} catch (err) {
|
||||
toast(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
return el(
|
||||
'div',
|
||||
{ class: 'joincode-row' },
|
||||
el('span', { class: 'members-label' }, 'Join code:'),
|
||||
el('span', { class: 'joincode-chip' }, grouped),
|
||||
copyBtn,
|
||||
isOwner
|
||||
? el('button', { class: 'btn btn-sm btn-ghost', type: 'button', onClick: onRegenerate }, '↻ Regenerate')
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
function toggleEdit(header) {
|
||||
const existing = header.querySelector('.trip-edit');
|
||||
if (existing) {
|
||||
existing.remove();
|
||||
return;
|
||||
}
|
||||
const { trip } = tctx.trip;
|
||||
const nameInput = el('input', { class: 'input', type: 'text', maxlength: '120', value: trip.name });
|
||||
const startInput = el('input', { class: 'input', type: 'date', value: trip.start_date });
|
||||
const endInput = el('input', { class: 'input', type: 'date', value: trip.end_date });
|
||||
const currencyInput = el('input', { class: 'input input-currency', type: 'text', maxlength: '3', value: trip.currency || 'USD', 'aria-label': 'Currency code' });
|
||||
const errorEl = el('p', { class: 'form-error' });
|
||||
const saveBtn = el('button', { class: 'btn btn-primary', type: 'submit' }, 'Save changes');
|
||||
|
||||
async function onSubmit(e) {
|
||||
e.preventDefault();
|
||||
errorEl.textContent = '';
|
||||
const name = nameInput.value.trim();
|
||||
const start_date = startInput.value;
|
||||
const end_date = endInput.value;
|
||||
const currency = currencyInput.value.trim().toUpperCase() || 'USD';
|
||||
if (!name) return (errorEl.textContent = 'Name cannot be empty.');
|
||||
if (end_date < start_date) return (errorEl.textContent = 'End date must be on or after the start date.');
|
||||
if (!/^[A-Z]{3}$/.test(currency)) return (errorEl.textContent = 'Currency must be a 3-letter code, e.g. USD.');
|
||||
saveBtn.disabled = true;
|
||||
saveBtn.textContent = 'Saving…';
|
||||
try {
|
||||
await api.trips.update(tctx.tripId, { name, start_date, end_date, currency });
|
||||
toast('Trip updated', 'success');
|
||||
await tctx.refreshTrip();
|
||||
} catch (err) {
|
||||
errorEl.textContent = err.message;
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.textContent = 'Save changes';
|
||||
}
|
||||
}
|
||||
|
||||
const editBox = el(
|
||||
'form',
|
||||
{ class: 'trip-edit', onSubmit },
|
||||
el(
|
||||
'div',
|
||||
{ class: 'form-row' },
|
||||
el('label', { class: 'field field-grow' }, el('span', { class: 'field-label' }, 'Name'), nameInput),
|
||||
),
|
||||
el(
|
||||
'div',
|
||||
{ class: 'form-row' },
|
||||
el('label', { class: 'field' }, el('span', { class: 'field-label' }, 'Start date'), startInput),
|
||||
el('label', { class: 'field' }, el('span', { class: 'field-label' }, 'End date'), endInput),
|
||||
el('label', { class: 'field field-currency' }, el('span', { class: 'field-label' }, 'Currency'), currencyInput),
|
||||
),
|
||||
el('p', { class: 'hint muted' }, 'Changing the range regenerates the calendar; entries outside the new range are kept and flagged.'),
|
||||
errorEl,
|
||||
el('div', { class: 'form-actions' }, saveBtn),
|
||||
);
|
||||
header.appendChild(editBox);
|
||||
}
|
||||
|
||||
async function onRemoveMember(member) {
|
||||
if (!window.confirm(`Remove ${member.display_name} from this trip?`)) return;
|
||||
try {
|
||||
await api.trips.removeMember(tctx.tripId, member.id);
|
||||
toast(`${member.display_name} removed`, 'success');
|
||||
await tctx.refreshTrip();
|
||||
} catch (err) {
|
||||
toast(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function onDelete() {
|
||||
const { trip } = tctx.trip;
|
||||
if (!window.confirm(`Delete "${trip.name}"? This removes all its entries and cannot be undone.`)) return;
|
||||
try {
|
||||
await api.trips.remove(tctx.tripId);
|
||||
toast('Trip deleted', 'success');
|
||||
ctx.navigate('#/trips');
|
||||
} catch (err) {
|
||||
toast(err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
// Trip list dashboard: cards, a "new trip" form, and a "join a trip" form.
|
||||
import { api } from '../api.js';
|
||||
import { el, mount, clear, loading, errorBox, emptyState, toast } from '../dom.js';
|
||||
import { formatRange, ymd, parseYMD, pluralize, normalizeCode } from '../format.js';
|
||||
|
||||
export function renderTrips(container, ctx) {
|
||||
mount(container, loading('Loading your trips…'));
|
||||
load();
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const data = await api.trips.list();
|
||||
draw(data.trips || []);
|
||||
} catch (err) {
|
||||
mount(container, errorBox(err.message, load));
|
||||
}
|
||||
}
|
||||
|
||||
function draw(trips) {
|
||||
const page = el('div', { class: 'page' });
|
||||
|
||||
// The form slot is created up front so every button below can capture a
|
||||
// fully-initialized reference (no reliance on declaration ordering).
|
||||
const formSlot = el('div', { class: 'form-slot' });
|
||||
|
||||
const newTripBtn = el('button', { class: 'btn btn-primary', onClick: () => openNewTrip(formSlot) }, '+ New trip');
|
||||
const joinBtn = el('button', { class: 'btn', onClick: () => openJoin(formSlot) }, 'Join a trip');
|
||||
|
||||
page.appendChild(
|
||||
el(
|
||||
'div',
|
||||
{ class: 'page-head' },
|
||||
el(
|
||||
'div',
|
||||
{},
|
||||
el('h1', {}, 'Your Trips'),
|
||||
el('p', { class: 'muted' }, trips.length
|
||||
? pluralize(trips.length, 'trip', 'trips')
|
||||
: 'Start planning your next adventure.'),
|
||||
),
|
||||
el('div', { class: 'page-head-actions' }, joinBtn, newTripBtn),
|
||||
),
|
||||
);
|
||||
|
||||
page.appendChild(formSlot);
|
||||
|
||||
if (!trips.length) {
|
||||
page.appendChild(
|
||||
emptyState(
|
||||
'No trips yet',
|
||||
'Create your first trip, or join one with a code a friend shared.',
|
||||
el('div', { class: 'empty-actions' },
|
||||
el('button', { class: 'btn btn-primary', onClick: () => openNewTrip(formSlot) }, '+ New trip'),
|
||||
el('button', { class: 'btn', onClick: () => openJoin(formSlot) }, 'Join a trip'),
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
const grid = el('div', { class: 'trip-grid' });
|
||||
for (const trip of trips) grid.appendChild(tripCard(trip));
|
||||
page.appendChild(grid);
|
||||
}
|
||||
|
||||
mount(container, page);
|
||||
}
|
||||
|
||||
function tripCard(trip) {
|
||||
const today = ymd(new Date());
|
||||
const future = trip.start_date > today;
|
||||
const daysUntil = future
|
||||
? Math.round((parseYMD(trip.start_date) - parseYMD(today)) / 86400000)
|
||||
: 0;
|
||||
return el(
|
||||
'a',
|
||||
{ class: 'trip-card card', href: `#/trip/${trip.id}` },
|
||||
el(
|
||||
'div',
|
||||
{ class: 'trip-card-top' },
|
||||
el('h3', { class: 'trip-card-name' }, trip.name),
|
||||
el('span', { class: `role-badge role-${trip.role}` }, trip.role),
|
||||
),
|
||||
el(
|
||||
'div',
|
||||
{ class: 'trip-card-dates' },
|
||||
'📅 ', formatRange(trip.start_date, trip.end_date),
|
||||
future ? el('span', { class: 'trip-card-countdown' }, `in ${daysUntil} ${daysUntil === 1 ? 'day' : 'days'}`) : null,
|
||||
),
|
||||
el(
|
||||
'div',
|
||||
{ class: 'trip-card-meta' },
|
||||
el('span', {}, '👥 ', pluralize(trip.member_count ?? 1, 'member', 'members')),
|
||||
el('span', {}, '📝 ', pluralize(trip.entry_count ?? 0, 'entry', 'entries')),
|
||||
el('span', { class: 'trip-card-currency' }, trip.currency || 'USD'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Render `build()` into the slot, or close it if the same panel is open.
|
||||
function togglePanel(slot, kind, build) {
|
||||
if (slot.dataset.open === kind) {
|
||||
clear(slot);
|
||||
slot.dataset.open = '';
|
||||
return;
|
||||
}
|
||||
mount(slot, build());
|
||||
slot.dataset.open = kind;
|
||||
}
|
||||
|
||||
function openNewTrip(slot) {
|
||||
togglePanel(slot, 'new', () => newTripForm(slot));
|
||||
}
|
||||
|
||||
function openJoin(slot) {
|
||||
togglePanel(slot, 'join', () => joinForm(slot));
|
||||
}
|
||||
|
||||
function closeSlot(slot) {
|
||||
clear(slot);
|
||||
slot.dataset.open = '';
|
||||
}
|
||||
|
||||
function newTripForm(slot) {
|
||||
const today = ymd(new Date());
|
||||
const nameInput = el('input', { class: 'input', type: 'text', maxlength: '120', placeholder: 'e.g. Northern Thailand' });
|
||||
const startInput = el('input', { class: 'input', type: 'date', value: today });
|
||||
const endInput = el('input', { class: 'input', type: 'date', value: today });
|
||||
const currencyInput = el('input', { class: 'input input-currency', type: 'text', maxlength: '3', value: 'USD', placeholder: 'USD', 'aria-label': 'Currency code' });
|
||||
const errorEl = el('p', { class: 'form-error' });
|
||||
const submitBtn = el('button', { class: 'btn btn-primary', type: 'submit' }, 'Create trip');
|
||||
|
||||
async function onSubmit(e) {
|
||||
e.preventDefault();
|
||||
errorEl.textContent = '';
|
||||
const name = nameInput.value.trim();
|
||||
const start_date = startInput.value;
|
||||
const end_date = endInput.value;
|
||||
const currency = currencyInput.value.trim().toUpperCase() || 'USD';
|
||||
if (!name) return (errorEl.textContent = 'Please give the trip a name.');
|
||||
if (!start_date || !end_date) return (errorEl.textContent = 'Pick a start and end date.');
|
||||
if (end_date < start_date) return (errorEl.textContent = 'End date must be on or after the start date.');
|
||||
if (!/^[A-Z]{3}$/.test(currency)) return (errorEl.textContent = 'Currency must be a 3-letter code, e.g. USD.');
|
||||
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Creating…';
|
||||
try {
|
||||
const data = await api.trips.create({ name, start_date, end_date, currency });
|
||||
toast('Trip created', 'success');
|
||||
ctx.navigate(`#/trip/${data.trip.id}`);
|
||||
} catch (err) {
|
||||
errorEl.textContent = err.message;
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Create trip';
|
||||
}
|
||||
}
|
||||
|
||||
const form = el(
|
||||
'form',
|
||||
{ class: 'card new-trip-form', onSubmit },
|
||||
el('h3', {}, 'New trip'),
|
||||
el('div', { class: 'form-row' },
|
||||
el('label', { class: 'field field-grow' }, el('span', { class: 'field-label' }, 'Name'), nameInput)),
|
||||
el('div', { class: 'form-row' },
|
||||
el('label', { class: 'field' }, el('span', { class: 'field-label' }, 'Start date'), startInput),
|
||||
el('label', { class: 'field' }, el('span', { class: 'field-label' }, 'End date'), endInput),
|
||||
el('label', { class: 'field field-currency' }, el('span', { class: 'field-label' }, 'Currency'), currencyInput)),
|
||||
errorEl,
|
||||
el('div', { class: 'form-actions' },
|
||||
el('button', { class: 'btn btn-ghost', type: 'button', onClick: () => closeSlot(slot) }, 'Cancel'),
|
||||
submitBtn),
|
||||
);
|
||||
setTimeout(() => nameInput.focus(), 0);
|
||||
return form;
|
||||
}
|
||||
|
||||
function joinForm(slot) {
|
||||
const codeInput = el('input', {
|
||||
class: 'input token-input',
|
||||
type: 'text',
|
||||
autocomplete: 'off',
|
||||
autocapitalize: 'characters',
|
||||
spellcheck: 'false',
|
||||
placeholder: 'XXXX-XXXX',
|
||||
'aria-label': 'Join code',
|
||||
});
|
||||
const errorEl = el('p', { class: 'form-error' });
|
||||
const submitBtn = el('button', { class: 'btn btn-primary', type: 'submit' }, 'Join trip');
|
||||
|
||||
async function onSubmit(e) {
|
||||
e.preventDefault();
|
||||
errorEl.textContent = '';
|
||||
const code = normalizeCode(codeInput.value);
|
||||
if (code.length < 4) return (errorEl.textContent = 'Enter the join code your friend shared.');
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Joining…';
|
||||
try {
|
||||
const data = await api.trips.join(code);
|
||||
toast('Joined trip', 'success');
|
||||
ctx.navigate(`#/trip/${data.trip.id}`);
|
||||
} catch (err) {
|
||||
errorEl.textContent = err.status === 404 ? 'No trip found for that code.' : err.message;
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Join trip';
|
||||
}
|
||||
}
|
||||
|
||||
const form = el(
|
||||
'form',
|
||||
{ class: 'card join-form', onSubmit },
|
||||
el('h3', {}, 'Join a trip'),
|
||||
el('p', { class: 'muted' }, 'Paste the join code from a trip you were invited to.'),
|
||||
el('div', { class: 'form-row' },
|
||||
el('label', { class: 'field field-grow' }, el('span', { class: 'field-label' }, 'Join code'), codeInput)),
|
||||
errorEl,
|
||||
el('div', { class: 'form-actions' },
|
||||
el('button', { class: 'btn btn-ghost', type: 'button', onClick: () => closeSlot(slot) }, 'Cancel'),
|
||||
submitBtn),
|
||||
);
|
||||
setTimeout(() => codeInput.focus(), 0);
|
||||
return form;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// Regenerate src/server/data/airports.json from the public-domain OurAirports
|
||||
// dataset. Usage: node scripts/generate-airports.mjs
|
||||
//
|
||||
// Keeps airports that have an IATA code AND scheduled service, projecting each
|
||||
// to { code, name, city, country, lat, lng }.
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const SOURCE_URL = 'https://davidmegginson.github.io/ourairports-data/airports.csv';
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const OUT_PATH = path.join(__dirname, '..', 'src', 'server', 'data', 'airports.json');
|
||||
|
||||
// Minimal RFC-4180 CSV parser (handles quotes, escaped quotes, embedded commas/newlines).
|
||||
function parseCsv(str) {
|
||||
const rows = [];
|
||||
let row = [];
|
||||
let field = '';
|
||||
let inQuotes = false;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const c = str[i];
|
||||
if (inQuotes) {
|
||||
if (c === '"') {
|
||||
if (str[i + 1] === '"') { field += '"'; i++; }
|
||||
else inQuotes = false;
|
||||
} else field += c;
|
||||
} else if (c === '"') inQuotes = true;
|
||||
else if (c === ',') { row.push(field); field = ''; }
|
||||
else if (c === '\n') { row.push(field); rows.push(row); row = []; field = ''; }
|
||||
else if (c === '\r') { /* ignore */ }
|
||||
else field += c;
|
||||
}
|
||||
if (field.length || row.length) { row.push(field); rows.push(row); }
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`Downloading ${SOURCE_URL} ...`);
|
||||
const res = await fetch(SOURCE_URL, { headers: { 'User-Agent': 'trip-plan-app/0.1 (self-hosted)' } });
|
||||
if (!res.ok) throw new Error(`download failed: HTTP ${res.status}`);
|
||||
const text = await res.text();
|
||||
|
||||
const rows = parseCsv(text);
|
||||
const header = rows[0];
|
||||
const col = (name) => header.indexOf(name);
|
||||
const iIata = col('iata_code');
|
||||
const iSched = col('scheduled_service');
|
||||
const iName = col('name');
|
||||
const iCity = col('municipality');
|
||||
const iCountry = col('iso_country');
|
||||
const iLat = col('latitude_deg');
|
||||
const iLng = col('longitude_deg');
|
||||
|
||||
const airports = [];
|
||||
for (let r = 1; r < rows.length; r++) {
|
||||
const row = rows[r];
|
||||
if (!row || row.length < header.length) continue;
|
||||
const code = (row[iIata] || '').trim();
|
||||
if (!code) continue;
|
||||
if ((row[iSched] || '').trim() !== 'yes') continue;
|
||||
const lat = Number(row[iLat]);
|
||||
const lng = Number(row[iLng]);
|
||||
airports.push({
|
||||
code,
|
||||
name: (row[iName] || '').trim(),
|
||||
city: (row[iCity] || '').trim(),
|
||||
country: (row[iCountry] || '').trim(),
|
||||
lat: Number.isFinite(lat) ? lat : null,
|
||||
lng: Number.isFinite(lng) ? lng : null,
|
||||
});
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.dirname(OUT_PATH), { recursive: true });
|
||||
fs.writeFileSync(OUT_PATH, JSON.stringify(airports));
|
||||
const bytes = fs.statSync(OUT_PATH).size;
|
||||
console.log(`Wrote ${airports.length} airports to ${OUT_PATH} (${bytes} bytes)`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import express from 'express';
|
||||
import cookieSession from 'cookie-session';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { openDb } from './db.js';
|
||||
import { requireAuth } from './auth.js';
|
||||
import authRoutes from './routes/auth.js';
|
||||
import tripsRoutes from './routes/trips.js';
|
||||
import entriesRoutes from './routes/entries.js';
|
||||
import geocodeRoutes from './routes/geocode.js';
|
||||
import airportsRoutes from './routes/airports.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
|
||||
|
||||
// Build the Express app. Accepts either an options object
|
||||
// { dbPath, sessionSecret } or a bare dbPath string (per API.md).
|
||||
export function createApp(options = {}) {
|
||||
const opts = typeof options === 'string' ? { dbPath: options } : options;
|
||||
const dbPath = opts.dbPath || ':memory:';
|
||||
const sessionSecret = opts.sessionSecret || 'dev-secret';
|
||||
|
||||
const db = openDb(dbPath);
|
||||
const app = express();
|
||||
app.disable('x-powered-by');
|
||||
|
||||
app.use(express.json());
|
||||
app.use(
|
||||
cookieSession({
|
||||
name: 'trip_session',
|
||||
secret: sessionSecret,
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
maxAge: 30 * 24 * 60 * 60 * 1000,
|
||||
})
|
||||
);
|
||||
|
||||
// no-cache (not no-store): browsers must revalidate, ETags turn unchanged
|
||||
// files into 304s. Without this, upgraded deployments keep serving a stale
|
||||
// cached frontend against the new API.
|
||||
app.use(
|
||||
express.static(PUBLIC_DIR, {
|
||||
etag: true,
|
||||
lastModified: true,
|
||||
setHeaders: (res) => res.setHeader('Cache-Control', 'no-cache'),
|
||||
})
|
||||
);
|
||||
|
||||
app.use('/api/auth', authRoutes(db));
|
||||
app.use('/api/trips', requireAuth, tripsRoutes(db));
|
||||
app.use('/api', requireAuth, entriesRoutes(db)); // /trips/:id/entries + /entries/:id
|
||||
app.use('/api/geocode', requireAuth, geocodeRoutes(db));
|
||||
app.use('/api/airports', requireAuth, airportsRoutes());
|
||||
|
||||
// JSON 404 for any unmatched /api route.
|
||||
app.use('/api', (req, res) => {
|
||||
res.status(404).json({ error: 'not found' });
|
||||
});
|
||||
|
||||
// JSON error handler (malformed body, unexpected failures).
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
app.use((err, req, res, next) => {
|
||||
if (err && err.type === 'entity.parse.failed') {
|
||||
return res.status(400).json({ error: 'invalid JSON body' });
|
||||
}
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'internal server error' });
|
||||
});
|
||||
|
||||
app.locals.db = db;
|
||||
return app;
|
||||
}
|
||||
|
||||
export default createApp;
|
||||
@@ -0,0 +1,8 @@
|
||||
// Session guard: rejects unauthenticated requests with 401 JSON.
|
||||
// Identity is Mullvad-style (account tokens); token hashing lives in util/token.js.
|
||||
export function requireAuth(req, res, next) {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'unauthorized' });
|
||||
}
|
||||
next();
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,88 @@
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
const SCHEMA = `
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY,
|
||||
token_hash TEXT UNIQUE NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT current_timestamp
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS trips (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
start_date TEXT NOT NULL,
|
||||
end_date TEXT NOT NULL,
|
||||
owner_id INTEGER NOT NULL REFERENCES users(id),
|
||||
currency TEXT NOT NULL DEFAULT 'USD',
|
||||
join_code TEXT UNIQUE NOT NULL,
|
||||
created_at TEXT DEFAULT current_timestamp
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS trip_members (
|
||||
trip_id INTEGER NOT NULL REFERENCES trips(id),
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
role TEXT NOT NULL DEFAULT 'editor',
|
||||
PRIMARY KEY (trip_id, user_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS entries (
|
||||
id INTEGER PRIMARY KEY,
|
||||
trip_id INTEGER NOT NULL REFERENCES trips(id),
|
||||
date TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
details TEXT DEFAULT '',
|
||||
start_time TEXT,
|
||||
end_time TEXT,
|
||||
location_name TEXT,
|
||||
lat REAL,
|
||||
lng REAL,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
price REAL,
|
||||
paid_by INTEGER REFERENCES users(id),
|
||||
split_mode TEXT NOT NULL DEFAULT 'equal',
|
||||
segments TEXT,
|
||||
rental TEXT,
|
||||
created_at TEXT DEFAULT current_timestamp
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS entry_participants (
|
||||
entry_id INTEGER NOT NULL REFERENCES entries(id),
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
PRIMARY KEY (entry_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_entries_trip ON entries(trip_id, date, sort_order, id);
|
||||
CREATE INDEX IF NOT EXISTS idx_members_user ON trip_members(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_participants_entry ON entry_participants(entry_id);
|
||||
`;
|
||||
|
||||
// Columns added after the initial release: CREATE TABLE IF NOT EXISTS never
|
||||
// alters existing tables, so databases created by an older version need them
|
||||
// backfilled with ALTER TABLE.
|
||||
const MIGRATIONS = [
|
||||
{ table: 'trips', column: 'currency', ddl: "ALTER TABLE trips ADD COLUMN currency TEXT NOT NULL DEFAULT 'USD'" },
|
||||
{ table: 'entries', column: 'price', ddl: 'ALTER TABLE entries ADD COLUMN price REAL' },
|
||||
{ table: 'entries', column: 'paid_by', ddl: 'ALTER TABLE entries ADD COLUMN paid_by INTEGER REFERENCES users(id)' },
|
||||
{ table: 'entries', column: 'split_mode', ddl: "ALTER TABLE entries ADD COLUMN split_mode TEXT NOT NULL DEFAULT 'equal'" },
|
||||
{ table: 'entries', column: 'segments', ddl: 'ALTER TABLE entries ADD COLUMN segments TEXT' },
|
||||
{ table: 'entries', column: 'rental', ddl: 'ALTER TABLE entries ADD COLUMN rental TEXT' },
|
||||
];
|
||||
|
||||
function applyMigrations(db) {
|
||||
for (const { table, column, ddl } of MIGRATIONS) {
|
||||
const columns = db.prepare(`PRAGMA table_info(${table})`).all();
|
||||
if (!columns.some((c) => c.name === column)) db.exec(ddl);
|
||||
}
|
||||
}
|
||||
|
||||
// Open (or create) the SQLite database at dbPath and ensure the schema exists.
|
||||
export function openDb(dbPath) {
|
||||
const db = new Database(dbPath);
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
db.exec(SCHEMA);
|
||||
applyMigrations(db);
|
||||
return db;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { createApp } from './app.js';
|
||||
|
||||
const PORT = Number(process.env.PORT) || 3000;
|
||||
const DATA_DIR = process.env.DATA_DIR || './data';
|
||||
|
||||
let sessionSecret = process.env.SESSION_SECRET;
|
||||
if (!sessionSecret) {
|
||||
sessionSecret = 'trip-plan-dev-secret-change-me';
|
||||
console.warn(
|
||||
'[trip-plan] SESSION_SECRET is not set; using an insecure development default. ' +
|
||||
'Set SESSION_SECRET in production.'
|
||||
);
|
||||
}
|
||||
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
const dbPath = path.join(DATA_DIR, 'trip-plan.db');
|
||||
|
||||
const app = createApp({ dbPath, sessionSecret });
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`[trip-plan] listening on http://localhost:${PORT}`);
|
||||
console.log(`[trip-plan] data directory: ${path.resolve(DATA_DIR)}`);
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import express from 'express';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const DATA_PATH = path.join(__dirname, '..', 'data', 'airports.json');
|
||||
const MAX_RESULTS = 8;
|
||||
|
||||
// Loaded once at startup (bundled, public-domain OurAirports subset).
|
||||
const AIRPORTS = JSON.parse(fs.readFileSync(DATA_PATH, 'utf8'));
|
||||
|
||||
export default function airportsRoutes() {
|
||||
const router = express.Router();
|
||||
|
||||
// GET /api/airports?q=<query>
|
||||
router.get('/', (req, res) => {
|
||||
const q = typeof req.query.q === 'string' ? req.query.q.trim() : '';
|
||||
if (!q) return res.status(200).json({ results: [] });
|
||||
const ql = q.toLowerCase();
|
||||
|
||||
// Rank: exact IATA, then IATA prefix, then name/city substring.
|
||||
const exact = [];
|
||||
const prefix = [];
|
||||
const substring = [];
|
||||
for (const a of AIRPORTS) {
|
||||
const code = a.code.toLowerCase();
|
||||
if (code === ql) {
|
||||
exact.push(a);
|
||||
} else if (code.startsWith(ql)) {
|
||||
prefix.push(a);
|
||||
} else if (
|
||||
(a.name && a.name.toLowerCase().includes(ql)) ||
|
||||
(a.city && a.city.toLowerCase().includes(ql))
|
||||
) {
|
||||
substring.push(a);
|
||||
}
|
||||
if (exact.length >= MAX_RESULTS) break;
|
||||
}
|
||||
|
||||
const results = [...exact, ...prefix, ...substring].slice(0, MAX_RESULTS);
|
||||
res.status(200).json({ results });
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import express from 'express';
|
||||
import { requireAuth } from '../auth.js';
|
||||
import {
|
||||
generateAccountToken,
|
||||
formatToken,
|
||||
hashToken,
|
||||
} from '../util/token.js';
|
||||
import { generateDisplayName } from '../util/names.js';
|
||||
|
||||
export default function authRoutes(db) {
|
||||
const router = express.Router();
|
||||
|
||||
const insertUser = db.prepare(
|
||||
'INSERT INTO users (token_hash, display_name) VALUES (?, ?)'
|
||||
);
|
||||
const findByHash = db.prepare(
|
||||
'SELECT id, display_name FROM users WHERE token_hash = ?'
|
||||
);
|
||||
const findById = db.prepare('SELECT id, display_name FROM users WHERE id = ?');
|
||||
const hashExists = db.prepare('SELECT 1 FROM users WHERE token_hash = ?');
|
||||
|
||||
// POST /api/auth/account — create an account, log in, return the raw token once.
|
||||
router.post('/account', (req, res) => {
|
||||
let raw;
|
||||
let hash;
|
||||
do {
|
||||
raw = generateAccountToken();
|
||||
hash = hashToken(raw);
|
||||
} while (hashExists.get(hash));
|
||||
|
||||
const displayName = generateDisplayName();
|
||||
const info = insertUser.run(hash, displayName);
|
||||
const user = { id: Number(info.lastInsertRowid), display_name: displayName };
|
||||
req.session.userId = user.id;
|
||||
res.status(201).json({ user, token: formatToken(raw) });
|
||||
});
|
||||
|
||||
// POST /api/auth/login — { token }
|
||||
router.post('/login', (req, res) => {
|
||||
const { token } = req.body || {};
|
||||
if (typeof token !== 'string' || token.trim() === '') {
|
||||
return res.status(401).json({ error: 'invalid token' });
|
||||
}
|
||||
const user = findByHash.get(hashToken(token));
|
||||
if (!user) return res.status(401).json({ error: 'invalid token' });
|
||||
req.session.userId = user.id;
|
||||
res.status(200).json({ user });
|
||||
});
|
||||
|
||||
// POST /api/auth/logout
|
||||
router.post('/logout', (req, res) => {
|
||||
req.session = null;
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
// GET /api/auth/me
|
||||
router.get('/me', requireAuth, (req, res) => {
|
||||
const user = findById.get(req.session.userId);
|
||||
if (!user) {
|
||||
req.session = null;
|
||||
return res.status(401).json({ error: 'unauthorized' });
|
||||
}
|
||||
res.status(200).json({ user });
|
||||
});
|
||||
|
||||
// PATCH /api/auth/me — { display_name }
|
||||
router.patch('/me', requireAuth, (req, res) => {
|
||||
const { display_name } = req.body || {};
|
||||
if (typeof display_name !== 'string') {
|
||||
return res.status(400).json({ error: 'display_name is required' });
|
||||
}
|
||||
const trimmed = display_name.trim();
|
||||
if (trimmed.length < 1 || trimmed.length > 40) {
|
||||
return res.status(400).json({ error: 'display_name must be 1-40 characters' });
|
||||
}
|
||||
db.prepare('UPDATE users SET display_name = ? WHERE id = ?').run(
|
||||
trimmed,
|
||||
req.session.userId
|
||||
);
|
||||
res.status(200).json({ user: findById.get(req.session.userId) });
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
import express from 'express';
|
||||
import { isValidDateStr } from '../util/dates.js';
|
||||
import { membership } from '../util/access.js';
|
||||
import { ENTRY_COLUMNS, attachParticipants } from '../util/entrySerialize.js';
|
||||
import { validateSegments } from '../util/segments.js';
|
||||
import { validateRental } from '../util/rental.js';
|
||||
|
||||
const ENTRY_TYPES = new Set([
|
||||
'flight',
|
||||
'immigration',
|
||||
'travel',
|
||||
'hotel',
|
||||
'activity',
|
||||
'rental',
|
||||
'note',
|
||||
]);
|
||||
const SPLIT_MODES = new Set(['equal', 'own', 'payer']);
|
||||
const TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/;
|
||||
|
||||
function validTime(v) {
|
||||
return v === null || v === undefined || (typeof v === 'string' && TIME_RE.test(v));
|
||||
}
|
||||
|
||||
function validCoord(v, min, max) {
|
||||
return typeof v === 'number' && Number.isFinite(v) && v >= min && v <= max;
|
||||
}
|
||||
|
||||
// Validate an entry body. `partial` = true for PATCH (only provided keys checked).
|
||||
// `existing` is the current row (PATCH merges for the payer/paid_by rule).
|
||||
// `memberIds` is a Set of the trip's member user ids.
|
||||
// Returns { error } or { fields, participants, hasParticipants } where
|
||||
// participants is null (= all members) or an array of ids.
|
||||
function validateEntry(body, { partial, existing, memberIds }) {
|
||||
const fields = {};
|
||||
const has = (k) => k in body;
|
||||
|
||||
if (!partial || has('type')) {
|
||||
if (!ENTRY_TYPES.has(body.type)) return { error: 'invalid entry type' };
|
||||
fields.type = body.type;
|
||||
}
|
||||
if (!partial || has('date')) {
|
||||
if (!isValidDateStr(body.date)) {
|
||||
return { error: 'date must be a valid YYYY-MM-DD date' };
|
||||
}
|
||||
fields.date = body.date;
|
||||
}
|
||||
if (!partial || has('title')) {
|
||||
if (typeof body.title !== 'string' || body.title.trim() === '') {
|
||||
return { error: 'title is required' };
|
||||
}
|
||||
if (body.title.length > 200) {
|
||||
return { error: 'title must be at most 200 characters' };
|
||||
}
|
||||
fields.title = body.title.trim();
|
||||
}
|
||||
if (has('details')) {
|
||||
if (typeof body.details !== 'string') {
|
||||
return { error: 'details must be a string' };
|
||||
}
|
||||
fields.details = body.details;
|
||||
}
|
||||
for (const key of ['start_time', 'end_time']) {
|
||||
if (has(key)) {
|
||||
if (!validTime(body[key])) return { error: `${key} must be HH:MM` };
|
||||
fields[key] = body[key] ?? null;
|
||||
}
|
||||
}
|
||||
if (has('location_name')) {
|
||||
const v = body.location_name;
|
||||
if (v !== null && typeof v !== 'string') {
|
||||
return { error: 'location_name must be a string' };
|
||||
}
|
||||
fields.location_name = v ?? null;
|
||||
}
|
||||
|
||||
// lat/lng: both present or both absent.
|
||||
const hasLat = has('lat');
|
||||
const hasLng = has('lng');
|
||||
if (hasLat !== hasLng) {
|
||||
return { error: 'lat and lng must both be present or both absent' };
|
||||
}
|
||||
if (hasLat && hasLng) {
|
||||
const bothNull = body.lat === null && body.lng === null;
|
||||
if (!bothNull) {
|
||||
if (!validCoord(body.lat, -90, 90)) return { error: 'lat must be in [-90, 90]' };
|
||||
if (!validCoord(body.lng, -180, 180)) {
|
||||
return { error: 'lng must be in [-180, 180]' };
|
||||
}
|
||||
}
|
||||
fields.lat = bothNull ? null : body.lat;
|
||||
fields.lng = bothNull ? null : body.lng;
|
||||
}
|
||||
|
||||
if (has('sort_order')) {
|
||||
if (!Number.isInteger(body.sort_order)) {
|
||||
return { error: 'sort_order must be an integer' };
|
||||
}
|
||||
fields.sort_order = body.sort_order;
|
||||
}
|
||||
|
||||
// price: null or a number >= 0.
|
||||
if (has('price')) {
|
||||
const v = body.price;
|
||||
if (v !== null && !(typeof v === 'number' && Number.isFinite(v) && v >= 0)) {
|
||||
return { error: 'price must be null or a number >= 0' };
|
||||
}
|
||||
fields.price = v;
|
||||
}
|
||||
|
||||
// paid_by: null or a trip-member user id.
|
||||
if (has('paid_by')) {
|
||||
const v = body.paid_by;
|
||||
if (v !== null && !(Number.isInteger(v) && memberIds.has(v))) {
|
||||
return { error: 'paid_by must be null or a trip member id' };
|
||||
}
|
||||
fields.paid_by = v;
|
||||
}
|
||||
|
||||
// split_mode: enum.
|
||||
if (has('split_mode')) {
|
||||
if (!SPLIT_MODES.has(body.split_mode)) {
|
||||
return { error: 'split_mode must be one of equal, own, payer' };
|
||||
}
|
||||
fields.split_mode = body.split_mode;
|
||||
}
|
||||
|
||||
// 'payer' requires an effective paid_by (merging existing values on PATCH).
|
||||
const effMode = 'split_mode' in fields ? fields.split_mode : existing?.split_mode ?? 'equal';
|
||||
const effPaidBy = 'paid_by' in fields ? fields.paid_by : existing?.paid_by ?? null;
|
||||
if (effMode === 'payer' && (effPaidBy === null || effPaidBy === undefined)) {
|
||||
return { error: "split_mode 'payer' requires paid_by" };
|
||||
}
|
||||
|
||||
// participants: null/[] (= all members) or array of trip-member ids.
|
||||
let participants;
|
||||
if (has('participants')) {
|
||||
const v = body.participants;
|
||||
if (v === null || (Array.isArray(v) && v.length === 0)) {
|
||||
participants = null;
|
||||
} else if (Array.isArray(v)) {
|
||||
for (const id of v) {
|
||||
if (!Number.isInteger(id) || !memberIds.has(id)) {
|
||||
return { error: 'participants must be trip member ids' };
|
||||
}
|
||||
}
|
||||
participants = [...new Set(v)];
|
||||
} else {
|
||||
return { error: 'participants must be null or an array of member ids' };
|
||||
}
|
||||
}
|
||||
|
||||
// segments: flight-only structured itinerary (null clears them).
|
||||
if (has('segments')) {
|
||||
if (body.segments === null) {
|
||||
fields.segments = null;
|
||||
} else {
|
||||
const effType = 'type' in fields ? fields.type : existing?.type;
|
||||
if (effType !== 'flight') {
|
||||
return { error: 'segments are only allowed on flight entries' };
|
||||
}
|
||||
const checked = validateSegments(body.segments);
|
||||
if (checked.error) return { error: checked.error };
|
||||
fields.segments = JSON.stringify(checked.value);
|
||||
}
|
||||
}
|
||||
|
||||
// rental: rental-only structured details (null clears them).
|
||||
if (has('rental')) {
|
||||
if (body.rental === null) {
|
||||
fields.rental = null;
|
||||
} else {
|
||||
const effType = 'type' in fields ? fields.type : existing?.type;
|
||||
if (effType !== 'rental') {
|
||||
return { error: 'rental details are only allowed on rental entries' };
|
||||
}
|
||||
const checked = validateRental(body.rental);
|
||||
if (checked.error) return { error: checked.error };
|
||||
fields.rental = JSON.stringify(checked.value);
|
||||
}
|
||||
}
|
||||
|
||||
return { fields, participants, hasParticipants: has('participants') };
|
||||
}
|
||||
|
||||
export default function entriesRoutes(db) {
|
||||
const router = express.Router();
|
||||
|
||||
const getEntry = db.prepare(`SELECT ${ENTRY_COLUMNS} FROM entries WHERE id = ?`);
|
||||
const getTripEntries = db.prepare(
|
||||
`SELECT ${ENTRY_COLUMNS} FROM entries WHERE trip_id = ? ORDER BY date, sort_order, id`
|
||||
);
|
||||
const getEntryRow = db.prepare(
|
||||
'SELECT trip_id, split_mode, paid_by, type FROM entries WHERE id = ?'
|
||||
);
|
||||
const getMemberIds = db.prepare('SELECT user_id FROM trip_members WHERE trip_id = ?');
|
||||
const insertParticipant = db.prepare(
|
||||
'INSERT OR IGNORE INTO entry_participants (entry_id, user_id) VALUES (?, ?)'
|
||||
);
|
||||
const clearParticipants = db.prepare('DELETE FROM entry_participants WHERE entry_id = ?');
|
||||
|
||||
const memberIdSet = (tripId) => new Set(getMemberIds.all(tripId).map((r) => r.user_id));
|
||||
|
||||
function writeParticipants(entryId, participants) {
|
||||
clearParticipants.run(entryId);
|
||||
if (Array.isArray(participants)) {
|
||||
for (const uid of participants) insertParticipant.run(entryId, uid);
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/trips/:id/entries
|
||||
router.get('/trips/:id/entries', (req, res) => {
|
||||
const tripId = Number(req.params.id);
|
||||
if (!Number.isInteger(tripId) || !membership(db, tripId, req.session.userId)) {
|
||||
return res.status(404).json({ error: 'not found' });
|
||||
}
|
||||
const entries = getTripEntries.all(tripId).map((r) => attachParticipants(db, r));
|
||||
res.status(200).json({ entries });
|
||||
});
|
||||
|
||||
// POST /api/trips/:id/entries
|
||||
router.post('/trips/:id/entries', (req, res) => {
|
||||
const tripId = Number(req.params.id);
|
||||
if (!Number.isInteger(tripId) || !membership(db, tripId, req.session.userId)) {
|
||||
return res.status(404).json({ error: 'not found' });
|
||||
}
|
||||
const check = validateEntry(req.body || {}, {
|
||||
partial: false,
|
||||
existing: null,
|
||||
memberIds: memberIdSet(tripId),
|
||||
});
|
||||
if (check.error) return res.status(400).json({ error: check.error });
|
||||
const f = check.fields;
|
||||
|
||||
const entry = db.transaction(() => {
|
||||
const info = db
|
||||
.prepare(
|
||||
`INSERT INTO entries
|
||||
(trip_id, date, type, title, details, start_time, end_time,
|
||||
location_name, lat, lng, sort_order, price, paid_by, split_mode, segments, rental)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
tripId,
|
||||
f.date,
|
||||
f.type,
|
||||
f.title,
|
||||
f.details ?? '',
|
||||
f.start_time ?? null,
|
||||
f.end_time ?? null,
|
||||
f.location_name ?? null,
|
||||
f.lat ?? null,
|
||||
f.lng ?? null,
|
||||
f.sort_order ?? 0,
|
||||
f.price ?? null,
|
||||
f.paid_by ?? null,
|
||||
f.split_mode ?? 'equal',
|
||||
f.segments ?? null,
|
||||
f.rental ?? null
|
||||
);
|
||||
const id = Number(info.lastInsertRowid);
|
||||
// participants provided as an array -> store rows; null/absent -> all members.
|
||||
if (Array.isArray(check.participants)) writeParticipants(id, check.participants);
|
||||
return attachParticipants(db, getEntry.get(id));
|
||||
})();
|
||||
|
||||
res.status(201).json({ entry });
|
||||
});
|
||||
|
||||
// PATCH /api/entries/:id
|
||||
router.patch('/entries/:id', (req, res) => {
|
||||
const entryId = Number(req.params.id);
|
||||
const row = Number.isInteger(entryId) ? getEntryRow.get(entryId) : null;
|
||||
if (!row || !membership(db, row.trip_id, req.session.userId)) {
|
||||
return res.status(404).json({ error: 'not found' });
|
||||
}
|
||||
const check = validateEntry(req.body || {}, {
|
||||
partial: true,
|
||||
existing: row,
|
||||
memberIds: memberIdSet(row.trip_id),
|
||||
});
|
||||
if (check.error) return res.status(400).json({ error: check.error });
|
||||
|
||||
const entry = db.transaction(() => {
|
||||
const keys = Object.keys(check.fields);
|
||||
if (keys.length > 0) {
|
||||
const setClause = keys.map((k) => `${k} = ?`).join(', ');
|
||||
const values = keys.map((k) => check.fields[k]);
|
||||
db.prepare(`UPDATE entries SET ${setClause} WHERE id = ?`).run(...values, entryId);
|
||||
}
|
||||
// participants key present -> replace the whole set (null/[] = all members).
|
||||
if (check.hasParticipants) writeParticipants(entryId, check.participants);
|
||||
return attachParticipants(db, getEntry.get(entryId));
|
||||
})();
|
||||
|
||||
res.status(200).json({ entry });
|
||||
});
|
||||
|
||||
// DELETE /api/entries/:id
|
||||
router.delete('/entries/:id', (req, res) => {
|
||||
const entryId = Number(req.params.id);
|
||||
const row = Number.isInteger(entryId) ? getEntryRow.get(entryId) : null;
|
||||
if (!row || !membership(db, row.trip_id, req.session.userId)) {
|
||||
return res.status(404).json({ error: 'not found' });
|
||||
}
|
||||
db.transaction(() => {
|
||||
clearParticipants.run(entryId);
|
||||
db.prepare('DELETE FROM entries WHERE id = ?').run(entryId);
|
||||
})();
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import express from 'express';
|
||||
|
||||
const NOMINATIM_URL = 'https://nominatim.openstreetmap.org/search';
|
||||
const USER_AGENT = 'trip-plan-app/0.1 (self-hosted)';
|
||||
const CACHE_TTL_MS = 10 * 60 * 1000;
|
||||
const MAX_RESULTS = 5;
|
||||
|
||||
export default function geocodeRoutes() {
|
||||
const router = express.Router();
|
||||
const cache = new Map(); // query -> { at, results }
|
||||
|
||||
// GET /api/geocode?q=...
|
||||
router.get('/', async (req, res) => {
|
||||
const q = typeof req.query.q === 'string' ? req.query.q.trim() : '';
|
||||
if (!q) return res.status(400).json({ error: 'query parameter q is required' });
|
||||
|
||||
const cached = cache.get(q);
|
||||
if (cached && Date.now() - cached.at < CACHE_TTL_MS) {
|
||||
return res.status(200).json({ results: cached.results });
|
||||
}
|
||||
|
||||
const url = `${NOMINATIM_URL}?format=jsonv2&limit=${MAX_RESULTS}&accept-language=en&q=${encodeURIComponent(q)}`;
|
||||
let data;
|
||||
try {
|
||||
const upstream = await fetch(url, {
|
||||
headers: { 'User-Agent': USER_AGENT, Accept: 'application/json' },
|
||||
});
|
||||
if (!upstream.ok) throw new Error(`upstream status ${upstream.status}`);
|
||||
data = await upstream.json();
|
||||
} catch {
|
||||
return res.status(502).json({ error: 'geocoding unavailable' });
|
||||
}
|
||||
|
||||
const results = (Array.isArray(data) ? data : [])
|
||||
.slice(0, MAX_RESULTS)
|
||||
.map((r) => ({
|
||||
name: r.display_name,
|
||||
lat: Number(r.lat),
|
||||
lng: Number(r.lon),
|
||||
}))
|
||||
.filter((r) => Number.isFinite(r.lat) && Number.isFinite(r.lng));
|
||||
|
||||
cache.set(q, { at: Date.now(), results });
|
||||
res.status(200).json({ results });
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
import express from 'express';
|
||||
import { isValidDateStr, daysInclusive } from '../util/dates.js';
|
||||
import { haversineKm } from '../util/distance.js';
|
||||
import { membership } from '../util/access.js';
|
||||
import { computeCosts } from '../util/costs.js';
|
||||
import {
|
||||
ENTRY_COLUMNS,
|
||||
attachParticipantsAll,
|
||||
parseSegments,
|
||||
parseRental,
|
||||
} from '../util/entrySerialize.js';
|
||||
import { generateJoinCode, formatJoinCode, normalizeCode } from '../util/token.js';
|
||||
|
||||
const MAX_RANGE_DAYS = 365;
|
||||
const MIN_LEG_KM = 0.05;
|
||||
const CURRENCY_RE = /^[A-Z]{3}$/;
|
||||
|
||||
// Trip JSON with the join_code shown in grouped display form.
|
||||
function tripJson(row) {
|
||||
if (!row) return row;
|
||||
return { ...row, join_code: formatJoinCode(row.join_code) };
|
||||
}
|
||||
|
||||
// Airport stops derived from a flight entry's coordinate-bearing segments:
|
||||
// `from` of the first segment then `to` of each, skipping coordless airports
|
||||
// and collapsing consecutive duplicate coordinates. Returns null if none.
|
||||
function airportStopsFor(entry) {
|
||||
const segs = entry.segments;
|
||||
if (!Array.isArray(segs) || segs.length === 0) return null;
|
||||
const candidates = [segs[0].from, ...segs.map((s) => s.to)];
|
||||
const stops = [];
|
||||
for (const a of candidates) {
|
||||
if (!a || a.lat === null || a.lat === undefined || a.lng === null || a.lng === undefined) {
|
||||
continue;
|
||||
}
|
||||
const prev = stops[stops.length - 1];
|
||||
if (prev && prev.lat === a.lat && prev.lng === a.lng) continue;
|
||||
stops.push({ code: a.code, name: a.name ?? null, lat: a.lat, lng: a.lng });
|
||||
}
|
||||
return stops.length ? stops : null;
|
||||
}
|
||||
|
||||
// Ordered map stops: flight entries with located segments expand into airport
|
||||
// stops; every other located entry is a single stop.
|
||||
function buildStops(entries) {
|
||||
const stops = [];
|
||||
for (const e of entries) {
|
||||
const airports = e.type === 'flight' ? airportStopsFor(e) : null;
|
||||
if (airports) {
|
||||
for (const a of airports) {
|
||||
stops.push({
|
||||
entryId: e.id,
|
||||
date: e.date,
|
||||
type: e.type,
|
||||
title: e.title,
|
||||
kind: 'airport',
|
||||
code: a.code,
|
||||
location_name: a.name,
|
||||
lat: a.lat,
|
||||
lng: a.lng,
|
||||
});
|
||||
}
|
||||
} else if (e.lat !== null && e.lng !== null) {
|
||||
stops.push({
|
||||
entryId: e.id,
|
||||
date: e.date,
|
||||
type: e.type,
|
||||
title: e.title,
|
||||
location_name: e.location_name,
|
||||
lat: e.lat,
|
||||
lng: e.lng,
|
||||
});
|
||||
}
|
||||
}
|
||||
return stops;
|
||||
}
|
||||
|
||||
// Validate a {name, start_date, end_date, currency} object, merging with
|
||||
// existing values (for PATCH). Returns { error } or { values }.
|
||||
function validateTripFields(body, existing) {
|
||||
const name = 'name' in body ? body.name : existing?.name;
|
||||
const start = 'start_date' in body ? body.start_date : existing?.start_date;
|
||||
const end = 'end_date' in body ? body.end_date : existing?.end_date;
|
||||
const currency =
|
||||
'currency' in body ? body.currency : existing?.currency ?? 'USD';
|
||||
|
||||
if (typeof name !== 'string' || name.trim() === '') {
|
||||
return { error: 'name is required' };
|
||||
}
|
||||
if (name.length > 120) {
|
||||
return { error: 'name must be at most 120 characters' };
|
||||
}
|
||||
if (!isValidDateStr(start)) {
|
||||
return { error: 'start_date must be a valid YYYY-MM-DD date' };
|
||||
}
|
||||
if (!isValidDateStr(end)) {
|
||||
return { error: 'end_date must be a valid YYYY-MM-DD date' };
|
||||
}
|
||||
if (end < start) {
|
||||
return { error: 'end_date must be on or after start_date' };
|
||||
}
|
||||
if (daysInclusive(start, end) > MAX_RANGE_DAYS) {
|
||||
return { error: 'date range must be at most 365 days' };
|
||||
}
|
||||
if (typeof currency !== 'string' || !CURRENCY_RE.test(currency)) {
|
||||
return { error: 'currency must be a 3-letter uppercase code' };
|
||||
}
|
||||
return {
|
||||
values: { name: name.trim(), start_date: start, end_date: end, currency },
|
||||
};
|
||||
}
|
||||
|
||||
export default function tripsRoutes(db) {
|
||||
const router = express.Router();
|
||||
|
||||
const listForUser = db.prepare(`
|
||||
SELECT t.id, t.name, t.start_date, t.end_date, t.owner_id, t.currency, tm.role,
|
||||
(SELECT COUNT(*) FROM trip_members WHERE trip_id = t.id) AS member_count,
|
||||
(SELECT COUNT(*) FROM entries WHERE trip_id = t.id) AS entry_count
|
||||
FROM trips t
|
||||
JOIN trip_members tm ON tm.trip_id = t.id AND tm.user_id = ?
|
||||
ORDER BY t.created_at DESC, t.id DESC
|
||||
`);
|
||||
const insertTrip = db.prepare(
|
||||
'INSERT INTO trips (name, start_date, end_date, owner_id, currency, join_code) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
const insertMember = db.prepare(
|
||||
'INSERT INTO trip_members (trip_id, user_id, role) VALUES (?, ?, ?)'
|
||||
);
|
||||
const getTrip = db.prepare(
|
||||
'SELECT id, name, start_date, end_date, owner_id, currency, join_code FROM trips WHERE id = ?'
|
||||
);
|
||||
const getMembers = db.prepare(`
|
||||
SELECT u.id, u.display_name, tm.role
|
||||
FROM trip_members tm JOIN users u ON u.id = tm.user_id
|
||||
WHERE tm.trip_id = ? ORDER BY tm.role = 'owner' DESC, u.display_name
|
||||
`);
|
||||
const getEntries = db.prepare(
|
||||
`SELECT ${ENTRY_COLUMNS} FROM entries WHERE trip_id = ? ORDER BY date, sort_order, id`
|
||||
);
|
||||
const findTripByJoinCode = db.prepare('SELECT id FROM trips WHERE join_code = ?');
|
||||
const joinCodeExists = db.prepare('SELECT 1 FROM trips WHERE join_code = ?');
|
||||
|
||||
// Generate a join_code not already in use.
|
||||
function uniqueJoinCode() {
|
||||
let code;
|
||||
do {
|
||||
code = generateJoinCode();
|
||||
} while (joinCodeExists.get(code));
|
||||
return code;
|
||||
}
|
||||
|
||||
// Resolve :id as an integer and confirm membership. Sends 404 and returns
|
||||
// null when the trip does not exist or the user is not a member.
|
||||
function requireMember(req, res) {
|
||||
const tripId = Number(req.params.id);
|
||||
if (!Number.isInteger(tripId)) {
|
||||
res.status(404).json({ error: 'not found' });
|
||||
return null;
|
||||
}
|
||||
const member = membership(db, tripId, req.session.userId);
|
||||
if (!member) {
|
||||
res.status(404).json({ error: 'not found' });
|
||||
return null;
|
||||
}
|
||||
return { tripId, role: member.role };
|
||||
}
|
||||
|
||||
// GET /api/trips
|
||||
router.get('/', (req, res) => {
|
||||
const trips = listForUser.all(req.session.userId);
|
||||
res.status(200).json({ trips });
|
||||
});
|
||||
|
||||
// POST /api/trips
|
||||
router.post('/', (req, res) => {
|
||||
const check = validateTripFields(req.body || {}, null);
|
||||
if (check.error) return res.status(400).json({ error: check.error });
|
||||
const { name, start_date, end_date, currency } = check.values;
|
||||
const userId = req.session.userId;
|
||||
const trip = db.transaction(() => {
|
||||
const info = insertTrip.run(
|
||||
name,
|
||||
start_date,
|
||||
end_date,
|
||||
userId,
|
||||
currency,
|
||||
uniqueJoinCode()
|
||||
);
|
||||
const id = Number(info.lastInsertRowid);
|
||||
insertMember.run(id, userId, 'owner');
|
||||
return getTrip.get(id);
|
||||
})();
|
||||
res.status(201).json({ trip: tripJson(trip) });
|
||||
});
|
||||
|
||||
// POST /api/trips/join { code } — join by code as editor (idempotent).
|
||||
router.post('/join', (req, res) => {
|
||||
const code = normalizeCode((req.body || {}).code);
|
||||
if (!code) return res.status(404).json({ error: 'not found' });
|
||||
const found = findTripByJoinCode.get(code);
|
||||
if (!found) return res.status(404).json({ error: 'not found' });
|
||||
if (!membership(db, found.id, req.session.userId)) {
|
||||
insertMember.run(found.id, req.session.userId, 'editor');
|
||||
}
|
||||
res.status(200).json({ trip: tripJson(getTrip.get(found.id)) });
|
||||
});
|
||||
|
||||
// GET /api/trips/:id
|
||||
router.get('/:id', (req, res) => {
|
||||
const ctx = requireMember(req, res);
|
||||
if (!ctx) return;
|
||||
res.status(200).json({
|
||||
trip: tripJson(getTrip.get(ctx.tripId)),
|
||||
members: getMembers.all(ctx.tripId),
|
||||
entries: attachParticipantsAll(db, getEntries.all(ctx.tripId)),
|
||||
});
|
||||
});
|
||||
|
||||
// PATCH /api/trips/:id
|
||||
router.patch('/:id', (req, res) => {
|
||||
const ctx = requireMember(req, res);
|
||||
if (!ctx) return;
|
||||
const existing = getTrip.get(ctx.tripId);
|
||||
const check = validateTripFields(req.body || {}, existing);
|
||||
if (check.error) return res.status(400).json({ error: check.error });
|
||||
const { name, start_date, end_date, currency } = check.values;
|
||||
db.prepare(
|
||||
'UPDATE trips SET name = ?, start_date = ?, end_date = ?, currency = ? WHERE id = ?'
|
||||
).run(name, start_date, end_date, currency, ctx.tripId);
|
||||
res.status(200).json({ trip: tripJson(getTrip.get(ctx.tripId)) });
|
||||
});
|
||||
|
||||
// DELETE /api/trips/:id (owner only)
|
||||
router.delete('/:id', (req, res) => {
|
||||
const ctx = requireMember(req, res);
|
||||
if (!ctx) return;
|
||||
if (ctx.role !== 'owner') {
|
||||
return res.status(403).json({ error: 'only the owner can delete a trip' });
|
||||
}
|
||||
db.transaction(() => {
|
||||
db.prepare('DELETE FROM entries WHERE trip_id = ?').run(ctx.tripId);
|
||||
db.prepare('DELETE FROM trip_members WHERE trip_id = ?').run(ctx.tripId);
|
||||
db.prepare('DELETE FROM trips WHERE id = ?').run(ctx.tripId);
|
||||
})();
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
// POST /api/trips/:id/join-code (owner only) — regenerate the join code.
|
||||
router.post('/:id/join-code', (req, res) => {
|
||||
const ctx = requireMember(req, res);
|
||||
if (!ctx) return;
|
||||
if (ctx.role !== 'owner') {
|
||||
return res.status(403).json({ error: 'only the owner can regenerate the join code' });
|
||||
}
|
||||
db.prepare('UPDATE trips SET join_code = ? WHERE id = ?').run(
|
||||
uniqueJoinCode(),
|
||||
ctx.tripId
|
||||
);
|
||||
res.status(200).json({ trip: tripJson(getTrip.get(ctx.tripId)) });
|
||||
});
|
||||
|
||||
// DELETE /api/trips/:id/members/:userId (owner only)
|
||||
router.delete('/:id/members/:userId', (req, res) => {
|
||||
const ctx = requireMember(req, res);
|
||||
if (!ctx) return;
|
||||
if (ctx.role !== 'owner') {
|
||||
return res.status(403).json({ error: 'only the owner can remove members' });
|
||||
}
|
||||
const userId = Number(req.params.userId);
|
||||
if (userId === req.session.userId) {
|
||||
return res.status(400).json({ error: 'owner cannot remove themselves' });
|
||||
}
|
||||
db.prepare('DELETE FROM trip_members WHERE trip_id = ? AND user_id = ?').run(
|
||||
ctx.tripId,
|
||||
userId
|
||||
);
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
// GET /api/trips/:id/route (computed legs + summary)
|
||||
router.get('/:id/route', (req, res) => {
|
||||
const ctx = requireMember(req, res);
|
||||
if (!ctx) return;
|
||||
const trip = getTrip.get(ctx.tripId);
|
||||
const allEntries = getEntries
|
||||
.all(ctx.tripId)
|
||||
.map((r) => ({
|
||||
...r,
|
||||
segments: parseSegments(r.segments),
|
||||
rental: parseRental(r.rental),
|
||||
}));
|
||||
|
||||
const stops = buildStops(allEntries);
|
||||
|
||||
const legs = [];
|
||||
let totalKm = 0;
|
||||
let kmAir = 0;
|
||||
let kmDriven = 0;
|
||||
for (let i = 1; i < stops.length; i++) {
|
||||
const a = stops[i - 1];
|
||||
const b = stops[i];
|
||||
const km = haversineKm(a.lat, a.lng, b.lat, b.lng);
|
||||
if (km < MIN_LEG_KM) continue;
|
||||
// "air" only when both endpoints are airport stops from the same flight entry.
|
||||
const isAir =
|
||||
a.kind === 'airport' && b.kind === 'airport' && a.entryId === b.entryId;
|
||||
const mode = isAir ? 'air' : 'ground';
|
||||
legs.push({
|
||||
fromEntryId: a.entryId,
|
||||
toEntryId: b.entryId,
|
||||
km: Math.round(km * 10) / 10,
|
||||
mode,
|
||||
});
|
||||
totalKm += km;
|
||||
if (isAir) kmAir += km;
|
||||
else kmDriven += km;
|
||||
}
|
||||
|
||||
const countType = (t) => allEntries.filter((e) => e.type === t).length;
|
||||
const flightSegments = allEntries.reduce(
|
||||
(n, e) =>
|
||||
n + (e.type === 'flight' && Array.isArray(e.segments) ? e.segments.length : 0),
|
||||
0
|
||||
);
|
||||
// Sum non-null included_km across rentals; null when none specify one.
|
||||
let includedKm = null;
|
||||
for (const e of allEntries) {
|
||||
if (e.type === 'rental' && e.rental && typeof e.rental.included_km === 'number') {
|
||||
includedKm = (includedKm ?? 0) + e.rental.included_km;
|
||||
}
|
||||
}
|
||||
if (includedKm !== null) includedKm = Math.round(includedKm * 10) / 10;
|
||||
const days = daysInclusive(trip.start_date, trip.end_date);
|
||||
const locations = [];
|
||||
for (const s of stops) {
|
||||
if (s.location_name && !locations.includes(s.location_name)) {
|
||||
locations.push(s.location_name);
|
||||
}
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
stops,
|
||||
legs,
|
||||
totalKm: Math.round(totalKm * 10) / 10,
|
||||
summary: {
|
||||
days,
|
||||
nights: Math.max(0, days - 1),
|
||||
flights: countType('flight'),
|
||||
flightSegments,
|
||||
hotels: countType('hotel'),
|
||||
travelLegs: countType('travel'),
|
||||
activities: countType('activity'),
|
||||
rentals: countType('rental'),
|
||||
kmAir: Math.round(kmAir * 10) / 10,
|
||||
kmDriven: Math.round(kmDriven * 10) / 10,
|
||||
includedKm,
|
||||
locations,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// GET /api/trips/:id/costs (computed cost split + settlements)
|
||||
router.get('/:id/costs', (req, res) => {
|
||||
const ctx = requireMember(req, res);
|
||||
if (!ctx) return;
|
||||
const trip = getTrip.get(ctx.tripId);
|
||||
const members = getMembers.all(ctx.tripId);
|
||||
const entries = attachParticipantsAll(db, getEntries.all(ctx.tripId));
|
||||
res.status(200).json(
|
||||
computeCosts({ currency: trip.currency, members, entries })
|
||||
);
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// Shared trip-membership lookup used by trip and entry routes.
|
||||
|
||||
export function membership(db, tripId, userId) {
|
||||
return db
|
||||
.prepare('SELECT role FROM trip_members WHERE trip_id = ? AND user_id = ?')
|
||||
.get(tripId, userId);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// Pure cost/splitting computation for GET /api/trips/:id/costs.
|
||||
// Kept side-effect free so it can be unit-tested directly.
|
||||
|
||||
function round2(v) {
|
||||
return Math.round((v + Number.EPSILON) * 100) / 100;
|
||||
}
|
||||
|
||||
// members: [{ id, display_name }] (current trip members, in display order)
|
||||
// entries: [{ type, price, paid_by, split_mode, participants }]
|
||||
// participants = array of user ids ([] = all members)
|
||||
// currency: trip currency string
|
||||
export function computeCosts({ currency, members, entries }) {
|
||||
const memberIds = members.map((m) => m.id);
|
||||
const memberSet = new Set(memberIds);
|
||||
const share = new Map(memberIds.map((id) => [id, 0]));
|
||||
const paid = new Map(memberIds.map((id) => [id, 0]));
|
||||
const add = (map, id, amt) => map.set(id, (map.get(id) || 0) + amt);
|
||||
|
||||
const byType = {};
|
||||
let totalCost = 0;
|
||||
let unassigned = 0;
|
||||
|
||||
for (const e of entries) {
|
||||
if (e.price === null || e.price === undefined) continue;
|
||||
|
||||
// Effective participants: the entry's rows, else all current members.
|
||||
let eff =
|
||||
Array.isArray(e.participants) && e.participants.length
|
||||
? e.participants.filter((id) => memberSet.has(id))
|
||||
: memberIds;
|
||||
if (eff.length === 0) eff = memberIds;
|
||||
|
||||
const mode = e.split_mode || 'equal';
|
||||
let effTotal;
|
||||
|
||||
if (mode === 'own') {
|
||||
// price is per person; each pays their own, no debt.
|
||||
for (const id of eff) {
|
||||
add(share, id, e.price);
|
||||
add(paid, id, e.price);
|
||||
}
|
||||
effTotal = e.price * eff.length;
|
||||
} else if (mode === 'payer') {
|
||||
// personal expense: paid_by owes and pays the whole price alone.
|
||||
add(share, e.paid_by, e.price);
|
||||
add(paid, e.paid_by, e.price);
|
||||
effTotal = e.price;
|
||||
} else {
|
||||
// equal: price is the total, split equally among participants.
|
||||
const per = e.price / eff.length;
|
||||
for (const id of eff) add(share, id, per);
|
||||
if (e.paid_by === null || e.paid_by === undefined) {
|
||||
unassigned += e.price;
|
||||
} else {
|
||||
add(paid, e.paid_by, e.price);
|
||||
}
|
||||
effTotal = e.price;
|
||||
}
|
||||
|
||||
totalCost += effTotal;
|
||||
byType[e.type] = (byType[e.type] || 0) + effTotal;
|
||||
}
|
||||
|
||||
const perUser = members.map((m) => {
|
||||
const s = share.get(m.id) || 0;
|
||||
const p = paid.get(m.id) || 0;
|
||||
return {
|
||||
userId: m.id,
|
||||
displayName: m.display_name,
|
||||
share: round2(s),
|
||||
paid: round2(p),
|
||||
net: round2(p - s),
|
||||
};
|
||||
});
|
||||
|
||||
const roundedByType = {};
|
||||
for (const [type, amt] of Object.entries(byType)) {
|
||||
roundedByType[type] = round2(amt);
|
||||
}
|
||||
|
||||
return {
|
||||
currency,
|
||||
totalCost: round2(totalCost),
|
||||
byType: roundedByType,
|
||||
perUser,
|
||||
settlements: settle(perUser),
|
||||
unassigned: round2(unassigned),
|
||||
};
|
||||
}
|
||||
|
||||
// Greedy minimal-transfer settlement over the rounded net balances.
|
||||
function settle(perUser) {
|
||||
const debtors = perUser
|
||||
.filter((u) => u.net < -0.005)
|
||||
.map((u) => ({ id: u.userId, amt: -u.net }));
|
||||
const creditors = perUser
|
||||
.filter((u) => u.net > 0.005)
|
||||
.map((u) => ({ id: u.userId, amt: u.net }));
|
||||
|
||||
const settlements = [];
|
||||
while (debtors.length && creditors.length) {
|
||||
debtors.sort((a, b) => b.amt - a.amt);
|
||||
creditors.sort((a, b) => b.amt - a.amt);
|
||||
const d = debtors[0];
|
||||
const c = creditors[0];
|
||||
const pay = Math.min(d.amt, c.amt);
|
||||
if (pay < 0.01) break;
|
||||
settlements.push({
|
||||
fromUserId: d.id,
|
||||
toUserId: c.id,
|
||||
amount: round2(pay),
|
||||
});
|
||||
d.amt = round2(d.amt - pay);
|
||||
c.amt = round2(c.amt - pay);
|
||||
if (d.amt < 0.01) debtors.shift();
|
||||
if (c.amt < 0.01) creditors.shift();
|
||||
}
|
||||
return settlements;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// Date-string helpers. All trip/entry dates are YYYY-MM-DD strings.
|
||||
|
||||
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
export function isValidDateStr(s) {
|
||||
if (typeof s !== 'string' || !DATE_RE.test(s)) return false;
|
||||
const [y, m, d] = s.split('-').map(Number);
|
||||
const dt = new Date(Date.UTC(y, m - 1, d));
|
||||
return (
|
||||
dt.getUTCFullYear() === y &&
|
||||
dt.getUTCMonth() === m - 1 &&
|
||||
dt.getUTCDate() === d
|
||||
);
|
||||
}
|
||||
|
||||
function dateToUTC(s) {
|
||||
const [y, m, d] = s.split('-').map(Number);
|
||||
return Date.UTC(y, m - 1, d);
|
||||
}
|
||||
|
||||
// Inclusive day count between two valid date strings (start <= end).
|
||||
export function daysInclusive(start, end) {
|
||||
return Math.round((dateToUTC(end) - dateToUTC(start)) / 86400000) + 1;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// Great-circle distance between two lat/lng points, in kilometers.
|
||||
|
||||
const EARTH_RADIUS_KM = 6371;
|
||||
|
||||
function toRad(deg) {
|
||||
return (deg * Math.PI) / 180;
|
||||
}
|
||||
|
||||
export function haversineKm(lat1, lng1, lat2, lng2) {
|
||||
const dLat = toRad(lat2 - lat1);
|
||||
const dLng = toRad(lng2 - lng1);
|
||||
const a =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
return EARTH_RADIUS_KM * c;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Shared entry column list and JSON serialization. Every entry response is a
|
||||
// full row plus a `participants` array (the entry_participants rows; [] means
|
||||
// "all trip members participate") and a parsed `segments` array (or null).
|
||||
|
||||
export const ENTRY_COLUMNS =
|
||||
'id, trip_id, date, type, title, details, start_time, end_time, ' +
|
||||
'location_name, lat, lng, sort_order, price, paid_by, split_mode, segments, rental';
|
||||
|
||||
// Parse the stored segments JSON text into an array, or null if absent/invalid.
|
||||
export function parseSegments(value) {
|
||||
if (!value) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return Array.isArray(parsed) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse the stored rental JSON text into an object, or null if absent/invalid.
|
||||
export function parseRental(value) {
|
||||
if (!value) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function attachParticipants(db, row) {
|
||||
if (!row) return row;
|
||||
const rows = db
|
||||
.prepare('SELECT user_id FROM entry_participants WHERE entry_id = ? ORDER BY user_id')
|
||||
.all(row.id);
|
||||
row.participants = rows.map((r) => r.user_id);
|
||||
row.segments = parseSegments(row.segments);
|
||||
row.rental = parseRental(row.rental);
|
||||
return row;
|
||||
}
|
||||
|
||||
export function attachParticipantsAll(db, rows) {
|
||||
return rows.map((r) => attachParticipants(db, r));
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Auto-generated, non-unique display names: adjective-animal (e.g. brave-otter).
|
||||
|
||||
const ADJECTIVES = [
|
||||
'brave', 'calm', 'clever', 'bold', 'gentle', 'swift', 'quiet', 'bright',
|
||||
'lucky', 'mellow', 'nimble', 'sunny', 'witty', 'eager', 'jolly', 'keen',
|
||||
'proud', 'wise', 'zesty', 'cosmic',
|
||||
];
|
||||
|
||||
const ANIMALS = [
|
||||
'otter', 'heron', 'lynx', 'panda', 'koala', 'falcon', 'marmot', 'gecko',
|
||||
'tapir', 'ibex', 'narwhal', 'quokka', 'badger', 'osprey', 'manta', 'puffin',
|
||||
'yak', 'wombat', 'civet', 'raven',
|
||||
];
|
||||
|
||||
function pick(list) {
|
||||
return list[Math.floor(Math.random() * list.length)];
|
||||
}
|
||||
|
||||
export function generateDisplayName() {
|
||||
return `${pick(ADJECTIVES)}-${pick(ANIMALS)}`;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// Validation + normalization for rental-car entry details (see docs/API.md).
|
||||
|
||||
import { isValidDateStr } from './dates.js';
|
||||
|
||||
const TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/;
|
||||
|
||||
function validCoord(v, min, max) {
|
||||
return typeof v === 'number' && Number.isFinite(v) && v >= min && v <= max;
|
||||
}
|
||||
|
||||
// Optional string field with a max length.
|
||||
function checkString(obj, key, max) {
|
||||
const v = obj[key];
|
||||
if (v === undefined || v === null) return { skip: true };
|
||||
if (typeof v !== 'string' || v.length > max) {
|
||||
return { error: `${key} must be a string <= ${max} chars` };
|
||||
}
|
||||
return { value: v };
|
||||
}
|
||||
|
||||
// Validate a pickup/dropoff endpoint: date required, others optional.
|
||||
function normalizeStop(stop, side) {
|
||||
if (typeof stop !== 'object' || stop === null || Array.isArray(stop)) {
|
||||
return { error: `${side} must be an object` };
|
||||
}
|
||||
if (!isValidDateStr(stop.date)) {
|
||||
return { error: `${side}.date must be a valid YYYY-MM-DD date` };
|
||||
}
|
||||
const out = { date: stop.date };
|
||||
if (stop.time !== undefined && stop.time !== null) {
|
||||
if (typeof stop.time !== 'string' || !TIME_RE.test(stop.time)) {
|
||||
return { error: `${side}.time must be HH:MM` };
|
||||
}
|
||||
out.time = stop.time;
|
||||
}
|
||||
const name = checkString(stop, 'location_name', 120);
|
||||
if (name.error) return { error: `${side}.${name.error}` };
|
||||
if (!name.skip) out.location_name = name.value;
|
||||
|
||||
const hasLat = stop.lat !== undefined && stop.lat !== null;
|
||||
const hasLng = stop.lng !== undefined && stop.lng !== null;
|
||||
if (hasLat !== hasLng) {
|
||||
return { error: `${side} lat/lng must both be present or both absent` };
|
||||
}
|
||||
if (hasLat) {
|
||||
if (!validCoord(stop.lat, -90, 90)) return { error: `${side}.lat out of range` };
|
||||
if (!validCoord(stop.lng, -180, 180)) return { error: `${side}.lng out of range` };
|
||||
out.lat = stop.lat;
|
||||
out.lng = stop.lng;
|
||||
}
|
||||
return { value: out };
|
||||
}
|
||||
|
||||
// Validate a rental object. Returns { error } or { value: normalized }.
|
||||
export function validateRental(rental) {
|
||||
if (typeof rental !== 'object' || rental === null || Array.isArray(rental)) {
|
||||
return { error: 'rental must be an object' };
|
||||
}
|
||||
const out = {};
|
||||
|
||||
for (const [key, max] of [['brand', 60], ['model', 60], ['car_type', 40], ['booking_ref', 60]]) {
|
||||
const r = checkString(rental, key, max);
|
||||
if (r.error) return { error: r.error };
|
||||
if (!r.skip) out[key] = r.value;
|
||||
}
|
||||
|
||||
if (rental.included_km !== undefined) {
|
||||
const v = rental.included_km;
|
||||
if (v !== null && !(typeof v === 'number' && Number.isFinite(v) && v >= 0)) {
|
||||
return { error: 'included_km must be null or a number >= 0' };
|
||||
}
|
||||
out.included_km = v;
|
||||
}
|
||||
|
||||
for (const side of ['pickup', 'dropoff']) {
|
||||
if (rental[side] !== undefined && rental[side] !== null) {
|
||||
const checked = normalizeStop(rental[side], side);
|
||||
if (checked.error) return { error: checked.error };
|
||||
out[side] = checked.value;
|
||||
}
|
||||
}
|
||||
|
||||
return { value: out };
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// Validation + normalization for flight-entry segments (see docs/API.md).
|
||||
|
||||
const CODE_RE = /^[A-Z0-9]{2,4}$/;
|
||||
const TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/;
|
||||
const MAX_SEGMENTS = 8;
|
||||
|
||||
function validCoord(v, min, max) {
|
||||
return typeof v === 'number' && Number.isFinite(v) && v >= min && v <= max;
|
||||
}
|
||||
|
||||
// Validate and normalize one airport endpoint ({code, name?, lat?, lng?}).
|
||||
function normalizeAirport(a, side, i) {
|
||||
if (!a || typeof a !== 'object' || Array.isArray(a)) {
|
||||
return { error: `segment ${i} ${side} is required` };
|
||||
}
|
||||
if (typeof a.code !== 'string') {
|
||||
return { error: `segment ${i} ${side}.code is required` };
|
||||
}
|
||||
const code = a.code.trim().toUpperCase();
|
||||
if (!CODE_RE.test(code)) {
|
||||
return { error: `segment ${i} ${side}.code must be 2-4 alphanumerics` };
|
||||
}
|
||||
const out = { code };
|
||||
if (a.name !== undefined && a.name !== null) {
|
||||
if (typeof a.name !== 'string' || a.name.length > 80) {
|
||||
return { error: `segment ${i} ${side}.name must be a string <= 80 chars` };
|
||||
}
|
||||
out.name = a.name;
|
||||
}
|
||||
const hasLat = a.lat !== undefined && a.lat !== null;
|
||||
const hasLng = a.lng !== undefined && a.lng !== null;
|
||||
if (hasLat !== hasLng) {
|
||||
return { error: `segment ${i} ${side} lat/lng must both be present or both absent` };
|
||||
}
|
||||
if (hasLat) {
|
||||
if (!validCoord(a.lat, -90, 90)) return { error: `segment ${i} ${side}.lat out of range` };
|
||||
if (!validCoord(a.lng, -180, 180)) return { error: `segment ${i} ${side}.lng out of range` };
|
||||
out.lat = a.lat;
|
||||
out.lng = a.lng;
|
||||
}
|
||||
return { value: out };
|
||||
}
|
||||
|
||||
// Validate a segments array. Returns { error } or { value: normalizedArray }.
|
||||
export function validateSegments(segments) {
|
||||
if (!Array.isArray(segments)) {
|
||||
return { error: 'segments must be an array' };
|
||||
}
|
||||
if (segments.length < 1 || segments.length > MAX_SEGMENTS) {
|
||||
return { error: `segments must have 1-${MAX_SEGMENTS} entries` };
|
||||
}
|
||||
const out = [];
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const s = segments[i];
|
||||
if (!s || typeof s !== 'object' || Array.isArray(s)) {
|
||||
return { error: `segment ${i} must be an object` };
|
||||
}
|
||||
const from = normalizeAirport(s.from, 'from', i);
|
||||
if (from.error) return from;
|
||||
const to = normalizeAirport(s.to, 'to', i);
|
||||
if (to.error) return to;
|
||||
|
||||
const seg = { from: from.value, to: to.value };
|
||||
if (s.flight_no !== undefined && s.flight_no !== null) {
|
||||
if (typeof s.flight_no !== 'string' || s.flight_no.length > 12) {
|
||||
return { error: `segment ${i} flight_no must be a string <= 12 chars` };
|
||||
}
|
||||
seg.flight_no = s.flight_no;
|
||||
}
|
||||
for (const key of ['dep_time', 'arr_time']) {
|
||||
if (s[key] !== undefined && s[key] !== null) {
|
||||
if (typeof s[key] !== 'string' || !TIME_RE.test(s[key])) {
|
||||
return { error: `segment ${i} ${key} must be HH:MM` };
|
||||
}
|
||||
seg[key] = s[key];
|
||||
}
|
||||
}
|
||||
out.push(seg);
|
||||
}
|
||||
return { value: out };
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
// Unambiguous alphabet (no I/L/O/0/1) shared by account tokens and join codes.
|
||||
const ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789'; // 31 chars
|
||||
|
||||
// Random string of `length` chars, uniformly sampled (rejection sampling to
|
||||
// avoid modulo bias) from ALPHABET.
|
||||
export function randomCode(length) {
|
||||
const n = ALPHABET.length;
|
||||
const maxUnbiased = Math.floor(256 / n) * n; // 248: reject bytes >= this
|
||||
let out = '';
|
||||
while (out.length < length) {
|
||||
const bytes = crypto.randomBytes(length - out.length);
|
||||
for (const b of bytes) {
|
||||
if (b >= maxUnbiased) continue;
|
||||
out += ALPHABET[b % n];
|
||||
if (out.length === length) break;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Strip dashes/spaces and uppercase — applied to both stored codes and user input.
|
||||
export function normalizeCode(input) {
|
||||
return String(input ?? '').replace(/[\s-]/g, '').toUpperCase();
|
||||
}
|
||||
|
||||
export function sha256Hex(s) {
|
||||
return crypto.createHash('sha256').update(s).digest('hex');
|
||||
}
|
||||
|
||||
// Group a raw code into dash-separated blocks of `size` for display.
|
||||
function group(raw, size) {
|
||||
return raw.match(new RegExp(`.{1,${size}}`, 'g')).join('-');
|
||||
}
|
||||
|
||||
export function generateAccountToken() {
|
||||
return randomCode(16);
|
||||
}
|
||||
|
||||
export function generateJoinCode() {
|
||||
return randomCode(8);
|
||||
}
|
||||
|
||||
export function formatToken(raw) {
|
||||
return group(raw, 4); // XXXX-XXXX-XXXX-XXXX
|
||||
}
|
||||
|
||||
export function formatJoinCode(raw) {
|
||||
return group(raw, 4); // XXXX-XXXX
|
||||
}
|
||||
|
||||
// sha256 of the normalized token/code — the only thing stored for accounts.
|
||||
export function hashToken(input) {
|
||||
return sha256Hex(normalizeCode(input));
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
import { test, beforeEach, afterEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import request from 'supertest';
|
||||
import { createApp } from '../src/server/app.js';
|
||||
|
||||
let tmpDir;
|
||||
let app;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tripplan-test-'));
|
||||
app = createApp({ dbPath: path.join(tmpDir, 'test.db'), sessionSecret: 'test-secret' });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
if (app.locals.db) app.locals.db.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// Create an account (Mullvad-style) and return an agent with the session set,
|
||||
// plus the user object {id, display_name} and the raw token.
|
||||
async function createAccount() {
|
||||
const agent = request.agent(app);
|
||||
const res = await agent.post('/api/auth/account').send({});
|
||||
assert.equal(res.status, 201);
|
||||
return { agent, user: res.body.user, token: res.body.token };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auth (account tokens)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('account creation returns a grouped token and a generated display name', async () => {
|
||||
const { user, token } = await createAccount();
|
||||
assert.deepEqual(Object.keys(user).sort(), ['display_name', 'id']);
|
||||
assert.match(token, /^[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{4}(-[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{4}){3}$/);
|
||||
assert.match(user.display_name, /^[a-z]+-[a-z]+$/);
|
||||
});
|
||||
|
||||
test('account / me / logout lifecycle', async () => {
|
||||
const { agent, user } = await createAccount();
|
||||
|
||||
const me = await agent.get('/api/auth/me');
|
||||
assert.equal(me.status, 200);
|
||||
assert.deepEqual(me.body.user, user);
|
||||
|
||||
const out = await agent.post('/api/auth/logout');
|
||||
assert.equal(out.status, 204);
|
||||
|
||||
const meAfter = await agent.get('/api/auth/me');
|
||||
assert.equal(meAfter.status, 401);
|
||||
assert.deepEqual(meAfter.body, { error: 'unauthorized' });
|
||||
});
|
||||
|
||||
test('me requires auth', async () => {
|
||||
const res = await request(app).get('/api/auth/me');
|
||||
assert.equal(res.status, 401);
|
||||
assert.equal(res.body.error, 'unauthorized');
|
||||
});
|
||||
|
||||
test('login with token (normalized) succeeds; bad token 401', async () => {
|
||||
const { user, token } = await createAccount();
|
||||
|
||||
// Fresh agent logs in with the token, dashes/lowercase/spaces tolerated.
|
||||
const agent = request.agent(app);
|
||||
const messy = ` ${token.toLowerCase().replace(/-/g, '')} `;
|
||||
const ok = await agent.post('/api/auth/login').send({ token: messy });
|
||||
assert.equal(ok.status, 200);
|
||||
assert.deepEqual(ok.body.user, user);
|
||||
|
||||
const me = await agent.get('/api/auth/me');
|
||||
assert.equal(me.body.user.id, user.id);
|
||||
|
||||
const bad = await request(app).post('/api/auth/login').send({ token: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ' });
|
||||
assert.equal(bad.status, 401);
|
||||
|
||||
const empty = await request(app).post('/api/auth/login').send({});
|
||||
assert.equal(empty.status, 401);
|
||||
});
|
||||
|
||||
test('patch display_name validates 1-40 chars after trim', async () => {
|
||||
const { agent, user } = await createAccount();
|
||||
|
||||
const ok = await agent.patch('/api/auth/me').send({ display_name: ' Anna the Explorer ' });
|
||||
assert.equal(ok.status, 200);
|
||||
assert.equal(ok.body.user.display_name, 'Anna the Explorer');
|
||||
assert.equal(ok.body.user.id, user.id);
|
||||
|
||||
const empty = await agent.patch('/api/auth/me').send({ display_name: ' ' });
|
||||
assert.equal(empty.status, 400);
|
||||
|
||||
const tooLong = await agent.patch('/api/auth/me').send({ display_name: 'x'.repeat(41) });
|
||||
assert.equal(tooLong.status, 400);
|
||||
|
||||
const noAuth = await request(app).patch('/api/auth/me').send({ display_name: 'nope' });
|
||||
assert.equal(noAuth.status, 401);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Trips: CRUD + validation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('create trip and list it', async () => {
|
||||
const { agent, user } = await createAccount();
|
||||
const create = await agent
|
||||
.post('/api/trips')
|
||||
.send({ name: 'Thailand', start_date: '2026-08-01', end_date: '2026-08-10' });
|
||||
assert.equal(create.status, 201);
|
||||
assert.equal(create.body.trip.name, 'Thailand');
|
||||
assert.equal(create.body.trip.owner_id, user.id);
|
||||
// join_code is present on the created trip, in grouped display form.
|
||||
assert.match(create.body.trip.join_code, /^[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{4}-[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{4}$/);
|
||||
|
||||
const list = await agent.get('/api/trips');
|
||||
assert.equal(list.status, 200);
|
||||
assert.equal(list.body.trips.length, 1);
|
||||
assert.equal(list.body.trips[0].role, 'owner');
|
||||
assert.equal(list.body.trips[0].member_count, 1);
|
||||
assert.equal(list.body.trips[0].entry_count, 0);
|
||||
});
|
||||
|
||||
test('trip validation: bad dates and oversized range', async () => {
|
||||
const { agent } = await createAccount();
|
||||
|
||||
const emptyName = await agent.post('/api/trips').send({ name: '', start_date: '2026-08-01', end_date: '2026-08-02' });
|
||||
assert.equal(emptyName.status, 400);
|
||||
|
||||
const badDate = await agent.post('/api/trips').send({ name: 'X', start_date: '2026-13-40', end_date: '2026-08-02' });
|
||||
assert.equal(badDate.status, 400);
|
||||
|
||||
const reversed = await agent.post('/api/trips').send({ name: 'X', start_date: '2026-08-10', end_date: '2026-08-01' });
|
||||
assert.equal(reversed.status, 400);
|
||||
|
||||
const tooLong = await agent.post('/api/trips').send({ name: 'X', start_date: '2026-01-01', end_date: '2027-06-01' });
|
||||
assert.equal(tooLong.status, 400);
|
||||
});
|
||||
|
||||
test('get / patch / delete trip', async () => {
|
||||
const { agent } = await createAccount();
|
||||
const created = (await agent.post('/api/trips').send({ name: 'Trip', start_date: '2026-08-01', end_date: '2026-08-05' })).body.trip;
|
||||
|
||||
const got = await agent.get(`/api/trips/${created.id}`);
|
||||
assert.equal(got.status, 200);
|
||||
assert.equal(got.body.trip.name, 'Trip');
|
||||
assert.ok(got.body.trip.join_code);
|
||||
assert.equal(got.body.members.length, 1);
|
||||
assert.deepEqual(Object.keys(got.body.members[0]).sort(), ['display_name', 'id', 'role']);
|
||||
assert.deepEqual(got.body.entries, []);
|
||||
|
||||
const patched = await agent.patch(`/api/trips/${created.id}`).send({ name: 'Renamed', end_date: '2026-08-08' });
|
||||
assert.equal(patched.status, 200);
|
||||
assert.equal(patched.body.trip.name, 'Renamed');
|
||||
assert.equal(patched.body.trip.end_date, '2026-08-08');
|
||||
|
||||
const del = await agent.delete(`/api/trips/${created.id}`);
|
||||
assert.equal(del.status, 204);
|
||||
|
||||
const gone = await agent.get(`/api/trips/${created.id}`);
|
||||
assert.equal(gone.status, 404);
|
||||
});
|
||||
|
||||
test('unknown trip returns 404', async () => {
|
||||
const { agent } = await createAccount();
|
||||
const res = await agent.get('/api/trips/9999');
|
||||
assert.equal(res.status, 404);
|
||||
assert.equal(res.body.error, 'not found');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Membership via join code
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('join by code: non-member 404, join as editor, idempotent, unknown 404', async () => {
|
||||
const owner = await createAccount();
|
||||
const guest = await createAccount();
|
||||
|
||||
const trip = (await owner.agent.post('/api/trips').send({ name: 'Shared', start_date: '2026-08-01', end_date: '2026-08-03' })).body.trip;
|
||||
const code = trip.join_code;
|
||||
|
||||
// Non-member cannot see the trip.
|
||||
assert.equal((await guest.agent.get(`/api/trips/${trip.id}`)).status, 404);
|
||||
|
||||
// Unknown code -> 404.
|
||||
assert.equal((await guest.agent.post('/api/trips/join').send({ code: 'ZZZZ-ZZZZ' })).status, 404);
|
||||
|
||||
// Join with the code (lowercase/spacing tolerated) -> 200, joined as editor.
|
||||
const joined = await guest.agent.post('/api/trips/join').send({ code: ` ${code.toLowerCase()} ` });
|
||||
assert.equal(joined.status, 200);
|
||||
assert.equal(joined.body.trip.id, trip.id);
|
||||
|
||||
const view = await guest.agent.get(`/api/trips/${trip.id}`);
|
||||
assert.equal(view.status, 200);
|
||||
const guestMember = view.body.members.find((m) => m.id === guest.user.id);
|
||||
assert.equal(guestMember.role, 'editor');
|
||||
|
||||
// Idempotent: joining again still 200, membership count unchanged.
|
||||
const again = await guest.agent.post('/api/trips/join').send({ code });
|
||||
assert.equal(again.status, 200);
|
||||
const memberCount = (await owner.agent.get('/api/trips')).body.trips[0].member_count;
|
||||
assert.equal(memberCount, 2);
|
||||
});
|
||||
|
||||
test('join-code regeneration is owner-only and invalidates the old code', async () => {
|
||||
const owner = await createAccount();
|
||||
const guest = await createAccount();
|
||||
const trip = (await owner.agent.post('/api/trips').send({ name: 'T', start_date: '2026-08-01', end_date: '2026-08-03' })).body.trip;
|
||||
const oldCode = trip.join_code;
|
||||
|
||||
// Non-member cannot regenerate (they can't even see the trip) -> 404.
|
||||
assert.equal((await guest.agent.post(`/api/trips/${trip.id}/join-code`)).status, 404);
|
||||
|
||||
const regen = await owner.agent.post(`/api/trips/${trip.id}/join-code`);
|
||||
assert.equal(regen.status, 200);
|
||||
assert.notEqual(regen.body.trip.join_code, oldCode);
|
||||
|
||||
// Old code no longer works; new one does.
|
||||
assert.equal((await guest.agent.post('/api/trips/join').send({ code: oldCode })).status, 404);
|
||||
assert.equal((await guest.agent.post('/api/trips/join').send({ code: regen.body.trip.join_code })).status, 200);
|
||||
|
||||
// A member who is not the owner cannot regenerate -> 403.
|
||||
const nonOwnerRegen = await guest.agent.post(`/api/trips/${trip.id}/join-code`);
|
||||
assert.equal(nonOwnerRegen.status, 403);
|
||||
});
|
||||
|
||||
test('owner-only member removal; owner cannot remove self', async () => {
|
||||
const owner = await createAccount();
|
||||
const guest = await createAccount();
|
||||
const trip = (await owner.agent.post('/api/trips').send({ name: 'T', start_date: '2026-08-01', end_date: '2026-08-03' })).body.trip;
|
||||
await guest.agent.post('/api/trips/join').send({ code: trip.join_code });
|
||||
|
||||
// Non-owner cannot delete the trip.
|
||||
assert.equal((await guest.agent.delete(`/api/trips/${trip.id}`)).status, 403);
|
||||
|
||||
// Owner removes the guest.
|
||||
assert.equal((await owner.agent.delete(`/api/trips/${trip.id}/members/${guest.user.id}`)).status, 204);
|
||||
assert.equal((await guest.agent.get(`/api/trips/${trip.id}`)).status, 404);
|
||||
|
||||
// Owner cannot remove self.
|
||||
assert.equal((await owner.agent.delete(`/api/trips/${trip.id}/members/${owner.user.id}`)).status, 400);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Entries: CRUD + validation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function makeTrip(agent) {
|
||||
return (await agent.post('/api/trips').send({ name: 'E', start_date: '2026-08-01', end_date: '2026-08-10' })).body.trip;
|
||||
}
|
||||
|
||||
test('entry CRUD and full-row shape', async () => {
|
||||
const { agent } = await createAccount();
|
||||
const trip = await makeTrip(agent);
|
||||
|
||||
const create = await agent.post(`/api/trips/${trip.id}/entries`).send({
|
||||
date: '2026-08-01',
|
||||
type: 'flight',
|
||||
title: 'BKK -> CNX',
|
||||
start_time: '09:30',
|
||||
location_name: 'Chiang Mai',
|
||||
lat: 18.79,
|
||||
lng: 98.98,
|
||||
});
|
||||
assert.equal(create.status, 201);
|
||||
const entry = create.body.entry;
|
||||
assert.deepEqual(
|
||||
Object.keys(entry).sort(),
|
||||
['date', 'details', 'end_time', 'id', 'lat', 'lng', 'location_name', 'paid_by', 'participants', 'price', 'rental', 'segments', 'sort_order', 'split_mode', 'start_time', 'title', 'trip_id', 'type'].sort()
|
||||
);
|
||||
assert.equal(entry.details, '');
|
||||
assert.equal(entry.lat, 18.79);
|
||||
assert.equal(entry.price, null);
|
||||
assert.equal(entry.paid_by, null);
|
||||
assert.equal(entry.split_mode, 'equal');
|
||||
assert.deepEqual(entry.participants, []);
|
||||
assert.equal(entry.segments, null);
|
||||
assert.equal(entry.rental, null);
|
||||
|
||||
const patch = await agent.patch(`/api/entries/${entry.id}`).send({ title: 'BKK to CNX', details: 'window seat' });
|
||||
assert.equal(patch.status, 200);
|
||||
assert.equal(patch.body.entry.title, 'BKK to CNX');
|
||||
assert.equal(patch.body.entry.details, 'window seat');
|
||||
|
||||
const list = await agent.get(`/api/trips/${trip.id}/entries`);
|
||||
assert.equal(list.body.entries.length, 1);
|
||||
|
||||
const del = await agent.delete(`/api/entries/${entry.id}`);
|
||||
assert.equal(del.status, 204);
|
||||
|
||||
const listAfter = await agent.get(`/api/trips/${trip.id}/entries`);
|
||||
assert.equal(listAfter.body.entries.length, 0);
|
||||
});
|
||||
|
||||
test('entry validation: type, title, lat/lng pairing and ranges', async () => {
|
||||
const { agent } = await createAccount();
|
||||
const trip = await makeTrip(agent);
|
||||
const base = `/api/trips/${trip.id}/entries`;
|
||||
|
||||
const badType = await agent.post(base).send({ date: '2026-08-01', type: 'teleport', title: 'x' });
|
||||
assert.equal(badType.status, 400);
|
||||
|
||||
const badDate = await agent.post(base).send({ date: '08/01/2026', type: 'note', title: 'x' });
|
||||
assert.equal(badDate.status, 400);
|
||||
|
||||
const noTitle = await agent.post(base).send({ date: '2026-08-01', type: 'note', title: '' });
|
||||
assert.equal(noTitle.status, 400);
|
||||
|
||||
const latOnly = await agent.post(base).send({ date: '2026-08-01', type: 'note', title: 'x', lat: 10 });
|
||||
assert.equal(latOnly.status, 400);
|
||||
|
||||
const badLat = await agent.post(base).send({ date: '2026-08-01', type: 'note', title: 'x', lat: 99, lng: 10 });
|
||||
assert.equal(badLat.status, 400);
|
||||
|
||||
const ok = await agent.post(base).send({ date: '2026-08-01', type: 'note', title: 'x', lat: 10, lng: 20 });
|
||||
assert.equal(ok.status, 201);
|
||||
});
|
||||
|
||||
test('non-member cannot add or view entries', async () => {
|
||||
const owner = await createAccount();
|
||||
const guest = await createAccount();
|
||||
const trip = await makeTrip(owner.agent);
|
||||
|
||||
const post = await guest.agent.post(`/api/trips/${trip.id}/entries`).send({ date: '2026-08-01', type: 'note', title: 'x' });
|
||||
assert.equal(post.status, 404);
|
||||
|
||||
const list = await guest.agent.get(`/api/trips/${trip.id}/entries`);
|
||||
assert.equal(list.status, 404);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Route + summary math
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('route computes legs, totalKm and summary counts', async () => {
|
||||
const { agent } = await createAccount();
|
||||
const trip = await makeTrip(agent); // 2026-08-01 .. 2026-08-10 => 10 days
|
||||
|
||||
// Bangkok
|
||||
await agent.post(`/api/trips/${trip.id}/entries`).send({
|
||||
date: '2026-08-01', type: 'flight', title: 'Depart', sort_order: 0,
|
||||
location_name: 'Bangkok', lat: 13.7563, lng: 100.5018,
|
||||
});
|
||||
// Chiang Mai
|
||||
await agent.post(`/api/trips/${trip.id}/entries`).send({
|
||||
date: '2026-08-01', type: 'hotel', title: 'Hotel CNX', sort_order: 1,
|
||||
location_name: 'Chiang Mai', lat: 18.7883, lng: 98.9853,
|
||||
});
|
||||
// A second flight + hotel + activities + a travel leg (no coords) to exercise counts
|
||||
await agent.post(`/api/trips/${trip.id}/entries`).send({ date: '2026-08-02', type: 'flight', title: 'F2' });
|
||||
await agent.post(`/api/trips/${trip.id}/entries`).send({ date: '2026-08-02', type: 'hotel', title: 'H2' });
|
||||
await agent.post(`/api/trips/${trip.id}/entries`).send({ date: '2026-08-02', type: 'travel', title: 'Drive' });
|
||||
await agent.post(`/api/trips/${trip.id}/entries`).send({ date: '2026-08-03', type: 'activity', title: 'A1' });
|
||||
await agent.post(`/api/trips/${trip.id}/entries`).send({ date: '2026-08-03', type: 'activity', title: 'A2' });
|
||||
|
||||
const res = await agent.get(`/api/trips/${trip.id}/route`);
|
||||
assert.equal(res.status, 200);
|
||||
|
||||
// Two located stops -> one leg. BKK<->CNX great-circle ~ 585 km.
|
||||
assert.equal(res.body.stops.length, 2);
|
||||
assert.equal(res.body.legs.length, 1);
|
||||
assert.ok(res.body.legs[0].km > 570 && res.body.legs[0].km < 600, `unexpected km ${res.body.legs[0].km}`);
|
||||
assert.equal(res.body.totalKm, res.body.legs[0].km);
|
||||
|
||||
const s = res.body.summary;
|
||||
assert.equal(s.days, 10);
|
||||
assert.equal(s.nights, 9);
|
||||
assert.equal(s.flights, 2);
|
||||
assert.equal(s.hotels, 2);
|
||||
assert.equal(s.travelLegs, 1);
|
||||
assert.equal(s.activities, 2);
|
||||
assert.deepEqual(s.locations, ['Bangkok', 'Chiang Mai']);
|
||||
});
|
||||
|
||||
test('route skips zero-distance legs but keeps the stop', async () => {
|
||||
const { agent } = await createAccount();
|
||||
const trip = await makeTrip(agent);
|
||||
|
||||
await agent.post(`/api/trips/${trip.id}/entries`).send({ date: '2026-08-01', type: 'activity', title: 'A', sort_order: 0, location_name: 'Same', lat: 10, lng: 10 });
|
||||
await agent.post(`/api/trips/${trip.id}/entries`).send({ date: '2026-08-01', type: 'activity', title: 'B', sort_order: 1, location_name: 'Same', lat: 10, lng: 10 });
|
||||
|
||||
const res = await agent.get(`/api/trips/${trip.id}/route`);
|
||||
assert.equal(res.body.stops.length, 2);
|
||||
assert.equal(res.body.legs.length, 0);
|
||||
assert.equal(res.body.totalKm, 0);
|
||||
assert.deepEqual(res.body.summary.locations, ['Same']);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Geocode (mocked upstream — never hits the real Nominatim)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('geocode proxies and caches results (mocked fetch)', async () => {
|
||||
const { agent } = await createAccount();
|
||||
const original = global.fetch;
|
||||
let calls = 0;
|
||||
global.fetch = async () => {
|
||||
calls += 1;
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => [
|
||||
{ display_name: 'Chiang Mai, Thailand', lat: '18.7883', lon: '98.9853' },
|
||||
],
|
||||
};
|
||||
};
|
||||
try {
|
||||
const first = await agent.get('/api/geocode?q=Chiang%20Mai');
|
||||
assert.equal(first.status, 200);
|
||||
assert.equal(first.body.results.length, 1);
|
||||
assert.deepEqual(first.body.results[0], { name: 'Chiang Mai, Thailand', lat: 18.7883, lng: 98.9853 });
|
||||
|
||||
// Second identical query is served from cache -> fetch not called again.
|
||||
const second = await agent.get('/api/geocode?q=Chiang%20Mai');
|
||||
assert.equal(second.status, 200);
|
||||
assert.equal(calls, 1);
|
||||
} finally {
|
||||
global.fetch = original;
|
||||
}
|
||||
});
|
||||
|
||||
test('geocode returns 502 on upstream failure (mocked fetch)', async () => {
|
||||
const { agent } = await createAccount();
|
||||
const original = global.fetch;
|
||||
global.fetch = async () => {
|
||||
throw new Error('network down');
|
||||
};
|
||||
try {
|
||||
const res = await agent.get('/api/geocode?q=Nowhere');
|
||||
assert.equal(res.status, 502);
|
||||
assert.deepEqual(res.body, { error: 'geocoding unavailable' });
|
||||
} finally {
|
||||
global.fetch = original;
|
||||
}
|
||||
});
|
||||
|
||||
test('geocode requires auth and a query', async () => {
|
||||
const noAuth = await request(app).get('/api/geocode?q=x');
|
||||
assert.equal(noAuth.status, 401);
|
||||
|
||||
const { agent } = await createAccount();
|
||||
const noQuery = await agent.get('/api/geocode');
|
||||
assert.equal(noQuery.status, 400);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unknown /api route -> JSON 404
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('unknown api path returns JSON 404', async () => {
|
||||
const { agent } = await createAccount();
|
||||
const res = await agent.get('/api/does-not-exist');
|
||||
assert.equal(res.status, 404);
|
||||
assert.deepEqual(res.body, { error: 'not found' });
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
import { test, beforeEach, afterEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import request from 'supertest';
|
||||
import { createApp } from '../src/server/app.js';
|
||||
import { computeCosts } from '../src/server/util/costs.js';
|
||||
|
||||
let tmpDir;
|
||||
let app;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tripplan-costs-'));
|
||||
app = createApp({ dbPath: path.join(tmpDir, 'test.db'), sessionSecret: 'test-secret' });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
if (app.locals.db) app.locals.db.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function createAccount() {
|
||||
const agent = request.agent(app);
|
||||
const res = await agent.post('/api/auth/account').send({});
|
||||
assert.equal(res.status, 201);
|
||||
return { agent, user: res.body.user, token: res.body.token };
|
||||
}
|
||||
|
||||
// Build a trip owned by `owner`; each member agent joins via the join code.
|
||||
async function costTrip(owner, memberAccounts = []) {
|
||||
const trip = (await owner.agent.post('/api/trips').send({
|
||||
name: 'Costs', start_date: '2026-08-01', end_date: '2026-08-05', currency: 'USD',
|
||||
})).body.trip;
|
||||
for (const m of memberAccounts) {
|
||||
const res = await m.agent.post('/api/trips/join').send({ code: trip.join_code });
|
||||
assert.equal(res.status, 200);
|
||||
}
|
||||
return trip;
|
||||
}
|
||||
|
||||
function findUser(costs, userId) {
|
||||
return costs.perUser.find((u) => u.userId === userId);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Currency
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('currency: defaults to USD, validates format, patchable', async () => {
|
||||
const { agent } = await createAccount();
|
||||
|
||||
const def = await agent.post('/api/trips').send({ name: 'T', start_date: '2026-08-01', end_date: '2026-08-05' });
|
||||
assert.equal(def.status, 201);
|
||||
assert.equal(def.body.trip.currency, 'USD');
|
||||
|
||||
const bad = await agent.post('/api/trips').send({ name: 'T', start_date: '2026-08-01', end_date: '2026-08-05', currency: 'us' });
|
||||
assert.equal(bad.status, 400);
|
||||
|
||||
const eur = await agent.post('/api/trips').send({ name: 'T', start_date: '2026-08-01', end_date: '2026-08-05', currency: 'EUR' });
|
||||
assert.equal(eur.status, 201);
|
||||
assert.equal(eur.body.trip.currency, 'EUR');
|
||||
|
||||
const patched = await agent.patch(`/api/trips/${eur.body.trip.id}`).send({ currency: 'THB' });
|
||||
assert.equal(patched.status, 200);
|
||||
assert.equal(patched.body.trip.currency, 'THB');
|
||||
|
||||
const listed = await agent.get('/api/trips');
|
||||
assert.ok(listed.body.trips.every((t) => typeof t.currency === 'string'));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Costs & splitting (endpoint)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('costs: equal split with payer produces net balances and a settlement', async () => {
|
||||
const anna = await createAccount();
|
||||
const ben = await createAccount();
|
||||
const trip = await costTrip(anna, [ben]);
|
||||
|
||||
// anna pays 950 (hotel), ben pays 500 (travel); both split equally between the two.
|
||||
await anna.agent.post(`/api/trips/${trip.id}/entries`).send({
|
||||
date: '2026-08-01', type: 'hotel', title: 'Hotel', price: 950, paid_by: anna.user.id,
|
||||
});
|
||||
await anna.agent.post(`/api/trips/${trip.id}/entries`).send({
|
||||
date: '2026-08-02', type: 'travel', title: 'Van', price: 500, paid_by: ben.user.id,
|
||||
});
|
||||
|
||||
const res = await anna.agent.get(`/api/trips/${trip.id}/costs`);
|
||||
assert.equal(res.status, 200);
|
||||
const c = res.body;
|
||||
assert.equal(c.currency, 'USD');
|
||||
assert.equal(c.totalCost, 1450);
|
||||
assert.deepEqual(c.byType, { hotel: 950, travel: 500 });
|
||||
assert.equal(c.unassigned, 0);
|
||||
|
||||
const a = findUser(c, anna.user.id);
|
||||
const b = findUser(c, ben.user.id);
|
||||
assert.equal(typeof a.displayName, 'string');
|
||||
assert.deepEqual([a.share, a.paid, a.net], [725, 950, 225]);
|
||||
assert.deepEqual([b.share, b.paid, b.net], [725, 500, -225]);
|
||||
|
||||
assert.equal(c.settlements.length, 1);
|
||||
assert.deepEqual(c.settlements[0], { fromUserId: ben.user.id, toUserId: anna.user.id, amount: 225 });
|
||||
});
|
||||
|
||||
test('costs: own mode creates no debt and totals price x participants', async () => {
|
||||
const anna = await createAccount();
|
||||
const ben = await createAccount();
|
||||
const trip = await costTrip(anna, [ben]);
|
||||
|
||||
await anna.agent.post(`/api/trips/${trip.id}/entries`).send({
|
||||
date: '2026-08-01', type: 'flight', title: 'Flights', price: 300, split_mode: 'own',
|
||||
});
|
||||
|
||||
const c = (await anna.agent.get(`/api/trips/${trip.id}/costs`)).body;
|
||||
assert.equal(c.totalCost, 600); // 300 per person x 2
|
||||
assert.deepEqual(c.byType, { flight: 600 });
|
||||
for (const u of c.perUser) {
|
||||
assert.deepEqual([u.share, u.paid, u.net], [300, 300, 0]);
|
||||
}
|
||||
assert.deepEqual(c.settlements, []);
|
||||
assert.equal(c.unassigned, 0);
|
||||
});
|
||||
|
||||
test('costs: payer mode is a personal expense (requires paid_by)', async () => {
|
||||
const anna = await createAccount();
|
||||
const ben = await createAccount();
|
||||
const trip = await costTrip(anna, [ben]);
|
||||
|
||||
// payer without paid_by -> validation error
|
||||
const bad = await anna.agent.post(`/api/trips/${trip.id}/entries`).send({
|
||||
date: '2026-08-01', type: 'activity', title: 'Solo tour', price: 100, split_mode: 'payer',
|
||||
});
|
||||
assert.equal(bad.status, 400);
|
||||
|
||||
await anna.agent.post(`/api/trips/${trip.id}/entries`).send({
|
||||
date: '2026-08-01', type: 'activity', title: 'Solo tour', price: 100, split_mode: 'payer', paid_by: ben.user.id,
|
||||
});
|
||||
|
||||
const c = (await anna.agent.get(`/api/trips/${trip.id}/costs`)).body;
|
||||
assert.equal(c.totalCost, 100);
|
||||
const a = findUser(c, anna.user.id);
|
||||
const b = findUser(c, ben.user.id);
|
||||
assert.deepEqual([a.share, a.paid, a.net], [0, 0, 0]);
|
||||
assert.deepEqual([b.share, b.paid, b.net], [100, 100, 0]);
|
||||
assert.deepEqual(c.settlements, []);
|
||||
});
|
||||
|
||||
test('costs: participants subset only splits among the chosen members', async () => {
|
||||
const anna = await createAccount();
|
||||
const ben = await createAccount();
|
||||
const carol = await createAccount();
|
||||
const trip = await costTrip(anna, [ben, carol]);
|
||||
|
||||
// 90 split equally between anna & ben only (carol excluded), anna pays.
|
||||
await anna.agent.post(`/api/trips/${trip.id}/entries`).send({
|
||||
date: '2026-08-01', type: 'travel', title: 'Taxi', price: 90, paid_by: anna.user.id,
|
||||
participants: [anna.user.id, ben.user.id],
|
||||
});
|
||||
|
||||
const c = (await anna.agent.get(`/api/trips/${trip.id}/costs`)).body;
|
||||
assert.equal(c.totalCost, 90);
|
||||
const a = findUser(c, anna.user.id);
|
||||
assert.deepEqual([a.share, a.paid, a.net], [45, 90, 45]);
|
||||
assert.deepEqual([findUser(c, ben.user.id).share, findUser(c, ben.user.id).net], [45, -45]);
|
||||
assert.deepEqual([findUser(c, carol.user.id).share, findUser(c, carol.user.id).net], [0, 0]);
|
||||
assert.equal(c.settlements.length, 1);
|
||||
assert.deepEqual(c.settlements[0], { fromUserId: ben.user.id, toUserId: anna.user.id, amount: 45 });
|
||||
});
|
||||
|
||||
test('costs: equal with no payer accumulates into unassigned', async () => {
|
||||
const anna = await createAccount();
|
||||
const ben = await createAccount();
|
||||
const trip = await costTrip(anna, [ben]);
|
||||
|
||||
await anna.agent.post(`/api/trips/${trip.id}/entries`).send({
|
||||
date: '2026-08-01', type: 'hotel', title: 'Hotel', price: 200, // paid_by omitted (null)
|
||||
});
|
||||
|
||||
const c = (await anna.agent.get(`/api/trips/${trip.id}/costs`)).body;
|
||||
assert.equal(c.totalCost, 200);
|
||||
assert.equal(c.unassigned, 200);
|
||||
assert.deepEqual(c.byType, { hotel: 200 });
|
||||
for (const u of c.perUser) {
|
||||
assert.deepEqual([u.share, u.paid, u.net], [100, 0, -100]);
|
||||
}
|
||||
// Nobody paid, so there is no creditor to settle toward.
|
||||
assert.deepEqual(c.settlements, []);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Costs computation (pure unit test — greedy minimal-transfer)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('computeCosts: greedy settlement matches largest debtor with largest creditor', () => {
|
||||
const members = [
|
||||
{ id: 1, display_name: 'brave-otter' },
|
||||
{ id: 2, display_name: 'calm-heron' },
|
||||
{ id: 3, display_name: 'wise-lynx' },
|
||||
];
|
||||
// id 1 pays 300 for a 3-way equal split (100 each); id 1 is owed 200 total.
|
||||
const c = computeCosts({
|
||||
currency: 'USD',
|
||||
members,
|
||||
entries: [{ type: 'hotel', price: 300, paid_by: 1, split_mode: 'equal', participants: [] }],
|
||||
});
|
||||
assert.equal(c.totalCost, 300);
|
||||
assert.equal(findUser(c, 1).displayName, 'brave-otter');
|
||||
assert.equal(findUser(c, 1).net, 200);
|
||||
assert.equal(findUser(c, 2).net, -100);
|
||||
assert.equal(findUser(c, 3).net, -100);
|
||||
// Two transfers of 100 into the single creditor (id 1).
|
||||
assert.equal(c.settlements.length, 2);
|
||||
assert.ok(c.settlements.every((s) => s.toUserId === 1 && s.amount === 100));
|
||||
});
|
||||
@@ -0,0 +1,227 @@
|
||||
import { test, beforeEach, afterEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import request from 'supertest';
|
||||
import { createApp } from '../src/server/app.js';
|
||||
|
||||
let tmpDir;
|
||||
let app;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tripplan-flights-'));
|
||||
app = createApp({ dbPath: path.join(tmpDir, 'test.db'), sessionSecret: 'test-secret' });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
if (app.locals.db) app.locals.db.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function createAccount() {
|
||||
const agent = request.agent(app);
|
||||
const res = await agent.post('/api/auth/account').send({});
|
||||
assert.equal(res.status, 201);
|
||||
return { agent, user: res.body.user };
|
||||
}
|
||||
|
||||
async function makeTrip(agent) {
|
||||
return (await agent.post('/api/trips').send({ name: 'F', start_date: '2026-08-01', end_date: '2026-08-10' })).body.trip;
|
||||
}
|
||||
|
||||
const CNX = { code: 'CNX', name: 'Chiang Mai Intl', lat: 18.77, lng: 98.96 };
|
||||
const BKK = { code: 'BKK', name: 'Suvarnabhumi', lat: 13.68, lng: 100.75 };
|
||||
const DXB = { code: 'DXB', name: 'Dubai Intl', lat: 25.25, lng: 55.36 };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Segments validation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('segments: accepted on flight, parsed back, codes normalized to uppercase', async () => {
|
||||
const { agent } = await createAccount();
|
||||
const trip = await makeTrip(agent);
|
||||
|
||||
const res = await agent.post(`/api/trips/${trip.id}/entries`).send({
|
||||
date: '2026-08-01', type: 'flight', title: 'CNX-BKK-DXB',
|
||||
segments: [
|
||||
{ flight_no: 'TG103', dep_time: '10:30', arr_time: '11:45', from: { code: 'cnx', ...{ name: CNX.name, lat: CNX.lat, lng: CNX.lng } }, to: BKK },
|
||||
{ flight_no: 'EK385', from: BKK, to: DXB },
|
||||
],
|
||||
});
|
||||
assert.equal(res.status, 201);
|
||||
assert.equal(res.body.entry.segments.length, 2);
|
||||
assert.equal(res.body.entry.segments[0].from.code, 'CNX'); // normalized
|
||||
assert.equal(res.body.entry.segments[0].flight_no, 'TG103');
|
||||
assert.equal(res.body.entry.segments[1].to.code, 'DXB');
|
||||
});
|
||||
|
||||
test('segments: rejected on non-flight entries', async () => {
|
||||
const { agent } = await createAccount();
|
||||
const trip = await makeTrip(agent);
|
||||
const res = await agent.post(`/api/trips/${trip.id}/entries`).send({
|
||||
date: '2026-08-01', type: 'note', title: 'Nope',
|
||||
segments: [{ from: CNX, to: BKK }],
|
||||
});
|
||||
assert.equal(res.status, 400);
|
||||
});
|
||||
|
||||
test('segments: bad code and out-of-range count rejected', async () => {
|
||||
const { agent } = await createAccount();
|
||||
const trip = await makeTrip(agent);
|
||||
const base = `/api/trips/${trip.id}/entries`;
|
||||
|
||||
const badCode = await agent.post(base).send({
|
||||
date: '2026-08-01', type: 'flight', title: 'x',
|
||||
segments: [{ from: { code: 'TOOLONG' }, to: BKK }],
|
||||
});
|
||||
assert.equal(badCode.status, 400);
|
||||
|
||||
const empty = await agent.post(base).send({
|
||||
date: '2026-08-01', type: 'flight', title: 'x', segments: [],
|
||||
});
|
||||
assert.equal(empty.status, 400);
|
||||
|
||||
const tooMany = await agent.post(base).send({
|
||||
date: '2026-08-01', type: 'flight', title: 'x',
|
||||
segments: Array.from({ length: 9 }, () => ({ from: CNX, to: BKK })),
|
||||
});
|
||||
assert.equal(tooMany.status, 400);
|
||||
});
|
||||
|
||||
test('segments: PATCH with null clears them', async () => {
|
||||
const { agent } = await createAccount();
|
||||
const trip = await makeTrip(agent);
|
||||
const entry = (await agent.post(`/api/trips/${trip.id}/entries`).send({
|
||||
date: '2026-08-01', type: 'flight', title: 'x', segments: [{ from: CNX, to: BKK }],
|
||||
})).body.entry;
|
||||
assert.equal(entry.segments.length, 1);
|
||||
|
||||
const cleared = await agent.patch(`/api/entries/${entry.id}`).send({ segments: null });
|
||||
assert.equal(cleared.status, 200);
|
||||
assert.equal(cleared.body.entry.segments, null);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Route expansion
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('route expands a CNX-BKK-DXB flight into airport stops and legs', async () => {
|
||||
const { agent } = await createAccount();
|
||||
const trip = await makeTrip(agent);
|
||||
|
||||
await agent.post(`/api/trips/${trip.id}/entries`).send({
|
||||
date: '2026-08-01', type: 'flight', title: 'CNX-BKK-DXB',
|
||||
segments: [
|
||||
{ flight_no: 'TG103', from: CNX, to: BKK },
|
||||
{ flight_no: 'EK385', from: BKK, to: DXB },
|
||||
],
|
||||
});
|
||||
|
||||
const res = await agent.get(`/api/trips/${trip.id}/route`);
|
||||
assert.equal(res.status, 200);
|
||||
|
||||
// from(CNX) + to(BKK) + to(DXB) = 3 stops, consecutive dup (BKK) collapsed.
|
||||
assert.equal(res.body.stops.length, 3);
|
||||
assert.deepEqual(res.body.stops.map((s) => s.code), ['CNX', 'BKK', 'DXB']);
|
||||
assert.ok(res.body.stops.every((s) => s.kind === 'airport'));
|
||||
|
||||
assert.equal(res.body.legs.length, 2);
|
||||
assert.ok(res.body.legs.every((l) => l.km > 0));
|
||||
// Both legs connect airport stops from the same flight entry -> air.
|
||||
assert.ok(res.body.legs.every((l) => l.mode === 'air'));
|
||||
assert.ok(res.body.totalKm > 0);
|
||||
|
||||
assert.equal(res.body.summary.flights, 1);
|
||||
assert.equal(res.body.summary.flightSegments, 2);
|
||||
assert.equal(res.body.summary.kmAir, res.body.totalKm);
|
||||
assert.equal(res.body.summary.kmDriven, 0);
|
||||
});
|
||||
|
||||
test('route: mixed ground transfers + air segments split kmAir / kmDriven', async () => {
|
||||
const { agent } = await createAccount();
|
||||
const trip = await makeTrip(agent);
|
||||
|
||||
// Hotel near CNX (ground), then the CNX-BKK-DXB flight, then a hotel near DXB.
|
||||
await agent.post(`/api/trips/${trip.id}/entries`).send({
|
||||
date: '2026-08-01', type: 'hotel', title: 'CNX Hotel', sort_order: 0,
|
||||
location_name: 'Chiang Mai', lat: 18.79, lng: 98.99,
|
||||
});
|
||||
await agent.post(`/api/trips/${trip.id}/entries`).send({
|
||||
date: '2026-08-01', type: 'flight', title: 'CNX-BKK-DXB', sort_order: 1,
|
||||
segments: [
|
||||
{ flight_no: 'TG103', from: CNX, to: BKK },
|
||||
{ flight_no: 'EK385', from: BKK, to: DXB },
|
||||
],
|
||||
});
|
||||
await agent.post(`/api/trips/${trip.id}/entries`).send({
|
||||
date: '2026-08-02', type: 'hotel', title: 'DXB Hotel', sort_order: 2,
|
||||
location_name: 'Dubai', lat: 25.2, lng: 55.27,
|
||||
});
|
||||
|
||||
const res = await agent.get(`/api/trips/${trip.id}/route`);
|
||||
assert.equal(res.status, 200);
|
||||
|
||||
// Stops: CNX-hotel, CNX, BKK, DXB, DXB-hotel = 5.
|
||||
assert.equal(res.body.stops.length, 5);
|
||||
assert.deepEqual(res.body.legs.map((l) => l.mode), ['ground', 'air', 'air', 'ground']);
|
||||
|
||||
const s = res.body.summary;
|
||||
assert.ok(s.kmAir > 0 && s.kmDriven > 0);
|
||||
// Air = the two inter-airport legs; driven = the two hotel<->airport transfers.
|
||||
assert.ok(Math.abs(s.kmAir - (res.body.legs[1].km + res.body.legs[2].km)) < 0.11);
|
||||
assert.ok(Math.abs(s.kmDriven - (res.body.legs[0].km + res.body.legs[3].km)) < 0.11);
|
||||
assert.ok(Math.abs(s.kmAir + s.kmDriven - res.body.totalKm) < 0.2);
|
||||
});
|
||||
|
||||
test('route: segmentless flight falls back to entry lat/lng', async () => {
|
||||
const { agent } = await createAccount();
|
||||
const trip = await makeTrip(agent);
|
||||
await agent.post(`/api/trips/${trip.id}/entries`).send({
|
||||
date: '2026-08-01', type: 'flight', title: 'plain', location_name: 'Bangkok', lat: 13.68, lng: 100.75,
|
||||
});
|
||||
const res = await agent.get(`/api/trips/${trip.id}/route`);
|
||||
assert.equal(res.body.stops.length, 1);
|
||||
assert.equal(res.body.stops[0].kind, undefined);
|
||||
assert.equal(res.body.summary.flightSegments, 0);
|
||||
// No legs -> both per-mode sums are 0.
|
||||
assert.equal(res.body.legs.length, 0);
|
||||
assert.equal(res.body.summary.kmAir, 0);
|
||||
assert.equal(res.body.summary.kmDriven, 0);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Airports lookup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('airports: exact IATA match ranks first; requires auth', async () => {
|
||||
const noAuth = await request(app).get('/api/airports?q=CNX');
|
||||
assert.equal(noAuth.status, 401);
|
||||
|
||||
const { agent } = await createAccount();
|
||||
const res = await agent.get('/api/airports?q=CNX');
|
||||
assert.equal(res.status, 200);
|
||||
assert.ok(res.body.results.length >= 1);
|
||||
assert.ok(res.body.results.length <= 8);
|
||||
assert.equal(res.body.results[0].code, 'CNX');
|
||||
assert.match(res.body.results[0].name, /Chiang Mai/i);
|
||||
assert.deepEqual(
|
||||
Object.keys(res.body.results[0]).sort(),
|
||||
['city', 'code', 'country', 'lat', 'lng', 'name'].sort()
|
||||
);
|
||||
});
|
||||
|
||||
test('airports: case-insensitive and empty query returns empty list', async () => {
|
||||
const { agent } = await createAccount();
|
||||
|
||||
const lower = await agent.get('/api/airports?q=cnx');
|
||||
assert.equal(lower.body.results[0].code, 'CNX');
|
||||
|
||||
const empty = await agent.get('/api/airports');
|
||||
assert.equal(empty.status, 200);
|
||||
assert.deepEqual(empty.body.results, []);
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
// Test barrel. The package.json `test` script runs `node --test tests/`, which
|
||||
// on this Node build resolves the directory to `tests/index.js` and runs it as
|
||||
// a single test file rather than scanning the directory. Importing each test
|
||||
// module here registers its tests with the runner. Add new test files below.
|
||||
//
|
||||
// These run in one process, so each test file scopes its fixtures with
|
||||
// top-level beforeEach/afterEach that touch only its own module-level
|
||||
// app/tmpDir — keeping the files independent.
|
||||
import './api.test.js';
|
||||
import './costs.test.js';
|
||||
import './flights.test.js';
|
||||
import './rental.test.js';
|
||||
@@ -0,0 +1,153 @@
|
||||
import { test, beforeEach, afterEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import request from 'supertest';
|
||||
import { createApp } from '../src/server/app.js';
|
||||
|
||||
let tmpDir;
|
||||
let app;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tripplan-rental-'));
|
||||
app = createApp({ dbPath: path.join(tmpDir, 'test.db'), sessionSecret: 'test-secret' });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
if (app.locals.db) app.locals.db.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function createAccount() {
|
||||
const agent = request.agent(app);
|
||||
const res = await agent.post('/api/auth/account').send({});
|
||||
assert.equal(res.status, 201);
|
||||
return { agent, user: res.body.user };
|
||||
}
|
||||
|
||||
async function makeTrip(agent) {
|
||||
return (await agent.post('/api/trips').send({ name: 'R', start_date: '2026-08-01', end_date: '2026-08-10' })).body.trip;
|
||||
}
|
||||
|
||||
const RENTAL = {
|
||||
brand: 'Toyota', model: 'Yaris Cross', car_type: 'SUV',
|
||||
booking_ref: 'RC-889231', included_km: 1500,
|
||||
pickup: { date: '2026-08-01', time: '09:00', location_name: 'CNX Airport', lat: 18.77, lng: 98.96 },
|
||||
dropoff: { date: '2026-08-07', time: '18:00', location_name: 'Old Town', lat: 18.79, lng: 98.98 },
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rental validation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('rental: accepted on rental entry, parsed back, cleared via null', async () => {
|
||||
const { agent } = await createAccount();
|
||||
const trip = await makeTrip(agent);
|
||||
|
||||
const res = await agent.post(`/api/trips/${trip.id}/entries`).send({
|
||||
date: '2026-08-01', type: 'rental', title: 'Car', rental: RENTAL,
|
||||
});
|
||||
assert.equal(res.status, 201);
|
||||
const r = res.body.entry.rental;
|
||||
assert.equal(r.brand, 'Toyota');
|
||||
assert.equal(r.included_km, 1500);
|
||||
assert.equal(r.pickup.location_name, 'CNX Airport');
|
||||
assert.equal(r.dropoff.date, '2026-08-07');
|
||||
|
||||
const cleared = await agent.patch(`/api/entries/${res.body.entry.id}`).send({ rental: null });
|
||||
assert.equal(cleared.status, 200);
|
||||
assert.equal(cleared.body.entry.rental, null);
|
||||
});
|
||||
|
||||
test('rental: rejected on non-rental entries', async () => {
|
||||
const { agent } = await createAccount();
|
||||
const trip = await makeTrip(agent);
|
||||
const res = await agent.post(`/api/trips/${trip.id}/entries`).send({
|
||||
date: '2026-08-01', type: 'hotel', title: 'Nope', rental: RENTAL,
|
||||
});
|
||||
assert.equal(res.status, 400);
|
||||
});
|
||||
|
||||
test('rental: bad shapes rejected', async () => {
|
||||
const { agent } = await createAccount();
|
||||
const trip = await makeTrip(agent);
|
||||
const base = `/api/trips/${trip.id}/entries`;
|
||||
|
||||
const longBrand = await agent.post(base).send({
|
||||
date: '2026-08-01', type: 'rental', title: 'x', rental: { brand: 'z'.repeat(61) },
|
||||
});
|
||||
assert.equal(longBrand.status, 400);
|
||||
|
||||
const negKm = await agent.post(base).send({
|
||||
date: '2026-08-01', type: 'rental', title: 'x', rental: { included_km: -5 },
|
||||
});
|
||||
assert.equal(negKm.status, 400);
|
||||
|
||||
const noDate = await agent.post(base).send({
|
||||
date: '2026-08-01', type: 'rental', title: 'x', rental: { pickup: { time: '09:00' } },
|
||||
});
|
||||
assert.equal(noDate.status, 400);
|
||||
|
||||
const halfCoord = await agent.post(base).send({
|
||||
date: '2026-08-01', type: 'rental', title: 'x', rental: { pickup: { date: '2026-08-01', lat: 18.77 } },
|
||||
});
|
||||
assert.equal(halfCoord.status, 400);
|
||||
});
|
||||
|
||||
test('rental: minimal object with only included_km null is accepted', async () => {
|
||||
const { agent } = await createAccount();
|
||||
const trip = await makeTrip(agent);
|
||||
const res = await agent.post(`/api/trips/${trip.id}/entries`).send({
|
||||
date: '2026-08-01', type: 'rental', title: 'Car', rental: { brand: 'Kia', included_km: null },
|
||||
});
|
||||
assert.equal(res.status, 201);
|
||||
assert.equal(res.body.entry.rental.included_km, null);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Summary + costs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('route summary: rentals count and includedKm sum', async () => {
|
||||
const { agent } = await createAccount();
|
||||
const trip = await makeTrip(agent);
|
||||
|
||||
await agent.post(`/api/trips/${trip.id}/entries`).send({
|
||||
date: '2026-08-01', type: 'rental', title: 'Car A', rental: { included_km: 1500 },
|
||||
});
|
||||
await agent.post(`/api/trips/${trip.id}/entries`).send({
|
||||
date: '2026-08-05', type: 'rental', title: 'Car B', rental: { included_km: 300 },
|
||||
});
|
||||
|
||||
const res = await agent.get(`/api/trips/${trip.id}/route`);
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.summary.rentals, 2);
|
||||
assert.equal(res.body.summary.includedKm, 1800);
|
||||
});
|
||||
|
||||
test('route summary: includedKm is null when no rental specifies one', async () => {
|
||||
const { agent } = await createAccount();
|
||||
const trip = await makeTrip(agent);
|
||||
await agent.post(`/api/trips/${trip.id}/entries`).send({
|
||||
date: '2026-08-01', type: 'rental', title: 'Car', rental: { brand: 'Kia' },
|
||||
});
|
||||
const res = await agent.get(`/api/trips/${trip.id}/route`);
|
||||
assert.equal(res.body.summary.rentals, 1);
|
||||
assert.equal(res.body.summary.includedKm, null);
|
||||
});
|
||||
|
||||
test('costs byType includes rental', async () => {
|
||||
const { agent } = await createAccount();
|
||||
const trip = await makeTrip(agent);
|
||||
await agent.post(`/api/trips/${trip.id}/entries`).send({
|
||||
date: '2026-08-01', type: 'rental', title: 'Car', price: 240, rental: { brand: 'Kia' },
|
||||
});
|
||||
const c = (await agent.get(`/api/trips/${trip.id}/costs`)).body;
|
||||
assert.equal(c.totalCost, 240);
|
||||
assert.equal(c.byType.rental, 240);
|
||||
});
|
||||
Reference in New Issue
Block a user