Compare commits

..

12 commits

Author SHA1 Message Date
cfe916ec72 bump to version 0.1.11 2025-10-06 11:08:52 -05:00
01b9644243 fix: update source code info for about page 2025-10-06 11:06:53 -05:00
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 332 additions and 267 deletions

1
.gitignore vendored
View file

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

View file

@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased
## 0.1.11 - 2025-10-06
### Changed
- update source code info for about page
## 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
### Added
- Add basic support for active weather alerts.

4
package-lock.json generated
View file

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

View file

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

View file

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

View file

@ -2,17 +2,6 @@
import appsettings from '../appsettings'
</script>
<script>
export default {
data() {
return {
appsettings,
}
},
}
</script>
<template>
<div class="about">
<h4 class="is-size-4">{{ appsettings.appTitle }} {{ appsettings.appVersion }}</h4>
@ -39,12 +28,13 @@ export default {
</p>
<p class="block">
Source code is not currently browseable online but you can get it with:
</p>
<p class="block is-family-monospace"
style="padding-left: 4rem;">
git clone https://git.edbob.org/readonly/myweather.git
Source code is available at
<a href="https://forgejo.wuttaproject.org/lance/myweather" target="_blank">
<span class="icon-text">
<o-icon icon="external-link" />
<span>https://forgejo.wuttaproject.org/lance/myweather</span>
</span>
</a>
</p>
<p class="block">

View file

@ -1,30 +1,44 @@
<script setup>
import { mapStores } from 'pinia'
import { ref, onActivated } from 'vue'
import { useWeatherStore } from '../stores/weather'
</script>
<script>
export default {
const weatherStore = useWeatherStore()
computed: {
...mapStores(useWeatherStore),
},
const refreshing = ref(false)
activated() {
this.weatherStore.getWeather()
},
onActivated(() => {
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>
<template>
<main>
<o-button variant="primary"
size="small"
icon-left="arrow-left"
@click="$router.push('/weather')">
Back
</o-button>
<div class="block"
style="display: flex; align-items: center; justify-content: space-between;">
<o-button variant="primary"
icon-left="arrow-left"
@click="$router.push('/weather')">
Back
</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">Alerts</h5>

View file

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

View file

@ -1,109 +1,107 @@
<script setup>
import { mapStores } from 'pinia'
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useWeatherStore } from '../stores/weather'
import { useLocationStore } from '../stores/location'
</script>
import { useOruga } from '@oruga-ui/oruga-next'
<script>
export default {
data() {
return {
coordinates: null,
loading: false,
locationAccessBlocked: false,
const router = useRouter()
const locationStore = useLocationStore()
const weatherStore = useWeatherStore()
const oruga = useOruga()
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
}
},
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')
},
},
alert(`error.code = ${error.code}\n\n${error.message}`)
})
}
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>
<template>
@ -112,7 +110,7 @@ export default {
<o-field grouped>
<o-input v-model="coordinates"
ref="coordinates"
ref="coordinatesInput"
placeholder="coordinates"
clearable />
<o-button variant="primary"

View file

@ -1,76 +1,85 @@
<script setup>
import { mapStores } from 'pinia'
import { ref, onActivated } from 'vue'
import { useWeatherStore } from '../stores/weather'
</script>
<script>
export default {
data() {
return {
coordinates: null,
hourly: null,
chunks: [],
}
},
const weatherStore = useWeatherStore()
computed: {
...mapStores(useWeatherStore),
},
const coordinates = ref(null)
const chunks = ref([])
const refreshing = ref(false)
activated() {
if (this.coordinates != this.weatherStore.coordinates) {
this.hourly = null
this.chunks = []
this.fetchHourly()
}
},
methods: {
onActivated(() => {
if (coordinates.value != weatherStore.coordinates) {
refreshing.value = true
chunks.value = []
fetchHourly().then(() => {
refreshing.value = false
})
}
})
async fetchHourly() {
async function fetchHourly() {
const weather = await this.weatherStore.getWeather()
this.coordinates = this.weatherStore.coordinates
const weather = await weatherStore.getWeather()
coordinates.value = weatherStore.coordinates
const url = weather.properties.forecastHourly
const response = await fetch(url)
this.hourly = await response.json()
const url = weather.properties.forecastHourly
const response = await fetch(url)
const 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)
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: [],
}
},
renderTime(period) {
const date = new Date(period.startTime)
return date.toLocaleTimeString('en-US', {timeStyle: 'short'})
},
},
chunks.value.push(chunk)
}
chunk.periods.push(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)
return date.toLocaleTimeString('en-US', {timeStyle: 'short'})
}
</script>
<template>
<main>
<o-button variant="primary"
size="small"
icon-left="arrow-left"
@click="$router.push('/weather')">
Back
</o-button>
<div class="block"
style="display: flex; align-items: center; justify-content: space-between;">
<o-button variant="primary"
icon-left="arrow-left"
@click="$router.push('/weather')">
Back
</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>
<br />

View file

@ -1,73 +1,99 @@
<script setup>
import { mapStores } from 'pinia'
import { ref, computed, onActivated } from 'vue'
import { useRouter } from 'vue-router'
import { useWeatherStore } from '../stores/weather'
import { useLocationStore } from '../stores/location'
</script>
<script>
export default {
data() {
return {
coordinates: null,
alerts: null,
}
},
const router = useRouter()
const locationStore = useLocationStore()
const weatherStore = useWeatherStore()
computed: {
...mapStores(useWeatherStore, useLocationStore),
const coordinates = ref(null)
const refreshing = ref(false)
const timestamp = ref(new Date().getTime())
panelHeadingTitle() {
if (!this.weatherStore.forecast) {
return "Forecast"
}
let generated = new Date(this.weatherStore.forecast.properties.generatedAt)
generated = generated.toLocaleTimeString('en-US', {timeStyle: 'short'})
return `Forecast @ ${generated}`
},
},
const panelHeadingTitle = computed(() => {
if (!weatherStore.forecast) {
return "Forecast"
}
activated() {
this.fetchWeather()
},
let generated = new Date(weatherStore.forecast.properties.generatedAt)
generated = generated.toLocaleTimeString('en-US', {timeStyle: 'short'})
return `Forecast @ ${generated}`
})
methods: {
coordinatesUpdated(coordinates) {
this.weatherStore.clearWeather()
this.weatherStore.setCoordinates(coordinates)
this.fetchWeather()
},
const radarLatestURL = computed(() => {
if (weatherStore.weather) {
return `${weatherStore.radarLatestURL}?t=${timestamp.value}`
}
})
async fetchWeather() {
if (!this.weatherStore.coordinates) {
this.$router.push('/')
return
}
const radarLoopURL = computed(() => {
if (weatherStore.weather) {
return `${weatherStore.radarLoopURL}?t=${timestamp.value}`
}
})
const forecast = await this.weatherStore.getForecast()
this.coordinates = this.weatherStore.coordinates
this.alerts = this.weatherStore.alerts
},
getShortForecast(period) {
return period.shortForecast.replace(/Showers And Thunderstorms/g, 'T-storms')
},
onActivated(() => {
showHourly(period) {
this.$router.push('/hourly')
},
},
if (!weatherStore.coordinates) {
router.push('/')
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>
<template>
<main>
<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"
v-model="coordinates"
@update:model-value="coordinatesUpdated">
@ -77,15 +103,22 @@ export default {
{{ location.cityState }}
</option>
</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"
icon="warning"
@click="$router.push('/alerts')"
style="cursor: pointer;">
<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>
</div>
@ -104,6 +137,7 @@ export default {
:key="period.number"
class="weather-period is-size-7 has-text-weight-bold"
:class="{daytime: period.isDaytime}"
style="cursor: pointer;"
@click="showHourly(period)">
<p>{{ period.name }}</p>
@ -132,16 +166,23 @@ export default {
</nav>
<nav class="panel weather-panel">
<p class="panel-heading">
Radar
</p>
<div class="panel-heading"
style="display: flex; align-items: center; justify-content: space-between;">
<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="columns">
<div class="column">
<img :src="weatherStore.radarLatestURL" />
<img :src="radarLatestURL" />
</div>
<div class="column">
<img :src="weatherStore.radarLoopURL" />
<img :src="radarLoopURL" />
</div>
</div>
</div>

View file

@ -24,7 +24,7 @@ def release(c):
version = js['version']
# 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')
filename = f'myweather-{version}.zip'
c.run(f'zip --recurse-paths {filename} *')