About to push

This commit is contained in:
2026-06-01 21:08:01 +01:00
parent 4224a535ef
commit 9607eac224
6 changed files with 319 additions and 3 deletions
+26 -1
View File
@@ -151,6 +151,21 @@ h2 {
padding: 16px;
}
.metric-link {
color: inherit;
text-decoration: none;
transition: border-color 0.15s ease, transform 0.15s ease;
}
.metric-link:hover {
border-color: var(--brand);
transform: translateY(-1px);
}
.device-summary {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.card-heading {
display: flex;
align-items: start;
@@ -243,6 +258,10 @@ h2 {
margin-bottom: 18px;
}
.report-form.compact {
grid-template-columns: minmax(180px, 240px) auto;
}
.report-form label {
display: grid;
gap: 6px;
@@ -293,6 +312,10 @@ th {
.report-form {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.device-summary {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 620px) {
@@ -313,7 +336,9 @@ th {
}
.cards,
.report-form {
.report-form,
.report-form.compact,
.device-summary {
grid-template-columns: 1fr;
}
+2 -2
View File
@@ -17,7 +17,7 @@
{% for device in devices %}
{% set reading = latest.get(device.id) %}
{% set stat = stats.get(device.id) %}
<article class="metric-card">
<a class="metric-card metric-link" href="/devices/{{ device.id }}">
<div class="card-heading">
<h2>{{ device.name }}</h2>
<span>{{ device.device_type }}</span>
@@ -44,7 +44,7 @@
{% else %}
<p class="empty">Waiting for the first reading.</p>
{% endif %}
</article>
</a>
{% endfor %}
</section>
+92
View File
@@ -0,0 +1,92 @@
{% extends "base.html" %}
{% block title %}{{ device.name }} - SwitchBot Temps{% endblock %}
{% block content %}
<section class="hero">
<div>
<p class="eyebrow">Device readings</p>
<h1>{{ device.name }}</h1>
<p class="muted">{{ device.device_type }}. Local timezone: {{ timezone }}.</p>
</div>
<a class="button secondary" href="/">Back to dashboard</a>
</section>
<form class="report-form compact" method="get" action="/devices/{{ device.id }}">
<label>
Day
<input type="date" name="date" value="{{ date }}">
</label>
<button class="button" type="submit">View day</button>
</form>
<section class="cards device-summary">
<article class="metric-card">
<h2>Samples</h2>
<div class="reading-row">
<strong>{{ readings|length }}</strong>
</div>
</article>
<article class="metric-card">
<h2>Low temp</h2>
<div class="reading-row">
<strong>{{ "%.1f"|format(stats.low) if stats else "n/a" }}&deg;C</strong>
</div>
</article>
<article class="metric-card">
<h2>High temp</h2>
<div class="reading-row">
<strong>{{ "%.1f"|format(stats.high) if stats else "n/a" }}&deg;C</strong>
</div>
</article>
</section>
<section class="panel">
<div class="panel-heading">
<div>
<h2>Day graph</h2>
<p class="muted">Temperature readings for {{ date }}.</p>
</div>
</div>
<div class="chart-wrap">
<canvas id="temperatureChart" height="360"></canvas>
</div>
<script id="chart-data" type="application/json">{{ chart_json|safe }}</script>
<script src="{{ url_for('static', filename='chart.js') }}"></script>
</section>
<section class="panel">
<div class="panel-heading">
<div>
<h2>Readings</h2>
<p class="muted">Timestamped readings for this device and day.</p>
</div>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Timestamp</th>
<th>Temperature</th>
<th>Humidity</th>
<th>Battery</th>
</tr>
</thead>
<tbody>
{% for reading in local_readings %}
<tr>
<td>{{ reading.timestamp }}</td>
<td>{{ "%.1f"|format(reading.temperature) if reading.temperature is not none else "n/a" }}&deg;C</td>
<td>{{ reading.humidity if reading.humidity is not none else "n/a" }}%</td>
<td>{{ reading.battery if reading.battery is not none else "n/a" }}%</td>
</tr>
{% else %}
<tr>
<td colspan="4" class="empty">No readings found for this day.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
{% endblock %}
+11
View File
@@ -0,0 +1,11 @@
{% extends "base.html" %}
{% block title %}Not found - SwitchBot Temps{% endblock %}
{% block content %}
<section class="empty-state">
<h1>Device not found</h1>
<p class="muted">That device is not available as a temperature sensor.</p>
<a class="button" href="/">Back to dashboard</a>
</section>
{% endblock %}
+58
View File
@@ -49,6 +49,30 @@ def create_app() -> Flask:
collect_interval_seconds=config.collect_interval_seconds,
)
@app.get("/devices/<device_id>")
def device_detail(device_id: str) -> str:
tz = ZoneInfo(config.app_timezone)
selected_date_text = request.args.get("date") or datetime.now(tz).date().isoformat()
selected_date = date.fromisoformat(selected_date_text)
start_utc, end_utc = local_date_range_to_utc(selected_date, selected_date, tz)
with SessionLocal() as session:
device = session.get(Device, device_id)
if device is None or device.device_type not in SENSOR_DEVICE_TYPES:
return render_template("not_found.html"), 404
readings = load_device_readings(session, device_id, start_utc, end_utc)
return render_template(
"device.html",
device=device,
date=selected_date_text,
readings=readings,
stats=stats_by_device(readings).get(device_id),
chart_json=json.dumps(chart_payload(readings, tz)),
timezone=config.app_timezone,
local_readings=readings_for_display(readings, tz),
)
@app.get("/reports")
def reports() -> str:
tz = ZoneInfo(config.app_timezone)
@@ -147,6 +171,25 @@ def load_readings(session: Session, start_utc: datetime, end_utc: datetime) -> l
return list(session.scalars(stmt))
def load_device_readings(
session: Session,
device_id: str,
start_utc: datetime,
end_utc: datetime,
) -> list[Reading]:
stmt = (
select(Reading)
.options(joinedload(Reading.device))
.where(
Reading.device_id == device_id,
Reading.recorded_at >= start_utc,
Reading.recorded_at < end_utc,
)
.order_by(Reading.recorded_at)
)
return list(session.scalars(stmt))
def latest_by_device(session: Session) -> dict[str, Reading]:
subquery = (
select(Reading.device_id, func.max(Reading.recorded_at).label("latest_at"))
@@ -204,6 +247,21 @@ def chart_payload(readings: list[Reading], tz: ZoneInfo) -> list[dict[str, objec
return list(series.values())
def readings_for_display(readings: list[Reading], tz: ZoneInfo) -> list[dict[str, object]]:
rows = []
for reading in readings:
local_time = reading.recorded_at.replace(tzinfo=timezone.utc).astimezone(tz)
rows.append(
{
"timestamp": local_time.strftime("%Y-%m-%d %H:%M"),
"temperature": reading.temperature,
"humidity": reading.humidity,
"battery": reading.battery,
}
)
return rows
def report_rows(
session: Session,
start_utc: datetime,