Compare commits

..

No commits in common. "52b83d05bbb280b9cacb659cf91bb0d465a56577" and "e611803bd72b35c3a43b11980b9cc2f0268ee971" have entirely different histories.

12 changed files with 268 additions and 328 deletions

1
.gitignore vendored
View file

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

View file

@ -7,22 +7,6 @@ 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.10", "version": "0.1.7",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "myweather", "name": "myweather",
"version": "0.1.10", "version": "0.1.7",
"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.10", "version": "0.1.7",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View file

@ -26,11 +26,9 @@ export const useWeatherStore = defineStore('weather', {
actions: { actions: {
clearWeather(keepCoordinates) { clearWeather() {
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
@ -105,10 +103,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 != 'Likely') { if (a.properties.certainty == 'Likely' && b.properties.certainty == 'Possible') {
return -1 return -1
} }
if (a.properties.certainty != 'Likely' && b.properties.certainty == 'Likely') { if (a.properties.certainty == 'Possible' && b.properties.certainty == 'Likely') {
return 1 return 1
} }
@ -131,7 +129,7 @@ export const useWeatherStore = defineStore('weather', {
if (!this.forecast) { if (!this.forecast) {
const weather = await this.getWeather() const weather = await this.getWeather(this.coordinates)
const url = weather.properties.forecast const url = weather.properties.forecast
const response = await fetch(url) const response = await fetch(url)

View file

@ -2,6 +2,17 @@
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,44 +1,30 @@
<script setup> <script setup>
import { ref, onActivated } from 'vue' import { mapStores } from 'pinia'
import { useWeatherStore } from '../stores/weather' import { useWeatherStore } from '../stores/weather'
</script>
const weatherStore = useWeatherStore() <script>
export default {
const refreshing = ref(false) computed: {
...mapStores(useWeatherStore),
},
onActivated(() => { activated() {
refreshing.value = true this.weatherStore.getWeather()
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" <o-button variant="primary"
style="display: flex; align-items: center; justify-content: space-between;"> size="small"
<o-button variant="primary" 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,12 +1,22 @@
<script setup> <script setup>
import { mapStores } from 'pinia'
import { useLocationStore } from '../stores/location' import { useLocationStore } from '../stores/location'
</script>
const locationStore = useLocationStore() <script>
export default {
function deleteLocation(location) { computed: {
locationStore.removeLocation(location) ...mapStores(useLocationStore),
},
methods: {
deleteLocation(location) {
this.locationStore.removeLocation(location)
},
},
} }
</script> </script>
<template> <template>

View file

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

View file

@ -1,85 +1,76 @@
<script setup> <script setup>
import { ref, onActivated } from 'vue' import { mapStores } from 'pinia'
import { useWeatherStore } from '../stores/weather' import { useWeatherStore } from '../stores/weather'
</script>
<script>
export default {
const weatherStore = useWeatherStore() data() {
return {
const coordinates = ref(null) coordinates: null,
const chunks = ref([]) hourly: null,
const refreshing = ref(false) chunks: [],
onActivated(() => {
if (coordinates.value != weatherStore.coordinates) {
refreshing.value = true
chunks.value = []
fetchHourly().then(() => {
refreshing.value = false
})
}
})
async function fetchHourly() {
const weather = await weatherStore.getWeather()
coordinates.value = weatherStore.coordinates
const url = weather.properties.forecastHourly
const response = await fetch(url)
const hourly = await response.json()
chunks.value = []
let chunk = null
for (let period of hourly.properties.periods.slice(0, 12)) {
const date = new Date(period.startTime).toLocaleDateString('en-US', {
dateStyle: 'full',
})
if (!chunk || chunk.date != date) {
chunk = {
date,
periods: [],
}
chunks.value.push(chunk)
} }
chunk.periods.push(period) },
}
computed: {
...mapStores(useWeatherStore),
},
activated() {
if (this.coordinates != this.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 response = await fetch(url)
this.hourly = await response.json()
this.chunks = []
let chunk = null
for (let period of this.hourly.properties.periods.slice(0, 12)) {
const date = new Date(period.startTime).toLocaleDateString('en-US', {
dateStyle: 'full',
})
if (!chunk || chunk.date != date) {
chunk = {
date,
periods: [],
}
this.chunks.push(chunk)
}
chunk.periods.push(period)
}
},
renderTime(period) {
const date = new Date(period.startTime)
return date.toLocaleTimeString('en-US', {timeStyle: 'short'})
},
},
} }
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)
return date.toLocaleTimeString('en-US', {timeStyle: 'short'})
}
</script> </script>
<template> <template>
<main> <main>
<div class="block" <o-button variant="primary"
style="display: flex; align-items: center; justify-content: space-between;"> size="small"
<o-button variant="primary" 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,99 +1,73 @@
<script setup> <script setup>
import { ref, computed, onActivated } from 'vue' import { mapStores } from 'pinia'
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 {
const router = useRouter() data() {
const locationStore = useLocationStore() return {
const weatherStore = useWeatherStore() coordinates: null,
alerts: null,
}
},
const coordinates = ref(null) computed: {
const refreshing = ref(false) ...mapStores(useWeatherStore, useLocationStore),
const timestamp = ref(new Date().getTime())
panelHeadingTitle() {
if (!this.weatherStore.forecast) {
return "Forecast"
}
const panelHeadingTitle = computed(() => { let generated = new Date(this.weatherStore.forecast.properties.generatedAt)
if (!weatherStore.forecast) { generated = generated.toLocaleTimeString('en-US', {timeStyle: 'short'})
return "Forecast" return `Forecast @ ${generated}`
} },
},
let generated = new Date(weatherStore.forecast.properties.generatedAt) activated() {
generated = generated.toLocaleTimeString('en-US', {timeStyle: 'short'}) this.fetchWeather()
return `Forecast @ ${generated}` },
})
methods: {
const radarLatestURL = computed(() => { coordinatesUpdated(coordinates) {
if (weatherStore.weather) { this.weatherStore.clearWeather()
return `${weatherStore.radarLatestURL}?t=${timestamp.value}` this.weatherStore.setCoordinates(coordinates)
} this.fetchWeather()
}) },
async fetchWeather() {
const radarLoopURL = computed(() => { if (!this.weatherStore.coordinates) {
if (weatherStore.weather) { this.$router.push('/')
return `${weatherStore.radarLoopURL}?t=${timestamp.value}` return
} }
})
const forecast = await this.weatherStore.getForecast()
this.coordinates = this.weatherStore.coordinates
this.alerts = this.weatherStore.alerts
},
onActivated(() => { getShortForecast(period) {
return period.shortForecast.replace(/Showers And Thunderstorms/g, 'T-storms')
},
if (!weatherStore.coordinates) { showHourly(period) {
router.push('/') this.$router.push('/hourly')
return },
} },
refreshing.value = true
fetchWeather().then(() => {
refreshing.value = false
})
})
async function coordinatesUpdated(coords) {
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;">
<div class="block" <o-field>
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">
@ -103,22 +77,15 @@ function showHourly(period) {
{{ location.cityState }} {{ location.cityState }}
</option> </option>
</o-select> </o-select>
<o-button variant="primary" </o-field>
icon-left="refresh"
@click="refreshWeather()"
:disabled="refreshing">
{{ refreshing ? "Refreshing" : "Refresh" }}
</o-button>
</div>
<div v-if="weatherStore.alerts?.features?.length" <div v-if="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(weatherStore.alerts.updated).toLocaleTimeString('en-US', {timeStyle: 'short'}) }}</p> <p>Updated {{ new Date(alerts.updated).toLocaleTimeString('en-US', {timeStyle: 'short'}) }}</p>
</o-notification> </o-notification>
</div> </div>
@ -137,7 +104,6 @@ function showHourly(period) {
: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>
@ -166,23 +132,16 @@ function showHourly(period) {
</nav> </nav>
<nav class="panel weather-panel"> <nav class="panel weather-panel">
<div class="panel-heading" <p class="panel-heading">
style="display: flex; align-items: center; justify-content: space-between;"> Radar
<p>Radar</p> </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="radarLatestURL" /> <img :src="weatherStore.radarLatestURL" />
</div> </div>
<div class="column"> <div class="column">
<img :src="radarLoopURL" /> <img :src="weatherStore.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("bash -lc 'nvm use lts/iron; npm run build'") c.run('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} *')