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
91 changes: 82 additions & 9 deletions src/lib/components/displays/AirDisplay/AirDisplay.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script lang="ts">
import {
CwButton,
CwCard,
CwDataTable,
CwDuration,
Expand Down Expand Up @@ -185,12 +186,66 @@
};
});

type HeatmapMetricKey = 'temperature' | 'humidity' | 'co2';

interface HeatmapMetric {
key: HeatmapMetricKey;
label: string;
unit: string;
colors: [string, string, string];
// Returns the cell value, or null to omit the row (rendered as a no-data gap).
value: (row: AirRow) => number | null;
}

// Metrics the heatmap can render from the data already loaded. Temperature and
// humidity are always reported; CO2 only appears when the device actually sent
// readings (see `hasCo2`). Switching metric just re-points the heatmap value —
// no re-fetch.
let heatmapMetrics: HeatmapMetric[] = $derived.by(() => {
const metrics: HeatmapMetric[] = [
{
key: 'temperature',
label: m.rule_subject_temperature(),
unit: '°C',
colors: ['#0ea5e9', '#84cc16', '#f97316'],
value: (row) => row.temperature_c
},
{
key: 'humidity',
label: m.rule_subject_humidity(),
unit: '%',
colors: ['#bae6fd', '#38bdf8', '#1d4ed8'],
value: (row) => row.humidity
}
];
if (hasCo2) {
metrics.push({
key: 'co2',
label: m.rule_subject_co2(),
unit: 'ppm',
colors: ['#84cc16', '#f59e0b', '#dc2626'],
// CO2 is sent on only ~1 of 3 uplinks, so most rows carry no reading
// (co2 === 0). Omit those so the heatmap shows gaps, not fake-low cells.
value: (row) => (row.co2 > 0 ? row.co2 : null)
});
}
return metrics;
});

let selectedMetric = $state<HeatmapMetricKey>('temperature');
// Fall back to the first metric when the selection is unavailable (e.g. CO2 was
// selected then dropped out of the data). `heatmapMetrics` always has ≥1 entry.
let activeMetric = $derived(
heatmapMetrics.find((metric) => metric.key === selectedMetric) ?? heatmapMetrics[0]
);

let heatmapSeries = $derived<(CwHeatmapDataPoint & { timestamp: string })[]>(
rows.map((row) => ({
timestamp: row.created_at,
created_at: row.created_at,
value: row.temperature_c
}))
rows.flatMap((row) => {
const value = activeMetric.value(row);
return value === null
? []
: [{ timestamp: row.created_at, created_at: row.created_at, value }];
})
);

let heatmapDays = $derived.by(() => {
Expand Down Expand Up @@ -340,16 +395,27 @@
</div>

{#if !loading && rows.length > 0}
<div class="air-wind-row" class:air-wind-row--paired={hasWind}>
<CwCard title={m.display_temperature_heatmap()} subtitle={m.display_reading_density()} elevated>
<div class="air-wind-row" class:air-wind-row--paired={hasWind} id="HeatMapDiv">
<CwCard title={activeMetric.label} subtitle={m.display_reading_density()} elevated>
<div class="heatmap-metric-toggle">
{#each heatmapMetrics as metric (metric.key)}
<CwButton
size="sm"
variant={activeMetric.key === metric.key ? 'info' : 'secondary'}
onclick={() => (selectedMetric = metric.key)}
>
{metric.label}
</CwButton>
{/each}
</div>
<CwHeatmap
labels={cwHeatmapLabels()}
data={heatmapSeries}
days={heatmapDays}
unit="°C"
unit={activeMetric.unit}
title=""
rowHeight={18}
colors={['#0ea5e9', '#84cc16', '#f97316']}
colors={activeMetric.colors}
/>
</CwCard>

Expand Down Expand Up @@ -426,6 +492,13 @@
.air-wind-row > :global(*) {
min-width: 0;
}
/* Metric select buttons above the heatmap. */
.heatmap-metric-toggle {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
@media (min-width: 48rem) {
.air-wind-row--paired {
display: grid;
Expand Down
143 changes: 143 additions & 0 deletions src/lib/utils/session-expiry.svelte.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { createSessionExpiryWatcher } from './session-expiry';

// `queueMicrotask` is intentionally left real so the already-expired path can be
// flushed with `await Promise.resolve()`; `setTimeout`/`Date` are faked so the
// scheduling math is deterministic.
const MAX_DELAY_MS = 2_000_000_000;

describe('createSessionExpiryWatcher', () => {
beforeEach(() => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] });
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
});

afterEach(() => {
vi.useRealTimers();
});

const nowSeconds = () => Math.floor(Date.now() / 1000);

it('fires onExpired at the expiry instant, not before', () => {
const onExpired = vi.fn();
const watcher = createSessionExpiryWatcher({
getExpSeconds: () => nowSeconds() + 100,
onExpired,
leadTimeMs: 0
});
watcher.rearm();

vi.advanceTimersByTime(99_000);
expect(onExpired).not.toHaveBeenCalled();

vi.advanceTimersByTime(1_000);
expect(onExpired).toHaveBeenCalledTimes(1);

watcher.destroy();
});

it('fires slightly early by the lead-time buffer', () => {
const onExpired = vi.fn();
const watcher = createSessionExpiryWatcher({
getExpSeconds: () => nowSeconds() + 100,
onExpired,
leadTimeMs: 5_000
});
watcher.rearm();

vi.advanceTimersByTime(95_000);
expect(onExpired).toHaveBeenCalledTimes(1);

watcher.destroy();
});

it('fires immediately (next microtask) when already expired', async () => {
const onExpired = vi.fn();
const watcher = createSessionExpiryWatcher({
getExpSeconds: () => nowSeconds() - 10,
onExpired,
leadTimeMs: 0
});
watcher.rearm();

expect(onExpired).not.toHaveBeenCalled(); // deferred off the synchronous body
await Promise.resolve();
expect(onExpired).toHaveBeenCalledTimes(1);

watcher.destroy();
});

it('does not fire after destroy()', () => {
const onExpired = vi.fn();
const watcher = createSessionExpiryWatcher({
getExpSeconds: () => nowSeconds() + 50,
onExpired,
leadTimeMs: 0
});
watcher.rearm();
watcher.destroy();

vi.advanceTimersByTime(60_000);
expect(onExpired).not.toHaveBeenCalled();
});

it('re-arms to the new expiry when the session changes (re-login)', () => {
const onExpired = vi.fn();
let exp = nowSeconds() + 30;
const watcher = createSessionExpiryWatcher({
getExpSeconds: () => exp,
onExpired,
leadTimeMs: 0
});
watcher.rearm();

// Re-login extends the session before the original timer fires.
exp = nowSeconds() + 300;
watcher.rearm();

vi.advanceTimersByTime(31_000); // past the original expiry
expect(onExpired).not.toHaveBeenCalled();

vi.advanceTimersByTime(270_000); // reach the new expiry
expect(onExpired).toHaveBeenCalledTimes(1);

watcher.destroy();
});

it('does nothing when there is no session', () => {
const onExpired = vi.fn();
const watcher = createSessionExpiryWatcher({
getExpSeconds: () => null,
onExpired
});
watcher.rearm();

vi.advanceTimersByTime(10_000_000);
expect(onExpired).not.toHaveBeenCalled();

watcher.destroy();
});

it('clamps a far-future expiry to avoid the 32-bit setTimeout immediate-fire bug', () => {
const onExpired = vi.fn();
const exp = nowSeconds() + 60 * 60 * 24 * 30; // 30 days out
const watcher = createSessionExpiryWatcher({
getExpSeconds: () => exp,
onExpired,
leadTimeMs: 0
});
watcher.rearm();

// At the clamp boundary it must re-arm, not fire.
vi.advanceTimersByTime(MAX_DELAY_MS);
expect(onExpired).not.toHaveBeenCalled();

// It still fires once the real expiry is finally reached.
const remainingMs = exp * 1000 - Date.now();
vi.advanceTimersByTime(remainingMs);
expect(onExpired).toHaveBeenCalledTimes(1);

watcher.destroy();
});
});
103 changes: 103 additions & 0 deletions src/lib/utils/session-expiry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { onAppForeground } from './onAppForeground';

/**
* Proactively detect JWT session expiry on the client.
*
* The server hook (`src/hooks.server.ts`) is the authoritative session guard,
* but it only runs on a server request/navigation. A user idling on a page — or
* one that only triggers client-side `ApiService` fetches — never causes a
* navigation, so the hook never fires and the session can expire while the page
* keeps showing stale data.
*
* This watcher fills that gap. It schedules a single `setTimeout` for the exact
* instant the token expires (its `exp` claim) and, because background tabs throttle
* timers, also re-checks the moment the tab regains focus via `onAppForeground`.
* When expiry is reached it invokes `onExpired` (which redirects to login).
*
* Re-arm whenever the session changes (re-login / navigation) by calling `rearm()`.
* Safe to construct during SSR (becomes a no-op).
*/

// `setTimeout` stores its delay in a 32-bit int; delays above ~24.8 days wrap and
// fire immediately. Clamp to this ceiling and re-arm instead.
const MAX_DELAY_MS = 2_000_000_000;

export interface SessionExpiryWatcher {
/** Cancel any pending timer and schedule a fresh one from the current expiry. */
rearm: () => void;
/** Clear the timer and detach the foreground listener. */
destroy: () => void;
}

export function createSessionExpiryWatcher(options: {
/** Returns the session expiry as a UNIX timestamp in seconds, or null if no session. */
getExpSeconds: () => number | null | undefined;
/** Called once when the session has expired. */
onExpired: () => void;
/** Fire this many ms before the real expiry to absorb minor clock skew. Default 1000. */
leadTimeMs?: number;
}): SessionExpiryWatcher {
if (typeof window === 'undefined') {
return { rearm: () => {}, destroy: () => {} };
}

const { getExpSeconds, onExpired, leadTimeMs = 1000 } = options;

let handle: ReturnType<typeof setTimeout> | null = null;
let redirecting = false;

const clear = () => {
if (handle) {
clearTimeout(handle);
handle = null;
}
};

const fire = () => {
if (redirecting) return;
redirecting = true;
onExpired();
};

const rearm = () => {
clear();

const exp = getExpSeconds();
if (exp == null) return; // no session (e.g. /auth routes) — nothing to watch

const delayMs = exp * 1000 - leadTimeMs - Date.now();

if (delayMs <= 0) {
// Already expired — defer so we never navigate during a synchronous $effect body.
queueMicrotask(fire);
return;
}

// A future-dated session is valid again (e.g. after re-login): allow a fresh fire.
redirecting = false;

if (delayMs > MAX_DELAY_MS) {
handle = setTimeout(rearm, MAX_DELAY_MS);
return;
}

handle = setTimeout(fire, delayMs);
};

const checkNow = () => {
const exp = getExpSeconds();
if (exp == null) return;
if (exp * 1000 - leadTimeMs - Date.now() <= 0) {
fire();
}
};

const detach = onAppForeground(checkNow);

const destroy = () => {
clear();
detach();
};

return { rearm, destroy };
}
Loading