Compare commits

...

10 commits

Author SHA1 Message Date
Lance Edgar
52b83d05bb build: explicitly use npm lts/iron when building package 2024-07-02 19:49:25 -05:00
Lance Edgar
a09d50af2c Update changelog 2024-06-09 19:32:46 -05:00
Lance Edgar
c92d9aae0d Fix URL bug when refreshing weather radar 2024-06-09 19:32:17 -05:00
Lance Edgar
d4cf4e1c3e Update changelog 2024-06-09 19:28:02 -05:00
Lance Edgar
fa1e702173 Add timestamp param to bust cache when refreshing radar images 2024-06-09 19:26:42 -05:00
Lance Edgar
cd615cb020 Warning notification should not butt up against forecast panel 2024-06-09 16:41:27 -05:00
Lance Edgar
d2afc8469d Update changelog 2024-06-09 16:29:15 -05:00
Lance Edgar
a1f84465cc Add refresh buttons to weather data pages 2024-06-09 16:27:04 -05:00
Lance Edgar
7a14101e01 Convert all view components to use Composition API
might as well start getting used to this new hotness
2024-06-09 15:45:38 -05:00
Lance Edgar
5e238aa2aa Add link to national radar map (live image) 2024-06-09 00:32:57 -05:00
12 changed files with 321 additions and 261 deletions

1
.gitignore vendored
View file

@ -28,3 +28,4 @@ coverage
*.sw? *.sw?
*.tsbuildinfo *.tsbuildinfo
*~

View file

@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased ## Unreleased
## 0.1.10 - 2024-06-09
### Changed
- Fix URL bug when refreshing weather radar.
## 0.1.9 - 2024-06-09
### Changed
- Warning notification should not butt up against forecast panel.
- Add timestamp param to bust cache when refreshing radar images.
## 0.1.8 - 2024-06-09
### Added
- Add link to national radar map (live image).
- Add refresh buttons to weather data pages.
### Changed
- Convert all view components to use Composition API.
## 0.1.7 - 2024-06-08 ## 0.1.7 - 2024-06-08
### Added ### Added
- Add basic support for active weather alerts. - Add basic support for active weather alerts.

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{ {
"name": "myweather", "name": "myweather",
"version": "0.1.7", "version": "0.1.10",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "myweather", "name": "myweather",
"version": "0.1.7", "version": "0.1.10",
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.5.2", "@fortawesome/fontawesome-svg-core": "^6.5.2",
"@fortawesome/free-solid-svg-icons": "^6.5.2", "@fortawesome/free-solid-svg-icons": "^6.5.2",

View file

@ -1,6 +1,6 @@
{ {
"name": "myweather", "name": "myweather",
"version": "0.1.7", "version": "0.1.10",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View file

@ -26,9 +26,11 @@ export const useWeatherStore = defineStore('weather', {
actions: { actions: {
clearWeather() { clearWeather(keepCoordinates) {
if (!keepCoordinates) {
this.setCoordinates(null) this.setCoordinates(null)
this.setCityState(null) this.setCityState(null)
}
this.setWeather(null) this.setWeather(null)
this.setForecast(null) this.setForecast(null)
this.radarLatestURL = null this.radarLatestURL = null
@ -103,10 +105,10 @@ export const useWeatherStore = defineStore('weather', {
// put "likely" before "possible" alerts // put "likely" before "possible" alerts
newAlerts.features.sort((a, b) => { newAlerts.features.sort((a, b) => {
if (a.properties.certainty == 'Likely' && b.properties.certainty == 'Possible') { if (a.properties.certainty == 'Likely' && b.properties.certainty != 'Likely') {
return -1 return -1
} }
if (a.properties.certainty == 'Possible' && b.properties.certainty == 'Likely') { if (a.properties.certainty != 'Likely' && b.properties.certainty == 'Likely') {
return 1 return 1
} }
@ -129,7 +131,7 @@ export const useWeatherStore = defineStore('weather', {
if (!this.forecast) { if (!this.forecast) {
const weather = await this.getWeather(this.coordinates) const weather = await this.getWeather()
const url = weather.properties.forecast const url = weather.properties.forecast
const response = await fetch(url) const response = await fetch(url)

View file

@ -2,17 +2,6 @@
import appsettings from '../appsettings' import appsettings from '../appsettings'
</script> </script>
<script>
export default {
data() {
return {
appsettings,
}
},
}
</script>
<template> <template>
<div class="about"> <div class="about">
<h4 class="is-size-4">{{ appsettings.appTitle }} {{ appsettings.appVersion }}</h4> <h4 class="is-size-4">{{ appsettings.appTitle }} {{ appsettings.appVersion }}</h4>

View file

@ -1,30 +1,44 @@
<script setup> <script setup>
import { mapStores } from 'pinia' import { ref, onActivated } from 'vue'
import { useWeatherStore } from '../stores/weather' import { useWeatherStore } from '../stores/weather'
</script>
<script> const weatherStore = useWeatherStore()
export default {
computed: { const refreshing = ref(false)
...mapStores(useWeatherStore),
},
activated() { onActivated(() => {
this.weatherStore.getWeather() refreshing.value = true
}, weatherStore.getWeather().then(() => {
refreshing.value = false
})
})
async function refreshAlerts() {
refreshing.value = true
weatherStore.clearWeather(true)
await weatherStore.getWeather()
refreshing.value = false
} }
</script> </script>
<template> <template>
<main> <main>
<div class="block"
style="display: flex; align-items: center; justify-content: space-between;">
<o-button variant="primary" <o-button variant="primary"
size="small"
icon-left="arrow-left" icon-left="arrow-left"
@click="$router.push('/weather')"> @click="$router.push('/weather')">
Back Back
</o-button> </o-button>
<o-button variant="primary"
icon-left="refresh"
@click="refreshAlerts()"
:disabled="refreshing">
{{ refreshing ? "Refreshing" : "Refresh" }}
</o-button>
</div>
<h5 class="is-size-5">{{ weatherStore.cityState }}</h5> <h5 class="is-size-5">{{ weatherStore.cityState }}</h5>
<h5 class="is-size-5">Alerts</h5> <h5 class="is-size-5">Alerts</h5>

View file

@ -1,22 +1,12 @@
<script setup> <script setup>
import { mapStores } from 'pinia'
import { useLocationStore } from '../stores/location' import { useLocationStore } from '../stores/location'
</script>
<script> const locationStore = useLocationStore()
export default {
computed: { function deleteLocation(location) {
...mapStores(useLocationStore), locationStore.removeLocation(location)
},
methods: {
deleteLocation(location) {
this.locationStore.removeLocation(location)
},
},
} }
</script> </script>
<template> <template>

View file

@ -1,77 +1,75 @@
<script setup> <script setup>
import { mapStores } from 'pinia' import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useWeatherStore } from '../stores/weather' import { useWeatherStore } from '../stores/weather'
import { useLocationStore } from '../stores/location' import { useLocationStore } from '../stores/location'
</script> import { useOruga } from '@oruga-ui/oruga-next'
<script>
export default {
data() { const router = useRouter()
return { const locationStore = useLocationStore()
coordinates: null, const weatherStore = useWeatherStore()
loading: false, const oruga = useOruga()
locationAccessBlocked: false,
}
},
computed: { const coordinates = ref(null)
...mapStores(useWeatherStore, useLocationStore), const coordinatesInput = ref()
}, const loading = ref(false)
const locationAccessBlocked = ref(false)
methods: {
useCurrentLocation() { function useCurrentLocation() {
if (this.locationAccessBlocked) { if (locationAccessBlocked.value) {
alert("You must refresh the page first, then try again.") alert("You must refresh the page first, then try again.")
return return
} }
navigator.geolocation.getCurrentPosition(loc => { navigator.geolocation.getCurrentPosition(loc => {
this.coordinates = `${loc.coords.latitude},${loc.coords.longitude}` coordinates.value = `${loc.coords.latitude},${loc.coords.longitude}`
this.loadCoordinates() loadCoordinates()
}, error => { }, error => {
if (error.code == 1) { // PERMISSION_DENIED if (error.code == 1) { // PERMISSION_DENIED
this.locationAccessBlocked = true locationAccessBlocked.value = true
} }
alert(`error.code = ${error.code}\n\n${error.message}`) alert(`error.code = ${error.code}\n\n${error.message}`)
}) })
}, }
editLocations() {
this.$router.push('/edit-list')
},
async loadCoordinates() { function editLocations() {
if (!this.coordinates) { router.push('/edit-list')
this.$refs.coordinates.focus() }
async function loadCoordinates() {
if (!coordinates.value) {
coordinatesInput.value.focus()
return return
} }
const parts = this.coordinates.split(/(?: +| *\, *)/) const parts = coordinates.value.split(/(?: +| *\, *)/)
const pattern = /^ *-?\d+(?:\.\d+)? *$/ const pattern = /^ *-?\d+(?:\.\d+)? *$/
if (parts.length != 2 if (parts.length != 2
|| !parts[0].match(pattern) || !parts[0].match(pattern)
|| !parts[1].match(pattern)) { || !parts[1].match(pattern)) {
this.$oruga.notification.open({ oruga.notification.open({
variant: 'warning', variant: 'warning',
message: "Coordinates are not valid.", message: "Coordinates are not valid.",
position: 'bottom', position: 'bottom',
}) })
this.$refs.coordinates.focus() coordinatesInput.value.focus()
return return
} }
this.coordinates = `${parts[0]},${parts[1]}` coordinates.value = `${parts[0]},${parts[1]}`
this.loading = true loading.value = true
const url = `https://api.weather.gov/points/${this.coordinates}` const url = `https://api.weather.gov/points/${coordinates.value}`
const response = await fetch(url) const response = await fetch(url)
const weather = await response.json() const weather = await response.json()
this.loading = false loading.value = false
if (weather.status == 404) { if (weather.status == 404) {
this.$oruga.notification.open({ oruga.notification.open({
variant: 'warning', variant: 'warning',
message: weather.title || "Data not found!", message: weather.title || "Data not found!",
position: 'bottom', position: 'bottom',
@ -86,24 +84,24 @@ export default {
const state = weather.properties.relativeLocation.properties.state const state = weather.properties.relativeLocation.properties.state
const cityState = `${city}, ${state}` const cityState = `${city}, ${state}`
this.locationStore.addLocation(coords, cityState) locationStore.addLocation(coords, cityState)
this.weatherStore.setCoordinates(coords) weatherStore.setCoordinates(coords)
this.weatherStore.setCityState(cityState) weatherStore.setCityState(cityState)
this.weatherStore.setWeather(weather) weatherStore.setWeather(weather)
// clear this so user sees empty input when they return // clear this so user sees empty input when they return
this.coordinates = null coordinates.value = null
this.$router.push('/weather') router.push('/weather')
},
showWeather(location) {
this.weatherStore.clearWeather()
this.weatherStore.setCoordinates(location.coordinates)
this.weatherStore.setCityState(location.cityState)
this.$router.push('/weather')
},
},
} }
function showWeather(location) {
weatherStore.clearWeather()
weatherStore.setCoordinates(location.coordinates)
weatherStore.setCityState(location.cityState)
router.push('/weather')
}
</script> </script>
<template> <template>
@ -112,7 +110,7 @@ export default {
<o-field grouped> <o-field grouped>
<o-input v-model="coordinates" <o-input v-model="coordinates"
ref="coordinates" ref="coordinatesInput"
placeholder="coordinates" placeholder="coordinates"
clearable /> clearable />
<o-button variant="primary" <o-button variant="primary"

View file

@ -1,45 +1,37 @@
<script setup> <script setup>
import { mapStores } from 'pinia' import { ref, onActivated } from 'vue'
import { useWeatherStore } from '../stores/weather' import { useWeatherStore } from '../stores/weather'
</script>
<script>
export default {
data() { const weatherStore = useWeatherStore()
return {
coordinates: null, const coordinates = ref(null)
hourly: null, const chunks = ref([])
chunks: [], const refreshing = ref(false)
onActivated(() => {
if (coordinates.value != weatherStore.coordinates) {
refreshing.value = true
chunks.value = []
fetchHourly().then(() => {
refreshing.value = false
})
} }
}, })
computed: { async function fetchHourly() {
...mapStores(useWeatherStore),
},
activated() { const weather = await weatherStore.getWeather()
if (this.coordinates != this.weatherStore.coordinates) { coordinates.value = weatherStore.coordinates
this.hourly = null
this.chunks = []
this.fetchHourly()
}
},
methods: {
async fetchHourly() {
const weather = await this.weatherStore.getWeather()
this.coordinates = this.weatherStore.coordinates
const url = weather.properties.forecastHourly const url = weather.properties.forecastHourly
const response = await fetch(url) const response = await fetch(url)
this.hourly = await response.json() const hourly = await response.json()
this.chunks = [] chunks.value = []
let chunk = null let chunk = null
for (let period of this.hourly.properties.periods.slice(0, 12)) { for (let period of hourly.properties.periods.slice(0, 12)) {
const date = new Date(period.startTime).toLocaleDateString('en-US', { const date = new Date(period.startTime).toLocaleDateString('en-US', {
dateStyle: 'full', dateStyle: 'full',
}) })
@ -48,29 +40,46 @@ export default {
date, date,
periods: [], periods: [],
} }
this.chunks.push(chunk) chunks.value.push(chunk)
} }
chunk.periods.push(period) chunk.periods.push(period)
} }
}, }
renderTime(period) {
async function refreshHourly() {
refreshing.value = true
weatherStore.clearWeather(true)
chunks.value = []
await fetchHourly()
refreshing.value = false
}
function renderTime(period) {
const date = new Date(period.startTime) const date = new Date(period.startTime)
return date.toLocaleTimeString('en-US', {timeStyle: 'short'}) return date.toLocaleTimeString('en-US', {timeStyle: 'short'})
},
},
} }
</script> </script>
<template> <template>
<main> <main>
<div class="block"
style="display: flex; align-items: center; justify-content: space-between;">
<o-button variant="primary" <o-button variant="primary"
size="small"
icon-left="arrow-left" icon-left="arrow-left"
@click="$router.push('/weather')"> @click="$router.push('/weather')">
Back Back
</o-button> </o-button>
<o-button variant="primary"
icon-left="refresh"
@click="refreshHourly()"
:disabled="refreshing">
{{ refreshing ? "Refreshing" : "Refresh" }}
</o-button>
</div>
<h5 class="is-size-5">{{ weatherStore.cityState }}</h5> <h5 class="is-size-5">{{ weatherStore.cityState }}</h5>
<br /> <br />

View file

@ -1,73 +1,99 @@
<script setup> <script setup>
import { mapStores } from 'pinia' import { ref, computed, onActivated } from 'vue'
import { useRouter } from 'vue-router'
import { useWeatherStore } from '../stores/weather' import { useWeatherStore } from '../stores/weather'
import { useLocationStore } from '../stores/location' import { useLocationStore } from '../stores/location'
</script>
<script>
export default {
data() { const router = useRouter()
return { const locationStore = useLocationStore()
coordinates: null, const weatherStore = useWeatherStore()
alerts: null,
}
},
computed: { const coordinates = ref(null)
...mapStores(useWeatherStore, useLocationStore), const refreshing = ref(false)
const timestamp = ref(new Date().getTime())
panelHeadingTitle() {
if (!this.weatherStore.forecast) { const panelHeadingTitle = computed(() => {
if (!weatherStore.forecast) {
return "Forecast" return "Forecast"
} }
let generated = new Date(this.weatherStore.forecast.properties.generatedAt) let generated = new Date(weatherStore.forecast.properties.generatedAt)
generated = generated.toLocaleTimeString('en-US', {timeStyle: 'short'}) generated = generated.toLocaleTimeString('en-US', {timeStyle: 'short'})
return `Forecast @ ${generated}` return `Forecast @ ${generated}`
}, })
},
activated() {
this.fetchWeather()
},
methods: { const radarLatestURL = computed(() => {
if (weatherStore.weather) {
return `${weatherStore.radarLatestURL}?t=${timestamp.value}`
}
})
coordinatesUpdated(coordinates) {
this.weatherStore.clearWeather()
this.weatherStore.setCoordinates(coordinates)
this.fetchWeather()
},
async fetchWeather() { const radarLoopURL = computed(() => {
if (weatherStore.weather) {
return `${weatherStore.radarLoopURL}?t=${timestamp.value}`
}
})
if (!this.weatherStore.coordinates) {
this.$router.push('/') onActivated(() => {
if (!weatherStore.coordinates) {
router.push('/')
return return
} }
const forecast = await this.weatherStore.getForecast() refreshing.value = true
this.coordinates = this.weatherStore.coordinates fetchWeather().then(() => {
this.alerts = this.weatherStore.alerts refreshing.value = false
}, })
})
getShortForecast(period) {
return period.shortForecast.replace(/Showers And Thunderstorms/g, 'T-storms')
},
showHourly(period) { async function coordinatesUpdated(coords) {
this.$router.push('/hourly') refreshing.value = true
}, weatherStore.clearWeather()
}, weatherStore.setCoordinates(coords)
await fetchWeather()
refreshing.value = false
} }
async function fetchWeather() {
await weatherStore.getForecast()
coordinates.value = weatherStore.coordinates
}
async function refreshWeather() {
refreshing.value = true
weatherStore.clearWeather(true)
timestamp.value = new Date().getTime()
await fetchWeather()
refreshing.value = false
}
function getShortForecast(period) {
return period.shortForecast.replace(/Showers And Thunderstorms/g, 'T-storms')
}
function showHourly(period) {
router.push('/hourly')
}
</script> </script>
<template> <template>
<main> <main>
<div style="display: flex; flex-direction: column;"> <div style="display: flex; flex-direction: column;">
<o-field> <div class="block"
style="display: flex; justify-content: space-between; align-items: center;">
<o-select v-if="weatherStore.weather" <o-select v-if="weatherStore.weather"
v-model="coordinates" v-model="coordinates"
@update:model-value="coordinatesUpdated"> @update:model-value="coordinatesUpdated">
@ -77,15 +103,22 @@ export default {
{{ location.cityState }} {{ location.cityState }}
</option> </option>
</o-select> </o-select>
</o-field> <o-button variant="primary"
icon-left="refresh"
@click="refreshWeather()"
:disabled="refreshing">
{{ refreshing ? "Refreshing" : "Refresh" }}
</o-button>
</div>
<div v-if="alerts?.features?.length"> <div v-if="weatherStore.alerts?.features?.length"
class="block">
<o-notification variant="warning" <o-notification variant="warning"
icon="warning" icon="warning"
@click="$router.push('/alerts')" @click="$router.push('/alerts')"
style="cursor: pointer;"> style="cursor: pointer;">
<p class="has-text-weight-bold">Watches In Effect</p> <p class="has-text-weight-bold">Watches In Effect</p>
<p>Updated {{ new Date(alerts.updated).toLocaleTimeString('en-US', {timeStyle: 'short'}) }}</p> <p>Updated {{ new Date(weatherStore.alerts.updated).toLocaleTimeString('en-US', {timeStyle: 'short'}) }}</p>
</o-notification> </o-notification>
</div> </div>
@ -104,6 +137,7 @@ export default {
:key="period.number" :key="period.number"
class="weather-period is-size-7 has-text-weight-bold" class="weather-period is-size-7 has-text-weight-bold"
:class="{daytime: period.isDaytime}" :class="{daytime: period.isDaytime}"
style="cursor: pointer;"
@click="showHourly(period)"> @click="showHourly(period)">
<p>{{ period.name }}</p> <p>{{ period.name }}</p>
@ -132,16 +166,23 @@ export default {
</nav> </nav>
<nav class="panel weather-panel"> <nav class="panel weather-panel">
<p class="panel-heading"> <div class="panel-heading"
Radar style="display: flex; align-items: center; justify-content: space-between;">
</p> <p>Radar</p>
<o-button tag="a"
href="https://radar.weather.gov/ridge/standard/CONUS-LARGE_loop.gif"
target="_blank"
icon-left="external-link">
National Radar
</o-button>
</div>
<div class="panel-block"> <div class="panel-block">
<div class="columns"> <div class="columns">
<div class="column"> <div class="column">
<img :src="weatherStore.radarLatestURL" /> <img :src="radarLatestURL" />
</div> </div>
<div class="column"> <div class="column">
<img :src="weatherStore.radarLoopURL" /> <img :src="radarLoopURL" />
</div> </div>
</div> </div>
</div> </div>

View file

@ -24,7 +24,7 @@ def release(c):
version = js['version'] version = js['version']
# build the app, create zip archive # build the app, create zip archive
c.run('npm run build') c.run("bash -lc 'nvm use lts/iron; npm run build'")
os.chdir('dist') os.chdir('dist')
filename = f'myweather-{version}.zip' filename = f'myweather-{version}.zip'
c.run(f'zip --recurse-paths {filename} *') c.run(f'zip --recurse-paths {filename} *')