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
4 changes: 3 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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=/
Expand Down
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
15 changes: 2 additions & 13 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -359,19 +360,7 @@ export default function App() {
</div>
)}

<footer className="border-t border-zinc-800 px-4 py-3 text-center text-xs text-zinc-500">
<a
href="https://github.com/libreble/multimeter"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 hover:text-zinc-300"
>
<svg viewBox="0 0 16 16" aria-hidden="true" className="h-4 w-4 fill-current">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0016 8c0-4.42-3.58-8-8-8z" />
</svg>
Source on GitHub
</a>
</footer>
<AppFooter />

<div aria-live="polite" role="status" className="sr-only">
{announcement}
Expand Down
79 changes: 79 additions & 0 deletions apps/web/src/components/AppFooter.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<footer className="border-t border-zinc-800 px-4 py-3 text-xs text-zinc-500">
<p className="flex flex-wrap items-center justify-center gap-x-4 gap-y-1">
<a href="https://libreble.github.io/" className={LINK}>
<LibrebleMark />
<span>
Part of{' '}
<span className="font-medium">
libre<span className="text-[#c2410c] dark:text-[#ffa259]">ble</span>
</span>
</span>
</a>
<a href={REPO} target="_blank" rel="noopener noreferrer" className={LINK}>
<GitHubMark />
GitHub
</a>
<a
href={`${REPO}/issues/new/choose`}
target="_blank"
rel="noopener noreferrer"
className={LINK}
>
Report an issue
</a>
{RELEASE ? (
<a
href={RELEASE}
target="_blank"
rel="noopener noreferrer"
className={`${LINK} font-mono`}
>
{__APP_VERSION__}
</a>
) : (
<span className="font-mono">{__APP_VERSION__}</span>
)}
</p>
</footer>
);
}

/** The libreble "open beacon": an LED inside two rings opening to the top right. */
function LibrebleMark() {
return (
<svg viewBox="0 0 64 64" aria-hidden="true" className="h-4 w-4">
<g
transform="translate(32 32) rotate(-45) translate(-32 -32)"
fill="none"
stroke="currentColor"
strokeWidth="6"
strokeLinecap="round"
>
<path d="M44.36 40.50A15 15 0 1 1 44.36 23.50" />
<path d="M54.69 42.50A25 25 0 1 1 54.69 21.50" />
</g>
<circle cx="32" cy="32" r="6.5" className="fill-[#f26b1d] dark:fill-[#ff8a3d]" />
</svg>
);
}

function GitHubMark() {
return (
<svg viewBox="0 0 16 16" aria-hidden="true" className="h-4 w-4 fill-current">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0016 8c0-4.42-3.58-8-8-8z" />
</svg>
);
}
39 changes: 38 additions & 1 deletion apps/web/src/components/ReportToast.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<ReportToast meters={meters(live, { describe })} />);
expect(again.container.firstChild).toBeNull();
again.unmount();
const other = render(
<ReportToast meters={meters([channel({ state: 'live', driverId: 'owon-plus' })])} />,
);
expect(other.container.firstChild).toBeNull();
});

it('asks once per install, even across drivers in one session', () => {
const { container, rerender } = render(
<ReportToast meters={meters([channel({ state: 'live', driverId: 'ut181a' })])} />,
);
fireEvent.click(screen.getByRole('button', { name: /dismiss/i }));
expect(container.firstChild).toBeNull();
rerender(<ReportToast meters={meters([channel({ state: 'live', driverId: 'owon-plus' })])} />);
expect(container.firstChild).toBeNull();
});

it('honours a per-driver key left by an older build', () => {
localStorage.setItem('multimeter.reportDone.ut181a', '1');
const { container } = render(
<ReportToast meters={meters([channel({ state: 'live', driverId: 'owon-plus' })])} />,
);
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(<ReportToast meters={live('write failed')} />);
expect(screen.getByText(/trouble with your meter/i)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /dismiss/i }));
expect(container.firstChild).toBeNull();
rerender(<ReportToast meters={live('write failed')} />);
expect(container.firstChild).toBeNull();
rerender(<ReportToast meters={live('GATT operation failed')} />);
expect(screen.getByText(/trouble with your meter/i)).toBeInTheDocument();
});

it('never asks about demo meters', () => {
Expand Down
49 changes: 25 additions & 24 deletions apps/web/src/components/ReportToast.tsx
Original file line number Diff line number Diff line change
@@ -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.<driverId>) — 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<Set<string>>(() => 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
Expand All @@ -74,7 +75,7 @@ export function ReportToast({ meters }: { meters: Meters }) {
if (problem && !problemDismissed) {
return (
<Toast onDismiss={() => setProblemDismissed(true)}>
<p>Trouble finding or connecting your meter?</p>
<p>Trouble with your meter?</p>
<a
href={connectionProblemUrl(problem.error)}
target="_blank"
Expand Down
5 changes: 5 additions & 0 deletions apps/web/src/vite-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,8 @@ declare global {
};
}
}

declare global {
/** Build version for the footer — see appVersion() in vite.config.ts. */
const __APP_VERSION__: string;
}
18 changes: 18 additions & 0 deletions apps/web/vite.config.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
/// <reference types="vitest/config" />
/// <reference types="node" />
import { defineConfig } from 'vitest/config';
import { loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
import { VitePWA } from 'vite-plugin-pwa';
import { execSync } from 'node:child_process';

// Web Bluetooth needs a secure context: localhost (dev) is fine; for phone testing
// over the LAN you'd need HTTPS. `host: true` exposes the dev server on the network.
Expand All @@ -12,6 +14,21 @@ import { VitePWA } from 'vite-plugin-pwa';
// `base` is the repo subpath and the service-worker scope + manifest start_url/scope all
// mirror it. (Switch to '/' + a CNAME only if it ever moves to a dedicated custom domain.)
// Self-hosters override it: `BASE_PATH=/ pnpm build` (the Docker image does this).
// Shown in the footer. Tag builds in CI use the tag (v1.2.0); elsewhere `git describe`
// (v1.2.0-3-gabc1234, or a bare sha on a shallow clone); `APP_VERSION` overrides; no git → "dev".
function appVersion(): string {
if (process.env.APP_VERSION) return process.env.APP_VERSION;
if (process.env.GITHUB_REF_TYPE === 'tag' && process.env.GITHUB_REF_NAME)
return process.env.GITHUB_REF_NAME;
try {
return execSync('git describe --tags --always', { stdio: ['ignore', 'pipe', 'ignore'] })
.toString()
.trim();
} catch {
return 'dev';
}
}

export default defineConfig(({ mode }) => {
const env = loadEnv(mode, '.', '');
const base = `/${(env.BASE_PATH || '/multimeter/').replace(/^\/+|\/+$/g, '')}/`.replace(
Expand All @@ -20,6 +37,7 @@ export default defineConfig(({ mode }) => {
);
return {
base,
define: { __APP_VERSION__: JSON.stringify(appVersion()) },
plugins: [
react(),
tailwindcss(),
Expand Down
Loading
Loading