From 1aa729aaeecc714af19096e050cacf4b350aa0a6 Mon Sep 17 00:00:00 2001 From: mannes Date: Thu, 24 Sep 2026 09:11:15 +0200 Subject: [PATCH] Invite device reports via pre-filled GitHub issues A dismissible toast, never in the way: - a brush that isn't an iO (the one confirmed line) connects: asks once per model whether it works and opens the device-report issue form pre-filled with model id, protocol, firmware and the GATT layout. No brushing data, no characteristic values, no raw frames. - the chooser is dismissed or connecting fails: offers the connection-problem form. The app sends nothing itself; the user reviews and submits on GitHub. --- .github/ISSUE_TEMPLATE/connection-problem.yml | 42 +++++++ .github/ISSUE_TEMPLATE/device-report.yml | 45 ++++++++ README.md | 7 ++ src/App.tsx | 10 ++ src/components/ReportToast.tsx | 103 ++++++++++++++++++ src/hooks/useBrush.ts | 6 +- src/lib/report.ts | 52 +++++++++ 7 files changed, 264 insertions(+), 1 deletion(-) create mode 100644 .github/ISSUE_TEMPLATE/connection-problem.yml create mode 100644 .github/ISSUE_TEMPLATE/device-report.yml create mode 100644 src/components/ReportToast.tsx create mode 100644 src/lib/report.ts diff --git a/.github/ISSUE_TEMPLATE/connection-problem.yml b/.github/ISSUE_TEMPLATE/connection-problem.yml new file mode 100644 index 0000000..63969ce --- /dev/null +++ b/.github/ISSUE_TEMPLATE/connection-problem.yml @@ -0,0 +1,42 @@ +name: Connection problem +description: Your brush isn't in the list, or it won't connect. +title: 'Connection problem: ' +labels: ['connection problem'] +body: + - type: markdown + attributes: + value: | + Sorry it didn't work. Tell us what you have — knowing which brushes don't connect is the + first step to supporting them. + + Tip: most brushes only advertise while awake. Press the power button (or pick the brush up) + right before connecting. + - type: input + id: model + attributes: + label: Brush model + description: As printed on the handle or box. + placeholder: Oral-B iO Series 7 + validations: + required: true + - type: dropdown + id: problem + attributes: + label: What happened? + options: + - My brush isn't in the list + - It's in the list, but won't connect + - It connects, then drops + validations: + required: true + - type: textarea + id: details + attributes: + label: Anything else? + description: Did it show up under a different name? Is it paired with the vendor app on another phone? + - type: textarea + id: environment + attributes: + label: Environment + description: Filled in by the app. Review it before submitting. + render: text diff --git a/.github/ISSUE_TEMPLATE/device-report.yml b/.github/ISSUE_TEMPLATE/device-report.yml new file mode 100644 index 0000000..2259f08 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/device-report.yml @@ -0,0 +1,45 @@ +name: Device report +description: Tell us how your brush works with Brushlog — this is how models get confirmed. +title: 'Device report: ' +labels: ['device report'] +body: + - type: markdown + attributes: + value: | + Thanks! Brushlog is confirmed on the Oral-B iO; older Genius / SmartSeries / Smart brushes + are supported from prior art but untested. Your report is how they get confirmed (or fixed). + + If you opened this from the app, the **Connection** box below is already filled in with + your brush's model identifiers and Bluetooth layout — no brushing data. If we need more to + fix something, we'll ask for the in-app Diagnostics report in the thread. + - type: input + id: model + attributes: + label: Brush model + description: As printed on the handle or box. + placeholder: Oral-B iO Series 7 + validations: + required: true + - type: dropdown + id: works + attributes: + label: Does it work? + multiple: true + options: + - Live view works (timer, mode, pressure) + - History sync works + - Brush-head wear shows up + - Something is wrong (tell us below) + validations: + required: true + - type: textarea + id: details + attributes: + label: What did you see? + description: Anything missing, wrong or odd — e.g. a mode shown under the wrong name. + - type: textarea + id: connection + attributes: + label: Connection + description: Filled in by the app. Review it before submitting. + render: text diff --git a/README.md b/README.md index 1f6f9d7..0dd160f 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,13 @@ brush-head wear. Older **Genius / SmartSeries / Smart** lines are covered from p but not tested here. The client feature-detects every characteristic, so an unknown model connects and exposes whatever it supports — the in-app Diagnostics panel shows what that is. +Have a brush other than an iO? When it connects, Brushlog asks how it went and opens a +pre-filled [device report](https://github.com/libreble/brushlog/issues/new?template=device-report.yml) +— model identifiers and Bluetooth layout, no brushing data. If your brush isn't in the list or +won't connect, dismissing the chooser offers a +[connection-problem report](https://github.com/libreble/brushlog/issues/new?template=connection-problem.yml). +Nothing is sent from the app; you review and submit the issue on GitHub. + ## Privacy No network calls, no analytics, no accounts. Your data lives in this browser only; export it to diff --git a/src/App.tsx b/src/App.tsx index d6dcb6c..33a622e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,6 +9,7 @@ import type { StoredSession } from './lib/session.ts'; import { ConnectBar } from './components/ConnectBar.tsx'; import { LiveSession } from './components/LiveSession.tsx'; import { DiagnosticsPanel } from './components/DiagnosticsPanel.tsx'; +import { ReportToast } from './components/ReportToast.tsx'; import { StatGrid } from './components/StatGrid.tsx'; import { TrendChart } from './components/TrendChart.tsx'; import { Insights } from './components/Insights.tsx'; @@ -104,6 +105,15 @@ export default function App() { )} + + {selected && ( setSelected(null)} goalDurationS={goalDurationS} /> )} diff --git a/src/components/ReportToast.tsx b/src/components/ReportToast.tsx new file mode 100644 index 0000000..bf6044e --- /dev/null +++ b/src/components/ReportToast.tsx @@ -0,0 +1,103 @@ +import { useEffect, useState } from 'react'; +import type { ConnState } from '../hooks/useBrush.ts'; +import type { DeviceInfo, DiscoveredService } from '../protocol/types.ts'; +import { connectionProblemUrl, deviceReportUrl, isConfirmedModel } from '../lib/report.ts'; + +interface Props { + state: ConnState; + cancelled: boolean; + error: string | null; + device: DeviceInfo | null; + discovery: DiscoveredService[] | null; + /** Lift above the bottom-centre "session saved" toast while that's showing. */ + raised: boolean; +} + +const doneKey = (d: DeviceInfo) => `brushlog.reportDone.${d.modelId}-${d.protocolVersion ?? 'x'}`; + +function isDone(d: DeviceInfo): boolean { + try { + return localStorage.getItem(doneKey(d)) !== null; + } catch { + return false; + } +} + +/** + * A dismissible, non-blocking toast inviting a device report (see lib/report.ts): + * - a brush that isn't an iO connected → "does it work?", once per model (dismiss or report); + * - the chooser was dismissed or connecting failed → "not in the list, or won't connect?", + * hidden on dismiss until the next cancel/failure. + * Both open a pre-filled GitHub issue form; the user reviews and submits it there. + */ +export function ReportToast({ state, cancelled, error, device, discovery, raised }: Props) { + const [doneNow, setDoneNow] = useState(false); + const [problemDismissed, setProblemDismissed] = useState(false); + const problem = cancelled || state === 'error'; + + useEffect(() => { + if (!problem) setProblemDismissed(false); + }, [problem]); + + // Wait for discovery so the report carries the GATT layout. + const ask = + state === 'connected' && device && discovery && !isConfirmedModel(device) && !doneNow && !isDone(device); + + if (ask) { + const finish = () => { + try { + localStorage.setItem(doneKey(device), '1'); + } catch { /* storage unavailable — it may ask again next time */ } + setDoneNow(true); + }; + return ( + +

Brushlog is only confirmed on the Oral-B iO so far. Does your brush work?

+ + Tell us how it went + +
+ ); + } + + if (problem && !problemDismissed && state !== 'connected') { + return ( + setProblemDismissed(true)}> +

Brush not in the list, or won't connect?

+ setProblemDismissed(true)} + className={ACTION} + > + Tell us which one + +
+ ); + } + + return null; +} + +const ACTION = 'mt-2 inline-block rounded-lg bg-btn px-3 py-1.5 text-sm font-medium text-on-btn hover:bg-btn-hover'; + +function Toast({ children, raised, onDismiss }: { children: React.ReactNode; raised: boolean; onDismiss: () => void }) { + return ( +
+ {children} + +
+ ); +} diff --git a/src/hooks/useBrush.ts b/src/hooks/useBrush.ts index b36ed1f..747b0ab 100644 --- a/src/hooks/useBrush.ts +++ b/src/hooks/useBrush.ts @@ -40,6 +40,8 @@ export function useBrush({ onSync, lastSyncedTimestamp }: UseBrushOptions) { const [live, setLive] = useState(null); const [device, setDevice] = useState(null); const [error, setError] = useState(null); + /** The last manual connect ended with the chooser dismissed — offer a connection-problem report. */ + const [cancelled, setCancelled] = useState(false); const [syncing, setSyncing] = useState(false); const [syncProgress, setSyncProgress] = useState(0); const [discovery, setDiscovery] = useState(null); @@ -128,6 +130,7 @@ export function useBrush({ onSync, lastSyncedTimestamp }: UseBrushOptions) { if (state === 'unsupported' || state === 'insecure') return; pausedRef.current = false; // a manual connect re-enables auto-reconnect setError(null); + setCancelled(false); setState('connecting'); setRawLog([]); try { @@ -139,6 +142,7 @@ export function useBrush({ onSync, lastSyncedTimestamp }: UseBrushOptions) { const msg = e instanceof Error ? e.message : String(e); if (e instanceof DOMException && e.name === 'NotFoundError') { setState('idle'); + setCancelled(true); } else { setError(msg); setState('error'); @@ -198,5 +202,5 @@ export function useBrush({ onSync, lastSyncedTimestamp }: UseBrushOptions) { useEffect(() => cleanup, [cleanup]); - return { state, live, device, error, syncing, syncProgress, discovery, rawLog, connect, disconnect, sync }; + return { state, live, device, error, cancelled, syncing, syncProgress, discovery, rawLog, connect, disconnect, sync }; } diff --git a/src/lib/report.ts b/src/lib/report.ts new file mode 100644 index 0000000..8d73bb2 --- /dev/null +++ b/src/lib/report.ts @@ -0,0 +1,52 @@ +// Device reports: pre-filled GitHub issue forms (.github/ISSUE_TEMPLATE/) the user reviews and +// submits themselves. Nothing is sent from the app — it only opens a URL. The summary carries +// model identifiers and the GATT layout, never brushing data, characteristic values or raw +// frames (we ask for a Diagnostics report in the thread if we need one). + +import type { DeviceInfo, DiscoveredService } from '../protocol/types.ts'; + +const ISSUES = 'https://github.com/libreble/brushlog/issues/new'; + +/** The iO (protocol V007) is the one line confirmed on hardware; everything else is prior art. */ +export function isConfirmedModel(device: DeviceInfo | null): boolean { + return device?.protocolVersion === 7; +} + +function browser(): string { + return typeof navigator === 'undefined' ? 'unknown' : navigator.userAgent; +} + +function issueUrl(template: string, title: string, fields: Record): string { + return `${ISSUES}?${new URLSearchParams({ template, title, ...fields })}`; +} + +export function connectedSummary(device: DeviceInfo | null, discovery: DiscoveredService[] | null): string { + const lines = [ + 'app: Brushlog', + `browser: ${browser()}`, + `advertised name: ${device?.name ?? 'unknown'}`, + `model id: ${device?.modelId ?? '?'} · protocol: ${device?.protocolVersion ?? '?'} · firmware: ${device?.firmwareVersion ?? '?'}`, + ]; + if (discovery) { + lines.push('services:'); + for (const s of discovery) { + lines.push(` ${s.uuid}${s.known ? ' [known]' : ''}`); + for (const c of s.characteristics) lines.push(` ${c.uuid} [${c.properties.join(',')}]`); + } + } + return lines.join('\n'); +} + +/** Issue form for a brush that connected: does it work? */ +export function deviceReportUrl(device: DeviceInfo | null, discovery: DiscoveredService[] | null): string { + return issueUrl('device-report.yml', `Device report: ${device?.name ?? 'my brush'}`, { + connection: connectedSummary(device, discovery), + }); +} + +/** Issue form for a brush that isn't in the chooser or won't connect. */ +export function connectionProblemUrl(error: string | null): string { + const lines = ['app: Brushlog', `browser: ${browser()}`]; + if (error) lines.push(`error: ${error}`); + return issueUrl('connection-problem.yml', 'Connection problem: ', { environment: lines.join('\n') }); +}