Compare commits

...

16 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
Lance Edgar
e611803bd7 Update changelog 2024-06-08 23:17:51 -05:00
Lance Edgar
0710f2238c Add basic support for active weather alerts
yikes nobody should stake their life on how well this works, but it
does seem to basically work..
2024-06-08 23:16:29 -05:00
Lance Edgar
7ed314b5bf Update changelog 2024-06-08 21:31:18 -05:00
Lance Edgar
e446084d62 Add proper About page 2024-06-08 21:30:24 -05:00
16 changed files with 542 additions and 254 deletions

1
.gitignore vendored
View file

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

View file

@ -7,6 +7,34 @@ 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.
## 0.1.6 - 2024-06-08
### Changed
- Add proper About page.
## 0.1.5 - 2024-06-08
### Changed
- Tighten up the forecast period panels a bit.

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "myweather",
"version": "0.1.5",
"version": "0.1.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "myweather",
"version": "0.1.5",
"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.5",
"version": "0.1.11",
"private": true,
"type": "module",
"scripts": {

View file

@ -3,21 +3,28 @@ import { RouterLink, RouterView } from 'vue-router'
</script>
<template>
<header>
<router-link to="/">
<img src="/nwslogo.gif" width="38" height="38" />
<h5 class="is-size-5 has-text-weight-bold">Weather</h5>
</router-link>
<div class="page-wrapper">
<header>
<router-link to="/">
<img src="/nwslogo.gif" width="38" height="38" />
<h5 class="is-size-5 has-text-weight-bold">Weather</h5>
</router-link>
</header>
</header>
<div style="padding: 0.5rem;">
<router-view v-slot="{Component}">
<keep-alive>
<component :is="Component" />
</keep-alive>
</router-view>
<div style="padding: 0.5rem;">
<router-view v-slot="{Component}">
<keep-alive>
<component :is="Component" />
</keep-alive>
</router-view>
</div>
</div>
<footer class="footer">
<div class="content">
<router-link to="/about">About this app</router-link>
</div>
</footer>
</template>
<style scoped>

10
src/appsettings.js Normal file
View file

@ -0,0 +1,10 @@
import packageData from '../package.json'
const appsettings = {
appTitle: packageData.name,
appVersion: packageData.version,
appDependencies: packageData.dependencies,
}
export default appsettings

View file

@ -1 +1,15 @@
@import './custom.scss';
/* nb. these are here to force footer to bottom of screen */
html, body {
height: 100%;
}
#app {
height: 100%;
display: flex;
flex-direction: column;
justify-content: space-between;
}

View file

@ -1,6 +1,7 @@
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'
import WeatherView from '../views/WeatherView.vue'
import AlertsView from '../views/AlertsView.vue'
import HourlyView from '../views/HourlyView.vue'
import EditListView from '../views/EditListView.vue'
@ -17,6 +18,11 @@ const router = createRouter({
name: 'weather',
component: WeatherView,
},
{
path: '/alerts',
name: 'alerts',
component: AlertsView,
},
{
path: '/hourly',
name: 'hourly',

View file

@ -10,6 +10,7 @@ const getDefaults = () => {
coordinates,
cityState,
weather: null,
alerts: null,
forecast: null,
radarLatestURL: null,
radarLoopURL: null,
@ -25,21 +26,26 @@ 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
this.radarLoopURL = null
this.alerts = null
},
async getWeather() {
if (!this.weather) {
let url
let response
const url = `https://api.weather.gov/points/${this.coordinates}`
const response = await fetch(url)
url = `https://api.weather.gov/points/${this.coordinates}`
response = await fetch(url)
const weather = await response.json()
if (weather.status == 404) {
throw new Error(`Data not found for ${this.coordinates}`)
@ -57,6 +63,65 @@ export const useWeatherStore = defineStore('weather', {
this.radarLoopURL = `https://radar.weather.gov/ridge/standard/${station}_loop.gif`
this.setWeather(weather)
// fetch zone to get its official id
url = weather.properties.forecastZone
response = await fetch(url)
const zone = await response.json()
// fetch alerts for zone
url = `https://api.weather.gov/alerts/active/zone/${zone.properties.id}`
response = await fetch(url)
const zoneAlerts = await response.json()
// fetch county to get its official id
url = weather.properties.county
response = await fetch(url)
const county = await response.json()
// fetch alerts for county
url = `https://api.weather.gov/alerts/active/zone/${county.properties.id}`
response = await fetch(url)
const countyAlerts = await response.json()
const newAlerts = {}
// use latest timestamp from either zone or county
newAlerts.updated = zoneAlerts.updated
if (countyAlerts.updated > zoneAlerts.updated) {
newAlerts.updated = countyAlerts.updated
}
// collect all alert "features" but de-duplicate them
newAlerts.features = {}
for (let feature of zoneAlerts.features) {
newAlerts.features[feature.properties.id] = feature
}
for (let feature of countyAlerts.features) {
newAlerts.features[feature.properties.id] = feature
}
newAlerts.features = Object.values(newAlerts.features)
// put "likely" before "possible" alerts
newAlerts.features.sort((a, b) => {
if (a.properties.certainty == 'Likely' && b.properties.certainty != 'Likely') {
return -1
}
if (a.properties.certainty != 'Likely' && b.properties.certainty == 'Likely') {
return 1
}
// if (a.properties.certainty == b.properties.certainty) {
// return 0
// }
// TODO: what else should this do?
return 0
})
// we have our final alerts
this.alerts = newAlerts
}
return this.weather
@ -66,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

@ -1,15 +1,57 @@
<script setup>
import appsettings from '../appsettings'
</script>
<template>
<div class="about">
<h1>This is an about page</h1>
<h4 class="is-size-4">{{ appsettings.appTitle }} {{ appsettings.appVersion }}</h4>
<br />
<p class="block">
This app was built in rather a hurry, to replace my personal
usage of the original
<a href="https://mobile.weather.gov" target="_blank">
<span class="icon-text">
<o-icon icon="external-link" />
<span>mobile.weather.gov</span>
</span>
</a>
site which was being decommissioned.&nbsp;
(See also
<a href="https://www.weather.gov/media/notification/pdf_2023_24/scn24-51_mobile_decommissioning.pdf" target="_blank">
<span class="icon-text">
<o-icon icon="external-link" />
<span>this statement</span>
</span>
</a>.)
</p>
<p class="block">
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">
This app is primarily for personal use, but if you also find it useful and
have suggestions, email me at <a href="mailto:lance@edbob.org">lance@edbob.org</a>
</p>
<br />
<o-field label="Dependencies">
<ul>
<li v-for="(ver, pkg, i) in appsettings.appDependencies"
:key="pkg">
{{ pkg }} {{ ver }}
</li>
</ul>
</o-field>
</div>
</template>
<style>
@media (min-width: 1024px) {
.about {
min-height: 100vh;
display: flex;
align-items: center;
}
}
</style>

65
src/views/AlertsView.vue Normal file
View file

@ -0,0 +1,65 @@
<script setup>
import { ref, onActivated } from 'vue'
import { useWeatherStore } from '../stores/weather'
const weatherStore = useWeatherStore()
const refreshing = ref(false)
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>
<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>
<br />
<o-collapse v-for="feature in weatherStore.alerts?.features || []"
variant="warning"
:open="false">
<template #trigger>
<o-notification :variant="feature.properties.certainty == 'Likely' ? 'danger' : 'warning'"
icon="plus"
icon-size="small"
style="cursor: pointer;">
{{ feature.properties.event }} ({{ feature.properties.severity }}, {{ feature.properties.certainty }})
</o-notification>
</template>
<div class="notification">
<h5 class="is-size-5 block">{{ feature.properties.headline }}</h5>
<p class="block" style="white-space: pre-wrap;">{{ feature.properties.description }}</p>
</div>
</o-collapse>
</main>
</template>

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,71 +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,
}
},
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
},
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">
@ -75,7 +103,24 @@ 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="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(weatherStore.alerts.updated).toLocaleTimeString('en-US', {timeStyle: 'short'}) }}</p>
</o-notification>
</div>
<nav class="panel weather-panel">
@ -92,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>
@ -120,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} *')