diff --git a/src/common/RelativeTimestamp.tsx b/src/common/RelativeTimestamp.tsx
new file mode 100644
index 0000000..811ded6
--- /dev/null
+++ b/src/common/RelativeTimestamp.tsx
@@ -0,0 +1,38 @@
+import { Typography } from "@mui/material";
+import moment from "moment";
+import { useEffect, useState } from "react";
+
+interface Props {
+ /** RFC3339 timestamp string */
+ timestamp: string;
+}
+
+/**
+ * Displays a timestamp as relative time (e.g. "a minute ago", "5 seconds ago").
+ * Uses a subtle, muted typography style.
+ * Refreshes every 60 seconds to keep the relative text current.
+ */
+export default function RelativeTimestamp({ timestamp }: Props) {
+ const [, setTick] = useState(0);
+
+ useEffect(() => {
+ const interval = setInterval(() => setTick((t) => t + 1), 60000);
+ return () => clearInterval(interval);
+ }, []);
+
+ const date = moment(timestamp);
+ if (!date.isValid()) return null;
+
+ return (
+
+ {date.fromNow()}
+
+ );
+}
diff --git a/src/common/StatusDust.tsx b/src/common/StatusDust.tsx
index a7e8e3a..4963269 100644
--- a/src/common/StatusDust.tsx
+++ b/src/common/StatusDust.tsx
@@ -3,10 +3,13 @@ import { green } from "@mui/material/colors";
import { makeStyles } from "@mui/styles";
import { Device } from "models";
import PulseBox from "./PulseBox";
+import RelativeTimestamp from "./RelativeTimestamp";
import { or } from "utils";
import { useEffect, useState } from "react";
+
interface Props {
- device: Device
+ device: Device;
+ showTimestamp?: boolean;
}
const useStyles = makeStyles((theme: Theme) => {
@@ -48,7 +51,7 @@ const useStyles = makeStyles((theme: Theme) => {
})
export default function StatusDust(props: Props) {
- const { device } = props;
+ const { device, showTimestamp } = props;
const classes = useStyles()
const colors = or(device.status.sen5x?.overlays.map(overlay => overlay.color), []);
@@ -165,6 +168,11 @@ export default function StatusDust(props: Props) {
+ {showTimestamp && device.status.sen5x?.timestamp && (
+
+
+
+ )}
diff --git a/src/common/StatusGas.tsx b/src/common/StatusGas.tsx
index dcf7ed3..4571278 100644
--- a/src/common/StatusGas.tsx
+++ b/src/common/StatusGas.tsx
@@ -3,13 +3,14 @@ import { blue, deepOrange, teal } from "@mui/material/colors";
import { makeStyles } from "@mui/styles";
import { Device } from "models";
import PulseBox from "./PulseBox";
+import RelativeTimestamp from "./RelativeTimestamp";
import { or } from "utils";
import { useEffect, useState } from "react";
interface Props {
- device: Device
- // noDust?: boolean;
- gasType: "co2" | "co" | "no2" | "o2" | "all"
+ device: Device;
+ gasType: "co2" | "co" | "no2" | "o2" | "all";
+ showTimestamp?: boolean;
}
const useStyles = makeStyles((theme: Theme) => {
@@ -61,7 +62,7 @@ const useStyles = makeStyles((theme: Theme) => {
})
export default function StatusGas(props: Props) {
- const { device, gasType } = props;
+ const { device, gasType, showTimestamp } = props;
const classes = useStyles()
// console.log(noDust)
let colors: string[] = []
@@ -203,6 +204,11 @@ export default function StatusGas(props: Props) {
Volt: {device.status[gas]?.millivolts.toFixed(1)}mV
+ {showTimestamp && device.status[gas]?.timestamp && (
+
+
+
+ )}
diff --git a/src/common/StatusPlenum.tsx b/src/common/StatusPlenum.tsx
index fe2b00d..250660a 100644
--- a/src/common/StatusPlenum.tsx
+++ b/src/common/StatusPlenum.tsx
@@ -3,11 +3,13 @@ import { blue, orange } from "@mui/material/colors";
import { makeStyles } from "@mui/styles";
import { Device } from "models";
import PulseBox from "./PulseBox";
+import RelativeTimestamp from "./RelativeTimestamp";
import { or } from "utils";
import { useEffect, useState } from "react";
interface Props {
- device: Device
+ device: Device;
+ showTimestamp?: boolean;
}
const useStyles = makeStyles((theme: Theme) => {
@@ -25,7 +27,7 @@ const useStyles = makeStyles((theme: Theme) => {
})
export default function StatusPlenum(props: Props) {
- const { device } = props;
+ const { device, showTimestamp } = props;
const classes = useStyles()
const colors = or(device.status.plenum?.overlays.map(overlay => overlay.color), []);
@@ -117,6 +119,11 @@ export default function StatusPlenum(props: Props) {
{device.status.plenum?.humidity.toFixed(1)}%
+ {showTimestamp && device.status.plenum?.timestamp && (
+
+
+
+ )}
diff --git a/src/common/StatusSen5x.tsx b/src/common/StatusSen5x.tsx
index fad0ecb..533fde2 100644
--- a/src/common/StatusSen5x.tsx
+++ b/src/common/StatusSen5x.tsx
@@ -3,12 +3,14 @@ import { blue, green, orange } from "@mui/material/colors";
import { makeStyles } from "@mui/styles";
import { Device } from "models";
import PulseBox from "./PulseBox";
+import RelativeTimestamp from "./RelativeTimestamp";
import { or } from "utils";
import { useEffect, useState } from "react";
interface Props {
- device: Device
+ device: Device;
noDust?: boolean;
+ showTimestamp?: boolean;
}
const useStyles = makeStyles((theme: Theme) => {
@@ -50,7 +52,7 @@ const useStyles = makeStyles((theme: Theme) => {
})
export default function StatusSen5x(props: Props) {
- const { device, noDust } = props;
+ const { device, noDust, showTimestamp } = props;
const classes = useStyles()
// console.log(noDust)
const colors = or(device.status.sen5x?.overlays.map(overlay => overlay.color), []);
@@ -200,6 +202,11 @@ export default function StatusSen5x(props: Props) {
}
+ {showTimestamp && device.status.sen5x?.timestamp && (
+
+
+
+ )}
diff --git a/src/hooks/useDeviceStatusStreams.ts b/src/hooks/useDeviceStatusStreams.ts
new file mode 100644
index 0000000..24ca8bb
--- /dev/null
+++ b/src/hooks/useDeviceStatusStreams.ts
@@ -0,0 +1,138 @@
+import { pond } from "protobuf-ts/pond";
+import { useEffect, useRef, useCallback, useMemo } from "react";
+
+/**
+ * Derives the WebSocket base URL from VITE_APP_API_URL.
+ * Matches the logic in useWebSocket.ts.
+ */
+function getWsBaseUrl(): string {
+ const apiUrl = import.meta.env.VITE_APP_API_URL;
+ if (apiUrl) {
+ return apiUrl.replace(/^http/, "ws");
+ }
+ const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
+ return `${protocol}//${window.location.host}`;
+}
+
+export interface UseDeviceStatusStreamsOptions {
+ /** Device IDs to stream status for (e.g. from the visible devices list) */
+ deviceIds: string[];
+ /** Called when a device status update is received */
+ onStatusUpdate: (deviceId: string, status: pond.DeviceStatus) => void;
+ /** Auth token passed as ?token= query param */
+ token?: string;
+ /** Whether the connections should be active. Default true. */
+ enabled?: boolean;
+}
+
+/**
+ * Subscribes to live device status streams for multiple devices.
+ * Uses the /v1/live/devices/:device/status endpoint per device.
+ * Readings (plenum, sen5x, co, co2, no2, o2, etc.) stream in real time.
+ */
+export function useDeviceStatusStreams(options: UseDeviceStatusStreamsOptions) {
+ const { deviceIds, onStatusUpdate, token, enabled = true } = options;
+
+ const onStatusUpdateRef = useRef(onStatusUpdate);
+ onStatusUpdateRef.current = onStatusUpdate;
+
+ const wsMapRef = useRef