Files
platform/services/trips/frontend-legacy/src/routes/+page.svelte
Yusuf Suleman d3e250e361 Initial commit: Second Brain Platform
Complete platform with unified design system and real API integration.

Apps: Dashboard, Fitness, Budget, Inventory, Trips, Reader, Media, Settings
Infrastructure: SvelteKit + Python gateway + Docker Compose
2026-03-28 23:20:40 -05:00

628 lines
22 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { goto } from '$app/navigation';
import { get, post } from '$lib/api/client';
import type { Trip } from '$lib/api/types';
import TripCard from '$lib/components/TripCard.svelte';
import StatsBar from '$lib/components/StatsBar.svelte';
interface TripsResponse {
trips: Trip[];
}
interface SearchResult {
type: string;
id: string;
trip_id: string;
name: string;
detail: string;
trip_name: string;
}
let trips = $state<Trip[]>([]);
let loading = $state(true);
let error = $state('');
let searchQuery = $state('');
let searchResults = $state<SearchResult[]>([]);
let showSearchResults = $state(false);
let searchDebounce: ReturnType<typeof setTimeout>;
// New trip modal
let newTripModal = $state<HTMLDialogElement | null>(null);
let newTrip = $state({ name: '', description: '', start_date: '', end_date: '' });
let creating = $state(false);
function typeIcon(type: string): string {
switch (type) {
case 'trip': return 'M3 7l6-3 6 3 6-3v13l-6 3-6-3-6 3V7z';
case 'location': return 'M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z';
case 'lodging': return 'M3 14h18v7H3zM3 7v7M21 7v7M3 7l9-4 9 4';
case 'transportation': return 'M22 16.21v-1.895L14 8.42V4a2 2 0 1 0-4 0v4.42L2 14.315v1.895l8-2.526V18l-2 1.5V21l4-1 4 1v-1.5L16 18v-4.316z';
case 'note': return 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z';
default: return 'M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z';
}
}
function typeLabel(type: string): string {
switch (type) {
case 'trip': return 'Trip';
case 'location': return 'Activity';
case 'lodging': return 'Hotel';
case 'transportation': return 'Flight';
case 'note': return 'Note';
default: return type;
}
}
function typeBadgeClass(type: string): string {
switch (type) {
case 'trip': return 'badge-primary';
case 'location': return 'badge-accent';
case 'lodging': return 'badge-secondary';
case 'transportation': return 'badge-info';
case 'note': return 'badge-warning';
default: return 'badge-ghost';
}
}
async function doSearch(query: string) {
if (query.length < 2) {
searchResults = [];
showSearchResults = false;
return;
}
try {
const data = await get<{ results: SearchResult[] }>(`/api/search?q=${encodeURIComponent(query)}`);
searchResults = data.results;
showSearchResults = true;
} catch {
searchResults = [];
}
}
function onSearchInput() {
clearTimeout(searchDebounce);
searchDebounce = setTimeout(() => doSearch(searchQuery), 250);
}
function selectResult(result: SearchResult) {
showSearchResults = false;
searchQuery = '';
goto(`/trip/${result.trip_id}`);
}
// Separate trips into categories
let activeTrips = $derived(trips.filter(t => {
const now = new Date(); now.setHours(0,0,0,0);
const start = t.start_date ? new Date(t.start_date + 'T00:00:00') : null;
const end = t.end_date ? new Date(t.end_date + 'T00:00:00') : null;
return start && end && now >= start && now <= end;
}));
let upcomingTrips = $derived(trips.filter(t => {
const now = new Date(); now.setHours(0,0,0,0);
const start = t.start_date ? new Date(t.start_date + 'T00:00:00') : null;
return start && now < start;
}).sort((a, b) => a.start_date.localeCompare(b.start_date)));
let pastTrips = $derived(trips.filter(t => {
const now = new Date(); now.setHours(0,0,0,0);
const end = t.end_date ? new Date(t.end_date + 'T00:00:00') : null;
return end && now > end;
}).sort((a, b) => b.start_date.localeCompare(a.start_date)));
// Next upcoming trip countdown
let nextTrip = $derived(upcomingTrips.length > 0 ? upcomingTrips[0] : null);
let daysUntilNext = $derived(() => {
if (!nextTrip?.start_date) return 0;
const now = new Date(); now.setHours(0,0,0,0);
const start = new Date(nextTrip.start_date + 'T00:00:00');
return Math.ceil((start.getTime() - now.getTime()) / 86400000);
});
function formatDateShort(d: string): string {
if (!d) return '';
return new Date(d + 'T00:00:00').toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
$effect(() => {
loadTrips();
});
async function loadTrips() {
try {
const data = await get<TripsResponse>('/api/trips');
trips = data.trips;
} catch (e) {
error = 'Failed to load trips';
console.error(e);
} finally {
loading = false;
}
}
let quickInput = $state('');
let quickParsed = $state(false);
let descLookupTimer: ReturnType<typeof setTimeout>;
let lastLookedUpName = '';
async function lookupDescription(name: string) {
if (!name || name.length < 2 || name === lastLookedUpName) return;
lastLookedUpName = name;
try {
const data = await post<{ predictions: Array<{ name: string; address: string }> }>(
'/api/places/autocomplete', { query: name }
);
if (data.predictions?.length > 0) {
const place = data.predictions[0];
const addr = place.address || '';
if (addr && !newTrip.description) {
newTrip.description = `Trip to ${place.name}${addr ? ', ' + addr : ''}`;
}
}
} catch { /* ignore */ }
}
function parseQuickInput(input: string) {
if (!input.trim()) {
quickParsed = false;
return;
}
const text = input.trim();
const currentYear = new Date().getFullYear();
// Month name mapping
const months: Record<string, string> = {
jan: '01', january: '01', feb: '02', february: '02', mar: '03', march: '03',
apr: '04', april: '04', may: '05', jun: '06', june: '06',
jul: '07', july: '07', aug: '08', august: '08', sep: '09', september: '09',
oct: '10', october: '10', nov: '11', november: '11', dec: '12', december: '12'
};
function parseDate(str: string, refYear?: number): string | null {
str = str.trim().replace(/,/g, '');
// ISO format: 2026-10-01
const isoMatch = str.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/);
if (isoMatch) return `${isoMatch[1]}-${isoMatch[2].padStart(2, '0')}-${isoMatch[3].padStart(2, '0')}`;
// MM/DD/YYYY or MM/DD/YY or MM/DD
const slashMatch = str.match(/^(\d{1,2})\/(\d{1,2})(?:\/(\d{2,4}))?$/);
if (slashMatch) {
const y = slashMatch[3] ? (slashMatch[3].length === 2 ? '20' + slashMatch[3] : slashMatch[3]) : String(refYear || currentYear);
return `${y}-${slashMatch[1].padStart(2, '0')}-${slashMatch[2].padStart(2, '0')}`;
}
// "Oct 1" or "October 1" or "Oct 1 2026"
const monthNameMatch = str.match(/^([a-z]+)\s+(\d{1,2})(?:\s+(\d{4}))?$/i);
if (monthNameMatch) {
const m = months[monthNameMatch[1].toLowerCase()];
if (m) {
const y = monthNameMatch[3] || String(refYear || currentYear);
return `${y}-${m}-${monthNameMatch[2].padStart(2, '0')}`;
}
}
// "1 Oct" or "1 October 2026"
const dayFirstMatch = str.match(/^(\d{1,2})\s+([a-z]+)(?:\s+(\d{4}))?$/i);
if (dayFirstMatch) {
const m = months[dayFirstMatch[2].toLowerCase()];
if (m) {
const y = dayFirstMatch[3] || String(refYear || currentYear);
return `${y}-${m}-${dayFirstMatch[1].padStart(2, '0')}`;
}
}
return null;
}
// Try splitting by common separators: "to", "-", "", "through"
const separators = [/\s+to\s+/i, /\s*[-–—]\s*(?=\d|[a-z])/i, /\s+through\s+/i];
let name = '';
let startStr = '';
let endStr = '';
let description = '';
let matched = false;
// Date patterns to look for
const datePatterns = [
/(\d{1,2}\/\d{1,2}(?:\/\d{2,4})?)/,
/(\d{4}-\d{1,2}-\d{1,2})/,
/([A-Za-z]+\s+\d{1,2}(?:\s+\d{4})?)/,
/(\d{1,2}\s+[A-Za-z]+(?:\s+\d{4})?)/,
];
for (const sep of separators) {
const parts = text.split(sep);
if (parts.length >= 2) {
const beforeSep = parts.slice(0, -1).join(' ').trim();
const afterSep = parts[parts.length - 1].trim();
// Find start date in beforeSep (from right)
for (const dp of datePatterns) {
// Anchor to end for start date search
const anchored = new RegExp(dp.source + '\\s*$');
const dm = beforeSep.match(anchored);
if (dm && dm.index !== undefined) {
name = beforeSep.slice(0, dm.index).trim();
startStr = dm[1];
// afterSep may be "1/10/27 Umrah and Maldives trip" or just "1/10/27"
// Try to extract end date from the beginning of afterSep
for (const edp of datePatterns) {
const anchStart = new RegExp('^' + edp.source);
const em = afterSep.match(anchStart);
if (em) {
endStr = em[1];
// Everything after the end date is the description
const remainder = afterSep.slice(em[0].length).trim();
if (remainder) {
description = remainder;
}
matched = true;
break;
}
}
if (!matched) {
// No date pattern found — treat entire afterSep as end date
endStr = afterSep;
matched = true;
}
break;
}
}
if (matched) break;
}
}
if (!matched) {
// No date range found — just use as name
newTrip.name = text;
quickParsed = false;
return;
}
// Parse the dates
const startDate = parseDate(startStr);
const startYear = startDate ? parseInt(startDate.split('-')[0]) : currentYear;
const endDate = parseDate(endStr, startYear);
if (name) newTrip.name = name;
if (description) newTrip.description = description;
if (startDate) newTrip.start_date = startDate;
if (endDate) newTrip.end_date = endDate;
// If end date is before start date, assume next year
if (startDate && endDate && endDate < startDate) {
const y = parseInt(endDate.split('-')[0]) + 1;
newTrip.end_date = `${y}${endDate.slice(4)}`;
}
quickParsed = !!(startDate || endDate);
}
function openNewTrip() {
newTrip = { name: '', description: '', start_date: '', end_date: '' };
quickInput = '';
quickParsed = false;
newTripModal?.showModal();
}
async function createTrip() {
if (!newTrip.name.trim()) return;
creating = true;
try {
const result = await post<{ success: boolean; id: string }>('/api/trip', newTrip);
if (result.success) {
newTripModal?.close();
goto(`/trip/${result.id}`);
}
} catch (e) {
console.error('Failed to create trip:', e);
} finally {
creating = false;
}
}
</script>
<div class="container mx-auto px-4 py-8 max-w-7xl">
<!-- Header -->
<div class="flex items-center justify-between mb-8">
<div>
<h1 class="text-3xl font-bold text-base-content">My Trips</h1>
<p class="text-base-content/50 mt-1">Plan and track your adventures</p>
</div>
<button class="btn btn-primary gap-2" onclick={openNewTrip}>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/>
</svg>
New Trip
</button>
</div>
<!-- Stats bar -->
<!-- Next trip countdown -->
{#if nextTrip}
<a href="/trip/{nextTrip.id}" class="flex items-center gap-4 p-4 mb-6 rounded-xl bg-gradient-to-r from-info/10 to-primary/10 border border-info/20 hover:border-info/40 transition-colors group">
<div class="p-2.5 rounded-xl bg-info/15 shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-info" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 16.21v-1.895L14 8.42V4a2 2 0 1 0-4 0v4.42L2 14.315v1.895l8-2.526V18l-2 1.5V21l4-1 4 1v-1.5L16 18v-4.316z"/>
</svg>
</div>
<div class="flex-1 min-w-0">
<div class="text-xs text-info/70 uppercase tracking-wide font-medium mb-0.5">Next Adventure</div>
<div class="font-semibold text-base-content group-hover:text-info transition-colors truncate">{nextTrip.name}</div>
<div class="text-sm text-base-content/50">{formatDateShort(nextTrip.start_date)}{formatDateShort(nextTrip.end_date)}</div>
</div>
<div class="text-center shrink-0">
<div class="text-2xl font-bold text-info leading-none">{daysUntilNext()}</div>
<div class="text-xs text-base-content/40 mt-0.5">days</div>
</div>
</a>
{:else if activeTrips.length > 0}
<a href="/trip/{activeTrips[0].id}" class="flex items-center gap-3 p-4 mb-6 rounded-xl bg-gradient-to-r from-success/10 to-success/5 border border-success/20 hover:border-success/40 transition-colors group">
<div class="p-2 rounded-xl bg-success/15">
<span class="relative flex h-3 w-3">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-success opacity-75"></span>
<span class="relative inline-flex rounded-full h-3 w-3 bg-success"></span>
</span>
</div>
<div class="flex-1">
<span class="font-semibold text-base-content group-hover:text-success transition-colors">{activeTrips[0].name}</span>
<span class="text-base-content/40 mx-2">·</span>
<span class="text-sm text-success">Happening now!</span>
</div>
</a>
{/if}
<StatsBar />
<!-- Search -->
{#if trips.length > 0}
<div class="mb-6">
<div class="relative max-w-md">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 absolute left-3 top-1/2 -translate-y-1/2 text-base-content/30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<input
type="text"
placeholder="Search trips, hotels, flights, places..."
class="input input-bordered w-full pl-10"
bind:value={searchQuery}
oninput={onSearchInput}
onfocus={() => { if (searchResults.length > 0) showSearchResults = true; }}
onblur={() => setTimeout(() => showSearchResults = false, 200)}
/>
{#if searchQuery}
<button
class="btn btn-ghost btn-sm btn-circle absolute right-2 top-1/2 -translate-y-1/2"
onclick={() => { searchQuery = ''; searchResults = []; showSearchResults = false; }}
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
{/if}
<!-- Search results dropdown -->
{#if showSearchResults && searchResults.length > 0}
<div class="absolute top-full left-0 right-0 mt-1 bg-base-200 border border-base-300 rounded-box shadow-xl z-50 max-h-80 overflow-y-auto">
{#each searchResults as result}
<button
class="w-full flex items-center gap-3 p-3 hover:bg-base-300 transition-colors text-left"
onmousedown={() => selectResult(result)}
>
<div class="w-8 h-8 rounded-lg bg-base-300 flex items-center justify-center shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-base-content/50" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d={typeIcon(result.type)}/>
</svg>
</div>
<div class="flex-1 min-w-0">
<div class="font-medium text-sm text-base-content truncate">{result.name}</div>
{#if result.detail}
<div class="text-xs text-base-content/40 truncate">{result.detail}</div>
{/if}
</div>
<div class="flex items-center gap-2 shrink-0">
<span class="badge badge-sm {typeBadgeClass(result.type)}">{typeLabel(result.type)}</span>
{#if result.type !== 'trip'}
<span class="text-xs text-base-content/30 max-w-24 truncate">{result.trip_name}</span>
{/if}
</div>
</button>
{/each}
</div>
{:else if showSearchResults && searchQuery.length >= 2 && searchResults.length === 0}
<div class="absolute top-full left-0 right-0 mt-1 bg-base-200 border border-base-300 rounded-box shadow-xl z-50 p-4 text-center text-base-content/40 text-sm">
No results for "{searchQuery}"
</div>
{/if}
</div>
</div>
{/if}
{#if loading}
<div class="flex justify-center py-20">
<span class="loading loading-spinner loading-lg text-primary"></span>
</div>
{:else if error}
<div class="alert alert-error">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0 stroke-current" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<span>{error}</span>
</div>
{:else}
<!-- Active trips -->
{#if activeTrips.length > 0}
<section class="mb-10">
<div class="flex items-center gap-2 mb-4">
<span class="badge badge-success badge-sm"></span>
<h2 class="text-xl font-semibold text-base-content">Active Now</h2>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{#each activeTrips as trip (trip.id)}
<TripCard {trip} />
{/each}
</div>
</section>
{/if}
<!-- Upcoming trips -->
{#if upcomingTrips.length > 0}
<section class="mb-10">
<div class="flex items-center gap-2 mb-4">
<span class="badge badge-info badge-sm"></span>
<h2 class="text-xl font-semibold text-base-content">Upcoming</h2>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{#each upcomingTrips as trip (trip.id)}
<TripCard {trip} />
{/each}
</div>
</section>
{/if}
<!-- Past trips -->
{#if pastTrips.length > 0}
<section class="mb-10">
<div class="flex items-center gap-2 mb-4">
<span class="badge badge-ghost badge-sm"></span>
<h2 class="text-xl font-semibold text-base-content">Past Adventures</h2>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{#each pastTrips as trip (trip.id)}
<TripCard {trip} />
{/each}
</div>
</section>
{/if}
<!-- Empty state -->
{#if trips.length === 0}
<div class="text-center py-20">
<svg xmlns="http://www.w3.org/2000/svg" class="h-24 w-24 mx-auto text-base-content/20 mb-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 7l6-3 6 3 6-3v13l-6 3-6-3-6 3V7z"/>
<path d="M9 4v13"/>
<path d="M15 7v13"/>
</svg>
<h3 class="text-xl font-semibold text-base-content/60 mb-2">No trips yet</h3>
<p class="text-base-content/40 mb-6">Start planning your first adventure</p>
<button class="btn btn-primary" onclick={openNewTrip}>Create Your First Trip</button>
</div>
{/if}
{/if}
</div>
<!-- New Trip Modal -->
<dialog bind:this={newTripModal} class="modal modal-bottom sm:modal-middle">
<div class="modal-box max-w-lg">
<form method="dialog">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">&#x2715;</button>
</form>
<h3 class="font-bold text-lg flex items-center gap-2 mb-4">
<div class="p-2 rounded-xl bg-primary/15">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
</div>
New Trip
</h3>
<form onsubmit={(e) => { e.preventDefault(); createTrip(); }}>
<div class="space-y-4">
<!-- Quick input -->
<div class="form-control">
<label class="label" for="quick-input">
<span class="label-text">Quick Add</span>
<span class="label-text-alt text-base-content/30">auto-fills below</span>
</label>
<input
id="quick-input"
type="text"
placeholder="e.g. Toronto Oct 1 to Oct 10 Family vacation"
class="input input-bordered input-primary w-full"
bind:value={quickInput}
oninput={() => parseQuickInput(quickInput)}
/>
{#if quickParsed}
<label class="label pb-0">
<span class="label-text-alt text-success flex items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
Parsed — check fields below
</span>
</label>
{/if}
</div>
<div class="divider text-base-content/20 text-xs my-1">or fill in manually</div>
<div class="form-control">
<label class="label" for="trip-name">
<span class="label-text">Trip Name</span>
</label>
<input
id="trip-name"
type="text"
placeholder="e.g. Japan 2026"
class="input input-bordered w-full"
bind:value={newTrip.name}
required
/>
</div>
<div class="form-control">
<label class="label" for="trip-desc">
<span class="label-text">Description</span>
<span class="label-text-alt text-base-content/30">Optional</span>
</label>
<textarea
id="trip-desc"
placeholder="What's this trip about?"
class="textarea textarea-bordered w-full"
rows="2"
bind:value={newTrip.description}
></textarea>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="form-control">
<label class="label" for="trip-start">
<span class="label-text">Start Date</span>
</label>
<input
id="trip-start"
type="date"
class="input input-bordered w-full"
bind:value={newTrip.start_date}
/>
</div>
<div class="form-control">
<label class="label" for="trip-end">
<span class="label-text">End Date</span>
</label>
<input
id="trip-end"
type="date"
class="input input-bordered w-full"
bind:value={newTrip.end_date}
/>
</div>
</div>
</div>
<div class="modal-action">
<button type="button" class="btn btn-ghost" onclick={() => newTripModal?.close()}>Cancel</button>
<button type="submit" class="btn btn-primary" disabled={creating || !newTrip.name.trim()}>
{#if creating}
<span class="loading loading-spinner loading-sm"></span>
{/if}
Create Trip
</button>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>