feat: Trip-specific bag selection with duration-based Carry-On vs Checked Bag auto-matching
This commit is contained in:
1 parent
be586ea386
commit
2d709cfd9b
5 files changed
+501
-65
No files matched your search
+82
-4
@@ -8,7 +8,8 @@ import {
|
||||
AppSettings,
|
||||
TripDebrief,
|
||||
CustomBag,
|
||||
CustomArchetype
|
||||
CustomArchetype,
|
||||
getActiveTripBags
|
||||
} from './types';
|
||||
import { StorageClient } from './utils/storage';
|
||||
import { sound } from './utils/audio';
|
||||
@@ -111,6 +112,76 @@ export function App() {
|
||||
}, []);
|
||||
|
||||
const activeTrip = trips.find((t) => t.id === activeTripId) || null;
|
||||
const activeTripBags: CustomBag[] = activeTrip
|
||||
? getActiveTripBags(activeTrip, settings.customBags)
|
||||
: settings.customBags;
|
||||
|
||||
// Keep quick-add bag dropdown pointed at a bag that is actually on this trip
|
||||
useEffect(() => {
|
||||
if (activeTripBags.length > 0 && !activeTripBags.some((b) => b.id === newItemBag)) {
|
||||
const preferred =
|
||||
activeTripBags.find((b) => b.id === 'checked') ||
|
||||
activeTripBags.find((b) => b.id === 'carry_on') ||
|
||||
activeTripBags[0];
|
||||
if (preferred) setNewItemBag(preferred.id);
|
||||
}
|
||||
}, [activeTripId, activeTrip?.activeBags, settings.customBags]);
|
||||
|
||||
const handleToggleTripBag = async (bagId: BagType) => {
|
||||
if (!activeTrip) return;
|
||||
const currentIds = activeTripBags.map((b) => b.id);
|
||||
const isCurrentlyActive = currentIds.includes(bagId);
|
||||
|
||||
if (isCurrentlyActive && currentIds.length <= 1) {
|
||||
showToast('At least one bag must remain active on the trip!');
|
||||
return;
|
||||
}
|
||||
|
||||
const nextIds = isCurrentlyActive
|
||||
? currentIds.filter((id) => id !== bagId)
|
||||
: [...currentIds, bagId];
|
||||
|
||||
const fallbackBag: BagType = nextIds.includes('checked')
|
||||
? 'checked'
|
||||
: nextIds.includes('carry_on')
|
||||
? 'carry_on'
|
||||
: nextIds.find((id) => id !== 'worn') || nextIds[0];
|
||||
|
||||
let movedCount = 0;
|
||||
const updatedItems = activeTrip.items.map((item) => {
|
||||
if (!nextIds.includes(item.bag)) {
|
||||
movedCount++;
|
||||
return { ...item, bag: fallbackBag };
|
||||
}
|
||||
return item;
|
||||
});
|
||||
|
||||
const derivedConfig = nextIds.includes('checked')
|
||||
? 'checked_bag'
|
||||
: nextIds.includes('carry_on')
|
||||
? 'carry_on_only'
|
||||
: 'backpack_only';
|
||||
|
||||
const updatedTrip: Trip = {
|
||||
...activeTrip,
|
||||
activeBags: nextIds,
|
||||
luggageConfig: derivedConfig,
|
||||
items: updatedItems,
|
||||
};
|
||||
|
||||
if (selectedBag === bagId && isCurrentlyActive) {
|
||||
setSelectedBag('all');
|
||||
}
|
||||
|
||||
await StorageClient.saveTrip(updatedTrip);
|
||||
setTrips((prev) => prev.map((t) => (t.id === activeTrip.id ? updatedTrip : t)));
|
||||
|
||||
if (movedCount > 0) {
|
||||
const targetLabel =
|
||||
settings.customBags.find((b) => b.id === fallbackBag)?.label || fallbackBag;
|
||||
showToast(`Moved ${movedCount} item(s) into ${targetLabel}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectTrip = (id: string) => {
|
||||
setActiveTripId(id);
|
||||
@@ -235,7 +306,7 @@ export function App() {
|
||||
text += `Destination: ${activeTrip.destination} • ${activeTrip.durationDays}D / ${activeTrip.durationNights}N • Archetype: ${activeTrip.tripType.toUpperCase()}\n`;
|
||||
text += `==========================================================\n\n`;
|
||||
|
||||
for (const bag of settings.customBags) {
|
||||
for (const bag of activeTripBags) {
|
||||
const bagItems = activeTrip.items.filter((i) => i.bag === bag.id);
|
||||
if (bagItems.length > 0) {
|
||||
text += `[${bag.tagCode}] ${bag.label} (${bag.sub}):\n`;
|
||||
@@ -352,7 +423,9 @@ export function App() {
|
||||
selectedBag={selectedBag}
|
||||
onSelectBag={setSelectedBag}
|
||||
luggageConfig={activeTrip.luggageConfig}
|
||||
activeTripBags={activeTripBags}
|
||||
customBags={settings.customBags}
|
||||
onToggleTripBag={handleToggleTripBag}
|
||||
onEditBags={() => {
|
||||
setSettingsTab('bags');
|
||||
setIsSettingsOpen(true);
|
||||
@@ -393,7 +466,7 @@ export function App() {
|
||||
onChange={(e) => setNewItemBag(e.target.value)}
|
||||
className="bg-white border-2 border-ink px-2 py-2 font-mono text-xs font-bold shadow-xs cursor-pointer"
|
||||
>
|
||||
{settings.customBags.map((b) => (
|
||||
{activeTripBags.map((b) => (
|
||||
<option key={b.id} value={b.id}>
|
||||
{b.label} ({b.tagCode})
|
||||
</option>
|
||||
@@ -613,7 +686,7 @@ export function App() {
|
||||
key={item.id}
|
||||
item={item}
|
||||
compact={compactView}
|
||||
availableBags={settings.customBags}
|
||||
availableBags={activeTripBags}
|
||||
onTogglePacked={handleTogglePacked}
|
||||
onUpdateQuantity={handleUpdateQuantity}
|
||||
onUpdateBag={handleUpdateBag}
|
||||
@@ -664,6 +737,11 @@ export function App() {
|
||||
onClose={() => setIsTripModalOpen(false)}
|
||||
onSaveTrip={handleSaveTrip}
|
||||
memory={memory}
|
||||
customBags={settings.customBags}
|
||||
onEditBags={() => {
|
||||
setSettingsTab('bags');
|
||||
setIsSettingsOpen(true);
|
||||
}}
|
||||
customArchetypes={settings.customArchetypes}
|
||||
onEditArchetypes={() => {
|
||||
setSettingsTab('archetypes');
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { BagType, PackingItem, LuggageConfig, CustomBag } from '../types';
|
||||
import { sound } from '../utils/audio';
|
||||
import {
|
||||
@@ -11,7 +11,9 @@ import {
|
||||
Settings2,
|
||||
Camera,
|
||||
Compass,
|
||||
Tag
|
||||
Tag,
|
||||
Check,
|
||||
Luggage
|
||||
} from 'lucide-react';
|
||||
|
||||
interface BagOverviewProps {
|
||||
@@ -19,7 +21,9 @@ interface BagOverviewProps {
|
||||
selectedBag: BagType | 'all';
|
||||
onSelectBag: (bag: BagType | 'all') => void;
|
||||
luggageConfig: LuggageConfig;
|
||||
activeTripBags: CustomBag[];
|
||||
customBags: CustomBag[];
|
||||
onToggleTripBag: (bagId: BagType) => void;
|
||||
onEditBags: () => void;
|
||||
}
|
||||
|
||||
@@ -46,35 +50,48 @@ export const BagOverview: React.FC<BagOverviewProps> = ({
|
||||
items,
|
||||
selectedBag,
|
||||
onSelectBag,
|
||||
luggageConfig,
|
||||
activeTripBags,
|
||||
customBags,
|
||||
onToggleTripBag,
|
||||
onEditBags,
|
||||
}) => {
|
||||
// Filter bags based on luggage mode if it's a default bag
|
||||
const visibleBags = customBags.filter((b) => {
|
||||
if (b.id === 'carry_on' && luggageConfig === 'backpack_only') return false;
|
||||
if (b.id === 'checked' && luggageConfig !== 'checked_bag') return false;
|
||||
return true;
|
||||
});
|
||||
const [showTripBagPicker, setShowTripBagPicker] = useState(false);
|
||||
const visibleBags = activeTripBags;
|
||||
|
||||
return (
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-2 flex-wrap gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1.5 sm:gap-2 flex-wrap">
|
||||
<span className="font-mono text-xs uppercase font-bold text-ink tracking-wider">
|
||||
LUGGAGE ALLOCATION // "WHAT GOES IN WHAT BAG"
|
||||
TRIP BAGS ({visibleBags.length}/{customBags.length})
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
sound.playClick();
|
||||
setShowTripBagPicker(!showTripBagPicker);
|
||||
}}
|
||||
className={`font-mono text-[11px] font-bold px-2 py-0.5 border border-ink transition-colors flex items-center gap-1 shadow-xs ${
|
||||
showTripBagPicker
|
||||
? 'bg-ink text-canary'
|
||||
: 'bg-canary/80 hover:bg-canary text-ink'
|
||||
}`}
|
||||
title="Select which bags are going on this specific trip"
|
||||
>
|
||||
<Luggage size={12} />
|
||||
<span>SELECT TRIP BAGS</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
sound.playClick();
|
||||
onEditBags();
|
||||
}}
|
||||
className="font-mono text-[11px] font-bold px-2 py-0.5 border border-ink bg-white hover:bg-canary text-ink transition-colors flex items-center gap-1 shadow-xs"
|
||||
title="Add, rename, or reconfigure bags"
|
||||
className="font-mono text-[11px] font-bold px-2 py-0.5 border border-ink bg-white hover:bg-ticket-200 text-ink transition-colors flex items-center gap-1 shadow-xs"
|
||||
title="Add, rename, or reconfigure your global bag pool"
|
||||
>
|
||||
<Settings2 size={12} />
|
||||
<span>EDIT BAGS</span>
|
||||
<span>EDIT BAG POOL</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -91,10 +108,53 @@ export const BagOverview: React.FC<BagOverviewProps> = ({
|
||||
}`}
|
||||
>
|
||||
<Layers size={13} />
|
||||
<span>VIEW ALL BAGS ({items.length})</span>
|
||||
<span>VIEW ALL ({items.length})</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Inline Trip Bag Loadout Switcher */}
|
||||
{showTripBagPicker && (
|
||||
<div className="mb-3 p-3 bg-ticket-100 border-2 border-ink shadow-card-hard-sm animate-in fade-in duration-100">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-mono text-[11px] font-bold uppercase text-ink">
|
||||
WHICH BAGS ARE GOING ON THIS TRIP? (UNCHECKED BAGS AUTOMATICALLY MOVE ITEMS TO YOUR MAIN BAG)
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{customBags.map((bag) => {
|
||||
const isBringing = visibleBags.some((b) => b.id === bag.id);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={bag.id}
|
||||
onClick={() => {
|
||||
sound.playClick();
|
||||
onToggleTripBag(bag.id);
|
||||
}}
|
||||
className={`px-2.5 py-1.5 border-2 font-mono text-xs font-bold flex items-center gap-2 transition-all ${
|
||||
isBringing
|
||||
? 'bg-canary-light border-ink text-ink shadow-card-hard-sm'
|
||||
: 'bg-white/60 border-ink/40 text-ink/50 hover:bg-white hover:text-ink'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`w-4 h-4 border border-ink flex items-center justify-center ${
|
||||
isBringing ? 'bg-ink text-canary' : 'bg-white'
|
||||
}`}
|
||||
>
|
||||
{isBringing && <Check size={11} strokeWidth={3} />}
|
||||
</span>
|
||||
<span>{bag.label}</span>
|
||||
<span className="text-[10px] bg-ink text-ticket-100 px-1 py-0.2">
|
||||
{bag.tagCode}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-2 sm:gap-3">
|
||||
{visibleBags.map((b) => {
|
||||
const bagItems = items.filter((i) => i.bag === b.id);
|
||||
|
||||
+201
-37
@@ -1,5 +1,17 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { TripType, Climate, LuggageConfig, Trip, PackMemory, CustomArchetype, PackingItem } from '../types';
|
||||
import {
|
||||
TripType,
|
||||
Climate,
|
||||
LuggageConfig,
|
||||
Trip,
|
||||
PackMemory,
|
||||
CustomArchetype,
|
||||
CustomBag,
|
||||
BagType,
|
||||
PackingItem,
|
||||
getRecommendedBagIdsForTrip,
|
||||
getActiveTripBags,
|
||||
} from '../types';
|
||||
import { generateAirportCode, generatePackingList } from '../utils/generator';
|
||||
import { sound } from '../utils/audio';
|
||||
import {
|
||||
@@ -22,6 +34,8 @@ interface TripModalProps {
|
||||
onClose: () => void;
|
||||
onSaveTrip: (trip: Trip) => void;
|
||||
memory: PackMemory;
|
||||
customBags: CustomBag[];
|
||||
onEditBags: () => void;
|
||||
customArchetypes: CustomArchetype[];
|
||||
onEditArchetypes: () => void;
|
||||
trips: Trip[];
|
||||
@@ -35,12 +49,6 @@ const CLIMATES: { id: Climate; label: string; icon: string }[] = [
|
||||
{ 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: '🏊' },
|
||||
@@ -55,6 +63,8 @@ export const TripModal: React.FC<TripModalProps> = ({
|
||||
onClose,
|
||||
onSaveTrip,
|
||||
memory,
|
||||
customBags,
|
||||
onEditBags,
|
||||
customArchetypes,
|
||||
onEditArchetypes,
|
||||
trips,
|
||||
@@ -73,12 +83,25 @@ export const TripModal: React.FC<TripModalProps> = ({
|
||||
const [destination, setDestination] = useState('');
|
||||
const [title, setTitle] = useState('');
|
||||
const [tripType, setTripType] = useState<TripType>(customArchetypes[0]?.id || 'international_urban');
|
||||
const [durationDays, setDurationDays] = useState(7);
|
||||
const [durationDays, setDurationDays] = useState(4);
|
||||
const [climate, setClimate] = useState<Climate>('mild');
|
||||
const [luggageConfig, setLuggageConfig] = useState<LuggageConfig>('carry_on_only');
|
||||
const [selectedBags, setSelectedBags] = useState<BagType[]>(() =>
|
||||
getRecommendedBagIdsForTrip(4, customArchetypes[0]?.id || 'international_urban', customBags)
|
||||
);
|
||||
const [bagsManuallyCustomized, setBagsManuallyCustomized] = useState(false);
|
||||
const [hasLaundry, setHasLaundry] = useState(true);
|
||||
const [selectedActivities, setSelectedActivities] = useState<string[]>(['remote_work']);
|
||||
|
||||
// Automatically recommend bags when duration or archetype changes (unless manually overridden)
|
||||
const applyDurationAndArchetype = (newDays: number, newArchetype: TripType, forceAuto = false) => {
|
||||
setDurationDays(newDays);
|
||||
setTripType(newArchetype);
|
||||
if (!bagsManuallyCustomized || forceAuto) {
|
||||
setSelectedBags(getRecommendedBagIdsForTrip(newDays, newArchetype, customBags));
|
||||
if (forceAuto) setBagsManuallyCustomized(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle pre-populating when cloning from a trip
|
||||
useEffect(() => {
|
||||
if (initialCloneTripId) {
|
||||
@@ -91,7 +114,8 @@ export const TripModal: React.FC<TripModalProps> = ({
|
||||
setTripType(src.tripType);
|
||||
setDurationDays(src.durationDays);
|
||||
setClimate(src.climate);
|
||||
setLuggageConfig(src.luggageConfig);
|
||||
setSelectedBags(getActiveTripBags(src, customBags).map((b) => b.id));
|
||||
setBagsManuallyCustomized(false);
|
||||
setHasLaundry(src.hasLaundry);
|
||||
setSelectedActivities(src.activities || []);
|
||||
}
|
||||
@@ -108,12 +132,43 @@ export const TripModal: React.FC<TripModalProps> = ({
|
||||
setTripType(src.tripType);
|
||||
setDurationDays(src.durationDays);
|
||||
setClimate(src.climate);
|
||||
setLuggageConfig(src.luggageConfig);
|
||||
setSelectedBags(getActiveTripBags(src, customBags).map((b) => b.id));
|
||||
setBagsManuallyCustomized(false);
|
||||
setHasLaundry(src.hasLaundry);
|
||||
setSelectedActivities(src.activities || []);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleBagForTrip = (bagId: BagType) => {
|
||||
sound.playClick();
|
||||
setBagsManuallyCustomized(true);
|
||||
setSelectedBags((prev) => {
|
||||
if (prev.includes(bagId)) {
|
||||
// Don't allow deselecting the very last bag
|
||||
if (prev.length <= 1) return prev;
|
||||
return prev.filter((id) => id !== bagId);
|
||||
}
|
||||
return [...prev, bagId];
|
||||
});
|
||||
};
|
||||
|
||||
const applyBagPreset = (preset: 'short_carryon' | 'long_checked' | 'both' | 'minimal') => {
|
||||
sound.playClick();
|
||||
setBagsManuallyCustomized(true);
|
||||
const hasBag = (id: string) => customBags.some((b) => b.id === id);
|
||||
const next: BagType[] = [];
|
||||
if (hasBag('personal_item')) next.push('personal_item');
|
||||
if (preset === 'short_carryon' && hasBag('carry_on')) next.push('carry_on');
|
||||
if (preset === 'long_checked' && hasBag('checked')) next.push('checked');
|
||||
if (preset === 'both') {
|
||||
if (hasBag('carry_on')) next.push('carry_on');
|
||||
if (hasBag('checked')) next.push('checked');
|
||||
}
|
||||
if (hasBag('worn')) next.push('worn');
|
||||
if (next.length === 0 && customBags[0]) next.push(customBags[0].id);
|
||||
setSelectedBags(next);
|
||||
};
|
||||
|
||||
const destinationAirportCode = generateAirportCode(destination || 'DEST');
|
||||
const routeCode = `DEP ➔ ${destinationAirportCode}`;
|
||||
|
||||
@@ -133,15 +188,33 @@ export const TripModal: React.FC<TripModalProps> = ({
|
||||
const tripTitle = title.trim() || `${destination.trim() || 'Voyage'} Expedition`;
|
||||
const tripId = `trip_${Date.now()}`;
|
||||
|
||||
const activeBags: BagType[] =
|
||||
selectedBags.length > 0
|
||||
? selectedBags
|
||||
: getRecommendedBagIdsForTrip(durationDays, tripType, customBags);
|
||||
|
||||
const derivedLuggageConfig: LuggageConfig = activeBags.includes('checked')
|
||||
? 'checked_bag'
|
||||
: activeBags.includes('carry_on')
|
||||
? 'carry_on_only'
|
||||
: 'backpack_only';
|
||||
|
||||
const mainBag: BagType = activeBags.includes('checked')
|
||||
? 'checked'
|
||||
: activeBags.includes('carry_on')
|
||||
? 'carry_on'
|
||||
: activeBags.find((b) => b !== 'worn') || activeBags[0] || 'personal_item';
|
||||
|
||||
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
|
||||
// Clone items and automatically re-route any item whose bag isn't coming on this trip!
|
||||
items = sourceTrip.items.map((item) => ({
|
||||
...item,
|
||||
id: `item_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`,
|
||||
bag: activeBags.includes(item.bag) ? item.bag : mainBag,
|
||||
packed: false,
|
||||
}));
|
||||
|
||||
@@ -151,7 +224,7 @@ export const TripModal: React.FC<TripModalProps> = ({
|
||||
// Remove deadweight items
|
||||
items = items.filter((item) => !excludedLower.includes(item.name.toLowerCase()));
|
||||
|
||||
// Inject wishlist items
|
||||
// Inject wishlist items into the trip's main luggage bag
|
||||
for (const wishlistName of sourceTrip.debrief.includedItemNames) {
|
||||
if (!items.some((i) => i.name.toLowerCase() === wishlistName.toLowerCase())) {
|
||||
items.push({
|
||||
@@ -159,7 +232,7 @@ export const TripModal: React.FC<TripModalProps> = ({
|
||||
name: wishlistName,
|
||||
category: 'gear',
|
||||
quantity: 1,
|
||||
bag: 'carry_on',
|
||||
bag: mainBag,
|
||||
packed: false,
|
||||
source: 'learned',
|
||||
learnedReason: `Included from past debrief of ${sourceTrip.title}`,
|
||||
@@ -179,7 +252,8 @@ export const TripModal: React.FC<TripModalProps> = ({
|
||||
durationDays,
|
||||
durationNights: Math.max(1, durationDays - 1),
|
||||
climate,
|
||||
luggageConfig,
|
||||
luggageConfig: derivedLuggageConfig,
|
||||
activeBags,
|
||||
hasLaundry,
|
||||
activities: selectedActivities,
|
||||
memory,
|
||||
@@ -195,7 +269,8 @@ export const TripModal: React.FC<TripModalProps> = ({
|
||||
durationDays,
|
||||
durationNights: Math.max(1, durationDays - 1),
|
||||
climate,
|
||||
luggageConfig,
|
||||
luggageConfig: derivedLuggageConfig,
|
||||
activeBags,
|
||||
hasLaundry,
|
||||
activities: selectedActivities,
|
||||
items,
|
||||
@@ -384,7 +459,7 @@ export const TripModal: React.FC<TripModalProps> = ({
|
||||
key={t.id}
|
||||
onClick={() => {
|
||||
sound.playClick();
|
||||
setTripType(t.id);
|
||||
applyDurationAndArchetype(durationDays, t.id);
|
||||
}}
|
||||
className={`text-left p-2.5 border-2 border-ink transition-all flex flex-col ${
|
||||
tripType === t.id
|
||||
@@ -419,17 +494,17 @@ export const TripModal: React.FC<TripModalProps> = ({
|
||||
min="1"
|
||||
max="30"
|
||||
value={durationDays}
|
||||
onChange={(e) => setDurationDays(parseInt(e.target.value))}
|
||||
onChange={(e) => applyDurationAndArchetype(parseInt(e.target.value), tripType)}
|
||||
className="w-full accent-gate-orange cursor-pointer"
|
||||
/>
|
||||
<div className="flex gap-1.5 mt-2">
|
||||
{[3, 5, 7, 10, 14].map((d) => (
|
||||
<div className="flex gap-1.5 mt-2 flex-wrap">
|
||||
{[2, 4, 5, 7, 10, 14].map((d) => (
|
||||
<button
|
||||
type="button"
|
||||
key={d}
|
||||
onClick={() => {
|
||||
sound.playClick();
|
||||
setDurationDays(d);
|
||||
applyDurationAndArchetype(d, tripType);
|
||||
}}
|
||||
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'
|
||||
@@ -507,34 +582,123 @@ export const TripModal: React.FC<TripModalProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Luggage Mode */}
|
||||
{/* Trip-Specific Bag Selection ("Which Bags Go on This Trip") */}
|
||||
<div className="bg-ticket-200/50 border-2 border-ink p-3.5 space-y-2.5">
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<div>
|
||||
<label className="block font-mono text-xs font-bold uppercase text-ink mb-1.5">
|
||||
Baggage Allowance Mode
|
||||
<label className="block font-mono text-xs font-bold uppercase text-ink">
|
||||
BAGS GOING ON THIS TRIP ({selectedBags.length} SELECTED)
|
||||
</label>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
{LUGGAGE_MODES.map((m) => (
|
||||
<span className="font-mono text-[10px] text-ink-muted block">
|
||||
{!bagsManuallyCustomized
|
||||
? durationDays <= 2
|
||||
? `Auto-matched for ${durationDays}D overnight trip: Personal Item only`
|
||||
: durationDays <= 5
|
||||
? `Auto-matched for ${durationDays}D shorter trip: Carry-On (no Checked Bag)`
|
||||
: `Auto-matched for ${durationDays}D longer trip: Checked Bag (no Carry-On)`
|
||||
: 'Custom bag selection for this voyage'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
{bagsManuallyCustomized && (
|
||||
<button
|
||||
type="button"
|
||||
key={m.id}
|
||||
onClick={() => {
|
||||
sound.playClick();
|
||||
setLuggageConfig(m.id);
|
||||
applyDurationAndArchetype(durationDays, tripType, true);
|
||||
}}
|
||||
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'
|
||||
className="font-mono text-[10px] font-bold px-2 py-0.5 border border-ink bg-canary text-ink shadow-2xs"
|
||||
title="Reset bags to automatic recommendation based on trip duration"
|
||||
>
|
||||
AUTO-MATCH BY LENGTH
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
sound.playClick();
|
||||
onEditBags();
|
||||
}}
|
||||
className="font-mono text-[10px] font-bold px-2 py-0.5 border border-ink bg-white hover:bg-canary text-ink flex items-center gap-1 shadow-2xs"
|
||||
title="Create or edit bags in your collection"
|
||||
>
|
||||
<Settings2 size={11} />
|
||||
<span>EDIT BAG POOL</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Presets */}
|
||||
<div className="flex flex-wrap gap-1.5 font-mono text-[10px] font-bold">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => applyBagPreset('short_carryon')}
|
||||
className="px-2 py-1 border border-ink bg-white hover:bg-ticket-100 text-ink"
|
||||
>
|
||||
SHORT TRIP (CARRY-ON + PERSONAL)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => applyBagPreset('long_checked')}
|
||||
className="px-2 py-1 border border-ink bg-white hover:bg-ticket-100 text-ink"
|
||||
>
|
||||
LONGER TRIP (CHECKED + PERSONAL)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => applyBagPreset('both')}
|
||||
className="px-2 py-1 border border-ink bg-white hover:bg-ticket-100 text-ink"
|
||||
>
|
||||
HEAVY HAUL (CHECKED + CARRY-ON + PERSONAL)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => applyBagPreset('minimal')}
|
||||
className="px-2 py-1 border border-ink bg-white hover:bg-ticket-100 text-ink"
|
||||
>
|
||||
OVERNIGHT (PERSONAL ONLY)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Individual Bag Toggle Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 pt-1">
|
||||
{customBags.map((bag) => {
|
||||
const isSelected = selectedBags.includes(bag.id);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={bag.id}
|
||||
onClick={() => toggleBagForTrip(bag.id)}
|
||||
className={`p-2.5 border-2 text-left transition-all flex items-start gap-2.5 ${
|
||||
isSelected
|
||||
? 'bg-canary-light border-ink shadow-card-hard-sm'
|
||||
: 'bg-white/60 border-ink/40 text-ink/50 hover:bg-white hover:border-ink'
|
||||
}`}
|
||||
>
|
||||
<span className="font-grotesk font-bold text-xs uppercase text-ink">
|
||||
{m.label}
|
||||
<div
|
||||
className={`w-4 h-4 mt-0.5 border border-ink flex items-center justify-center shrink-0 ${
|
||||
isSelected ? 'bg-ink text-canary' : 'bg-white'
|
||||
}`}
|
||||
>
|
||||
{isSelected && <Check size={12} strokeWidth={3} />}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<span className="font-grotesk font-bold text-xs uppercase text-ink truncate">
|
||||
{bag.label}
|
||||
</span>
|
||||
<span className="font-sans text-[10px] text-ink-muted leading-tight mt-0.5">
|
||||
{m.desc}
|
||||
<span className="font-mono text-[9px] font-bold bg-ink text-ticket-100 px-1 py-0.2 shrink-0">
|
||||
{bag.tagCode}
|
||||
</span>
|
||||
</div>
|
||||
<span className="font-sans text-[10px] text-ink-muted block truncate">
|
||||
{bag.sub}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ export interface Trip {
|
||||
durationNights: number;
|
||||
climate: Climate;
|
||||
luggageConfig: LuggageConfig;
|
||||
activeBags?: BagType[]; // Specific bag IDs brought on this trip
|
||||
hasLaundry: boolean;
|
||||
activities: string[]; // 'formal', 'swimming', 'hiking', 'gym', 'remote_work', 'photography'
|
||||
items: PackingItem[];
|
||||
@@ -49,6 +50,80 @@ export interface Trip {
|
||||
debrief?: TripDebrief;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recommends which bags go on a trip based on trip duration and archetype:
|
||||
* - 1-2 days: Personal Item + Worn (unless business/alpine)
|
||||
* - 3-5 days (shorter trips): Personal Item + Carry-On + Worn (no checked bag)
|
||||
* - 6+ days (longer trips): Personal Item + Checked Bag + Worn
|
||||
*/
|
||||
export function getRecommendedBagIdsForTrip(
|
||||
durationDays: number,
|
||||
tripType: TripType,
|
||||
allBags: CustomBag[]
|
||||
): BagType[] {
|
||||
const hasBag = (id: string) => allBags.some((b) => b.id === id);
|
||||
const selected: BagType[] = [];
|
||||
|
||||
if (hasBag('personal_item')) selected.push('personal_item');
|
||||
|
||||
if (durationDays <= 2 && tripType !== 'business' && tripType !== 'alpine_cold') {
|
||||
// Ultra-short 1-2 day trip: Personal item only (+ worn)
|
||||
} else if (durationDays <= 5 && tripType !== 'alpine_cold') {
|
||||
// Shorter trip (3-5 days): Carry-On bag, no Checked bag
|
||||
if (hasBag('carry_on')) selected.push('carry_on');
|
||||
} else {
|
||||
// Longer trip (6+ days) or Alpine gear trip: Checked bag instead of Carry-On by default
|
||||
if (hasBag('checked')) {
|
||||
selected.push('checked');
|
||||
} else if (hasBag('carry_on')) {
|
||||
selected.push('carry_on');
|
||||
}
|
||||
}
|
||||
|
||||
if (hasBag('worn')) selected.push('worn');
|
||||
|
||||
// Fallback if user deleted all default bags and only has custom ones
|
||||
if (selected.length === 0 && allBags.length > 0) {
|
||||
selected.push(allBags[0].id);
|
||||
}
|
||||
|
||||
return selected;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the active CustomBag objects for a given trip.
|
||||
*/
|
||||
export function getActiveTripBags(trip: Trip, allBags: CustomBag[]): CustomBag[] {
|
||||
if (trip.activeBags && trip.activeBags.length > 0) {
|
||||
const filtered = allBags.filter((b) => trip.activeBags!.includes(b.id));
|
||||
if (filtered.length > 0) return filtered;
|
||||
}
|
||||
|
||||
// Fallback for legacy trips created before activeBags was stored
|
||||
const legacyIds = new Set<string>();
|
||||
if (trip.luggageConfig === 'backpack_only') {
|
||||
legacyIds.add('personal_item');
|
||||
legacyIds.add('worn');
|
||||
} else if (trip.luggageConfig === 'carry_on_only') {
|
||||
legacyIds.add('personal_item');
|
||||
legacyIds.add('carry_on');
|
||||
legacyIds.add('worn');
|
||||
} else if (trip.luggageConfig === 'checked_bag') {
|
||||
legacyIds.add('personal_item');
|
||||
// If legacy trip has no items in carry_on, only include checked; otherwise include any bag with items
|
||||
legacyIds.add('checked');
|
||||
legacyIds.add('worn');
|
||||
}
|
||||
|
||||
// Also ensure any bag that currently holds items in this trip is visible
|
||||
for (const item of trip.items || []) {
|
||||
legacyIds.add(item.bag);
|
||||
}
|
||||
|
||||
const matched = allBags.filter((b) => legacyIds.has(b.id));
|
||||
return matched.length > 0 ? matched : allBags;
|
||||
}
|
||||
|
||||
export interface MemoryExclusion {
|
||||
itemName: string;
|
||||
tripType: TripType;
|
||||
|
||||
+68
-9
@@ -8,6 +8,7 @@ interface GenerationParams {
|
||||
durationNights: number;
|
||||
climate: Climate;
|
||||
luggageConfig: LuggageConfig;
|
||||
activeBags?: BagType[];
|
||||
hasLaundry: boolean;
|
||||
activities: string[];
|
||||
memory?: PackMemory;
|
||||
@@ -56,11 +57,69 @@ export function generatePackingList(params: GenerationParams): PackingItem[] {
|
||||
durationDays,
|
||||
climate,
|
||||
luggageConfig,
|
||||
activeBags,
|
||||
hasLaundry,
|
||||
activities,
|
||||
memory,
|
||||
} = params;
|
||||
|
||||
// Determine which bags are actually going on this trip
|
||||
const tripBags: BagType[] =
|
||||
activeBags && activeBags.length > 0
|
||||
? activeBags
|
||||
: luggageConfig === 'backpack_only'
|
||||
? ['personal_item', 'worn']
|
||||
: luggageConfig === 'checked_bag'
|
||||
? ['personal_item', 'checked', 'worn']
|
||||
: ['personal_item', 'carry_on', 'worn'];
|
||||
|
||||
const activeSet = new Set<string>(tripBags);
|
||||
const hasChecked = activeSet.has('checked');
|
||||
const hasCarryOn = activeSet.has('carry_on');
|
||||
const hasPersonal = activeSet.has('personal_item');
|
||||
const hasWorn = activeSet.has('worn');
|
||||
|
||||
// Find any custom bag IDs the user selected for this trip
|
||||
const customBagIds = tripBags.filter(
|
||||
(id) => !['personal_item', 'carry_on', 'checked', 'worn'].includes(id)
|
||||
);
|
||||
|
||||
// Fallback cabin/personal bag
|
||||
const cabinBag: BagType = hasPersonal
|
||||
? 'personal_item'
|
||||
: hasCarryOn
|
||||
? 'carry_on'
|
||||
: customBagIds[0] || tripBags[0] || 'personal_item';
|
||||
|
||||
// Primary main luggage bag (Carry-On for shorter trips, Checked Bag for longer trips)
|
||||
const mainLuggageBag: BagType = hasChecked
|
||||
? 'checked'
|
||||
: hasCarryOn
|
||||
? 'carry_on'
|
||||
: customBagIds[0] || cabinBag;
|
||||
|
||||
// Secondary luggage bag (if user brings BOTH Carry-On and Checked on an extended trip)
|
||||
const secondaryLuggageBag: BagType =
|
||||
hasChecked && hasCarryOn ? 'carry_on' : mainLuggageBag;
|
||||
|
||||
// Resolve any requested bag to a valid active bag on this trip
|
||||
const resolveBag = (requested: BagType): BagType => {
|
||||
if (requested === 'worn') {
|
||||
return hasWorn ? 'worn' : mainLuggageBag;
|
||||
}
|
||||
if (requested === 'personal_item') {
|
||||
return cabinBag;
|
||||
}
|
||||
if (requested === 'checked') {
|
||||
return hasChecked ? 'checked' : mainLuggageBag;
|
||||
}
|
||||
if (requested === 'carry_on') {
|
||||
// Route standard clothing/gear to mainLuggageBag (Checked on long trips, Carry-On on short trips)
|
||||
return mainLuggageBag;
|
||||
}
|
||||
return activeSet.has(requested) ? requested : mainLuggageBag;
|
||||
};
|
||||
|
||||
const items: PackingItem[] = [];
|
||||
|
||||
const addItem = (
|
||||
@@ -93,7 +152,7 @@ export function generatePackingList(params: GenerationParams): PackingItem[] {
|
||||
name,
|
||||
category,
|
||||
quantity,
|
||||
bag: luggageConfig === 'backpack_only' && bag === 'checked' ? 'carry_on' : bag,
|
||||
bag: resolveBag(bag),
|
||||
packed: false,
|
||||
essential,
|
||||
notes,
|
||||
@@ -178,7 +237,7 @@ export function generatePackingList(params: GenerationParams): PackingItem[] {
|
||||
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');
|
||||
addItem('Reef-safe sunscreen SPF 50', 'toiletries', 1, hasChecked ? 'checked' : 'carry_on', true, hasChecked ? 'Full size in checked' : 'TSA 3.4oz limit');
|
||||
}
|
||||
|
||||
if (climate === 'rainy') {
|
||||
@@ -197,7 +256,7 @@ export function generatePackingList(params: GenerationParams): PackingItem[] {
|
||||
}
|
||||
|
||||
if (activities.includes('formal') || tripType === 'business') {
|
||||
addItem('Tailored blazer / suit jacket', 'clothing', 1, 'carry_on', true);
|
||||
addItem('Tailored blazer / suit jacket', 'clothing', 1, secondaryLuggageBag, 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);
|
||||
@@ -213,7 +272,7 @@ export function generatePackingList(params: GenerationParams): PackingItem[] {
|
||||
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') {
|
||||
if (hasChecked) {
|
||||
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');
|
||||
}
|
||||
@@ -226,12 +285,12 @@ export function generatePackingList(params: GenerationParams): PackingItem[] {
|
||||
}
|
||||
|
||||
// 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';
|
||||
const toiletryBag: BagType = hasChecked ? 'checked' : 'carry_on';
|
||||
const liquidNote = hasChecked ? 'Full size allowed in Checked Bag' : '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('Toothbrush & travel toothpaste', 'toiletries', 1, secondaryLuggageBag, true, liquidNote);
|
||||
addItem('Dental floss', 'toiletries', 1, secondaryLuggageBag);
|
||||
addItem('Deodorant', 'toiletries', 1, secondaryLuggageBag, 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);
|
||||
|
||||
Reference in new issue
Block a user