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/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/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/build.gradle b/SWS_App/android/app/build.gradle index 3581b82..638e822 100644 --- a/SWS_App/android/app/build.gradle +++ b/SWS_App/android/app/build.gradle @@ -7,8 +7,8 @@ android { applicationId "com.cph5236.simpleweatherservice" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 2 - versionName "0.0.3" + versionCode 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/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/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/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/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-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..fa59d0e 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", @@ -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 62ddca3..7b32325 100644 --- a/SWS_App/src/__tests__/weather.test.ts +++ b/SWS_App/src/__tests__/weather.test.ts @@ -39,24 +39,36 @@ 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], + 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'], }, } +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), weather_code: Array.from({ length: 48 }, () => 0), + uv_index: Array.from({ length: 48 }, () => 3), }, } @@ -119,8 +131,16 @@ describe('getDailyForecast', () => { date: '2026-03-11', tempMax: 24, tempMin: 14, + feelsLikeMax: 22, + feelsLikeMin: 12, precipProbability: 10, + precipitationSum: 0, + snowfallSum: 0, weatherCode: 1, + windSpeedMax: 18, + windDirectionDominant: 270, + uvIndexMax: 4, + humidityMean: 65, sunrise: '2026-03-11T06:30', sunset: '2026-03-11T18:30', }) @@ -142,5 +162,35 @@ 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 () => { + // 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), + uv_index: 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/CurrentWeatherCard.tsx b/SWS_App/src/components/CurrentWeatherCard.tsx index 4a39856..81b6814 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,33 @@ 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 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) + } + }, [lastRefreshed]) + + const canRefresh = secondsLeft === 0 + return (
@@ -36,15 +64,27 @@ export function CurrentWeatherCard({ location, weather, isSaved, onSaveToggle }: {location.country}
- +
+ + +
diff --git a/SWS_App/src/components/Forecast10Day.tsx b/SWS_App/src/components/Forecast10Day.tsx index 5721302..5d6e99f 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,39 +14,275 @@ 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 }) +} + +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)) + } return (
-

10-Day Forecast

-
    - {days.map((day, i) => ( -
  • -
    -
    - {formatDay(day.date, i)} -
    - -
    - {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 */} + + + {/* 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

+ +
+
(
{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/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}"`)