Initial commit — energy dashboard frontend (TimescaleDB + Vue/Chart.js)
This commit is contained in:
237
frontend/src/views/StatisticsView.vue
Normal file
237
frontend/src/views/StatisticsView.vue
Normal file
@@ -0,0 +1,237 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import {
|
||||
Chart,
|
||||
BarController, BarElement,
|
||||
CategoryScale, LinearScale,
|
||||
Tooltip, Legend,
|
||||
type ChartDataset,
|
||||
} from 'chart.js'
|
||||
import { api, type EnergyBar } from '@/api/client'
|
||||
|
||||
Chart.register(
|
||||
BarController, BarElement,
|
||||
CategoryScale, LinearScale,
|
||||
Tooltip, Legend,
|
||||
)
|
||||
|
||||
// ── Selectors ─────────────────────────────────────────────────────────────────
|
||||
|
||||
type Period = 'day' | 'week' | 'month'
|
||||
|
||||
const periods: { value: Period; label: string }[] = [
|
||||
{ value: 'day', label: 'Day' },
|
||||
{ value: 'week', label: 'Week' },
|
||||
{ value: 'month', label: 'Month' },
|
||||
]
|
||||
|
||||
const countOptions: Record<Period, number[]> = {
|
||||
day: [7, 14, 30, 90],
|
||||
week: [4, 8, 13, 26],
|
||||
month: [3, 6, 12, 24],
|
||||
}
|
||||
|
||||
const selectedPeriod = ref<Period>('day')
|
||||
const selectedCount = ref(30)
|
||||
|
||||
function selectPeriod(p: Period) {
|
||||
selectedPeriod.value = p
|
||||
selectedCount.value = countOptions[p][2] // third option as default
|
||||
refresh()
|
||||
}
|
||||
|
||||
function selectCount(c: number) {
|
||||
selectedCount.value = c
|
||||
refresh()
|
||||
}
|
||||
|
||||
// ── Data ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const bars = ref<EnergyBar[]>([])
|
||||
|
||||
// ── Chart ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const canvas = ref<HTMLCanvasElement | null>(null)
|
||||
let chart: Chart | null = null
|
||||
|
||||
const COLORS = {
|
||||
buy: { bg: 'rgba(239,83,80,0.8)', border: '#ef5350' },
|
||||
sell: { bg: 'rgba(66,165,245,0.8)', border: '#42a5f5' },
|
||||
produce: { bg: 'rgba(102,187,106,0.8)', border: '#66bb6a' },
|
||||
consume: { bg: 'rgba(255,167,38,0.8)', border: '#ffa726' },
|
||||
}
|
||||
|
||||
function labelFor(t: string): string {
|
||||
const d = new Date(t)
|
||||
switch (selectedPeriod.value) {
|
||||
case 'month': return d.toLocaleDateString('en-GB', { month: 'short', year: '2-digit' })
|
||||
case 'week': return d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })
|
||||
default: return d.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short' })
|
||||
}
|
||||
}
|
||||
|
||||
function datasets(data: EnergyBar[]): ChartDataset<'bar'>[] {
|
||||
return [
|
||||
{ label: 'Buy', data: data.map(b => b.buy), backgroundColor: COLORS.buy.bg, borderColor: COLORS.buy.border, borderWidth: 1 },
|
||||
{ label: 'Sell', data: data.map(b => b.sell), backgroundColor: COLORS.sell.bg, borderColor: COLORS.sell.border, borderWidth: 1 },
|
||||
{ label: 'Produce', data: data.map(b => b.produce), backgroundColor: COLORS.produce.bg, borderColor: COLORS.produce.border, borderWidth: 1 },
|
||||
{ label: 'Consume', data: data.map(b => b.consume), backgroundColor: COLORS.consume.bg, borderColor: COLORS.consume.border, borderWidth: 1 },
|
||||
]
|
||||
}
|
||||
|
||||
function buildChart() {
|
||||
chart?.destroy()
|
||||
chart = null
|
||||
if (!canvas.value || !bars.value.length) return
|
||||
chart = new Chart(canvas.value, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: bars.value.map(b => labelFor(b.time)),
|
||||
datasets: datasets(bars.value),
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { labels: { color: '#8b8fa3' } },
|
||||
tooltip: { callbacks: { label: (ctx: any) => ` ${ctx.dataset.label}: ${ctx.parsed.y.toFixed(1)} kWh` } },
|
||||
},
|
||||
scales: {
|
||||
x: { ticks: { color: '#8b8fa3' }, grid: { color: '#2e3140' } },
|
||||
y: { ticks: { color: '#8b8fa3' }, grid: { color: '#2e3140' }, title: { display: true, text: 'kWh', color: '#8b8fa3' } },
|
||||
},
|
||||
} as any,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Refresh ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const res = await api.getBars(selectedPeriod.value, selectedCount.value)
|
||||
bars.value = res.bars
|
||||
loading.value = false
|
||||
await nextTick()
|
||||
buildChart()
|
||||
} catch (e: any) {
|
||||
error.value = e.message
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────────────
|
||||
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
onMounted(async () => {
|
||||
await refresh()
|
||||
timer = setInterval(refresh, 5 * 60 * 1000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer) clearInterval(timer)
|
||||
chart?.destroy()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="statistics">
|
||||
|
||||
<div class="controls">
|
||||
<div class="tab-group">
|
||||
<button
|
||||
v-for="p in periods"
|
||||
:key="p.value"
|
||||
:class="['tab-btn', { active: selectedPeriod === p.value }]"
|
||||
@click="selectPeriod(p.value)"
|
||||
>{{ p.label }}</button>
|
||||
</div>
|
||||
|
||||
<div class="tab-group">
|
||||
<button
|
||||
v-for="c in countOptions[selectedPeriod]"
|
||||
:key="c"
|
||||
:class="['tab-btn', { active: selectedCount === c }]"
|
||||
@click="selectCount(c)"
|
||||
>{{ c }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">Loading…</div>
|
||||
<div v-else-if="error" class="error">{{ error }}</div>
|
||||
<div v-else class="card chart-card">
|
||||
<div class="chart-header">Energy (kWh)</div>
|
||||
<div class="chart-wrap"><canvas ref="canvas" /></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.statistics {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tab-group {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
padding: 0.25rem 0.625rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.tab-btn:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--text-muted);
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.loading {
|
||||
padding: 3rem;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.chart-header {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.chart-wrap {
|
||||
position: relative;
|
||||
height: 340px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user