Skip to content

Commit 2de5fb9

Browse files
jdrumgooleclaude
andcommitted
Highly graphical history: metric tabs + 24h tile sparklines
The History page used to chart one metric (power for day, energy for the other ranges). It now offers five tabs across the top — Power, Energy, Battery, Money, Alarms — driving a single chart underneath. Each metric defines which ranges it supports (e.g. Battery is sample- resolution only; Money is daily-rollup only), so unsupported range options grey out when the active tab doesn't support them. Per-metric colours, and the CSV download tracks the active metric. The home dashboard tiles each gain a 24-hour SVG sparkline drawn from station_samples. Paths are pre-rendered server-side (Python helper + inline SVG), so there's no extra JS, no Chart.js for the tiles, and they refresh automatically with the existing 30s tile swap. Behind the scenes: - HistoryService methods now take an optional `metric=` keyword and share a small registry that maps metric → field/label. Day-view metrics read from station_samples; aggregate-view metrics read from station_daily and respect per-row unit fields. - New JSON/CSV history endpoints accept `?metric=`; without it, callers get the original default for that view. - LiveTilesService swallows sparkline lookup failures — the home page must keep rendering if Mongo is slow or misbehaving on that one decorative path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f62072e commit 2de5fb9

13 files changed

Lines changed: 740 additions & 97 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,31 @@ All notable changes to **Solisdash** are documented here. Version numbers
44
follow [Semantic Versioning](https://semver.org/). The package is published
55
to PyPI on every `vX.Y.Z` tag push.
66

7+
## 0.8.0 — 2026-05-16
8+
9+
### Added
10+
- **Metric tabs on the History page.** Power / Energy / Battery / Money /
11+
Alarms sit across the top; the range selector greys out options that
12+
aren't valid for the chosen metric (e.g. Money is daily-rollup only,
13+
so the Day option disables when Money is active). Each metric gets its
14+
own series colour. CSV download now follows the active metric.
15+
- **24-hour sparklines under each live tile** on the home page. Tiny SVG
16+
trend lines drawn from `station_samples`, refreshed alongside the
17+
tiles via the existing 30-second HTMX swap. Server-rendered SVG paths,
18+
no extra JS or Chart.js needed.
19+
- New JSON/CSV history endpoints accept `?metric=` (`power`, `energy`,
20+
`battery`, `money`, `alarms`). Existing callers without the parameter
21+
keep getting the previous default (power for day, energy elsewhere).
22+
23+
### Changed
24+
- `HistoryService.day_series`, `month_daily`, `year_monthly`, `all_time`
25+
now take an optional `metric=` keyword. Defaults are unchanged.
26+
27+
### Fixed
28+
- `LiveTilesService.get_tiles` falls through to the existing tile data
29+
if the sparkline lookup fails. Sparklines are decorative; a Mongo
30+
hiccup must never blank the home page.
31+
732
## 0.7.3 — 2026-05-16
833

934
### Changed

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "solisdash"
3-
version = "0.7.3"
3+
version = "0.8.0"
44
description = "Self-hosted dashboard for the SolisCloud (Ginlong Solis) platform"
55
readme = "README.md"
66
requires-python = ">=3.10"

‎src/solisdash/__init__.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "0.7.3"
1+
__version__ = "0.8.0"

‎src/solisdash/app.py‎

Lines changed: 87 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,19 @@
3535
from solisdash.config import get_settings
3636
from solisdash.configfile import delete_toml, user_config_toml_path, write_toml
3737
from solisdash.db import ensure_indexes
38-
from solisdash.history import HistoryService, Series, parse_month
38+
from solisdash.history import (
39+
METRIC_ENERGY,
40+
METRIC_POWER,
41+
METRIC_SUPPORTS,
42+
VIEW_ALL,
43+
VIEW_DAY,
44+
VIEW_MONTH,
45+
VIEW_YEAR,
46+
HistoryService,
47+
Series,
48+
metric_supports,
49+
parse_month,
50+
)
3951
from solisdash.poller import Poller
4052
from solisdash.ratelimit import TokenBucket
4153
from solisdash.scheduler import build_scheduler
@@ -778,70 +790,108 @@ async def history_poll_now(
778790
)
779791

780792

793+
_DAY_LABELS = {
794+
"power": ("Power", "kW"),
795+
"energy": ("Day energy", "kWh"),
796+
"battery": ("Battery SOC", "%"),
797+
"alarms": ("Open alarms", ""),
798+
}
799+
_DAILY_LABELS = {
800+
"energy": {
801+
"month": ("Daily energy", "kWh"),
802+
"year": ("Monthly energy", "kWh"),
803+
"all": ("Annual energy", "kWh"),
804+
},
805+
"money": {
806+
"month": ("Daily revenue", ""),
807+
"year": ("Monthly revenue", ""),
808+
"all": ("Annual revenue", ""),
809+
},
810+
}
811+
812+
813+
def _validate_metric(metric: str, view: str) -> None:
814+
if metric not in METRIC_SUPPORTS:
815+
raise HTTPException(status_code=400, detail=f"unknown metric: {metric!r}")
816+
if not metric_supports(metric, view):
817+
raise HTTPException(
818+
status_code=400,
819+
detail=f"metric {metric!r} is not available for the {view!r} view",
820+
)
821+
822+
781823
@app.get("/history/day.json")
782824
async def history_day_json(
783825
station_id: str | None = Query(None),
784826
when: str = Query(..., description="YYYY-MM-DD"),
827+
metric: str = Query(METRIC_POWER),
785828
history: HistoryService = Depends(get_history_service),
786829
user: dict[str, Any] = Depends(require_user),
787830
) -> JSONResponse:
831+
_validate_metric(metric, VIEW_DAY)
788832
sid = await _resolve_station_id(history, station_id)
789833
if sid is None:
790-
return JSONResponse(_empty_series_response("Power", "kW", station_id=None))
834+
label, unit = _DAY_LABELS.get(metric, ("Series", ""))
835+
return JSONResponse(_empty_series_response(label, unit, station_id=None))
791836
try:
792837
when_date = date.fromisoformat(when)
793838
except ValueError as exc:
794839
raise HTTPException(status_code=400, detail=str(exc)) from exc
795-
series = await history.day_series(sid, when_date)
840+
series = await history.day_series(sid, when_date, metric=metric)
796841
return JSONResponse({"station_id": sid, **series.to_json()})
797842

798843

799844
@app.get("/history/month.json")
800845
async def history_month_json(
801846
station_id: str | None = Query(None),
802847
month: str = Query(..., description="YYYY-MM"),
848+
metric: str = Query(METRIC_ENERGY),
803849
history: HistoryService = Depends(get_history_service),
804850
user: dict[str, Any] = Depends(require_user),
805851
) -> JSONResponse:
852+
_validate_metric(metric, VIEW_MONTH)
806853
sid = await _resolve_station_id(history, station_id)
807854
if sid is None:
808-
return JSONResponse(_empty_series_response("Daily energy", "kWh", station_id=None))
855+
label, unit = _DAILY_LABELS[metric]["month"]
856+
return JSONResponse(_empty_series_response(label, unit, station_id=None))
809857
try:
810858
parse_month(month)
811859
except ValueError as exc:
812860
raise HTTPException(status_code=400, detail=str(exc)) from exc
813-
series = await history.month_daily(sid, month)
861+
series = await history.month_daily(sid, month, metric=metric)
814862
return JSONResponse({"station_id": sid, **series.to_json()})
815863

816864

817865
@app.get("/history/year.json")
818866
async def history_year_json(
819867
station_id: str | None = Query(None),
820868
year: int = Query(...),
869+
metric: str = Query(METRIC_ENERGY),
821870
history: HistoryService = Depends(get_history_service),
822871
user: dict[str, Any] = Depends(require_user),
823872
) -> JSONResponse:
873+
_validate_metric(metric, VIEW_YEAR)
824874
sid = await _resolve_station_id(history, station_id)
825875
if sid is None:
826-
return JSONResponse(
827-
_empty_series_response("Monthly energy", "kWh", station_id=None)
828-
)
829-
series = await history.year_monthly(sid, year)
876+
label, unit = _DAILY_LABELS[metric]["year"]
877+
return JSONResponse(_empty_series_response(label, unit, station_id=None))
878+
series = await history.year_monthly(sid, year, metric=metric)
830879
return JSONResponse({"station_id": sid, **series.to_json()})
831880

832881

833882
@app.get("/history/all.json")
834883
async def history_all_json(
835884
station_id: str | None = Query(None),
885+
metric: str = Query(METRIC_ENERGY),
836886
history: HistoryService = Depends(get_history_service),
837887
user: dict[str, Any] = Depends(require_user),
838888
) -> JSONResponse:
889+
_validate_metric(metric, VIEW_ALL)
839890
sid = await _resolve_station_id(history, station_id)
840891
if sid is None:
841-
return JSONResponse(
842-
_empty_series_response("Annual energy", "kWh", station_id=None)
843-
)
844-
series = await history.all_time(sid)
892+
label, unit = _DAILY_LABELS[metric]["all"]
893+
return JSONResponse(_empty_series_response(label, unit, station_id=None))
894+
series = await history.all_time(sid, metric=metric)
845895
return JSONResponse({"station_id": sid, **series.to_json()})
846896

847897

@@ -872,74 +922,82 @@ def _csv_response(body: str, filename: str) -> Response:
872922
async def history_day_csv(
873923
station_id: str | None = Query(None),
874924
when: str = Query(..., description="YYYY-MM-DD"),
925+
metric: str = Query(METRIC_POWER),
875926
history: HistoryService = Depends(get_history_service),
876927
user: dict[str, Any] = Depends(require_user),
877928
) -> Response:
929+
_validate_metric(metric, VIEW_DAY)
878930
sid = await _resolve_station_id(history, station_id)
879931
if sid is None:
880-
return _csv_response("timestamp_ms,power\n", "history-day.csv")
932+
return _csv_response(f"timestamp_ms,{metric}\n", f"history-day-{metric}.csv")
881933
try:
882934
when_date = date.fromisoformat(when)
883935
except ValueError as exc:
884936
raise HTTPException(status_code=400, detail=str(exc)) from exc
885-
series = await history.day_series(sid, when_date)
937+
series = await history.day_series(sid, when_date, metric=metric)
886938
return _csv_response(
887-
_series_to_csv(series, x_header="timestamp_ms", y_header="power"),
888-
f"history-day-{sid}-{when}.csv",
939+
_series_to_csv(series, x_header="timestamp_ms", y_header=metric),
940+
f"history-day-{metric}-{sid}-{when}.csv",
889941
)
890942

891943

892944
@app.get("/history/month.csv")
893945
async def history_month_csv(
894946
station_id: str | None = Query(None),
895947
month: str = Query(..., description="YYYY-MM"),
948+
metric: str = Query(METRIC_ENERGY),
896949
history: HistoryService = Depends(get_history_service),
897950
user: dict[str, Any] = Depends(require_user),
898951
) -> Response:
952+
_validate_metric(metric, VIEW_MONTH)
899953
sid = await _resolve_station_id(history, station_id)
900954
if sid is None:
901-
return _csv_response("date,energy\n", "history-month.csv")
955+
return _csv_response(f"date,{metric}\n", f"history-month-{metric}.csv")
902956
try:
903957
parse_month(month)
904958
except ValueError as exc:
905959
raise HTTPException(status_code=400, detail=str(exc)) from exc
906-
series = await history.month_daily(sid, month)
960+
series = await history.month_daily(sid, month, metric=metric)
907961
return _csv_response(
908-
_series_to_csv(series, x_header="date", y_header="energy"),
909-
f"history-month-{sid}-{month}.csv",
962+
_series_to_csv(series, x_header="date", y_header=metric),
963+
f"history-month-{metric}-{sid}-{month}.csv",
910964
)
911965

912966

913967
@app.get("/history/year.csv")
914968
async def history_year_csv(
915969
station_id: str | None = Query(None),
916970
year: int = Query(...),
971+
metric: str = Query(METRIC_ENERGY),
917972
history: HistoryService = Depends(get_history_service),
918973
user: dict[str, Any] = Depends(require_user),
919974
) -> Response:
975+
_validate_metric(metric, VIEW_YEAR)
920976
sid = await _resolve_station_id(history, station_id)
921977
if sid is None:
922-
return _csv_response("month,energy\n", "history-year.csv")
923-
series = await history.year_monthly(sid, year)
978+
return _csv_response(f"month,{metric}\n", f"history-year-{metric}.csv")
979+
series = await history.year_monthly(sid, year, metric=metric)
924980
return _csv_response(
925-
_series_to_csv(series, x_header="month", y_header="energy"),
926-
f"history-year-{sid}-{year}.csv",
981+
_series_to_csv(series, x_header="month", y_header=metric),
982+
f"history-year-{metric}-{sid}-{year}.csv",
927983
)
928984

929985

930986
@app.get("/history/all.csv")
931987
async def history_all_csv(
932988
station_id: str | None = Query(None),
989+
metric: str = Query(METRIC_ENERGY),
933990
history: HistoryService = Depends(get_history_service),
934991
user: dict[str, Any] = Depends(require_user),
935992
) -> Response:
993+
_validate_metric(metric, VIEW_ALL)
936994
sid = await _resolve_station_id(history, station_id)
937995
if sid is None:
938-
return _csv_response("year,energy\n", "history-all.csv")
939-
series = await history.all_time(sid)
996+
return _csv_response(f"year,{metric}\n", f"history-all-{metric}.csv")
997+
series = await history.all_time(sid, metric=metric)
940998
return _csv_response(
941-
_series_to_csv(series, x_header="year", y_header="energy"),
942-
f"history-all-{sid}.csv",
999+
_series_to_csv(series, x_header="year", y_header=metric),
1000+
f"history-all-{metric}-{sid}.csv",
9431001
)
9441002

9451003

0 commit comments

Comments
 (0)