feat: Initial release of LOADOUT - Travel Manifest & Bag Allocation System

This commit is contained in:
airent106 committed 2026-10-15 11:01:26 -06:00
commit 805f3fe97a
26 files changed
+7618

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
node_modules
dist
.DS_Store
*.local
data/
+39
View File
@@ -0,0 +1,39 @@
# Build stage
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . ./
RUN npm run build
# Production stage
FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=80
ENV DATA_DIR=/app/data
# Install production dependencies
COPY package*.json ./
RUN npm install --omit=dev
# Copy backend server code
COPY server/ ./server/
# Copy built frontend assets
COPY --from=builder /app/dist ./dist
# Create persistent storage directories
RUN mkdir -p /app/data/uploads
EXPOSE 80
HEALTHCHECK --interval=15s --timeout=5s --start-period=5s --retries=3 \
CMD wget --quiet --tries=1 --spider http://127.0.0.1:80/api/health || exit 1
CMD ["node", "server/index.js"]
+82
View File
@@ -0,0 +1,82 @@
# LOADOUT // Travel Manifest & Bag Allocation System
> **LOADOUT** is a tactile, retro-ephemera inspired travel packing manifest and luggage allocation system designed for purposeful travelers. Built with a boarding pass and cargo ticket aesthetic, continuous post-trip debrief learning, and modular bag management ("What goes in what bag").
---
## Key Features
### 1. Boarding Pass & Retro Ephemera Aesthetic
* **Distinctive Swiss Grotesk & Telemetry Monospace**: Uses `Bebas Neue` uppercase departure board headers, `Space Grotesk` structural typography, and `IBM Plex Mono` for flight codes, weights, and timestamps.
* **Tactile Perforated Stubs**: Items are rendered as tear-off baggage coupons with circular notch punchouts and dashed perforation lines.
* **Canary Yellow & Gate Orange Accents**: High-contrast utilitarian colorway with rubber stamp badges (`[STOWED]`, `[EXCLUDED]`, `[WISHLIST]`).
* **Built-in Tactile Web Audio**: Physical paper tear sounds upon checking off items and mechanical stamp thuds when stowing bags or filing debriefs—zero audio file downloads required.
### 2. Luggage Allocation ("What Goes In What Bag")
* Assign and visualize items across distinct containers:
* 🎒 **Personal Item / Daypack**: Under-seat essentials (passport, power bank, medications, AirPods, Kindle, cables).
* 🧳 **Carry-On Roller**: Overhead compartment (clothes packing cubes, 3-1-1 TSA compliant liquids pouch).
* 📦 **Checked Luggage**: Cargo hold (full-size liquids > 100ml, trekking poles, pocket knives, heavy boots).
* 🧥 **Worn on Person**: Travel day outfit (heaviest jacket, boots, watch to minimize bag weight).
* Dedicated visual bag overview cards with individual capacity meters and TSA advisory reminders.
* One-click re-allocation to move any item between bags on the fly.
### 3. Continuous Learning Loop (Post-Trip Debrief)
* At the conclusion of your voyage, launch the **Trip Debrief**:
* **Identify Deadweight (What Should Have Been Excluded)**: Tap items you packed but never used. The engine remembers this and penalizes or omits them on future matching voyages.
* **Log Missing Items (What Should Have Been Included)**: Record items you wished you had on the road. The engine automatically suggests them when planning future trips of that archetype.
* **Flight Log Archive**: Inspect accumulated packing wisdom, deadweight statistics, and learned discovery rules anytime.
### 4. Smart Heuristic Engine & AI Booster
* **Deterministic Multiplier Formulas**: Calculates clothing requirements dynamically based on days, climate, and mid-trip laundry access (e.g., 7 days with laundry caps duplicate clothing to ~4 days).
* **Optional AI Dispatcher**: Connect your local Ollama instance running on `vloser` or a cloud API key for customized destination recommendations that blend with your personal learned rules.
* **100% Offline Capability**: Full client-side fallback via `localStorage`—works seamlessly in airplane mode or remote backcountry camps.
---
## Self-Hosting with Docker & Traefik on `vloser`
Loadout is packaged as a lightweight Alpine Node container with persistent JSON storage:
```bash
docker compose up -d --build
```
### Compose Configuration (`docker-compose.yml`)
```yaml
services:
loadout:
build:
context: .
dockerfile: Dockerfile
container_name: loadout
restart: unless-stopped
volumes:
- /home/chrisf/docker/appdata/loadout/data:/app/data
networks:
- t3_proxy
ports:
- "127.0.0.1:8478:80"
healthcheck:
test: ["CMD-SHELL", "wget --quiet --tries=1 --spider http://127.0.0.1:80/api/health || exit 1"]
interval: 15s
timeout: 5s
retries: 3
start_period: 5s
labels:
- "traefik.enable=true"
- "traefik.http.routers.loadout.rule=Host(`loadout.vloser.us`)"
- "traefik.http.routers.loadout.entrypoints=websecure"
- "traefik.http.routers.loadout.tls.certresolver=letsencrypt"
- "traefik.http.services.loadout.loadbalancer.server.port=80"
networks:
t3_proxy:
external: true
```
Data is stored persistently in `/app/data`:
* `trips.json` — All past, active, and upcoming travel manifests
* `memory.json` — Learned exclusions, inclusions, and debrief statistics
* `settings.json` — Audio preferences and optional AI endpoint configuration
+29
View File
@@ -0,0 +1,29 @@
services:
loadout:
build:
context: .
dockerfile: Dockerfile
container_name: loadout
restart: unless-stopped
volumes:
- /home/chrisf/docker/appdata/loadout/data:/app/data
networks:
- t3_proxy
ports:
- "127.0.0.1:8478:80"
healthcheck:
test: ["CMD-SHELL", "wget --quiet --tries=1 --spider http://127.0.0.1:80/api/health || exit 1"]
interval: 15s
timeout: 5s
retries: 3
start_period: 5s
labels:
- "traefik.enable=true"
- "traefik.http.routers.loadout.rule=Host(`loadout.vloser.us`)"
- "traefik.http.routers.loadout.entrypoints=websecure"
- "traefik.http.routers.loadout.tls.certresolver=letsencrypt"
- "traefik.http.services.loadout.loadbalancer.server.port=80"
networks:
t3_proxy:
external: true
+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='70' height='70' x='15' y='15' fill='%23FFE279' rx='4' transform='rotate(6 50 50)'/><rect width='65' height='65' x='18' y='18' fill='%23FFF9A6' rx='3' transform='rotate(-4 50 50)'/></svg>" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>LOADOUT // Travel Manifest & Bag Allocation</title>
<!-- Google Fonts for retro travel ephemera, boarding pass typography, and tactile monospace -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Bebas+Neue&family=IBM+Plex+Mono:ital,wght@0,400;0,500;0,600;0,700;1,400&family=Plus+Jakarta+Sans:ital,wght@0,400;0,500;0,600;0,700;0,800;1,400&family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">
</head>
<body class="bg-[#F4F1EA] text-[#141518] antialiased selection:bg-[#FFD000] selection:text-black">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+3853
View File
File diff suppressed because it is too large. Load diff
+36
View File
@@ -0,0 +1,36 @@
{
"name": "loadout",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"start": "node server/index.js"
},
"dependencies": {
"clsx": "^2.1.1",
"cors": "^2.8.6",
"express": "^5.3.0",
"lucide-react": "^1.16.0",
"multer": "^2.4.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwind-merge": "^3.0.2"
},
"devDependencies": {
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/multer": "^2.3.0",
"@types/node": "^22.13.9",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"postcss": "^8.5.3",
"tailwindcss": "^3.4.17",
"typescript": "^5.8.2",
"vite": "^6.2.0"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+270
View File
@@ -0,0 +1,270 @@
import express from 'express';
import cors from 'cors';
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
const PORT = process.env.PORT || 80;
const DATA_DIR = process.env.DATA_DIR || path.resolve(__dirname, '../data');
// Storage file paths
const TRIPS_FILE = path.join(DATA_DIR, 'trips.json');
const MEMORY_FILE = path.join(DATA_DIR, 'memory.json');
const SETTINGS_FILE = path.join(DATA_DIR, 'settings.json');
// Ensure storage directory exists
if (!fs.existsSync(DATA_DIR)) {
fs.mkdirSync(DATA_DIR, { recursive: true });
}
// Helpers for safe file I/O
function readJson(file, defaultVal) {
try {
if (fs.existsSync(file)) {
const data = fs.readFileSync(file, 'utf8');
return JSON.parse(data);
}
} catch (err) {
console.error(`Error reading ${file}:`, err);
}
return defaultVal;
}
function writeJson(file, data) {
try {
fs.writeFileSync(file, JSON.stringify(data, null, 2), 'utf8');
} catch (err) {
console.error(`Error writing ${file}:`, err);
}
}
// Seed default memory if empty
if (!fs.existsSync(MEMORY_FILE)) {
writeJson(MEMORY_FILE, {
exclusions: [
{ itemName: 'Heavy winter coat', tripType: 'weekend_getaway', count: 1, lastDate: '2026-08-10' },
{ itemName: 'Full size hair dryer', tripType: 'beach', count: 2, lastDate: '2026-07-14' },
{ itemName: 'Extra pair of dress shoes', tripType: 'business', count: 1, lastDate: '2026-09-02' },
],
inclusions: [
{
itemName: 'Packable laundry detergent sheets',
category: 'clothing',
defaultBag: 'carry_on',
tripType: 'international_urban',
count: 2,
lastDate: '2026-06-20',
reason: 'Saves hotel laundry fees; perfect for sink-washing socks/shirts',
},
{
itemName: 'Compact 3-prong outlet splitter',
category: 'tech',
defaultBag: 'personal_item',
tripType: 'business',
count: 2,
lastDate: '2026-09-02',
reason: 'Airport and hotel outlets are always limited or loose',
},
],
totalTripsDebriefed: 2,
});
}
// Middleware
app.use(cors());
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
// Healthcheck endpoint for Docker & Traefik
app.get('/api/health', (_req, res) => {
res.json({
status: 'ok',
app: 'loadout',
timestamp: new Date().toISOString(),
uptimeSeconds: Math.floor(process.uptime()),
});
});
// GET /api/trips
app.get('/api/trips', (_req, res) => {
const trips = readJson(TRIPS_FILE, []);
res.json(trips);
});
// POST /api/trips
app.post('/api/trips', (req, res) => {
const trip = req.body;
if (!trip || !trip.id) {
return res.status(400).json({ error: 'Invalid trip data' });
}
const trips = readJson(TRIPS_FILE, []);
const index = trips.findIndex((t) => t.id === trip.id);
if (index >= 0) {
trips[index] = trip;
} else {
trips.unshift(trip);
}
writeJson(TRIPS_FILE, trips);
res.json({ success: true, trip });
});
// DELETE /api/trips/:id
app.delete('/api/trips/:id', (req, res) => {
const { id } = req.params;
let trips = readJson(TRIPS_FILE, []);
trips = trips.filter((t) => t.id !== id);
writeJson(TRIPS_FILE, trips);
res.json({ success: true });
});
// GET /api/memory
app.get('/api/memory', (_req, res) => {
const memory = readJson(MEMORY_FILE, { exclusions: [], inclusions: [], totalTripsDebriefed: 0 });
res.json(memory);
});
// POST /api/memory/debrief
app.post('/api/memory/debrief', (req, res) => {
const { tripId, debrief } = req.body;
if (!tripId || !debrief) {
return res.status(400).json({ error: 'Missing tripId or debrief data' });
}
const trips = readJson(TRIPS_FILE, []);
const trip = trips.find((t) => t.id === tripId);
if (!trip) {
return res.status(404).json({ error: 'Trip not found' });
}
trip.debrief = debrief;
writeJson(TRIPS_FILE, trips);
// Update memory
const memory = readJson(MEMORY_FILE, { exclusions: [], inclusions: [], totalTripsDebriefed: 0 });
memory.totalTripsDebriefed = (memory.totalTripsDebriefed || 0) + 1;
// Record deadweight exclusions
if (Array.isArray(debrief.excludedItemNames)) {
for (const itemName of debrief.excludedItemNames) {
const existing = memory.exclusions.find(
(e) => e.tripType === trip.tripType && e.itemName.toLowerCase() === itemName.toLowerCase()
);
if (existing) {
existing.count += 1;
existing.lastDate = new Date().toISOString();
} else {
memory.exclusions.push({
itemName,
tripType: trip.tripType,
count: 1,
lastDate: new Date().toISOString(),
});
}
}
}
// Record wishlist inclusions
if (Array.isArray(debrief.includedItemNames)) {
for (const itemName of debrief.includedItemNames) {
const existing = memory.inclusions.find(
(i) => i.tripType === trip.tripType && i.itemName.toLowerCase() === itemName.toLowerCase()
);
if (existing) {
existing.count += 1;
existing.lastDate = new Date().toISOString();
} else {
memory.inclusions.push({
itemName,
category: 'gear',
defaultBag: 'carry_on',
tripType: trip.tripType,
count: 1,
lastDate: new Date().toISOString(),
reason: `Wished for after trip: ${trip.title}`,
});
}
}
}
writeJson(MEMORY_FILE, memory);
res.json({ success: true, memory });
});
// GET /api/settings
app.get('/api/settings', (_req, res) => {
const settings = readJson(SETTINGS_FILE, {
soundEnabled: true,
aiEnabled: false,
aiProvider: 'ollama',
ollamaUrl: 'http://localhost:11434',
ollamaModel: 'llama3.2',
apiKey: '',
});
res.json(settings);
});
// POST /api/settings
app.post('/api/settings', (req, res) => {
const settings = req.body;
writeJson(SETTINGS_FILE, settings);
res.json({ success: true, settings });
});
// POST /api/ai/suggest
app.post('/api/ai/suggest', async (req, res) => {
const { prompt, tripDetails } = req.body;
const settings = readJson(SETTINGS_FILE, {});
if (!settings.aiEnabled) {
return res.status(400).json({ error: 'AI features are disabled in settings' });
}
try {
if (settings.aiProvider === 'ollama') {
const ollamaUrl = (settings.ollamaUrl || 'http://localhost:11434').replace(/\/$/, '');
const response = await fetch(`${ollamaUrl}/api/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: settings.ollamaModel || 'llama3.2',
prompt: `You are a travel packing specialist. Based on this trip: ${JSON.stringify(tripDetails)}, suggest 3 to 5 hyper-specific, high-value packing items or travel wisdom tips. Respond in clear bullet points with concise reasoning.`,
stream: false,
}),
});
if (!response.ok) {
throw new Error(`Ollama returned status ${response.status}`);
}
const data = await response.json();
return res.json({ suggestion: data.response });
}
return res.status(400).json({ error: 'Configured AI provider not supported directly yet' });
} catch (err) {
console.error('AI suggest error:', err);
return res.status(500).json({ error: 'Failed to communicate with AI provider: ' + err.message });
}
});
// Serve frontend static build in production
const DIST_DIR = path.resolve(__dirname, '../dist');
if (fs.existsSync(DIST_DIR)) {
app.use(express.static(DIST_DIR));
app.use((req, res, next) => {
if (req.method === 'GET' && !req.path.startsWith('/api')) {
return res.sendFile(path.join(DIST_DIR, 'index.html'));
}
next();
});
}
app.listen(PORT, '0.0.0.0', () => {
console.log(`[LOADOUT] Server listening on port ${PORT}`);
console.log(`[LOADOUT] Data directory: ${DATA_DIR}`);
});
+510
View File
@@ -0,0 +1,510 @@
import React, { useState, useEffect } from 'react';
import {
Trip,
PackingItem,
BagType,
Category,
PackMemory,
AppSettings,
TripDebrief
} from './types';
import { StorageClient } from './utils/storage';
import { sound } from './utils/audio';
import { BoardingHeader } from './components/BoardingHeader';
import { BagOverview } from './components/BagOverview';
import { ItemRow } from './components/ItemRow';
import { TripModal } from './components/TripModal';
import { DebriefModal } from './components/DebriefModal';
import { MemoryDrawer } from './components/MemoryDrawer';
import { SettingsModal } from './components/SettingsModal';
import {
Plus,
Search,
Filter,
CheckCircle2,
Luggage,
Sparkles,
Trash2,
Printer,
Share2,
RotateCcw,
Plane
} from 'lucide-react';
const CATEGORY_TABS: { id: Category | 'all'; label: string }[] = [
{ id: 'all', label: 'ALL GEAR' },
{ id: 'clothing', label: 'CLOTHING' },
{ id: 'toiletries', label: 'TOILETRIES' },
{ id: 'tech', label: 'TECH & POWER' },
{ id: 'documents', label: 'DOCUMENTS' },
{ id: 'gear', label: 'EXPEDITION GEAR' },
{ id: 'wellness', label: 'WELLNESS / MED' },
];
export function App() {
const [trips, setTrips] = useState<Trip[]>([]);
const [activeTripId, setActiveTripId] = useState<string | null>(null);
const [memory, setMemory] = useState<PackMemory>({
exclusions: [],
inclusions: [],
totalTripsDebriefed: 0,
});
const [settings, setSettings] = useState<AppSettings>(StorageClient.getSettings());
// UI Filter states
const [selectedBag, setSelectedBag] = useState<BagType | 'all'>('all');
const [selectedCategory, setSelectedCategory] = useState<Category | 'all'>('all');
const [searchQuery, setSearchQuery] = useState('');
const [filterMode, setFilterMode] = useState<'all' | 'unpacked' | 'packed'>('all');
// Quick item entry
const [newItemName, setNewItemName] = useState('');
const [newItemCategory, setNewItemCategory] = useState<Category>('clothing');
const [newItemBag, setNewItemBag] = useState<BagType>('carry_on');
// Modals
const [isTripModalOpen, setIsTripModalOpen] = useState(false);
const [isDebriefModalOpen, setIsDebriefModalOpen] = useState(false);
const [isMemoryOpen, setIsMemoryOpen] = useState(false);
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
// Load initial data
useEffect(() => {
async function loadData() {
const loadedTrips = await StorageClient.getTrips();
const loadedMemory = await StorageClient.getMemory();
const loadedSettings = StorageClient.getSettings();
setTrips(loadedTrips);
setMemory(loadedMemory);
setSettings(loadedSettings);
sound.enabled = loadedSettings.soundEnabled;
const savedActiveId = StorageClient.getActiveTripId();
if (savedActiveId && loadedTrips.some((t) => t.id === savedActiveId)) {
setActiveTripId(savedActiveId);
} else if (loadedTrips.length > 0) {
setActiveTripId(loadedTrips[0].id);
}
}
loadData();
}, []);
const activeTrip = trips.find((t) => t.id === activeTripId) || null;
const handleSelectTrip = (id: string) => {
setActiveTripId(id);
StorageClient.setActiveTripId(id);
setSelectedBag('all');
};
const handleSaveTrip = async (trip: Trip) => {
await StorageClient.saveTrip(trip);
const updatedTrips = await StorageClient.getTrips();
setTrips(updatedTrips);
setActiveTripId(trip.id);
StorageClient.setActiveTripId(trip.id);
};
const handleDeleteTrip = async (tripId: string) => {
if (!window.confirm('Are you sure you want to delete this trip manifest?')) return;
sound.playClick();
await StorageClient.deleteTrip(tripId);
const updatedTrips = await StorageClient.getTrips();
setTrips(updatedTrips);
if (activeTripId === tripId) {
setActiveTripId(updatedTrips[0]?.id || null);
}
};
// Item actions
const handleTogglePacked = async (itemId: string) => {
if (!activeTrip) return;
const updatedItems = activeTrip.items.map((i) =>
i.id === itemId ? { ...i, packed: !i.packed } : i
);
const updatedTrip = { ...activeTrip, items: updatedItems };
await StorageClient.saveTrip(updatedTrip);
setTrips((prev) => prev.map((t) => (t.id === activeTrip.id ? updatedTrip : t)));
};
const handleUpdateQuantity = async (itemId: string, delta: number) => {
if (!activeTrip) return;
const updatedItems = activeTrip.items.map((i) => {
if (i.id === itemId) {
const nextQty = Math.max(1, i.quantity + delta);
return { ...i, quantity: nextQty };
}
return i;
});
const updatedTrip = { ...activeTrip, items: updatedItems };
await StorageClient.saveTrip(updatedTrip);
setTrips((prev) => prev.map((t) => (t.id === activeTrip.id ? updatedTrip : t)));
};
const handleUpdateBag = async (itemId: string, newBag: BagType) => {
if (!activeTrip) return;
const updatedItems = activeTrip.items.map((i) =>
i.id === itemId ? { ...i, bag: newBag } : i
);
const updatedTrip = { ...activeTrip, items: updatedItems };
await StorageClient.saveTrip(updatedTrip);
setTrips((prev) => prev.map((t) => (t.id === activeTrip.id ? updatedTrip : t)));
};
const handleDeleteItem = async (itemId: string) => {
if (!activeTrip) return;
const updatedItems = activeTrip.items.filter((i) => i.id !== itemId);
const updatedTrip = { ...activeTrip, items: updatedItems };
await StorageClient.saveTrip(updatedTrip);
setTrips((prev) => prev.map((t) => (t.id === activeTrip.id ? updatedTrip : t)));
};
const handleAddItem = async (e: React.FormEvent) => {
e.preventDefault();
if (!activeTrip || !newItemName.trim()) return;
sound.playStamp();
const newItem: PackingItem = {
id: `item_${Date.now()}_${Math.random().toString(36).substring(2, 6)}`,
name: newItemName.trim(),
category: newItemCategory,
quantity: 1,
bag: newItemBag,
packed: false,
source: 'user',
};
const updatedTrip = { ...activeTrip, items: [newItem, ...activeTrip.items] };
await StorageClient.saveTrip(updatedTrip);
setTrips((prev) => prev.map((t) => (t.id === activeTrip.id ? updatedTrip : t)));
setNewItemName('');
};
const handleSaveDebrief = async (debrief: TripDebrief) => {
if (!activeTrip) return;
const updated = await StorageClient.recordDebrief(activeTrip.id, debrief);
if (updated) {
setTrips((prev) => prev.map((t) => (t.id === updated.id ? updated : t)));
const refreshedMemory = await StorageClient.getMemory();
setMemory(refreshedMemory);
}
};
const handleClearMemoryItem = async (type: 'exclusion' | 'inclusion', itemName: string) => {
const updated = { ...memory };
if (type === 'exclusion') {
updated.exclusions = updated.exclusions.filter((e) => e.itemName !== itemName);
} else {
updated.inclusions = updated.inclusions.filter((i) => i.itemName !== itemName);
}
setMemory(updated);
localStorage.setItem('loadout_memory_v1', JSON.stringify(updated));
};
const handleSaveSettings = (newSettings: AppSettings) => {
setSettings(newSettings);
StorageClient.saveSettings(newSettings);
};
// Filtered items
const filteredItems = (activeTrip?.items || []).filter((item) => {
if (selectedBag !== 'all' && item.bag !== selectedBag) return false;
if (selectedCategory !== 'all' && item.category !== selectedCategory) return false;
if (filterMode === 'unpacked' && item.packed) return false;
if (filterMode === 'packed' && !item.packed) return false;
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase();
return (
item.name.toLowerCase().includes(q) ||
(item.notes && item.notes.toLowerCase().includes(q))
);
}
return true;
});
return (
<div className="min-h-screen bg-[#F4F1EA] text-[#141518] flex flex-col font-sans">
{/* Boarding Pass Telemetry Header */}
<BoardingHeader
activeTrip={activeTrip}
trips={trips}
onSelectTrip={handleSelectTrip}
onNewTrip={() => setIsTripModalOpen(true)}
onOpenDebrief={() => setIsDebriefModalOpen(true)}
onOpenMemory={() => setIsMemoryOpen(true)}
onOpenSettings={() => setIsSettingsOpen(true)}
soundEnabled={settings.soundEnabled}
onToggleSound={() => {
const next = !settings.soundEnabled;
sound.enabled = next;
handleSaveSettings({ ...settings, soundEnabled: next });
}}
/>
{/* Main Content Area */}
<main className="flex-1 max-w-6xl w-full mx-auto px-3 sm:px-6 py-6">
{activeTrip ? (
<>
{/* Bag Allocation Gauges ("What Goes in What Bag") */}
<BagOverview
items={activeTrip.items}
selectedBag={selectedBag}
onSelectBag={setSelectedBag}
luggageConfig={activeTrip.luggageConfig}
/>
{/* Quick Add Perforated Coupon Strip */}
<div className="mb-6 bg-ticket-100 border-2 border-ink p-3 shadow-card-hard-sm">
<div className="flex items-center gap-2 mb-2 font-mono text-xs font-bold uppercase text-ink">
<Plus size={14} className="text-gate-orange" />
<span>EXPEDITION ADDITION // FAST ITEM STOWAGE</span>
</div>
<form onSubmit={handleAddItem} className="flex flex-col sm:flex-row gap-2">
<input
type="text"
value={newItemName}
onChange={(e) => setNewItemName(e.target.value)}
placeholder="Enter item name (e.g. Passport pouch, extra camera lens, dry bag)..."
className="flex-1 bg-white border-2 border-ink px-3 py-2 text-xs font-grotesk font-bold text-ink shadow-xs focus:outline-none focus:ring-2 focus:ring-canary"
/>
<select
value={newItemCategory}
onChange={(e) => setNewItemCategory(e.target.value as Category)}
className="bg-white border-2 border-ink px-2 py-2 font-mono text-xs font-bold shadow-xs cursor-pointer"
>
<option value="clothing">Clothing</option>
<option value="toiletries">Toiletries</option>
<option value="tech">Tech & Power</option>
<option value="documents">Documents</option>
<option value="gear">Expedition Gear</option>
<option value="wellness">Wellness / Med</option>
</select>
<select
value={newItemBag}
onChange={(e) => setNewItemBag(e.target.value as BagType)}
className="bg-white border-2 border-ink px-2 py-2 font-mono text-xs font-bold shadow-xs cursor-pointer"
>
<option value="personal_item">Personal Item (Cabin)</option>
<option value="carry_on">Carry-On Roller</option>
<option value="checked">Checked Bag</option>
<option value="worn">Worn on Person</option>
</select>
<button
type="submit"
className="bg-canary hover:bg-canary-hover border-2 border-ink px-4 py-2 font-grotesk font-bold text-xs uppercase shadow-card-hard-sm active:translate-x-0.5 active:translate-y-0.5"
>
STOW ITEM
</button>
</form>
</div>
{/* Filter & Search Bar */}
<div className="mb-4 flex flex-col md:flex-row md:items-center justify-between gap-3 border-b-2 border-ink pb-3">
{/* Category Pills */}
<div className="flex items-center gap-1.5 overflow-x-auto pb-1 max-w-full">
{CATEGORY_TABS.map((cat) => (
<button
key={cat.id}
onClick={() => {
sound.playClick();
setSelectedCategory(cat.id);
}}
className={`font-mono text-xs font-bold px-2.5 py-1 border border-ink whitespace-nowrap transition-all ${
selectedCategory === cat.id
? 'bg-ink text-ticket-100 shadow-card-hard-sm'
: 'bg-white text-ink hover:bg-ticket-100'
}`}
>
{cat.label}
</button>
))}
</div>
{/* Search & State Filter */}
<div className="flex items-center gap-2">
<div className="relative">
<Search
size={14}
className="absolute left-2.5 top-1/2 -translate-y-1/2 text-ink-muted"
/>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search manifest..."
className="pl-8 pr-3 py-1 bg-white border border-ink font-mono text-xs w-36 sm:w-48 focus:outline-none focus:ring-1 focus:ring-ink"
/>
</div>
<div className="flex border border-ink bg-white font-mono text-xs">
<button
onClick={() => {
sound.playClick();
setFilterMode('all');
}}
className={`px-2 py-1 ${filterMode === 'all' ? 'bg-ink text-ticket-100 font-bold' : 'text-ink'}`}
>
ALL
</button>
<button
onClick={() => {
sound.playClick();
setFilterMode('unpacked');
}}
className={`px-2 py-1 border-l border-ink ${filterMode === 'unpacked' ? 'bg-ink text-ticket-100 font-bold' : 'text-ink'}`}
>
UNPACKED
</button>
<button
onClick={() => {
sound.playClick();
setFilterMode('packed');
}}
className={`px-2 py-1 border-l border-ink ${filterMode === 'packed' ? 'bg-ink text-ticket-100 font-bold' : 'text-ink'}`}
>
STOWED
</button>
</div>
</div>
</div>
{/* Manifest Item Count & Bag Filter Notice */}
<div className="flex items-center justify-between text-xs font-mono text-ink-muted mb-3">
<span>
DISPLAYING {filteredItems.length} ITEM(S)
{selectedBag !== 'all' && (
<span className="text-gate-orange font-bold ml-1">
(FILTERED TO {selectedBag.toUpperCase()})
</span>
)}
</span>
<div className="flex items-center gap-3">
<button
onClick={() => window.print()}
className="hover:text-ink flex items-center gap-1 font-bold"
title="Print paper boarding manifest"
>
<Printer size={13} />
<span>PRINT MANIFEST</span>
</button>
<span>•</span>
<button
onClick={() => handleDeleteTrip(activeTrip.id)}
className="hover:text-stamp-red flex items-center gap-1 font-bold text-stamp-red/70"
title="Delete voyage"
>
<Trash2 size={13} />
<span>DELETE VOYAGE</span>
</button>
</div>
</div>
{/* List of Perforated Stubs */}
{filteredItems.length === 0 ? (
<div className="p-12 border-2 border-dashed border-ink/40 text-center font-mono text-ink-muted bg-ticket-50/50">
<p className="text-sm font-bold uppercase text-ink mb-1">
NO ITEMS MATCHING THIS FILTER
</p>
<p className="text-xs">
Adjust your bag or category selection above, or add a new custom item.
</p>
</div>
) : (
<div className="space-y-1">
{filteredItems.map((item) => (
<ItemRow
key={item.id}
item={item}
onTogglePacked={handleTogglePacked}
onUpdateQuantity={handleUpdateQuantity}
onUpdateBag={handleUpdateBag}
onDelete={handleDeleteItem}
/>
))}
</div>
)}
</>
) : (
/* Empty State: Terminal Welcome */
<div className="max-w-xl mx-auto my-12 p-8 bg-ticket-100 border-2 border-ink shadow-card-hard text-center">
<div className="w-16 h-16 bg-canary border-2 border-ink mx-auto flex items-center justify-center shadow-card-hard-sm mb-4">
<Plane className="text-ink -rotate-45" size={32} />
</div>
<h2 className="font-display text-3xl tracking-wide uppercase text-ink mb-2">
DISPATCH TERMINAL READY
</h2>
<p className="font-sans text-sm text-ink-muted mb-6">
You currently have no active packing manifests. Create a new trip to generate an intelligent list, assign items to your bags, and begin your loadout.
</p>
<button
onClick={() => {
sound.playClick();
setIsTripModalOpen(true);
}}
className="bg-canary hover:bg-canary-hover text-ink font-grotesk font-bold text-sm uppercase px-6 py-3 border-2 border-ink shadow-card-hard active:translate-x-0.5 active:translate-y-0.5 inline-flex items-center gap-2"
>
<Plus size={18} strokeWidth={3} />
<span>DISPATCH FIRST VOYAGE</span>
</button>
</div>
)}
</main>
{/* Footer */}
<footer className="border-t-2 border-ink bg-ticket-200 py-3 px-4 sm:px-6 text-center font-mono text-xs text-ink-muted">
<div className="flex flex-col sm:flex-row items-center justify-between max-w-6xl mx-auto gap-2">
<span>LOADOUT // TRAVEL MANIFEST & BAG ALLOCATION SYSTEM</span>
<span>SELF-HOSTED AT VLOSER.US • DEBRIEF-DRIVEN INTELLIGENCE</span>
</div>
</footer>
{/* Modals */}
<TripModal
isOpen={isTripModalOpen}
onClose={() => setIsTripModalOpen(false)}
onSaveTrip={handleSaveTrip}
memory={memory}
/>
{activeTrip && (
<DebriefModal
isOpen={isDebriefModalOpen}
onClose={() => setIsDebriefModalOpen(false)}
trip={activeTrip}
onSaveDebrief={handleSaveDebrief}
/>
)}
<MemoryDrawer
isOpen={isMemoryOpen}
onClose={() => setIsMemoryOpen(false)}
memory={memory}
onClearMemoryItem={handleClearMemoryItem}
/>
<SettingsModal
isOpen={isSettingsOpen}
onClose={() => setIsSettingsOpen(false)}
settings={settings}
onSaveSettings={handleSaveSettings}
trips={trips}
memory={memory}
onImportData={({ trips: impTrips, memory: impMemory }) => {
setTrips(impTrips);
setMemory(impMemory);
localStorage.setItem('loadout_trips_v1', JSON.stringify(impTrips));
localStorage.setItem('loadout_memory_v1', JSON.stringify(impMemory));
if (impTrips.length > 0) setActiveTripId(impTrips[0].id);
}}
/>
</div>
);
}
export default App;
+175
View File
@@ -0,0 +1,175 @@
import React from 'react';
import { BagType, PackingItem, LuggageConfig } from '../types';
import { sound } from '../utils/audio';
import { Backpack, Briefcase, Package, UserCheck, Layers, CheckCircle2 } from 'lucide-react';
interface BagOverviewProps {
items: PackingItem[];
selectedBag: BagType | 'all';
onSelectBag: (bag: BagType | 'all') => void;
luggageConfig: LuggageConfig;
}
export const BagOverview: React.FC<BagOverviewProps> = ({
items,
selectedBag,
onSelectBag,
luggageConfig,
}) => {
const bags: {
type: BagType;
label: string;
sub: string;
icon: React.ReactNode;
tsaTip: string;
tagCode: string;
visible: boolean;
}[] = [
{
type: 'personal_item',
label: 'PERSONAL ITEM',
sub: 'Under-Seat / Daypack',
icon: <Backpack size={20} strokeWidth={2.2} />,
tsaTip: 'Cabin only: Passports, power banks, essential meds, laptop',
tagCode: 'CABIN-01',
visible: true,
},
{
type: 'carry_on',
label: 'CARRY-ON ROLLER',
sub: 'Overhead Compartment',
icon: <Briefcase size={20} strokeWidth={2.2} />,
tsaTip: 'Max 3.4oz / 100ml liquids pouch. Clothes cubes & toiletries',
tagCode: 'CABIN-02',
visible: luggageConfig !== 'backpack_only',
},
{
type: 'checked',
label: 'CHECKED LUGGAGE',
sub: 'Cargo Hold',
icon: <Package size={20} strokeWidth={2.2} />,
tsaTip: 'Liquids > 100ml, trekking poles, pocket knives, heavy layers',
tagCode: 'HOLD-03',
visible: luggageConfig === 'checked_bag',
},
{
type: 'worn',
label: 'WORN ON PERSON',
sub: 'Travel Day Outfit',
icon: <UserCheck size={20} strokeWidth={2.2} />,
tsaTip: 'Heaviest boots & airport jacket to minimize luggage bulk',
tagCode: 'PAX-WORN',
visible: true,
},
];
return (
<div className="mb-6">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="font-mono text-xs uppercase font-bold text-ink tracking-wider">
LUGGAGE ALLOCATION // "WHAT GOES IN WHAT BAG"
</span>
</div>
<button
onClick={() => {
sound.playClick();
onSelectBag('all');
}}
className={`font-mono text-xs font-bold px-2.5 py-1 border border-ink transition-all flex items-center gap-1.5 ${
selectedBag === 'all'
? 'bg-ink text-ticket-100 shadow-card-hard-sm'
: 'bg-ticket-100 text-ink hover:bg-ticket-200'
}`}
>
<Layers size={13} />
<span>VIEW ALL BAGS ({items.length})</span>
</button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
{bags
.filter((b) => b.visible)
.map((b) => {
const bagItems = items.filter((i) => i.bag === b.type);
const bagPacked = bagItems.filter((i) => i.packed).length;
const bagTotal = bagItems.length;
const pct = bagTotal > 0 ? Math.round((bagPacked / bagTotal) * 100) : 0;
const isSelected = selectedBag === b.type;
const isFull = bagTotal > 0 && bagPacked === bagTotal;
return (
<button
key={b.type}
onClick={() => {
sound.playClick();
onSelectBag(isSelected ? 'all' : b.type);
}}
className={`relative text-left p-3.5 border-2 border-ink transition-all flex flex-col justify-between ${
isSelected
? 'bg-canary-light shadow-card-hard border-ink translate-x-0.5 translate-y-0.5'
: 'bg-ticket-50 hover:bg-ticket-100 shadow-card-hard-sm'
}`}
>
{/* Perforation holes on card edges */}
<div className="ticket-notch-left" />
<div className="ticket-notch-right" />
<div>
{/* Top Barcode Header */}
<div className="flex items-center justify-between border-b border-ink/20 pb-2 mb-2 font-mono text-[10px]">
<span className="bg-ink text-ticket-100 px-1 py-0.5 font-bold uppercase">
{b.tagCode}
</span>
<span className="text-ink-muted tracking-tight">
{b.sub}
</span>
</div>
{/* Title & Icon */}
<div className="flex items-center justify-between gap-2 mb-1">
<div className="flex items-center gap-2 text-ink">
<span className="text-ink">{b.icon}</span>
<span className="font-display text-xl tracking-wide uppercase leading-tight">
{b.label}
</span>
</div>
{isFull && (
<CheckCircle2 size={18} className="text-stamp-green shrink-0" />
)}
</div>
{/* TSA Advisory Tip */}
<p className="font-sans text-[11px] text-ink-muted leading-tight mb-3">
{b.tsaTip}
</p>
</div>
{/* Meter and Stowed Count */}
<div>
<div className="flex items-center justify-between font-mono text-xs font-bold mb-1">
<span className="text-ink">
{bagPacked}/{bagTotal} ITEMS
</span>
<span className={isFull ? 'text-stamp-green' : 'text-ink-muted'}>
{pct}%
</span>
</div>
<div className="w-full bg-ticket-200 border border-ink h-2.5 overflow-hidden">
<div
className={`h-full transition-all duration-200 ${
isFull ? 'bg-stamp-green' : 'bg-gate-orange'
}`}
style={{ width: `${pct}%` }}
/>
</div>
</div>
</button>
);
})}
</div>
</div>
);
};
+223
View File
@@ -0,0 +1,223 @@
import React from 'react';
import { Trip } from '../types';
import { sound } from '../utils/audio';
import {
Plane,
Luggage,
Volume2,
VolumeX,
BrainCircuit,
Settings as SettingsIcon,
Plus,
RotateCcw,
Sparkles
} from 'lucide-react';
interface BoardingHeaderProps {
activeTrip: Trip | null;
trips: Trip[];
onSelectTrip: (tripId: string) => void;
onNewTrip: () => void;
onOpenDebrief: () => void;
onOpenMemory: () => void;
onOpenSettings: () => void;
soundEnabled: boolean;
onToggleSound: () => void;
}
export const BoardingHeader: React.FC<BoardingHeaderProps> = ({
activeTrip,
trips,
onSelectTrip,
onNewTrip,
onOpenDebrief,
onOpenMemory,
onOpenSettings,
soundEnabled,
onToggleSound,
}) => {
const totalItems = activeTrip ? activeTrip.items.length : 0;
const packedItems = activeTrip ? activeTrip.items.filter((i) => i.packed).length : 0;
const percentPacked = totalItems > 0 ? Math.round((packedItems / totalItems) * 100) : 0;
return (
<header className="border-b-2 border-ink bg-ticket-100 shadow-card-hard">
{/* Top Telemetry Flight Ticker */}
<div className="bg-ink text-ticket-100 px-3 py-1.5 flex items-center justify-between text-xs font-mono tracking-wider border-b border-ink-light">
<div className="flex items-center gap-2">
<span className="inline-block w-2 h-2 rounded-full bg-canary animate-pulse" />
<span className="font-bold uppercase tracking-widest text-canary">LOADOUT // MANIFEST</span>
<span className="hidden sm:inline text-ink-faint">|</span>
<span className="hidden sm:inline text-ticket-200">
{activeTrip ? `REF: ${activeTrip.routeCode}` : 'DISPATCH: READY'}
</span>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => {
onToggleSound();
sound.playClick();
}}
title={soundEnabled ? 'Mute sound effects' : 'Enable tactile sounds'}
className="flex items-center gap-1 hover:text-canary transition-colors"
>
{soundEnabled ? <Volume2 size={13} /> : <VolumeX size={13} />}
<span className="hidden md:inline">{soundEnabled ? 'AUDIO: ON' : 'AUDIO: OFF'}</span>
</button>
<span className="text-ink-faint">|</span>
<button
onClick={() => {
sound.playClick();
onOpenMemory();
}}
className="flex items-center gap-1 text-canary hover:underline"
title="View accumulated packing wisdom & debrief history"
>
<BrainCircuit size={13} />
<span className="font-bold">FLIGHT LOG & MEMORY</span>
</button>
<span className="text-ink-faint">|</span>
<button
onClick={() => {
sound.playClick();
onOpenSettings();
}}
className="hover:text-canary transition-colors"
title="App settings & AI dispatch"
>
<SettingsIcon size={13} />
</button>
</div>
</div>
{/* Main Boarding Pass Header Card */}
<div className="px-3 sm:px-6 py-4 flex flex-col md:flex-row md:items-center justify-between gap-4">
{/* Left: Voyage & Route Code */}
<div className="flex items-start sm:items-center gap-3">
<div className="w-12 h-12 bg-canary border-2 border-ink flex items-center justify-center shadow-card-hard-sm shrink-0">
<Plane className="text-ink transform -rotate-45" size={26} strokeWidth={2.5} />
</div>
<div>
<div className="flex items-center gap-2 flex-wrap">
<h1 className="font-display text-3xl sm:text-4xl tracking-wide uppercase leading-none text-ink">
{activeTrip ? activeTrip.title : 'NO VOYAGE SELECTED'}
</h1>
{activeTrip?.debrief && (
<span className="stamp-badge stamp-badge-packed text-[11px] py-0.5">
DEBRIEFED
</span>
)}
</div>
<div className="flex items-center gap-2 mt-1 font-mono text-xs text-ink-muted flex-wrap">
{activeTrip ? (
<>
<span className="bg-ticket-200 border border-ticket-border px-1.5 py-0.5 font-bold text-ink">
{activeTrip.routeCode}
</span>
<span>•</span>
<span>{activeTrip.destination}</span>
<span>•</span>
<span>
{activeTrip.durationDays}D / {activeTrip.durationNights}N
</span>
<span>•</span>
<span className="uppercase text-gate-orange font-bold">
{activeTrip.tripType.replace('_', ' ')}
</span>
{activeTrip.hasLaundry && (
<span className="bg-stamp-green-light text-stamp-green border border-stamp-green/30 px-1 py-0.5 text-[10px] uppercase font-bold">
LAUNDRY ACCESS
</span>
)}
</>
) : (
<span>CREATE A NEW TRAVEL MANIFEST TO BEGIN PACKING</span>
)}
</div>
</div>
</div>
{/* Center / Right: Voyage switcher and Actions */}
<div className="flex items-center gap-2 sm:gap-3 flex-wrap">
{trips.length > 0 && (
<select
value={activeTrip?.id || ''}
onChange={(e) => {
sound.playClick();
onSelectTrip(e.target.value);
}}
className="bg-white border-2 border-ink font-mono text-xs px-2.5 py-2 shadow-card-hard-sm focus:outline-none focus:ring-2 focus:ring-canary"
>
{trips.map((t) => (
<option key={t.id} value={t.id}>
{t.routeCode} — {t.title} ({t.durationDays}D)
</option>
))}
</select>
)}
<button
onClick={() => {
sound.playClick();
onNewTrip();
}}
className="flex items-center gap-1.5 bg-canary hover:bg-canary-hover text-ink font-grotesk font-bold text-xs uppercase px-3.5 py-2 border-2 border-ink shadow-card-hard-sm active:translate-x-0.5 active:translate-y-0.5 transition-all"
>
<Plus size={15} strokeWidth={3} />
<span>NEW VOYAGE</span>
</button>
{activeTrip && (
<button
onClick={() => {
sound.playStamp();
onOpenDebrief();
}}
className="flex items-center gap-1.5 bg-gate-orange hover:bg-gate-hover text-white font-grotesk font-bold text-xs uppercase px-3 py-2 border-2 border-ink shadow-card-hard-sm active:translate-x-0.5 active:translate-y-0.5 transition-all"
title="Conduct post-trip debrief to log what to exclude/include next time"
>
<RotateCcw size={14} strokeWidth={2.5} />
<span>{activeTrip.debrief ? 'EDIT DEBRIEF' : 'TRIP DEBRIEF'}</span>
</button>
)}
</div>
</div>
{/* Progress & Stowing Tally Bar */}
{activeTrip && (
<div className="bg-ticket-200 border-t border-ticket-border px-3 sm:px-6 py-2 flex flex-col sm:flex-row sm:items-center justify-between gap-2 text-xs font-mono">
<div className="flex items-center gap-3">
<span className="font-bold uppercase text-ink">
TOTAL MANIFEST LOAD:
</span>
<span className="bg-ink text-ticket-100 px-2 py-0.5 font-bold">
{packedItems} / {totalItems} STOWED
</span>
<span className="font-bold text-ink">
({percentPacked}%)
</span>
</div>
<div className="flex items-center gap-3 w-full sm:w-64">
<div className="w-full bg-ticket-50 border border-ink h-3.5 relative overflow-hidden">
<div
className={`h-full transition-all duration-300 ${
percentPacked === 100 ? 'bg-stamp-green' : 'bg-canary'
}`}
style={{ width: `${percentPacked}%` }}
/>
</div>
{percentPacked === 100 && (
<span className="stamp-badge stamp-badge-packed text-[10px] py-0 px-1 font-bold whitespace-nowrap">
ALL STOWED
</span>
)}
</div>
</div>
)}
</header>
);
};
+272
View File
@@ -0,0 +1,272 @@
import React, { useState } from 'react';
import { Trip, TripDebrief, Category, BagType } from '../types';
import { sound } from '../utils/audio';
import { X, Check, AlertTriangle, Plus, Trash2, Star, Sparkles, CheckCircle2 } from 'lucide-react';
interface DebriefModalProps {
isOpen: boolean;
onClose: () => void;
trip: Trip;
onSaveDebrief: (debrief: TripDebrief) => void;
}
export const DebriefModal: React.FC<DebriefModalProps> = ({
isOpen,
onClose,
trip,
onSaveDebrief,
}) => {
if (!isOpen) return null;
// Initialize with existing debrief data if already debriefed
const [excludedItems, setExcludedItems] = useState<string[]>(
trip.debrief?.excludedItemNames || []
);
const [includedItems, setIncludedItems] = useState<string[]>(
trip.debrief?.includedItemNames || []
);
const [newMissingItem, setNewMissingItem] = useState('');
const [notes, setNotes] = useState(trip.debrief?.notes || '');
const [rating, setRating] = useState(trip.debrief?.rating || 4);
const toggleDeadweight = (itemName: string) => {
sound.playClick();
if (excludedItems.includes(itemName)) {
setExcludedItems(excludedItems.filter((i) => i !== itemName));
} else {
setExcludedItems([...excludedItems, itemName]);
}
};
const handleAddMissingItem = (e: React.FormEvent) => {
e.preventDefault();
const clean = newMissingItem.trim();
if (!clean) return;
if (!includedItems.includes(clean)) {
sound.playClick();
setIncludedItems([...includedItems, clean]);
}
setNewMissingItem('');
};
const handleRemoveMissingItem = (name: string) => {
sound.playClick();
setIncludedItems(includedItems.filter((i) => i !== name));
};
const handleComplete = () => {
sound.playStamp();
const debriefData: TripDebrief = {
notes,
rating,
completedAt: new Date().toISOString(),
excludedItemNames: excludedItems,
includedItemNames: includedItems,
};
onSaveDebrief(debriefData);
onClose();
};
return (
<div className="fixed inset-0 z-50 bg-ink/70 backdrop-blur-xs flex items-center justify-center p-3 sm:p-6 overflow-y-auto">
<div className="bg-ticket-100 border-2 border-ink shadow-card-hard max-w-2xl w-full my-auto overflow-hidden animate-in fade-in zoom-in-95 duration-150">
{/* Top Header */}
<div className="bg-ink text-ticket-100 p-4 flex items-center justify-between border-b-2 border-gate-orange">
<div className="flex items-center gap-2">
<span className="w-3 h-3 bg-gate-orange rotate-45" />
<div>
<h2 className="font-display text-2xl tracking-wide uppercase text-white">
VOYAGE DEBRIEF & LEARNING LOOP
</h2>
<p className="font-mono text-xs text-ticket-200">
{trip.routeCode} — {trip.title}
</p>
</div>
</div>
<button
onClick={() => {
sound.playClick();
onClose();
}}
className="text-ticket-200 hover:text-white p-1"
>
<X size={20} />
</button>
</div>
{/* Advisory banner */}
<div className="bg-canary-light border-b-2 border-ink px-4 py-2.5 flex items-start gap-2 text-xs font-sans text-ink">
<Sparkles size={16} className="text-gate-orange shrink-0 mt-0.5" />
<p>
<strong>Continuous Learning Engine:</strong> Record what you packed but never used (to exclude or reduce next time), and what you wished you had packed (to automatically suggest in future {trip.tripType.replace('_', ' ')} voyages).
</p>
</div>
<div className="p-4 sm:p-6 max-h-[75vh] overflow-y-auto space-y-6">
{/* SECTION 1: WHAT TO EXCLUDE (DEADWEIGHT) */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="font-mono text-xs font-bold uppercase text-stamp-red flex items-center gap-1.5">
<AlertTriangle size={14} />
<span>1. WHAT SHOULD HAVE BEEN EXCLUDED? ("DEADWEIGHT")</span>
</label>
<span className="font-mono text-xs font-bold bg-stamp-red text-white px-2 py-0.5">
{excludedItems.length} MARKED
</span>
</div>
<p className="font-sans text-xs text-ink-muted mb-3">
Tap any item you brought on this trip but never touched. The engine will mark these as deadweight and prevent overpacking next time.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 max-h-56 overflow-y-auto p-2 bg-ticket-50 border-2 border-ink">
{trip.items.map((item) => {
const isExcluded = excludedItems.includes(item.name);
return (
<button
type="button"
key={item.id}
onClick={() => toggleDeadweight(item.name)}
className={`p-2 border text-left flex items-center justify-between text-xs font-grotesk font-bold transition-all ${
isExcluded
? 'bg-stamp-red-light border-stamp-red text-stamp-red shadow-xs'
: 'bg-white border-ink/30 text-ink hover:border-ink'
}`}
>
<span className="truncate mr-2">{item.name}</span>
{isExcluded ? (
<span className="stamp-badge stamp-badge-excluded text-[9px] py-0 px-1 shrink-0">
EXCLUDE
</span>
) : (
<span className="font-mono text-[10px] text-ink/40 shrink-0">
[ USED ]
</span>
)}
</button>
);
})}
</div>
</div>
{/* SECTION 2: WHAT TO INCLUDE (WISHLIST / MISSING) */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="font-mono text-xs font-bold uppercase text-gate-orange flex items-center gap-1.5">
<Plus size={15} />
<span>2. WHAT SHOULD HAVE BEEN INCLUDED? ("WISH I HAD BROUGHT")</span>
</label>
<span className="font-mono text-xs font-bold bg-gate-orange text-white px-2 py-0.5">
{includedItems.length} ADDED
</span>
</div>
<p className="font-sans text-xs text-ink-muted mb-2">
What did you find yourself needing on the trip that wasn't in your bags?
</p>
<form onSubmit={handleAddMissingItem} className="flex gap-2 mb-3">
<input
type="text"
value={newMissingItem}
onChange={(e) => setNewMissingItem(e.target.value)}
placeholder="e.g. Travel extension cord, blister tape, laundry sheets..."
className="flex-1 bg-white border-2 border-ink px-3 py-2 text-xs font-grotesk font-bold text-ink shadow-card-hard-sm focus:outline-none focus:ring-2 focus:ring-gate-orange"
/>
<button
type="submit"
className="bg-gate-orange hover:bg-gate-hover text-white px-4 py-2 border-2 border-ink font-grotesk font-bold text-xs uppercase shadow-card-hard-sm active:translate-x-0.5 active:translate-y-0.5"
>
ADD ITEM
</button>
</form>
{includedItems.length > 0 && (
<div className="flex flex-wrap gap-2 p-2 bg-ticket-50 border-2 border-ink">
{includedItems.map((name) => (
<span
key={name}
className="bg-white border border-ink px-2.5 py-1 text-xs font-mono font-bold text-ink flex items-center gap-2 shadow-xs"
>
<span>💡 {name}</span>
<button
type="button"
onClick={() => handleRemoveMissingItem(name)}
className="text-ink/40 hover:text-stamp-red"
title="Remove"
>
<Trash2 size={12} />
</button>
</span>
))}
</div>
)}
</div>
{/* SECTION 3: NOTES & RATING */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 bg-ticket-200/60 p-3.5 border-2 border-ink">
<div className="sm:col-span-2">
<label className="block font-mono text-xs font-bold uppercase text-ink mb-1">
Trip Reflection / Packing Debrief Notes
</label>
<textarea
rows={2}
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="e.g. Weather was 5 degrees colder than expected in the mornings; 1 pair of shoes was plenty..."
className="w-full bg-white border-2 border-ink p-2 font-sans text-xs text-ink shadow-card-hard-sm focus:outline-none focus:ring-2 focus:ring-canary"
/>
</div>
<div>
<label className="block font-mono text-xs font-bold uppercase text-ink mb-1.5">
Packing Efficiency Rating
</label>
<div className="flex gap-1.5 items-center mt-2">
{[1, 2, 3, 4, 5].map((val) => (
<button
type="button"
key={val}
onClick={() => {
sound.playClick();
setRating(val);
}}
className={`p-1 border border-ink ${
rating >= val ? 'bg-canary text-ink' : 'bg-white text-ink/30'
}`}
>
<Star size={16} fill={rating >= val ? '#141518' : 'none'} />
</button>
))}
</div>
<span className="font-mono text-[10px] text-ink-muted mt-1 block">
{rating === 5 ? 'Perfect loadout' : rating >= 3 ? 'Good packing' : 'Needs tuning'}
</span>
</div>
</div>
{/* Actions */}
<div className="border-t-2 border-ink pt-4 flex items-center justify-between gap-3">
<button
type="button"
onClick={() => {
sound.playClick();
onClose();
}}
className="px-4 py-2 border-2 border-ink font-grotesk font-bold text-xs uppercase bg-white hover:bg-ticket-200"
>
CANCEL
</button>
<button
type="button"
onClick={handleComplete}
className="px-6 py-2.5 border-2 border-ink font-grotesk font-bold text-sm uppercase bg-stamp-green hover:bg-green-700 text-white shadow-card-hard active:translate-x-0.5 active:translate-y-0.5 flex items-center gap-2"
>
<CheckCircle2 size={16} />
<span>STAMP DEBRIEF & UPDATE ENGINE</span>
</button>
</div>
</div>
</div>
</div>
);
};
+210
View File
@@ -0,0 +1,210 @@
import React from 'react';
import { PackingItem, BagType, Category } from '../types';
import { sound } from '../utils/audio';
import {
Check,
Trash2,
Sparkles,
AlertCircle,
Backpack,
Briefcase,
Package,
UserCheck,
Plus,
Minus
} from 'lucide-react';
interface ItemRowProps {
item: PackingItem;
onTogglePacked: (id: string) => void;
onUpdateQuantity: (id: string, delta: number) => void;
onUpdateBag: (id: string, bag: BagType) => void;
onDelete: (id: string) => void;
}
const BAG_OPTIONS: { type: BagType; label: string; icon: React.ReactNode; short: string }[] = [
{ type: 'personal_item', label: 'Personal Item', short: 'PERS', icon: <Backpack size={12} /> },
{ type: 'carry_on', label: 'Carry-On Roller', short: 'CARRY', icon: <Briefcase size={12} /> },
{ type: 'checked', label: 'Checked Bag', short: 'CHKD', icon: <Package size={12} /> },
{ type: 'worn', label: 'Worn on Person', short: 'WORN', icon: <UserCheck size={12} /> },
];
const CATEGORY_COLORS: Record<Category, { bg: string; text: string; label: string }> = {
clothing: { bg: 'bg-[#E3EAF7]', text: 'text-[#1D3E74]', label: 'CLOTHING' },
toiletries: { bg: 'bg-[#E3F5EC]', text: 'text-[#15663F]', label: 'TOILETRIES' },
tech: { bg: 'bg-[#FBEAE3]', text: 'text-[#873215]', label: 'TECH & POWER' },
documents: { bg: 'bg-[#FFF6D1]', text: 'text-[#7D5700]', label: 'DOCS / IDENTITY' },
gear: { bg: 'bg-[#ECEAF7]', text: 'text-[#483B89]', label: 'EXPEDITION GEAR' },
wellness: { bg: 'bg-[#FCE8EE]', text: 'text-[#85183E]', label: 'MED / WELLNESS' },
};
export const ItemRow: React.FC<ItemRowProps> = ({
item,
onTogglePacked,
onUpdateQuantity,
onUpdateBag,
onDelete,
}) => {
const catStyle = CATEGORY_COLORS[item.category] || CATEGORY_COLORS.gear;
const currentBag = BAG_OPTIONS.find((b) => b.type === item.bag) || BAG_OPTIONS[0];
return (
<div
className={`group relative flex flex-col sm:flex-row items-stretch border-2 border-ink transition-all mb-2 shadow-card-hard-sm ${
item.packed
? 'bg-[#ECE8DC]/70 border-ink/70'
: 'bg-ticket-50 hover:bg-ticket-100'
}`}
>
{/* Left Tear-Off Coupon Stub: Checkbox Stamp */}
<button
onClick={() => {
sound.playTear();
onTogglePacked(item.id);
}}
className={`w-full sm:w-16 flex items-center justify-center p-3 border-b-2 sm:border-b-0 sm:border-r-2 border-dashed border-ink/40 transition-colors shrink-0 ${
item.packed
? 'bg-stamp-green-light text-stamp-green'
: 'hover:bg-canary-light text-ink/30 hover:text-ink'
}`}
title={item.packed ? 'Mark as unpacked' : 'Tear & stow in luggage'}
>
<div
className={`w-7 h-7 rounded border-2 flex items-center justify-center font-mono font-bold transition-transform ${
item.packed
? 'border-stamp-green bg-stamp-green text-white rotate-[-4deg] scale-105'
: 'border-ink/60 bg-white'
}`}
>
{item.packed ? <Check size={18} strokeWidth={3} /> : null}
</div>
</button>
{/* Main Body of Ticket Stub */}
<div className="flex-1 p-3 flex flex-col justify-between gap-2">
<div className="flex items-start justify-between gap-3">
<div className="flex-1">
<div className="flex items-center gap-2 flex-wrap mb-1">
{/* Category Pill */}
<span
className={`font-mono text-[10px] font-bold px-1.5 py-0.5 border border-ink/20 ${catStyle.bg} ${catStyle.text}`}
>
{catStyle.label}
</span>
{/* Essential Badge */}
{item.essential && (
<span className="font-mono text-[9px] font-bold bg-gate-orange text-white px-1 py-0.5 uppercase tracking-wider">
ESSENTIAL
</span>
)}
{/* Learned Wisdom Badge */}
{item.source === 'learned' && (
<span className="font-mono text-[9px] font-bold bg-purple-100 text-purple-900 border border-purple-300 px-1 py-0.5 flex items-center gap-1 uppercase">
<Sparkles size={10} />
DEBRIEF MEMORY
</span>
)}
</div>
{/* Item Name */}
<h3
className={`font-grotesk font-bold text-base text-ink leading-snug transition-all ${
item.packed ? 'line-through text-ink/60' : ''
}`}
>
{item.name}
</h3>
{/* Notes or TSA Warning */}
{item.notes && (
<p className="font-sans text-xs text-ink-muted mt-0.5 flex items-center gap-1">
<AlertCircle size={11} className="text-gate-orange shrink-0" />
<span>{item.notes}</span>
</p>
)}
{/* Learned Reason Details */}
{item.learnedReason && (
<p className="font-mono text-[11px] text-purple-800 bg-purple-50 border border-purple-200 px-2 py-0.5 mt-1 rounded-sm">
💡 {item.learnedReason}
</p>
)}
</div>
{/* Stamped Status if Packed */}
{item.packed && (
<span className="stamp-badge stamp-badge-packed text-[10px] py-0.5 px-2 shrink-0 hidden sm:inline-flex">
STOWED
</span>
)}
</div>
{/* Bottom Bar: Quantity & Bag Switcher */}
<div className="flex items-center justify-between border-t border-ink/10 pt-2 mt-1 flex-wrap gap-2 text-xs font-mono">
{/* Quantity Controls */}
<div className="flex items-center gap-1.5 bg-ticket-200 border border-ink/30 px-1.5 py-0.5">
<span className="text-[10px] text-ink-muted uppercase mr-1">QTY:</span>
<button
onClick={() => {
sound.playClick();
onUpdateQuantity(item.id, -1);
}}
className="w-5 h-5 bg-white border border-ink/40 flex items-center justify-center hover:bg-ticket-50 active:scale-95"
title="Decrease quantity"
>
<Minus size={10} />
</button>
<span className="font-bold text-ink w-5 text-center">
{item.quantity}
</span>
<button
onClick={() => {
sound.playClick();
onUpdateQuantity(item.id, 1);
}}
className="w-5 h-5 bg-white border border-ink/40 flex items-center justify-center hover:bg-ticket-50 active:scale-95"
title="Increase quantity"
>
<Plus size={10} />
</button>
</div>
{/* Bag Allocation Switcher */}
<div className="flex items-center gap-1.5">
<span className="text-[10px] text-ink-muted uppercase hidden sm:inline">
ASSIGNED TO:
</span>
<select
value={item.bag}
onChange={(e) => {
sound.playClick();
onUpdateBag(item.id, e.target.value as BagType);
}}
className="bg-white border border-ink font-mono text-[11px] font-bold px-2 py-1 shadow-card-hard-sm cursor-pointer hover:bg-canary-light transition-colors"
>
{BAG_OPTIONS.map((opt) => (
<option key={opt.type} value={opt.type}>
{opt.label} ({opt.short})
</option>
))}
</select>
{/* Delete Item */}
<button
onClick={() => {
sound.playClick();
onDelete(item.id);
}}
className="p-1 text-ink/40 hover:text-stamp-red transition-colors ml-1"
title="Remove from manifest"
>
<Trash2 size={14} />
</button>
</div>
</div>
</div>
</div>
);
};
+186
View File
@@ -0,0 +1,186 @@
import React from 'react';
import { PackMemory } from '../types';
import { sound } from '../utils/audio';
import { X, BrainCircuit, Trash2, AlertTriangle, Sparkles, Award } from 'lucide-react';
interface MemoryDrawerProps {
isOpen: boolean;
onClose: () => void;
memory: PackMemory;
onClearMemoryItem: (type: 'exclusion' | 'inclusion', itemName: string) => void;
}
export const MemoryDrawer: React.FC<MemoryDrawerProps> = ({
isOpen,
onClose,
memory,
onClearMemoryItem,
}) => {
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 bg-ink/70 backdrop-blur-xs flex items-center justify-center p-3 sm:p-6 overflow-y-auto">
<div className="bg-ticket-100 border-2 border-ink shadow-card-hard max-w-2xl w-full my-auto overflow-hidden animate-in fade-in zoom-in-95 duration-150">
{/* Top Header */}
<div className="bg-ink text-ticket-100 p-4 flex items-center justify-between border-b-2 border-canary">
<div className="flex items-center gap-2">
<BrainCircuit size={22} className="text-canary" />
<div>
<h2 className="font-display text-2xl tracking-wide uppercase text-canary">
FLIGHT LOG // PACKING WISDOM & MEMORY
</h2>
<p className="font-mono text-xs text-ticket-200">
CONTINUOUS FEEDBACK ARCHIVE FROM YOUR PAST TRIP DEBRIEFS
</p>
</div>
</div>
<button
onClick={() => {
sound.playClick();
onClose();
}}
className="text-ticket-200 hover:text-white p-1"
>
<X size={20} />
</button>
</div>
{/* Stats Strip */}
<div className="bg-ticket-200 border-b-2 border-ink p-3 flex items-center justify-around font-mono text-xs text-ink">
<div className="text-center">
<span className="text-[10px] text-ink-muted uppercase block">VOYAGES DEBRIEFED</span>
<span className="font-bold text-base text-ink">{memory.totalTripsDebriefed}</span>
</div>
<div className="w-px h-6 bg-ink/20" />
<div className="text-center">
<span className="text-[10px] text-ink-muted uppercase block">DEADWEIGHT RULES</span>
<span className="font-bold text-base text-stamp-red">{memory.exclusions.length}</span>
</div>
<div className="w-px h-6 bg-ink/20" />
<div className="text-center">
<span className="text-[10px] text-ink-muted uppercase block">LEARNED INCLUSIONS</span>
<span className="font-bold text-base text-purple-700">{memory.inclusions.length}</span>
</div>
</div>
<div className="p-4 sm:p-6 max-h-[70vh] overflow-y-auto space-y-6">
{/* Section: Deadweight Rules */}
<div>
<div className="flex items-center gap-2 mb-2 font-mono text-xs font-bold text-stamp-red">
<AlertTriangle size={15} />
<span>OVERPACKED / DEADWEIGHT RULES (PENALIZED OR OMITTED)</span>
</div>
<p className="font-sans text-xs text-ink-muted mb-3">
Items you repeatedly marked as unused on previous trips. The generator penalizes or removes these automatically for matching trip types.
</p>
{memory.exclusions.length === 0 ? (
<div className="p-4 border-2 border-dashed border-ink/30 text-center font-mono text-xs text-ink-muted">
No deadweight items recorded yet. Complete a trip debrief to log overpacked items.
</div>
) : (
<div className="space-y-2">
{memory.exclusions.map((exc) => (
<div
key={`${exc.tripType}-${exc.itemName}`}
className="p-2.5 bg-white border border-ink/40 flex items-center justify-between text-xs font-grotesk shadow-xs"
>
<div>
<div className="flex items-center gap-2">
<span className="font-bold text-ink">{exc.itemName}</span>
<span className="font-mono text-[10px] bg-stamp-red-light text-stamp-red px-1.5 py-0.2 border border-stamp-red/30 uppercase font-bold">
{exc.tripType.replace('_', ' ')}
</span>
</div>
<span className="font-mono text-[10px] text-ink-muted">
Excluded {exc.count} time(s) • Last flagged {exc.lastDate?.split('T')[0]}
</span>
</div>
<button
onClick={() => {
sound.playClick();
onClearMemoryItem('exclusion', exc.itemName);
}}
className="text-ink/40 hover:text-stamp-red p-1"
title="Clear this learned exclusion"
>
<Trash2 size={13} />
</button>
</div>
))}
</div>
)}
</div>
{/* Section: Learned Inclusions */}
<div>
<div className="flex items-center gap-2 mb-2 font-mono text-xs font-bold text-purple-800">
<Sparkles size={15} />
<span>WISHLIST INCLUSIONS (AUTO-INJECTED ON MATCHING TRIPS)</span>
</div>
<p className="font-sans text-xs text-ink-muted mb-3">
Items you wished you had packed during past debriefs. These are automatically recommended when planning new trips of the same archetype.
</p>
{memory.inclusions.length === 0 ? (
<div className="p-4 border-2 border-dashed border-ink/30 text-center font-mono text-xs text-ink-muted">
No wishlist inclusions recorded yet. Add items during your trip debriefs.
</div>
) : (
<div className="space-y-2">
{memory.inclusions.map((inc) => (
<div
key={`${inc.tripType}-${inc.itemName}`}
className="p-2.5 bg-white border border-ink/40 flex items-center justify-between text-xs font-grotesk shadow-xs"
>
<div>
<div className="flex items-center gap-2">
<span className="font-bold text-ink">{inc.itemName}</span>
<span className="font-mono text-[10px] bg-purple-100 text-purple-900 px-1.5 py-0.2 border border-purple-300 uppercase font-bold">
{inc.tripType.replace('_', ' ')}
</span>
<span className="font-mono text-[10px] bg-ticket-200 text-ink px-1 py-0.2 border border-ink/20 uppercase">
{inc.defaultBag}
</span>
</div>
{inc.reason && (
<p className="font-sans text-[11px] text-ink-muted mt-0.5">
"{inc.reason}"
</p>
)}
</div>
<button
onClick={() => {
sound.playClick();
onClearMemoryItem('inclusion', inc.itemName);
}}
className="text-ink/40 hover:text-stamp-red p-1"
title="Clear this learned inclusion"
>
<Trash2 size={13} />
</button>
</div>
))}
</div>
)}
</div>
</div>
{/* Footer */}
<div className="p-4 border-t-2 border-ink bg-ticket-200 flex justify-end">
<button
onClick={() => {
sound.playClick();
onClose();
}}
className="px-5 py-2 border-2 border-ink font-grotesk font-bold text-xs uppercase bg-ink text-ticket-100 shadow-card-hard-sm"
>
CLOSE FLIGHT LOG
</button>
</div>
</div>
</div>
);
};
+293
View File
@@ -0,0 +1,293 @@
import React, { useState } from 'react';
import { AppSettings, Trip, PackMemory } from '../types';
import { sound } from '../utils/audio';
import { X, Settings as SettingsIcon, Volume2, Bot, Download, Upload, Check } from 'lucide-react';
interface SettingsModalProps {
isOpen: boolean;
onClose: () => void;
settings: AppSettings;
onSaveSettings: (settings: AppSettings) => void;
trips: Trip[];
memory: PackMemory;
onImportData: (data: { trips: Trip[]; memory: PackMemory }) => void;
}
export const SettingsModal: React.FC<SettingsModalProps> = ({
isOpen,
onClose,
settings,
onSaveSettings,
trips,
memory,
onImportData,
}) => {
if (!isOpen) return null;
const [current, setCurrent] = useState<AppSettings>({ ...settings });
const [importStatus, setImportStatus] = useState<string | null>(null);
const handleSave = (e: React.FormEvent) => {
e.preventDefault();
sound.playStamp();
sound.enabled = current.soundEnabled;
onSaveSettings(current);
onClose();
};
const handleExport = () => {
sound.playClick();
const exportBundle = {
app: 'loadout',
version: '1.0.0',
exportedAt: new Date().toISOString(),
trips,
memory,
settings: current,
};
const blob = new Blob([JSON.stringify(exportBundle, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `loadout_backup_${new Date().toISOString().split('T')[0]}.json`;
a.click();
URL.revokeObjectURL(url);
};
const handleImport = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
try {
const parsed = JSON.parse(event.target?.result as string);
if (parsed.trips && Array.isArray(parsed.trips)) {
sound.playStamp();
onImportData({
trips: parsed.trips,
memory: parsed.memory || { exclusions: [], inclusions: [], totalTripsDebriefed: 0 },
});
setImportStatus('Backup successfully restored!');
setTimeout(() => setImportStatus(null), 3000);
} else {
setImportStatus('Invalid backup file format');
}
} catch {
setImportStatus('Error reading file');
}
};
reader.readAsText(file);
};
return (
<div className="fixed inset-0 z-50 bg-ink/70 backdrop-blur-xs flex items-center justify-center p-3 sm:p-6 overflow-y-auto">
<div className="bg-ticket-100 border-2 border-ink shadow-card-hard max-w-xl w-full my-auto overflow-hidden animate-in fade-in zoom-in-95 duration-150">
{/* Header */}
<div className="bg-ink text-ticket-100 p-4 flex items-center justify-between border-b-2 border-canary">
<div className="flex items-center gap-2">
<SettingsIcon size={20} className="text-canary" />
<h2 className="font-display text-2xl tracking-wide uppercase text-canary">
DISPATCH TERMINAL SETTINGS
</h2>
</div>
<button
onClick={() => {
sound.playClick();
onClose();
}}
className="text-ticket-200 hover:text-white p-1"
>
<X size={20} />
</button>
</div>
<form onSubmit={handleSave} className="p-4 sm:p-6 max-h-[75vh] overflow-y-auto space-y-6">
{/* Audio Synthesizer */}
<div className="bg-white border-2 border-ink p-3.5 shadow-card-hard-sm">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Volume2 size={18} className="text-gate-orange" />
<div>
<span className="font-mono text-xs font-bold uppercase text-ink block">
TACTILE AUDIO SYNTHESIZER
</span>
<span className="font-sans text-[11px] text-ink-muted">
Paper tear, luggage stamp, and flight terminal sounds
</span>
</div>
</div>
<button
type="button"
onClick={() => {
const next = !current.soundEnabled;
setCurrent({ ...current, soundEnabled: next });
sound.enabled = next;
if (next) sound.playStamp();
}}
className={`font-mono text-xs font-bold px-3 py-1 border-2 border-ink ${
current.soundEnabled
? 'bg-stamp-green-light text-stamp-green border-stamp-green'
: 'bg-ticket-200 text-ink-muted'
}`}
>
{current.soundEnabled ? 'ENABLED' : 'MUTED'}
</button>
</div>
</div>
{/* AI Configuration */}
<div className="bg-white border-2 border-ink p-3.5 shadow-card-hard-sm space-y-3">
<div className="flex items-center justify-between border-b border-ink/20 pb-2">
<div className="flex items-center gap-2">
<Bot size={18} className="text-purple-700" />
<div>
<span className="font-mono text-xs font-bold uppercase text-ink block">
OPTIONAL AI FLIGHT DISPATCHER
</span>
<span className="font-sans text-[11px] text-ink-muted">
Supports local Ollama on vloser or cloud API
</span>
</div>
</div>
<button
type="button"
onClick={() => {
sound.playClick();
setCurrent({ ...current, aiEnabled: !current.aiEnabled });
}}
className={`font-mono text-xs font-bold px-3 py-1 border-2 border-ink ${
current.aiEnabled
? 'bg-purple-100 text-purple-900 border-purple-400'
: 'bg-ticket-200 text-ink-muted'
}`}
>
{current.aiEnabled ? 'AI ACTIVE' : 'OFFLINE ONLY'}
</button>
</div>
{current.aiEnabled && (
<div className="space-y-3 pt-2">
<div>
<label className="block font-mono text-[11px] font-bold uppercase text-ink mb-1">
Provider
</label>
<select
value={current.aiProvider}
onChange={(e) =>
setCurrent({
...current,
aiProvider: e.target.value as 'ollama' | 'gemini' | 'openai',
})
}
className="w-full bg-ticket-50 border border-ink font-mono text-xs px-2.5 py-1.5"
>
<option value="ollama">Local Ollama (Self-Hosted on vloser)</option>
<option value="gemini">Google Gemini API</option>
<option value="openai">OpenAI API</option>
</select>
</div>
{current.aiProvider === 'ollama' ? (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<div>
<label className="block font-mono text-[11px] font-bold uppercase text-ink mb-1">
Ollama Endpoint URL
</label>
<input
type="text"
value={current.ollamaUrl}
onChange={(e) => setCurrent({ ...current, ollamaUrl: e.target.value })}
placeholder="http://localhost:11434"
className="w-full bg-ticket-50 border border-ink font-mono text-xs px-2 py-1.5"
/>
</div>
<div>
<label className="block font-mono text-[11px] font-bold uppercase text-ink mb-1">
Model Tag
</label>
<input
type="text"
value={current.ollamaModel}
onChange={(e) => setCurrent({ ...current, ollamaModel: e.target.value })}
placeholder="llama3.2 or mistral"
className="w-full bg-ticket-50 border border-ink font-mono text-xs px-2 py-1.5"
/>
</div>
</div>
) : (
<div>
<label className="block font-mono text-[11px] font-bold uppercase text-ink mb-1">
API Key
</label>
<input
type="password"
value={current.apiKey}
onChange={(e) => setCurrent({ ...current, apiKey: e.target.value })}
placeholder="sk-..."
className="w-full bg-ticket-50 border border-ink font-mono text-xs px-2 py-1.5"
/>
</div>
)}
</div>
)}
</div>
{/* Backup & Restore */}
<div className="bg-ticket-200/60 border-2 border-ink p-3.5 space-y-2">
<span className="font-mono text-xs font-bold uppercase text-ink block">
DATA ARCHIVE & RESTORE
</span>
<div className="flex gap-2 flex-wrap">
<button
type="button"
onClick={handleExport}
className="flex items-center gap-1.5 px-3 py-1.5 bg-white border border-ink font-mono text-xs font-bold hover:bg-ticket-50"
>
<Download size={13} />
<span>EXPORT MANIFEST BACKUP (.JSON)</span>
</button>
<label className="flex items-center gap-1.5 px-3 py-1.5 bg-white border border-ink font-mono text-xs font-bold hover:bg-ticket-50 cursor-pointer">
<Upload size={13} />
<span>IMPORT BACKUP</span>
<input
type="file"
accept=".json"
onChange={handleImport}
className="hidden"
/>
</label>
</div>
{importStatus && (
<p className="font-mono text-xs font-bold text-stamp-green mt-1">
{importStatus}
</p>
)}
</div>
{/* Actions */}
<div className="border-t-2 border-ink pt-4 flex items-center justify-end gap-3">
<button
type="button"
onClick={() => {
sound.playClick();
onClose();
}}
className="px-4 py-2 border-2 border-ink font-grotesk font-bold text-xs uppercase bg-white hover:bg-ticket-200"
>
CANCEL
</button>
<button
type="submit"
className="px-6 py-2.5 border-2 border-ink font-grotesk font-bold text-xs uppercase bg-ink text-ticket-100 shadow-card-hard active:translate-x-0.5 active:translate-y-0.5"
>
SAVE SETTINGS
</button>
</div>
</form>
</div>
</div>
);
};
+412
View File
@@ -0,0 +1,412 @@
import React, { useState } from 'react';
import { TripType, Climate, LuggageConfig, Trip, PackMemory } from '../types';
import { generateAirportCode, generatePackingList } from '../utils/generator';
import { sound } from '../utils/audio';
import { X, Plane, Calendar, Cloud, Luggage, Sparkles, Check } from 'lucide-react';
interface TripModalProps {
isOpen: boolean;
onClose: () => void;
onSaveTrip: (trip: Trip) => void;
memory: PackMemory;
}
const TRIP_TYPES: { id: TripType; label: string; desc: string }[] = [
{ id: 'international_urban', label: 'International City', desc: 'Urban walking, adapters, transit cards' },
{ id: 'beach', label: 'Beach & Tropical', desc: 'Swimwear, reef sunscreen, UV protection' },
{ id: 'alpine_cold', label: 'Cold / Alpine Ski', desc: 'Merino base layers, down coat, thermal gear' },
{ id: 'hiking_camping', label: 'Hiking & Outdoor', desc: 'Trail boots, daypack, headlamp, blister kit' },
{ id: 'business', label: 'Business / Conference', desc: 'Blazer, dress shoes, laptop, power cords' },
{ id: 'weekend_getaway', label: 'Weekend Break', desc: 'Minimalist 2-3 day capsule wardrobe' },
{ id: 'road_trip', label: 'Road Trip / Overland', desc: 'Car gear, casual flex, comfort layers' },
];
const CLIMATES: { id: Climate; label: string; icon: string }[] = [
{ id: 'hot', label: 'Hot / Sunny', icon: '☀️' },
{ id: 'mild', label: 'Mild / Temperate', icon: '⛅' },
{ id: 'cold', label: 'Cold / Freezing', icon: '❄️' },
{ id: 'rainy', label: 'Rainy / Variable', icon: '🌧️' },
];
const LUGGAGE_MODES: { id: LuggageConfig; label: string; desc: string }[] = [
{ id: 'carry_on_only', label: 'Carry-On Only', desc: 'Personal item + overhead roller (TSA 3-1-1 liquids rule enforced)' },
{ id: 'checked_bag', label: 'Checked Bag Included', desc: 'Cargo hold: full liquids allowed, trekking poles, heavy winter boots' },
{ id: 'backpack_only', label: 'One Backpack Only', desc: 'Minimalist 25-35L pack for ultra-mobile travel' },
];
const ACTIVITIES = [
{ id: 'formal', label: 'Formal / Fine Dining', icon: '👔' },
{ id: 'swimming', label: 'Swimming / Water', icon: '🏊' },
{ id: 'hiking', label: 'Trail Hiking', icon: '🥾' },
{ id: 'gym', label: 'Gym / Fitness', icon: '🏋️' },
{ id: 'remote_work', label: 'Remote Work / Tech', icon: '💻' },
{ id: 'photography', label: 'Photography', icon: '📷' },
];
export const TripModal: React.FC<TripModalProps> = ({
isOpen,
onClose,
onSaveTrip,
memory,
}) => {
if (!isOpen) return null;
const [destination, setDestination] = useState('');
const [title, setTitle] = useState('');
const [tripType, setTripType] = useState<TripType>('international_urban');
const [durationDays, setDurationDays] = useState(7);
const [climate, setClimate] = useState<Climate>('mild');
const [luggageConfig, setLuggageConfig] = useState<LuggageConfig>('carry_on_only');
const [hasLaundry, setHasLaundry] = useState(true);
const [selectedActivities, setSelectedActivities] = useState<string[]>(['remote_work']);
const destinationAirportCode = generateAirportCode(destination || 'DEST');
const routeCode = `DEP ➔ ${destinationAirportCode}`;
const toggleActivity = (id: string) => {
sound.playClick();
if (selectedActivities.includes(id)) {
setSelectedActivities(selectedActivities.filter((a) => a !== id));
} else {
setSelectedActivities([...selectedActivities, id]);
}
};
const handleGenerate = (e: React.FormEvent) => {
e.preventDefault();
sound.playStamp();
const tripTitle = title.trim() || `${destination.trim() || 'Voyage'} Expedition`;
const tripId = `trip_${Date.now()}`;
const items = generatePackingList({
title: tripTitle,
destination: destination.trim() || 'New Destination',
tripType,
durationDays,
durationNights: Math.max(1, durationDays - 1),
climate,
luggageConfig,
hasLaundry,
activities: selectedActivities,
memory,
});
const newTrip: Trip = {
id: tripId,
title: tripTitle,
destination: destination.trim() || 'New Destination',
routeCode,
tripType,
durationDays,
durationNights: Math.max(1, durationDays - 1),
climate,
luggageConfig,
hasLaundry,
activities: selectedActivities,
items,
createdAt: new Date().toISOString(),
};
onSaveTrip(newTrip);
onClose();
};
// Count relevant memory items
const relevantInclusions = memory.inclusions.filter((i) => i.tripType === tripType).length;
const relevantExclusions = memory.exclusions.filter((e) => e.tripType === tripType).length;
return (
<div className="fixed inset-0 z-50 bg-ink/70 backdrop-blur-xs flex items-center justify-center p-3 sm:p-6 overflow-y-auto">
<div className="bg-ticket-100 border-2 border-ink shadow-card-hard max-w-2xl w-full my-auto overflow-hidden animate-in fade-in zoom-in-95 duration-150">
{/* Top Boarding Pass Header Strip */}
<div className="bg-ink text-ticket-100 p-4 flex items-center justify-between border-b-2 border-canary">
<div className="flex items-center gap-2">
<span className="w-3 h-3 bg-canary rotate-45" />
<h2 className="font-display text-2xl tracking-wide uppercase text-canary">
DISPATCH NEW TRAVEL MANIFEST
</h2>
</div>
<button
onClick={() => {
sound.playClick();
onClose();
}}
className="text-ticket-200 hover:text-white p-1"
>
<X size={20} />
</button>
</div>
{/* Route Code Banner */}
<div className="bg-canary border-b-2 border-ink px-4 py-2 flex items-center justify-between font-mono text-xs font-bold text-ink">
<span>ROUTING TELEMETRY:</span>
<span className="bg-ink text-canary px-2 py-0.5 tracking-widest text-sm">
{routeCode}
</span>
</div>
<form onSubmit={handleGenerate} className="p-4 sm:p-6 max-h-[80vh] overflow-y-auto space-y-5">
{/* Destination & Title */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block font-mono text-xs font-bold uppercase text-ink mb-1">
Destination City / Country
</label>
<input
type="text"
required
value={destination}
onChange={(e) => setDestination(e.target.value)}
placeholder="e.g. Reykjavik, Iceland or Tokyo"
className="w-full bg-white border-2 border-ink px-3 py-2 font-grotesk font-bold text-ink text-sm shadow-card-hard-sm focus:outline-none focus:ring-2 focus:ring-canary"
/>
</div>
<div>
<label className="block font-mono text-xs font-bold uppercase text-ink mb-1">
Trip Name / Manifest Title
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g. Autumn Highlands Trek"
className="w-full bg-white border-2 border-ink px-3 py-2 font-grotesk font-bold text-ink text-sm shadow-card-hard-sm focus:outline-none focus:ring-2 focus:ring-canary"
/>
</div>
</div>
{/* Trip Type */}
<div>
<label className="block font-mono text-xs font-bold uppercase text-ink mb-1.5">
Trip Archetype
</label>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{TRIP_TYPES.map((t) => (
<button
type="button"
key={t.id}
onClick={() => {
sound.playClick();
setTripType(t.id);
}}
className={`text-left p-2.5 border-2 border-ink transition-all flex flex-col ${
tripType === t.id
? 'bg-canary-light shadow-card-hard border-ink'
: 'bg-white hover:bg-ticket-50'
}`}
>
<span className="font-grotesk font-bold text-sm text-ink uppercase">
{t.label}
</span>
<span className="font-sans text-[11px] text-ink-muted">
{t.desc}
</span>
</button>
))}
</div>
</div>
{/* Duration & Laundry Access */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 bg-ticket-200/60 p-3.5 border-2 border-ink">
<div>
<div className="flex items-center justify-between mb-1">
<label className="font-mono text-xs font-bold uppercase text-ink">
Duration (Days)
</label>
<span className="font-mono text-xs font-bold bg-ink text-ticket-100 px-1.5 py-0.5">
{durationDays} DAYS / {Math.max(1, durationDays - 1)} NIGHTS
</span>
</div>
<input
type="range"
min="1"
max="30"
value={durationDays}
onChange={(e) => setDurationDays(parseInt(e.target.value))}
className="w-full accent-gate-orange cursor-pointer"
/>
<div className="flex gap-1.5 mt-2">
{[3, 5, 7, 10, 14].map((d) => (
<button
type="button"
key={d}
onClick={() => {
sound.playClick();
setDurationDays(d);
}}
className={`font-mono text-[10px] font-bold px-2 py-0.5 border border-ink ${
durationDays === d ? 'bg-ink text-ticket-100' : 'bg-white text-ink'
}`}
>
{d}D
</button>
))}
</div>
</div>
<div>
<label className="block font-mono text-xs font-bold uppercase text-ink mb-1">
Laundry Facility Access
</label>
<button
type="button"
onClick={() => {
sound.playClick();
setHasLaundry(!hasLaundry);
}}
className={`w-full p-2 border-2 border-ink font-mono text-xs text-left flex items-start gap-2 ${
hasLaundry
? 'bg-stamp-green-light border-stamp-green text-stamp-green font-bold'
: 'bg-white text-ink-muted'
}`}
>
<div className="mt-0.5">
<div
className={`w-4 h-4 border border-ink flex items-center justify-center ${
hasLaundry ? 'bg-stamp-green text-white' : 'bg-white'
}`}
>
{hasLaundry && <Check size={12} strokeWidth={3} />}
</div>
</div>
<div>
<div className="text-ink font-bold">
{hasLaundry ? 'LAUNDRY AVAILABLE' : 'NO LAUNDRY (FULL CLOTHING)'}
</div>
<div className="text-[11px] text-ink-muted font-sans leading-tight mt-0.5">
{hasLaundry
? 'Engine caps clothes count to ~4 days, saving massive bag space'
: 'Packs full daily allocation for all days'}
</div>
</div>
</button>
</div>
</div>
{/* Climate Selection */}
<div>
<label className="block font-mono text-xs font-bold uppercase text-ink mb-1.5">
Climate & Weather Forecast
</label>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
{CLIMATES.map((c) => (
<button
type="button"
key={c.id}
onClick={() => {
sound.playClick();
setClimate(c.id);
}}
className={`p-2 border-2 border-ink flex items-center gap-2 font-mono text-xs font-bold ${
climate === c.id
? 'bg-canary shadow-card-hard-sm border-ink'
: 'bg-white hover:bg-ticket-50'
}`}
>
<span className="text-base">{c.icon}</span>
<span className="uppercase text-[11px]">{c.label}</span>
</button>
))}
</div>
</div>
{/* Luggage Mode */}
<div>
<label className="block font-mono text-xs font-bold uppercase text-ink mb-1.5">
Baggage Allowance Mode
</label>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
{LUGGAGE_MODES.map((m) => (
<button
type="button"
key={m.id}
onClick={() => {
sound.playClick();
setLuggageConfig(m.id);
}}
className={`p-2.5 border-2 border-ink text-left flex flex-col ${
luggageConfig === m.id
? 'bg-gate-light border-gate-orange shadow-card-hard-sm'
: 'bg-white hover:bg-ticket-50'
}`}
>
<span className="font-grotesk font-bold text-xs uppercase text-ink">
{m.label}
</span>
<span className="font-sans text-[10px] text-ink-muted leading-tight mt-0.5">
{m.desc}
</span>
</button>
))}
</div>
</div>
{/* Activities Badges */}
<div>
<label className="block font-mono text-xs font-bold uppercase text-ink mb-1.5">
Planned Activities / Special Gear
</label>
<div className="flex flex-wrap gap-2">
{ACTIVITIES.map((a) => {
const active = selectedActivities.includes(a.id);
return (
<button
type="button"
key={a.id}
onClick={() => toggleActivity(a.id)}
className={`px-2.5 py-1.5 border-2 border-ink font-mono text-xs font-bold flex items-center gap-1.5 transition-all ${
active
? 'bg-ink text-ticket-100 shadow-card-hard-sm'
: 'bg-white text-ink hover:bg-ticket-50'
}`}
>
<span>{a.icon}</span>
<span>{a.label}</span>
</button>
);
})}
</div>
</div>
{/* Learning Memory Signal */}
{(relevantInclusions > 0 || relevantExclusions > 0) && (
<div className="bg-purple-50 border-2 border-purple-300 p-3 flex items-start gap-2.5 font-mono text-xs text-purple-900">
<Sparkles size={16} className="text-purple-600 shrink-0 mt-0.5" />
<div>
<span className="font-bold">CONTINUOUS LEARNING ACTIVE:</span>
<p className="font-sans text-[11px] text-purple-800 mt-0.5">
Your past debriefs have taught the engine for {tripType.replace('_', ' ')}:
{relevantInclusions > 0 && ` ${relevantInclusions} wishlist item(s) will be automatically injected.`}
{relevantExclusions > 0 && ` ${relevantExclusions} frequently unused deadweight item(s) will be omitted or reduced.`}
</p>
</div>
</div>
)}
{/* Form Actions */}
<div className="border-t-2 border-ink pt-4 flex items-center justify-end gap-3">
<button
type="button"
onClick={() => {
sound.playClick();
onClose();
}}
className="px-4 py-2 border-2 border-ink font-grotesk font-bold text-xs uppercase bg-white hover:bg-ticket-200"
>
CANCEL
</button>
<button
type="submit"
className="px-6 py-2.5 border-2 border-ink font-grotesk font-bold text-sm uppercase bg-gate-orange hover:bg-gate-hover text-white shadow-card-hard active:translate-x-0.5 active:translate-y-0.5 flex items-center gap-2"
>
<Plane size={16} />
<span>STAMP & ISSUE LOADOUT MANIFEST</span>
</button>
</div>
</form>
</div>
</div>
);
};
+120
View File
@@ -0,0 +1,120 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer utilities {
.font-display {
font-family: 'Bebas Neue', Impact, sans-serif;
}
.font-grotesk {
font-family: 'Space Grotesk', sans-serif;
}
.font-mono {
font-family: 'IBM Plex Mono', monospace;
}
.font-sans {
font-family: 'Plus Jakarta Sans', system-ui, sans-serif;
}
/* Perforated ticket edge punch-outs */
.ticket-notch-left {
position: relative;
}
.ticket-notch-left::before {
content: '';
position: absolute;
left: -10px;
top: 50%;
transform: translateY(-50%);
width: 20px;
height: 20px;
background-color: #F4F1EA;
border-radius: 50%;
border-right: 1.5px dashed #D0C8B5;
z-index: 10;
}
.ticket-notch-right {
position: relative;
}
.ticket-notch-right::after {
content: '';
position: absolute;
right: -10px;
top: 50%;
transform: translateY(-50%);
width: 20px;
height: 20px;
background-color: #F4F1EA;
border-radius: 50%;
border-left: 1.5px dashed #D0C8B5;
z-index: 10;
}
/* Rubber stamp visual style */
.stamp-badge {
display: inline-flex;
align-items: center;
justify-content: center;
text-transform: uppercase;
letter-spacing: 0.12em;
font-family: 'IBM Plex Mono', monospace;
font-weight: 700;
border: 2px solid currentColor;
border-radius: 4px;
padding: 2px 8px;
transform: rotate(-2deg);
mask-image: radial-gradient(circle, black 70%, rgba(0,0,0,0.8) 100%);
}
.stamp-badge-packed {
color: #147D52;
background-color: rgba(20, 125, 82, 0.08);
border-color: #147D52;
}
.stamp-badge-excluded {
color: #D92D20;
background-color: rgba(217, 45, 32, 0.08);
border-color: #D92D20;
transform: rotate(3deg);
}
.stamp-badge-wishlist {
color: #0E7490;
background-color: rgba(14, 116, 144, 0.08);
border-color: #0E7490;
transform: rotate(-1.5deg);
}
}
html, body {
margin: 0;
padding: 0;
min-height: 100%;
background-color: #F4F1EA;
color: #141518;
-webkit-font-smoothing: antialiased;
}
#root {
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* Custom sleek manifest scrollbar */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: #EFE9DA;
}
::-webkit-scrollbar-thumb {
background: #C4BAA3;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #9EA4B1;
}
+10
View File
@@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
+102
View File
@@ -0,0 +1,102 @@
export type BagType = 'personal_item' | 'carry_on' | 'checked' | 'worn';
export type Category = 'clothing' | 'toiletries' | 'tech' | 'documents' | 'gear' | 'wellness';
export type TripType =
| 'weekend_getaway'
| 'business'
| 'beach'
| 'alpine_cold'
| 'hiking_camping'
| 'international_urban'
| 'road_trip';
export type Climate = 'hot' | 'mild' | 'cold' | 'rainy';
export type LuggageConfig = 'carry_on_only' | 'checked_bag' | 'backpack_only';
export type ItemSource = 'generated' | 'user' | 'learned' | 'ai';
export interface PackingItem {
id: string;
name: string;
category: Category;
quantity: number;
bag: BagType;
packed: boolean;
essential?: boolean;
notes?: string;
source?: ItemSource;
learnedReason?: string;
debriefStatus?: 'used' | 'deadweight' | 'wishlist';
}
export interface TripDebrief {
notes: string;
rating: number; // 1 to 5
completedAt: string;
excludedItemNames: string[]; // Packed but never used ("Deadweight")
includedItemNames: string[]; // Missing items ("Wish I brought")
}
export interface Trip {
id: string;
title: string;
destination: string;
routeCode: string; // e.g. "SFO ➔ NRT"
tripType: TripType;
durationDays: number;
durationNights: number;
climate: Climate;
luggageConfig: LuggageConfig;
hasLaundry: boolean;
activities: string[]; // 'formal', 'swimming', 'hiking', 'gym', 'remote_work', 'photography'
items: PackingItem[];
createdAt: string;
debrief?: TripDebrief;
}
export interface MemoryExclusion {
itemName: string;
tripType: TripType;
count: number;
lastDate: string;
}
export interface MemoryInclusion {
itemName: string;
category: Category;
defaultBag: BagType;
tripType: TripType;
count: number;
lastDate: string;
reason?: string;
}
export interface PackMemory {
exclusions: MemoryExclusion[];
inclusions: MemoryInclusion[];
totalTripsDebriefed: number;
}
export interface AppSettings {
soundEnabled: boolean;
aiEnabled: boolean;
aiProvider: 'ollama' | 'gemini' | 'openai';
ollamaUrl: string;
ollamaModel: string;
apiKey: string;
}
export interface BagMeta {
type: BagType;
label: string;
code: string;
icon: string;
description: string;
tsaRule?: string;
color: string;
borderClass: string;
bgClass: string;
badgeBg: string;
}
+135
View File
@@ -0,0 +1,135 @@
// Web Audio API tactile feedback synthesizer for Loadout
// Generates physical ticket tears, stamps, and clicks without external audio files
class AudioEngine {
private ctx: AudioContext | null = null;
public enabled: boolean = true;
private init() {
if (!this.ctx && typeof window !== 'undefined') {
const AudioCtx = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
if (AudioCtx) {
this.ctx = new AudioCtx();
}
}
if (this.ctx && this.ctx.state === 'suspended') {
this.ctx.resume().catch(() => {});
}
}
// Crisp ticket perforation tear sound when packing/checking off an item
playTear() {
if (!this.enabled) return;
try {
this.init();
if (!this.ctx) return;
const now = this.ctx.currentTime;
const bufferSize = this.ctx.sampleRate * 0.08;
const buffer = this.ctx.createBuffer(1, bufferSize, this.ctx.sampleRate);
const data = buffer.getChannelData(0);
// Generate crackly paper friction noise
for (let i = 0; i < bufferSize; i++) {
data[i] = (Math.random() * 2 - 1) * Math.exp(-i / (bufferSize * 0.3));
}
const noise = this.ctx.createBufferSource();
noise.buffer = buffer;
const filter = this.ctx.createBiquadFilter();
filter.type = 'bandpass';
filter.frequency.setValueAtTime(1800, now);
filter.Q.setValueAtTime(3, now);
const gain = this.ctx.createGain();
gain.gain.setValueAtTime(0.18, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.08);
noise.connect(filter);
filter.connect(gain);
gain.connect(this.ctx.destination);
noise.start(now);
} catch {
// Ignore audio context errors gracefully
}
}
// Heavy mechanical stamp thud (for debrief stamping or trip creation)
playStamp() {
if (!this.enabled) return;
try {
this.init();
if (!this.ctx) return;
const now = this.ctx.currentTime;
// Low frequency thump
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = 'triangle';
osc.frequency.setValueAtTime(130, now);
osc.frequency.exponentialRampToValueAtTime(35, now + 0.12);
gain.gain.setValueAtTime(0.3, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.12);
osc.connect(gain);
gain.connect(this.ctx.destination);
osc.start(now);
osc.stop(now + 0.12);
// Paper slap friction
const bufferSize = this.ctx.sampleRate * 0.05;
const buffer = this.ctx.createBuffer(1, bufferSize, this.ctx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
data[i] = (Math.random() * 2 - 1) * Math.exp(-i / (bufferSize * 0.2));
}
const noise = this.ctx.createBufferSource();
noise.buffer = buffer;
const noiseGain = this.ctx.createGain();
noiseGain.gain.setValueAtTime(0.12, now);
noiseGain.gain.exponentialRampToValueAtTime(0.001, now + 0.05);
noise.connect(noiseGain);
noiseGain.connect(this.ctx.destination);
noise.start(now);
} catch {
// Ignore audio context errors gracefully
}
}
// Crisp digital boarding pass scanner beep/click
playClick() {
if (!this.enabled) return;
try {
this.init();
if (!this.ctx) return;
const now = this.ctx.currentTime;
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(1400, now);
osc.frequency.exponentialRampToValueAtTime(900, now + 0.03);
gain.gain.setValueAtTime(0.08, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.03);
osc.connect(gain);
gain.connect(this.ctx.destination);
osc.start(now);
osc.stop(now + 0.03);
} catch {
// Ignore audio context errors gracefully
}
}
}
export const sound = new AudioEngine();
+269
View File
@@ -0,0 +1,269 @@
import { PackingItem, TripType, Climate, LuggageConfig, Category, BagType, PackMemory } from '../types';
interface GenerationParams {
title: string;
destination: string;
tripType: TripType;
durationDays: number;
durationNights: number;
climate: Climate;
luggageConfig: LuggageConfig;
hasLaundry: boolean;
activities: string[];
memory?: PackMemory;
}
// Generate an authentic airport IATA-style 3-letter code from a city name
export function generateAirportCode(city: string): string {
if (!city || city.trim().length === 0) return 'DEP';
const clean = city.trim().toUpperCase().replace(/[^A-Z]/g, '');
if (clean.length <= 3) return clean.padEnd(3, 'X');
// Well known city shortcuts
const map: Record<string, string> = {
PARIS: 'CDG',
LONDON: 'LHR',
TOKYO: 'HND',
NEWYORK: 'JFK',
CHICAGO: 'ORD',
SANFRANCISCO: 'SFO',
SEATTLE: 'SEA',
LOSANGELES: 'LAX',
BERLIN: 'BER',
AMSTERDAM: 'AMS',
ROME: 'FCO',
DENVER: 'DEN',
MIAMI: 'MIA',
HONOLULU: 'HNL',
BARCELONA: 'BCN',
SYDNEY: 'SYD',
TORONTO: 'YYZ',
VANCOUVER: 'YVR',
'SALT LAKE': 'SLC',
};
for (const [key, code] of Object.entries(map)) {
if (clean.includes(key)) return code;
}
// Fallback: First letter + middle letter + last letter
return `${clean[0]}${clean[Math.floor(clean.length / 2)]}${clean[clean.length - 1]}`;
}
export function generatePackingList(params: GenerationParams): PackingItem[] {
const {
tripType,
durationDays,
climate,
luggageConfig,
hasLaundry,
activities,
memory,
} = params;
const items: PackingItem[] = [];
const addItem = (
name: string,
category: Category,
quantity: number,
bag: BagType,
essential: boolean = false,
notes?: string,
source: 'generated' | 'user' | 'learned' = 'generated',
learnedReason?: string
) => {
// Check if user previously marked this item as deadweight for this trip type
if (memory && source === 'generated') {
const exclusion = memory.exclusions.find(
(e) => e.tripType === tripType && e.itemName.toLowerCase() === name.toLowerCase()
);
if (exclusion && exclusion.count >= 2) {
// Excluded 2+ times: skip completely to prevent repeating past packing mistakes!
return;
} else if (exclusion && exclusion.count === 1) {
// Excluded once: reduce quantity if > 1 or mark note
quantity = Math.max(1, Math.floor(quantity * 0.7));
learnedReason = `Reduced quantity (marked deadweight on previous ${tripType.replace('_', ' ')})`;
}
}
items.push({
id: `item_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`,
name,
category,
quantity,
bag: luggageConfig === 'backpack_only' && bag === 'checked' ? 'carry_on' : bag,
packed: false,
essential,
notes,
source,
learnedReason,
});
};
// 1. CRITICAL TRAVEL DOCUMENTS & VALUE (Always Personal Item)
addItem('Passport / Government ID', 'documents', 1, 'personal_item', true, 'Physical original + photo backup on phone');
addItem('Wallet (Credit cards, debit, minimal cash)', 'documents', 1, 'personal_item', true);
addItem('Boarding passes & hotel reservations (offline)', 'documents', 1, 'personal_item', true);
if (tripType === 'international_urban' || tripType === 'beach') {
addItem('Health / Travel insurance card', 'documents', 1, 'personal_item', true);
}
// 2. TECH & ESSENTIAL ELECTRONICS (TSA requires lithium batteries in cabin!)
addItem('Smartphone & heavy-duty cable', 'tech', 1, 'personal_item', true);
addItem('Power Bank (10,000–20,000 mAh)', 'tech', 1, 'personal_item', true, 'FAA/TSA rule: Must be in personal/cabin bag, never checked');
addItem('Noise-canceling headphones / earbuds', 'tech', 1, 'personal_item', false, 'For flight/transit');
addItem('Multi-port USB-C GaN fast wall charger', 'tech', 1, 'personal_item', true);
if (tripType === 'international_urban') {
addItem('Universal international travel plug adapter', 'tech', 1, 'personal_item', true);
}
if (activities.includes('remote_work') || tripType === 'business') {
addItem('Laptop & power brick', 'tech', 1, 'personal_item', true);
addItem('Wireless travel mouse & pad', 'tech', 1, 'personal_item');
addItem('Laptop stand / portable keyboard', 'tech', 1, 'carry_on');
}
if (activities.includes('photography')) {
addItem('Camera body & primary lens', 'tech', 1, 'personal_item', true);
addItem('Extra SD cards & spare battery', 'tech', 1, 'personal_item', true);
addItem('Lens cloth & rocket blower', 'tech', 1, 'personal_item');
}
// 3. MEDICINE & WELLNESS
addItem('Prescription medications (full trip + 2 days reserve)', 'wellness', 1, 'personal_item', true, 'Keep in cabin bag in case luggage is delayed');
addItem('Mini medical kit (ibuprofen, band-aids, antacids, allergy)', 'wellness', 1, 'personal_item', false);
addItem('Electrolyte hydration packets', 'wellness', Math.min(durationDays, 5), 'personal_item');
addItem('Lip balm with SPF', 'wellness', 1, 'personal_item');
// 4. CLOTHING FORMULAS
// Laundry factor: if laundry is available, max out at ~4 days worth regardless of trip length!
const clothingMultiplier = hasLaundry ? Math.min(durationDays, 4) : Math.min(durationDays, 14);
// Underwear & socks: 1 per day + 1 buffer (or capped with laundry)
const underwearQty = hasLaundry ? Math.min(durationDays + 1, 5) : durationDays + 1;
const socksQty = hasLaundry ? Math.min(durationDays + 1, 5) : durationDays + 1;
addItem('Underwear', 'clothing', underwearQty, 'carry_on', true, hasLaundry ? 'Capped for mid-trip laundry cycle' : undefined);
addItem('Pairs of socks', 'clothing', socksQty, 'carry_on', true);
// Tops & shirts
const shirtsQty = hasLaundry ? Math.min(Math.ceil(durationDays * 0.6), 4) : Math.min(durationDays, 8);
addItem('T-shirts / casual tops', 'clothing', shirtsQty, 'carry_on');
// Bottoms
const pantsQty = Math.max(1, Math.min(Math.ceil(durationDays / 3), 3));
addItem('Pants / Jeans / Chinos', 'clothing', pantsQty, 'carry_on');
// Travel Day Outfit (Worn)
addItem('Travel day comfortable shoes', 'clothing', 1, 'worn', true, 'Wear heaviest shoes on plane to save luggage weight');
addItem('Comfortable travel pants / joggers', 'clothing', 1, 'worn', true);
addItem('Transit layer (soft hoodie or light sweater)', 'clothing', 1, 'worn', false, 'Airplanes are often cold');
// Sleepwear
addItem('Sleepwear / lounge set', 'clothing', hasLaundry ? 2 : Math.min(Math.ceil(durationDays / 3), 3), 'carry_on');
// 5. CLIMATE-SPECIFIC ADJUSTMENTS
if (climate === 'cold' || tripType === 'alpine_cold') {
addItem('Packable down / insulated winter jacket', 'clothing', 1, 'worn', true, 'Wear onto plane to save bag space');
addItem('Thermal base layer top & bottoms (merino wool)', 'clothing', 2, 'carry_on', true);
addItem('Warm wool beanie & neck gaiter', 'clothing', 1, 'personal_item');
addItem('Insulated windproof gloves', 'clothing', 1, 'personal_item');
addItem('Heavy wool boot socks', 'clothing', 3, 'carry_on');
addItem('Hand warmers', 'gear', 4, 'carry_on');
} else if (climate === 'hot' || tripType === 'beach') {
addItem('Lightweight linen / breathable shirts', 'clothing', Math.min(durationDays, 4), 'carry_on');
addItem('Casual shorts', 'clothing', Math.max(2, Math.min(durationDays, 4)), 'carry_on');
addItem('UV protection sunglasses', 'gear', 1, 'personal_item', true);
addItem('Wide-brim sun hat or baseball cap', 'clothing', 1, 'personal_item');
addItem('Reef-safe sunscreen SPF 50', 'toiletries', 1, luggageConfig === 'checked_bag' ? 'checked' : 'carry_on', true, luggageConfig === 'checked_bag' ? 'Full size in checked' : 'TSA 3.4oz limit');
}
if (climate === 'rainy') {
addItem('Ultralight packable rain shell / jacket', 'clothing', 1, 'personal_item', true);
addItem('Compact windproof travel umbrella', 'gear', 1, 'personal_item');
addItem('Waterproof phone pouch / dry bag', 'gear', 1, 'carry_on');
addItem('Quick-dry moisture-wicking socks', 'clothing', 2, 'carry_on');
}
// 6. ACTIVITY ADJUSTMENTS
if (activities.includes('swimming') || tripType === 'beach') {
addItem('Swimsuits / swim trunks', 'clothing', 2, 'carry_on', true);
addItem('Packable quick-dry microfiber towel', 'gear', 1, 'carry_on');
addItem('Waterproof wet bag for damp clothes', 'gear', 1, 'carry_on');
addItem('Waterproof sandals / flip-flops', 'clothing', 1, 'carry_on');
}
if (activities.includes('formal') || tripType === 'business') {
addItem('Tailored blazer / suit jacket', 'clothing', 1, 'carry_on', true);
addItem('Dress button-down shirts', 'clothing', Math.min(durationDays, 3), 'carry_on', true);
addItem('Dress belt', 'clothing', 1, 'carry_on');
addItem('Dress shoes / oxfords / heels', 'clothing', 1, 'carry_on', true);
if (tripType === 'business') {
addItem('Business cards & notebook / pen', 'documents', 1, 'personal_item');
}
}
if (activities.includes('hiking') || tripType === 'hiking_camping') {
addItem('Trail runners / hiking boots', 'clothing', 1, 'worn', true, 'Break them in before trip');
addItem('Lightweight packable daypack (15-20L)', 'gear', 1, 'carry_on', true);
addItem('Water filtration bottle / reservoir', 'gear', 1, 'carry_on', true);
addItem('Headlamp with extra batteries', 'gear', 1, 'personal_item', true);
addItem('Blister treatment & moleskin tape', 'wellness', 1, 'personal_item', true);
addItem('Insect repellent spray / wipes', 'toiletries', 1, 'carry_on');
if (luggageConfig === 'checked_bag') {
addItem('Collapsible trekking poles', 'gear', 1, 'checked', false, 'TSA banned in carry-on!');
addItem('Pocket knife / multi-tool', 'gear', 1, 'checked', false, 'TSA strictly prohibited in cabin');
}
}
if (activities.includes('gym')) {
addItem('Workout athletic shirts', 'clothing', 2, 'carry_on');
addItem('Athletic gym shorts / leggings', 'clothing', 2, 'carry_on');
addItem('Lightweight training shoes', 'clothing', 1, 'carry_on');
}
// 7. TOILETRIES (TSA 3-1-1 liquids rule enforced if carry-on)
const toiletryBag: BagType = luggageConfig === 'checked_bag' ? 'checked' : 'carry_on';
const liquidNote = luggageConfig === 'checked_bag' ? 'Full size allowed' : 'Must be <= 3.4oz (100ml) in 1-quart transparent pouch';
addItem('Toothbrush & travel toothpaste', 'toiletries', 1, 'carry_on', true, liquidNote);
addItem('Dental floss', 'toiletries', 1, 'carry_on');
addItem('Deodorant', 'toiletries', 1, 'carry_on', true);
addItem('Face wash & moisturizer', 'toiletries', 1, toiletryBag, false, liquidNote);
addItem('Shampoo & conditioner bar or travel bottles', 'toiletries', 1, toiletryBag, false, liquidNote);
addItem('Razor & shave cream', 'toiletries', 1, toiletryBag);
addItem('Nail clippers & tweezers', 'toiletries', 1, toiletryBag);
// 8. TRANSIT CONVENIENCE & GEAR
addItem('Inflatable neck pillow & eye mask', 'gear', 1, 'personal_item');
addItem('Silicone earplugs (airplane cabin pressure & hotel noise)', 'gear', 1, 'personal_item');
addItem('Pen for customs / immigration arrival cards', 'documents', 1, 'personal_item', true);
addItem('Empty reusable water bottle (fill after airport security)', 'gear', 1, 'personal_item', true);
addItem('Compact nylon laundry bag / compression sack', 'gear', 1, 'carry_on');
// 9. CONTINUOUS LEARNING INJECTION (From User's Past Debriefs)
if (memory && memory.inclusions && memory.inclusions.length > 0) {
const relevantInclusions = memory.inclusions.filter((inc) => inc.tripType === tripType);
for (const inc of relevantInclusions) {
// Don't add duplicate if already present
const existing = items.find((i) => i.name.toLowerCase() === inc.itemName.toLowerCase());
if (!existing) {
addItem(
inc.itemName,
inc.category || 'gear',
1,
inc.defaultBag || 'carry_on',
false,
inc.reason || 'Added from your past trip debrief feedback',
'learned',
`Learned wisdom: You previously wished for this on a ${tripType.replace('_', ' ')}`
);
}
}
}
return items;
}
+272
View File
@@ -0,0 +1,272 @@
import { Trip, PackMemory, AppSettings, TripDebrief, TripType } from '../types';
const STORAGE_KEYS = {
TRIPS: 'loadout_trips_v1',
MEMORY: 'loadout_memory_v1',
SETTINGS: 'loadout_settings_v1',
ACTIVE_TRIP_ID: 'loadout_active_trip_id_v1',
};
// Initial default memory data so the app has smart travel wisdom right out of the box
const DEFAULT_MEMORY: PackMemory = {
exclusions: [
{ itemName: 'Heavy winter coat', tripType: 'weekend_getaway', count: 1, lastDate: '2026-08-10' },
{ itemName: 'Full size hair dryer', tripType: 'beach', count: 2, lastDate: '2026-07-14' },
{ itemName: 'Extra pair of dress shoes', tripType: 'business', count: 1, lastDate: '2026-09-02' },
],
inclusions: [
{
itemName: 'Packable laundry detergent sheets',
category: 'clothing',
defaultBag: 'carry_on',
tripType: 'international_urban',
count: 2,
lastDate: '2026-06-20',
reason: 'Saves hotel laundry fees; perfect for sink-washing socks/shirts',
},
{
itemName: 'Compact 3-prong outlet splitter',
category: 'tech',
defaultBag: 'personal_item',
tripType: 'business',
count: 2,
lastDate: '2026-09-02',
reason: 'Airport and hotel outlets are always limited or loose',
},
{
itemName: 'High SPF reef-safe mineral stick',
category: 'toiletries',
defaultBag: 'personal_item',
tripType: 'beach',
count: 3,
lastDate: '2026-07-14',
reason: 'Easy to reapply on the go without greasy hands',
},
],
totalTripsDebriefed: 3,
};
const DEFAULT_SETTINGS: AppSettings = {
soundEnabled: true,
aiEnabled: false,
aiProvider: 'ollama',
ollamaUrl: 'http://localhost:11434',
ollamaModel: 'llama3.2',
apiKey: '',
};
// Seed sample trip if user has none yet
function getInitialTrips(): Trip[] {
return [
{
id: 'trip_demo_tokyo',
title: 'Autumn in Tokyo',
destination: 'Tokyo, Japan',
routeCode: 'SEA ➔ HND',
tripType: 'international_urban',
durationDays: 7,
durationNights: 6,
climate: 'mild',
luggageConfig: 'carry_on_only',
hasLaundry: true,
activities: ['remote_work', 'formal'],
createdAt: new Date().toISOString(),
items: [
{ id: 't1', name: 'Passport & IC Transit Card (Suica)', category: 'documents', quantity: 1, bag: 'personal_item', packed: true, essential: true },
{ id: 't2', name: 'Credit card (no foreign transaction fee)', category: 'documents', quantity: 1, bag: 'personal_item', packed: true, essential: true },
{ id: 't3', name: 'Laptop & 65W GaN USB-C charger', category: 'tech', quantity: 1, bag: 'personal_item', packed: true, essential: true },
{ id: 't4', name: 'Power bank 10,000mAh', category: 'tech', quantity: 1, bag: 'personal_item', packed: true, essential: true, notes: 'Cabin only' },
{ id: 't5', name: 'Noise-canceling headphones', category: 'tech', quantity: 1, bag: 'personal_item', packed: false },
{ id: 't6', name: 'Packable laundry detergent sheets', category: 'clothing', quantity: 1, bag: 'carry_on', packed: true, source: 'learned', learnedReason: 'Suggested from previous debrief' },
{ id: 't7', name: 'Merino wool t-shirts', category: 'clothing', quantity: 3, bag: 'carry_on', packed: true },
{ id: 't8', name: 'Comfortable walking sneakers', category: 'clothing', quantity: 1, bag: 'worn', packed: true, essential: true, notes: 'Tokyo averages 15k+ steps/day' },
{ id: 't9', name: 'Compact travel umbrella', category: 'gear', quantity: 1, bag: 'personal_item', packed: false },
{ id: 't10', name: 'Slip-on dress shoes for dinners', category: 'clothing', quantity: 1, bag: 'carry_on', packed: false },
],
},
];
}
export const StorageClient = {
// Fetch trips (tries backend API first, falls back to local storage)
async getTrips(): Promise<Trip[]> {
try {
const res = await fetch('/api/trips');
if (res.ok) {
const data = await res.json();
if (Array.isArray(data) && data.length > 0) {
localStorage.setItem(STORAGE_KEYS.TRIPS, JSON.stringify(data));
return data;
}
}
} catch {
// Offline fallback
}
const local = localStorage.getItem(STORAGE_KEYS.TRIPS);
if (local) {
try {
return JSON.parse(local);
} catch {}
}
const initial = getInitialTrips();
localStorage.setItem(STORAGE_KEYS.TRIPS, JSON.stringify(initial));
return initial;
},
async saveTrip(trip: Trip): Promise<void> {
const trips = await this.getTrips();
const index = trips.findIndex((t) => t.id === trip.id);
let updated: Trip[];
if (index >= 0) {
updated = [...trips];
updated[index] = trip;
} else {
updated = [trip, ...trips];
}
localStorage.setItem(STORAGE_KEYS.TRIPS, JSON.stringify(updated));
// Async sync to backend
try {
await fetch('/api/trips', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(trip),
});
} catch {
// Offline queued
}
},
async deleteTrip(tripId: string): Promise<void> {
const trips = await this.getTrips();
const updated = trips.filter((t) => t.id !== tripId);
localStorage.setItem(STORAGE_KEYS.TRIPS, JSON.stringify(updated));
try {
await fetch(`/api/trips/${tripId}`, { method: 'DELETE' });
} catch {}
},
// Active trip ID
getActiveTripId(): string | null {
return localStorage.getItem(STORAGE_KEYS.ACTIVE_TRIP_ID);
},
setActiveTripId(id: string): void {
localStorage.setItem(STORAGE_KEYS.ACTIVE_TRIP_ID, id);
},
// Memory & Learning
async getMemory(): Promise<PackMemory> {
try {
const res = await fetch('/api/memory');
if (res.ok) {
const data = await res.json();
if (data && data.exclusions) {
localStorage.setItem(STORAGE_KEYS.MEMORY, JSON.stringify(data));
return data;
}
}
} catch {}
const local = localStorage.getItem(STORAGE_KEYS.MEMORY);
if (local) {
try {
return JSON.parse(local);
} catch {}
}
localStorage.setItem(STORAGE_KEYS.MEMORY, JSON.stringify(DEFAULT_MEMORY));
return DEFAULT_MEMORY;
},
async recordDebrief(tripId: string, debrief: TripDebrief): Promise<Trip | null> {
const trips = await this.getTrips();
const trip = trips.find((t) => t.id === tripId);
if (!trip) return null;
trip.debrief = debrief;
await this.saveTrip(trip);
// Update memory
const memory = await this.getMemory();
memory.totalTripsDebriefed += 1;
// Record deadweight exclusions
for (const itemName of debrief.excludedItemNames) {
const existing = memory.exclusions.find(
(e) => e.tripType === trip.tripType && e.itemName.toLowerCase() === itemName.toLowerCase()
);
if (existing) {
existing.count += 1;
existing.lastDate = new Date().toISOString();
} else {
memory.exclusions.push({
itemName,
tripType: trip.tripType,
count: 1,
lastDate: new Date().toISOString(),
});
}
}
// Record wishlist inclusions
for (const itemName of debrief.includedItemNames) {
const existing = memory.inclusions.find(
(i) => i.tripType === trip.tripType && i.itemName.toLowerCase() === itemName.toLowerCase()
);
if (existing) {
existing.count += 1;
existing.lastDate = new Date().toISOString();
} else {
memory.inclusions.push({
itemName,
category: 'gear',
defaultBag: 'carry_on',
tripType: trip.tripType,
count: 1,
lastDate: new Date().toISOString(),
reason: `Wished for after trip: ${trip.title}`,
});
}
}
localStorage.setItem(STORAGE_KEYS.MEMORY, JSON.stringify(memory));
// Sync to backend
try {
await fetch('/api/memory/debrief', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tripId, debrief }),
});
} catch {}
return trip;
},
// App Settings
getSettings(): AppSettings {
const local = localStorage.getItem(STORAGE_KEYS.SETTINGS);
if (local) {
try {
return { ...DEFAULT_SETTINGS, ...JSON.parse(local) };
} catch {}
}
return DEFAULT_SETTINGS;
},
saveSettings(settings: AppSettings): void {
localStorage.setItem(STORAGE_KEYS.SETTINGS, JSON.stringify(settings));
try {
fetch('/api/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings),
}).catch(() => {});
} catch {}
},
};
+57
View File
@@ -0,0 +1,57 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
fontFamily: {
display: ['"Bebas Neue"', 'Impact', 'sans-serif'],
grotesk: ['"Space Grotesk"', 'sans-serif'],
sans: ['"Plus Jakarta Sans"', 'system-ui', 'sans-serif'],
mono: ['"IBM Plex Mono"', 'monospace'],
},
colors: {
ticket: {
50: '#FDFBF7',
100: '#F7F4EB',
200: '#EFE9DA',
300: '#E2D9C3',
400: '#D1C4A5',
border: '#D0C8B5',
},
ink: {
DEFAULT: '#141518',
light: '#2B2D33',
muted: '#666C79',
faint: '#9EA4B1',
},
canary: {
DEFAULT: '#FFD100',
hover: '#E6BC00',
light: '#FFF6D1',
},
gate: {
orange: '#FF4D1A',
hover: '#E53E0D',
light: '#FFEBE5',
},
stamp: {
green: '#147D52',
'green-light': '#EBFBF3',
red: '#D92D20',
'red-light': '#FEECEB',
}
},
boxShadow: {
'stub': '2px 3px 0px rgba(20,21,24,0.12)',
'stub-hover': '3px 5px 0px rgba(20,21,24,0.18)',
'card-hard': '4px 4px 0px #141518',
'card-hard-sm': '2px 2px 0px #141518',
'stamp': 'inset 0 0 0 2px #FF4D1A',
}
},
},
plugins: [],
}
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
host: true
}
})