Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions src/components/dashboard/ResourceChart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
"use client";

import { useMemo } from "react";
import { toDecimalNumber, type Decimal } from "@/utils/decimal";

/**
* Time-series chart for fixed-point resource data. Charting needs plain
* `number`s, so each {@link Decimal} is converted exactly once at render time
* via {@link toDecimalNumber} (which warns on precision loss). The exact Decimal
* is preserved for the headline total, which is formatted with `toFixed`.
*/

export interface ResourceChartPoint {
/** Unix milliseconds. */
timestamp: number;
value: Decimal;
}

export interface ResourceChartProps {
points: ResourceChartPoint[];
/** Display unit (e.g. "kWh"). */
unit?: string;
height?: number;
/** Stroke color. */
color?: string;
className?: string;
}

const PADDING = 6;

export function ResourceChart({
points,
unit = "",
height = 160,
color = "#22c55e",
className,
}: ResourceChartProps) {
// Convert exactly once; toDecimalNumber warns if a value can't be represented.
const numeric = useMemo(
() => points.map((p) => ({ t: p.timestamp, y: toDecimalNumber(p.value) })),
[points]
);

const { path, minY, maxY } = useMemo(() => {
if (numeric.length === 0) {
return { path: "", minY: 0, maxY: 0 };
}
let lo = Infinity;
let hi = -Infinity;
for (const p of numeric) {
if (p.y < lo) lo = p.y;
if (p.y > hi) hi = p.y;
}
if (lo === hi) {
hi = lo + 1;
}
const w = 100;
const h = height - PADDING * 2;
const n = numeric.length;
const d = numeric
.map((p, i) => {
const x = n === 1 ? w / 2 : (i / (n - 1)) * w;
const y = PADDING + h - ((p.y - lo) / (hi - lo)) * h;
return `${i === 0 ? "M" : "L"}${x.toFixed(2)},${y.toFixed(2)}`;
})
.join(" ");
return { path: d, minY: lo, maxY: hi };
}, [numeric, height]);

// The headline value stays exact (formatted from the Decimal, not the float).
const latest = points.length ? points[points.length - 1].value.toFixed() : null;

if (points.length === 0) {
return (
<div
className={`flex items-center justify-center rounded-lg border border-border bg-muted text-sm text-muted-foreground ${
className ?? ""
}`}
style={{ height }}
>
No data
</div>
);
}

return (
<div className={`rounded-lg border border-border bg-background p-3 ${className ?? ""}`}>
<div className="mb-2 flex items-baseline justify-between">
<span className="text-sm font-semibold tabular-nums">
{latest}
{unit && <span className="ml-1 text-xs text-muted-foreground">{unit}</span>}
</span>
<span className="text-[10px] text-muted-foreground tabular-nums">
{minY.toFixed(2)} – {maxY.toFixed(2)}
</span>
</div>
<svg
viewBox={`0 0 100 ${height}`}
preserveAspectRatio="none"
className="w-full"
style={{ height }}
role="img"
aria-label={`Resource time series in ${unit || "units"}`}
>
<path d={path} fill="none" stroke={color} strokeWidth={1.5} vectorEffect="non-scaling-stroke" />
</svg>
</div>
);
}

export default ResourceChart;
111 changes: 111 additions & 0 deletions src/hooks/useResourceData.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
"use client";

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { decimal, type Decimal } from "@/utils/decimal";
import { aggregateReadings } from "@/utils/aggregation";
import { resourceStore, useResourceReadings } from "@/store/slices/resourceSlice";
import {
RESOURCE_PRECISION,
type MeterReading,
type ResourceConsumption,
type ResourceKind,
} from "@/types/meter";

/**
* Fetches metered readings for a resource and converts the API's string-encoded
* decimals into exact {@link Decimal} values at the resource's canonical
* precision before storing them. Values are never parsed as `number`, so no
* precision is lost on ingestion.
*/

/** Shape of a reading as delivered by the REST API (decimals are strings). */
export interface RawReading {
meterId: string;
timestamp: number;
/** String-encoded decimal, e.g. "12.345". */
value: string;
}

export interface UseResourceDataOptions {
resource: ResourceKind;
/** Override the endpoint. Defaults to `/api/meter/readings?resource=`. */
endpoint?: string;
/** Injectable transport for tests. */
fetchFn?: typeof fetch;
/** Fetch on mount. @default true */
enabled?: boolean;
}

export interface UseResourceDataResult {
readings: MeterReading[];
total: Decimal;
consumption: ResourceConsumption;
loading: boolean;
error: string | null;
refetch: () => void;
}

/** Parse a raw API reading into a precision-tagged {@link MeterReading}. */
export function parseRawReading(
resource: ResourceKind,
raw: RawReading
): MeterReading {
return {
meterId: raw.meterId,
resource,
timestamp: raw.timestamp,
value: decimal(raw.value, RESOURCE_PRECISION[resource]) as MeterReading["value"],
};
}

export function useResourceData(
options: UseResourceDataOptions
): UseResourceDataResult {
const { resource, endpoint, fetchFn = fetch, enabled = true } = options;
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);

const readings = useResourceReadings(resource);
const fetchRef = useRef(fetchFn);
fetchRef.current = fetchFn;

const url =
endpoint ?? `/api/meter/readings?resource=${encodeURIComponent(resource)}`;

const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetchRef.current(url, {
headers: { Accept: "application/json" },
});
if (!res.ok) throw new Error(`Readings request failed: HTTP ${res.status}`);
const raw = (await res.json()) as RawReading[];
const parsed = raw.map((r) => parseRawReading(resource, r));
resourceStore.dispatch({ type: "ADD_READINGS", payload: parsed });
} catch (err) {
setError((err as Error).message);
} finally {
setLoading(false);
}
}, [resource, url]);

useEffect(() => {
if (!enabled) return;
void load();
}, [enabled, load]);

const consumption = useMemo(
() => aggregateReadings(resource, readings),
[resource, readings]
);

return {
readings,
total: consumption.total,
consumption,
loading,
error,
refetch: load,
};
}
157 changes: 157 additions & 0 deletions src/store/slices/resourceSlice.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
"use client";

import { useSyncExternalStore } from "react";
import { fromRedux, type Decimal } from "@/utils/decimal";
import { sumReadings } from "@/utils/aggregation";
import {
RESOURCE_PRECISION,
type MeterReading,
type ResourceKind,
type SerializedMeterReading,
} from "@/types/meter";

/**
* Resource consumption store. Readings are persisted in their string-encoded
* {@link SerializedMeterReading} form so the state survives Redux DevTools /
* JSON serialization without floating-point corruption. Decimals are rehydrated
* only when a selector reads them.
*/

const RESOURCE_KINDS: ResourceKind[] = [
"water",
"electricity",
"gas",
"cost",
"submeter",
"nano",
];

export type ResourceState = Record<ResourceKind, SerializedMeterReading[]>;

export type ResourceAction =
| { type: "ADD_READING"; payload: MeterReading }
| { type: "ADD_READINGS"; payload: MeterReading[] }
| { type: "CLEAR_RESOURCE"; payload: { resource: ResourceKind } }
| { type: "RESET" };

function emptyState(): ResourceState {
return RESOURCE_KINDS.reduce((acc, k) => {
acc[k] = [];
return acc;
}, {} as ResourceState);
}

/** Serialize a reading for storage (string-encoded decimal value). */
export function serializeReading(reading: MeterReading): SerializedMeterReading {
return {
meterId: reading.meterId,
resource: reading.resource,
timestamp: reading.timestamp,
value: reading.value.toRedux(),
};
}

/** Rehydrate a stored reading back into a {@link MeterReading}. */
export function deserializeReading(s: SerializedMeterReading): MeterReading {
return {
meterId: s.meterId,
resource: s.resource,
timestamp: s.timestamp,
value: fromRedux(s.value) as MeterReading["value"],
};
}

type Listener = (state: ResourceState) => void;

class ResourceStore {
private state: ResourceState = emptyState();
private listeners = new Set<Listener>();

getState = (): Readonly<ResourceState> => this.state;

subscribe = (listener: Listener): (() => void) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};

dispatch(action: ResourceAction): void {
const next = this.reducer(this.state, action);
if (next !== this.state) {
this.state = next;
this.notify();
}
}

private reducer(state: ResourceState, action: ResourceAction): ResourceState {
switch (action.type) {
case "ADD_READING": {
const r = action.payload;
return {
...state,
[r.resource]: [...state[r.resource], serializeReading(r)],
};
}
case "ADD_READINGS": {
if (action.payload.length === 0) return state;
const next = { ...state };
const grouped = new Map<ResourceKind, SerializedMeterReading[]>();
for (const r of action.payload) {
const list = grouped.get(r.resource) ?? [];
list.push(serializeReading(r));
grouped.set(r.resource, list);
}
for (const [resource, serialized] of grouped) {
next[resource] = [...state[resource], ...serialized];
}
return next;
}
case "CLEAR_RESOURCE":
return { ...state, [action.payload.resource]: [] };
case "RESET":
return emptyState();
default:
return state;
}
}

private notify(): void {
for (const listener of this.listeners) listener(this.state);
}
}

/** Shared singleton resource store. */
export const resourceStore = new ResourceStore();

// --- Selectors --------------------------------------------------------------

/** Rehydrated readings for a resource. */
export function selectReadings(
state: ResourceState,
resource: ResourceKind
): MeterReading[] {
return state[resource].map(deserializeReading);
}

/** Exact total for a resource at its canonical precision. */
export function selectTotal(
state: ResourceState,
resource: ResourceKind
): Decimal {
return sumReadings(selectReadings(state, resource), RESOURCE_PRECISION[resource]);
}

// --- React bindings ---------------------------------------------------------

export function useResourceState(): ResourceState {
return useSyncExternalStore(
resourceStore.subscribe,
resourceStore.getState,
resourceStore.getState
);
}

/** Subscribe to the rehydrated readings for a single resource. */
export function useResourceReadings(resource: ResourceKind): MeterReading[] {
const state = useResourceState();
return selectReadings(state, resource);
}
Loading
Loading