Compare commits

...

30 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
Lance Edgar
8109c6d3f6 Update changelog 2024-06-08 20:35:33 -05:00
Lance Edgar
aa12f028a5 Tighten up the forecast period panels a bit 2024-06-08 20:34:47 -05:00
Lance Edgar
757e5ce6c8 Update changelog 2024-06-08 19:58:59 -05:00
Lance Edgar
f82540c677 Fix horizontal scroll for main forecast panel 2024-06-08 19:58:31 -05:00
Lance Edgar
fe411cd27c Update changelog 2024-06-08 19:37:04 -05:00
Lance Edgar
b983a34bc9 Make buttons half-wide on home screen (desktop mode)
having a button take up full screen width is a bit much
2024-06-08 19:35:45 -05:00
Lance Edgar
5d079e1208 Tell user to refresh the page, if access to location has failed
at least in my local firefox on laptop, it would not prompt user again
until page was refreshed
2024-06-08 19:35:03 -05:00
Lance Edgar
c2e6bfcfdd Update changelog 2024-06-08 19:03:44 -05:00
Lance Edgar
6df6f84afe Add "use my current location" button to home page 2024-06-08 19:02:51 -05:00
Lance Edgar
de9980b014 Show both "latest" and "loop" radar images 2024-06-08 19:02:41 -05:00
Lance Edgar
d17ab853af Upgrade server when pushing release
since that is really the only point of doing a release, for now
2024-06-08 18:40:14 -05:00
Lance Edgar
d28c4f7395 Update changelog 2024-06-08 18:38:27 -05:00
Lance Edgar
866742130d Add radar panel for Weather view 2024-06-08 18:36:44 -05:00
Lance Edgar
32cfafc88a Add release task 2024-06-08 15:16:02 -05:00
16 changed files with 671 additions and 245 deletions

1
.gitignore vendored
View file

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

63
CHANGELOG.md Normal file
View file

@ -0,0 +1,63 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 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.
## 0.1.4 - 2024-06-08
### Changed
- Fix horizontal scroll for main forecast panel.
## 0.1.3 - 2024-06-08
### Changed
- Tell user to refresh the page, if access to location has failed.
- Make buttons half-wide on home screen (desktop mode).
## 0.1.2 - 2024-06-08
### Added
- Add "use my current location" button to home page.
### Changed
- Show both "latest" and "loop" radar images.
## 0.1.1 - 2024-06-08
### Added
- Add radar panel for Weather view.
## 0.1.0 - 2024-06-07
### Added
- Initial release, very basic.

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "myweather",
"version": "0.1.0",
"version": "0.1.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "myweather",
"version": "0.1.0",
"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.0",
"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,7 +10,10 @@ const getDefaults = () => {
coordinates,
cityState,
weather: null,
alerts: null,
forecast: null,
radarLatestURL: null,
radarLoopURL: null,
}
}
@ -23,19 +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}`)
@ -48,7 +58,70 @@ export const useWeatherStore = defineStore('weather', {
this.setCityState(cityState)
}
const station = weather.properties.radarStation
this.radarLatestURL = `https://radar.weather.gov/ridge/standard/${station}_0.gif`
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
@ -58,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,90 +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,
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: {
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>
@ -93,7 +110,7 @@ export default {
<o-field grouped>
<o-input v-model="coordinates"
ref="coordinates"
ref="coordinatesInput"
placeholder="coordinates"
clearable />
<o-button variant="primary"
@ -104,24 +121,40 @@ export default {
</o-button>
</o-field>
<div v-for="location in locationStore.locations"
:key="location.coordinates"
class="location">
<o-button variant="primary"
icon-right="arrow-right"
expanded
@click="showWeather(location)">
{{ location.cityState }}
</o-button>
</div>
<div class="columns">
<div class="column is-half">
<div v-if="locationStore.locations.length"
class="location">
<o-button icon-right="edit"
expanded
@click="editLocations()">
Edit List
</o-button>
<div v-for="location in locationStore.locations"
:key="location.coordinates"
class="location">
<o-button variant="primary"
icon-right="arrow-right"
expanded
@click="showWeather(location)">
{{ location.cityState }}
</o-button>
</div>
<div v-if="locationStore.locations.length"
class="location">
<o-button icon-right="edit"
expanded
@click="editLocations()">
Edit List
</o-button>
</div>
<br />
<div class="location">
<o-button variant="primary"
icon-right="location-dot"
expanded
@click="useCurrentLocation()">
Use My Current Location
</o-button>
</div>
</div>
</div>
</main>

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,67 +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
},
showHourly(period) {
this.$router.push('/hourly')
},
},
onActivated(() => {
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">
@ -71,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">
@ -82,12 +131,13 @@ export default {
<div class="panel-block">
<div v-if="weatherStore.forecast"
style="display: flex;">
style="display: flex; overflow-x: scroll;">
<div v-for="period in weatherStore.forecast.properties.periods"
: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>
@ -96,7 +146,7 @@ export default {
<img :src="period.icon" />
</div>
<p>{{ period.shortForecast }}</p>
<p>{{ getShortForecast(period) }}</p>
<p>
<span v-if="period.isDaytime">Hi</span>
@ -114,6 +164,30 @@ export default {
</div>
</nav>
<nav class="panel weather-panel">
<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="radarLatestURL" />
</div>
<div class="column">
<img :src="radarLoopURL" />
</div>
</div>
</div>
</nav>
</div>
</main>
</template>
@ -122,6 +196,7 @@ export default {
.weather-period {
width: 100px;
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: center;

38
tasks.py Normal file
View file

@ -0,0 +1,38 @@
# -*- coding: utf-8; -*-
"""
Tasks for myweather
"""
import os
import json
from invoke import task
here = os.path.dirname(__file__)
@task
def release(c):
"""
Release a new version of myweather
"""
# figure out current package version
package_json = os.path.join(here, 'package.json')
with open(package_json, 'rt') as f:
js = json.load(f)
version = js['version']
# build the app, create zip archive
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} *')
os.chdir(os.pardir)
# upload zip archive to server
c.run(f'scp dist/{filename} weather.edbob.org:/srv/myweather/releases/{filename}')
c.run(f"ssh weather.edbob.org 'cd /srv/myweather/releases; ln -sf {filename} latest.zip'")
# run upgrade on server
c.run('ssh weather.edbob.org /srv/myweather/upgrade.sh')