-
- {formatDay(day.date, i)}
-
-
- {getWeatherEmoji(day.weatherCode)}
-
-
- {day.precipProbability > 0 && (
+
+ 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 (
+
+ {/* 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 */}
- 💧{day.precipProbability}%
+ {formatDay(day.date, i)}
- )}
-
-
{Math.round(day.tempMax)}{tempUnit}
-
/ {Math.round(day.tempMin)}{tempUnit}
+
+ {/* Emoji */}
+
+ {getWeatherEmoji(day.weatherCode)}
+
+
+ {/* Temp range — mobile: compact text; sm+: visual bar */}
+ {/* Mobile text fallback */}
+
+ {Math.round(day.tempMin)}
+ –
+ {Math.round(day.tempMax)}{tempUnit}
+
+
+ {/* Desktop bar */}
+
+ {/* Min label — above bar, centered on fill start */}
+
+ {Math.round(day.tempMin)}{tempUnit}
+
+ {/* Max label — above bar, centered on fill end */}
+
+ {Math.round(day.tempMax)}{tempUnit}
+
+ {/* Bar track */}
+
+ {/* Colored fill */}
+
+
+
+
+ {/* Precip badge */}
+ {day.precipProbability > 0 ? (
+
+ 💧 {day.precipProbability}%
+
+ ) : (
+
+ )}
+
+ {/* Chevron */}
+
+
+
+
+ {/* Expanded panel */}
+ {isExpanded && (
+
+
+
+
+
+
+ {day.snowfallSum > 0 && (
+
+ )}
+
+
+
+
+ )}
-
- ))}
-
+ )
+ })}
+
)
}
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
+ setExpanded((e) => !e)}
+ aria-expanded={expanded}
+ aria-label={expanded ? 'Hide UV index chart' : 'Show UV index chart'}
+ style={{ textDecoration: 'none', fontSize: '0.75rem' }}
+ >
+ UV Index {expanded ? '▲' : '▼'}
+
+
+
(
{formatHour(hour.time)}
@@ -44,6 +95,73 @@ export function Hourly24({ hours, units }: Hourly24Props) {
))}
+
+ {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 (
+
+
+
+
+ {formatHour(hour.time)}
+
+
+ )
+ })}
+
+
+ {UV_LEGEND.map(({ label, color }) => (
+
+ ))}
+
+
+ )}
)
}
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 81c2233..914e64b 100644
--- a/SWS_App/src/hooks/useWeather.ts
+++ b/SWS_App/src/hooks/useWeather.ts
@@ -1,5 +1,14 @@
import { useEffect, useRef, useState } from 'react'
-import { getCurrentWeather, getDailyForecast, getHourlyForecast } from '../services/weather'
+import {
+ DAILY_TTL_MS,
+ HOURLY_TTL_MS,
+ clearCurrentWeatherCache,
+ clearDailyWeatherCache,
+ clearHourlyWeatherCache,
+ getCurrentWeather,
+ getDailyForecast,
+ getHourlyForecast,
+} from '../services/weather'
import type { CurrentWeather, DailyForecast, HourlyForecast, Location, Units } from '../types/weather'
interface WeatherState {
@@ -13,7 +22,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 +30,9 @@ export function useWeather(
loading: false,
error: null,
})
+ const [lastCurrentFetch, setLastCurrentFetch] = useState(0)
+ const [lastHourlyFetch, setLastHourlyFetch] = useState(0)
+ const [lastDailyFetch, setLastDailyFetch] = useState(0)
const fetchCountRef = useRef(0)
@@ -37,6 +49,10 @@ export function useWeather(
])
.then(([current, daily, hourly]) => {
if (fetchId !== fetchCountRef.current) return
+ const ts = Date.now()
+ setLastCurrentFetch(ts)
+ setLastHourlyFetch(ts)
+ setLastDailyFetch(ts)
setState({ current, daily, hourly, loading: false, error: null })
})
.catch((err: unknown) => {
@@ -46,10 +62,42 @@ export function useWeather(
})
}
+ function refetchCurrent() {
+ if (!location) return
+
+ clearCurrentWeatherCache(location.lat, location.lon, units)
+
+ 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'
+ 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/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 1bcad05..566c4bd 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,
- public code: number
+ code: number
) {
super(message)
this.name = 'WeatherError'
+ this.code = code
}
}
@@ -16,12 +18,27 @@ interface CacheEntry {
}
const cache = new Map>()
-const TTL_MS = 10 * 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()
}
+export function clearCurrentWeatherCache(lat: number, lon: number, units: Units): void {
+ 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
@@ -32,15 +49,15 @@ 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'
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 +78,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 {
@@ -87,7 +104,7 @@ export async function getCurrentWeather(lat: number, lon: number, units: Units):
units,
}
- setCached(key, result)
+ setCached(key, result, CURRENT_TTL_MS)
return result
}
@@ -98,8 +115,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,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)
const data = json as {
@@ -107,8 +125,16 @@ 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[]
+ 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[]
}
@@ -118,51 +144,84 @@ 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,
+ 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)
+ 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&forecast_days=2` +
- unitParams(units)
+ 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 {
- hourly: {
- time: string[]
- temperature_2m: number[]
- precipitation_probability: number[]
- wind_speed_10m: number[]
- weather_code: 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 now = new Date()
- const currentHour = now.toISOString().slice(0, 13) // "YYYY-MM-DDTHH"
+ 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],
+ 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)
- return result
}
diff --git a/SWS_App/src/types/weather.ts b/SWS_App/src/types/weather.ts
index 2aecd23..ae04a1b 100644
--- a/SWS_App/src/types/weather.ts
+++ b/SWS_App/src/types/weather.ts
@@ -25,14 +25,23 @@ export interface HourlyForecast {
precipProbability: number
windSpeed: number
weatherCode: number
+ uvIndex: number
}
export interface DailyForecast {
date: string
tempMax: number
tempMin: number
+ feelsLikeMax: number
+ feelsLikeMin: number
precipProbability: number
+ precipitationSum: number
+ 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}"`)