About to push
This commit is contained in:
+26
-1
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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" }}°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" }}°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" }}°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 %}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user