feat: Trip-specific bag selection with duration-based Carry-On vs Checked Bag auto-matching

This commit is contained in:
airent106 committed 2026-10-15 22:57:08 -06:00
1 parent be586ea386
commit 2d709cfd9b
5 files changed
+506 -70

No files matched your search

+82 -4
View File
@@ -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');
+75 -15
View File
@@ -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);
+206 -42
View File
@@ -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 */}
<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) => (
{/* 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">
BAGS GOING ON THIS TRIP ({selectedBags.length} SELECTED)
</label>
<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"
onClick={() => {
sound.playClick();
applyDurationAndArchetype(durationDays, tripType, true);
}}
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"
key={m.id}
onClick={() => {
sound.playClick();
setLuggageConfig(m.id);
onEditBags();
}}
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-white hover:bg-canary text-ink flex items-center gap-1 shadow-2xs"
title="Create or edit bags in your collection"
>
<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>
<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'
}`}
>
<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-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>
+75
View File
@@ -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
View File
@@ -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);