This commit is contained in:
2026-06-21 22:14:04 -04:00
commit 533aec8ba2
46 changed files with 3530 additions and 0 deletions

39
client/.gitignore vendored Normal file
View File

@@ -0,0 +1,39 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo
.eslintcache
# Cypress
/cypress/videos/
/cypress/screenshots/
# Vitest
__screenshots__/
# Vite
*.timestamp-*-*.mjs

3
client/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

42
client/README.md Normal file
View File

@@ -0,0 +1,42 @@
# client
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
## Recommended Browser Setup
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
- Firefox:
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
## Type Support for `.vue` Imports in TS
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
## Customize configuration
See [Vite Configuration Reference](https://vite.dev/config/).
## Project Setup
```sh
npm install
```
### Compile and Hot-Reload for Development
```sh
npm run dev
```
### Type-Check, Compile and Minify for Production
```sh
npm run build
```

1
client/env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

13
client/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

27
client/package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "weather-station",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "vite",
"build": "vue-tsc --build && vue build"
},
"dependencies": {
"vue": "^3.5.38"
},
"devDependencies": {
"@tsconfig/node24": "^24.0.4",
"@types/node": "^24.13.2",
"@vitejs/plugin-vue": "^6.0.7",
"@vue/tsconfig": "^0.9.1",
"npm-run-all2": "^9.0.2",
"typescript": "~6.0.0",
"vite": "^8.0.16",
"vite-plugin-vue-devtools": "^8.1.2",
"vue-tsc": "^3.3.5"
},
"engines": {
"node": "^22.18.0 || >=24.12.0"
}
}

BIN
client/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

77
client/src/App.vue Normal file
View File

@@ -0,0 +1,77 @@
<script setup lang="ts">
import { ref } from 'vue'
import Dashboard from './views/Dashboard.vue'
const dark = ref(window.matchMedia('(prefers-color-scheme: dark)').matches)
function toggleDark() { dark.value = !dark.value }
</script>
<style lang="scss">
:root {
--bg: #ffffff;
--surface: #f8fafc;
--border: #e2e8f0;
--text: #0f172a;
--text-muted: #64748b;
--hover: #f1f5f9;
--accent: #3b82f6;
}
.dark {
--bg: #0a0a0a;
--surface: #111111;
--border: #222222;
--text: #f1f5f9;
--text-muted: #64748b;
--hover: #1a1a1a;
--accent: #3b82f6;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: var(--bg);
color: var(--text);
height: 100vh;
overflow: hidden;
}
.fade-enter-active, .fade-leave-active { transition: opacity 0.2s; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
</style>
<style scoped lang="scss">
.app-wrap {
height: 100vh;
overflow: hidden;
}
.theme-toggle {
position: fixed;
top: 16px;
right: 16px;
z-index: 500;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 99px;
padding: 6px 12px;
cursor: pointer;
font-size: 18px;
color: var(--text);
transition: background 0.15s;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
&:hover { background: var(--hover); }
}
</style>
<template>
<div class="app-wrap" :class="{ dark }">
<button class="theme-toggle" @click="toggleDark">
{{ dark ? '' : '🌙' }}
</button>
<Dashboard :dark="dark" />
</div>
</template>

View File

@@ -0,0 +1,86 @@
/* color palette from <https://github.com/vuejs/theme> */
:root {
--vt-c-white: #ffffff;
--vt-c-white-soft: #f8f8f8;
--vt-c-white-mute: #f2f2f2;
--vt-c-black: #181818;
--vt-c-black-soft: #222222;
--vt-c-black-mute: #282828;
--vt-c-indigo: #2c3e50;
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
--vt-c-text-light-1: var(--vt-c-indigo);
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
--vt-c-text-dark-1: var(--vt-c-white);
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
}
/* semantic color variables for this project */
:root {
--color-background: var(--vt-c-white);
--color-background-soft: var(--vt-c-white-soft);
--color-background-mute: var(--vt-c-white-mute);
--color-border: var(--vt-c-divider-light-2);
--color-border-hover: var(--vt-c-divider-light-1);
--color-heading: var(--vt-c-text-light-1);
--color-text: var(--vt-c-text-light-1);
--section-gap: 160px;
}
@media (prefers-color-scheme: dark) {
:root {
--color-background: var(--vt-c-black);
--color-background-soft: var(--vt-c-black-soft);
--color-background-mute: var(--vt-c-black-mute);
--color-border: var(--vt-c-divider-dark-2);
--color-border-hover: var(--vt-c-divider-dark-1);
--color-heading: var(--vt-c-text-dark-1);
--color-text: var(--vt-c-text-dark-2);
}
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
font-weight: normal;
}
body {
min-height: 100vh;
color: var(--color-text);
background: var(--color-background);
transition:
color 0.5s,
background-color 0.5s;
line-height: 1.6;
font-family:
Inter,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
Oxygen,
Ubuntu,
Cantarell,
'Fira Sans',
'Droid Sans',
'Helvetica Neue',
sans-serif;
font-size: 15px;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>

After

Width:  |  Height:  |  Size: 276 B

View File

@@ -0,0 +1,35 @@
@import './base.css';
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
font-weight: normal;
}
a,
.green {
text-decoration: none;
color: hsla(160, 100%, 37%, 1);
transition: 0.4s;
padding: 3px;
}
@media (hover: hover) {
a:hover {
background-color: hsla(160, 100%, 37%, 0.2);
}
}
@media (min-width: 1024px) {
body {
display: flex;
place-items: center;
}
#app {
display: grid;
grid-template-columns: 1fr 1fr;
padding: 0 2rem;
}
}

View File

@@ -0,0 +1,99 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { DataRow } from '../services/api'
const props = defineProps<{ data: DataRow }>()
const temp = computed(() => props.data.env_temp_c != null ? `${(props.data.env_temp_c as number).toFixed(1)}°C` : '—')
const feels = computed(() => props.data.env_heat_index_c != null ? `Feels ${(props.data.env_heat_index_c as number).toFixed(1)}°C` : '')
const icon = computed(() => props.data.forecast_weather_icon as string | null)
const label = computed(() => props.data.forecast_weather_label as string | null)
const humidity = computed(() => props.data.env_humidity != null ? `${(props.data.env_humidity as number).toFixed(0)}%` : '—')
const wind = computed(() => props.data.wind_speed_kmh != null ? `${(props.data.wind_speed_kmh as number).toFixed(1)} km/h` : '—')
const uvi = computed(() => props.data.light_uvi != null ? `UV ${(props.data.light_uvi as number).toFixed(1)}` : null)
const sunrise = computed(() => props.data.sun_sunrise ? new Date(props.data.sun_sunrise as string).toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit' }) : '—')
const sunset = computed(() => props.data.sun_sunset ? new Date(props.data.sun_sunset as string).toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit' }) : '—')
</script>
<style scoped lang="scss">
.hero {
display: flex;
flex-direction: column;
gap: 8px;
padding: 16px;
border-radius: 12px;
background: var(--surface);
border: 1px solid var(--border);
}
.top {
display: flex;
align-items: center;
gap: 12px;
}
.weather-icon {
width: 64px;
height: 64px;
object-fit: contain;
}
.temps {
flex: 1;
}
.temp-main {
font-size: 42px;
font-weight: 700;
line-height: 1;
color: var(--text);
}
.temp-feel {
font-size: 13px;
color: var(--text-muted);
}
.weather-label {
font-size: 14px;
font-weight: 500;
color: var(--text);
}
.stats {
display: flex;
gap: 16px;
flex-wrap: wrap;
border-top: 1px solid var(--border);
padding-top: 10px;
}
.stat {
display: flex;
flex-direction: column;
gap: 2px;
}
.stat-label { font-size: 10px; color: var(--text-muted); text-transform: uppercase; }
.stat-value { font-size: 14px; font-weight: 600; color: var(--text); }
</style>
<template>
<div class="hero">
<div class="top">
<img v-if="icon" :src="icon" class="weather-icon" alt="weather" />
<div class="temps">
<div class="temp-main">{{ temp }}</div>
<div class="temp-feel">{{ feels }}</div>
<div class="weather-label">{{ label }}</div>
</div>
</div>
<div class="stats">
<div class="stat"><span class="stat-label">Humidity</span><span class="stat-value">{{ humidity }}</span></div>
<div class="stat"><span class="stat-label">Wind</span><span class="stat-value">{{ wind }}</span></div>
<div v-if="uvi" class="stat"><span class="stat-label">UV</span><span class="stat-value">{{ uvi }}</span></div>
<div class="stat"><span class="stat-label">Sunrise</span><span class="stat-value">{{ sunrise }}</span></div>
<div class="stat"><span class="stat-label">Sunset</span><span class="stat-value">{{ sunset }}</span></div>
</div>
</div>
</template>

View File

@@ -0,0 +1,62 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { DataRow } from '../services/api'
const props = defineProps<{ days: DataRow[] }>()
const items = computed(() => props.days.slice(0, 5).map(d => ({
date: new Date(d.time as string).toLocaleDateString('en', { weekday: 'short', month: 'short', day: 'numeric' }),
icon: d.forecast_weather_icon as string | null,
label: d.forecast_weather_label as string,
high: d.env_temp_max_c != null ? `${(d.env_temp_max_c as number).toFixed(1)}°` : '—',
low: d.env_temp_min_c != null ? `${(d.env_temp_min_c as number).toFixed(1)}°` : '—',
rain: d.forecast_precipitation_probability != null ? `${d.forecast_precipitation_probability}%` : null,
})))
</script>
<style scoped lang="scss">
.strip {
display: flex;
gap: 8px;
overflow-x: auto;
padding-bottom: 4px;
&::-webkit-scrollbar { height: 4px; }
&::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
}
.day {
flex: 1;
min-width: 80px;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 10px 8px;
border-radius: 10px;
background: var(--surface);
border: 1px solid var(--border);
}
.day-name { font-size: 11px; color: var(--text-muted); font-weight: 600; text-transform: uppercase; }
.day-icon { width: 36px; height: 36px; object-fit: contain; }
.day-label { font-size: 10px; color: var(--text-muted); text-align: center; line-height: 1.2; }
.day-temps { display: flex; gap: 6px; font-size: 13px; font-weight: 600; color: var(--text); }
.day-low { color: var(--text-muted); font-weight: 400; }
.day-rain { font-size: 11px; color: #38bdf8; }
</style>
<template>
<div class="strip">
<div v-for="item in items" :key="item.date" class="day">
<div class="day-name">{{ item.date }}</div>
<img v-if="item.icon" :src="item.icon" class="day-icon" :alt="item.label" />
<div class="day-label">{{ item.label }}</div>
<div class="day-temps">
<span>{{ item.high }}</span>
<span class="day-low">{{ item.low }}</span>
</div>
<div v-if="item.rain" class="day-rain">🌧 {{ item.rain }}</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,87 @@
<script setup lang="ts">
import { computed } from 'vue'
import { METRICS } from '../services/units'
import MetricChart from './MetricChart.vue'
import type { DataRow } from '../services/api'
const props = defineProps<{ metricKey: string | null; currentData: DataRow }>()
const emit = defineEmits<{ (e: 'close'): void }>()
const meta = computed(() => props.metricKey ? METRICS[props.metricKey] : null)
</script>
<style scoped lang="scss">
.backdrop {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.6);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
backdrop-filter: blur(4px);
}
.modal {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 16px;
padding: 24px;
width: 100%;
max-width: 800px;
max-height: 90vh;
overflow-y: auto;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.modal-title {
font-size: 18px;
font-weight: 600;
color: var(--text);
display: flex;
align-items: center;
gap: 8px;
}
.close-btn {
background: none;
border: 1px solid var(--border);
color: var(--text);
border-radius: 8px;
width: 32px;
height: 32px;
cursor: pointer;
font-size: 16px;
display: flex;
align-items: center;
justify-content: center;
&:hover { background: var(--hover); }
}
</style>
<template>
<Teleport to="body">
<Transition name="fade">
<div v-if="metricKey" class="backdrop" @click.self="emit('close')">
<div class="modal">
<div class="modal-header">
<div class="modal-title">
<span>{{ meta?.icon }}</span>
<span>{{ meta?.label ?? metricKey }}</span>
</div>
<button class="close-btn" @click="emit('close')"></button>
</div>
<MetricChart :metric-key="metricKey" :current-data="currentData" />
</div>
</div>
</Transition>
</Teleport>
</template>

View File

@@ -0,0 +1,169 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref, watch } from 'vue'
import Map from 'ol/Map'
import View from 'ol/View'
import TileLayer from 'ol/layer/Tile'
import XYZ from 'ol/source/XYZ'
import { fromLonLat } from 'ol/proj'
import { current } from '../services/weather'
import 'ol/ol.css'
const props = defineProps<{ dark: boolean }>()
const mapEl = ref<HTMLDivElement>()
let map: Map
const OVERLAYS = [
{ id: 'clouds', label: 'Clouds', icon: '☁️', url: (ts: number) => `https://tilecache.rainviewer.com/v2/coverage/0/256/{z}/{x}/{y}/1/1_1.png` },
{ id: 'rain', label: 'Rain', icon: '🌧️', url: (ts: number) => `https://tilecache.rainviewer.com/v2/radar/${ts}/256/{z}/{x}/{y}/4/1_1.png` },
{ id: 'wind', label: 'Wind', icon: '💨', url: (_: number) => `https://tile.openweathermap.org/map/wind_new/{z}/{x}/{y}.png?appid=demo` },
{ id: 'temp', label: 'Temperature', icon: '🌡️', url: (_: number) => `https://tile.openweathermap.org/map/temp_new/{z}/{x}/{y}.png?appid=demo` },
{ id: 'pressure', label: 'Pressure', icon: '📊', url: (_: number) => `https://tile.openweathermap.org/map/pressure_new/{z}/{x}/{y}.png?appid=demo` },
]
const activeOverlays = ref<Set<string>>(new Set(['rain']))
const radarTs = ref(Math.floor(Date.now() / 1000))
const overlayLayers = new Map<string, TileLayer<XYZ>>()
let radarInterval: ReturnType<typeof setInterval>
function buildBaseLayer(dark: boolean) {
return new TileLayer({
source: new XYZ({
url: dark
? 'https://tiles.stadiamaps.com/tiles/alidade_smooth_dark/{z}/{x}/{y}.png'
: 'https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}.png',
attributions: '© Stadia Maps © OpenMapTiles © OpenStreetMap',
}),
})
}
function buildOverlayLayer(id: string): TileLayer<XYZ> {
const def = OVERLAYS.find(o => o.id === id)!
return new TileLayer({
source: new XYZ({ url: def.url(radarTs.value), attributions: '' }),
opacity: 0.6,
zIndex: 10,
})
}
function toggleOverlay(id: string) {
if (activeOverlays.value.has(id)) {
activeOverlays.value.delete(id)
const layer = overlayLayers.get(id)
if (layer) { map.removeLayer(layer); overlayLayers.delete(id) }
} else {
activeOverlays.value.add(id)
const layer = buildOverlayLayer(id)
overlayLayers.set(id, layer)
map.addLayer(layer)
}
}
onMounted(() => {
const lat = (current.value.gps_lat as number) || 0
const lon = (current.value.gps_lon as number) || 0
map = new Map({
target: mapEl.value!,
layers: [buildBaseLayer(props.dark)],
view: new View({
center: fromLonLat([lon, lat]),
zoom: 9,
}),
controls: [],
})
// Add default active overlays
for (const id of activeOverlays.value) {
const layer = buildOverlayLayer(id)
overlayLayers.set(id, layer)
map.addLayer(layer)
}
// Refresh radar every 5 min
radarInterval = setInterval(() => {
radarTs.value = Math.floor(Date.now() / 1000)
const layer = overlayLayers.get('rain')
if (layer) {
layer.setSource(new XYZ({ url: OVERLAYS.find(o => o.id === 'rain')!.url(radarTs.value) }))
}
}, 5 * 60 * 1000)
})
onUnmounted(() => clearInterval(radarInterval))
watch(() => props.dark, (dark) => {
const layers = map.getLayers()
layers.setAt(0, buildBaseLayer(dark))
})
</script>
<style scoped lang="scss">
.map-wrap {
position: relative;
width: 100%;
height: 100%;
border-radius: 12px;
overflow: hidden;
}
.map-el {
width: 100%;
height: 100%;
}
.overlay-toggles {
position: absolute;
bottom: 16px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 8px;
z-index: 100;
background: var(--surface);
border-radius: 99px;
padding: 6px 10px;
box-shadow: 0 2px 12px rgba(0,0,0,0.2);
}
.overlay-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 12px;
border-radius: 99px;
border: 1.5px solid var(--border);
background: transparent;
color: var(--text);
font-size: 13px;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
&.active {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
&:hover:not(.active) {
background: var(--hover);
}
}
</style>
<template>
<div class="map-wrap">
<div ref="mapEl" class="map-el" />
<div class="overlay-toggles">
<button
v-for="o in OVERLAYS"
:key="o.id"
class="overlay-btn"
:class="{ active: activeOverlays.has(o.id) }"
@click="toggleOverlay(o.id)"
>
{{ o.icon }} {{ o.label }}
</button>
</div>
</div>
</template>

View File

@@ -0,0 +1,76 @@
<script setup lang="ts">
import { computed } from 'vue'
import { METRICS, formatValue } from '../services/units'
import type { DataRow } from '../services/api'
const props = defineProps<{
metricKey: string
data: DataRow
}>()
const emit = defineEmits<{ (e: 'click', key: string): void }>()
const meta = computed(() => METRICS[props.metricKey])
const value = computed(() => formatValue(props.metricKey, props.data[props.metricKey] as number | null))
const label = computed(() => props.data[`${props.metricKey.replace(/_[^_]+$/, '')}_label`] as string | undefined)
</script>
<style scoped lang="scss">
.card {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
border-radius: 10px;
background: var(--surface);
border: 1px solid var(--border);
cursor: pointer;
transition: background 0.15s, transform 0.1s;
user-select: none;
&:hover { background: var(--hover); }
&:active { transform: scale(0.97); }
}
.icon {
font-size: 22px;
line-height: 1;
flex-shrink: 0;
}
.info {
flex: 1;
min-width: 0;
}
.metric-label {
font-size: 11px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
white-space: nowrap;
}
.metric-value {
font-size: 18px;
font-weight: 600;
color: var(--text);
white-space: nowrap;
}
.metric-sub {
font-size: 11px;
color: var(--text-muted);
}
</style>
<template>
<div class="card" @click="emit('click', metricKey)">
<span class="icon">{{ meta?.icon ?? '📊' }}</span>
<div class="info">
<div class="metric-label">{{ meta?.label ?? metricKey }}</div>
<div class="metric-value">{{ value }}</div>
<div v-if="label" class="metric-sub">{{ label }}</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,160 @@
<script setup lang="ts">
import { computed, ref, onMounted } from 'vue'
import { Line } from 'vue-chartjs'
import {
Chart as ChartJS, CategoryScale, LinearScale, PointElement,
LineElement, Tooltip, Legend, Filler, type ChartOptions
} from 'chart.js'
import { METRICS, formatValue } from '../services/units'
import { fetchHistoricHourly } from '../services/weather'
import type { DataRow } from '../services/api'
import { api } from '../services/api'
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Tooltip, Legend, Filler)
const props = defineProps<{ metricKey: string; currentData: DataRow }>()
const historic = ref<DataRow[]>([])
const forecast = ref<DataRow[]>([])
const loading = ref(true)
const meta = computed(() => METRICS[props.metricKey])
onMounted(async () => {
const now = new Date()
const start = new Date(now.getTime() - 24 * 3600000).toISOString()
const end = new Date(now.getTime() + 48 * 3600000).toISOString()
const [hist, fore] = await Promise.allSettled([
fetchHistoricHourly(start, now.toISOString()),
api.hourly(now.toISOString(), end),
])
if (hist.status === 'fulfilled') historic.value = hist.value.filter(r => r[props.metricKey] != null)
if (fore.status === 'fulfilled') forecast.value = fore.value.filter(r => r[props.metricKey] != null)
loading.value = false
})
const nowLabel = computed(() => {
const d = new Date()
return `${d.getHours().toString().padStart(2,'0')}:${d.getMinutes().toString().padStart(2,'0')}`
})
const chartData = computed(() => {
const color = meta.value?.color ?? '#38bdf8'
const histLabels = historic.value.map(r => r.time as string)
const foreLabels = forecast.value.map(r => r.time as string)
const histVals = historic.value.map(r => r[props.metricKey] as number)
const foreVals = forecast.value.map(r => r[props.metricKey] as number)
const currentVal = props.currentData[props.metricKey] as number | null
const allLabels = [...histLabels, nowLabel.value, ...foreLabels]
const histData = [...histVals, currentVal, ...foreVals.map(() => null)]
const foreData = [...histVals.map(() => null), currentVal, ...foreVals]
return {
labels: allLabels,
datasets: [
{
label: 'Historic',
data: histData,
borderColor: color,
backgroundColor: `${color}22`,
borderWidth: 2,
pointRadius: 0,
tension: 0.3,
fill: true,
spanGaps: false,
},
{
label: 'Forecast',
data: foreData,
borderColor: color,
backgroundColor: 'transparent',
borderWidth: 2,
borderDash: [6, 4],
pointRadius: 0,
tension: 0.3,
fill: false,
spanGaps: false,
},
{
label: 'Now',
data: allLabels.map(l => l === nowLabel.value ? currentVal : null),
borderColor: '#ffffff88',
backgroundColor: color,
pointRadius: 6,
pointHoverRadius:8,
borderWidth: 0,
showLine: false,
spanGaps: false,
}
]
}
})
const chartOptions = computed<ChartOptions<'line'>>(() => ({
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: ctx => formatValue(props.metricKey, ctx.parsed.y),
}
}
},
scales: {
x: {
ticks: {
color: 'var(--text-muted)',
maxTicksLimit: 12,
maxRotation: 0,
callback(val, i) {
const lbl = this.getLabelForValue(i)
return lbl?.length === 5 && lbl.endsWith(':00') ? lbl : ''
}
},
grid: { color: 'var(--border)' },
},
y: {
ticks: { color: 'var(--text-muted)', callback: v => formatValue(props.metricKey, v as number) },
grid: { color: 'var(--border)' },
}
},
annotation: {
annotations: {
nowLine: {
type: 'line',
xMin: nowLabel.value,
xMax: nowLabel.value,
borderColor: '#ffffff44',
borderWidth: 1,
borderDash: [4, 4],
}
}
}
}))
</script>
<style scoped lang="scss">
.chart-wrap {
position: relative;
height: 260px;
width: 100%;
}
.loading {
display: flex;
align-items: center;
justify-content: center;
height: 260px;
color: var(--text-muted);
font-size: 14px;
}
</style>
<template>
<div v-if="loading" class="loading">Loading chart</div>
<div v-else class="chart-wrap">
<Line :data="chartData" :options="chartOptions" />
</div>
</template>

6
client/src/main.ts Normal file
View File

@@ -0,0 +1,6 @@
import './assets/main.css'
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')

View File

@@ -0,0 +1,16 @@
const BASE = import.meta.env.DEV ? 'http://localhost:3000' : ''
export type DataRow = Record<string, number | string | null>
async function get<T>(path: string, params: Record<string, string> = {}): Promise<T> {
const url = new URL(`${BASE}${path}`, window.location.origin)
Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v))
const res = await fetch(url.toString())
return res.json()
}
export const api = {
current: (fields?: string) => get<DataRow>('/api/data', fields ? { fields } : {}),
hourly: (start?: string, end?: string, fields?: string) => get<DataRow[]>('/api/hourly', { ...(start ? { start } : {}), ...(end ? { end } : {}), ...(fields ? { fields } : {}) }),
daily: (start?: string, end?: string, fields?: string) => get<DataRow[]>('/api/daily', { ...(start ? { start } : {}), ...(end ? { end } : {}), ...(fields ? { fields } : {}) }),
}

View File

@@ -0,0 +1,79 @@
export interface MetricMeta {
label: string
unit: string
icon: string
group: string
precision: number
color: string
}
export const METRICS: Record<string, MetricMeta> = {
// Environment
env_temp_c: { label: 'Temperature', unit: '°C', icon: '🌡️', group: 'Environment', precision: 1, color: '#f97316' },
env_temp_f: { label: 'Temperature', unit: '°F', icon: '🌡️', group: 'Environment', precision: 1, color: '#f97316' },
env_humidity: { label: 'Humidity', unit: '%', icon: '💧', group: 'Environment', precision: 1, color: '#38bdf8' },
env_dew_point_c: { label: 'Dew Point', unit: '°C', icon: '🌫️', group: 'Environment', precision: 1, color: '#818cf8' },
env_heat_index_c: { label: 'Feels Like', unit: '°C', icon: '🤔', group: 'Environment', precision: 1, color: '#fb923c' },
env_pressure_hpa: { label: 'Pressure', unit: ' hPa', icon: '📊', group: 'Environment', precision: 1, color: '#a78bfa' },
env_pressure_slp: { label: 'Sea Level Pressure', unit: ' hPa', icon: '📊', group: 'Environment', precision: 1, color: '#a78bfa' },
env_abs_humidity: { label: 'Absolute Humidity', unit: ' g/m³', icon: '💦', group: 'Environment', precision: 2, color: '#67e8f9' },
env_vpd_kpa: { label: 'Vapour Pressure Deficit', unit: ' kPa', icon: '🌿', group: 'Environment', precision: 2, color: '#4ade80' },
env_aqi_score: { label: 'Air Quality', unit: '/100', icon: '🏭', group: 'Environment', precision: 0, color: '#34d399' },
env_gas_ohms: { label: 'Gas Resistance', unit: ' Ω', icon: '⚗️', group: 'Environment', precision: 0, color: '#fbbf24' },
// Light
light_lux: { label: 'Illuminance', unit: ' lux', icon: '☀️', group: 'Light', precision: 0, color: '#fde68a' },
light_uvi: { label: 'UV Index', unit: '', icon: '🕶️', group: 'Light', precision: 1, color: '#f472b6' },
light_solar_wm2: { label: 'Solar Irradiance', unit: ' W/m²', icon: '⚡', group: 'Light', precision: 1, color: '#fcd34d' },
light_uv_dose_mj: { label: 'UV Dose Today', unit: ' mJ/cm²', icon: '📡', group: 'Light', precision: 2, color: '#f9a8d4' },
light_burn_time_min: { label: 'Burn Time', unit: ' min', icon: '🔥', group: 'Light', precision: 0, color: '#fb7185' },
light_cloud_pct: { label: 'Cloud Cover', unit: '%', icon: '☁️', group: 'Light', precision: 0, color: '#94a3b8' },
light_dli: { label: 'Daily Light Integral', unit: ' mol/m²', icon: '🌱', group: 'Light', precision: 3, color: '#86efac' },
light_visibility_km: { label: 'Visibility', unit: ' km', icon: '👁️', group: 'Light', precision: 1, color: '#7dd3fc' },
// Wind
wind_speed_kmh: { label: 'Wind Speed', unit: ' km/h', icon: '💨', group: 'Wind', precision: 1, color: '#93c5fd' },
wind_gusts_kmh: { label: 'Wind Gusts', unit: ' km/h', icon: '🌬️', group: 'Wind', precision: 1, color: '#60a5fa' },
wind_direction_deg: { label: 'Wind Direction', unit: '°', icon: '🧭', group: 'Wind', precision: 0, color: '#818cf8' },
// Seismic
seismic_magnitude: { label: 'Seismic Magnitude', unit: ' g', icon: '🌍', group: 'Seismic', precision: 4, color: '#f87171' },
seismic_ax: { label: 'Accel X', unit: ' g', icon: '📐', group: 'Seismic', precision: 3, color: '#fca5a5' },
seismic_ay: { label: 'Accel Y', unit: ' g', icon: '📐', group: 'Seismic', precision: 3, color: '#fca5a5' },
seismic_az: { label: 'Accel Z', unit: ' g', icon: '📐', group: 'Seismic', precision: 3, color: '#fca5a5' },
// Compass
compass_heading: { label: 'Compass Heading', unit: '°', icon: '🧭', group: 'Compass', precision: 1, color: '#c084fc' },
// Ground
ground_distance_cm: { label: 'Ground Distance', unit: ' cm', icon: '📏', group: 'Ground', precision: 0, color: '#a3e635' },
ground_accumulation_depth_cm: { label: 'Accumulation Depth', unit: ' cm', icon: '❄️', group: 'Ground', precision: 1, color: '#bfdbfe' },
ground_lidar_strength: { label: 'LIDAR Strength', unit: '', icon: '📡', group: 'Ground', precision: 0, color: '#6ee7b7' },
// Lightning
lightning_distance_km: { label: 'Lightning Distance', unit: ' km', icon: '⚡', group: 'Lightning', precision: 0, color: '#fde047' },
lightning_strikes_per_hour: { label: 'Strike Rate', unit: '/hr', icon: '⚡', group: 'Lightning', precision: 1, color: '#facc15' },
// Moon
moon_illumination_pct: { label: 'Moon Illumination', unit: '%', icon: '🌙', group: 'Celestial', precision: 0, color: '#e2e8f0' },
// Sun
sun_elevation: { label: 'Solar Elevation', unit: '°', icon: '☀️', group: 'Celestial', precision: 1, color: '#fef08a' },
// Space
space_kp_index: { label: 'Kp Index', unit: '', icon: '🌌', group: 'Space', precision: 1, color: '#818cf8' },
space_solar_wind_speed_kms: { label: 'Solar Wind', unit: ' km/s', icon: '☄️', group: 'Space', precision: 0, color: '#c4b5fd' },
space_imf_bz: { label: 'IMF Bz', unit: ' nT', icon: '🧲', group: 'Space', precision: 1, color: '#a5b4fc' },
// Marine
wave_height: { label: 'Wave Height', unit: ' m', icon: '🌊', group: 'Marine', precision: 2, color: '#38bdf8' },
swell_wave_height: { label: 'Swell Height', unit: ' m', icon: '🌊', group: 'Marine', precision: 2, color: '#7dd3fc' },
wave_period: { label: 'Wave Period', unit: ' s', icon: '⏱️', group: 'Marine', precision: 1, color: '#bae6fd' },
// AQ
pm2_5: { label: 'PM2.5', unit: ' μg/m³', icon: '🏭', group: 'Air Quality', precision: 1, color: '#fca5a5' },
pm10: { label: 'PM10', unit: ' μg/m³', icon: '🏭', group: 'Air Quality', precision: 1, color: '#fdba74' },
ozone: { label: 'Ozone', unit: ' μg/m³', icon: '🌐', group: 'Air Quality', precision: 1, color: '#6ee7b7' },
// Forecast
forecast_precipitation_mm: { label: 'Precipitation', unit: ' mm', icon: '🌧️', group: 'Forecast', precision: 1, color: '#38bdf8' },
forecast_precipitation_probability: { label: 'Rain Chance', unit: '%', icon: '🌂', group: 'Forecast', precision: 0, color: '#60a5fa' },
}
export function formatValue(key: string, value: number | string | null): string {
if (value === null || value === undefined) return '—'
const meta = METRICS[key]
if (!meta) return String(value)
if (typeof value === 'number') return `${value.toFixed(meta.precision)}${meta.unit}`
return `${value}${meta.unit}`
}
export const GROUPS = [...new Set(Object.values(METRICS).map(m => m.group))]

View File

@@ -0,0 +1,28 @@
import { ref, computed } from 'vue'
import { api, type DataRow } from './api'
export const current = ref<DataRow>({})
export const hourly = ref<DataRow[]>([])
export const daily = ref<DataRow[]>([])
export const loading = ref(false)
export const lastFetch = ref<Date | null>(null)
export async function fetchAll() {
loading.value = true
const [c, h, d] = await Promise.allSettled([api.current(), api.hourly(), api.daily()])
if (c.status === 'fulfilled') current.value = c.value
if (h.status === 'fulfilled') hourly.value = h.value
if (d.status === 'fulfilled') daily.value = d.value
lastFetch.value = new Date()
loading.value = false
}
export async function fetchHistoricHourly(start: string, end: string): Promise<DataRow[]> {
return api.hourly(start, end)
}
export async function fetchHistoricDaily(start: string, end: string): Promise<DataRow[]> {
return api.daily(start, end)
}
export const isDaytime = computed(() => current.value.sun_is_day === 1)

View File

@@ -0,0 +1,138 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref, computed } from 'vue'
import { current, hourly, daily, fetchAll, loading } from '../services/weather'
import { METRICS, GROUPS } from '../services/units'
import MapView from '../components/MapView.vue'
import CurrentWeather from '../components/CurrentWeather.vue'
import ForecastStrip from '../components/ForecastStrip.vue'
import MetricCard from '../components/MetricCard.vue'
import GraphModal from '../components/GraphModal.vue'
const props = defineProps<{ dark: boolean }>()
const selectedMetric = ref<string | null>(null)
let interval: ReturnType<typeof setInterval>
onMounted(async () => {
await fetchAll()
interval = setInterval(fetchAll, 60 * 1000)
})
onUnmounted(() => clearInterval(interval))
// Group all available metric keys present in current data
const groupedMetrics = computed(() => {
return GROUPS.map(group => ({
group,
keys: Object.entries(METRICS)
.filter(([key, meta]) => meta.group === group && current.value[key] != null)
.map(([key]) => key)
})).filter(g => g.keys.length > 0)
})
</script>
<style scoped lang="scss">
.dashboard {
display: flex;
height: 100vh;
overflow: hidden;
background: var(--bg);
color: var(--text);
}
.map-col {
flex: 1;
min-width: 0;
padding: 16px;
display: flex;
flex-direction: column;
}
.panel-col {
width: 380px;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
overflow-y: auto;
border-left: 1px solid var(--border);
&::-webkit-scrollbar { width: 4px; }
&::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
}
.group-label {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-muted);
margin-top: 4px;
margin-bottom: 2px;
padding: 0 2px;
}
.metric-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.loading-bar {
height: 2px;
background: var(--accent);
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 9999;
animation: pulse 1s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
@media (max-width: 768px) {
.dashboard { flex-direction: column; }
.map-col { height: 45vh; padding: 8px; }
.panel-col { width: 100%; border-left: none; border-top: 1px solid var(--border); }
.metric-grid { grid-template-columns: 1fr 1fr; }
}
</style>
<template>
<div class="dashboard">
<div v-if="loading" class="loading-bar" />
<div class="map-col">
<MapView :dark="props.dark" />
</div>
<div class="panel-col">
<CurrentWeather :data="current" />
<ForecastStrip :days="daily" />
<template v-for="g in groupedMetrics" :key="g.group">
<div class="group-label">{{ g.group }}</div>
<div class="metric-grid">
<MetricCard
v-for="key in g.keys"
:key="key"
:metric-key="key"
:data="current"
@click="selectedMetric = key"
/>
</div>
</template>
</div>
<GraphModal
:metric-key="selectedMetric"
:current-data="current"
@close="selectedMetric = null"
/>
</div>
</template>

18
client/tsconfig.app.json Normal file
View File

@@ -0,0 +1,18 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
// Extra safety for array and object lookups, but may have false positives.
"noUncheckedIndexedAccess": true,
// Path mapping for cleaner imports.
"paths": {
"@/*": ["./src/*"]
},
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
// Specified here to keep it out of the root directory.
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo"
}
}

11
client/tsconfig.json Normal file
View File

@@ -0,0 +1,11 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
}
]
}

27
client/tsconfig.node.json Normal file
View File

@@ -0,0 +1,27 @@
// TSConfig for modules that run in Node.js environment via either transpilation or type-stripping.
{
"extends": "@tsconfig/node24/tsconfig.json",
"include": [
"vite.config.*",
"vitest.config.*",
"cypress.config.*",
"playwright.config.*",
"eslint.config.*"
],
"compilerOptions": {
// Most tools use transpilation instead of Node.js's native type-stripping.
// Bundler mode provides a smoother developer experience.
"module": "preserve",
"moduleResolution": "bundler",
// Include Node.js types and avoid accidentally including other `@types/*` packages.
"types": ["node"],
// Disable emitting output during `vue-tsc --build`, which is used for type-checking only.
"noEmit": true,
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
// Specified here to keep it out of the root directory.
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
}
}

20
client/vite.config.ts Normal file
View File

@@ -0,0 +1,20 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
serve: { port: 5173 },
build: {
outDir: '../server/public',
emptyOutDir: true,
},
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
})