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

@@ -1,64 +1,12 @@
<script setup lang="ts">
import { ref } from 'vue'
import Dashboard from './views/Dashboard.vue'
import '@ztimson/css-utils/dist/css-utils.min.css';
const dark = ref(window.matchMedia('(prefers-color-scheme: dark)').matches)
function toggleDark() { dark.value = !dark.value }
</script>
<style lang="scss">
::-webkit-scrollbar {
width: .45rem;
height: .45rem;
}
::-webkit-scrollbar-thumb {
--tw-border-opacity: 1;
border-color: rgba(255, 255, 255, var(--tw-border-opacity));
background-color: #d7d7d799;
border-width: 1px;
border-radius: 9999px;
}
::-webkit-scrollbar-track {
background-color: #0000;
border-radius: 9999px;
}
:root {
--bg: #ffffff;
--surface: #d0dae1;
--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;

View File

@@ -1,4 +1,41 @@
@import './base.css';
@import '@ztimson/css-utils/dist/css-utils.min.css';
::-webkit-scrollbar { height: 4px; width: 4px; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
:root {
--bg: #ffffff;
--surface: #d0dae1;
--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; }
#app {
width: 100%;
@@ -27,3 +64,25 @@ a,
place-items: center;
}
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
overflow: hidden;
.card-title {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-muted);
padding: 10px 12px 6px;
}
.divider {
height: 1px;
background: var(--border);
margin: 6px 0;
}
}

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>

View File

@@ -1,28 +1,31 @@
export const BASE = import.meta.env.DEV ? 'http://10.69.5.23' : ''
export const BASE = import.meta.env.DEV ? 'http://10.69.5.23' : '';
export type DataRow = Record<string, number | string | null>
const cache: any = {};
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))
if(cache[url.toString()]) return cache[url.toString()];
cache[url.toString()] = await fetch(url.toString()).then(resp => resp.json()).finally(() => { delete cache[url.toString()]; });
return cache[url.toString()]
const url = new URL(`${BASE}${path}`, window.location.origin);
Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v));
const key = url.toString();
if(cache[key]) return cache[key];
cache[key] = fetch(url.toString())
.then(resp => resp.json())
.finally(() => delete cache[key]);
return cache[key];
}
export const api = {
position: (fields?: string) => get<{latitude: number, longitude: number, altitude: number}>('/api/position', fields ? { fields } : {}),
current: (fields?: string) => get<DataRow>('/api/current', fields ? { fields } : {}),
hourly: (start?: Date, end?: Date, fields?: string) => get<DataRow[]>('/api/hourly', {
...(start ? { start: start.toISOString().slice(0, 10) } : {}),
...(end ? { end: end.toISOString().slice(0, 10) } : {}),
...(fields ? { fields } : {})
}),
daily: (start?: Date, end?: Date, fields?: string) => get<DataRow[]>('/api/daily', {
...(start ? { start: start.toISOString().slice(0, 10) } : {}),
...(end ? { end: end.toISOString().slice(0, 10) } : {}),
...(fields ? { fields } : {})
}),
}
position: (fields?: string) => get<{latitude: number, longitude: number, altitude: number}>('/api/position', fields ? {fields} : {}),
current: (fields?: string) => get<DataRow>('/api/current', fields ? {fields} : {}),
hourly: (start?: Date, end?: Date, fields?: string) => get<DataRow[]>('/api/hourly', {
...(start ? {start: start.toISOString().slice(0, 10)} : {}),
...(end ? {end: end.toISOString().slice(0, 10)} : {}),
...(fields ? {fields} : {})
}),
daily: (start?: Date, end?: Date, fields?: string) => get<DataRow[]>('/api/daily', {
...(start ? {start: start.toISOString().slice(0, 10)} : {}),
...(end ? {end: end.toISOString().slice(0, 10)} : {}),
...(fields ? {fields} : {})
}),
};

View File

@@ -1,7 +1,9 @@
`<script setup lang="ts">
import EnvironmentCard from '@/components/EnvironmentCard.vue';
import GraphModal from '@/components/GraphModal.vue';
import HourlyForecast from '@/components/HourlyForecast.vue';
import {formatDate} from '@ztimson/utils';
import {onMounted, onUnmounted, ref} from 'vue';
import {onMounted, onUnmounted, provide, ref} from 'vue';
import MapView from '../components/MapView.vue';
import CurrentWeather from '../components/CurrentWeather.vue';
import ForecastStrip from '../components/ForecastStrip.vue';
@@ -10,8 +12,11 @@ const day = ref(formatDate('dddd'));
const date = ref(formatDate('MMMM D'));
const time = ref(formatDate('h:mm A'));
const props = defineProps<{dark: boolean}>();
const selectedMetric = ref<string | null>(null);
let clock: ReturnType<typeof setInterval>;
provide('openGraph', (key: string) => selectedMetric.value = key);
onMounted(async () => {
clock = setInterval(() => {
day.value = formatDate('dddd');
@@ -161,13 +166,11 @@ onUnmounted(() => {
<CurrentWeather />
<HourlyForecast />
<ForecastStrip />
<EnvironmentCard />
</div>
<!-- <GraphModal-->
<!-- :metric-key="selectedMetric"-->
<!-- :current-data="current"-->
<!-- @close="selectedMetric = null"-->
<!-- />-->
<GraphModal :metric-key="selectedMetric" @close="selectedMetric = null" />
</div>
</template>
`