feat: Add trip cloning from past voyages, PWA manifest, and batch pack/share actions
This commit is contained in:
1 parent
d275808711
commit
253b7f3329
5 files changed
+440
-62
No files matched your search
+7
-1
@@ -2,8 +2,14 @@
|
||||
<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" />
|
||||
<meta name="theme-color" content="#141518" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Loadout" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="apple-touch-icon" href="/icon.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
||||
<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">
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Loadout">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#1E2633"/>
|
||||
<stop offset="100%" stop-color="#0F141C"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="luggage" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#FF5A26"/>
|
||||
<stop offset="100%" stop-color="#D93510"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="tag" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#FFE853"/>
|
||||
<stop offset="100%" stop-color="#FFC700"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="64" height="64" rx="16" fill="url(#bg)"/>
|
||||
<rect x="1" y="1" width="62" height="62" rx="15" fill="none" stroke="#FFFFFF" stroke-opacity="0.08" stroke-width="1"/>
|
||||
|
||||
<!-- Luggage Handle -->
|
||||
<path d="M25 21 V15 C25 13.5 26.5 12 28 12 H36 C37.5 12 39 13.5 39 15 V21" fill="none" stroke="#CBD5E1" stroke-width="3" stroke-linecap="round"/>
|
||||
<rect x="23" y="19" width="4" height="4" rx="1" fill="#475569"/>
|
||||
<rect x="37" y="19" width="4" height="4" rx="1" fill="#475569"/>
|
||||
|
||||
<!-- Suitcase Body -->
|
||||
<rect x="13" y="21" width="38" height="29" rx="5" fill="url(#luggage)"/>
|
||||
|
||||
<!-- Suitcase Accents & Straps -->
|
||||
<rect x="13" y="33" width="38" height="4" fill="#872205" opacity="0.6"/>
|
||||
<rect x="22" y="21" width="3" height="29" fill="#1E2633" opacity="0.45"/>
|
||||
<rect x="39" y="21" width="3" height="29" fill="#1E2633" opacity="0.45"/>
|
||||
|
||||
<!-- Wheels -->
|
||||
<circle cx="20" cy="51" r="2.5" fill="#0F172A"/>
|
||||
<circle cx="44" cy="51" r="2.5" fill="#0F172A"/>
|
||||
|
||||
<!-- Dangling Baggage Tag -->
|
||||
<g transform="rotate(-15 36 21)">
|
||||
<path d="M37 17 L37 23" stroke="#FFEBA8" stroke-width="1.3" stroke-linecap="round"/>
|
||||
<polygon points="32,23 42,23 45,38 29,38" fill="url(#tag)"/>
|
||||
<circle cx="37" cy="25.5" r="1.5" fill="#8B4513"/>
|
||||
<line x1="32" y1="30" x2="42" y2="30" stroke="#1E2633" stroke-width="1.2" stroke-dasharray="2 1 1 1.5 2"/>
|
||||
<line x1="33" y1="33" x2="41" y2="33" stroke="#1E2633" stroke-width="1.2" stroke-dasharray="1 1.5 2 1"/>
|
||||
<line x1="33" y1="36" x2="41" y2="36" stroke="#1E2633" stroke-width="0.8"/>
|
||||
</g>
|
||||
|
||||
<!-- Stamped Verified Badge -->
|
||||
<circle cx="48" cy="46" r="6.5" fill="#FFD100" stroke="#1E2633" stroke-width="1"/>
|
||||
<path d="M45.5 46.2 L47.2 47.8 L50.5 44.5" fill="none" stroke="#141518" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "LOADOUT // Travel Manifest & Bag Allocation",
|
||||
"short_name": "Loadout",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#F4F1EA",
|
||||
"theme_color": "#141518",
|
||||
"description": "Tactile travel packing manifest and luggage allocation system.",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon.svg",
|
||||
"sizes": "64x64",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
+155
-25
@@ -32,7 +32,9 @@ import {
|
||||
RotateCcw,
|
||||
Plane,
|
||||
List,
|
||||
LayoutList
|
||||
LayoutList,
|
||||
Copy,
|
||||
CheckCheck
|
||||
} from 'lucide-react';
|
||||
|
||||
const CATEGORY_TABS: { id: Category | 'all'; label: string }[] = [
|
||||
@@ -55,6 +57,8 @@ export function App() {
|
||||
});
|
||||
const [settings, setSettings] = useState<AppSettings>(StorageClient.getSettings());
|
||||
const [compactView, setCompactView] = useState<boolean>(false);
|
||||
const [cloneSourceTripId, setCloneSourceTripId] = useState<string | null>(null);
|
||||
const [toastMessage, setToastMessage] = useState<string | null>(null);
|
||||
|
||||
// UI Filter states
|
||||
const [selectedBag, setSelectedBag] = useState<BagType | 'all'>('all');
|
||||
@@ -74,6 +78,11 @@ export function App() {
|
||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||
const [settingsTab, setSettingsTab] = useState<'general' | 'bags' | 'archetypes'>('general');
|
||||
|
||||
const showToast = (msg: string) => {
|
||||
setToastMessage(msg);
|
||||
setTimeout(() => setToastMessage(null), 2800);
|
||||
};
|
||||
|
||||
// Load initial data
|
||||
useEffect(() => {
|
||||
async function loadData() {
|
||||
@@ -115,6 +124,7 @@ export function App() {
|
||||
setTrips(updatedTrips);
|
||||
setActiveTripId(trip.id);
|
||||
StorageClient.setActiveTripId(trip.id);
|
||||
showToast(`Voyage "${trip.title}" manifest issued!`);
|
||||
};
|
||||
|
||||
const handleDeleteTrip = async (tripId: string) => {
|
||||
@@ -126,6 +136,14 @@ export function App() {
|
||||
if (activeTripId === tripId) {
|
||||
setActiveTripId(updatedTrips[0]?.id || null);
|
||||
}
|
||||
showToast('Voyage manifest deleted');
|
||||
};
|
||||
|
||||
const handleCloneCurrentTrip = () => {
|
||||
if (!activeTrip) return;
|
||||
sound.playClick();
|
||||
setCloneSourceTripId(activeTrip.id);
|
||||
setIsTripModalOpen(true);
|
||||
};
|
||||
|
||||
// Item actions
|
||||
@@ -192,6 +210,55 @@ export function App() {
|
||||
setNewItemName('');
|
||||
};
|
||||
|
||||
const handleBatchTogglePacked = async (packedState: boolean) => {
|
||||
if (!activeTrip || filteredItems.length === 0) return;
|
||||
sound.playStamp();
|
||||
const targetItemIds = new Set(filteredItems.map((i) => i.id));
|
||||
const updatedItems = activeTrip.items.map((item) =>
|
||||
targetItemIds.has(item.id) ? { ...item, packed: packedState } : item
|
||||
);
|
||||
const updatedTrip = { ...activeTrip, items: updatedItems };
|
||||
await StorageClient.saveTrip(updatedTrip);
|
||||
setTrips((prev) => prev.map((t) => (t.id === activeTrip.id ? updatedTrip : t)));
|
||||
showToast(
|
||||
packedState
|
||||
? `Marked ${targetItemIds.size} item(s) as stowed!`
|
||||
: `Reset ${targetItemIds.size} item(s) to unpacked.`
|
||||
);
|
||||
};
|
||||
|
||||
const handleCopyTextManifest = () => {
|
||||
if (!activeTrip) return;
|
||||
sound.playClick();
|
||||
|
||||
let text = `LOADOUT MANIFEST // ${activeTrip.routeCode}: ${activeTrip.title}\n`;
|
||||
text += `Destination: ${activeTrip.destination} • ${activeTrip.durationDays}D / ${activeTrip.durationNights}N • Archetype: ${activeTrip.tripType.toUpperCase()}\n`;
|
||||
text += `==========================================================\n\n`;
|
||||
|
||||
for (const bag of settings.customBags) {
|
||||
const bagItems = activeTrip.items.filter((i) => i.bag === bag.id);
|
||||
if (bagItems.length > 0) {
|
||||
text += `[${bag.tagCode}] ${bag.label} (${bag.sub}):\n`;
|
||||
for (const item of bagItems) {
|
||||
const check = item.packed ? '[✓]' : '[ ]';
|
||||
text += ` ${check} ${item.name} (${item.quantity}x)${item.essential ? ' [ESSENTIAL]' : ''}${
|
||||
item.notes ? ` — Note: ${item.notes}` : ''
|
||||
}\n`;
|
||||
}
|
||||
text += `\n`;
|
||||
}
|
||||
}
|
||||
|
||||
navigator.clipboard
|
||||
.writeText(text)
|
||||
.then(() => {
|
||||
showToast('Text manifest copied to clipboard!');
|
||||
})
|
||||
.catch(() => {
|
||||
showToast('Could not copy to clipboard.');
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveDebrief = async (debrief: TripDebrief) => {
|
||||
if (!activeTrip) return;
|
||||
const updated = await StorageClient.recordDebrief(activeTrip.id, debrief);
|
||||
@@ -199,6 +266,7 @@ export function App() {
|
||||
setTrips((prev) => prev.map((t) => (t.id === updated.id ? updated : t)));
|
||||
const refreshedMemory = await StorageClient.getMemory();
|
||||
setMemory(refreshedMemory);
|
||||
showToast('Debrief saved & engine memory updated!');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -243,12 +311,23 @@ export function App() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F4F1EA] text-[#141518] flex flex-col font-sans">
|
||||
{/* Toast Feedback Notification */}
|
||||
{toastMessage && (
|
||||
<div className="fixed bottom-5 right-5 z-50 bg-ink text-ticket-100 border-2 border-canary px-4 py-2 font-mono text-xs font-bold shadow-card-hard animate-in fade-in slide-in-from-bottom-2 duration-150 flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-canary animate-ping" />
|
||||
<span>{toastMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Boarding Pass Telemetry Header */}
|
||||
<BoardingHeader
|
||||
activeTrip={activeTrip}
|
||||
trips={trips}
|
||||
onSelectTrip={handleSelectTrip}
|
||||
onNewTrip={() => setIsTripModalOpen(true)}
|
||||
onNewTrip={() => {
|
||||
setCloneSourceTripId(null);
|
||||
setIsTripModalOpen(true);
|
||||
}}
|
||||
onOpenDebrief={() => setIsDebriefModalOpen(true)}
|
||||
onOpenMemory={() => setIsMemoryOpen(true)}
|
||||
onOpenSettings={() => {
|
||||
@@ -432,39 +511,87 @@ export function App() {
|
||||
</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>
|
||||
)}
|
||||
{compactView && (
|
||||
<span className="text-blue-700 font-bold ml-1">
|
||||
[COMPACT MODE]
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{/* Manifest Item Count, Batch Actions & Toolbar */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between text-xs font-mono text-ink-muted mb-3 gap-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span>
|
||||
DISPLAYING {filteredItems.length} ITEM(S)
|
||||
{selectedBag !== 'all' && (
|
||||
<span className="text-gate-orange font-bold ml-1">
|
||||
(FILTERED TO {selectedBag.toUpperCase()})
|
||||
</span>
|
||||
)}
|
||||
{compactView && (
|
||||
<span className="text-blue-700 font-bold ml-1">
|
||||
[COMPACT MODE]
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Batch Stow / Unpack Buttons */}
|
||||
{filteredItems.length > 0 && (
|
||||
<div className="flex items-center gap-1 ml-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleBatchTogglePacked(true)}
|
||||
className="px-1.5 py-0.5 border border-ink bg-white hover:bg-stamp-green-light text-stamp-green font-bold flex items-center gap-1 shadow-xs text-[10px]"
|
||||
title="Mark all currently displayed items as stowed"
|
||||
>
|
||||
<CheckCheck size={11} />
|
||||
<span>PACK ALL</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleBatchTogglePacked(false)}
|
||||
className="px-1.5 py-0.5 border border-ink bg-white hover:bg-ticket-200 text-ink-muted font-bold flex items-center gap-1 shadow-xs text-[10px]"
|
||||
title="Reset all displayed items to unpacked"
|
||||
>
|
||||
<RotateCcw size={11} />
|
||||
<span>UNPACK ALL</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 sm:gap-3 flex-wrap">
|
||||
<button
|
||||
onClick={() => window.print()}
|
||||
className="hover:text-ink flex items-center gap-1 font-bold"
|
||||
title="Print paper boarding manifest"
|
||||
type="button"
|
||||
onClick={handleCloneCurrentTrip}
|
||||
className="hover:text-ink flex items-center gap-1 font-bold text-ink"
|
||||
title="Use this trip's packing list as the basis for a new voyage"
|
||||
>
|
||||
<Printer size={13} />
|
||||
<span>PRINT MANIFEST</span>
|
||||
<Copy size={12} />
|
||||
<span>CLONE TO NEW VOYAGE</span>
|
||||
</button>
|
||||
<span>•</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyTextManifest}
|
||||
className="hover:text-ink flex items-center gap-1 font-bold text-ink"
|
||||
title="Copy formatted text packing list to clipboard"
|
||||
>
|
||||
<Share2 size={12} />
|
||||
<span>SHARE / COPY TEXT</span>
|
||||
</button>
|
||||
<span>•</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.print()}
|
||||
className="hover:text-ink flex items-center gap-1 font-bold text-ink"
|
||||
title="Print paper boarding manifest"
|
||||
>
|
||||
<Printer size={12} />
|
||||
<span className="hidden sm:inline">PRINT</span>
|
||||
</button>
|
||||
<span>•</span>
|
||||
<button
|
||||
type="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>
|
||||
<Trash2 size={12} />
|
||||
<span className="hidden sm:inline">DELETE</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -511,6 +638,7 @@ export function App() {
|
||||
<button
|
||||
onClick={() => {
|
||||
sound.playClick();
|
||||
setCloneSourceTripId(null);
|
||||
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"
|
||||
@@ -541,6 +669,8 @@ export function App() {
|
||||
setSettingsTab('archetypes');
|
||||
setIsSettingsOpen(true);
|
||||
}}
|
||||
trips={trips}
|
||||
initialCloneTripId={cloneSourceTripId}
|
||||
/>
|
||||
|
||||
{activeTrip && (
|
||||
|
||||
+212
-36
@@ -1,8 +1,21 @@
|
||||
import React, { useState } from 'react';
|
||||
import { TripType, Climate, LuggageConfig, Trip, PackMemory, CustomArchetype } from '../types';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { TripType, Climate, LuggageConfig, Trip, PackMemory, CustomArchetype, PackingItem } from '../types';
|
||||
import { generateAirportCode, generatePackingList } from '../utils/generator';
|
||||
import { sound } from '../utils/audio';
|
||||
import { X, Plane, Calendar, Cloud, Luggage, Sparkles, Check, Settings2 } from 'lucide-react';
|
||||
import {
|
||||
X,
|
||||
Plane,
|
||||
Calendar,
|
||||
Cloud,
|
||||
Luggage,
|
||||
Sparkles,
|
||||
Check,
|
||||
Settings2,
|
||||
Copy,
|
||||
Wand2,
|
||||
ArrowRight,
|
||||
AlertTriangle
|
||||
} from 'lucide-react';
|
||||
|
||||
interface TripModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -11,6 +24,8 @@ interface TripModalProps {
|
||||
memory: PackMemory;
|
||||
customArchetypes: CustomArchetype[];
|
||||
onEditArchetypes: () => void;
|
||||
trips: Trip[];
|
||||
initialCloneTripId?: string | null;
|
||||
}
|
||||
|
||||
const CLIMATES: { id: Climate; label: string; icon: string }[] = [
|
||||
@@ -42,9 +57,19 @@ export const TripModal: React.FC<TripModalProps> = ({
|
||||
memory,
|
||||
customArchetypes,
|
||||
onEditArchetypes,
|
||||
trips,
|
||||
initialCloneTripId = null,
|
||||
}) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
const [creationMode, setCreationMode] = useState<'generate' | 'clone'>(
|
||||
initialCloneTripId ? 'clone' : 'generate'
|
||||
);
|
||||
const [selectedCloneId, setSelectedCloneId] = useState<string>(
|
||||
initialCloneTripId || (trips[0]?.id || '')
|
||||
);
|
||||
const [applyDebriefLearnings, setApplyDebriefLearnings] = useState<boolean>(true);
|
||||
|
||||
const [destination, setDestination] = useState('');
|
||||
const [title, setTitle] = useState('');
|
||||
const [tripType, setTripType] = useState<TripType>(customArchetypes[0]?.id || 'international_urban');
|
||||
@@ -54,6 +79,41 @@ export const TripModal: React.FC<TripModalProps> = ({
|
||||
const [hasLaundry, setHasLaundry] = useState(true);
|
||||
const [selectedActivities, setSelectedActivities] = useState<string[]>(['remote_work']);
|
||||
|
||||
// Handle pre-populating when cloning from a trip
|
||||
useEffect(() => {
|
||||
if (initialCloneTripId) {
|
||||
setCreationMode('clone');
|
||||
setSelectedCloneId(initialCloneTripId);
|
||||
const src = trips.find((t) => t.id === initialCloneTripId);
|
||||
if (src) {
|
||||
setDestination(src.destination);
|
||||
setTitle(`${src.title} (Copy)`);
|
||||
setTripType(src.tripType);
|
||||
setDurationDays(src.durationDays);
|
||||
setClimate(src.climate);
|
||||
setLuggageConfig(src.luggageConfig);
|
||||
setHasLaundry(src.hasLaundry);
|
||||
setSelectedActivities(src.activities || []);
|
||||
}
|
||||
}
|
||||
}, [initialCloneTripId, isOpen]);
|
||||
|
||||
const handleSelectSourceTrip = (tripId: string) => {
|
||||
setSelectedCloneId(tripId);
|
||||
const src = trips.find((t) => t.id === tripId);
|
||||
if (src) {
|
||||
sound.playClick();
|
||||
setDestination(src.destination);
|
||||
setTitle(`${src.title} (Repack)`);
|
||||
setTripType(src.tripType);
|
||||
setDurationDays(src.durationDays);
|
||||
setClimate(src.climate);
|
||||
setLuggageConfig(src.luggageConfig);
|
||||
setHasLaundry(src.hasLaundry);
|
||||
setSelectedActivities(src.activities || []);
|
||||
}
|
||||
};
|
||||
|
||||
const destinationAirportCode = generateAirportCode(destination || 'DEST');
|
||||
const routeCode = `DEP ➔ ${destinationAirportCode}`;
|
||||
|
||||
@@ -73,18 +133,58 @@ export const TripModal: React.FC<TripModalProps> = ({
|
||||
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,
|
||||
});
|
||||
let items: PackingItem[] = [];
|
||||
|
||||
if (creationMode === 'clone') {
|
||||
const sourceTrip = trips.find((t) => t.id === selectedCloneId);
|
||||
if (sourceTrip) {
|
||||
// Clone items with reset packed checkboxes and new IDs
|
||||
items = sourceTrip.items.map((item) => ({
|
||||
...item,
|
||||
id: `item_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`,
|
||||
packed: false,
|
||||
}));
|
||||
|
||||
// Apply debrief learnings if selected and available
|
||||
if (applyDebriefLearnings && sourceTrip.debrief) {
|
||||
const excludedLower = sourceTrip.debrief.excludedItemNames.map((n) => n.toLowerCase());
|
||||
// Remove deadweight items
|
||||
items = items.filter((item) => !excludedLower.includes(item.name.toLowerCase()));
|
||||
|
||||
// Inject wishlist items
|
||||
for (const wishlistName of sourceTrip.debrief.includedItemNames) {
|
||||
if (!items.some((i) => i.name.toLowerCase() === wishlistName.toLowerCase())) {
|
||||
items.push({
|
||||
id: `item_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`,
|
||||
name: wishlistName,
|
||||
category: 'gear',
|
||||
quantity: 1,
|
||||
bag: 'carry_on',
|
||||
packed: false,
|
||||
source: 'learned',
|
||||
learnedReason: `Included from past debrief of ${sourceTrip.title}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback or generator mode
|
||||
if (items.length === 0) {
|
||||
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,
|
||||
@@ -106,9 +206,7 @@ export const TripModal: React.FC<TripModalProps> = ({
|
||||
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;
|
||||
const selectedCloneTrip = trips.find((t) => t.id === selectedCloneId);
|
||||
|
||||
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">
|
||||
@@ -132,6 +230,45 @@ export const TripModal: React.FC<TripModalProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Creation Mode Tabs */}
|
||||
<div className="bg-ticket-200 border-b-2 border-ink px-4 pt-2 flex gap-1 font-mono text-xs font-bold">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
sound.playClick();
|
||||
setCreationMode('generate');
|
||||
}}
|
||||
className={`px-3 py-2 border-t-2 border-l-2 border-r-2 border-ink transition-all flex items-center gap-1.5 ${
|
||||
creationMode === 'generate'
|
||||
? 'bg-ticket-100 text-ink shadow-xs -mb-[2px] z-10'
|
||||
: 'bg-white/60 text-ink-muted hover:bg-white'
|
||||
}`}
|
||||
>
|
||||
<Wand2 size={13} />
|
||||
<span>SMART HEURISTIC GENERATOR</span>
|
||||
</button>
|
||||
{trips.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
sound.playClick();
|
||||
setCreationMode('clone');
|
||||
if (!selectedCloneId && trips[0]) {
|
||||
handleSelectSourceTrip(trips[0].id);
|
||||
}
|
||||
}}
|
||||
className={`px-3 py-2 border-t-2 border-l-2 border-r-2 border-ink transition-all flex items-center gap-1.5 ${
|
||||
creationMode === 'clone'
|
||||
? 'bg-ticket-100 text-ink shadow-xs -mb-[2px] z-10'
|
||||
: 'bg-white/60 text-ink-muted hover:bg-white'
|
||||
}`}
|
||||
>
|
||||
<Copy size={13} />
|
||||
<span>CLONE FROM PAST VOYAGE ({trips.length})</span>
|
||||
</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>
|
||||
@@ -140,7 +277,57 @@ export const TripModal: React.FC<TripModalProps> = ({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleGenerate} className="p-4 sm:p-6 max-h-[80vh] overflow-y-auto space-y-5">
|
||||
<form onSubmit={handleGenerate} className="p-4 sm:p-6 max-h-[75vh] overflow-y-auto space-y-5">
|
||||
{/* CLONE SELECTION SECTION (When cloning from a past trip) */}
|
||||
{creationMode === 'clone' && (
|
||||
<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">
|
||||
<span className="font-mono text-xs font-bold uppercase text-ink flex items-center gap-1.5">
|
||||
<Copy size={14} className="text-gate-orange" />
|
||||
<span>SELECT BASE VOYAGE TO RE-FLY</span>
|
||||
</span>
|
||||
<span className="font-mono text-[10px] bg-ticket-200 border border-ink/30 px-1.5 py-0.5 text-ink">
|
||||
{selectedCloneTrip?.items.length || 0} ITEMS IN BASE
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={selectedCloneId}
|
||||
onChange={(e) => handleSelectSourceTrip(e.target.value)}
|
||||
className="w-full bg-ticket-50 border-2 border-ink font-mono text-xs font-bold p-2 shadow-xs cursor-pointer"
|
||||
>
|
||||
{trips.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.routeCode} — {t.title} ({t.items.length} items, {t.durationDays}D)
|
||||
{t.debrief ? ' [DEBRIEFED]' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Debrief Learning Filter Toggle */}
|
||||
{selectedCloneTrip?.debrief && (
|
||||
<div className="p-2.5 bg-purple-50 border border-purple-300 font-mono text-xs">
|
||||
<label className="flex items-start gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={applyDebriefLearnings}
|
||||
onChange={(e) => setApplyDebriefLearnings(e.target.checked)}
|
||||
className="mt-0.5 accent-purple-700"
|
||||
/>
|
||||
<div>
|
||||
<span className="font-bold text-purple-900 block">
|
||||
APPLY PAST DEBRIEF REFINEMENTS
|
||||
</span>
|
||||
<span className="font-sans text-[11px] text-purple-800 leading-tight block mt-0.5">
|
||||
Automatically omit {selectedCloneTrip.debrief.excludedItemNames.length} deadweight item(s) you never used, and automatically inject {selectedCloneTrip.debrief.includedItemNames.length} wishlist item(s) you missed!
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Destination & Title */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
@@ -171,7 +358,7 @@ export const TripModal: React.FC<TripModalProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trip Type */}
|
||||
{/* Trip Type (Archetypes) */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<label className="font-mono text-xs font-bold uppercase text-ink">
|
||||
@@ -378,21 +565,6 @@ export const TripModal: React.FC<TripModalProps> = ({
|
||||
</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
|
||||
@@ -410,7 +582,11 @@ export const TripModal: React.FC<TripModalProps> = ({
|
||||
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>
|
||||
<span>
|
||||
{creationMode === 'clone'
|
||||
? 'CLONE & ISSUE MANIFEST'
|
||||
: 'STAMP & ISSUE LOADOUT MANIFEST'}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
Reference in new issue
Block a user