test: Add E2E test framework with Playwright
feat: Basic Homepage elements
This commit is contained in:
123
app/components/WeatherWidget.vue
Normal file
123
app/components/WeatherWidget.vue
Normal file
@@ -0,0 +1,123 @@
|
||||
<template>
|
||||
<IonCard>
|
||||
<IonCardHeader>
|
||||
<IonCardTitle>Weather</IonCardTitle>
|
||||
<IonCardSubtitle>Marina conditions</IonCardSubtitle>
|
||||
</IonCardHeader>
|
||||
<IonCardContent>
|
||||
<div v-if="loading" class="ion-text-center ion-padding">
|
||||
<IonSpinner name="crescent" />
|
||||
</div>
|
||||
<p v-else-if="error" class="empty-text">{{ error }}</p>
|
||||
<div v-else class="weather-grid">
|
||||
<div class="weather-item">
|
||||
<IonIcon :icon="thermometerOutline" />
|
||||
<span class="value">{{ weather.temp }}°C</span>
|
||||
<span class="label">Temperature</span>
|
||||
</div>
|
||||
<div class="weather-item">
|
||||
<IonIcon :icon="partlySunnyOutline" />
|
||||
<span class="value">{{ weather.description }}</span>
|
||||
<span class="label">Conditions</span>
|
||||
</div>
|
||||
<div class="weather-item">
|
||||
<IonIcon
|
||||
:icon="arrowUpOutline"
|
||||
:style="{ transform: `rotate(${weather.windDir}deg)` }"
|
||||
/>
|
||||
<span class="value">{{ weather.windSpeed }} kn</span>
|
||||
<span class="label">Wind</span>
|
||||
</div>
|
||||
</div>
|
||||
</IonCardContent>
|
||||
</IonCard>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
IonCard, IonCardHeader, IonCardTitle, IonCardSubtitle,
|
||||
IonCardContent, IonSpinner, IonIcon,
|
||||
} from '@ionic/vue'
|
||||
import { thermometerOutline, partlySunnyOutline, arrowUpOutline } from 'ionicons/icons'
|
||||
|
||||
const LAT = 43.4412629
|
||||
const LON = -79.6696725
|
||||
|
||||
const WMO: Record<number, string> = {
|
||||
0: 'Clear', 1: 'Mostly clear', 2: 'Partly cloudy', 3: 'Overcast',
|
||||
45: 'Fog', 48: 'Icy fog',
|
||||
51: 'Light drizzle', 53: 'Drizzle', 55: 'Heavy drizzle',
|
||||
61: 'Light rain', 63: 'Rain', 65: 'Heavy rain',
|
||||
71: 'Light snow', 73: 'Snow', 75: 'Heavy snow',
|
||||
80: 'Showers', 81: 'Showers', 82: 'Heavy showers',
|
||||
95: 'Thunderstorm', 96: 'T-storm + hail', 99: 'Severe t-storm',
|
||||
}
|
||||
|
||||
const CACHE_KEY = 'weather_cache'
|
||||
const CACHE_TTL_MS = 10 * 60 * 1000 // 10 minutes
|
||||
|
||||
interface WeatherData { temp: number; windSpeed: number; windDir: number; description: string }
|
||||
interface WeatherCache { data: WeatherData; fetchedAt: number }
|
||||
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const weather = ref<WeatherData>({ temp: 0, windSpeed: 0, windDir: 0, description: '' })
|
||||
|
||||
onMounted(async () => {
|
||||
const cached = localStorage.getItem(CACHE_KEY)
|
||||
if (cached) {
|
||||
const { data, fetchedAt }: WeatherCache = JSON.parse(cached)
|
||||
if (Date.now() - fetchedAt < CACHE_TTL_MS) {
|
||||
weather.value = data
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const url =
|
||||
`https://api.open-meteo.com/v1/forecast` +
|
||||
`?latitude=${LAT}&longitude=${LON}` +
|
||||
`¤t=temperature_2m,wind_speed_10m,wind_direction_10m,weather_code` +
|
||||
`&wind_speed_unit=kn`
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) throw new Error()
|
||||
const json = await res.json()
|
||||
const c = json.current
|
||||
const data: WeatherData = {
|
||||
temp: Math.round(c.temperature_2m),
|
||||
windSpeed: Math.round(c.wind_speed_10m),
|
||||
windDir: c.wind_direction_10m,
|
||||
description: WMO[c.weather_code as number] ?? 'Unknown',
|
||||
}
|
||||
weather.value = data
|
||||
localStorage.setItem(CACHE_KEY, JSON.stringify({ data, fetchedAt: Date.now() }))
|
||||
} catch {
|
||||
error.value = 'Weather data unavailable'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.weather-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
.weather-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.weather-item ion-icon {
|
||||
font-size: 1.75rem;
|
||||
color: var(--ion-color-primary);
|
||||
}
|
||||
.value { font-size: 1rem; font-weight: 600; }
|
||||
.label { font-size: 0.75rem; color: var(--ion-color-medium); }
|
||||
.empty-text { color: var(--ion-color-medium); margin: 0; }
|
||||
</style>
|
||||
@@ -21,7 +21,46 @@
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
<IonContent class="ion-padding">
|
||||
<h2>Welcome to OYS Borrow a Boat</h2>
|
||||
<h2 class="welcome">Welcome, {{ auth.displayName }}</h2>
|
||||
|
||||
<IonCard>
|
||||
<IonCardHeader>
|
||||
<IonCardTitle>Upcoming Reservations</IonCardTitle>
|
||||
</IonCardHeader>
|
||||
<IonCardContent>
|
||||
<div v-if="loadingReservations" class="ion-text-center ion-padding">
|
||||
<IonSpinner name="crescent" />
|
||||
</div>
|
||||
<p v-else-if="upcomingReservations.length === 0" class="empty-text">
|
||||
No upcoming reservations.
|
||||
</p>
|
||||
<IonList v-else lines="full">
|
||||
<IonItem v-for="r in upcomingReservations" :key="r.id">
|
||||
<IonLabel>
|
||||
<h3>{{ boatName(r) }}</h3>
|
||||
<p>{{ formatDateRange(r.start_time, r.end_time) }}</p>
|
||||
</IonLabel>
|
||||
<IonBadge slot="end" :color="statusColor(r.status)">{{ r.status }}</IonBadge>
|
||||
</IonItem>
|
||||
</IonList>
|
||||
</IonCardContent>
|
||||
</IonCard>
|
||||
|
||||
<IonButton expand="block" router-link="/reservations/create" class="ion-margin-bottom">
|
||||
<IonIcon slot="start" :icon="addCircleOutline" />
|
||||
Create Reservation
|
||||
</IonButton>
|
||||
|
||||
<IonCard>
|
||||
<IonCardHeader>
|
||||
<IonCardTitle>Calendar</IonCardTitle>
|
||||
</IonCardHeader>
|
||||
<IonCardContent class="calendar-content">
|
||||
<DatePicker inline :min-date="today" />
|
||||
</IonCardContent>
|
||||
</IonCard>
|
||||
|
||||
<WeatherWidget />
|
||||
</IonContent>
|
||||
</template>
|
||||
</IonPage>
|
||||
@@ -30,19 +69,69 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
IonPage, IonHeader, IonToolbar, IonTitle, IonContent,
|
||||
IonButtons, IonMenuButton, IonButton,
|
||||
IonButtons, IonMenuButton, IonButton, IonCard, IonCardHeader,
|
||||
IonCardTitle, IonCardContent, IonList, IonItem, IonLabel,
|
||||
IonBadge, IonSpinner, IonIcon,
|
||||
} from '@ionic/vue'
|
||||
import { addCircleOutline } from 'ionicons/icons'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
import type { Database, ReservationStatus } from '~/types/supabase'
|
||||
|
||||
const user = useSupabaseUser()
|
||||
type ReservationWithBoat = Database['public']['Tables']['reservations']['Row'] & {
|
||||
boats: { name: string; display_name: string | null } | null
|
||||
}
|
||||
|
||||
definePageMeta({ layout: false })
|
||||
|
||||
const user = useSupabaseUser()
|
||||
const auth = useAuthStore()
|
||||
const supabase = useSupabaseClient<Database>()
|
||||
|
||||
const today = new Date()
|
||||
const loadingReservations = ref(true)
|
||||
const upcomingReservations = ref<ReservationWithBoat[]>([])
|
||||
|
||||
async function fetchReservations() {
|
||||
if (!user.value) return
|
||||
loadingReservations.value = true
|
||||
const { data } = await (supabase as ReturnType<typeof useSupabaseClient>)
|
||||
.from('reservations')
|
||||
.select('*, boats(name, display_name)')
|
||||
.eq('user_id', user.value.id)
|
||||
.gte('start_time', new Date().toISOString())
|
||||
.order('start_time', { ascending: true })
|
||||
.limit(3)
|
||||
upcomingReservations.value = (data as ReservationWithBoat[]) ?? []
|
||||
loadingReservations.value = false
|
||||
}
|
||||
|
||||
watch(user, (val) => { if (val) fetchReservations() }, { immediate: true })
|
||||
|
||||
function boatName(r: ReservationWithBoat) {
|
||||
return r.boats?.display_name || r.boats?.name || 'Unknown boat'
|
||||
}
|
||||
|
||||
function formatDateRange(start: string, end: string) {
|
||||
const s = new Date(start)
|
||||
const e = new Date(end)
|
||||
const date = s.toLocaleDateString('en-CA', { weekday: 'short', month: 'short', day: 'numeric' })
|
||||
const startTime = s.toLocaleTimeString('en-CA', { hour: '2-digit', minute: '2-digit' })
|
||||
const endTime = e.toLocaleTimeString('en-CA', { hour: '2-digit', minute: '2-digit' })
|
||||
return `${date} · ${startTime}–${endTime}`
|
||||
}
|
||||
|
||||
function statusColor(status: ReservationStatus): string {
|
||||
const colors: Record<ReservationStatus, string> = {
|
||||
pending: 'warning',
|
||||
tentative: 'medium',
|
||||
confirmed: 'success',
|
||||
}
|
||||
return colors[status]
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.splash-content {
|
||||
--background: var(--ion-color-light);
|
||||
}
|
||||
|
||||
.splash-content { --background: var(--ion-color-light); }
|
||||
.splash-center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -52,14 +141,10 @@ definePageMeta({ layout: false })
|
||||
padding: 2rem;
|
||||
gap: 2rem;
|
||||
}
|
||||
.splash-logo { max-width: 280px; width: 100%; }
|
||||
.splash-btn { width: 100%; max-width: 280px; }
|
||||
|
||||
.splash-logo {
|
||||
max-width: 280px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.splash-btn {
|
||||
width: 100%;
|
||||
max-width: 280px;
|
||||
}
|
||||
.welcome { font-size: 1.25rem; font-weight: 600; margin: 0 0 1rem; }
|
||||
.empty-text { color: var(--ion-color-medium); margin: 0; }
|
||||
.calendar-content { display: flex; justify-content: center; padding: 0.5rem 0; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user