Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name: CI
on:
push:
pull_request:
branches: [main]
branches: [main, release/*]

defaults:
run:
Expand Down
17 changes: 16 additions & 1 deletion .github/workflows/mobile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: Mobile Builds

on:
push:
branches: [main]
branches: [main, release/*]
workflow_dispatch:

jobs:
Expand Down Expand Up @@ -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 }}
Expand All @@ -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 \
Expand All @@ -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
18 changes: 18 additions & 0 deletions SWS_App/README.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
4 changes: 2 additions & 2 deletions SWS_App/android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions SWS_App/android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,6 @@
<!-- Permissions -->

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
</manifest>
2 changes: 1 addition & 1 deletion SWS_App/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
2 changes: 1 addition & 1 deletion SWS_App/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>my-app</title>
<title>Simple Weather Service</title>
</head>
<body>
<div id="root"></div>
Expand Down
2 changes: 2 additions & 0 deletions SWS_App/ios/App/App/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -47,5 +47,7 @@
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Simple Weather Service uses your location to show local weather conditions.</string>
</dict>
</plist>
4 changes: 2 additions & 2 deletions SWS_App/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions SWS_App/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "sws-app",
"private": true,
"version": "0.0.3",
"version": "0.0.4",
"type": "module",
"scripts": {
"dev": "vite",
Expand All @@ -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",
Expand Down
60 changes: 55 additions & 5 deletions SWS_App/src/__tests__/weather.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
},
}

Expand Down Expand Up @@ -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',
})
Expand All @@ -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()
})
})
60 changes: 50 additions & 10 deletions SWS_App/src/components/CurrentWeatherCard.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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 (
<div className="card shadow-sm">
<div className="card-body">
Expand All @@ -36,15 +64,27 @@ export function CurrentWeatherCard({ location, weather, isSaved, onSaveToggle }:
</h2>
<span className="text-muted small">{location.country}</span>
</div>
<button
type="button"
className={`btn btn-sm ${isSaved ? 'btn-warning' : 'btn-outline-secondary'}`}
onClick={onSaveToggle}
aria-label={isSaved ? 'Remove from saved locations' : 'Save this location'}
title={isSaved ? 'Saved' : 'Save location'}
>
{isSaved ? '★' : '☆'}
</button>
<div className="d-flex gap-2">
<button
type="button"
className="btn btn-sm btn-outline-secondary"
onClick={onRefresh}
disabled={!canRefresh}
aria-label="Refresh current weather"
title={canRefresh ? 'Refresh current weather' : `Refresh available in ${secondsLeft}s`}
>
{canRefresh ? '↻' : `↻ ${secondsLeft}s`}
</button>
<button
type="button"
className={`btn btn-sm ${isSaved ? 'btn-warning' : 'btn-outline-secondary'}`}
onClick={onSaveToggle}
aria-label={isSaved ? 'Remove from saved locations' : 'Save this location'}
title={isSaved ? 'Saved' : 'Save location'}
>
{isSaved ? '★' : '☆'}
</button>
</div>
</div>

<div className="d-flex align-items-center gap-3 mb-3">
Expand Down
Loading
Loading