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
42 changes: 42 additions & 0 deletions .github/ISSUE_TEMPLATE/connection-problem.yml
Original file line number Diff line number Diff line change
@@ -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
45 changes: 45 additions & 0 deletions .github/ISSUE_TEMPLATE/device-report.yml
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -104,6 +105,15 @@ export default function App() {
</div>
)}

<ReportToast
state={brush.state}
cancelled={brush.cancelled}
error={brush.error}
device={brush.device}
discovery={brush.discovery}
raised={savedToast !== null}
/>

{selected && (
<SessionDetail session={selected} onClose={() => setSelected(null)} goalDurationS={goalDurationS} />
)}
Expand Down
103 changes: 103 additions & 0 deletions src/components/ReportToast.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Toast raised={raised} onDismiss={finish}>
<p>Brushlog is only confirmed on the Oral-B iO so far. Does your brush work?</p>
<a href={deviceReportUrl(device, discovery)} target="_blank" rel="noopener noreferrer" onClick={finish} className={ACTION}>
Tell us how it went
</a>
</Toast>
);
}

if (problem && !problemDismissed && state !== 'connected') {
return (
<Toast raised={raised} onDismiss={() => setProblemDismissed(true)}>
<p>Brush not in the list, or won't connect?</p>
<a
href={connectionProblemUrl(error)}
target="_blank"
rel="noopener noreferrer"
onClick={() => setProblemDismissed(true)}
className={ACTION}
>
Tell us which one
</a>
</Toast>
);
}

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 (
<div
role="status"
className={`fixed inset-x-4 z-20 ml-auto max-w-sm rounded-2xl border border-line bg-surface p-4 pr-10 text-sm text-fg-secondary shadow-lg transition-[bottom] ${raised ? 'bottom-20' : 'bottom-4'}`}
>
{children}
<button
type="button"
onClick={onDismiss}
aria-label="Dismiss"
title="Dismiss"
className="absolute right-2 top-2 flex h-7 w-7 items-center justify-center rounded-lg text-fg-muted hover:bg-surface-hover hover:text-fg"
>
<span aria-hidden>✕</span>
</button>
</div>
);
}
6 changes: 5 additions & 1 deletion src/hooks/useBrush.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ export function useBrush({ onSync, lastSyncedTimestamp }: UseBrushOptions) {
const [live, setLive] = useState<LiveState | null>(null);
const [device, setDevice] = useState<DeviceInfo | null>(null);
const [error, setError] = useState<string | null>(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<DiscoveredService[] | null>(null);
Expand Down Expand Up @@ -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 {
Expand All @@ -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');
Expand Down Expand Up @@ -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 };
}
52 changes: 52 additions & 0 deletions src/lib/report.ts
Original file line number Diff line number Diff line change
@@ -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, string>): 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') });
}
Loading