+
- LUGGAGE ALLOCATION // "WHAT GOES IN WHAT BAG"
+ TRIP BAGS ({visibleBags.length}/{customBags.length})
+
@@ -91,10 +108,53 @@ export const BagOverview: React.FC
= ({
}`}
>
- VIEW ALL BAGS ({items.length})
+ VIEW ALL ({items.length})
+ {/* Inline Trip Bag Loadout Switcher */}
+ {showTripBagPicker && (
+
+
+
+ WHICH BAGS ARE GOING ON THIS TRIP? (UNCHECKED BAGS AUTOMATICALLY MOVE ITEMS TO YOUR MAIN BAG)
+
+
+
+ {customBags.map((bag) => {
+ const isBringing = visibleBags.some((b) => b.id === bag.id);
+ return (
+
+ );
+ })}
+
+
{visibleBags.map((b) => {
const bagItems = items.filter((i) => i.bag === b.id);
diff --git a/src/components/TripModal.tsx b/src/components/TripModal.tsx
index a0e51e0..fb11a87 100644
--- a/src/components/TripModal.tsx
+++ b/src/components/TripModal.tsx
@@ -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
= ({
onClose,
onSaveTrip,
memory,
+ customBags,
+ onEditBags,
customArchetypes,
onEditArchetypes,
trips,
@@ -73,12 +83,25 @@ export const TripModal: React.FC = ({
const [destination, setDestination] = useState('');
const [title, setTitle] = useState('');
const [tripType, setTripType] = useState(customArchetypes[0]?.id || 'international_urban');
- const [durationDays, setDurationDays] = useState(7);
+ const [durationDays, setDurationDays] = useState(4);
const [climate, setClimate] = useState('mild');
- const [luggageConfig, setLuggageConfig] = useState('carry_on_only');
+ const [selectedBags, setSelectedBags] = useState(() =>
+ getRecommendedBagIdsForTrip(4, customArchetypes[0]?.id || 'international_urban', customBags)
+ );
+ const [bagsManuallyCustomized, setBagsManuallyCustomized] = useState(false);
const [hasLaundry, setHasLaundry] = useState(true);
const [selectedActivities, setSelectedActivities] = useState(['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 = ({
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 = ({
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 = ({
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 = ({
// 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 = ({
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 = ({
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 = ({
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 = ({
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 = ({
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"
/>
-
- {[3, 5, 7, 10, 14].map((d) => (
+
+ {[2, 4, 5, 7, 10, 14].map((d) => (
- {/* Luggage Mode */}
-
-
-
- {LUGGAGE_MODES.map((m) => (
+ {/* Trip-Specific Bag Selection ("Which Bags Go on This Trip") */}
+
+
+
+
+
+ {!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'}
+
+
+
+
+ {bagsManuallyCustomized && (
+
+ )}
- ))}
+
+
+
+ {/* Quick Presets */}
+
+
+
+
+
+
+
+ {/* Individual Bag Toggle Cards */}
+
+ {customBags.map((bag) => {
+ const isSelected = selectedBags.includes(bag.id);
+ return (
+
+ );
+ })}
diff --git a/src/types/index.ts b/src/types/index.ts
index 17b2dc3..63f9154 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -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
();
+ 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;
diff --git a/src/utils/generator.ts b/src/utils/generator.ts
index bf6bf64..b9092a4 100644
--- a/src/utils/generator.ts
+++ b/src/utils/generator.ts
@@ -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(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);