From aace5b2175eac69e6ec8aed57a53835096168448 Mon Sep 17 00:00:00 2001 From: Isaac Date: Mon, 23 Mar 2026 19:43:43 +0800 Subject: [PATCH 1/8] initial commit --- crypto.css | 96 ++++++++++++++++++++ index.html | 257 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 353 insertions(+) create mode 100644 crypto.css create mode 100644 index.html diff --git a/crypto.css b/crypto.css new file mode 100644 index 0000000..89df63d --- /dev/null +++ b/crypto.css @@ -0,0 +1,96 @@ +body { + margin: 0; +} + +.titleContainer { + display: flex; + justify-content: center; + align-items: flex-start; +} +.title { + font-size: 48px; + font-weight: 800; + width: 99%; + height: 100px; + background-color: beige; + + display: flex; + justify-content: center; + align-items: center; +} +.bar { + width: 100%; + background-color: beige; + display: flex; + justify-content: center; + align-items: center; + padding: 18px 0; + margin-bottom:25px; +} +#first { + font-size: 55px; + font-weight: 800; +} +#third { + font-size: 45px; + font-weight: 800; +} +#second { + font-size: 40px; + font-weight: 800; +} +#secondContainer { + display: flex; + justify-content: center; + align-items: center; +} +.formRow { + display:flex; + align-items: center; +} + +.formRow label { + width: 170px; + text-align: right; + margin-right: 12px; +} + +.formRow input { + width 280px; + height: 36px +} + +.btnRow { + display: flex; + justify-content: center; +} + +#userForm { + display: flex; + flex-direction: column; + gap: 18px; +} + +#table { + margin-top: 12px; + text-align: left; + width: 100%; + border-collapse: collapse; +} + +#table th { + border: 2px solid; + padding: 10px 12px; + text-align: left; +} + +.bar1 { + width: 100%; + background-color: beige; + display: flex; + justify-content: center; + align-items: center; + padding: 18px 0; + margin-bottom:25px; + font-size: 30px; +} \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 0000000..07e2308 --- /dev/null +++ b/index.html @@ -0,0 +1,257 @@ + + + + + + CPP + + + + + + + + +
+
Welcome to CPP
+
+ +
Add coins
+ +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+
+ + + + + + + + + + + + +
S.NoCoinTickerBuy_PriceBuy_QuantityCurrent_PriceProfitOptions
+ +
+
Your Total Profit is: $0.00
+
+ + + + + From 3e9c4a1c148f9c3e8c33b10b773dd1a505efabbe Mon Sep 17 00:00:00 2001 From: neoweijie88-design Date: Tue, 24 Mar 2026 20:16:19 +0800 Subject: [PATCH 2/8] added firebase.js --- src/firebase.js | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 src/firebase.js diff --git a/src/firebase.js b/src/firebase.js new file mode 100644 index 0000000..e69de29 From 165632a1f434bc469006b838eda84da459376acc Mon Sep 17 00:00:00 2001 From: neoweijie88-design Date: Tue, 24 Mar 2026 20:35:47 +0800 Subject: [PATCH 3/8] new branch --- src/App.vue | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/App.vue b/src/App.vue index 6ec9f60..f8afd82 100644 --- a/src/App.vue +++ b/src/App.vue @@ -5,7 +5,9 @@

Visit vuejs.org to read the documentation +

+

I'm working on a new branch

From 2dc6228d2568077170b2096b45dfa4bba1635320 Mon Sep 17 00:00:00 2001 From: Isaac Date: Wed, 25 Mar 2026 16:40:04 +0800 Subject: [PATCH 4/8] remove old crypto and index files --- crypto.css | 96 ----------------- "feature\\weather" | 0 index.html | 257 --------------------------------------------- 3 files changed, 353 deletions(-) delete mode 100644 crypto.css create mode 100644 "feature\\weather" delete mode 100644 index.html diff --git a/crypto.css b/crypto.css deleted file mode 100644 index 89df63d..0000000 --- a/crypto.css +++ /dev/null @@ -1,96 +0,0 @@ -body { - margin: 0; -} - -.titleContainer { - display: flex; - justify-content: center; - align-items: flex-start; -} -.title { - font-size: 48px; - font-weight: 800; - width: 99%; - height: 100px; - background-color: beige; - - display: flex; - justify-content: center; - align-items: center; -} -.bar { - width: 100%; - background-color: beige; - display: flex; - justify-content: center; - align-items: center; - padding: 18px 0; - margin-bottom:25px; -} -#first { - font-size: 55px; - font-weight: 800; -} -#third { - font-size: 45px; - font-weight: 800; -} -#second { - font-size: 40px; - font-weight: 800; -} -#secondContainer { - display: flex; - justify-content: center; - align-items: center; -} -.formRow { - display:flex; - align-items: center; -} - -.formRow label { - width: 170px; - text-align: right; - margin-right: 12px; -} - -.formRow input { - width 280px; - height: 36px -} - -.btnRow { - display: flex; - justify-content: center; -} - -#userForm { - display: flex; - flex-direction: column; - gap: 18px; -} - -#table { - margin-top: 12px; - text-align: left; - width: 100%; - border-collapse: collapse; -} - -#table th { - border: 2px solid; - padding: 10px 12px; - text-align: left; -} - -.bar1 { - width: 100%; - background-color: beige; - display: flex; - justify-content: center; - align-items: center; - padding: 18px 0; - margin-bottom:25px; - font-size: 30px; -} \ No newline at end of file diff --git "a/feature\\weather" "b/feature\\weather" new file mode 100644 index 0000000..e69de29 diff --git a/index.html b/index.html deleted file mode 100644 index 07e2308..0000000 --- a/index.html +++ /dev/null @@ -1,257 +0,0 @@ - - - - - - CPP - - - - - - - - -
-
Welcome to CPP
-
- -
Add coins
- -
-
-
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- -
-
-
- - - - - - - - - - - - -
S.NoCoinTickerBuy_PriceBuy_QuantityCurrent_PriceProfitOptions
- -
-
Your Total Profit is: $0.00
-
- - - - - From 0727a239b299de981ef3723fcfb37b265eca19f9 Mon Sep 17 00:00:00 2001 From: Isaac Date: Fri, 3 Apr 2026 22:52:05 +0800 Subject: [PATCH 5/8] save current weather work --- weather.css | 0 weather.html | 0 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 weather.css create mode 100644 weather.html diff --git a/weather.css b/weather.css new file mode 100644 index 0000000..e69de29 diff --git a/weather.html b/weather.html new file mode 100644 index 0000000..e69de29 From 008b4578245c7a4f022c25b36411b7d9d907e459 Mon Sep 17 00:00:00 2001 From: Isaac Date: Sat, 4 Apr 2026 00:28:43 +0800 Subject: [PATCH 6/8] Update my branch --- src/components/tabs/WeatherTab.vue | 1054 ++++++++++++++++++++++-- src/components/weather/WeatherIcon.vue | 84 ++ src/services/weatherService.js | 391 +++++++++ src/stores/weather.js | 101 +++ 4 files changed, 1581 insertions(+), 49 deletions(-) create mode 100644 src/components/weather/WeatherIcon.vue create mode 100644 src/services/weatherService.js create mode 100644 src/stores/weather.js diff --git a/src/components/tabs/WeatherTab.vue b/src/components/tabs/WeatherTab.vue index a6a08ee..6987e8c 100644 --- a/src/components/tabs/WeatherTab.vue +++ b/src/components/tabs/WeatherTab.vue @@ -1,92 +1,1048 @@ + \ No newline at end of file diff --git a/src/components/weather/WeatherIcon.vue b/src/components/weather/WeatherIcon.vue new file mode 100644 index 0000000..7368edf --- /dev/null +++ b/src/components/weather/WeatherIcon.vue @@ -0,0 +1,84 @@ + + + + + \ No newline at end of file diff --git a/src/services/weatherService.js b/src/services/weatherService.js new file mode 100644 index 0000000..f2da862 --- /dev/null +++ b/src/services/weatherService.js @@ -0,0 +1,391 @@ +const SINGAPORE = { + latitude: 1.3521, + longitude: 103.8198, + timezone: 'Asia/Singapore' +} + +const FALLBACK_ALTERNATIVES = [ + { + name: 'Asian Civilisations Museum', + address: '1 Empress Pl, Singapore 179555', + category: 'History', + googlePlaceId: 'mock-acm', + placeId: 'mock-acm', + theme: 'museum', + location: { lat: null, lng: null }, + imageUrl: null + }, + { + name: 'SEA Aquarium', + address: '8 Sentosa Gateway, Singapore 098269', + category: 'Attraction', + googlePlaceId: 'mock-sea-aquarium', + placeId: 'mock-sea-aquarium', + theme: 'ocean', + location: { lat: null, lng: null }, + imageUrl: null + } +] + +function toJsDate(value) { + if (!value) return null + if (value instanceof Date) return value + if (typeof value?.toDate === 'function') return value.toDate() + + const parsed = new Date(value) + return Number.isNaN(parsed.getTime()) ? null : parsed +} + +function startOfDay(date) { + const copy = new Date(date) + copy.setHours(0, 0, 0, 0) + return copy +} + +function formatDateKey(date) { + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` +} + +function formatISODate(date) { + return formatDateKey(date) +} + +function addDays(date, days) { + const copy = new Date(date) + copy.setDate(copy.getDate() + days) + return copy +} + +function buildTripDates(start, end) { + const dates = [] + const current = startOfDay(start) + const last = startOfDay(end) + + while (current <= last) { + dates.push(new Date(current)) + current.setDate(current.getDate() + 1) + } + + return dates +} + +function buildErrorMessage(response, payload) { + if (payload?.reason) return payload.reason + return `HTTP ${response.status}` +} + +function weatherCodeToIconName(code) { + if (code === 0) return 'sunny' + if ([1, 2].includes(code)) return 'partly-cloudy' + if ([3, 45, 48].includes(code)) return 'cloudy' + + const rainCodes = [ + 51, 53, 55, 56, 57, + 61, 63, 65, 66, 67, + 80, 81, 82, + 95, 96, 99 + ] + + if (rainCodes.includes(code)) return 'rain' + return 'cloudy' +} + +function average(values) { + if (!values.length) return 0 + return values.reduce((sum, value) => sum + value, 0) / values.length +} + +function max(values) { + if (!values.length) return 0 + return Math.max(...values) +} + +function sum(values) { + if (!values.length) return 0 + return values.reduce((total, value) => total + value, 0) +} + +function round1(value) { + return Math.round((value + Number.EPSILON) * 10) / 10 +} + +function labelFromHour(hour24) { + if (hour24 === 0) return '12AM' + if (hour24 < 12) return `${hour24}AM` + if (hour24 === 12) return '12PM' + return `${hour24 - 12}PM` +} + +function safeIndex(array, index, fallback = null) { + return Array.isArray(array) && index >= 0 && index < array.length + ? array[index] + : fallback +} + +function ensureForecastWindow(start, end) { + const today = startOfDay(new Date()) + const maxForecastDate = addDays(today, 15) + + if (start < today) { + throw new Error( + 'Live weather only supports today onwards. Change the trip dates to today or later.' + ) + } + + if (end > maxForecastDate) { + throw new Error( + 'Live weather forecast is only available up to 16 days ahead. Set the trip within the next 16 days.' + ) + } +} + +async function fetchJson(url) { + const response = await fetch(url) + + let payload = null + try { + payload = await response.json() + } catch { + payload = null + } + + if (!response.ok) { + throw new Error(buildErrorMessage(response, payload)) + } + + return payload +} + +function buildHourlyBuckets(weatherPayload, uvPayload) { + const buckets = new Map() + + const weatherHourly = weatherPayload.hourly || {} + const uvHourly = uvPayload?.hourly || {} + + const weatherTimes = weatherHourly.time || [] + const uvTimes = uvHourly.time || [] + + const uvByTime = new Map() + uvTimes.forEach((time, index) => { + uvByTime.set(time, safeIndex(uvHourly.uv_index, index, null)) + }) + + weatherTimes.forEach((time, index) => { + const [dateKey, hourPart] = time.split('T') + const hour24 = Number(hourPart?.split(':')[0] ?? 0) + + if (!buckets.has(dateKey)) { + buckets.set(dateKey, []) + } + + buckets.get(dateKey).push({ + time, + dateKey, + hour24, + label: labelFromHour(hour24), + temperature: safeIndex(weatherHourly.temperature_2m, index, null), + apparentTemperature: safeIndex(weatherHourly.apparent_temperature, index, null), + humidity: safeIndex(weatherHourly.relative_humidity_2m, index, null), + precipitationProbability: safeIndex( + weatherHourly.precipitation_probability, + index, + null + ), + precipitation: safeIndex(weatherHourly.precipitation, index, null), + weatherCode: safeIndex(weatherHourly.weather_code, index, null), + uv: uvByTime.get(time) ?? null + }) + }) + + return buckets +} + +function sliceDisplayHours(dayHourly, fallbackUvValue) { + const targetHours = [12, 13, 14, 15, 16, 17, 18] + + const filtered = targetHours + .map((hour) => { + const match = dayHourly.find((point) => point.hour24 === hour) + if (!match) return null + + return { + label: match.label, + temperature: match.temperature ?? 0, + precipitation: match.precipitation ?? 0, + uv: match.uv ?? fallbackUvValue ?? 0, + humidity: match.humidity ?? 0, + precipitationProbability: match.precipitationProbability ?? 0, + weatherCode: match.weatherCode ?? 3 + } + }) + .filter(Boolean) + + if (filtered.length) return filtered + + return dayHourly.slice(0, 7).map((point) => ({ + label: point.label, + temperature: point.temperature ?? 0, + precipitation: point.precipitation ?? 0, + uv: point.uv ?? fallbackUvValue ?? 0, + humidity: point.humidity ?? 0, + precipitationProbability: point.precipitationProbability ?? 0, + weatherCode: point.weatherCode ?? 3 + })) +} + +export function isWetDay(day) { + return (day?.rainChance || 0) >= 50 || (day?.precipitation || 0) >= 1 +} + +export async function buildTripWeatherForecast(trip) { + const start = toJsDate(trip?.startDate) + const end = toJsDate(trip?.endDate) + + if (!start || !end || end < start) { + throw new Error('Trip dates are missing or invalid.') + } + + const startDate = startOfDay(start) + const endDate = startOfDay(end) + + ensureForecastWindow(startDate, endDate) + + const startDateStr = formatISODate(startDate) + const endDateStr = formatISODate(endDate) + + const weatherUrl = new URL('https://api.open-meteo.com/v1/forecast') + weatherUrl.searchParams.set('latitude', String(SINGAPORE.latitude)) + weatherUrl.searchParams.set('longitude', String(SINGAPORE.longitude)) + weatherUrl.searchParams.set('timezone', SINGAPORE.timezone) + weatherUrl.searchParams.set( + 'current', + [ + 'temperature_2m', + 'apparent_temperature', + 'relative_humidity_2m', + 'precipitation', + 'weather_code' + ].join(',') + ) + weatherUrl.searchParams.set( + 'hourly', + [ + 'temperature_2m', + 'apparent_temperature', + 'relative_humidity_2m', + 'precipitation_probability', + 'precipitation', + 'weather_code' + ].join(',') + ) + weatherUrl.searchParams.set( + 'daily', + [ + 'weather_code', + 'temperature_2m_max', + 'temperature_2m_min', + 'precipitation_probability_max', + 'uv_index_max' + ].join(',') + ) + weatherUrl.searchParams.set('start_date', startDateStr) + weatherUrl.searchParams.set('end_date', endDateStr) + + const today = startOfDay(new Date()) + const uvMaxEnd = addDays(today, 5) + const uvHasOverlap = startDate <= uvMaxEnd + + let uvUrl = null + if (uvHasOverlap) { + const uvStart = startDate < today ? today : startDate + const uvEnd = endDate < uvMaxEnd ? endDate : uvMaxEnd + + uvUrl = new URL('https://air-quality-api.open-meteo.com/v1/air-quality') + uvUrl.searchParams.set('latitude', String(SINGAPORE.latitude)) + uvUrl.searchParams.set('longitude', String(SINGAPORE.longitude)) + uvUrl.searchParams.set('timezone', SINGAPORE.timezone) + uvUrl.searchParams.set('hourly', 'uv_index') + uvUrl.searchParams.set('current', 'uv_index') + uvUrl.searchParams.set('start_date', formatISODate(uvStart)) + uvUrl.searchParams.set('end_date', formatISODate(uvEnd)) + } + + const [weatherPayload, uvPayload] = await Promise.all([ + fetchJson(weatherUrl.toString()), + uvUrl ? fetchJson(uvUrl.toString()) : Promise.resolve(null) + ]) + + const hourlyBuckets = buildHourlyBuckets(weatherPayload, uvPayload) + const tripDates = buildTripDates(startDate, endDate) + const daily = weatherPayload.daily || {} + const dailyTimes = daily.time || [] + + const dailyIndexByDate = new Map() + dailyTimes.forEach((dateKey, index) => { + dailyIndexByDate.set(dateKey, index) + }) + + return tripDates.map((date, index) => { + const dateKey = formatDateKey(date) + const dailyIndex = dailyIndexByDate.get(dateKey) + + if (dailyIndex === undefined) { + throw new Error( + `No live forecast returned for ${dateKey}. Keep the trip within the provider forecast window.` + ) + } + + const dayHourlyRaw = hourlyBuckets.get(dateKey) || [] + const fallbackUvValue = safeIndex(daily.uv_index_max, dailyIndex, 0) + const hourly = sliceDisplayHours(dayHourlyRaw, fallbackUvValue) + + const currentTemp = + hourly[0]?.temperature ?? safeIndex(daily.temperature_2m_max, dailyIndex, 0) + const feelsLike = round1(average(hourly.map((point) => point.temperature))) + const humidity = Math.round(average(hourly.map((point) => point.humidity))) + const rainChance = Math.round( + max([ + ...hourly.map((point) => point.precipitationProbability), + safeIndex(daily.precipitation_probability_max, dailyIndex, 0) + ]) + ) + const precipitation = round1(sum(hourly.map((point) => point.precipitation))) + const uv = round1(max(hourly.map((point) => point.uv))) + const weatherCode = safeIndex( + daily.weather_code, + dailyIndex, + hourly[0]?.weatherCode ?? 3 + ) + + return { + id: `${dateKey}-${index}`, + date, + dateKey, + weekdayInitial: date.toLocaleDateString('en-SG', { weekday: 'narrow' }), + weekdayShort: date.toLocaleDateString('en-SG', { weekday: 'short' }), + dayNumber: date.getDate(), + monthShort: date.toLocaleDateString('en-SG', { month: 'short' }), + monthLong: date.toLocaleDateString('en-SG', { month: 'long' }), + year: date.getFullYear(), + displayLabel: `${date.getDate()} ${date.toLocaleDateString('en-SG', { month: 'short' })}`, + condition: weatherCodeToIconName(weatherCode), + iconName: weatherCodeToIconName(weatherCode), + currentTemp: round1(currentTemp), + feelsLike, + humidity: Number.isFinite(humidity) ? humidity : 0, + rainChance, + precipitation, + uv, + hourly, + fallbackAlternatives: FALLBACK_ALTERNATIVES + } + }) +} + +// Keeps your current store import working without changing weather.js +export async function buildMockWeatherForecast(trip) { + return buildTripWeatherForecast(trip) +} \ No newline at end of file diff --git a/src/stores/weather.js b/src/stores/weather.js new file mode 100644 index 0000000..1d97088 --- /dev/null +++ b/src/stores/weather.js @@ -0,0 +1,101 @@ +import { computed, ref } from 'vue' +import { defineStore } from 'pinia' +import { buildMockWeatherForecast } from '@/services/weatherService' + +export const useWeatherStore = defineStore('weather', () => { + const forecastDays = ref([]) + const selectedDayIndex = ref(0) + const selectedMetric = ref('temperature') + const queuedReplacements = ref({}) + const loading = ref(false) + const error = ref(null) + + const metricOptions = [ + { value: 'temperature', label: 'Temperature' }, + { value: 'precipitation', label: 'Precipitation' }, + { value: 'uv', label: 'UV Index' } + ] + + const selectedDay = computed(() => { + return forecastDays.value[selectedDayIndex.value] || null + }) + + async function loadTripWeather(trip) { + loading.value = true + error.value = null + + try { + forecastDays.value = await buildMockWeatherForecast(trip) + selectedDayIndex.value = 0 + selectedMetric.value = 'temperature' + queuedReplacements.value = {} + } catch (err) { + console.error('Weather preview error:', err) + error.value = err?.message || 'Failed to prepare weather preview.' + } finally { + loading.value = false + } + } + + function setSelectedDay(index) { + if (index < 0 || index >= forecastDays.value.length) return + selectedDayIndex.value = index + } + + function nextDay() { + if (selectedDayIndex.value < forecastDays.value.length - 1) { + selectedDayIndex.value += 1 + } + } + + function prevDay() { + if (selectedDayIndex.value > 0) { + selectedDayIndex.value -= 1 + } + } + + function setMetric(metric) { + selectedMetric.value = metric + } + + function queueReplacement(dateKey, suggestion) { + queuedReplacements.value = { + ...queuedReplacements.value, + [dateKey]: suggestion + } + } + + function clearQueuedReplacement(dateKey) { + const copy = { ...queuedReplacements.value } + delete copy[dateKey] + queuedReplacements.value = copy + } + + function reset() { + forecastDays.value = [] + selectedDayIndex.value = 0 + selectedMetric.value = 'temperature' + queuedReplacements.value = {} + loading.value = false + error.value = null + } + + return { + forecastDays, + selectedDayIndex, + selectedMetric, + queuedReplacements, + metricOptions, + loading, + error, + selectedDay, + loadTripWeather, + setSelectedDay, + nextDay, + prevDay, + setMetric, + queueReplacement, + clearQueuedReplacement, + reset + } +}) \ No newline at end of file From c0e537d8175df0d7d14de8c019ac0c14e9f3da90 Mon Sep 17 00:00:00 2001 From: Isaac Date: Tue, 14 Apr 2026 01:59:45 +0800 Subject: [PATCH 7/8] Implement WeatherAPI weather recommendations and backup flow --- src/components/tabs/WeatherTab.vue | 1089 +++++++++++++++++++--------- src/services/weatherService.js | 580 ++++++++------- src/stores/poi.js | 60 +- src/stores/weather.js | 37 +- 4 files changed, 1111 insertions(+), 655 deletions(-) diff --git a/src/components/tabs/WeatherTab.vue b/src/components/tabs/WeatherTab.vue index 6987e8c..b90d7b7 100644 --- a/src/components/tabs/WeatherTab.vue +++ b/src/components/tabs/WeatherTab.vue @@ -1,5 +1,8 @@ @@ -362,7 +725,7 @@ function clearQueuedReplacement() {
-

Hourly Weather

+

Weather overview

@@ -483,7 +859,7 @@ function clearQueuedReplacement() {
@@ -681,30 +1077,52 @@ function clearQueuedReplacement() { } .day-chip-weekday { - font-size: 11px; + font-size: 12px; font-weight: 700; - text-transform: uppercase; } .day-chip-date { font-size: 18px; font-weight: 800; + line-height: 1; } .summary-row { - display: grid; - grid-template-columns: minmax(0, 1fr) 220px; - gap: 20px; - align-items: end; - padding: 18px 0 16px; - border-top: 1px solid #eceff3; - border-bottom: 1px solid #eceff3; + display: flex; + justify-content: space-between; + gap: 16px; + align-items: flex-start; + margin-bottom: 18px; +} + +.summary-main { + display: flex; + flex-direction: column; + gap: 12px; +} + +.summary-topline { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; } .summary-date { - font-size: 15px; - color: #7d8794; - margin-bottom: 6px; + font-size: 16px; + font-weight: 700; + color: var(--text-muted); +} + +.source-pill { + display: inline-flex; + align-items: center; + padding: 6px 10px; + border-radius: 999px; + background: #fff3e9; + color: #c96e2b; + font-size: 12px; + font-weight: 700; } .summary-temp-row { @@ -714,13 +1132,14 @@ function clearQueuedReplacement() { } .summary-temp { - font-size: 46px; + font-size: 44px; line-height: 1; font-weight: 800; color: var(--text); } .metric-box { + min-width: 160px; display: flex; flex-direction: column; gap: 8px; @@ -730,177 +1149,161 @@ function clearQueuedReplacement() { font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; - color: #8c95a1; font-weight: 700; + color: var(--text-muted); } .metric-select { - width: 100%; - padding: 11px 14px; - border-radius: 999px; border: 1px solid #d9dee5; + border-radius: 12px; + padding: 10px 12px; background: #fff; - color: #4b5563; - font-size: 14px; - font-weight: 700; + color: var(--text); + font-weight: 600; } .detail-pill-row { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); + display: flex; + flex-wrap: wrap; gap: 12px; - margin: 16px 0 18px; + margin-bottom: 18px; } .detail-pill { - min-height: 86px; - border-radius: 14px; - background: #f7f8fb; - border: 1px solid #edf0f5; + flex: 1 1 150px; + border-radius: 16px; padding: 14px 16px; - display: flex; - flex-direction: column; - justify-content: center; - gap: 6px; + background: #f7f9fc; + border: 1px solid #e8edf4; } .detail-label { + display: block; font-size: 12px; - color: #8c95a1; font-weight: 700; + color: var(--text-muted); + margin-bottom: 6px; + text-transform: uppercase; + letter-spacing: 0.04em; } .detail-value { - font-size: 17px; + font-size: 18px; color: var(--text); } .chart-card { + background: #fbfcfe; border-radius: 18px; - background: linear-gradient(180deg, #fffaf4 0%, #f9fcff 100%); - border: 1px solid #edf0f5; - overflow: hidden; - min-height: 398px; + border: 1px solid #eef2f6; + padding: 18px; } .chart-svg-wrap { - padding: 12px 12px 0; + width: 100%; + height: 220px; } .weather-chart { width: 100%; - height: 260px; - display: block; + height: 100%; } .baseline { - stroke: #e7ebf0; - stroke-width: 1.1; + stroke: #dde5ef; + stroke-width: 1; } .chart-line { fill: none; - stroke-width: 3.2; + stroke-width: 3; stroke-linecap: round; stroke-linejoin: round; } -.focus-ring { - fill: #fff; - stroke: #c7dff3; - stroke-width: 2.5; -} - -.focus-dot { - fill: #8fc7f1; -} - .chart-axis { display: grid; - grid-template-columns: repeat(7, minmax(0, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(48px, 1fr)); gap: 8px; - padding: 0 22px 12px; + margin-top: 8px; } .axis-label { text-align: center; - color: #9ba4af; font-size: 12px; font-weight: 700; + color: var(--text-muted); } .chart-caption { - padding: 0 22px 18px; - color: #9ba4af; - font-size: 12px; + margin-top: 12px; + font-size: 13px; + color: var(--text-muted); font-weight: 700; } .overview-header { display: flex; - align-items: baseline; justify-content: space-between; - gap: 12px; - margin-bottom: 12px; + align-items: center; + margin-bottom: 16px; } .overview-month { - color: #8f98a6; font-size: 13px; font-weight: 700; + color: var(--text-muted); } .calendar-head { display: grid; - grid-template-columns: repeat(7, minmax(0, 1fr)); - gap: 6px; - margin-bottom: 6px; + grid-template-columns: repeat(7, 1fr); + gap: 8px; + margin-bottom: 10px; } .calendar-head-cell { text-align: center; - font-size: 11px; + font-size: 12px; font-weight: 700; - color: #a4acb7; - padding: 4px 0; + color: var(--text-muted); } .calendar-grid { display: grid; - grid-template-columns: repeat(7, minmax(0, 1fr)); - gap: 6px; + grid-template-columns: repeat(7, 1fr); + gap: 8px; } .calendar-cell { min-height: 58px; - border-radius: 10px; - border: 1px solid #edf0f5; - background: #fff; - padding: 6px 6px 4px; + border-radius: 14px; + background: #f8fafc; + border: 1px solid transparent; display: flex; flex-direction: column; + justify-content: space-between; align-items: center; - gap: 4px; + padding: 8px 6px; } .calendar-cell.muted { - background: #f9fafc; - color: #c0c6cf; + opacity: 0.4; } .calendar-cell.trip { - background: #fffaf3; + border-color: #e4ebf4; } .calendar-cell.selected { - border-color: #f3a05a; - box-shadow: 0 0 0 2px rgba(242, 154, 74, 0.14); + background: #edf2fb; + border-color: #c8d6ea; } .calendar-date { font-size: 12px; font-weight: 700; - color: #7c8794; + color: var(--text); } .suggestion-header { @@ -910,139 +1313,169 @@ function clearQueuedReplacement() { .suggestion-subtitle { margin-top: 6px; font-size: 13px; - color: #8f98a6; + color: var(--text-muted); + line-height: 1.45; } -.queued-banner { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - background: #fff7ef; - border: 1px solid #f3d1b2; - color: #7a542e; - border-radius: 12px; - padding: 10px 12px; +.status-message { margin-bottom: 12px; font-size: 13px; -} - -.queued-clear { - border: none; - background: transparent; - color: var(--accent); + color: #5f6f82; font-weight: 700; } -.status-message { - margin-bottom: 12px; +.helper-text { font-size: 13px; - color: #5f6b78; + color: var(--text-muted); + line-height: 1.5; +} + +.error-text { + color: #b33434; } .suggestions-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; + align-items: stretch; } .suggestion-card { + border: 1px solid #e8edf4; border-radius: 18px; overflow: hidden; background: #fff; - border: 1px solid #edf0f5; - box-shadow: 0 8px 20px rgba(60, 30, 10, 0.08); + display: flex; + flex-direction: column; + height: 100%; } .suggestion-media { - height: 138px; position: relative; + height: 190px; + background: linear-gradient(135deg, #f7d5b1, #f1a75d); + overflow: hidden; + flex-shrink: 0; } .suggestion-media.museum { - background: linear-gradient(135deg, #68b7ff 0%, #2f86df 100%); + background: linear-gradient(135deg, #f7d5b1, #f1a75d); } .suggestion-media.ocean { - background: linear-gradient(135deg, #1165d7 0%, #082d72 100%); + background: linear-gradient(135deg, #bee7ff, #56a5e5); } .suggestion-media.science { - background: linear-gradient(135deg, #68c8c2 0%, #2b8e88 100%); + background: linear-gradient(135deg, #d7cbff, #8b71ff); } .suggestion-media.lifestyle { - background: linear-gradient(135deg, #9a8cff 0%, #6656d6 100%); + background: linear-gradient(135deg, #f9dfd4, #ef9a83); +} + +.suggestion-image { + width: 100%; + height: 100%; + object-fit: cover; } -.suggestion-media-label { +.suggestion-badge { position: absolute; - left: 12px; - bottom: 12px; - display: inline-flex; - padding: 5px 10px; + top: 12px; + right: 12px; + padding: 8px 12px; border-radius: 999px; background: rgba(255, 255, 255, 0.92); - color: #243140; - font-size: 11px; + font-size: 12px; font-weight: 800; - text-transform: uppercase; - letter-spacing: 0.04em; + color: #4d5b6b; } .suggestion-body { - padding: 14px 14px 16px; + padding: 16px; + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; +} + +.suggestion-copy { + flex: 1; } .suggestion-title { font-size: 16px; font-weight: 800; - line-height: 1.25; color: var(--text); - margin-bottom: 8px; + margin-bottom: 10px; + line-height: 1.35; + min-height: 44px; } .suggestion-meta { - font-size: 12px; - color: #5f6b78; + font-size: 13px; + color: var(--text-muted); + margin-bottom: 8px; line-height: 1.45; - margin-bottom: 6px; + min-height: 18px; +} + +.card-actions { + margin-top: auto; + display: flex; + flex-direction: column; + gap: 8px; } -.replace-btn { +.primary-btn, +.secondary-btn { width: 100%; - margin-top: 10px; border: none; - border-radius: 999px; + border-radius: 12px; + padding: 11px 14px; + font-weight: 800; + cursor: pointer; +} + +.primary-btn { background: var(--accent); color: #fff; - padding: 10px 14px; - font-size: 13px; - font-weight: 800; } -.replace-btn:disabled { - opacity: 0.6; - cursor: not-allowed; +.secondary-btn { + background: #f7f1ea; + color: #8f6339; } -@media (max-width: 1024px) { +@media (max-width: 1100px) { .weather-grid { grid-template-columns: 1fr; } + + .weather-side { + order: 2; + } +} + +@media (max-width: 820px) { + .suggestions-grid { + grid-template-columns: 1fr; + } } -@media (max-width: 768px) { +@media (max-width: 720px) { .summary-row { - grid-template-columns: 1fr; + flex-direction: column; } - .detail-pill-row { - grid-template-columns: 1fr; + .metric-box { + width: 100%; } - .suggestions-grid { - grid-template-columns: 1fr; + .detail-pill-row { + flex-direction: column; } } \ No newline at end of file diff --git a/src/services/weatherService.js b/src/services/weatherService.js index f2da862..dd39b15 100644 --- a/src/services/weatherService.js +++ b/src/services/weatherService.js @@ -1,31 +1,17 @@ -const SINGAPORE = { - latitude: 1.3521, - longitude: 103.8198, - timezone: 'Asia/Singapore' -} +const API_KEY = import.meta.env.VITE_WEATHERAPI_KEY +const BASE_URL = 'https://api.weatherapi.com/v1' +const MAX_FORECAST_DAYS = 14 +const MAX_FUTURE_DAYS = 300 + +const futureCache = new Map() -const FALLBACK_ALTERNATIVES = [ - { - name: 'Asian Civilisations Museum', - address: '1 Empress Pl, Singapore 179555', - category: 'History', - googlePlaceId: 'mock-acm', - placeId: 'mock-acm', - theme: 'museum', - location: { lat: null, lng: null }, - imageUrl: null - }, - { - name: 'SEA Aquarium', - address: '8 Sentosa Gateway, Singapore 098269', - category: 'Attraction', - googlePlaceId: 'mock-sea-aquarium', - placeId: 'mock-sea-aquarium', - theme: 'ocean', - location: { lat: null, lng: null }, - imageUrl: null +function ensureApiKey() { + if (!API_KEY) { + throw new Error( + 'Missing VITE_WEATHERAPI_KEY. Add it to your .env file and restart the dev server.' + ) } -] +} function toJsDate(value) { if (!value) return null @@ -42,6 +28,12 @@ function startOfDay(date) { return copy } +function addDays(date, days) { + const copy = new Date(date) + copy.setDate(copy.getDate() + days) + return copy +} + function formatDateKey(date) { const year = date.getFullYear() const month = String(date.getMonth() + 1).padStart(2, '0') @@ -49,16 +41,6 @@ function formatDateKey(date) { return `${year}-${month}-${day}` } -function formatISODate(date) { - return formatDateKey(date) -} - -function addDays(date, days) { - const copy = new Date(date) - copy.setDate(copy.getDate() + days) - return copy -} - function buildTripDates(start, end) { const dates = [] const current = startOfDay(start) @@ -72,46 +54,31 @@ function buildTripDates(start, end) { return dates } -function buildErrorMessage(response, payload) { - if (payload?.reason) return payload.reason - return `HTTP ${response.status}` +function daysBetween(start, end) { + const msPerDay = 24 * 60 * 60 * 1000 + return Math.floor((startOfDay(end) - startOfDay(start)) / msPerDay) } -function weatherCodeToIconName(code) { - if (code === 0) return 'sunny' - if ([1, 2].includes(code)) return 'partly-cloudy' - if ([3, 45, 48].includes(code)) return 'cloudy' - - const rainCodes = [ - 51, 53, 55, 56, 57, - 61, 63, 65, 66, 67, - 80, 81, 82, - 95, 96, 99 - ] +function toNumberOrNull(value) { + const num = Number(value) + return Number.isFinite(num) ? num : null +} - if (rainCodes.includes(code)) return 'rain' - return 'cloudy' +function round1(value) { + if (!Number.isFinite(value)) return null + return Math.round((value + Number.EPSILON) * 10) / 10 } function average(values) { - if (!values.length) return 0 + if (!values.length) return null return values.reduce((sum, value) => sum + value, 0) / values.length } function max(values) { - if (!values.length) return 0 + if (!values.length) return null return Math.max(...values) } -function sum(values) { - if (!values.length) return 0 - return values.reduce((total, value) => total + value, 0) -} - -function round1(value) { - return Math.round((value + Number.EPSILON) * 10) / 10 -} - function labelFromHour(hour24) { if (hour24 === 0) return '12AM' if (hour24 < 12) return `${hour24}AM` @@ -119,31 +86,63 @@ function labelFromHour(hour24) { return `${hour24 - 12}PM` } -function safeIndex(array, index, fallback = null) { - return Array.isArray(array) && index >= 0 && index < array.length - ? array[index] - : fallback +function extractHour24(timeString) { + if (!timeString) return 0 + const hourPart = timeString.includes(' ') + ? timeString.split(' ')[1] + : timeString.split('T')[1] + + return Number(hourPart?.split(':')[0] ?? 0) } -function ensureForecastWindow(start, end) { - const today = startOfDay(new Date()) - const maxForecastDate = addDays(today, 15) +function conditionToIconName(conditionText, isDay = true) { + const text = String(conditionText || '').toLowerCase() + + if ( + text.includes('rain') || + text.includes('drizzle') || + text.includes('thunder') || + text.includes('snow') || + text.includes('sleet') || + text.includes('ice') || + text.includes('blizzard') + ) { + return 'rain' + } - if (start < today) { - throw new Error( - 'Live weather only supports today onwards. Change the trip dates to today or later.' - ) + if (text.includes('partly cloudy')) { + return 'partly-cloudy' } - if (end > maxForecastDate) { - throw new Error( - 'Live weather forecast is only available up to 16 days ahead. Set the trip within the next 16 days.' - ) + if ( + text.includes('cloud') || + text.includes('overcast') || + text.includes('mist') || + text.includes('fog') + ) { + return 'cloudy' + } + + if (text.includes('sunny') || text.includes('clear')) { + return isDay ? 'sunny' : 'cloudy' } + + return isDay ? 'partly-cloudy' : 'cloudy' } -async function fetchJson(url) { - const response = await fetch(url) +async function fetchWeatherApi(endpoint, params) { + ensureApiKey() + + const url = new URL(`${BASE_URL}/${endpoint}.json`) + url.searchParams.set('key', API_KEY) + + Object.entries(params).forEach(([key, value]) => { + if (value !== null && value !== undefined && value !== '') { + url.searchParams.set(key, String(value)) + } + }) + + const response = await fetch(url.toString()) let payload = null try { @@ -152,97 +151,213 @@ async function fetchJson(url) { payload = null } - if (!response.ok) { - throw new Error(buildErrorMessage(response, payload)) + if (!response.ok || payload?.error) { + throw new Error( + payload?.error?.message || `WeatherAPI ${endpoint} request failed.` + ) } return payload } -function buildHourlyBuckets(weatherPayload, uvPayload) { - const buckets = new Map() +function pickDisplayPoints(hours, maxPoints = 7) { + if (!hours.length) return [] + + const sorted = [...hours].sort((a, b) => a.hour24 - b.hour24) + if (sorted.length <= maxPoints) return sorted + + const selectedIndexes = [] + for (let i = 0; i < maxPoints; i += 1) { + selectedIndexes.push(Math.round((i * (sorted.length - 1)) / (maxPoints - 1))) + } + + const uniqueIndexes = [...new Set(selectedIndexes)] + return uniqueIndexes.map((index) => sorted[index]) +} + +function normaliseHour(hour) { + const hour24 = extractHour24(hour.time) + + return { + label: labelFromHour(hour24), + hour24, + temperature: round1(toNumberOrNull(hour.temp_c)), + apparentTemperature: round1(toNumberOrNull(hour.feelslike_c)), + precipitation: round1(toNumberOrNull(hour.precip_mm)) ?? 0, + precipitationProbability: toNumberOrNull(hour.chance_of_rain) ?? toNumberOrNull(hour.chance_of_snow), + humidity: toNumberOrNull(hour.humidity), + weatherCode: hour.condition?.code ?? null, + uv: round1(toNumberOrNull(hour.uv)) + } +} + +function normaliseForecastDay(forecastDay, options = {}) { + const { + sourceLabel = 'Forecast', + current = null, + useCurrent = false + } = options + + const date = new Date(forecastDay.date) + const dateKey = formatDateKey(date) + const day = forecastDay.day || {} + const hours = Array.isArray(forecastDay.hour) + ? forecastDay.hour.map(normaliseHour) + : [] + + const displayHours = pickDisplayPoints(hours, 7) + + const hourlyRainChance = displayHours + .map((point) => point.precipitationProbability) + .filter(Number.isFinite) + + const hourlyUv = displayHours + .map((point) => point.uv) + .filter(Number.isFinite) + + const hourlyHumidity = displayHours + .map((point) => point.humidity) + .filter(Number.isFinite) + + const conditionText = useCurrent + ? current?.condition?.text || day.condition?.text || '' + : day.condition?.text || '' + + const iconName = conditionToIconName( + conditionText, + useCurrent ? current?.is_day === 1 : true + ) + + const dayAvgTemp = toNumberOrNull(day.avgtemp_c) + const dayMaxTemp = toNumberOrNull(day.maxtemp_c) + const currentTemp = round1( + useCurrent && Number.isFinite(toNumberOrNull(current?.temp_c)) + ? toNumberOrNull(current.temp_c) + : dayAvgTemp ?? dayMaxTemp ?? displayHours[0]?.temperature + ) + + const feelsLike = round1( + useCurrent && Number.isFinite(toNumberOrNull(current?.feelslike_c)) + ? toNumberOrNull(current.feelslike_c) + : average(displayHours.map((point) => point.apparentTemperature).filter(Number.isFinite)) ?? + dayAvgTemp ?? + currentTemp + ) + + const humidity = useCurrent && Number.isFinite(toNumberOrNull(current?.humidity)) + ? Math.round(toNumberOrNull(current.humidity)) + : Number.isFinite(average(hourlyHumidity)) + ? Math.round(average(hourlyHumidity)) + : null + + const rainChance = Number.isFinite(toNumberOrNull(day.daily_chance_of_rain)) + ? Math.round(toNumberOrNull(day.daily_chance_of_rain)) + : Number.isFinite(max(hourlyRainChance)) + ? Math.round(max(hourlyRainChance)) + : null + + const precipitation = round1(toNumberOrNull(day.totalprecip_mm)) ?? 0 + const uv = round1( + Number.isFinite(toNumberOrNull(day.uv)) + ? toNumberOrNull(day.uv) + : max(hourlyUv) + ) - const weatherHourly = weatherPayload.hourly || {} - const uvHourly = uvPayload?.hourly || {} + return { + id: `${dateKey}-${sourceLabel.toLowerCase().replace(/\s+/g, '-')}`, + date, + dateKey, + weekdayInitial: date.toLocaleDateString('en-SG', { weekday: 'narrow' }), + weekdayShort: date.toLocaleDateString('en-SG', { weekday: 'short' }), + dayNumber: date.getDate(), + monthShort: date.toLocaleDateString('en-SG', { month: 'short' }), + monthLong: date.toLocaleDateString('en-SG', { month: 'long' }), + year: date.getFullYear(), + displayLabel: `${date.getDate()} ${date.toLocaleDateString('en-SG', { month: 'short' })}`, + condition: conditionText, + iconName, + currentTemp, + feelsLike, + humidity, + rainChance, + precipitation, + uv, + hourly: displayHours, + sourceLabel + } +} - const weatherTimes = weatherHourly.time || [] - const uvTimes = uvHourly.time || [] +async function fetchForecastDays(locationQuery, endDate) { + const today = startOfDay(new Date()) + const daysNeeded = daysBetween(today, endDate) + 1 + + if (daysNeeded <= 0) return new Map() - const uvByTime = new Map() - uvTimes.forEach((time, index) => { - uvByTime.set(time, safeIndex(uvHourly.uv_index, index, null)) + const payload = await fetchWeatherApi('forecast', { + q: locationQuery, + days: Math.min(daysNeeded, MAX_FORECAST_DAYS), + aqi: 'no', + alerts: 'no' }) - weatherTimes.forEach((time, index) => { - const [dateKey, hourPart] = time.split('T') - const hour24 = Number(hourPart?.split(':')[0] ?? 0) + const result = new Map() + const forecastDays = payload?.forecast?.forecastday || [] - if (!buckets.has(dateKey)) { - buckets.set(dateKey, []) - } + forecastDays.forEach((forecastDay) => { + const dateKey = forecastDay.date + const isToday = dateKey === formatDateKey(today) - buckets.get(dateKey).push({ - time, + result.set( dateKey, - hour24, - label: labelFromHour(hour24), - temperature: safeIndex(weatherHourly.temperature_2m, index, null), - apparentTemperature: safeIndex(weatherHourly.apparent_temperature, index, null), - humidity: safeIndex(weatherHourly.relative_humidity_2m, index, null), - precipitationProbability: safeIndex( - weatherHourly.precipitation_probability, - index, - null - ), - precipitation: safeIndex(weatherHourly.precipitation, index, null), - weatherCode: safeIndex(weatherHourly.weather_code, index, null), - uv: uvByTime.get(time) ?? null - }) + normaliseForecastDay(forecastDay, { + sourceLabel: 'Forecast', + current: isToday ? payload.current : null, + useCurrent: isToday + }) + ) }) - return buckets + return result } -function sliceDisplayHours(dayHourly, fallbackUvValue) { - const targetHours = [12, 13, 14, 15, 16, 17, 18] - - const filtered = targetHours - .map((hour) => { - const match = dayHourly.find((point) => point.hour24 === hour) - if (!match) return null - - return { - label: match.label, - temperature: match.temperature ?? 0, - precipitation: match.precipitation ?? 0, - uv: match.uv ?? fallbackUvValue ?? 0, - humidity: match.humidity ?? 0, - precipitationProbability: match.precipitationProbability ?? 0, - weatherCode: match.weatherCode ?? 3 - } - }) - .filter(Boolean) - - if (filtered.length) return filtered - - return dayHourly.slice(0, 7).map((point) => ({ - label: point.label, - temperature: point.temperature ?? 0, - precipitation: point.precipitation ?? 0, - uv: point.uv ?? fallbackUvValue ?? 0, - humidity: point.humidity ?? 0, - precipitationProbability: point.precipitationProbability ?? 0, - weatherCode: point.weatherCode ?? 3 - })) +async function fetchFutureDay(locationQuery, date) { + const dateKey = formatDateKey(date) + const cacheKey = `${locationQuery}::${dateKey}` + + if (futureCache.has(cacheKey)) { + return futureCache.get(cacheKey) + } + + const payload = await fetchWeatherApi('future', { + q: locationQuery, + dt: dateKey + }) + + const forecastDay = payload?.forecast?.forecastday?.[0] + if (!forecastDay) { + throw new Error(`No future weather returned for ${dateKey}.`) + } + + const normalised = normaliseForecastDay(forecastDay, { + sourceLabel: 'Future weather', + current: null, + useCurrent: false + }) + + futureCache.set(cacheKey, normalised) + return normalised } export function isWetDay(day) { - return (day?.rainChance || 0) >= 50 || (day?.precipitation || 0) >= 1 + const rainChance = Number.isFinite(day?.rainChance) ? day.rainChance : 0 + const precipitation = Number.isFinite(day?.precipitation) ? day.precipitation : 0 + return rainChance >= 50 || precipitation >= 1 } -export async function buildTripWeatherForecast(trip) { +export async function buildTripWeatherForecast(trip, options = {}) { const start = toJsDate(trip?.startDate) const end = toJsDate(trip?.endDate) + const locationQuery = options.locationQuery || 'Singapore' if (!start || !end || end < start) { throw new Error('Trip dates are missing or invalid.') @@ -250,142 +365,59 @@ export async function buildTripWeatherForecast(trip) { const startDate = startOfDay(start) const endDate = startOfDay(end) + const today = startOfDay(new Date()) - ensureForecastWindow(startDate, endDate) - - const startDateStr = formatISODate(startDate) - const endDateStr = formatISODate(endDate) - - const weatherUrl = new URL('https://api.open-meteo.com/v1/forecast') - weatherUrl.searchParams.set('latitude', String(SINGAPORE.latitude)) - weatherUrl.searchParams.set('longitude', String(SINGAPORE.longitude)) - weatherUrl.searchParams.set('timezone', SINGAPORE.timezone) - weatherUrl.searchParams.set( - 'current', - [ - 'temperature_2m', - 'apparent_temperature', - 'relative_humidity_2m', - 'precipitation', - 'weather_code' - ].join(',') - ) - weatherUrl.searchParams.set( - 'hourly', - [ - 'temperature_2m', - 'apparent_temperature', - 'relative_humidity_2m', - 'precipitation_probability', - 'precipitation', - 'weather_code' - ].join(',') - ) - weatherUrl.searchParams.set( - 'daily', - [ - 'weather_code', - 'temperature_2m_max', - 'temperature_2m_min', - 'precipitation_probability_max', - 'uv_index_max' - ].join(',') - ) - weatherUrl.searchParams.set('start_date', startDateStr) - weatherUrl.searchParams.set('end_date', endDateStr) - - const today = startOfDay(new Date()) - const uvMaxEnd = addDays(today, 5) - const uvHasOverlap = startDate <= uvMaxEnd - - let uvUrl = null - if (uvHasOverlap) { - const uvStart = startDate < today ? today : startDate - const uvEnd = endDate < uvMaxEnd ? endDate : uvMaxEnd - - uvUrl = new URL('https://air-quality-api.open-meteo.com/v1/air-quality') - uvUrl.searchParams.set('latitude', String(SINGAPORE.latitude)) - uvUrl.searchParams.set('longitude', String(SINGAPORE.longitude)) - uvUrl.searchParams.set('timezone', SINGAPORE.timezone) - uvUrl.searchParams.set('hourly', 'uv_index') - uvUrl.searchParams.set('current', 'uv_index') - uvUrl.searchParams.set('start_date', formatISODate(uvStart)) - uvUrl.searchParams.set('end_date', formatISODate(uvEnd)) - } + if (startDate < today) { + throw new Error('This weather tab currently supports today onwards only.') + } - const [weatherPayload, uvPayload] = await Promise.all([ - fetchJson(weatherUrl.toString()), - uvUrl ? fetchJson(uvUrl.toString()) : Promise.resolve(null) - ]) + const farthestDate = addDays(today, MAX_FUTURE_DAYS) + if (endDate > farthestDate) { + throw new Error(`Weather preview is only supported up to ${MAX_FUTURE_DAYS} days ahead.`) + } - const hourlyBuckets = buildHourlyBuckets(weatherPayload, uvPayload) - const tripDates = buildTripDates(startDate, endDate) - const daily = weatherPayload.daily || {} - const dailyTimes = daily.time || [] + const allDates = buildTripDates(startDate, endDate) + const forecastDates = allDates.filter((date) => daysBetween(today, date) < MAX_FORECAST_DAYS) + const futureDates = allDates.filter((date) => daysBetween(today, date) >= MAX_FORECAST_DAYS) - const dailyIndexByDate = new Map() - dailyTimes.forEach((dateKey, index) => { - dailyIndexByDate.set(dateKey, index) - }) + const results = new Map() - return tripDates.map((date, index) => { - const dateKey = formatDateKey(date) - const dailyIndex = dailyIndexByDate.get(dateKey) + if (forecastDates.length) { + const forecastMap = await fetchForecastDays( + locationQuery, + forecastDates[forecastDates.length - 1] + ) - if (dailyIndex === undefined) { - throw new Error( - `No live forecast returned for ${dateKey}. Keep the trip within the provider forecast window.` - ) - } + forecastDates.forEach((date) => { + const dateKey = formatDateKey(date) + const day = forecastMap.get(dateKey) - const dayHourlyRaw = hourlyBuckets.get(dateKey) || [] - const fallbackUvValue = safeIndex(daily.uv_index_max, dailyIndex, 0) - const hourly = sliceDisplayHours(dayHourlyRaw, fallbackUvValue) - - const currentTemp = - hourly[0]?.temperature ?? safeIndex(daily.temperature_2m_max, dailyIndex, 0) - const feelsLike = round1(average(hourly.map((point) => point.temperature))) - const humidity = Math.round(average(hourly.map((point) => point.humidity))) - const rainChance = Math.round( - max([ - ...hourly.map((point) => point.precipitationProbability), - safeIndex(daily.precipitation_probability_max, dailyIndex, 0) - ]) - ) - const precipitation = round1(sum(hourly.map((point) => point.precipitation))) - const uv = round1(max(hourly.map((point) => point.uv))) - const weatherCode = safeIndex( - daily.weather_code, - dailyIndex, - hourly[0]?.weatherCode ?? 3 + if (!day) { + throw new Error(`No forecast weather returned for ${dateKey}.`) + } + + results.set(dateKey, day) + }) + } + + if (futureDates.length) { + const futureResults = await Promise.all( + futureDates.map((date) => fetchFutureDay(locationQuery, date)) ) - return { - id: `${dateKey}-${index}`, - date, - dateKey, - weekdayInitial: date.toLocaleDateString('en-SG', { weekday: 'narrow' }), - weekdayShort: date.toLocaleDateString('en-SG', { weekday: 'short' }), - dayNumber: date.getDate(), - monthShort: date.toLocaleDateString('en-SG', { month: 'short' }), - monthLong: date.toLocaleDateString('en-SG', { month: 'long' }), - year: date.getFullYear(), - displayLabel: `${date.getDate()} ${date.toLocaleDateString('en-SG', { month: 'short' })}`, - condition: weatherCodeToIconName(weatherCode), - iconName: weatherCodeToIconName(weatherCode), - currentTemp: round1(currentTemp), - feelsLike, - humidity: Number.isFinite(humidity) ? humidity : 0, - rainChance, - precipitation, - uv, - hourly, - fallbackAlternatives: FALLBACK_ALTERNATIVES + futureResults.forEach((day) => { + results.set(day.dateKey, day) + }) + } + + return allDates.map((date) => { + const dateKey = formatDateKey(date) + const day = results.get(dateKey) + + if (!day) { + throw new Error(`No weather data returned for ${dateKey}.`) } - }) -} -// Keeps your current store import working without changing weather.js -export async function buildMockWeatherForecast(trip) { - return buildTripWeatherForecast(trip) + return day + }) } \ No newline at end of file diff --git a/src/stores/poi.js b/src/stores/poi.js index 6d817b7..dd3efa0 100644 --- a/src/stores/poi.js +++ b/src/stores/poi.js @@ -25,8 +25,6 @@ export const usePoiStore = defineStore('poi', () => { let unsubscribePois = null - // --- Real-time sync --- - function subscribeToPois(tripId) { if (unsubscribePois) { unsubscribePois() @@ -48,8 +46,6 @@ export const usePoiStore = defineStore('poi', () => { searchQuery.value = '' } - // --- Search --- - async function searchPois(query) { searchQuery.value = query if (!query || !query.trim()) { @@ -69,40 +65,60 @@ export const usePoiStore = defineStore('poi', () => { } } - // --- CRUD --- + function buildPlaceIdentity(placeData) { + return ( + placeData.googlePlaceId || + placeData.placeId || + placeData.name?.trim()?.toLowerCase() || + null + ) + } async function addPoiToTrip(tripId, placeData) { const authStore = useAuthStore() if (!authStore.user) return - // Check for duplicate by googlePlaceId - const existing = pois.value.find(p => p.googlePlaceId === placeData.placeId) + const incomingKey = buildPlaceIdentity(placeData) + const existing = pois.value.find((poi) => { + const poiKey = + poi.googlePlaceId || + poi.placeId || + poi.name?.trim()?.toLowerCase() || + null + + return incomingKey && poiKey && incomingKey === poiKey + }) + if (existing) { - error.value = 'This POI is already in the trip pool.' - return + return existing.id } loading.value = true error.value = null + try { - await addDoc(collection(db, 'trips', tripId, 'pois'), { + const docRef = await addDoc(collection(db, 'trips', tripId, 'pois'), { name: placeData.name, - address: placeData.address, - category: placeData.category, - openingHours: null, + address: placeData.address || '', + category: placeData.category || 'Place', + openingHours: placeData.openingHours || null, imageUrl: placeData.imageUrl || null, - latitude: placeData.location?.lat || null, - longitude: placeData.location?.lng || null, - googlePlaceId: placeData.googlePlaceId, - tags: [], + latitude: placeData.location?.lat ?? null, + longitude: placeData.location?.lng ?? null, + googlePlaceId: placeData.googlePlaceId || null, + placeId: placeData.placeId || placeData.googlePlaceId || null, + tags: Array.isArray(placeData.tags) ? [...new Set(placeData.tags)].slice(0, 8) : [], votes: {}, upvotes: 0, downvotes: 0, addedBy: authStore.user.uid }) + + return docRef.id } catch (err) { error.value = 'Failed to add POI.' console.error(err) + throw err } finally { loading.value = false } @@ -121,8 +137,6 @@ export const usePoiStore = defineStore('poi', () => { } } - // --- Voting --- - async function votePoi(tripId, poiId, voteType) { const authStore = useAuthStore() if (!authStore.user) return @@ -137,12 +151,10 @@ export const usePoiStore = defineStore('poi', () => { let newDownvotes = poi.downvotes || 0 if (currentVote === voteType) { - // Toggle off: remove vote delete newVotes[uid] if (voteType === 'up') newUpvotes-- else newDownvotes-- } else { - // Switch or new vote if (currentVote === 'up') newUpvotes-- if (currentVote === 'down') newDownvotes-- newVotes[uid] = voteType @@ -162,8 +174,6 @@ export const usePoiStore = defineStore('poi', () => { } } - // --- Tagging --- - async function addTag(tripId, poiId, tag) { const trimmed = tag.trim() if (!trimmed) return @@ -195,8 +205,6 @@ export const usePoiStore = defineStore('poi', () => { } } - // --- Single POI fetch --- - async function fetchPoi(tripId, poiId) { loading.value = true error.value = null @@ -233,4 +241,4 @@ export const usePoiStore = defineStore('poi', () => { removeTag, fetchPoi } -}) +}) \ No newline at end of file diff --git a/src/stores/weather.js b/src/stores/weather.js index 1d97088..358ffc7 100644 --- a/src/stores/weather.js +++ b/src/stores/weather.js @@ -1,12 +1,11 @@ import { computed, ref } from 'vue' import { defineStore } from 'pinia' -import { buildMockWeatherForecast } from '@/services/weatherService' +import { buildTripWeatherForecast } from '@/services/weatherService' export const useWeatherStore = defineStore('weather', () => { const forecastDays = ref([]) const selectedDayIndex = ref(0) const selectedMetric = ref('temperature') - const queuedReplacements = ref({}) const loading = ref(false) const error = ref(null) @@ -20,20 +19,21 @@ export const useWeatherStore = defineStore('weather', () => { return forecastDays.value[selectedDayIndex.value] || null }) - async function loadTripWeather(trip) { + async function loadTripWeather(trip, options = {}) { loading.value = true error.value = null try { - forecastDays.value = await buildMockWeatherForecast(trip) - selectedDayIndex.value = 0 - selectedMetric.value = 'temperature' - queuedReplacements.value = {} + forecastDays.value = await buildTripWeatherForecast(trip, options) + selectedDayIndex.value = 0 + selectedMetric.value = 'temperature' } catch (err) { - console.error('Weather preview error:', err) - error.value = err?.message || 'Failed to prepare weather preview.' + console.error('Weather preview error:', err) + error.value = err?.message || 'Failed to load weather preview.' + forecastDays.value = [] + selectedDayIndex.value = 0 } finally { - loading.value = false + loading.value = false } } @@ -58,24 +58,10 @@ export const useWeatherStore = defineStore('weather', () => { selectedMetric.value = metric } - function queueReplacement(dateKey, suggestion) { - queuedReplacements.value = { - ...queuedReplacements.value, - [dateKey]: suggestion - } - } - - function clearQueuedReplacement(dateKey) { - const copy = { ...queuedReplacements.value } - delete copy[dateKey] - queuedReplacements.value = copy - } - function reset() { forecastDays.value = [] selectedDayIndex.value = 0 selectedMetric.value = 'temperature' - queuedReplacements.value = {} loading.value = false error.value = null } @@ -84,7 +70,6 @@ export const useWeatherStore = defineStore('weather', () => { forecastDays, selectedDayIndex, selectedMetric, - queuedReplacements, metricOptions, loading, error, @@ -94,8 +79,6 @@ export const useWeatherStore = defineStore('weather', () => { nextDay, prevDay, setMetric, - queueReplacement, - clearQueuedReplacement, reset } }) \ No newline at end of file From c4403537b445d5ac8300c51d85dcf1eb22854121 Mon Sep 17 00:00:00 2001 From: Isaac Date: Fri, 17 Apr 2026 11:41:05 +0800 Subject: [PATCH 8/8] Add WeatherAPI key to env --- .env | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.env b/.env index 2406a1b..cfa1a65 100644 --- a/.env +++ b/.env @@ -12,4 +12,4 @@ VITE_GOOGLE_PLACES_KEY=AIzaSyDiz7lUEfude_eoTdsPO5xBt7IAOdryd4Q # OpenWeather API # Get from: openweathermap.org → API keys -#VITE_OPENWEATHER_KEY= \ No newline at end of file +VITE_WEATHERAPI_KEY=224a25d3e6e5417393f165513261304 \ No newline at end of file