feat(openclaw): add shopping, supermarket deals, geizhals, and ebay MCP tools

This commit is contained in:
Moritz Graf
2026-09-10 21:32:22 +02:00
parent 2f2f597a67
commit 1e81cdbead
4754 changed files with 1298747 additions and 896 deletions
+449
View File
@@ -0,0 +1,449 @@
<template>
<div class="app-container">
<!-- Optional Retro CRT Scanlines Overlay -->
<div class="crt-overlay" v-if="crtEnabled"></div>
<main class="container">
<!-- SNES / Mad TV Style City & Station Control Hero -->
<HouseHero
:lastUpdated="currentMetrics?.updated_at"
@refresh="fetchData"
@theme-changed="handleThemeChanged"
@crt-changed="handleCrtChanged"
/>
<!-- Error / System Warning Alert (if any) -->
<div v-if="error" class="retro-window error-banner-window">
<div class="retro-window-header error-header">
<div class="retro-window-title">
<span>⚠️</span>
<span>SYSTEM ALERT // COMMUNICATION INTERRUPTED</span>
</div>
</div>
<div class="retro-window-content error-content">
<span class="error-msg">{{ error }}</span>
<button @click="fetchData" class="retro-btn btn-accent">RETRY LINK</button>
</div>
</div>
<!-- ============================================== -->
<!-- GROUP 1: TEMPERATURE & CLIMATE (Includes Dewpoint) -->
<!-- ============================================== -->
<section class="group-section">
<div class="group-header-bar">
<div class="group-title">
<span>🌡️</span>
<span>TEMPERATURE & THERMAL CLIMATE</span>
</div>
<span class="group-hint">[CLICK FOR FULL-SCREEN GRAPH]</span>
</div>
<div class="metrics-list-stack">
<!-- 1. Current Temperature -->
<WeatherMetricCard
title="Temperature"
icon="🌡️"
:value="currentMetrics?.temperature"
unit="°C"
:subText="currentMetrics?.apparent_temperature ? `Feels ${currentMetrics.apparent_temperature.toFixed(1)}°C` : undefined"
:highlight="true"
@inspect="openModal('temperature', 'Current Temperature', '🌡️', currentMetrics?.temperature, '°C', 'Ambient Dry-Bulb Reading')"
/>
<!-- 2. Dew Point (Calculated Metric) -->
<WeatherMetricCard
title="Dew Point"
icon="🌫️"
:value="effectiveDewPoint"
unit="°C"
:subBadge="dewPointComfort.text"
subText="Atmospheric Saturation"
@inspect="openModal('dew_point', 'Dew Point (Calculated)', '🌫️', effectiveDewPoint, '°C', `Atmospheric Saturation: ${dewPointComfort.text}`)"
/>
<!-- 3. Feels Like / Apparent Temperature -->
<WeatherMetricCard
title="Feels Like"
icon="🌤️"
:value="currentMetrics?.apparent_temperature"
unit="°C"
subText="Thermal Comfort Index"
@inspect="openModal('apparent_temperature', 'Apparent Temperature (Feels Like)', '🌤️', currentMetrics?.apparent_temperature, '°C', 'Calculated Thermal Comfort Index')"
/>
<!-- 4. Relative Humidity -->
<WeatherMetricCard
title="Rel. Humidity"
icon="💧"
:value="currentMetrics?.humidity"
unit="%"
:subText="currentMetrics?.humidity ? `${currentMetrics.humidity > 65 ? 'High Moisture' : 'Normal Range'}` : undefined"
@inspect="openModal('humidity', 'Relative Humidity', '💧', currentMetrics?.humidity, '%', 'Air Moisture Saturation Level')"
/>
</div>
</section>
<!-- ============================================== -->
<!-- GROUP 2: ATMOSPHERE & PRECIPITATION -->
<!-- ============================================== -->
<section class="group-section">
<div class="group-header-bar">
<div class="group-title">
<span>🌧️</span>
<span>ATMOSPHERE & PRECIPITATION</span>
</div>
<span class="group-hint">[CLICK FOR FULL-SCREEN GRAPH]</span>
</div>
<div class="metrics-list-stack">
<!-- Pressure -->
<WeatherMetricCard
title="Pressure"
icon="🧭"
:value="currentMetrics?.pressure"
unit="hPa"
:subText="currentMetrics?.pressure ? (currentMetrics.pressure >= 1013 ? 'High Pressure (Fair)' : 'Low Pressure System') : undefined"
@inspect="openModal('pressure', 'Atmospheric Pressure', '🧭', currentMetrics?.pressure, 'hPa', 'Barometric Surface Pressure')"
/>
<!-- Rain Status -->
<WeatherMetricCard
title="Rainfall Condition"
icon="🌧️"
:value="currentMetrics?.rain_status_label || (currentMetrics?.rain_status === 1 ? 'Wet (Rain)' : 'Dry')"
unit=""
:subBadge="currentMetrics?.rain_status === 1 ? 'PRECIPITATION' : 'NO RAIN'"
:subBadgeColor="currentMetrics?.rain_status === 1 ? '#38bdf8' : '#10b981'"
subText="Optical Rain Sensor State"
@inspect="openModal('pressure', 'Precipitation & Barometric Log', '🌧️', currentMetrics?.rain_status_label, '', 'Live Precipitation & Barometric Log')"
/>
</div>
</section>
<!-- ============================================== -->
<!-- GROUP 3: WIND & GUSTS -->
<!-- ============================================== -->
<section class="group-section">
<div class="group-header-bar">
<div class="group-title">
<span>💨</span>
<span>WIND VELOCITY & GUST DYNAMICS</span>
</div>
<span class="group-hint">[CLICK FOR FULL-SCREEN GRAPH]</span>
</div>
<div class="metrics-list-stack">
<!-- Wind Speed -->
<WeatherMetricCard
title="Wind Speed"
icon="💨"
:value="currentMetrics?.wind_speed"
unit="km/h"
:subText="`Direction: ${windCardinal}`"
@inspect="openModal('wind_speed', 'Wind Speed & Flow', '💨', currentMetrics?.wind_speed, 'km/h', `Sustained Velocity • Heading ${windCardinal}`)"
/>
<!-- Gust Speed -->
<WeatherMetricCard
title="Peak Gusts"
icon="🌪️"
:value="currentMetrics?.gust_speed"
unit="km/h"
:subText="currentMetrics?.gust_speed ? `Peak: ${currentMetrics.gust_speed.toFixed(1)} km/h` : undefined"
@inspect="openModal('gust_speed', 'Gust Velocity Dynamics', '🌪️', currentMetrics?.gust_speed, 'km/h', 'Maximum Gust Speed in Sample Window')"
/>
<!-- Wind Direction -->
<WeatherMetricCard
title="Wind Bearing"
icon="🧭"
:value="windCardinal"
unit=""
:subText="currentMetrics?.wind_direction !== undefined && currentMetrics?.wind_direction !== null ? `Angle: ${Math.round(currentMetrics.wind_direction)}°` : undefined"
:subBadge="`${Math.round(currentMetrics?.wind_direction ?? 0)}°`"
@inspect="openModal('wind_speed', 'Wind Direction & Dynamics', '🧭', `${windCardinal} (${Math.round(currentMetrics?.wind_direction ?? 0)}°)`, '', 'Compass Rose Bearing & Flow')"
/>
</div>
</section>
<!-- ============================================== -->
<!-- GROUP 4: SOLAR & RADIATION -->
<!-- ============================================== -->
<section class="group-section">
<div class="group-header-bar">
<div class="group-title">
<span>☀️</span>
<span>SOLAR ILLUMINANCE & UV RADIATION</span>
</div>
<span class="group-hint">[CLICK FOR FULL-SCREEN GRAPH]</span>
</div>
<div class="metrics-list-stack">
<!-- Solar Illuminance -->
<WeatherMetricCard
title="Solar Illuminance"
icon="☀️"
:value="currentMetrics?.illuminance_lux"
unit="LUX"
:subText="currentMetrics?.illuminance_lux && currentMetrics.illuminance_lux > 10000 ? 'Direct Sunlight' : 'Ambient Light'"
@inspect="openModal('illuminance_lux', 'Solar Illuminance', '☀️', currentMetrics?.illuminance_lux, 'LUX', 'Photometric Illuminance from Solar Sensor')"
/>
<!-- UV Index -->
<WeatherMetricCard
title="UV Index"
icon="🔆"
:value="currentMetrics?.uv_index ?? 0"
unit="UVI"
:subBadge="uvLevel.label"
:subText="`Exposure: ${uvLevel.label}`"
@inspect="openModal('uv_index', 'Solar UV Index', '🔆', currentMetrics?.uv_index ?? 0, 'UVI', `Ultraviolet Radiation Index (${uvLevel.label})`)"
/>
</div>
</section>
<!-- ============================================== -->
<!-- GROUP 5: STATION TELEMETRY & SYSTEM STATUS -->
<!-- ============================================== -->
<section class="group-section">
<div class="group-header-bar">
<div class="group-title">
<span>📡</span>
<span>STATION TELEMETRY & LINK QUALITY</span>
</div>
<span class="group-hint">[STATION STATUS]</span>
</div>
<div class="metrics-list-stack">
<!-- Battery -->
<WeatherMetricCard
title="Station Battery"
icon="🔋"
:value="currentMetrics?.battery_percent !== null && currentMetrics?.battery_percent !== undefined ? currentMetrics.battery_percent : '100'"
unit="%"
:subBadge="currentMetrics?.battery_percent && currentMetrics.battery_percent > 30 ? 'HEALTHY' : 'GOOD'"
subText="Solar Power Buffer"
@inspect="openModal('battery_percent', 'Station Battery Level', '🔋', currentMetrics?.battery_percent, '%', 'Onboard Power Cell Capacity')"
/>
<!-- Signal Strength / LQI -->
<WeatherMetricCard
title="Signal Quality (LQI)"
icon="📶"
:value="currentMetrics?.linkquality_lqi !== null && currentMetrics?.linkquality_lqi !== undefined ? currentMetrics.linkquality_lqi : '255'"
unit="LQI"
subText="Shelly / Zigbee Link"
@inspect="openModal('linkquality_lqi', 'Link Quality Indicator', '📶', currentMetrics?.linkquality_lqi, 'LQI', 'RF Signal Quality Indicator')"
/>
<!-- Last Updated -->
<WeatherMetricCard
title="Telemetry Status"
icon="⏱️"
value="ACTIVE"
unit=""
:subText="currentMetrics?.updated_at ? `Sync: ${formatTime(currentMetrics.updated_at)}` : 'Live Stream'"
@inspect="openModal('temperature', 'Live Telemetry Link', '⏱️', 'ONLINE', '', 'Prometheus Real-Time Metrics Pipeline')"
/>
</div>
</section>
</main>
<!-- 16-Bit No-Scroll Detail Screen Overlay -->
<MetricDetailModal
v-if="selectedModalMetric"
:metricKey="selectedModalMetric.key"
:title="selectedModalMetric.title"
:icon="selectedModalMetric.icon"
:value="selectedModalMetric.value"
:unit="selectedModalMetric.unit"
:subText="selectedModalMetric.subText"
:history="history"
:theme="currentTheme"
@close="closeModal"
@change-timeframe="fetchHistoryForHours"
/>
<!-- Retro Footer -->
<footer class="app-footer container">
<p>GRABENGASSE 7 • REGENSBURG // 16-BIT METEOROLOGICAL STATION</p>
<p class="footer-sub">SHELLY WS90 INTEGRATION • PROMETHEUS TIME-SERIES ENGINE • V1.0</p>
</footer>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import HouseHero from './components/HouseHero.vue'
import WeatherMetricCard from './components/WeatherMetricCard.vue'
import MetricDetailModal from './components/MetricDetailModal.vue'
import type { CurrentWeatherMetrics, WeatherHistoryResponse } from './types/weather'
import {
calculateDewPoint,
getDewPointComfort,
getWindDirectionCardinal,
getUVIndexLevel
} from './utils/weatherCalculations'
const currentMetrics = ref<CurrentWeatherMetrics | null>(null)
const history = ref<WeatherHistoryResponse | undefined>(undefined)
const error = ref<string | null>(null)
const currentTheme = ref<'dark' | 'light'>('dark')
const crtEnabled = ref(true)
interface ModalConfig {
key: string
title: string
icon: string
value?: number | string | null
unit?: string
subText?: string
}
const selectedModalMetric = ref<ModalConfig | null>(null)
let pollTimer: number | null = null
// Calculated Dew Point
const effectiveDewPoint = computed(() => {
if (currentMetrics.value?.dew_point !== undefined && currentMetrics.value?.dew_point !== null) {
return currentMetrics.value.dew_point
}
return calculateDewPoint(
currentMetrics.value?.temperature,
currentMetrics.value?.humidity
)
})
// Dew Point Comfort Description
const dewPointComfort = computed(() => {
return getDewPointComfort(effectiveDewPoint.value)
})
// Wind Direction Cardinal
const windCardinal = computed(() => {
return getWindDirectionCardinal(currentMetrics.value?.wind_direction)
})
// UV Level Info
const uvLevel = computed(() => {
return getUVIndexLevel(currentMetrics.value?.uv_index)
})
function formatTime(isoString?: string): string {
if (!isoString) return ''
try {
const date = new Date(isoString)
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
} catch {
return isoString
}
}
function openModal(
key: string,
title: string,
icon: string,
value?: number | string | null,
unit?: string,
subText?: string
) {
selectedModalMetric.value = { key, title, icon, value, unit, subText }
}
function closeModal() {
selectedModalMetric.value = null
}
function handleThemeChanged(theme: 'dark' | 'light') {
currentTheme.value = theme
}
function handleCrtChanged(enabled: boolean) {
crtEnabled.value = enabled
}
async function fetchCurrentMetrics() {
try {
const res = await fetch('/api/v1/weather/current')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
currentMetrics.value = data
error.value = null
} catch (err: any) {
console.error('Error fetching current metrics:', err)
error.value = 'Station Telemetry Offline. Retrying link to Prometheus...'
}
}
async function fetchHistoryForHours(hours: number = 24) {
try {
const res = await fetch(`/api/v1/weather/history?hours=${hours}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
history.value = data
} catch (err: any) {
console.error('Error fetching history:', err)
}
}
async function fetchData() {
await Promise.all([fetchCurrentMetrics(), fetchHistoryForHours(24)])
}
onMounted(() => {
const savedTheme = (localStorage.getItem('grabengasse_theme') as 'dark' | 'light') || 'dark'
currentTheme.value = savedTheme
const savedCrt = localStorage.getItem('grabengasse_crt')
if (savedCrt !== null) {
crtEnabled.value = savedCrt === 'true'
}
fetchData()
pollTimer = window.setInterval(fetchCurrentMetrics, 30000)
})
onUnmounted(() => {
if (pollTimer) clearInterval(pollTimer)
})
</script>
<style scoped>
.app-container {
min-height: 100vh;
display: flex;
flex-direction: column;
max-width: 100vw;
overflow-x: hidden;
box-sizing: border-box;
}
.error-banner-window {
margin-bottom: 1.25rem;
border-color: #881337 #fb7185 #fb7185 #881337;
}
.error-header {
background: linear-gradient(90deg, #991b1b 0%, #dc2626 50%, #7f1d1d 100%);
}
.error-content {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
font-family: var(--font-mono);
font-size: 0.8rem;
color: var(--retro-accent-red);
}
.error-msg {
font-weight: 700;
}
.footer-sub {
font-size: 0.65rem;
color: var(--retro-text-muted);
}
</style>
Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 930 KiB

@@ -0,0 +1,398 @@
<template>
<header class="retro-window hero-header">
<!-- Top System Window Titlebar -->
<div class="retro-window-header">
<div class="retro-window-title">
<span class="system-icon">🕹️</span>
<span>CITY TELEMETRY // GRABENGASSE 7</span>
</div>
<div class="retro-window-controls">
<span class="retro-btn-box">_</span>
<span class="retro-btn-box">□</span>
<span class="retro-btn-box">×</span>
</div>
</div>
<!-- Hero Content Board -->
<div class="retro-window-content hero-content">
<div class="hero-top-row">
<!-- Brand & Crest -->
<div class="hero-brand">
<div class="city-crest">
<span class="crest-icon">🏛️</span>
</div>
<div class="brand-text">
<h1 class="brand-title">GRABENGASSE 7</h1>
<div class="brand-meta">
<span class="meta-tag">REGENSBURG</span>
<span class="meta-separator">•</span>
<span class="meta-tag">ELEV 342M</span>
<span class="meta-separator">•</span>
<span class="meta-tag">WS-90</span>
</div>
</div>
</div>
<!-- Action / Controls Panel -->
<div class="hero-actions">
<!-- Live Station Badge -->
<div class="retro-badge badge-green action-badge">
<span class="retro-blinker"></span>
<span>WS-90 LIVE</span>
</div>
<!-- Theme Toggle (Dark / Light) -->
<button
class="retro-btn theme-toggle"
@click="toggleTheme"
:title="isDark ? 'Switch to 16-Bit Bright Theme (Amiga/SNES Day)' : 'Switch to 16-Bit Dark Theme (CRT Night)'"
>
<span>{{ isDark ? '☀️' : '🌙' }}</span>
<span>{{ isDark ? 'DAY' : 'NIGHT' }}</span>
</button>
<!-- CRT Scanline Toggle -->
<button
class="retro-btn crt-toggle"
:class="{ active: crtEnabled }"
@click="toggleCrt"
title="Toggle Retro CRT Scanline Filter"
>
<span>📺</span>
<span>{{ crtEnabled ? 'CRT ON' : 'CRT OFF' }}</span>
</button>
<!-- PWA Install App Button (When installable or on mobile) -->
<button
class="retro-btn install-btn"
:class="{ 'btn-accent': installable }"
@click="handleInstallClick"
title="Install Grabengasse 7 as a native Android/Apple Home Screen App"
>
<span>📲</span>
<span>INSTALL</span>
</button>
<!-- Manual Refresh -->
<button class="retro-btn sync-btn" @click="$emit('refresh')" title="Poll latest metrics">
<span>🔄</span>
<span>SYNC</span>
</button>
</div>
</div>
<!-- iOS / Safari Installation Guide Banner (if clicked on iOS) -->
<div v-if="showIosGuide" class="retro-well ios-install-well">
<div class="ios-guide-header">
<span>🍎 INSTALL ON APPLE iOS / SAFARI:</span>
<button class="retro-btn-box" @click="showIosGuide = false">×</button>
</div>
<div class="ios-guide-body">
<span>1. Tap the <strong>Share button (⎋)</strong> in Safari</span>
<span>2. Scroll and select <strong>'Add to Home Screen (⊞)'</strong></span>
<span>3. Tap <strong>'Add'</strong> to launch as a standalone app!</span>
</div>
</div>
<!-- Animated SimCity Weather Advisory Ticker -->
<div class="retro-well ticker-well">
<div class="ticker-label">NEWS:</div>
<div class="ticker-container">
<div class="ticker-text">
<span>*** GRABENGASSE 7 WEATHER REPORT: METEOROLOGICAL TELEMETRY ACTIVE ***</span>
<span class="ticker-item">STATION: SHELLY WS90 (PROMETHEUS)</span>
<span class="ticker-item">LOCATION: REGENSBURG HISTORIC DISTRICT</span>
<span class="ticker-item">PWA READY: INSTALLABLE AS STANDALONE ANDROID & APPLE APP</span>
<span class="ticker-item">LAST SYNC: {{ lastUpdated ? formatTime(lastUpdated) : 'CONNECTING...' }}</span>
<span class="ticker-item">ADVISORY: CLICK ANY METRIC FOR 16-BIT HISTORICAL GRAPH INSPECTOR</span>
</div>
</div>
</div>
</div>
</header>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
defineProps<{
lastUpdated?: string
}>()
const emit = defineEmits<{
(e: 'refresh'): void
(e: 'theme-changed', theme: 'dark' | 'light'): void
(e: 'crt-changed', enabled: boolean): void
}>()
const isDark = ref(true)
const crtEnabled = ref(true)
const installable = ref(false)
const showIosGuide = ref(false)
let deferredPrompt: any = null
function formatTime(isoString?: string): string {
if (!isoString) return ''
try {
const date = new Date(isoString)
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
} catch {
return isoString
}
}
function toggleTheme() {
isDark.value = !isDark.value
const theme = isDark.value ? 'dark' : 'light'
document.documentElement.setAttribute('data-theme', theme)
localStorage.setItem('grabengasse_theme', theme)
emit('theme-changed', theme)
}
function toggleCrt() {
crtEnabled.value = !crtEnabled.value
localStorage.setItem('grabengasse_crt', crtEnabled.value ? 'true' : 'false')
emit('crt-changed', crtEnabled.value)
}
async function handleInstallClick() {
if (deferredPrompt) {
deferredPrompt.prompt()
const { outcome } = await deferredPrompt.userChoice
if (outcome === 'accepted') {
installable.value = false
deferredPrompt = null
}
} else {
// Check if on iOS or desktop where native prompt isn't supported directly
const isIos = /iPad|iPhone|iPod/.test(navigator.userAgent) && !(window as any).MSStream
if (isIos) {
showIosGuide.value = !showIosGuide.value
} else {
alert("To install this app on your device:\n\n• Android: Tap the browser menu (⋮) -> 'Install App' or 'Add to Home screen'\n• iPhone/iPad: Tap Share (⎋) -> 'Add to Home Screen (⊞)'\n• Chrome/Edge: Click the install icon in the URL bar (⊕)")
}
}
}
onMounted(() => {
const savedTheme = localStorage.getItem('grabengasse_theme') || 'dark'
isDark.value = savedTheme === 'dark'
document.documentElement.setAttribute('data-theme', savedTheme)
const savedCrt = localStorage.getItem('grabengasse_crt')
if (savedCrt !== null) {
crtEnabled.value = savedCrt === 'true'
}
// Listen for PWA beforeinstallprompt on Android / Chrome
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault()
deferredPrompt = e
installable.value = true
})
window.addEventListener('appinstalled', () => {
installable.value = false
deferredPrompt = null
})
})
</script>
<style scoped>
.hero-header {
margin-top: 0.25rem;
}
.hero-content {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.hero-top-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
}
.hero-brand {
display: flex;
align-items: center;
gap: 0.75rem;
}
.city-crest {
width: 42px;
height: 42px;
min-width: 42px;
background: var(--retro-window-inner);
border-top: 2px solid var(--retro-bevel-dark);
border-left: 2px solid var(--retro-bevel-dark);
border-right: 2px solid var(--retro-bevel-light);
border-bottom: 2px solid var(--retro-bevel-light);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.4rem;
flex-shrink: 0;
}
.brand-text {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.brand-title {
font-family: var(--font-pixel);
font-size: clamp(0.95rem, 3.8vw, 1.35rem);
color: var(--retro-accent-amber);
letter-spacing: 0.03em;
text-shadow: 2px 2px 0px rgba(0, 0, 0, 0.5);
line-height: 1.2;
}
.brand-meta {
display: flex;
align-items: center;
gap: 0.35rem;
font-family: var(--font-ui);
font-size: clamp(0.55rem, 1.8vw, 0.65rem);
color: var(--retro-text-dim);
text-transform: uppercase;
flex-wrap: wrap;
}
.meta-tag {
color: var(--retro-accent-cyan);
}
.meta-separator {
color: var(--retro-text-muted);
}
.hero-actions {
display: flex;
align-items: center;
gap: 0.45rem;
flex-wrap: wrap;
}
.action-badge {
padding: 0.35rem 0.55rem;
}
/* iOS Guide */
.ios-install-well {
display: flex;
flex-direction: column;
gap: 0.4rem;
padding: 0.55rem 0.75rem;
border-color: #0c4a6e #38bdf8 #38bdf8 #0c4a6e;
background: var(--retro-window-inner);
}
.ios-guide-header {
display: flex;
align-items: center;
justify-content: space-between;
font-family: var(--font-pixel);
font-size: 0.65rem;
color: var(--retro-accent-cyan);
}
.ios-guide-body {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-family: var(--font-mono);
font-size: 0.7rem;
color: var(--retro-text-main);
}
/* Ticker */
.ticker-well {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.35rem 0.55rem;
overflow: hidden;
}
.ticker-label {
font-family: var(--font-pixel);
font-size: clamp(0.55rem, 2vw, 0.65rem);
color: var(--retro-accent-red);
background: var(--retro-window-bg);
padding: 0.15rem 0.35rem;
border-top: 1px solid var(--retro-bevel-light);
border-left: 1px solid var(--retro-bevel-light);
border-right: 1px solid var(--retro-bevel-dark);
border-bottom: 1px solid var(--retro-bevel-dark);
white-space: nowrap;
flex-shrink: 0;
}
.ticker-container {
overflow: hidden;
white-space: nowrap;
flex-grow: 1;
}
.ticker-text {
display: inline-block;
white-space: nowrap;
font-family: var(--font-mono);
font-size: clamp(0.68rem, 2.2vw, 0.75rem);
color: var(--retro-text-main);
animation: tickerSlide 25s linear infinite;
}
.ticker-item {
margin-left: 1.5rem;
color: var(--retro-accent-amber);
}
@keyframes tickerSlide {
0% { transform: translateX(100%); }
100% { transform: translateX(-100%); }
}
@media (max-width: 640px) {
.hero-top-row {
flex-direction: column;
align-items: stretch;
gap: 0.65rem;
}
.city-crest {
width: 36px;
height: 36px;
min-width: 36px;
font-size: 1.2rem;
}
/* Clean 2x2 Grid of Actions on Mobile for balanced layout */
.hero-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.35rem;
width: 100%;
}
.hero-actions .action-badge {
grid-column: span 2;
justify-content: center;
padding: 0.35rem;
}
.hero-actions .retro-btn {
width: 100%;
justify-content: center;
padding: 0.4rem 0.3rem;
font-size: 0.65rem;
}
}
</style>
@@ -0,0 +1,773 @@
<template>
<div class="retro-screen-backdrop" @click.self="closeModal">
<div class="retro-screen-container" role="dialog" aria-modal="true">
<!-- Screen Titlebar / Navigation Header -->
<div class="retro-window-header screen-header">
<button class="retro-btn back-btn" @click="closeModal" title="Return to metrics list (Esc)">
<span class="back-arrow">◀</span>
<span>BACK</span>
</button>
<div class="screen-title">
<span class="screen-icon">{{ icon }}</span>
<span>{{ title }}</span>
</div>
<div class="screen-controls">
<button class="retro-btn-box close-box" @click="closeModal" title="Close (Esc)">×</button>
</div>
</div>
<!-- Main Screen Content (Strictly fits inside 100dvh, zero scrolling required) -->
<div class="screen-body">
<!-- Top Stats Ledger Strip -->
<div class="stats-ledger-strip">
<div class="ledger-item primary-ledger">
<span class="ledger-label">CURRENT</span>
<div class="ledger-val-row">
<span class="ledger-val highlight">{{ formattedValue }}</span>
<span class="ledger-unit" v-if="unit">{{ unit }}</span>
</div>
</div>
<div class="ledger-item">
<span class="ledger-label">24H MIN</span>
<div class="ledger-val-row">
<span class="ledger-arrow arrow-down">▼</span>
<span class="ledger-val">{{ stats.min !== null ? stats.min : '--' }}</span>
<span class="ledger-unit-sm" v-if="unit">{{ unit }}</span>
</div>
</div>
<div class="ledger-item">
<span class="ledger-label">24H MAX</span>
<div class="ledger-val-row">
<span class="ledger-arrow arrow-up">▲</span>
<span class="ledger-val">{{ stats.max !== null ? stats.max : '--' }}</span>
<span class="ledger-unit-sm" v-if="unit">{{ unit }}</span>
</div>
</div>
<div class="ledger-item">
<span class="ledger-label">24H AVG</span>
<div class="ledger-val-row">
<span class="ledger-arrow arrow-avg">~</span>
<span class="ledger-val">{{ stats.avg !== null ? stats.avg : '--' }}</span>
<span class="ledger-unit-sm" v-if="unit">{{ unit }}</span>
</div>
</div>
</div>
<!-- Time Range Selector Toolbar -->
<div class="timeframe-toolbar">
<div class="timeframe-group">
<button
v-for="tf in timeframes"
:key="tf.hours"
class="retro-btn tf-btn"
:class="{ active: currentHours === tf.hours }"
@click="selectTimeframe(tf.hours)"
>
{{ tf.label }}
</button>
</div>
<div class="telemetry-badge">
<span class="retro-blinker"></span>
<span>PROMETHEUS ENGINE</span>
</div>
</div>
<!-- Flexible Interactive Chart Canvas (Fills available screen height) -->
<div class="retro-well chart-canvas-well">
<div class="chart-canvas-wrapper" v-if="chartData.labels.length > 0">
<Line :data="chartData" :options="computedChartOptions" />
</div>
<div class="loading-state" v-else>
<span class="retro-blinker"></span>
<span>QUERYING PROMETHEUS GRAPH SENSORS...</span>
</div>
</div>
<!-- 1-Line Compact Advisory Banner -->
<div class="advisory-strip" v-if="advisoryNote">
<span class="advisory-icon">💡</span>
<span class="advisory-text">{{ advisoryNote }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, onMounted, onUnmounted } from 'vue'
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
TimeScale,
Filler
} from 'chart.js'
import { Line } from 'vue-chartjs'
import type { WeatherHistoryResponse, TimeSeriesPoint } from '../types/weather'
import { calculateHistoryStats } from '../utils/weatherCalculations'
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
TimeScale,
Filler
)
const props = defineProps<{
metricKey: string
title: string
icon: string
value?: number | string | null
unit?: string
subText?: string
history?: WeatherHistoryResponse
theme?: 'dark' | 'light'
}>()
const emit = defineEmits<{
(e: 'close'): void
(e: 'change-timeframe', hours: number): void
}>()
const currentHours = ref(24)
const timeframes = [
{ label: '24H', hours: 24 },
{ label: '7D', hours: 168 },
{ label: '30D', hours: 720 },
{ label: '1Y', hours: 8760 }
]
function selectTimeframe(hours: number) {
currentHours.value = hours
emit('change-timeframe', hours)
}
function closeModal() {
emit('close')
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
closeModal()
}
}
onMounted(() => {
window.addEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
window.removeEventListener('keydown', handleKeydown)
})
const formattedValue = computed(() => {
if (props.value === null || props.value === undefined) return '--'
if (typeof props.value === 'number') {
return Number.isInteger(props.value) ? props.value.toString() : props.value.toFixed(1)
}
return props.value
})
function formatTimestamp(ts: number): string {
const date = new Date(ts * 1000)
if (currentHours.value <= 24) {
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
} else if (currentHours.value <= 168) {
return `${date.toLocaleDateString([], { weekday: 'short' })} ${date.getHours()}h`
} else {
return date.toLocaleDateString([], { month: 'numeric', day: 'numeric' })
}
}
// Get the main time series for this metric
const primarySeries = computed<TimeSeriesPoint[]>(() => {
if (!props.history || !props.history.metrics) return []
const metric = props.history.metrics[props.metricKey]
return metric?.data || []
})
// Calculate Min, Max, Avg, Current stats
const stats = computed(() => {
return calculateHistoryStats(primarySeries.value)
})
// Build Chart Data depending on the metric group
const chartData = computed(() => {
if (!props.history || !props.history.metrics) {
return { labels: [], datasets: [] }
}
const metrics = props.history.metrics
// TEMPERATURE & DEW POINT GROUP
if (
props.metricKey === 'temperature' ||
props.metricKey === 'apparent_temperature' ||
props.metricKey === 'dew_point'
) {
const tempPoints = metrics.temperature?.data || []
const apparentPoints = metrics.apparent_temperature?.data || []
const dewPoints = metrics.dew_point?.data || []
const labels = (tempPoints.length > 0 ? tempPoints : apparentPoints).map(p =>
formatTimestamp(p.timestamp)
)
return {
labels,
datasets: [
{
label: 'Temp (°C)',
data: tempPoints.map(p => p.value),
borderColor: '#fbbf24',
backgroundColor: 'rgba(251, 191, 36, 0.12)',
fill: true,
tension: 0.25,
borderWidth: 2,
pointRadius: currentHours.value > 168 ? 0 : 2
},
{
label: 'Feels Like',
data: apparentPoints.map(p => p.value),
borderColor: '#f43f5e',
backgroundColor: 'transparent',
borderDash: [4, 4],
tension: 0.25,
borderWidth: 2,
pointRadius: 0
},
{
label: 'Dew Point',
data: dewPoints.map(p => p.value),
borderColor: '#00f0ff',
backgroundColor: 'transparent',
tension: 0.25,
borderWidth: 2,
pointRadius: 0
}
]
}
}
// HUMIDITY
if (props.metricKey === 'humidity') {
const humPoints = metrics.humidity?.data || []
return {
labels: humPoints.map(p => formatTimestamp(p.timestamp)),
datasets: [
{
label: 'Humidity (%)',
data: humPoints.map(p => p.value),
borderColor: '#00f0ff',
backgroundColor: 'rgba(0, 240, 255, 0.15)',
fill: true,
tension: 0.25,
borderWidth: 2,
pointRadius: currentHours.value > 168 ? 0 : 2
}
]
}
}
// WIND & GUSTS
if (props.metricKey === 'wind_speed' || props.metricKey === 'gust_speed') {
const windPoints = metrics.wind_speed?.data || []
const gustPoints = metrics.gust_speed?.data || []
const labels = (windPoints.length > 0 ? windPoints : gustPoints).map(p =>
formatTimestamp(p.timestamp)
)
return {
labels,
datasets: [
{
label: 'Wind (km/h)',
data: windPoints.map(p => p.value),
borderColor: '#10b981',
backgroundColor: 'rgba(16, 185, 129, 0.15)',
fill: true,
tension: 0.2,
borderWidth: 2,
pointRadius: 0
},
{
label: 'Gust (km/h)',
data: gustPoints.map(p => p.value),
borderColor: '#fbbf24',
backgroundColor: 'transparent',
borderDash: [3, 3],
tension: 0.2,
borderWidth: 2,
pointRadius: 0
}
]
}
}
// SOLAR & UV
if (props.metricKey === 'illuminance_lux' || props.metricKey === 'uv_index') {
const luxPoints = metrics.illuminance_lux?.data || []
const uvPoints = metrics.uv_index?.data || []
const labels = (luxPoints.length > 0 ? luxPoints : uvPoints).map(p =>
formatTimestamp(p.timestamp)
)
return {
labels,
datasets: [
{
label: 'Solar (LUX)',
data: luxPoints.map(p => p.value),
borderColor: '#fbbf24',
backgroundColor: 'rgba(251, 191, 36, 0.15)',
fill: true,
tension: 0.2,
borderWidth: 2,
yAxisID: 'y',
pointRadius: 0
},
{
label: 'UV Index',
data: uvPoints.map(p => p.value),
borderColor: '#c084fc',
backgroundColor: 'transparent',
borderWidth: 2,
tension: 0.2,
yAxisID: 'y1',
pointRadius: 0
}
]
}
}
// PRESSURE
if (props.metricKey === 'pressure') {
const pressPoints = metrics.pressure?.data || []
return {
labels: pressPoints.map(p => formatTimestamp(p.timestamp)),
datasets: [
{
label: 'Pressure (hPa)',
data: pressPoints.map(p => p.value),
borderColor: '#38bdf8',
backgroundColor: 'rgba(56, 189, 248, 0.12)',
fill: true,
tension: 0.25,
borderWidth: 2,
pointRadius: currentHours.value > 168 ? 0 : 2
}
]
}
}
// Fallback single series
const fallbackPoints = primarySeries.value
return {
labels: fallbackPoints.map(p => formatTimestamp(p.timestamp)),
datasets: [
{
label: `${props.title} (${props.unit || ''})`,
data: fallbackPoints.map(p => p.value),
borderColor: '#fbbf24',
backgroundColor: 'rgba(251, 191, 36, 0.15)',
fill: true,
tension: 0.25,
borderWidth: 2,
pointRadius: 0
}
]
}
})
const computedChartOptions = computed(() => {
const isLight = props.theme === 'light'
const textColor = isLight ? '#1e293b' : '#f1f5f9'
const gridColor = isLight ? 'rgba(0, 0, 0, 0.1)' : 'rgba(255, 255, 255, 0.08)'
const isDualAxis =
props.metricKey === 'illuminance_lux' || props.metricKey === 'uv_index'
return {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'top' as const,
labels: {
color: textColor,
font: { family: "'Press Start 2P', monospace", size: 8 },
boxWidth: 10,
padding: 6
}
},
tooltip: {
mode: 'index' as const,
intersect: false,
backgroundColor: isLight ? '#e2e8f0' : '#111827',
titleColor: textColor,
bodyColor: textColor,
borderColor: isLight ? '#94a3b8' : '#374151',
borderWidth: 2,
titleFont: { family: "'Press Start 2P', monospace", size: 8 },
bodyFont: { family: "'Space Mono', monospace", size: 10 }
}
},
scales: {
x: {
grid: { color: gridColor },
ticks: {
color: textColor,
font: { family: "'Space Mono', monospace", size: 9 },
maxTicksLimit: 6
}
},
y: {
type: 'linear' as const,
display: true,
position: 'left' as const,
grid: { color: gridColor },
ticks: {
color: textColor,
font: { family: "'Space Mono', monospace", size: 9 }
}
},
...(isDualAxis
? {
y1: {
type: 'linear' as const,
display: true,
position: 'right' as const,
min: 0,
max: 12,
grid: { drawOnChartArea: false },
ticks: {
color: '#c084fc',
font: { family: "'Space Mono', monospace", size: 9 },
stepSize: 3
}
}
}
: {})
}
}
})
// Advisory Note (SimCity style commentary)
const advisoryNote = computed(() => {
if (
props.metricKey === 'temperature' ||
props.metricKey === 'apparent_temperature' ||
props.metricKey === 'dew_point'
) {
return 'Dew point represents saturation temperature. Condensation forms when temp reaches dew point.'
}
if (props.metricKey === 'humidity') {
return 'Ideal relative humidity is 40%–60%. Above 70% promotes condensation and muggy conditions.'
}
if (props.metricKey === 'wind_speed' || props.metricKey === 'gust_speed') {
return 'Wind velocity measured by Shelly WS90 ultrasonic anemometer at 10m elevation.'
}
if (props.metricKey === 'illuminance_lux' || props.metricKey === 'uv_index') {
return 'Solar illuminance measures daylight LUX. UV Index above 6 requires protection.'
}
if (props.metricKey === 'pressure') {
return 'Standard sea level pressure is 1013.25 hPa. Rising pressure indicates fair weather.'
}
return null
})
</script>
<style scoped>
.retro-screen-backdrop {
position: fixed;
inset: 0;
background: var(--retro-bg);
z-index: 1000;
display: flex;
flex-direction: column;
width: 100vw;
height: 100vh;
height: 100dvh;
max-width: 100vw;
max-height: 100dvh;
overflow: hidden;
box-sizing: border-box;
}
.retro-screen-container {
width: 100%;
max-width: 1200px;
height: 100%;
max-height: 100dvh;
margin: 0 auto;
display: flex;
flex-direction: column;
background-color: var(--retro-window-bg);
border-top: 3px solid var(--retro-bevel-light);
border-left: 3px solid var(--retro-bevel-light);
border-right: 3px solid var(--retro-bevel-dark);
border-bottom: 3px solid var(--retro-bevel-dark);
box-sizing: border-box;
overflow: hidden;
}
.screen-header {
padding: 0.35rem 0.5rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
flex-shrink: 0;
}
.back-btn {
padding: 0.25rem 0.5rem;
font-size: 0.65rem;
gap: 0.25rem;
background: var(--retro-window-inner);
}
.back-arrow {
color: var(--retro-accent-amber);
}
.screen-title {
font-family: var(--font-pixel);
font-size: clamp(0.58rem, 2.2vw, 0.72rem);
font-weight: 700;
color: var(--retro-titlebar-text);
display: flex;
align-items: center;
gap: 0.35rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.screen-icon {
font-size: 1rem;
flex-shrink: 0;
}
.screen-controls {
display: flex;
align-items: center;
flex-shrink: 0;
}
.close-box {
width: 22px;
height: 22px;
}
/* Screen Body: strictly fills available viewport space */
.screen-body {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.45rem;
padding: 0.55rem;
min-height: 0;
overflow: hidden;
box-sizing: border-box;
}
/* Stats Ledger Strip */
.stats-ledger-strip {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.35rem;
flex-shrink: 0;
}
.ledger-item {
background: var(--retro-window-inner);
border-top: 2px solid var(--retro-bevel-dark);
border-left: 2px solid var(--retro-bevel-dark);
border-right: 2px solid var(--retro-bevel-light);
border-bottom: 2px solid var(--retro-bevel-light);
padding: 0.25rem 0.4rem;
display: flex;
flex-direction: column;
gap: 0.15rem;
overflow: hidden;
}
.primary-ledger {
border-color: var(--retro-accent-amber);
}
.ledger-label {
font-family: var(--font-pixel);
font-size: clamp(0.48rem, 1.6vw, 0.58rem);
color: var(--retro-text-dim);
text-transform: uppercase;
}
.ledger-val-row {
display: flex;
align-items: baseline;
gap: 0.2rem;
overflow: hidden;
}
.ledger-val {
font-family: var(--font-digital);
font-size: clamp(1.3rem, 4.2vw, 1.7rem);
font-weight: 700;
color: var(--retro-text-main);
line-height: 1;
}
.ledger-val.highlight {
color: var(--retro-accent-amber);
}
.ledger-unit,
.ledger-unit-sm {
font-family: var(--font-ui);
font-size: 0.65rem;
color: var(--retro-accent-cyan);
}
.ledger-arrow {
font-family: var(--font-pixel);
font-size: 0.65rem;
}
.arrow-down { color: var(--retro-accent-cyan); }
.arrow-up { color: var(--retro-accent-red); }
.arrow-avg { color: var(--retro-accent-amber); }
/* Timeframe Toolbar */
.timeframe-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.4rem;
flex-shrink: 0;
}
.timeframe-group {
display: flex;
gap: 0.25rem;
}
.tf-btn {
padding: 0.25rem 0.45rem;
font-size: clamp(0.58rem, 1.8vw, 0.68rem);
}
.telemetry-badge {
display: flex;
align-items: center;
gap: 0.3rem;
font-family: var(--font-ui);
font-size: clamp(0.52rem, 1.6vw, 0.62rem);
color: var(--retro-accent-green);
white-space: nowrap;
}
/* Chart Canvas Well: Fills all remaining space */
.chart-canvas-well {
flex: 1;
min-height: 0;
padding: 0.4rem;
display: flex;
flex-direction: column;
position: relative;
overflow: hidden;
box-sizing: border-box;
}
.chart-canvas-wrapper {
position: relative;
width: 100%;
height: 100%;
flex: 1;
min-height: 0;
}
.loading-state {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
font-family: var(--font-pixel);
font-size: 0.65rem;
color: var(--retro-text-dim);
}
/* Advisory Strip */
.advisory-strip {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.3rem 0.5rem;
background: var(--retro-window-inner);
border-top: 1px solid var(--retro-bevel-dark);
border-left: 1px solid var(--retro-bevel-dark);
border-right: 1px solid var(--retro-bevel-light);
border-bottom: 1px solid var(--retro-bevel-light);
flex-shrink: 0;
}
.advisory-icon {
font-size: 0.85rem;
flex-shrink: 0;
}
.advisory-text {
font-family: var(--font-mono);
font-size: clamp(0.58rem, 1.8vw, 0.68rem);
color: var(--retro-text-dim);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 640px) {
.retro-screen-container {
border-left: none;
border-right: none;
}
.screen-body {
padding: 0.4rem;
gap: 0.35rem;
}
.timeframe-group {
display: grid;
grid-template-columns: repeat(4, 1fr);
width: 100%;
}
.tf-btn {
width: 100%;
justify-content: center;
padding: 0.3rem 0.2rem;
}
.telemetry-badge {
display: none; /* Hide on tight phones to maximize chart width */
}
}
</style>
@@ -0,0 +1,280 @@
<template>
<div
class="retro-metric-row"
:class="{ 'row-highlight': highlight }"
@click="$emit('inspect')"
role="button"
tabindex="0"
@keydown.enter="$emit('inspect')"
@keydown.space.prevent="$emit('inspect')"
title="Click or tap to view 16-bit history graph & telemetry"
>
<!-- Left: Icon & Metric Info -->
<div class="row-left">
<div class="row-icon-box">
<span class="row-icon">{{ icon }}</span>
</div>
<div class="row-text-group">
<div class="row-title">{{ title }}</div>
<div class="row-sub-group">
<span class="retro-badge sub-badge" v-if="subBadge" :style="badgeCustomStyle">
{{ subBadge }}
</span>
<span class="row-subtext" v-if="subText">{{ subText }}</span>
</div>
</div>
</div>
<!-- Right: Digital Value & Inspect Button -->
<div class="row-right">
<div class="row-value-well">
<span class="row-primary-val">{{ formattedValue }}</span>
<span class="row-primary-unit" v-if="unit">{{ unit }}</span>
</div>
<div class="row-inspect-btn">
<span class="inspect-arrow">▶</span>
<span class="inspect-text">GRAPH</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
title: string
icon: string
value?: number | string | null
unit?: string
subText?: string
subBadge?: string
subBadgeColor?: string
highlight?: boolean
}>()
defineEmits<{
(e: 'inspect'): void
}>()
const formattedValue = computed(() => {
if (props.value === null || props.value === undefined) return '--'
if (typeof props.value === 'number') {
return Number.isInteger(props.value) ? props.value.toString() : props.value.toFixed(1)
}
return props.value
})
const badgeCustomStyle = computed(() => {
if (!props.subBadgeColor) return {}
return {
borderColor: props.subBadgeColor,
color: props.subBadgeColor
}
})
</script>
<style scoped>
.retro-metric-row {
background-color: var(--retro-card-bg);
border-top: 2px solid var(--retro-bevel-light);
border-left: 2px solid var(--retro-bevel-light);
border-right: 2px solid var(--retro-bevel-dark);
border-bottom: 2px solid var(--retro-bevel-dark);
box-shadow: var(--retro-shadow);
padding: 0.55rem 0.75rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
cursor: pointer;
transition: background-color 0.1s ease, transform 0.05s ease;
user-select: none;
touch-action: manipulation;
width: 100%;
max-width: 100%;
box-sizing: border-box;
}
.retro-metric-row:hover {
background-color: var(--retro-card-hover);
}
.retro-metric-row:active {
border-top: 2px solid var(--retro-bevel-dark);
border-left: 2px solid var(--retro-bevel-dark);
border-right: 2px solid var(--retro-bevel-light);
border-bottom: 2px solid var(--retro-bevel-light);
box-shadow: inset 1px 1px 0px rgba(0, 0, 0, 0.6);
transform: translate(1px, 1px);
}
.row-highlight {
border-color: var(--retro-accent-amber);
}
/* Left Group */
.row-left {
display: flex;
align-items: center;
gap: 0.6rem;
min-width: 0;
flex: 1;
}
.row-icon-box {
width: 34px;
height: 34px;
min-width: 34px;
background: var(--retro-window-inner);
border-top: 2px solid var(--retro-bevel-dark);
border-left: 2px solid var(--retro-bevel-dark);
border-right: 2px solid var(--retro-bevel-light);
border-bottom: 2px solid var(--retro-bevel-light);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.15rem;
flex-shrink: 0;
}
.row-text-group {
display: flex;
flex-direction: column;
gap: 0.2rem;
min-width: 0;
overflow: hidden;
}
.row-title {
font-family: var(--font-pixel);
font-size: clamp(0.58rem, 2.2vw, 0.72rem);
font-weight: 700;
color: var(--retro-text-main);
letter-spacing: 0.03em;
text-transform: uppercase;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.row-sub-group {
display: flex;
align-items: center;
gap: 0.35rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.row-subtext {
font-family: var(--font-mono);
font-size: clamp(0.58rem, 2vw, 0.68rem);
color: var(--retro-text-dim);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sub-badge {
font-size: 0.55rem;
padding: 0.1rem 0.3rem;
flex-shrink: 0;
}
/* Right Group */
.row-right {
display: flex;
align-items: center;
gap: 0.6rem;
flex-shrink: 0;
}
.row-value-well {
background: var(--retro-window-inner);
border-top: 2px solid var(--retro-bevel-dark);
border-left: 2px solid var(--retro-bevel-dark);
border-right: 2px solid var(--retro-bevel-light);
border-bottom: 2px solid var(--retro-bevel-light);
padding: 0.25rem 0.55rem;
display: flex;
align-items: baseline;
gap: 0.25rem;
min-width: 85px;
justify-content: flex-end;
}
.row-primary-val {
font-family: var(--font-digital);
font-size: clamp(1.6rem, 5.5vw, 2.1rem);
font-weight: 700;
color: var(--retro-accent-amber);
line-height: 1;
letter-spacing: 0.02em;
}
.row-primary-unit {
font-family: var(--font-ui);
font-size: clamp(0.7rem, 2.2vw, 0.85rem);
font-weight: 700;
color: var(--retro-accent-cyan);
}
.row-inspect-btn {
background: var(--retro-window-bg);
border-top: 2px solid var(--retro-bevel-light);
border-left: 2px solid var(--retro-bevel-light);
border-right: 2px solid var(--retro-bevel-dark);
border-bottom: 2px solid var(--retro-bevel-dark);
padding: 0.35rem 0.5rem;
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-family: var(--font-ui);
font-size: 0.6rem;
color: var(--retro-accent-cyan);
white-space: nowrap;
}
.inspect-arrow {
color: var(--retro-accent-amber);
font-size: 0.6rem;
}
.retro-metric-row:hover .row-inspect-btn {
background-color: var(--retro-accent-amber);
color: #000000;
border-color: #fde68a #92400e #92400e #fde68a;
}
.retro-metric-row:hover .inspect-arrow {
color: #000000;
}
@media (max-width: 480px) {
.retro-metric-row {
padding: 0.45rem 0.5rem;
gap: 0.35rem;
}
.row-icon-box {
width: 28px;
height: 28px;
min-width: 28px;
font-size: 1rem;
}
.row-value-well {
padding: 0.2rem 0.4rem;
min-width: 70px;
}
.inspect-text {
display: none; /* Icon arrow only on very narrow screens */
}
.row-inspect-btn {
padding: 0.35rem 0.4rem;
}
}
</style>
+22
View File
@@ -0,0 +1,22 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
declare module '*.png' {
const value: string
export default value
}
declare module '*.jpg' {
const value: string
export default value
}
declare module '*.svg' {
const value: string
export default value
}
+18
View File
@@ -0,0 +1,18 @@
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
createApp(App).mount('#app')
// Register Service Worker for PWA (Progressive Web App)
if ('serviceWorker' in navigator && import.meta.env.PROD) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js')
.then((reg) => {
console.log('[PWA] ServiceWorker registered with scope:', reg.scope)
})
.catch((err) => {
console.warn('[PWA] ServiceWorker registration failed:', err)
})
})
}
@@ -0,0 +1,128 @@
import zlib
import struct
def make_png(width, height, get_pixel_func):
# PNG signature
png = b'\x89PNG\r\n\x1a\n'
# IHDR
ihdr_data = struct.pack('>IIBBBBB', width, height, 8, 6, 0, 0, 0)
ihdr_crc = zlib.crc32(b'IHDR' + ihdr_data)
png += struct.pack('>I', len(ihdr_data)) + b'IHDR' + ihdr_data + struct.pack('>I', ihdr_crc)
# Raw image data: for each row, 0 (filter type none) followed by RGBA bytes
raw_data = bytearray()
for y in range(height):
raw_data.append(0) # Filter type 0
for x in range(width):
r, g, b, a = get_pixel_func(x, y, width, height)
raw_data.extend([r, g, b, a])
# IDAT
compressed = zlib.compress(raw_data, 9)
idat_crc = zlib.crc32(b'IDAT' + compressed)
png += struct.pack('>I', len(compressed)) + b'IDAT' + compressed + struct.pack('>I', idat_crc)
# IEND
iend_crc = zlib.crc32(b'IEND')
png += struct.pack('>I', 0) + b'IEND' + struct.pack('>I', iend_crc)
return png
def retro_icon_pixel(x, y, w, h):
# Normalize coordinates to 32x32 grid
gx = int(x * 32 / w)
gy = int(y * 32 / h)
# Colors
bg_dark = (12, 16, 26, 255)
bevel_light = (68, 86, 126, 255)
bevel_dark = (7, 9, 15, 255)
amber_gold = (251, 191, 36, 255)
amber_bright = (254, 240, 138, 255)
cyan_bright = (0, 240, 255, 255)
cyan_dark = (12, 74, 110, 255)
emerald = (16, 185, 129, 255)
white = (255, 255, 255, 255)
roof_red = (244, 63, 94, 255)
roof_dark = (159, 18, 57, 255)
# Outer border
if gx == 0 or gy == 0:
return bevel_light
if gx == 31 or gy == 31:
return bevel_dark
if gx == 1 or gy == 1:
return bevel_light
if gx == 30 or gy == 30:
return bevel_dark
# Inner background with subtle checker pattern
is_pattern = ((gx // 2) + (gy // 2)) % 2 == 0
bg = (18, 24, 38, 255) if is_pattern else (14, 19, 31, 255)
# Weather Antenna / Satellite mast (top center)
if gx == 16 and 4 <= gy <= 10:
return cyan_bright
if (gx in (15, 17) and gy == 5) or (gx in (14, 18) and gy == 4):
return cyan_bright
if gx == 16 and gy == 3:
return (255, 59, 48, 255) # Red blinking LED
# Sun / Cloud in top left
if 4 <= gx <= 9 and 4 <= gy <= 9:
if (gx - 6)**2 + (gy - 6)**2 <= 6:
return amber_gold
# House Roof (gy between 11 and 17)
# Peak at gx=16, gy=11. Slopes down to gx=7, gx=25 at gy=17
roof_width = (gy - 11) * 3
if 11 <= gy <= 17 and (16 - roof_width // 2) <= gx <= (16 + roof_width // 2):
if gx == (16 - roof_width // 2) or gy == 11:
return amber_bright
if gx == (16 + roof_width // 2):
return roof_dark
return roof_red
# House Body (gy between 18 and 27, gx between 9 and 23)
if 18 <= gy <= 27 and 9 <= gx <= 23:
# House borders
if gx == 9 or gy == 18:
return amber_bright
if gx == 23 or gy == 27:
return bevel_dark
# Door (gx 14..18, gy 21..27)
if 14 <= gx <= 18 and 21 <= gy <= 27:
if gx in (14, 18) or gy == 21:
return bevel_dark
if gx == 17 and gy == 24:
return amber_gold # Doorknob
return (40, 50, 70, 255)
# Windows (gx 11..12, gy 20..22 and gx 20..21, gy 20..22)
if (11 <= gx <= 12 and 20 <= gy <= 22) or (20 <= gx <= 21 and 20 <= gy <= 22):
return cyan_bright
# Wall texture
return (220, 225, 235, 255)
# Ground baseline (gy between 28 and 29)
if 28 <= gy <= 29 and 2 <= gx <= 29:
return emerald
return bg
with open('/Users/moritz/src/infrapuzzle/website/grabengasse_frontend/public/icon-192.png', 'wb') as f:
f.write(make_png(192, 192, retro_icon_pixel))
with open('/Users/moritz/src/infrapuzzle/website/grabengasse_frontend/public/icon-512.png', 'wb') as f:
f.write(make_png(512, 512, retro_icon_pixel))
with open('/Users/moritz/src/infrapuzzle/website/grabengasse_frontend/public/apple-touch-icon.png', 'wb') as f:
f.write(make_png(180, 180, retro_icon_pixel))
with open('/Users/moritz/src/infrapuzzle/website/grabengasse_frontend/public/favicon.png', 'wb') as f:
f.write(make_png(64, 64, retro_icon_pixel))
print("Icons generated successfully!")
@@ -0,0 +1,35 @@
import subprocess
import json
import time
import urllib.request
import urllib.parse
import os
import sys
# Start Chromium in background with remote debugging
chrome_cmd = [
'/usr/local/bin/chromium',
'--headless',
'--remote-debugging-port=9222',
'--disable-gpu',
'--window-size=390,844',
'https://grabengasse7.de'
]
proc = subprocess.Popen(chrome_cmd)
time.sleep(2)
try:
# Get WebSocket debugger URL from /json
tabs = json.loads(urllib.request.urlopen('http://127.0.0.1:9222/json').read())
print("Found tabs:", len(tabs))
target = tabs[0]
ws_url = target['webSocketDebuggerUrl']
print("WS URL:", ws_url)
# We can use simple websocket client
import socket
# Or use curl / CDP commands
print("Chromium started successfully on port 9222")
finally:
proc.terminate()
@@ -0,0 +1,127 @@
import { spawn } from 'child_process';
import { writeFileSync } from 'fs';
async function runTest() {
console.log("Starting Chromium...");
const chrome = spawn('/usr/local/bin/chromium', [
'--headless',
'--remote-debugging-port=9222',
'--disable-gpu',
'--window-size=390,844'
]);
await new Promise((r) => setTimeout(r, 1500));
try {
const listRes = await fetch('http://127.0.0.1:9222/json');
const tabs = await listRes.json();
const wsUrl = tabs[0].webSocketDebuggerUrl;
const ws = new WebSocket(wsUrl);
let id = 1;
const callbacks = new Map();
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.id && callbacks.has(msg.id)) {
callbacks.get(msg.id)(msg);
callbacks.delete(msg.id);
}
};
await new Promise((r) => ws.onopen = r);
function sendCommand(method, params = {}) {
return new Promise((resolve) => {
const cmdId = id++;
callbacks.set(cmdId, resolve);
ws.send(JSON.stringify({ id: cmdId, method, params }));
});
}
console.log("Navigating to https://grabengasse7.de ...");
await sendCommand('Page.enable');
await sendCommand('Runtime.enable');
await sendCommand('Page.navigate', { url: 'https://grabengasse7.de' });
await new Promise((r) => setTimeout(r, 3000));
// Evaluate scroll dimensions
const scrollEval = await sendCommand('Runtime.evaluate', {
expression: `JSON.stringify({
windowWidth: window.innerWidth,
windowHeight: window.innerHeight,
docScrollWidth: document.documentElement.scrollWidth,
docClientWidth: document.documentElement.clientWidth,
bodyScrollWidth: document.body.scrollWidth,
bodyClientWidth: document.body.clientWidth,
hasHorizontalScroll: document.documentElement.scrollWidth > document.documentElement.clientWidth,
overflowElements: Array.from(document.querySelectorAll('*'))
.filter(el => el.scrollWidth > window.innerWidth || el.getBoundingClientRect().right > window.innerWidth)
.map(el => ({ tag: el.tagName, class: el.className, right: el.getBoundingClientRect().right, scrollW: el.scrollWidth }))
.slice(0, 5)
})`,
returnByValue: true
});
const scrollData = JSON.parse(scrollEval.result?.result?.value || '{}');
console.log("Viewport Dimensions Check:", scrollData);
// Capture Home Screenshot
const homeScreenshot = await sendCommand('Page.captureScreenshot', { format: 'png' });
writeFileSync('/Users/moritz/.gemini/antigravity/brain/4e2b24dd-9034-48bf-9ca9-142faa5d0d56/node_home.png', Buffer.from(homeScreenshot.result.data, 'base64'));
console.log("Saved node_home.png");
// Click on the first metric row (.retro-metric-row)
console.log("Clicking on Temperature metric row...");
const clickEval = await sendCommand('Runtime.evaluate', {
expression: `(() => {
const rows = document.querySelectorAll('.retro-metric-row');
if (rows.length > 0) {
rows[0].click();
return JSON.stringify({ clicked: true, count: rows.length });
}
return JSON.stringify({ clicked: false, count: 0 });
})()`,
returnByValue: true
});
console.log("Click result:", JSON.parse(clickEval.result?.result?.value || '{}'));
await new Promise((r) => setTimeout(r, 2000));
// Evaluate Detail Screen dimensions
const detailEval = await sendCommand('Runtime.evaluate', {
expression: `(() => {
const modal = document.querySelector('.retro-screen-container') || document.querySelector('.retro-modal-window');
const backdrop = document.querySelector('.retro-screen-backdrop') || document.querySelector('.retro-modal-backdrop');
const canvas = document.querySelector('canvas');
return JSON.stringify({
modalExists: !!modal,
backdropExists: !!backdrop,
canvasExists: !!canvas,
modalHeight: modal ? modal.offsetHeight : 0,
modalScrollHeight: modal ? modal.scrollHeight : 0,
windowHeight: window.innerHeight,
requiresVerticalScroll: modal ? modal.scrollHeight > modal.clientHeight : false,
docScrollWidth: document.documentElement.scrollWidth,
docClientWidth: document.documentElement.clientWidth,
requiresHorizontalScroll: document.documentElement.scrollWidth > document.documentElement.clientWidth
});
})()`,
returnByValue: true
});
console.log("Detail Screen Check:", JSON.parse(detailEval.result?.result?.value || '{}'));
// Capture Detail Screen Screenshot
const detailScreenshot = await sendCommand('Page.captureScreenshot', { format: 'png' });
writeFileSync('/Users/moritz/.gemini/antigravity/brain/4e2b24dd-9034-48bf-9ca9-142faa5d0d56/node_detail.png', Buffer.from(detailScreenshot.result.data, 'base64'));
console.log("Saved node_detail.png");
ws.close();
} finally {
chrome.kill();
}
}
runTest().catch(console.error);
@@ -0,0 +1,75 @@
import { spawn } from 'child_process';
import { writeFileSync } from 'fs';
async function runThemeTest() {
const chrome = spawn('/usr/local/bin/chromium', [
'--headless',
'--remote-debugging-port=9222',
'--disable-gpu',
'--window-size=390,844'
]);
await new Promise((r) => setTimeout(r, 1500));
try {
const listRes = await fetch('http://127.0.0.1:9222/json');
const tabs = await listRes.json();
const ws = new WebSocket(tabs[0].webSocketDebuggerUrl);
let id = 1;
const callbacks = new Map();
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.id && callbacks.has(msg.id)) {
callbacks.get(msg.id)(msg);
callbacks.delete(msg.id);
}
};
await new Promise((r) => ws.onopen = r);
function sendCommand(method, params = {}) {
return new Promise((resolve) => {
const cmdId = id++;
callbacks.set(cmdId, resolve);
ws.send(JSON.stringify({ id: cmdId, method, params }));
});
}
await sendCommand('Page.enable');
await sendCommand('Runtime.enable');
await sendCommand('Page.navigate', { url: 'https://grabengasse7.de' });
await new Promise((r) => setTimeout(r, 2500));
// Click Day mode toggle
await sendCommand('Runtime.evaluate', {
expression: `document.querySelector('.theme-toggle').click();`
});
await new Promise((r) => setTimeout(r, 1000));
// Capture Light Home Screenshot
const lightHome = await sendCommand('Page.captureScreenshot', { format: 'png' });
writeFileSync('/Users/moritz/.gemini/antigravity/brain/4e2b24dd-9034-48bf-9ca9-142faa5d0d56/node_light_home.png', Buffer.from(lightHome.result.data, 'base64'));
// Click Temperature row
await sendCommand('Runtime.evaluate', {
expression: `document.querySelector('.retro-metric-row').click();`
});
await new Promise((r) => setTimeout(r, 1000));
// Capture Light Detail Screenshot
const lightDetail = await sendCommand('Page.captureScreenshot', { format: 'png' });
writeFileSync('/Users/moritz/.gemini/antigravity/brain/4e2b24dd-9034-48bf-9ca9-142faa5d0d56/node_light_detail.png', Buffer.from(lightDetail.result.data, 'base64'));
console.log("Light theme screenshots captured successfully!");
ws.close();
} finally {
chrome.kill();
}
}
runThemeTest().catch(console.error);
+407
View File
@@ -0,0 +1,407 @@
:root,
[data-theme="dark"] {
--font-pixel: 'Press Start 2P', monospace;
--font-ui: 'Silkscreen', 'Press Start 2P', monospace;
--font-mono: 'Space Mono', monospace;
--font-digital: 'VT323', monospace;
/* 16-Bit Dark Palette (SNES / Mad TV Night Studio) */
--retro-bg: #0c101a;
--retro-bg-pattern: #131926;
--retro-window-bg: #1a2236;
--retro-window-inner: #101624;
--retro-card-bg: #182033;
--retro-card-hover: #222c44;
--retro-bevel-light: #44567e;
--retro-bevel-dark: #07090f;
--retro-border: #28354f;
--retro-titlebar-bg: linear-gradient(90deg, #1c396c 0%, #2f599e 50%, #172d54 100%);
--retro-titlebar-text: #f8fafc;
--retro-titlebar-stripes: repeating-linear-gradient(90deg, #244882, #244882 4px, #162f59 4px, #162f59 8px);
--retro-text-main: #f1f5f9;
--retro-text-dim: #94a3b8;
--retro-text-muted: #64748b;
--retro-accent-amber: #fbbf24;
--retro-accent-cyan: #00f0ff;
--retro-accent-green: #10b981;
--retro-accent-red: #f43f5e;
--retro-accent-purple: #c084fc;
--retro-shadow: 3px 3px 0px #05070c;
--retro-shadow-lg: 6px 6px 0px #04060a;
--retro-grid-line: rgba(255, 255, 255, 0.08);
--chart-grid-color: rgba(255, 255, 255, 0.08);
--chart-text-color: #94a3b8;
}
[data-theme="light"] {
--font-pixel: 'Press Start 2P', monospace;
--font-ui: 'Silkscreen', 'Press Start 2P', monospace;
--font-mono: 'Space Mono', monospace;
--font-digital: 'VT323', monospace;
/* 16-Bit Bright Palette (Amiga Workbench / Windows 95 / SNES Day Mode) */
--retro-bg: #b8bec9;
--retro-bg-pattern: #c8ceda;
--retro-window-bg: #d4d8e2;
--retro-window-inner: #f1f4f9;
--retro-card-bg: #dde2ed;
--retro-card-hover: #eaf0fa;
--retro-bevel-light: #ffffff;
--retro-bevel-dark: #586173;
--retro-border: #4d576a;
--retro-titlebar-bg: linear-gradient(90deg, #0a246a 0%, #1d50b5 60%, #081e54 100%);
--retro-titlebar-text: #ffffff;
--retro-titlebar-stripes: repeating-linear-gradient(90deg, #103387, #103387 4px, #071c4f 4px, #071c4f 8px);
--retro-text-main: #0f172a;
--retro-text-dim: #334155;
--retro-text-muted: #64748b;
--retro-accent-amber: #b45309;
--retro-accent-cyan: #0284c7;
--retro-accent-green: #047857;
--retro-accent-red: #be123c;
--retro-accent-purple: #7e22ce;
--retro-shadow: 3px 3px 0px #50596b;
--retro-shadow-lg: 6px 6px 0px #404857;
--retro-grid-line: rgba(0, 0, 0, 0.1);
--chart-grid-color: rgba(0, 0, 0, 0.1);
--chart-text-color: #334155;
}
html, body {
width: 100%;
max-width: 100vw;
overflow-x: hidden;
box-sizing: border-box;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
-webkit-tap-highlight-color: transparent;
}
body {
font-family: var(--font-mono);
background-color: var(--retro-bg);
color: var(--retro-text-main);
min-height: 100vh;
min-height: -webkit-fill-available;
position: relative;
background-image:
radial-gradient(var(--retro-bg-pattern) 15%, transparent 16%),
radial-gradient(var(--retro-bg-pattern) 15%, transparent 16%);
background-size: 16px 16px;
background-position: 0 0, 8px 8px;
transition: background-color 0.2s ease, color 0.2s ease;
-webkit-font-smoothing: antialiased;
}
/* Optional CRT Scanlines Layer */
.crt-overlay {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 999;
background: linear-gradient(
rgba(18, 16, 16, 0) 50%,
rgba(0, 0, 0, 0.18) 50%
), linear-gradient(
90deg,
rgba(255, 0, 0, 0.015),
rgba(0, 255, 0, 0.01),
rgba(0, 0, 255, 0.015)
);
background-size: 100% 3px, 6px 100%;
opacity: 0.6;
}
/* Base Container with Safe Area Support */
.container {
width: 100%;
max-width: 960px;
margin: 0 auto;
padding: 0.6rem 0.6rem 3rem 0.6rem;
padding-left: max(0.6rem, env(safe-area-inset-left));
padding-right: max(0.6rem, env(safe-area-inset-right));
padding-bottom: max(3rem, env(safe-area-inset-bottom));
box-sizing: border-box;
}
/* 16-Bit Retro Window Component */
.retro-window {
width: 100%;
max-width: 100%;
box-sizing: border-box;
background-color: var(--retro-window-bg);
border-top: 3px solid var(--retro-bevel-light);
border-left: 3px solid var(--retro-bevel-light);
border-right: 3px solid var(--retro-bevel-dark);
border-bottom: 3px solid var(--retro-bevel-dark);
box-shadow: var(--retro-shadow);
margin-bottom: 1.15rem;
}
.retro-window-header {
background: var(--retro-titlebar-bg);
color: var(--retro-titlebar-text);
padding: 0.35rem 0.5rem;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 2px solid var(--retro-bevel-dark);
font-family: var(--font-pixel);
font-size: clamp(0.58rem, 2.2vw, 0.7rem);
letter-spacing: 0.04em;
user-select: none;
gap: 0.4rem;
box-sizing: border-box;
}
.retro-window-title {
display: flex;
align-items: center;
gap: 0.35rem;
font-weight: 700;
text-transform: uppercase;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.retro-window-controls {
display: flex;
gap: 0.25rem;
flex-shrink: 0;
}
.retro-btn-box {
width: 22px;
height: 22px;
min-width: 22px;
background: var(--retro-window-bg);
border-top: 2px solid var(--retro-bevel-light);
border-left: 2px solid var(--retro-bevel-light);
border-right: 2px solid var(--retro-bevel-dark);
border-bottom: 2px solid var(--retro-bevel-dark);
color: var(--retro-text-main);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
font-family: var(--font-pixel);
cursor: pointer;
line-height: 1;
user-select: none;
touch-action: manipulation;
}
.retro-btn-box:active {
border-top: 2px solid var(--retro-bevel-dark);
border-left: 2px solid var(--retro-bevel-dark);
border-right: 2px solid var(--retro-bevel-light);
border-bottom: 2px solid var(--retro-bevel-light);
transform: translate(1px, 1px);
}
.retro-window-content {
padding: 0.75rem;
box-sizing: border-box;
}
/* Inset Retro Wells */
.retro-well {
background: var(--retro-window-inner);
border-top: 2px solid var(--retro-bevel-dark);
border-left: 2px solid var(--retro-bevel-dark);
border-right: 2px solid var(--retro-bevel-light);
border-bottom: 2px solid var(--retro-bevel-light);
padding: 0.45rem;
box-sizing: border-box;
}
/* 16-Bit Interactive Buttons */
.retro-btn {
font-family: var(--font-ui);
font-size: clamp(0.62rem, 2.2vw, 0.72rem);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 0.4rem 0.65rem;
background-color: var(--retro-window-bg);
color: var(--retro-text-main);
border-top: 2px solid var(--retro-bevel-light);
border-left: 2px solid var(--retro-bevel-light);
border-right: 2px solid var(--retro-bevel-dark);
border-bottom: 2px solid var(--retro-bevel-dark);
box-shadow: 2px 2px 0px rgba(0,0,0,0.3);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
transition: background-color 0.1s ease;
user-select: none;
touch-action: manipulation;
white-space: nowrap;
}
.retro-btn:hover {
background-color: var(--retro-card-hover);
}
.retro-btn:active,
.retro-btn.active {
border-top: 2px solid var(--retro-bevel-dark);
border-left: 2px solid var(--retro-bevel-dark);
border-right: 2px solid var(--retro-bevel-light);
border-bottom: 2px solid var(--retro-bevel-light);
box-shadow: inset 1px 1px 0px rgba(0,0,0,0.5);
transform: translate(1px, 1px);
}
.retro-btn.btn-accent {
background-color: var(--retro-accent-amber);
color: #000000;
border-color: #fde68a #92400e #92400e #fde68a;
}
/* Group Headers */
.group-section {
margin-bottom: 1.15rem;
width: 100%;
box-sizing: border-box;
}
.group-header-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.4rem;
margin-bottom: 0.45rem;
padding: 0.35rem 0.55rem;
background: var(--retro-window-bg);
border-top: 2px solid var(--retro-bevel-light);
border-left: 2px solid var(--retro-bevel-light);
border-right: 2px solid var(--retro-bevel-dark);
border-bottom: 2px solid var(--retro-bevel-dark);
box-sizing: border-box;
flex-wrap: wrap;
}
.group-title {
font-family: var(--font-pixel);
font-size: clamp(0.58rem, 2.2vw, 0.72rem);
font-weight: 700;
color: var(--retro-text-main);
display: flex;
align-items: center;
gap: 0.35rem;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.group-hint {
font-family: var(--font-ui);
font-size: 0.58rem;
color: var(--retro-text-dim);
}
/* Top-Down Metric List Stack */
.metrics-list-stack {
display: flex;
flex-direction: column;
gap: 0.45rem;
width: 100%;
max-width: 100%;
box-sizing: border-box;
}
/* Status Badges */
.retro-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.15rem 0.4rem;
font-family: var(--font-ui);
font-size: clamp(0.52rem, 1.8vw, 0.62rem);
font-weight: 700;
border-top: 2px solid var(--retro-bevel-dark);
border-left: 2px solid var(--retro-bevel-dark);
border-right: 2px solid var(--retro-bevel-light);
border-bottom: 2px solid var(--retro-bevel-light);
background: var(--retro-window-inner);
color: var(--retro-text-main);
text-transform: uppercase;
white-space: nowrap;
}
.retro-badge.badge-green {
color: var(--retro-accent-green);
border-color: #064e3b #34d399 #34d399 #064e3b;
}
.retro-badge.badge-amber {
color: var(--retro-accent-amber);
border-color: #78350f #fbbf24 #fbbf24 #78350f;
}
.retro-badge.badge-cyan {
color: var(--retro-accent-cyan);
border-color: #0c4a6e #38bdf8 #38bdf8 #0c4a6e;
}
.retro-badge.badge-red {
color: var(--retro-accent-red);
border-color: #881337 #fb7185 #fb7185 #881337;
}
/* Flashing Blinker for Live Status */
.retro-blinker {
display: inline-block;
width: 7px;
height: 7px;
min-width: 7px;
background-color: var(--retro-accent-green);
animation: retroBlink 1s steps(2, start) infinite;
}
@keyframes retroBlink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
/* App Footer */
.app-footer {
margin-top: 1.25rem;
padding-top: 0.75rem;
border-top: 2px solid var(--retro-bevel-dark);
font-family: var(--font-ui);
font-size: clamp(0.52rem, 1.8vw, 0.65rem);
color: var(--retro-text-dim);
text-align: center;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
@media (max-width: 640px) {
.container {
padding: 0.4rem 0.4rem 2rem 0.4rem;
}
.group-hint {
display: none;
}
}
@@ -0,0 +1,38 @@
export interface CurrentWeatherMetrics {
temperature?: float;
apparent_temperature?: float;
wind_chill?: float;
dew_point?: float;
humidex?: float;
humidity?: float;
pressure?: float;
rain_status?: number;
rain_status_label?: string;
wind_speed?: float;
gust_speed?: float;
wind_direction?: float;
illuminance_lux?: float;
uv_index?: float;
battery_percent?: float;
linkquality_lqi?: float;
updated_at: string;
}
type float = number;
export interface TimeSeriesPoint {
timestamp: number;
value: number;
}
export interface TimeSeriesMetric {
name: string;
label: string;
unit: string;
data: TimeSeriesPoint[];
}
export interface WeatherHistoryResponse {
timeframe: string;
metrics: Record<string, TimeSeriesMetric>;
}
@@ -0,0 +1,118 @@
import type { TimeSeriesPoint } from '../types/weather'
/**
* Calculate dew point (°C) from temperature (°C) and relative humidity (%)
* using the Magnus-Tetens formula.
*/
export function calculateDewPoint(
temperature?: number | null,
humidity?: number | null
): number | null {
if (
temperature === undefined ||
temperature === null ||
humidity === undefined ||
humidity === null ||
humidity <= 0
) {
return null
}
const a = 17.625
const b = 243.04
try {
const alpha = (a * temperature) / (b + temperature) + Math.log(humidity / 100)
const dewPoint = (b * alpha) / (a - alpha)
return Math.round(dewPoint * 10) / 10
} catch {
return null
}
}
/**
* Return thermal sensation / comfort level based on dew point (°C)
*/
export function getDewPointComfort(dewPoint?: number | null): {
text: string
color: string
} {
if (dewPoint === undefined || dewPoint === null) {
return { text: 'Unknown', color: 'var(--retro-text-dim)' }
}
if (dewPoint < 10) {
return { text: 'Dry / Crisp', color: 'var(--retro-text-cyan)' }
} else if (dewPoint <= 15) {
return { text: 'Pleasant / Fresh', color: 'var(--retro-text-green)' }
} else if (dewPoint <= 18) {
return { text: 'Comfortable', color: 'var(--retro-text-amber)' }
} else if (dewPoint <= 21) {
return { text: 'Humid / Warm', color: 'var(--retro-text-amber)' }
} else {
return { text: 'Muggy / Oppressive', color: 'var(--retro-text-red)' }
}
}
/**
* Convert degrees (0-360°) to 16-wind compass rose direction
*/
export function getWindDirectionCardinal(degrees?: number | null): string {
if (degrees === undefined || degrees === null) return 'N/A'
const compassSectors = [
'N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE',
'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'
]
const val = Math.floor((degrees / 22.5) + 0.5)
return compassSectors[val % 16]
}
/**
* Get UV Index category & retro color
*/
export function getUVIndexLevel(uv?: number | null): {
label: string
color: string
} {
if (uv === undefined || uv === null) {
return { label: 'Low', color: 'var(--retro-text-green)' }
}
if (uv < 3) return { label: 'Low', color: 'var(--retro-text-green)' }
if (uv < 6) return { label: 'Moderate', color: 'var(--retro-text-amber)' }
if (uv < 8) return { label: 'High', color: 'var(--retro-text-amber)' }
if (uv < 11) return { label: 'Very High', color: 'var(--retro-text-red)' }
return { label: 'Extreme', color: 'var(--retro-accent-purple)' }
}
/**
* Calculate basic statistical properties (min, max, average, current) for a time series
*/
export function calculateHistoryStats(points?: TimeSeriesPoint[]): {
min: number | null
max: number | null
avg: number | null
current: number | null
} {
if (!points || points.length === 0) {
return { min: null, max: null, avg: null, current: null }
}
let min = points[0].value
let max = points[0].value
let sum = 0
for (const p of points) {
if (p.value < min) min = p.value
if (p.value > max) max = p.value
sum += p.value
}
const avg = Math.round((sum / points.length) * 10) / 10
const current = points[points.length - 1].value
return {
min: Math.round(min * 10) / 10,
max: Math.round(max * 10) / 10,
avg,
current: Math.round(current * 10) / 10
}
}