Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 33 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: Test
name: CI
on:
push:
branches: [main]
Expand All @@ -10,8 +10,39 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- run: bun install --frozen-lockfile
with:
bun-version: latest
- run: bun install
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CI drops frozen-lockfile, inconsistent with deploy workflow

Medium Severity

All four CI jobs changed bun install --frozen-lockfile to plain bun install, while deploy.yml still uses --frozen-lockfile. This means CI tests can silently pass with auto-resolved dependency versions that differ from the lockfile, but the deployment build will fail if the lockfile is out of sync. This inconsistency can cause PRs to pass CI yet break the deploy pipeline.

Additional Locations (2)

Fix in Cursor Fix in Web

- run: bun run test:coverage

lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- run: bun install
- run: bun run lint

typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- run: bun install
- run: bun run --bun nuxi typecheck

bundle-size:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- run: bun install
- name: Check bundle size
run: bun run test:bundle-size
env:
Expand Down
233 changes: 188 additions & 45 deletions app.vue
Original file line number Diff line number Diff line change
@@ -1,87 +1,189 @@
<script setup lang="ts">
import { ref, watch, computed, watchEffect } from 'vue'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Button } from '@/components/ui/button'
import { Toaster } from '@/components/ui/sonner'
import { toast } from 'vue-sonner'
import { MapIcon } from 'lucide-vue-next'
import { SpeedInsights } from '@vercel/speed-insights/vue'
import { useTripStore } from '~/stores/tripStore'
import { useMapMarkers } from '~/composables/useMapMarkers'
import type { Place } from '~/types/trip'

const store = useTripStore()

const worldMapRef = ref<InstanceType<typeof WorldMap> | null>(null)
const mapRef = computed(() => worldMapRef.value?.map ?? null)
const { syncMarkers, flyToPlace, fitAllPlaces } = useMapMarkers(mapRef)

const showSetupDialog = ref(!store.trip)
const showEditDialog = ref(false)
import { ref, watch, computed, watchEffect, onMounted } from 'vue';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Button } from '@/components/ui/button';
import { Toaster } from '@/components/ui/sonner';
import { toast } from 'vue-sonner';
import { MapIcon, ListIcon, SunIcon, MoonIcon, KeyboardIcon, LayoutListIcon, ClockIcon } from 'lucide-vue-next';
import { SpeedInsights } from '@vercel/speed-insights/vue';
import { useTripStore } from '~/stores/tripStore';
import { useTripSharing } from '~/composables/useTripSharing';
import { useMapMarkers } from '~/composables/useMapMarkers';
import { useKeyboardShortcuts } from '~/composables/useKeyboardShortcuts';
import type { Place } from '~/types/trip';

const store = useTripStore();
const colorMode = useColorMode();

// Check for shared trip in URL
if (typeof window !== 'undefined') {
const { decodeTripFromUrl, clearShareHash } = useTripSharing();
const sharedTrip = decodeTripFromUrl();
if (sharedTrip) {
store.createTrip(sharedTrip.name, sharedTrip.startDate, sharedTrip.endDate);
if (store.trip) {
store.trip = { ...store.trip, days: sharedTrip.days };
}
clearShareHash();
toast(`Imported shared trip: "${sharedTrip.name}"`);
}
}

function toggleDarkMode() {
colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark';
}

const worldMapRef = ref<InstanceType<typeof WorldMap> | null>(null);
const mapRef = computed(() => worldMapRef.value?.map ?? null);
const highlightedPlaceId = ref<string | null>(null);

function onMarkerClicked(placeId: string, dayIndex: number) {
if (store.selectedDayIndex !== dayIndex) {
store.selectDay(dayIndex);
}
highlightedPlaceId.value = placeId;
setTimeout(() => {
highlightedPlaceId.value = null;
}, 2000);
}

const { syncMarkers, flyToPlace, fitAllPlaces } = useMapMarkers(mapRef, onMarkerClicked, store.getDayColor);

const showSetupDialog = ref(!store.trip);
const showEditDialog = ref(false);
const showExportDialog = ref(false);
const showShortcutsHelp = ref(false);
const showTripSelector = ref(false);
const showMap = ref(false);
const viewMode = ref<'list' | 'timeline'>('list');
const placeSearchRef = ref<InstanceType<typeof PlaceSearch> | null>(null);

const isLoaded = ref(false);
onMounted(() => {
requestAnimationFrame(() => {
isLoaded.value = true;
});
});

useKeyboardShortcuts({
onFocusSearch: () => placeSearchRef.value?.focus(),
onEditTrip: () => { showEditDialog.value = true; },
onNewTrip,
onToggleExport: () => { showExportDialog.value = true; },
showShortcutsHelp,
});

// Sync markers whenever trip data or map readiness changes
watchEffect(() => {
const map = mapRef.value
const trip = store.trip
const map = mapRef.value;
const trip = store.trip;

if (!map || !trip) {
syncMarkers([])
return
syncMarkers([]);
return;
}

const allDayData = trip.days.map((day, idx) => ({
dayIndex: idx,
places: day.places,
}))
syncMarkers(allDayData)
})
}));
syncMarkers(allDayData, store.selectedDayIndex);
});

// Fit map to current day's places when switching days
watch(
() => store.selectedDayIndex,
() => {
if (store.currentDay && store.currentDay.places.length > 0) {
fitAllPlaces(store.currentDay.places)
fitAllPlaces(store.currentDay.places);
}
},
)
);

function onPlaceSelected(place: Place) {
flyToPlace(place.coordinates)
toast(`Added "${place.name}" to Day ${store.selectedDayIndex + 1}`)
flyToPlace(place.coordinates);
toast(`Added "${place.name}" to Day ${store.selectedDayIndex + 1}`);
}

function onPlaceClicked(place: Place) {
flyToPlace(place.coordinates)
flyToPlace(place.coordinates);
}

function onTripCreated() {
showSetupDialog.value = false
toast('Trip created! Start adding places.')
async function onTripCreated() {
showSetupDialog.value = false;
toast('Trip created! Start adding places.');
const { default: confetti } = await import('canvas-confetti');
confetti({
particleCount: 100,
spread: 70,
origin: { y: 0.6 },
colors: ['#667eea', '#764ba2', '#f093fb', '#4facfe', '#43e97b'],
});
}

function onNewTrip() {
store.clearTrip()
showSetupDialog.value = true
showTripSelector.value = true;
}

function onCreateNewFromSelector() {
showSetupDialog.value = true;
}

function onStyleChanged() {
if (!store.trip || !mapRef.value) return;
const allDayData = store.trip.days.map((day, idx) => ({
dayIndex: idx,
places: day.places,
}));
syncMarkers(allDayData, store.selectedDayIndex);
}
</script>

<template>
<div class="h-screen bg-white font-outfit overflow-hidden">
<div class="h-screen bg-background font-outfit overflow-hidden transition-all duration-700 ease-out" :class="isLoaded ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-2'">
<SpeedInsights />
<Toaster position="top-right" />
<TripSetupDialog v-model:open="showSetupDialog" @created="onTripCreated" />
<TripEditDialog v-model:open="showEditDialog" />
<LazyTripSetupDialog v-model:open="showSetupDialog" @created="onTripCreated" />
<LazyTripEditDialog v-model:open="showEditDialog" />
<LazyTripExport v-model:open="showExportDialog" />
<LazyKeyboardShortcutsHelp v-model:open="showShortcutsHelp" />
<LazyTripSelector v-model:open="showTripSelector" @create-new="onCreateNewFromSelector" />

<div class="flex h-full">
<!-- Left panel -->
<div class="w-[45%] xl:w-[40%] flex flex-col gap-3 min-h-0 px-6 py-6 border-r border-gray-200">
<div
class="w-full md:w-[45%] xl:w-[40%] flex flex-col gap-3 min-h-0 px-4 md:px-6 py-4 md:py-6 border-r border-border"
:class="{ 'hidden': showMap, 'flex': !showMap }"
>
<div class="flex justify-end">
<Button variant="ghost" size="icon" class="h-8 w-8" aria-label="Toggle dark mode" @click="toggleDarkMode">
<SunIcon v-if="colorMode.value === 'dark'" class="h-4 w-4" />
<MoonIcon v-else class="h-4 w-4" />
</Button>
</div>

<template v-if="store.trip">
<TripHeader @new-trip="onNewTrip" @edit-trip="showEditDialog = true" />
<TripHeader @new-trip="onNewTrip" @edit-trip="showEditDialog = true" @export-trip="showExportDialog = true" />
<TripStats />
<DayTabs />
<PlaceSearch @place-selected="onPlaceSelected" />
<div class="flex items-center justify-between">
<PlaceSearch ref="placeSearchRef" class="flex-1" @place-selected="onPlaceSelected" />
<Button
variant="ghost"
size="icon"
class="h-8 w-8 shrink-0 ml-1"
:aria-label="viewMode === 'list' ? 'Switch to timeline view' : 'Switch to list view'"
@click="viewMode = viewMode === 'list' ? 'timeline' : 'list'"
>
<LayoutListIcon v-if="viewMode === 'list'" class="h-4 w-4" />
<ClockIcon v-else class="h-4 w-4" />
</Button>
</div>
<ScrollArea class="flex-1">
<PlaceList @place-clicked="onPlaceClicked" />
<Transition name="slide-fade" mode="out-in">
<PlaceList v-if="viewMode === 'list'" :key="'list-' + store.selectedDayIndex" :highlighted-place-id="highlightedPlaceId" @place-clicked="onPlaceClicked" />
<DayTimeline v-else :key="'timeline-' + store.selectedDayIndex" @place-clicked="onPlaceClicked" />
</Transition>
</ScrollArea>
</template>

Expand All @@ -95,19 +197,60 @@ function onNewTrip() {
</div>

<!-- Right panel -->
<div class="flex-1 h-full">
<WorldMap ref="worldMapRef" />
<div
class="h-full md:block md:flex-1"
:class="showMap ? 'block flex-1' : 'hidden'"
>
<LazyWorldMap ref="worldMapRef" @style-changed="onStyleChanged" />
</div>
</div>

<!-- Keyboard shortcuts help button -->
<Button
variant="outline"
size="icon"
class="fixed bottom-6 right-6 z-50 hidden md:flex rounded-full h-9 w-9 shadow-md"
aria-label="Keyboard shortcuts"
@click="showShortcutsHelp = true"
>
<KeyboardIcon class="h-4 w-4" />
</Button>

<!-- Mobile map/list toggle -->
<Button
class="fixed bottom-6 right-6 z-50 md:hidden rounded-full h-14 w-14 shadow-lg"
size="icon"
:aria-label="showMap ? 'Show list' : 'Show map'"
@click="showMap = !showMap"
>
<ListIcon v-if="showMap" class="h-5 w-5" />
<MapIcon v-else class="h-5 w-5" />
</Button>
</div>
</template>

<style>
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap');
@import '~/assets/css/print.css';

body {
margin: 0;
min-height: 100vh;
font-family: 'Outfit', sans-serif;
}

.slide-fade-enter-active {
transition: all 0.2s ease;
}
.slide-fade-leave-active {
transition: all 0.15s ease;
}
.slide-fade-enter-from {
opacity: 0;
transform: translateX(10px);
}
.slide-fade-leave-to {
opacity: 0;
transform: translateX(-10px);
}
</style>
Loading