Use local time

This commit is contained in:
2026-06-24 20:31:58 -04:00
parent d6e799e956
commit 60f76b06d4
13 changed files with 476 additions and 309 deletions

View File

@@ -0,0 +1,28 @@
<script setup lang="ts">
import {ref, onMounted} from 'vue';
import {api} from '../services/api';
import MetricRow from './MetricRow.vue';
const d = ref<any>(null);
onMounted(async () => d.value = await api.current());
</script>
<template>
<div class="card" v-if="d">
<div class="card-title">🌡 Environment</div>
<MetricRow label="Temperature" :value="`${d.temperature?.toFixed(1)}°C`" metric-key="temperature" :data="d" />
<MetricRow label="Feels Like" :value="`${d.heat_index?.toFixed(1)}°C`" metric-key="heat_index" :data="d" />
<MetricRow label="Dew Point" :value="`${d.dew_point?.toFixed(1)}°C`" metric-key="dew_point" :data="d" />
<div class="divider" />
<MetricRow label="Humidity" :value="`${d.humidity?.toFixed(1)}%`" metric-key="humidity" :data="d" />
<MetricRow label="Abs Humidity" :value="`${d.humidity_abs?.toFixed(2)} g/m³`" metric-key="humidity_abs" :data="d" />
<MetricRow label="Vapor Pressure Deficit" :value="`${d.vapor_pressure_deficit?.toFixed(2)} kPa`" metric-key="vapor_pressure_deficit" :data="d" />
<div class="divider" />
<MetricRow label="Pressure" :value="`${d.pressure_hpa?.toFixed(1)} hPa`" metric-key="pressure_hpa" :data="d" />
<MetricRow label="Sea Level Pressure" :value="`${d.pressure_slp?.toFixed(1)} hPa`" metric-key="pressure_slp" :data="d" />
<MetricRow label="Trend" :value="d.pressure_trend" />
<div class="divider" />
<MetricRow label="Visibility" :value="`${d.visibility?.toFixed(1)} km`" metric-key="visibility" :data="d" />
</div>
</template>

View File

@@ -37,9 +37,6 @@ onMounted(async () => {
overflow-x: auto;
padding-bottom: 6px;
width: 100%;
&::-webkit-scrollbar { height: 4px; }
&::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
}
.day {

View File

@@ -1,87 +1,153 @@
<script setup lang="ts">
import { computed } from 'vue'
import { METRICS } from '../services/units'
import MetricChart from './MetricChart.vue'
import type { DataRow } from '../services/api'
import {computed, ref, watch} from 'vue';
import {api} from '../services/api';
const props = defineProps<{ metricKey: string | null; currentData: DataRow }>()
const emit = defineEmits<{ (e: 'close'): void }>()
const props = defineProps<{metricKey: string | null}>();
const emit = defineEmits<{(e: 'close'): void}>();
const meta = computed(() => props.metricKey ? METRICS[props.metricKey] : null)
const mode = ref<'hourly' | 'daily'>('hourly');
const rows = ref<any[]>([]);
const W = 600, H = 200, PAD = 30;
watch([() => props.metricKey, mode], async ([key]) => {
if(!key) return;
const now = new Date(), past = new Date(), future = new Date();
past.setDate(past.getDate() - 3);
future.setDate(future.getDate() + 3);
rows.value = mode.value === 'hourly'
? await api.hourly(past, future)
: await api.daily(past, future);
}, {immediate: true});
const now = new Date();
const points = computed(() => {
if(!rows.value.length || !props.metricKey) return [];
return rows.value.map(r => ({
t: new Date(r.time as string).getTime(),
v: r[props.metricKey!] as number | null,
})).filter(p => p.v != null);
});
const {minT, maxT, minV, maxV, path, currentX} = computed(() => {
const pts = points.value;
if(!pts.length) return {minT:0, maxT:1, minV:0, maxV:1, path:'', currentX: 0};
const ts = pts.map(p => p.t), vs = pts.map(p => p.v as number);
const minT = Math.min(...ts), maxT = Math.max(...ts);
const minV = Math.min(...vs), maxV = Math.max(...vs);
const xOf = (t: number) => PAD + ((t - minT) / (maxT - minT || 1)) * (W - PAD * 2);
const yOf = (v: number) => H - PAD - ((v - minV) / (maxV - minV || 1)) * (H - PAD * 2);
const nowT = now.getTime();
const currentX = xOf(nowT);
// split historic vs future
const hist = pts.filter(p => p.t <= nowT);
const fut = pts.filter(p => p.t >= nowT);
const histPath = hist.map((p, i) => `${i===0?'M':'L'} ${xOf(p.t).toFixed(1)} ${yOf(p.v as number).toFixed(1)}`).join(' ');
const futPath = fut.map((p, i) => `${i===0?'M':'L'} ${xOf(p.t).toFixed(1)} ${yOf(p.v as number).toFixed(1)}`).join(' ');
const path = {hist: histPath, fut: futPath};
return {minT, maxT, minV, maxV, path, currentX};
}).value;
const currentDot = computed(() => {
const pts = points.value;
if(!pts.length || !props.metricKey) return null;
const nowT = now.getTime();
const closest = pts.reduce((a, b) => Math.abs(a.t - nowT) < Math.abs(b.t - nowT) ? a : b);
const xOf = (t: number) => PAD + ((t - minT) / (maxT - minT || 1)) * (W - PAD * 2);
const yOf = (v: number) => H - PAD - ((v - minV) / (maxV - minV || 1)) * (H - PAD * 2);
return {x: xOf(closest.t), y: yOf(closest.v as number), v: closest.v};
});
</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);
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;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 16px;
padding: 24px;
width: 100%;
max-width: 700px;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
.modal-title {
font-size: 18px;
font-weight: 600;
color: var(--text);
display: flex;
align-items: center;
gap: 8px;
.title { font-size: 16px; font-weight: 700; color: var(--text); }
.close-btn {
background: none;
border: 1px solid var(--border);
color: var(--text);
border-radius: 8px;
width: 32px;
height: 32px;
cursor: pointer;
font-size: 16px;
&:hover { background: var(--hover); }
}
}
.mode-toggle {
display: flex;
gap: 6px;
margin-bottom: 16px;
.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); }
button {
font-size: 11px;
padding: 4px 12px;
border-radius: 6px;
border: 1px solid var(--border);
background: none;
color: var(--text-muted);
cursor: pointer;
&.active { background: var(--accent); color: #fff; border-color: var(--accent); }
}
}
svg { width: 100%; overflow: visible; }
</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>
<Teleport to="body">
<Transition name="fade">
<div v-if="metricKey" class="backdrop" @click.self="emit('close')">
<div class="modal">
<div class="modal-header">
<span class="title">{{ metricKey }}</span>
<button class="close-btn" @click="emit('close')"></button>
</div>
<div class="mode-toggle">
<button :class="{active: mode === 'hourly'}" @click="mode = 'hourly'">Hourly</button>
<button :class="{active: mode === 'daily'}" @click="mode = 'daily'">Daily</button>
</div>
<svg :viewBox="`0 0 ${W} ${H}`" preserveAspectRatio="none">
<!-- grid -->
<line :x1="PAD" :y1="PAD" :x2="PAD" :y2="H-PAD" stroke="var(--border)" stroke-width="1"/>
<line :x1="PAD" :y1="H-PAD" :x2="W-PAD" :y2="H-PAD" stroke="var(--border)" stroke-width="1"/>
<!-- now line -->
<line :x1="currentX" :y1="PAD" :x2="currentX" :y2="H-PAD" stroke="var(--accent)" stroke-width="1" stroke-dasharray="3 2" opacity="0.5"/>
<!-- historic solid -->
<path :d="path.hist" fill="none" stroke="var(--accent)" stroke-width="2"/>
<!-- future dashed -->
<path :d="path.fut" fill="none" stroke="var(--accent)" stroke-width="2" stroke-dasharray="5 3" opacity="0.6"/>
<!-- current dot -->
<circle v-if="currentDot" :cx="currentDot.x" :cy="currentDot.y" r="4" fill="var(--accent)"/>
</svg>
</div>
</div>
</Transition>
</Teleport>
</template>

View File

@@ -82,8 +82,8 @@ onMounted(async () => {
<span class="chevron" :class="{ open }"></span>
</div>
<div style="max-height: 50vh; overflow-y: auto">
<div v-if="open" class="hour-list">
<div v-if="open" style="height: 50vh; overflow-y: auto">
<div class="hour-list">
<template v-if="hours?.length">
<div v-for="h in hours" class="hour-row">
<span class="hour-time">{{ formatDate('h A', h.time) }}</span>

View File

@@ -0,0 +1,33 @@
<script setup lang="ts">
import {inject} from 'vue';
const props = defineProps<{label: string; value: any; metricKey?: string; data?: any}>();
const openGraph = inject<(key: string, data: any) => void>('openGraph');
</script>
<style scoped lang="scss">
.row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 5px 12px;
font-size: 12px;
border-radius: 6px;
transition: background 0.15s;
&.clickable {
cursor: pointer;
&:hover { background: var(--hover); }
}
.label { color: var(--text-muted); }
.value { font-weight: 600; color: var(--text); }
}
</style>
<template>
<div class="row" :class="{clickable: !!metricKey}" @click="metricKey && openGraph?.(metricKey, data)">
<span class="label">{{ label }}</span>
<span class="value">{{ value ?? '—' }}</span>
</div>
</template>