diff --git a/Dockerfile b/Dockerfile
index 4ceb43b..028b2cd 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -10,7 +10,9 @@ RUN npm install -g corepack && corepack enable
COPY . .
RUN pnpm install --frozen-lockfile
ARG BASE_PATH=/
-RUN BASE_PATH="$BASE_PATH" pnpm --filter web build
+# .git isn't in the build context, so pass the version in: --build-arg APP_VERSION=v1.2.0
+ARG APP_VERSION=dev
+RUN BASE_PATH="$BASE_PATH" APP_VERSION="$APP_VERSION" pnpm --filter web build
FROM nginxinc/nginx-unprivileged:1.29-alpine AS serve
ARG BASE_PATH=/
diff --git a/apps/web/package.json b/apps/web/package.json
index ccef632..d252a0f 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -31,6 +31,7 @@
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
+ "@types/node": "^26.6.2",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@types/web-bluetooth": "^0.0.21",
diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index 560e7c2..70c420b 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -27,6 +27,7 @@ import { ChartColorPicker } from './components/ChartColorPicker';
import { ShortcutsHelp } from './components/ShortcutsHelp';
import { UnsupportedBrowser } from './components/UnsupportedBrowser';
import { ReportToast } from './components/ReportToast';
+import { AppFooter } from './components/AppFooter';
import { exportCsv, exportPng } from './lib/exporters';
// Code-split the uPlot-heavy chart + the whole Recordings view so the initial bundle stays lean.
@@ -359,19 +360,7 @@ export default function App() {
)}
-
+
{announcement}
diff --git a/apps/web/src/components/AppFooter.tsx b/apps/web/src/components/AppFooter.tsx
new file mode 100644
index 0000000..30a7e16
--- /dev/null
+++ b/apps/web/src/components/AppFooter.tsx
@@ -0,0 +1,79 @@
+// One centered line: libreble · GitHub · Report an issue · build version. Colours come from the
+// zinc ramp, which index.css mirrors per theme; the libreble accent opts in via `dark:`.
+
+const REPO = 'https://github.com/libreble/multimeter';
+/** A clean tag build ("v1.2.0") links to its release; anything else is shown as plain text. */
+const RELEASE = /^v\d+\.\d+\.\d+$/.test(__APP_VERSION__)
+ ? `${REPO}/releases/tag/${__APP_VERSION__}`
+ : null;
+
+const LINK = 'inline-flex items-center gap-1.5 hover:text-zinc-300';
+
+export function AppFooter() {
+ return (
+
+ );
+}
+
+/** The libreble "open beacon": an LED inside two rings opening to the top right. */
+function LibrebleMark() {
+ return (
+
+ );
+}
+
+function GitHubMark() {
+ return (
+
+ );
+}
diff --git a/apps/web/src/components/ReportToast.test.tsx b/apps/web/src/components/ReportToast.test.tsx
index ec1fbf4..9dcc50d 100644
--- a/apps/web/src/components/ReportToast.test.tsx
+++ b/apps/web/src/components/ReportToast.test.tsx
@@ -67,9 +67,46 @@ describe('ReportToast', () => {
open.mockRestore();
unmount();
- // Remembered per driver: a later session doesn't ask again.
+ // Remembered per install: a later session doesn't ask again — not even for another driver.
const again = render();
expect(again.container.firstChild).toBeNull();
+ again.unmount();
+ const other = render(
+ ,
+ );
+ expect(other.container.firstChild).toBeNull();
+ });
+
+ it('asks once per install, even across drivers in one session', () => {
+ const { container, rerender } = render(
+ ,
+ );
+ fireEvent.click(screen.getByRole('button', { name: /dismiss/i }));
+ expect(container.firstChild).toBeNull();
+ rerender();
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('honours a per-driver key left by an older build', () => {
+ localStorage.setItem('multimeter.reportDone.ut181a', '1');
+ const { container } = render(
+ ,
+ );
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('offers a problem report for an error surfaced while connected, re-armed by a new error', () => {
+ localStorage.setItem('multimeter.reportDone', '1');
+ const live = (error: string | null) =>
+ meters([channel({ state: 'live', driverId: 'uni-t', error })]);
+ const { container, rerender } = render();
+ expect(screen.getByText(/trouble with your meter/i)).toBeInTheDocument();
+ fireEvent.click(screen.getByRole('button', { name: /dismiss/i }));
+ expect(container.firstChild).toBeNull();
+ rerender();
+ expect(container.firstChild).toBeNull();
+ rerender();
+ expect(screen.getByText(/trouble with your meter/i)).toBeInTheDocument();
});
it('never asks about demo meters', () => {
diff --git a/apps/web/src/components/ReportToast.tsx b/apps/web/src/components/ReportToast.tsx
index 7ed2f0f..bcc0c7a 100644
--- a/apps/web/src/components/ReportToast.tsx
+++ b/apps/web/src/components/ReportToast.tsx
@@ -1,57 +1,58 @@
// A dismissible, non-blocking toast inviting a device report (see lib/report.ts). Two cases:
// * a real meter is live on a driver not yet confirmed on hardware → "how is it working?". Dismissing
-// or reporting is remembered per driver, so it asks once.
-// * the chooser was dismissed or connecting failed → "trouble finding or connecting?".
-// Dismissing hides it until the next cancel/failure.
+// or reporting is remembered once per install, whatever the driver — a working meter shouldn't nag.
+// * the chooser was dismissed or any error (connect, reconnect, or one surfaced while live) →
+// "trouble with your meter?". Dismissing hides it until the next cancel/error.
// Both open a pre-filled GitHub issue form in a new tab; the user reviews and submits it there.
import { useEffect, useState } from 'react';
import type { Meters } from '@libreble/multimeter-react';
import { connectionProblemUrl, deviceReportUrl, verification } from '../lib/report';
-const DONE_KEY = (driverId: string) => `multimeter.reportDone.${driverId}`;
+const DONE_KEY = 'multimeter.reportDone';
-function isDone(driverId: string): boolean {
+function isDone(): boolean {
try {
- return localStorage.getItem(DONE_KEY(driverId)) !== null;
+ // Older builds stored one key per driver (multimeter.reportDone.) — honour those.
+ return Object.keys(localStorage).some(k => k === DONE_KEY || k.startsWith(`${DONE_KEY}.`));
} catch {
return false;
}
}
-function markDone(driverId: string): void {
+function markDone(): void {
try {
- localStorage.setItem(DONE_KEY(driverId), '1');
+ localStorage.setItem(DONE_KEY, '1');
} catch {
/* storage unavailable — it may ask again next time */
}
}
export function ReportToast({ meters }: { meters: Meters }) {
- const [done, setDone] = useState>(() => new Set());
+ const [doneNow, setDoneNow] = useState(false);
const [problemDismissed, setProblemDismissed] = useState(false);
const real = meters.meters.filter(c => !meters.meterSession(c.id)?.isDemo);
- const unconfirmed = real.find(
- c =>
- c.state === 'live' &&
- c.driverId !== null &&
- verification(c.driverId) !== 'live-tested' &&
- !done.has(c.driverId) &&
- !isDone(c.driverId),
- );
- const problem = real.find(c => c.cancelled || c.state === 'error');
+ const unconfirmed =
+ !doneNow && !isDone()
+ ? real.find(
+ c =>
+ c.state === 'live' && c.driverId !== null && verification(c.driverId) !== 'live-tested',
+ )
+ : undefined;
+ const problem = real.find(c => c.cancelled || c.state === 'error' || c.error !== null);
+ // Which problem is showing: re-arm once it clears, or when a different error replaces it.
+ const problemKey = problem ? `${problem.id}\n${problem.error ?? 'cancelled'}` : null;
- // A new cancel/failure after a successful attempt shows the toast again.
useEffect(() => {
- if (!problem) setProblemDismissed(false);
- }, [problem]);
+ setProblemDismissed(false);
+ }, [problemKey]);
if (unconfirmed) {
const driverId = unconfirmed.driverId!;
const finish = () => {
- markDone(driverId);
- setDone(d => new Set(d).add(driverId));
+ markDone();
+ setDoneNow(true);
};
// Read the GATT description only on click: a few best-effort Device Information reads that
// shouldn't sit in the connect path. Transient user activation outlives them, so the new tab
@@ -74,7 +75,7 @@ export function ReportToast({ meters }: { meters: Meters }) {
if (problem && !problemDismissed) {
return (
setProblemDismissed(true)}>
-