From 6d944737ca0610656b3552738713ece618bacfdd Mon Sep 17 00:00:00 2001 From: cph5236 Date: Fri, 13 Mar 2026 12:00:25 -0400 Subject: [PATCH 1/9] Version 0.0.4 - Add Drop down to 10 day forcast --- .github/workflows/ci.yml | 2 +- .github/workflows/deploy.yml | 2 +- .github/workflows/mobile.yml | 17 +++- SWS_App/index.html | 2 +- SWS_App/src/__tests__/weather.test.ts | 51 ++++++++++- SWS_App/src/components/Forecast10Day.tsx | 111 ++++++++++++++++++----- SWS_App/src/services/weather.ts | 27 ++++-- SWS_App/src/types/weather.ts | 5 + 8 files changed, 176 insertions(+), 41 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 70570d6..b7c3479 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,7 +3,7 @@ name: CI on: push: pull_request: - branches: [main] + branches: [main, release/*] defaults: run: diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 9a62a03..4e7e8d8 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -2,7 +2,7 @@ name: Deploy to GitHub Pages on: push: - branches: [main] + branches: [main, release/*] permissions: contents: read diff --git a/.github/workflows/mobile.yml b/.github/workflows/mobile.yml index 4315979..8aac0c1 100644 --- a/.github/workflows/mobile.yml +++ b/.github/workflows/mobile.yml @@ -2,7 +2,7 @@ name: Mobile Builds on: push: - branches: [main] + branches: [main, release/*] workflow_dispatch: jobs: @@ -66,7 +66,20 @@ jobs: - run: npm run build - run: npx cap sync ios + - name: Check signing secrets + id: check-signing-secrets + env: + CERT: ${{ secrets.IOS_CERTIFICATE_P12_BASE64 }} + run: | + if [[ -z "$CERT" ]]; then + echo "iOS signing secrets not configured — skipping signing and build steps" + echo "available=false" >> "$GITHUB_OUTPUT" + else + echo "available=true" >> "$GITHUB_OUTPUT" + fi + - name: Install certificate and provisioning profile + if: steps.check-signing-secrets.outputs.available == 'true' env: CERTIFICATE_P12: ${{ secrets.IOS_CERTIFICATE_P12_BASE64 }} CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }} @@ -88,6 +101,7 @@ jobs: > ~/Library/MobileDevice/Provisioning\ Profiles/profile.mobileprovision - name: Build IPA + if: steps.check-signing-secrets.outputs.available == 'true' working-directory: SWS_App/ios/App run: | xcodebuild -workspace App.xcworkspace \ @@ -101,6 +115,7 @@ jobs: -exportOptionsPlist ExportOptions.plist - uses: actions/upload-artifact@v4 + if: steps.check-signing-secrets.outputs.available == 'true' with: name: ios-ipa path: SWS_App/ios/App/build/ipa/App.ipa diff --git a/SWS_App/index.html b/SWS_App/index.html index 379ed01..3ad0417 100644 --- a/SWS_App/index.html +++ b/SWS_App/index.html @@ -4,7 +4,7 @@ - my-app + Simple Weather Service
diff --git a/SWS_App/src/__tests__/weather.test.ts b/SWS_App/src/__tests__/weather.test.ts index 62ddca3..b978d25 100644 --- a/SWS_App/src/__tests__/weather.test.ts +++ b/SWS_App/src/__tests__/weather.test.ts @@ -39,20 +39,28 @@ const mockDailyResponse = { time: ['2026-03-11', '2026-03-12'], temperature_2m_max: [24, 22], temperature_2m_min: [14, 12], + apparent_temperature_max: [22, 20], + apparent_temperature_min: [12, 10], precipitation_probability_max: [10, 30], + precipitation_sum: [0, 2.4], weather_code: [1, 61], + wind_speed_10m_max: [18, 25], + uv_index_max: [4, 2], sunrise: ['2026-03-11T06:30', '2026-03-12T06:31'], sunset: ['2026-03-11T18:30', '2026-03-12T18:31'], }, } +const pad = (n: number) => String(n).padStart(2, '0') +const hourlyTimes = Array.from({ length: 48 }, (_, i) => { + const day = i < 24 ? '2026-03-11' : '2026-03-12' + return `${day}T${pad(i % 24)}:00` +}) + const mockHourlyResponse = { + utc_offset_seconds: 0, hourly: { - time: Array.from({ length: 48 }, (_, i) => { - const d = new Date('2026-03-11T00:00:00') - d.setHours(i) - return d.toISOString().slice(0, 16) - }), + time: hourlyTimes, temperature_2m: Array.from({ length: 48 }, (_, i) => 20 + i * 0.1), precipitation_probability: Array.from({ length: 48 }, () => 5), wind_speed_10m: Array.from({ length: 48 }, () => 10), @@ -119,8 +127,13 @@ describe('getDailyForecast', () => { date: '2026-03-11', tempMax: 24, tempMin: 14, + feelsLikeMax: 22, + feelsLikeMin: 12, precipProbability: 10, + precipitationSum: 0, weatherCode: 1, + windSpeedMax: 18, + uvIndexMax: 4, sunrise: '2026-03-11T06:30', sunset: '2026-03-11T18:30', }) @@ -143,4 +156,32 @@ describe('getHourlyForecast', () => { expect(result[0]).toHaveProperty('windSpeed') expect(result[0]).toHaveProperty('weatherCode') }) + + it('starts from location local time, not UTC, when utc_offset_seconds is negative', async () => { + // UTC 03:00 with UTC-5 offset → local time is 22:00 (10 PM) on the previous day + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-03-12T03:00:00Z')) + + const offsetResponse = { + utc_offset_seconds: -18000, // UTC-5 + hourly: { + time: Array.from({ length: 48 }, (_, i) => { + const day = i < 24 ? '2026-03-11' : '2026-03-12' + return `${day}T${pad(i % 24)}:00` + }), + temperature_2m: Array.from({ length: 48 }, () => 20), + precipitation_probability: Array.from({ length: 48 }, () => 0), + wind_speed_10m: Array.from({ length: 48 }, () => 10), + weather_code: Array.from({ length: 48 }, () => 0), + }, + } + + mockFetch.mockResolvedValueOnce(makeResponse(offsetResponse)) + const result = await getHourlyForecast(51.5, -0.1, 'metric') + + // Local 10 PM (hour 22), not UTC 3 AM (which would be "2026-03-12T03:00") + expect(result[0].time).toBe('2026-03-11T22:00') + + vi.useRealTimers() + }) }) diff --git a/SWS_App/src/components/Forecast10Day.tsx b/SWS_App/src/components/Forecast10Day.tsx index 5721302..565ecae 100644 --- a/SWS_App/src/components/Forecast10Day.tsx +++ b/SWS_App/src/components/Forecast10Day.tsx @@ -1,4 +1,5 @@ -import { getWeatherEmoji } from '../types/weather' +import { useState } from 'react' +import { getWeatherDescription, getWeatherEmoji } from '../types/weather' import type { DailyForecast, Units } from '../types/weather' interface Forecast10DayProps { @@ -13,38 +14,100 @@ function formatDay(dateStr: string, index: number): string { return date.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }) } +function formatTime(isoStr: string): string { + const date = new Date(isoStr) + return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }) +} + +const statLabelStyle: React.CSSProperties = { + fontSize: '0.7rem', + textTransform: 'uppercase', + letterSpacing: '0.05em', +} + export function Forecast10Day({ days, units }: Forecast10DayProps) { + const [expandedDate, setExpandedDate] = useState(null) const tempUnit = units === 'metric' ? '°C' : '°F' + const windUnit = units === 'metric' ? 'km/h' : 'mph' + const precipUnit = units === 'metric' ? 'mm' : 'in' + + function toggleExpand(date: string) { + setExpandedDate((prev) => (prev === date ? null : date)) + } return (

10-Day Forecast

    - {days.map((day, i) => ( -
  • -
    -
    - {formatDay(day.date, i)} -
    - -
    - {day.precipProbability > 0 && ( - - 💧{day.precipProbability}% + {days.map((day, i) => { + const isExpanded = expandedDate === day.date + return ( +
  • toggleExpand(day.date)} + > +
    +
    + {formatDay(day.date, i)} +
    + +
    + {day.precipProbability > 0 && ( + + 💧{day.precipProbability}% + + )} +
    + {Math.round(day.tempMax)}{tempUnit} + / {Math.round(day.tempMin)}{tempUnit} +
    + + {isExpanded ? '▾' : '▸'} - )} -
    - {Math.round(day.tempMax)}{tempUnit} - / {Math.round(day.tempMin)}{tempUnit}
    -
    -
  • - ))} + + {isExpanded && ( +
    +
    +
    Condition
    +
    {getWeatherDescription(day.weatherCode)}
    +
    +
    +
    Feels like
    +
    {Math.round(day.feelsLikeMax)}{tempUnit} / {Math.round(day.feelsLikeMin)}{tempUnit}
    +
    +
    +
    Wind max
    +
    {Math.round(day.windSpeedMax)} {windUnit}
    +
    +
    +
    Precipitation
    +
    {day.precipitationSum.toFixed(1)} {precipUnit}
    +
    +
    +
    UV Index
    +
    {Math.round(day.uvIndexMax)}
    +
    +
    +
    Sunrise
    +
    {formatTime(day.sunrise)}
    +
    +
    +
    Sunset
    +
    {formatTime(day.sunset)}
    +
    +
    + )} + + ) + })}
) diff --git a/SWS_App/src/services/weather.ts b/SWS_App/src/services/weather.ts index 1bcad05..563f557 100644 --- a/SWS_App/src/services/weather.ts +++ b/SWS_App/src/services/weather.ts @@ -3,7 +3,7 @@ import type { CurrentWeather, DailyForecast, HourlyForecast, Units } from '../ty export class WeatherError extends Error { constructor( message: string, - public code: number + code: number ) { super(message) this.name = 'WeatherError' @@ -40,7 +40,7 @@ const BASE_URL = 'https://api.open-meteo.com/v1/forecast' function unitParams(units: Units): string { if (units === 'imperial') { - return '&temperature_unit=fahrenheit&wind_speed_unit=mph' + return '&temperature_unit=fahrenheit&wind_speed_unit=mph&precipitation_unit=inch' } return '&temperature_unit=celsius' } @@ -61,7 +61,7 @@ export async function getCurrentWeather(lat: number, lon: number, units: Units): const url = `${BASE_URL}?latitude=${lat}&longitude=${lon}` + `¤t=temperature_2m,apparent_temperature,relative_humidity_2m,wind_speed_10m,wind_direction_10m,weather_code,is_day` + - unitParams(units) + unitParams(units) + '&timezone=auto' const json = await fetchWeather(url) const data = json as { @@ -98,8 +98,9 @@ export async function getDailyForecast(lat: number, lon: number, units: Units): const url = `${BASE_URL}?latitude=${lat}&longitude=${lon}` + - `&daily=temperature_2m_max,temperature_2m_min,precipitation_probability_max,weather_code,sunrise,sunset&forecast_days=10` + - unitParams(units) + `&daily=temperature_2m_max,temperature_2m_min,apparent_temperature_max,apparent_temperature_min,` + + `precipitation_probability_max,precipitation_sum,weather_code,wind_speed_10m_max,uv_index_max,sunrise,sunset&forecast_days=10` + + unitParams(units) + '&timezone=auto' const json = await fetchWeather(url) const data = json as { @@ -107,8 +108,13 @@ export async function getDailyForecast(lat: number, lon: number, units: Units): time: string[] temperature_2m_max: number[] temperature_2m_min: number[] + apparent_temperature_max: number[] + apparent_temperature_min: number[] precipitation_probability_max: number[] + precipitation_sum: number[] weather_code: number[] + wind_speed_10m_max: number[] + uv_index_max: number[] sunrise: string[] sunset: string[] } @@ -118,8 +124,13 @@ export async function getDailyForecast(lat: number, lon: number, units: Units): date, tempMax: data.daily.temperature_2m_max[i], tempMin: data.daily.temperature_2m_min[i], + feelsLikeMax: data.daily.apparent_temperature_max[i], + feelsLikeMin: data.daily.apparent_temperature_min[i], precipProbability: data.daily.precipitation_probability_max[i] ?? 0, + precipitationSum: data.daily.precipitation_sum[i] ?? 0, weatherCode: data.daily.weather_code[i], + windSpeedMax: data.daily.wind_speed_10m_max[i], + uvIndexMax: data.daily.uv_index_max[i], sunrise: data.daily.sunrise[i], sunset: data.daily.sunset[i], })) @@ -136,10 +147,11 @@ export async function getHourlyForecast(lat: number, lon: number, units: Units): const url = `${BASE_URL}?latitude=${lat}&longitude=${lon}` + `&hourly=temperature_2m,precipitation_probability,wind_speed_10m,weather_code&forecast_days=2` + - unitParams(units) + unitParams(units) + '&timezone=auto' const json = await fetchWeather(url) const data = json as { + utc_offset_seconds: number hourly: { time: string[] temperature_2m: number[] @@ -149,8 +161,7 @@ export async function getHourlyForecast(lat: number, lon: number, units: Units): } } - const now = new Date() - const currentHour = now.toISOString().slice(0, 13) // "YYYY-MM-DDTHH" + const currentHour = new Date(Date.now() + data.utc_offset_seconds * 1000).toISOString().slice(0, 13) const startIndex = data.hourly.time.findIndex((t) => t >= currentHour) const sliceStart = startIndex === -1 ? 0 : startIndex diff --git a/SWS_App/src/types/weather.ts b/SWS_App/src/types/weather.ts index 2aecd23..2d227b0 100644 --- a/SWS_App/src/types/weather.ts +++ b/SWS_App/src/types/weather.ts @@ -31,8 +31,13 @@ export interface DailyForecast { date: string tempMax: number tempMin: number + feelsLikeMax: number + feelsLikeMin: number precipProbability: number + precipitationSum: number weatherCode: number + windSpeedMax: number + uvIndexMax: number sunrise: string sunset: string } From c99158e5b3fcc38368d1f38681f012ca794b02a5 Mon Sep 17 00:00:00 2001 From: cph5236 Date: Fri, 13 Mar 2026 12:08:24 -0400 Subject: [PATCH 2/9] Fix Typecheck for WeatherError --- SWS_App/src/services/weather.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/SWS_App/src/services/weather.ts b/SWS_App/src/services/weather.ts index 563f557..41d5ff9 100644 --- a/SWS_App/src/services/weather.ts +++ b/SWS_App/src/services/weather.ts @@ -1,12 +1,14 @@ import type { CurrentWeather, DailyForecast, HourlyForecast, Units } from '../types/weather' export class WeatherError extends Error { + code: number constructor( message: string, code: number ) { super(message) this.name = 'WeatherError' + this.code = code } } From 7503b6e28b3935ac9aef7f177fb8897e35c00a0c Mon Sep 17 00:00:00 2001 From: cph5236 <33178603+cph5236@users.noreply.github.com> Date: Fri, 13 Mar 2026 12:11:45 -0400 Subject: [PATCH 3/9] Update deploy.yml remove release/* from deploy.yml --- .github/workflows/deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 4e7e8d8..9a62a03 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -2,7 +2,7 @@ name: Deploy to GitHub Pages on: push: - branches: [main, release/*] + branches: [main] permissions: contents: read From 4201e015100b76a8c914f50baf5bb74df187be49 Mon Sep 17 00:00:00 2001 From: cph5236 Date: Fri, 13 Mar 2026 20:14:59 -0400 Subject: [PATCH 4/9] Improvements to page caching and weather reload --- SWS_App/src/components/CurrentWeatherCard.tsx | 67 ++++++++++++++++--- SWS_App/src/hooks/useWeather.ts | 28 +++++++- SWS_App/src/pages/HomePage.tsx | 27 ++++++-- SWS_App/src/services/weather.ts | 17 +++-- 4 files changed, 116 insertions(+), 23 deletions(-) diff --git a/SWS_App/src/components/CurrentWeatherCard.tsx b/SWS_App/src/components/CurrentWeatherCard.tsx index 4a39856..eca0dd1 100644 --- a/SWS_App/src/components/CurrentWeatherCard.tsx +++ b/SWS_App/src/components/CurrentWeatherCard.tsx @@ -1,11 +1,16 @@ +import { useEffect, useState } from 'react' import { getWeatherDescription, getWeatherEmoji } from '../types/weather' import type { CurrentWeather, Location, Units } from '../types/weather' +const REFRESH_COOLDOWN_MS = 60 * 1000 + interface CurrentWeatherCardProps { location: Location weather: CurrentWeather isSaved: boolean onSaveToggle: () => void + onRefresh: () => void + lastRefreshed: number } function windDirectionLabel(degrees: number): string { @@ -21,10 +26,40 @@ function windUnitLabel(units: Units): string { return units === 'metric' ? 'km/h' : 'mph' } -export function CurrentWeatherCard({ location, weather, isSaved, onSaveToggle }: CurrentWeatherCardProps) { +export function CurrentWeatherCard({ + location, + weather, + isSaved, + onSaveToggle, + onRefresh, + lastRefreshed, +}: CurrentWeatherCardProps) { const tempUnit = unitLabel(weather.units) const windUnit = windUnitLabel(weather.units) + const [secondsLeft, setSecondsLeft] = useState(0) + + useEffect(() => { + const remaining = Math.ceil((lastRefreshed + REFRESH_COOLDOWN_MS - Date.now()) / 1000) + if (remaining <= 0) { + setSecondsLeft(0) + return + } + setSecondsLeft(remaining) + const id = setInterval(() => { + const s = Math.ceil((lastRefreshed + REFRESH_COOLDOWN_MS - Date.now()) / 1000) + if (s <= 0) { + setSecondsLeft(0) + clearInterval(id) + } else { + setSecondsLeft(s) + } + }, 1000) + return () => clearInterval(id) + }, [lastRefreshed]) + + const canRefresh = secondsLeft === 0 + return (
@@ -36,15 +71,27 @@ export function CurrentWeatherCard({ location, weather, isSaved, onSaveToggle }: {location.country}
- +
+ + +
diff --git a/SWS_App/src/hooks/useWeather.ts b/SWS_App/src/hooks/useWeather.ts index 81c2233..c1aa33f 100644 --- a/SWS_App/src/hooks/useWeather.ts +++ b/SWS_App/src/hooks/useWeather.ts @@ -1,5 +1,10 @@ import { useEffect, useRef, useState } from 'react' -import { getCurrentWeather, getDailyForecast, getHourlyForecast } from '../services/weather' +import { + clearCurrentWeatherCache, + getCurrentWeather, + getDailyForecast, + getHourlyForecast, +} from '../services/weather' import type { CurrentWeather, DailyForecast, HourlyForecast, Location, Units } from '../types/weather' interface WeatherState { @@ -13,7 +18,7 @@ interface WeatherState { export function useWeather( location: Location | null, units: Units -): WeatherState & { refetch: () => void } { +): WeatherState & { refetch: () => void; refetchCurrent: () => void; lastCurrentFetch: number } { const [state, setState] = useState({ current: null, daily: [], @@ -21,6 +26,7 @@ export function useWeather( loading: false, error: null, }) + const [lastCurrentFetch, setLastCurrentFetch] = useState(0) const fetchCountRef = useRef(0) @@ -37,6 +43,7 @@ export function useWeather( ]) .then(([current, daily, hourly]) => { if (fetchId !== fetchCountRef.current) return + setLastCurrentFetch(Date.now()) setState({ current, daily, hourly, loading: false, error: null }) }) .catch((err: unknown) => { @@ -46,10 +53,25 @@ export function useWeather( }) } + function refetchCurrent() { + if (!location) return + clearCurrentWeatherCache(location.lat, location.lon, units) + + getCurrentWeather(location.lat, location.lon, units) + .then((current) => { + setLastCurrentFetch(Date.now()) + setState((prev) => ({ ...prev, current })) + }) + .catch((err: unknown) => { + const message = err instanceof Error ? err.message : 'Failed to fetch weather data' + setState((prev) => ({ ...prev, error: message })) + }) + } + useEffect(() => { fetch_() // eslint-disable-next-line react-hooks/exhaustive-deps }, [location?.lat, location?.lon, units]) - return { ...state, refetch: fetch_ } + return { ...state, refetch: fetch_, refetchCurrent, lastCurrentFetch } } diff --git a/SWS_App/src/pages/HomePage.tsx b/SWS_App/src/pages/HomePage.tsx index 8335e51..a54c59c 100644 --- a/SWS_App/src/pages/HomePage.tsx +++ b/SWS_App/src/pages/HomePage.tsx @@ -11,16 +11,33 @@ import { useUnits } from '../hooks/useUnits' import { useWeather } from '../hooks/useWeather' import type { Location, SavedLocation } from '../types/weather' +const LAST_LOCATION_KEY = 'sws-last-location' + +function loadLastLocation(): Location | null { + try { + const raw = localStorage.getItem(LAST_LOCATION_KEY) + return raw ? (JSON.parse(raw) as Location) : null + } catch { + return null + } +} + export function HomePage() { const { units, toggleUnits } = useUnits() const { savedLocations, addLocation, removeLocation, hasLocation } = useSavedLocations() - const [activeLocation, setActiveLocation] = useState(null) - const { current, daily, hourly, loading, error, refetch } = useWeather(activeLocation, units) + const [activeLocation, setActiveLocation] = useState(loadLastLocation) + const { current, daily, hourly, loading, error, refetch, refetchCurrent, lastCurrentFetch } = + useWeather(activeLocation, units) const activeId = activeLocation ? `${activeLocation.lat},${activeLocation.lon}` : null - function handleSelectSaved(loc: SavedLocation) { + function handleSelectLocation(loc: Location) { setActiveLocation(loc) + localStorage.setItem(LAST_LOCATION_KEY, JSON.stringify(loc)) + } + + function handleSelectSaved(loc: SavedLocation) { + handleSelectLocation(loc) } function handleSaveToggle() { @@ -41,7 +58,7 @@ export function HomePage() { Simple Weather Service
- +
@@ -101,6 +118,8 @@ export function HomePage() { weather={current} isSaved={hasLocation(activeId!)} onSaveToggle={handleSaveToggle} + onRefresh={refetchCurrent} + lastRefreshed={lastCurrentFetch} /> {hourly.length > 0 && } {daily.length > 0 && } diff --git a/SWS_App/src/services/weather.ts b/SWS_App/src/services/weather.ts index 41d5ff9..fa4326e 100644 --- a/SWS_App/src/services/weather.ts +++ b/SWS_App/src/services/weather.ts @@ -18,12 +18,17 @@ interface CacheEntry { } const cache = new Map>() -const TTL_MS = 10 * 60 * 1000 +const CURRENT_TTL_MS = 60 * 1000 +const FORECAST_TTL_MS = 30 * 60 * 1000 export function clearWeatherCache(): void { cache.clear() } +export function clearCurrentWeatherCache(lat: number, lon: number, units: Units): void { + cache.delete(`current,${lat},${lon},${units}`) +} + function getCached(key: string): T | null { const entry = cache.get(key) if (!entry) return null @@ -34,8 +39,8 @@ function getCached(key: string): T | null { return entry.data as T } -function setCached(key: string, data: T): void { - cache.set(key, { data, expiresAt: Date.now() + TTL_MS }) +function setCached(key: string, data: T, ttl: number): void { + cache.set(key, { data, expiresAt: Date.now() + ttl }) } const BASE_URL = 'https://api.open-meteo.com/v1/forecast' @@ -89,7 +94,7 @@ export async function getCurrentWeather(lat: number, lon: number, units: Units): units, } - setCached(key, result) + setCached(key, result, CURRENT_TTL_MS) return result } @@ -137,7 +142,7 @@ export async function getDailyForecast(lat: number, lon: number, units: Units): sunset: data.daily.sunset[i], })) - setCached(key, result) + setCached(key, result, FORECAST_TTL_MS) return result } @@ -176,6 +181,6 @@ export async function getHourlyForecast(lat: number, lon: number, units: Units): weatherCode: data.hourly.weather_code[sliceStart + i], })) - setCached(key, result) + setCached(key, result, FORECAST_TTL_MS) return result } From 72a204a72c2bac7492308785fb2947bf3c58a289 Mon Sep 17 00:00:00 2001 From: cph5236 Date: Fri, 13 Mar 2026 22:14:19 -0400 Subject: [PATCH 5/9] Add UV index Graph and Fix Formatting for 10day forcast and 24 hour forcast --- SWS_App/eslint.config.js | 2 +- SWS_App/src/components/CurrentWeatherCard.tsx | 23 +- SWS_App/src/components/Forecast10Day.tsx | 280 ++++++++++++++---- SWS_App/src/components/Hourly24.tsx | 126 +++++++- SWS_App/src/services/weather.ts | 8 +- SWS_App/src/types/weather.ts | 2 + 6 files changed, 363 insertions(+), 78 deletions(-) diff --git a/SWS_App/eslint.config.js b/SWS_App/eslint.config.js index 4f891eb..490d87e 100644 --- a/SWS_App/eslint.config.js +++ b/SWS_App/eslint.config.js @@ -8,7 +8,7 @@ import eslintConfigPrettier from 'eslint-config-prettier' import { defineConfig, globalIgnores } from 'eslint/config' export default defineConfig([ - globalIgnores(['dist']), + globalIgnores(['dist', 'android/**']), { files: ['**/*.{ts,tsx}'], extends: [ diff --git a/SWS_App/src/components/CurrentWeatherCard.tsx b/SWS_App/src/components/CurrentWeatherCard.tsx index eca0dd1..81b6814 100644 --- a/SWS_App/src/components/CurrentWeatherCard.tsx +++ b/SWS_App/src/components/CurrentWeatherCard.tsx @@ -40,22 +40,15 @@ export function CurrentWeatherCard({ const [secondsLeft, setSecondsLeft] = useState(0) useEffect(() => { - const remaining = Math.ceil((lastRefreshed + REFRESH_COOLDOWN_MS - Date.now()) / 1000) - if (remaining <= 0) { - setSecondsLeft(0) - return + const computeSeconds = () => + Math.max(0, Math.ceil((lastRefreshed + REFRESH_COOLDOWN_MS - Date.now()) / 1000)) + + const initial = setTimeout(() => setSecondsLeft(computeSeconds()), 0) + const id = setInterval(() => setSecondsLeft(computeSeconds()), 1000) + return () => { + clearTimeout(initial) + clearInterval(id) } - setSecondsLeft(remaining) - const id = setInterval(() => { - const s = Math.ceil((lastRefreshed + REFRESH_COOLDOWN_MS - Date.now()) / 1000) - if (s <= 0) { - setSecondsLeft(0) - clearInterval(id) - } else { - setSecondsLeft(s) - } - }, 1000) - return () => clearInterval(id) }, [lastRefreshed]) const canRefresh = secondsLeft === 0 diff --git a/SWS_App/src/components/Forecast10Day.tsx b/SWS_App/src/components/Forecast10Day.tsx index 565ecae..122eaef 100644 --- a/SWS_App/src/components/Forecast10Day.tsx +++ b/SWS_App/src/components/Forecast10Day.tsx @@ -19,17 +19,44 @@ function formatTime(isoStr: string): string { return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }) } -const statLabelStyle: React.CSSProperties = { - fontSize: '0.7rem', - textTransform: 'uppercase', - letterSpacing: '0.05em', +interface StatTileProps { + icon: string + label: string + value: string +} + +function StatTile({ icon, label, value }: StatTileProps) { + return ( +
+ + {icon} {label} + + {value} +
+ ) } export function Forecast10Day({ days, units }: Forecast10DayProps) { const [expandedDate, setExpandedDate] = useState(null) + const [hoveredDate, setHoveredDate] = useState(null) const tempUnit = units === 'metric' ? '°C' : '°F' const windUnit = units === 'metric' ? 'km/h' : 'mph' const precipUnit = units === 'metric' ? 'mm' : 'in' + const snowUnit = units === 'metric' ? 'cm' : 'in' + + const globalMin = Math.min(...days.map((d) => d.tempMin)) + const globalMax = Math.max(...days.map((d) => d.tempMax)) + const globalRange = globalMax - globalMin || 1 function toggleExpand(date: string) { setExpandedDate((prev) => (prev === date ? null : date)) @@ -37,78 +64,219 @@ export function Forecast10Day({ days, units }: Forecast10DayProps) { return (
-

10-Day Forecast

-
    +

    + 10-Day Forecast +

    + +
    {days.map((day, i) => { const isExpanded = expandedDate === day.date + const isHovered = hoveredDate === day.date + const barLeft = ((day.tempMin - globalMin) / globalRange) * 100 + const barWidth = ((day.tempMax - day.tempMin) / globalRange) * 100 + return ( -
  • toggleExpand(day.date)} + style={{ + borderBottom: i < days.length - 1 ? '1px solid #f1f5f9' : 'none', + }} > -
    -
    + {/* Row */} +
    toggleExpand(day.date)} + onMouseEnter={() => setHoveredDate(day.date)} + onMouseLeave={() => setHoveredDate(null)} + style={{ + display: 'flex', + alignItems: 'center', + gap: 12, + padding: '12px 16px', + cursor: 'pointer', + background: isHovered && !isExpanded ? '#f8fafc' : isExpanded ? '#f0f7ff' : 'transparent', + transition: 'background 150ms ease', + userSelect: 'none', + }} + > + {/* Day label */} + {formatDay(day.date, i)} -
    -
  • +
    ) })} -
+
) } diff --git a/SWS_App/src/components/Hourly24.tsx b/SWS_App/src/components/Hourly24.tsx index 0551c9f..6ad8289 100644 --- a/SWS_App/src/components/Hourly24.tsx +++ b/SWS_App/src/components/Hourly24.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react' import { getWeatherEmoji } from '../types/weather' import type { HourlyForecast, Units } from '../types/weather' @@ -11,12 +12,58 @@ function formatHour(isoTime: string): string { return date.toLocaleTimeString('en-US', { hour: 'numeric', hour12: true }) } +function uvColor(uv: number): string { + if (uv <= 2) return '#16a34a' + if (uv <= 5) return '#d97706' + if (uv <= 7) return '#ea580c' + if (uv <= 10) return '#dc2626' + return '#7c3aed' +} + +function uvLabel(uv: number): string { + if (uv <= 2) return 'Low' + if (uv <= 5) return 'Moderate' + if (uv <= 7) return 'High' + if (uv <= 10) return 'Very High' + return 'Extreme' +} + +const UV_LEGEND = [ + { label: 'Low', color: '#16a34a' }, + { label: 'Moderate', color: '#d97706' }, + { label: 'High', color: '#ea580c' }, + { label: 'Very High', color: '#dc2626' }, + { label: 'Extreme', color: '#7c3aed' }, +] + +const BAR_MAX_HEIGHT = 60 + export function Hourly24({ hours, units }: Hourly24Props) { + const [expanded, setExpanded] = useState(false) const tempUnit = units === 'metric' ? '°C' : '°F' + const maxUV = Math.max(...hours.map((h) => h.uvIndex), 1) return ( -
-

24-Hour Forecast

+
+
+

24-Hour Forecast

+ +
+
(
{formatHour(hour.time)}
))}
+ + {expanded && ( +
+
+ UV Index +
+
+ {hours.map((hour) => { + const uv = Math.round(hour.uvIndex) + const barHeight = Math.max(4, Math.round((uv / maxUV) * BAR_MAX_HEIGHT)) + const color = uvColor(uv) + return ( +
+
+
+ {uv} +
+
+
+
+
+ {formatHour(hour.time)} +
+
+ ) + })} +
+
+ {UV_LEGEND.map(({ label, color }) => ( +
+
+ {label} +
+ ))} +
+
+ )}
) } diff --git a/SWS_App/src/services/weather.ts b/SWS_App/src/services/weather.ts index fa4326e..4657ae6 100644 --- a/SWS_App/src/services/weather.ts +++ b/SWS_App/src/services/weather.ts @@ -106,7 +106,7 @@ export async function getDailyForecast(lat: number, lon: number, units: Units): const url = `${BASE_URL}?latitude=${lat}&longitude=${lon}` + `&daily=temperature_2m_max,temperature_2m_min,apparent_temperature_max,apparent_temperature_min,` + - `precipitation_probability_max,precipitation_sum,weather_code,wind_speed_10m_max,uv_index_max,sunrise,sunset&forecast_days=10` + + `precipitation_probability_max,precipitation_sum,snowfall_sum,weather_code,wind_speed_10m_max,uv_index_max,sunrise,sunset&forecast_days=10` + unitParams(units) + '&timezone=auto' const json = await fetchWeather(url) @@ -119,6 +119,7 @@ export async function getDailyForecast(lat: number, lon: number, units: Units): apparent_temperature_min: number[] precipitation_probability_max: number[] precipitation_sum: number[] + snowfall_sum: number[] weather_code: number[] wind_speed_10m_max: number[] uv_index_max: number[] @@ -135,6 +136,7 @@ export async function getDailyForecast(lat: number, lon: number, units: Units): feelsLikeMin: data.daily.apparent_temperature_min[i], precipProbability: data.daily.precipitation_probability_max[i] ?? 0, precipitationSum: data.daily.precipitation_sum[i] ?? 0, + snowfallSum: data.daily.snowfall_sum[i] ?? 0, weatherCode: data.daily.weather_code[i], windSpeedMax: data.daily.wind_speed_10m_max[i], uvIndexMax: data.daily.uv_index_max[i], @@ -153,7 +155,7 @@ export async function getHourlyForecast(lat: number, lon: number, units: Units): const url = `${BASE_URL}?latitude=${lat}&longitude=${lon}` + - `&hourly=temperature_2m,precipitation_probability,wind_speed_10m,weather_code&forecast_days=2` + + `&hourly=temperature_2m,precipitation_probability,wind_speed_10m,weather_code,uv_index&forecast_days=2` + unitParams(units) + '&timezone=auto' const json = await fetchWeather(url) @@ -165,6 +167,7 @@ export async function getHourlyForecast(lat: number, lon: number, units: Units): precipitation_probability: number[] wind_speed_10m: number[] weather_code: number[] + uv_index: number[] } } @@ -179,6 +182,7 @@ export async function getHourlyForecast(lat: number, lon: number, units: Units): precipProbability: data.hourly.precipitation_probability[sliceStart + i] ?? 0, windSpeed: data.hourly.wind_speed_10m[sliceStart + i], weatherCode: data.hourly.weather_code[sliceStart + i], + uvIndex: data.hourly.uv_index[sliceStart + i] ?? 0, })) setCached(key, result, FORECAST_TTL_MS) diff --git a/SWS_App/src/types/weather.ts b/SWS_App/src/types/weather.ts index 2d227b0..e7a49d5 100644 --- a/SWS_App/src/types/weather.ts +++ b/SWS_App/src/types/weather.ts @@ -25,6 +25,7 @@ export interface HourlyForecast { precipProbability: number windSpeed: number weatherCode: number + uvIndex: number } export interface DailyForecast { @@ -35,6 +36,7 @@ export interface DailyForecast { feelsLikeMin: number precipProbability: number precipitationSum: number + snowfallSum: number weatherCode: number windSpeedMax: number uvIndexMax: number From 21e89c6c8afdec2858b576465a63193d6c467838 Mon Sep 17 00:00:00 2001 From: cph5236 Date: Fri, 13 Mar 2026 22:18:40 -0400 Subject: [PATCH 6/9] move version to 0.0.4 --- SWS_App/android/app/build.gradle | 2 +- SWS_App/package-lock.json | 4 ++-- SWS_App/package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/SWS_App/android/app/build.gradle b/SWS_App/android/app/build.gradle index 3581b82..40d6bd8 100644 --- a/SWS_App/android/app/build.gradle +++ b/SWS_App/android/app/build.gradle @@ -8,7 +8,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionCode 2 - versionName "0.0.3" + versionName "0.0.4" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. diff --git a/SWS_App/package-lock.json b/SWS_App/package-lock.json index c383db3..4b3ce16 100644 --- a/SWS_App/package-lock.json +++ b/SWS_App/package-lock.json @@ -1,12 +1,12 @@ { "name": "my-app", - "version": "0.0.3", + "version": "0.0.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "my-app", - "version": "0.0.3", + "version": "0.0.4", "dependencies": { "@capacitor/android": "^8.2.0", "@capacitor/core": "^8.2.0", diff --git a/SWS_App/package.json b/SWS_App/package.json index a666fcc..43f7bd0 100644 --- a/SWS_App/package.json +++ b/SWS_App/package.json @@ -1,7 +1,7 @@ { "name": "sws-app", "private": true, - "version": "0.0.3", + "version": "0.0.4", "type": "module", "scripts": { "dev": "vite", From f1e8ff6c2cff1940a07c85c5cc935ff6499bd99b Mon Sep 17 00:00:00 2001 From: cph5236 Date: Fri, 13 Mar 2026 22:23:36 -0400 Subject: [PATCH 7/9] Fixing testing that failed after Uv additions --- SWS_App/src/__tests__/weather.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/SWS_App/src/__tests__/weather.test.ts b/SWS_App/src/__tests__/weather.test.ts index b978d25..ea8c0cc 100644 --- a/SWS_App/src/__tests__/weather.test.ts +++ b/SWS_App/src/__tests__/weather.test.ts @@ -43,6 +43,7 @@ const mockDailyResponse = { apparent_temperature_min: [12, 10], precipitation_probability_max: [10, 30], precipitation_sum: [0, 2.4], + snowfall_sum: [0, 0.5], weather_code: [1, 61], wind_speed_10m_max: [18, 25], uv_index_max: [4, 2], @@ -65,6 +66,7 @@ const mockHourlyResponse = { precipitation_probability: Array.from({ length: 48 }, () => 5), wind_speed_10m: Array.from({ length: 48 }, () => 10), weather_code: Array.from({ length: 48 }, () => 0), + uv_index: Array.from({ length: 48 }, () => 3), }, } @@ -131,6 +133,7 @@ describe('getDailyForecast', () => { feelsLikeMin: 12, precipProbability: 10, precipitationSum: 0, + snowfallSum: 0, weatherCode: 1, windSpeedMax: 18, uvIndexMax: 4, @@ -155,6 +158,7 @@ describe('getHourlyForecast', () => { expect(result[0]).toHaveProperty('precipProbability') expect(result[0]).toHaveProperty('windSpeed') expect(result[0]).toHaveProperty('weatherCode') + expect(result[0]).toHaveProperty('uvIndex') }) it('starts from location local time, not UTC, when utc_offset_seconds is negative', async () => { @@ -173,6 +177,7 @@ describe('getHourlyForecast', () => { precipitation_probability: Array.from({ length: 48 }, () => 0), wind_speed_10m: Array.from({ length: 48 }, () => 10), weather_code: Array.from({ length: 48 }, () => 0), + uv_index: Array.from({ length: 48 }, () => 0), }, } From bd84956f1def73e76ac04f9bbddcede632eb3d26 Mon Sep 17 00:00:00 2001 From: cph5236 Date: Fri, 13 Mar 2026 22:26:09 -0400 Subject: [PATCH 8/9] increment to version code 3 --- SWS_App/android/app/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SWS_App/android/app/build.gradle b/SWS_App/android/app/build.gradle index 40d6bd8..638e822 100644 --- a/SWS_App/android/app/build.gradle +++ b/SWS_App/android/app/build.gradle @@ -7,7 +7,7 @@ android { applicationId "com.cph5236.simpleweatherservice" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 2 + versionCode 3 versionName "0.0.4" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { From dd776509a62d17ba6ebebf8c93a542090f17594f Mon Sep 17 00:00:00 2001 From: cph5236 Date: Sun, 15 Mar 2026 22:35:04 -0400 Subject: [PATCH 9/9] Add humidity to the 10 day forcast and Use Current Location code --- SWS_App/README.md | 18 ++++ .../android/app/src/main/AndroidManifest.xml | 2 + SWS_App/ios/App/App/Info.plist | 2 + SWS_App/package.json | 3 +- SWS_App/src/__tests__/weather.test.ts | 4 + SWS_App/src/components/Forecast10Day.tsx | 8 +- SWS_App/src/components/SearchBar.tsx | 79 ++++++++++---- SWS_App/src/hooks/useWeather.ts | 36 ++++++- SWS_App/src/services/geocode.ts | 39 +++++++ SWS_App/src/services/weather.ts | 101 ++++++++++++------ SWS_App/src/types/weather.ts | 2 + scripts/bump-version.js | 53 +++++++++ 12 files changed, 289 insertions(+), 58 deletions(-) create mode 100644 scripts/bump-version.js diff --git a/SWS_App/README.md b/SWS_App/README.md index 86b2b11..b37c4c0 100644 --- a/SWS_App/README.md +++ b/SWS_App/README.md @@ -1,3 +1,21 @@ +# Simple Weather Service + +## Releasing + +Before merging a release branch or tagging, bump the version with: + +```bash +cd SWS_App +npm run bump patch # 0.0.4 → 0.0.5 (bug fixes) +npm run bump minor # 0.0.4 → 0.1.0 (new features) +npm run bump major # 0.0.4 → 1.0.0 (breaking changes) +``` + +This updates **both** `package.json` (version field) and `android/app/build.gradle` +(`versionCode` incremented by 1, `versionName` set to match). Commit the result before tagging. + +--- + # React + TypeScript + Vite This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. diff --git a/SWS_App/android/app/src/main/AndroidManifest.xml b/SWS_App/android/app/src/main/AndroidManifest.xml index b06ddbf..bc19ac8 100644 --- a/SWS_App/android/app/src/main/AndroidManifest.xml +++ b/SWS_App/android/app/src/main/AndroidManifest.xml @@ -38,4 +38,6 @@ + + diff --git a/SWS_App/ios/App/App/Info.plist b/SWS_App/ios/App/App/Info.plist index 9143626..afc9aa4 100644 --- a/SWS_App/ios/App/App/Info.plist +++ b/SWS_App/ios/App/App/Info.plist @@ -47,5 +47,7 @@ UIViewControllerBasedStatusBarAppearance + NSLocationWhenInUseUsageDescription + Simple Weather Service uses your location to show local weather conditions. diff --git a/SWS_App/package.json b/SWS_App/package.json index 43f7bd0..fa59d0e 100644 --- a/SWS_App/package.json +++ b/SWS_App/package.json @@ -17,7 +17,8 @@ "cap:ios": "npx cap open ios", "version:patch": "npm version patch --no-git-tag-version", "version:minor": "npm version minor --no-git-tag-version", - "version:major": "npm version major --no-git-tag-version" + "version:major": "npm version major --no-git-tag-version", + "bump": "node ../scripts/bump-version.js" }, "dependencies": { "@capacitor/android": "^8.2.0", diff --git a/SWS_App/src/__tests__/weather.test.ts b/SWS_App/src/__tests__/weather.test.ts index ea8c0cc..7b32325 100644 --- a/SWS_App/src/__tests__/weather.test.ts +++ b/SWS_App/src/__tests__/weather.test.ts @@ -46,7 +46,9 @@ const mockDailyResponse = { snowfall_sum: [0, 0.5], weather_code: [1, 61], wind_speed_10m_max: [18, 25], + wind_direction_10m_dominant: [270, 315], uv_index_max: [4, 2], + relative_humidity_2m_mean: [65, 72], sunrise: ['2026-03-11T06:30', '2026-03-12T06:31'], sunset: ['2026-03-11T18:30', '2026-03-12T18:31'], }, @@ -136,7 +138,9 @@ describe('getDailyForecast', () => { snowfallSum: 0, weatherCode: 1, windSpeedMax: 18, + windDirectionDominant: 270, uvIndexMax: 4, + humidityMean: 65, sunrise: '2026-03-11T06:30', sunset: '2026-03-11T18:30', }) diff --git a/SWS_App/src/components/Forecast10Day.tsx b/SWS_App/src/components/Forecast10Day.tsx index 122eaef..5d6e99f 100644 --- a/SWS_App/src/components/Forecast10Day.tsx +++ b/SWS_App/src/components/Forecast10Day.tsx @@ -14,6 +14,11 @@ function formatDay(dateStr: string, index: number): string { return date.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }) } +function degreesToCompass(deg: number): string { + const dirs = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'] + return dirs[Math.round(deg / 45) % 8] +} + function formatTime(isoStr: string): string { const date = new Date(isoStr) return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }) @@ -263,7 +268,8 @@ export function Forecast10Day({ days, units }: Forecast10DayProps) { label="Feels like" value={`High ${Math.round(day.feelsLikeMax)}${tempUnit} / Low ${Math.round(day.feelsLikeMin)}${tempUnit}`} /> - + + {day.snowfallSum > 0 && ( diff --git a/SWS_App/src/components/SearchBar.tsx b/SWS_App/src/components/SearchBar.tsx index 4d27550..35b0caa 100644 --- a/SWS_App/src/components/SearchBar.tsx +++ b/SWS_App/src/components/SearchBar.tsx @@ -1,5 +1,5 @@ import { useEffect, useId, useRef, useState } from 'react' -import { searchCity } from '../services/geocode' +import { reverseGeocode, searchCity } from '../services/geocode' import type { Location } from '../types/weather' interface SearchBarProps { @@ -11,16 +11,21 @@ export function SearchBar({ onSelect, placeholder = 'Search for a city…' }: Se const [query, setQuery] = useState('') const [results, setResults] = useState([]) const [loading, setLoading] = useState(false) - const [open, setOpen] = useState(false) + const [focused, setFocused] = useState(false) const [activeIndex, setActiveIndex] = useState(-1) + const [locating, setLocating] = useState(false) + const [locationError, setLocationError] = useState(null) const inputId = useId() const listId = useId() const inputRef = useRef(null) + // totalItems = GPS option (1) + search results + const totalItems = 1 + results.length + const open = focused + useEffect(() => { if (!query.trim()) { setResults([]) - setOpen(false) return } @@ -29,11 +34,9 @@ export function SearchBar({ onSelect, placeholder = 'Search for a city…' }: Se try { const locs = await searchCity(query) setResults(locs) - setOpen(locs.length > 0) setActiveIndex(-1) } catch { setResults([]) - setOpen(false) } finally { setLoading(false) } @@ -46,27 +49,53 @@ export function SearchBar({ onSelect, placeholder = 'Search for a city…' }: Se onSelect(loc) setQuery('') setResults([]) - setOpen(false) + setFocused(false) setActiveIndex(-1) } + function handleGpsSelect() { + if (!navigator.geolocation) return + setLocating(true) + setFocused(false) + setActiveIndex(-1) + navigator.geolocation.getCurrentPosition( + async (pos) => { + try { + const loc = await reverseGeocode(pos.coords.latitude, pos.coords.longitude) + onSelect(loc) + } catch { + // silent fail — user stays on current location + } finally { + setLocating(false) + } + }, + (err) => { + setLocating(false) + setLocationError(err.code === err.PERMISSION_DENIED ? 'Location access denied' : 'Could not get location') + }, + { timeout: 10000 } + ) + } + function handleKeyDown(e: React.KeyboardEvent) { if (!open) return if (e.key === 'ArrowDown') { e.preventDefault() - setActiveIndex((i) => Math.min(i + 1, results.length - 1)) + setActiveIndex((i) => Math.min(i + 1, totalItems - 1)) } else if (e.key === 'ArrowUp') { e.preventDefault() setActiveIndex((i) => Math.max(i - 1, 0)) } else if (e.key === 'Enter') { e.preventDefault() - if (activeIndex >= 0 && results[activeIndex]) { - handleSelect(results[activeIndex]) - } else if (results.length === 1) { + if (activeIndex === 0) { + handleGpsSelect() + } else if (activeIndex > 0 && results[activeIndex - 1]) { + handleSelect(results[activeIndex - 1]) + } else if (activeIndex === -1 && results.length === 1) { handleSelect(results[0]) } } else if (e.key === 'Escape') { - setOpen(false) + setFocused(false) setActiveIndex(-1) } } @@ -83,10 +112,10 @@ export function SearchBar({ onSelect, placeholder = 'Search for a city…' }: Se className="form-control" placeholder={placeholder} value={query} - onChange={(e) => setQuery(e.target.value)} + onChange={(e) => { setQuery(e.target.value); setLocationError(null) }} onKeyDown={handleKeyDown} - onFocus={() => results.length > 0 && setOpen(true)} - onBlur={() => setTimeout(() => setOpen(false), 150)} + onFocus={() => { setFocused(true); setLocationError(null) }} + onBlur={() => setTimeout(() => setFocused(false), 150)} role="combobox" aria-label="Search for a city" aria-expanded={open} @@ -95,13 +124,15 @@ export function SearchBar({ onSelect, placeholder = 'Search for a city…' }: Se aria-activedescendant={activeOptionId} autoComplete="off" /> - {loading && ( + {(loading || locating) && ( )}
+ {locationError &&
{locationError}
} + {open && (
    +
  • + + Use current location +
  • {results.map((loc, i) => (
  • handleSelect(loc)} > {loc.name} {loc.admin1 ? `, ${loc.admin1}` : ''} - {loc.country} + {loc.country}
  • ))}
diff --git a/SWS_App/src/hooks/useWeather.ts b/SWS_App/src/hooks/useWeather.ts index c1aa33f..914e64b 100644 --- a/SWS_App/src/hooks/useWeather.ts +++ b/SWS_App/src/hooks/useWeather.ts @@ -1,6 +1,10 @@ import { useEffect, useRef, useState } from 'react' import { + DAILY_TTL_MS, + HOURLY_TTL_MS, clearCurrentWeatherCache, + clearDailyWeatherCache, + clearHourlyWeatherCache, getCurrentWeather, getDailyForecast, getHourlyForecast, @@ -27,6 +31,8 @@ export function useWeather( error: null, }) const [lastCurrentFetch, setLastCurrentFetch] = useState(0) + const [lastHourlyFetch, setLastHourlyFetch] = useState(0) + const [lastDailyFetch, setLastDailyFetch] = useState(0) const fetchCountRef = useRef(0) @@ -43,7 +49,10 @@ export function useWeather( ]) .then(([current, daily, hourly]) => { if (fetchId !== fetchCountRef.current) return - setLastCurrentFetch(Date.now()) + const ts = Date.now() + setLastCurrentFetch(ts) + setLastHourlyFetch(ts) + setLastDailyFetch(ts) setState({ current, daily, hourly, loading: false, error: null }) }) .catch((err: unknown) => { @@ -55,12 +64,29 @@ export function useWeather( function refetchCurrent() { if (!location) return + clearCurrentWeatherCache(location.lat, location.lon, units) - getCurrentWeather(location.lat, location.lon, units) - .then((current) => { - setLastCurrentFetch(Date.now()) - setState((prev) => ({ ...prev, current })) + const now = Date.now() + const hourlyStale = now - lastHourlyFetch > HOURLY_TTL_MS + const dailyStale = now - lastDailyFetch > DAILY_TTL_MS + + // Clear forecast caches only when TTL has expired; otherwise the service + // recomputes the hourly slice from cached raw data (free, no API call). + if (hourlyStale) clearHourlyWeatherCache(location.lat, location.lon, units) + if (dailyStale) clearDailyWeatherCache(location.lat, location.lon, units) + + Promise.all([ + getCurrentWeather(location.lat, location.lon, units), + getHourlyForecast(location.lat, location.lon, units), + getDailyForecast(location.lat, location.lon, units), + ]) + .then(([current, hourly, daily]) => { + const ts = Date.now() + setLastCurrentFetch(ts) + if (hourlyStale) setLastHourlyFetch(ts) + if (dailyStale) setLastDailyFetch(ts) + setState((prev) => ({ ...prev, current, hourly, daily })) }) .catch((err: unknown) => { const message = err instanceof Error ? err.message : 'Failed to fetch weather data' diff --git a/SWS_App/src/services/geocode.ts b/SWS_App/src/services/geocode.ts index 1e11df4..9e5190b 100644 --- a/SWS_App/src/services/geocode.ts +++ b/SWS_App/src/services/geocode.ts @@ -22,6 +22,45 @@ interface GeocodingResponse { results?: GeocodingResult[] } +const NOMINATIM_URL = 'https://nominatim.openstreetmap.org/reverse' + +interface NominatimResponse { + address: { + city?: string + town?: string + village?: string + county?: string + state?: string + country: string + } +} + +export async function reverseGeocode(lat: number, lon: number): Promise { + const url = `${NOMINATIM_URL}?lat=${lat}&lon=${lon}&format=json` + let res: Response + try { + res = await fetch(url, { headers: { 'Accept-Language': 'en' } }) + } catch (err) { + throw new GeocodingError(`Network error: ${err instanceof Error ? err.message : 'Unknown error'}`) + } + if (!res.ok) throw new GeocodingError(`Geocoding API error: ${res.statusText}`) + + const data: NominatimResponse = await res.json() + const name = + data.address.city ?? + data.address.town ?? + data.address.village ?? + data.address.county ?? + 'Current Location' + return { + name, + lat, + lon, + country: data.address.country ?? '', + ...(data.address.state ? { admin1: data.address.state } : {}), + } +} + export async function searchCity(query: string): Promise { if (!query.trim()) return [] diff --git a/SWS_App/src/services/weather.ts b/SWS_App/src/services/weather.ts index 4657ae6..566c4bd 100644 --- a/SWS_App/src/services/weather.ts +++ b/SWS_App/src/services/weather.ts @@ -18,8 +18,10 @@ interface CacheEntry { } const cache = new Map>() -const CURRENT_TTL_MS = 60 * 1000 -const FORECAST_TTL_MS = 30 * 60 * 1000 + +export const CURRENT_TTL_MS = 60 * 1000 +export const HOURLY_TTL_MS = 60 * 60 * 1000 +export const DAILY_TTL_MS = 4 * 60 * 60 * 1000 export function clearWeatherCache(): void { cache.clear() @@ -29,6 +31,14 @@ export function clearCurrentWeatherCache(lat: number, lon: number, units: Units) cache.delete(`current,${lat},${lon},${units}`) } +export function clearHourlyWeatherCache(lat: number, lon: number, units: Units): void { + cache.delete(`hourly,${lat},${lon},${units}`) +} + +export function clearDailyWeatherCache(lat: number, lon: number, units: Units): void { + cache.delete(`daily,${lat},${lon},${units}`) +} + function getCached(key: string): T | null { const entry = cache.get(key) if (!entry) return null @@ -106,7 +116,7 @@ export async function getDailyForecast(lat: number, lon: number, units: Units): const url = `${BASE_URL}?latitude=${lat}&longitude=${lon}` + `&daily=temperature_2m_max,temperature_2m_min,apparent_temperature_max,apparent_temperature_min,` + - `precipitation_probability_max,precipitation_sum,snowfall_sum,weather_code,wind_speed_10m_max,uv_index_max,sunrise,sunset&forecast_days=10` + + `precipitation_probability_max,precipitation_sum,snowfall_sum,weather_code,wind_speed_10m_max,wind_direction_10m_dominant,uv_index_max,relative_humidity_2m_mean,sunrise,sunset&forecast_days=10` + unitParams(units) + '&timezone=auto' const json = await fetchWeather(url) @@ -122,7 +132,9 @@ export async function getDailyForecast(lat: number, lon: number, units: Units): snowfall_sum: number[] weather_code: number[] wind_speed_10m_max: number[] + wind_direction_10m_dominant: number[] uv_index_max: number[] + relative_humidity_2m_mean: number[] sunrise: string[] sunset: string[] } @@ -139,52 +151,77 @@ export async function getDailyForecast(lat: number, lon: number, units: Units): snowfallSum: data.daily.snowfall_sum[i] ?? 0, weatherCode: data.daily.weather_code[i], windSpeedMax: data.daily.wind_speed_10m_max[i], + windDirectionDominant: data.daily.wind_direction_10m_dominant[i] ?? 0, uvIndexMax: data.daily.uv_index_max[i], + humidityMean: data.daily.relative_humidity_2m_mean[i] ?? 0, sunrise: data.daily.sunrise[i], sunset: data.daily.sunset[i], })) - setCached(key, result, FORECAST_TTL_MS) + setCached(key, result, DAILY_TTL_MS) return result } +// Raw 48-hour data stored in cache; slice is recomputed on every call so past +// hours are automatically trimmed without an extra API request. +interface HourlyRawData { + utcOffsetSeconds: number + time: string[] + temperature_2m: number[] + precipitation_probability: number[] + wind_speed_10m: number[] + weather_code: number[] + uv_index: number[] +} + export async function getHourlyForecast(lat: number, lon: number, units: Units): Promise { const key = `hourly,${lat},${lon},${units}` - const cached = getCached(key) - if (cached) return cached - const url = - `${BASE_URL}?latitude=${lat}&longitude=${lon}` + - `&hourly=temperature_2m,precipitation_probability,wind_speed_10m,weather_code,uv_index&forecast_days=2` + - unitParams(units) + '&timezone=auto' + let raw = getCached(key) + + if (!raw) { + const url = + `${BASE_URL}?latitude=${lat}&longitude=${lon}` + + `&hourly=temperature_2m,precipitation_probability,wind_speed_10m,weather_code,uv_index&forecast_days=2` + + unitParams(units) + '&timezone=auto' + + const json = await fetchWeather(url) + const data = json as { + utc_offset_seconds: number + hourly: { + time: string[] + temperature_2m: number[] + precipitation_probability: number[] + wind_speed_10m: number[] + weather_code: number[] + uv_index: number[] + } + } - const json = await fetchWeather(url) - const data = json as { - utc_offset_seconds: number - hourly: { - time: string[] - temperature_2m: number[] - precipitation_probability: number[] - wind_speed_10m: number[] - weather_code: number[] - uv_index: number[] + raw = { + utcOffsetSeconds: data.utc_offset_seconds, + time: data.hourly.time, + temperature_2m: data.hourly.temperature_2m, + precipitation_probability: data.hourly.precipitation_probability, + wind_speed_10m: data.hourly.wind_speed_10m, + weather_code: data.hourly.weather_code, + uv_index: data.hourly.uv_index, } - } - const currentHour = new Date(Date.now() + data.utc_offset_seconds * 1000).toISOString().slice(0, 13) + setCached(key, raw, HOURLY_TTL_MS) + } - const startIndex = data.hourly.time.findIndex((t) => t >= currentHour) + // Always recompute the slice from current local time so past hours are trimmed. + const currentHour = new Date(Date.now() + raw.utcOffsetSeconds * 1000).toISOString().slice(0, 13) + const startIndex = raw.time.findIndex((t) => t >= currentHour) const sliceStart = startIndex === -1 ? 0 : startIndex - const result: HourlyForecast[] = data.hourly.time.slice(sliceStart, sliceStart + 24).map((time, i) => ({ + return raw.time.slice(sliceStart, sliceStart + 24).map((time, i) => ({ time, - temperature: data.hourly.temperature_2m[sliceStart + i], - precipProbability: data.hourly.precipitation_probability[sliceStart + i] ?? 0, - windSpeed: data.hourly.wind_speed_10m[sliceStart + i], - weatherCode: data.hourly.weather_code[sliceStart + i], - uvIndex: data.hourly.uv_index[sliceStart + i] ?? 0, + temperature: raw.temperature_2m[sliceStart + i], + precipProbability: raw.precipitation_probability[sliceStart + i] ?? 0, + windSpeed: raw.wind_speed_10m[sliceStart + i], + weatherCode: raw.weather_code[sliceStart + i], + uvIndex: raw.uv_index[sliceStart + i] ?? 0, })) - - setCached(key, result, FORECAST_TTL_MS) - return result } diff --git a/SWS_App/src/types/weather.ts b/SWS_App/src/types/weather.ts index e7a49d5..ae04a1b 100644 --- a/SWS_App/src/types/weather.ts +++ b/SWS_App/src/types/weather.ts @@ -39,7 +39,9 @@ export interface DailyForecast { snowfallSum: number weatherCode: number windSpeedMax: number + windDirectionDominant: number uvIndexMax: number + humidityMean: number sunrise: string sunset: string } diff --git a/scripts/bump-version.js b/scripts/bump-version.js new file mode 100644 index 0000000..8c941e6 --- /dev/null +++ b/scripts/bump-version.js @@ -0,0 +1,53 @@ +#!/usr/bin/env node +// Usage: node scripts/bump-version.js [patch|minor|major] +// or: cd SWS_App && npm run bump [patch|minor|major] +// Defaults to "patch" if no argument is given. + +import { readFileSync, writeFileSync } from 'fs' +import { fileURLToPath } from 'url' +import { dirname, join } from 'path' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const root = join(__dirname, '..') + +const bumpType = process.argv[2] ?? 'patch' +if (!['patch', 'minor', 'major'].includes(bumpType)) { + console.error(`Unknown bump type: "${bumpType}". Use patch, minor, or major.`) + process.exit(1) +} + +// --- package.json --- +const pkgPath = join(root, 'SWS_App', 'package.json') +const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) +const oldVersion = pkg.version + +const [major, minor, patch] = oldVersion.split('.').map(Number) +let newVersion +if (bumpType === 'major') newVersion = `${major + 1}.0.0` +else if (bumpType === 'minor') newVersion = `${major}.${minor + 1}.0` +else newVersion = `${major}.${minor}.${patch + 1}` + +pkg.version = newVersion +writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf8') + +// --- build.gradle --- +const gradlePath = join(root, 'SWS_App', 'android', 'app', 'build.gradle') +let gradle = readFileSync(gradlePath, 'utf8') + +const oldVersionCode = Number(gradle.match(/versionCode\s+(\d+)/)?.[1]) +if (isNaN(oldVersionCode)) { + console.error('Could not parse versionCode from build.gradle') + process.exit(1) +} +const newVersionCode = oldVersionCode + 1 + +gradle = gradle + .replace(/versionCode\s+\d+/, `versionCode ${newVersionCode}`) + .replace(/versionName\s+"[^"]+"/, `versionName "${newVersion}"`) + +writeFileSync(gradlePath, gradle, 'utf8') + +console.log(`Bumped ${bumpType}: ${oldVersion} → ${newVersion}`) +console.log(` package.json version: ${newVersion}`) +console.log(` build.gradle versionCode: ${oldVersionCode} → ${newVersionCode}`) +console.log(` build.gradle versionName: "${newVersion}"`)