Skip to content

Commit 7b3be48

Browse files
Kjubikstronkclaude
andcommitted
Add a bug / suggestion button
Either of you can report something broken or float an idea, and both see the list — so it's a loop rather than a shout into the void. Reports can be ticked off as sorted, reopened, or binned. Each report carries the context nobody thinks to include: screen size, which tab you were on, browser and device, and the time. "The map went blank" is a much better report as "the map went blank, map tab, 390x844, iPhone". What gets attached is spelled out in the form rather than collected quietly. Reports live in their own Firestore collection, so the catch-all deny at the bottom of the rules needs an explicit entry for them — which is exactly what that catch-all is for. Until those rules are published, sending fails, and it now says so instead of appearing to do nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent da1b20b commit 7b3be48

4 files changed

Lines changed: 390 additions & 9 deletions

File tree

‎firestore.rules‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,16 @@ service cloud.firestore {
4646
&& request.resource.data.createdBy == resource.data.createdBy;
4747
}
4848

49+
// Bug reports and suggestions. Either of you can tick one off or bin it,
50+
// so `done` is freely updatable — but authorship still can't be rewritten.
51+
match /reports/{reportId} {
52+
allow read, delete: if isUs();
53+
allow create: if isUs()
54+
&& request.resource.data.createdBy == request.auth.uid;
55+
allow update: if isUs()
56+
&& request.resource.data.createdBy == resource.data.createdBy;
57+
}
58+
4959
// Anything not named above is denied. Adding a collection later means
5060
// adding a rule for it here, on purpose.
5161
match /{document=**} {

‎src/App.tsx‎

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { Suspense, lazy } from 'react'
1+
import { Suspense, lazy, useState } from 'react'
22
import Device from './components/Device'
33
import PixelHeart from './components/PixelHeart'
4+
import ReportSheet from './components/ReportSheet'
45
import Login from './screens/Login'
56
import { AuthProvider, useAuth } from './lib/auth'
67
import { isConfigured } from './lib/firebase'
@@ -29,15 +30,28 @@ export default function App() {
2930
*/
3031
function Gate() {
3132
const { user, loading, leave } = useAuth()
33+
// Counter, not a boolean — a <dialog> can close itself without telling React.
34+
const [reportReq, setReportReq] = useState(0)
35+
const reportButton = (
36+
<button
37+
type="button"
38+
onClick={() => setReportReq((n) => n + 1)}
39+
className="pixel-btn legend px-2 py-1"
40+
title="Report a bug or suggest something"
41+
>
42+
bug?
43+
</button>
44+
)
3245

3346
// Dev-only UI preview. `PREVIEW` is hard-wired to false in any production
3447
// build, so this branch cannot exist on the deployed site.
3548
if (PREVIEW) {
3649
return (
37-
<Device status={<span className="legend px-2 py-1">preview</span>}>
50+
<Device status={reportButton}>
3851
<Suspense fallback={<Booting />}>
3952
<Home />
4053
</Suspense>
54+
<ReportSheet openRequest={reportReq} onClose={() => setReportReq(0)} />
4155
</Device>
4256
)
4357
}
@@ -69,18 +83,22 @@ function Gate() {
6983
return (
7084
<Device
7185
status={
72-
<button
73-
type="button"
74-
onClick={() => void leave()}
75-
className="pixel-btn legend px-3 py-1"
76-
>
77-
leave
78-
</button>
86+
<>
87+
{reportButton}
88+
<button
89+
type="button"
90+
onClick={() => void leave()}
91+
className="pixel-btn legend px-3 py-1"
92+
>
93+
leave
94+
</button>
95+
</>
7996
}
8097
>
8198
<Suspense fallback={<Booting />}>
8299
<Home />
83100
</Suspense>
101+
<ReportSheet openRequest={reportReq} onClose={() => setReportReq(0)} />
84102
</Device>
85103
)
86104
}

‎src/components/ReportSheet.tsx‎

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
import { useEffect, useRef, useState } from 'react'
2+
import { captureContext, useReports, type Report } from '../lib/reports'
3+
4+
type Props = {
5+
/** Counter, not a boolean — see the note on EditSheet's `openRequest`. */
6+
openRequest: number
7+
onClose: () => void
8+
}
9+
10+
const KINDS: [Report['kind'], string][] = [
11+
['bug', 'something broke'],
12+
['idea', 'wouldn’t it be nice'],
13+
]
14+
15+
export default function ReportSheet({ openRequest, onClose }: Props) {
16+
const open = openRequest > 0
17+
const ref = useRef<HTMLDialogElement>(null)
18+
const textRef = useRef<HTMLTextAreaElement>(null)
19+
20+
const { items, send, setDone, remove } = useReports()
21+
const [kind, setKind] = useState<Report['kind']>('bug')
22+
const [text, setText] = useState('')
23+
const [sent, setSent] = useState(false)
24+
const [failed, setFailed] = useState(false)
25+
26+
useEffect(() => {
27+
if (!open) return
28+
setKind('bug')
29+
setText('')
30+
setSent(false)
31+
setFailed(false)
32+
}, [openRequest, open])
33+
34+
useEffect(() => {
35+
const el = ref.current
36+
if (!el) return
37+
if (open) {
38+
if (!el.open) el.showModal()
39+
textRef.current?.focus()
40+
} else if (el.open) {
41+
el.close()
42+
}
43+
}, [openRequest, open])
44+
45+
const openReports = items.filter((r) => !r.done)
46+
const doneReports = items.filter((r) => r.done)
47+
48+
async function submit(e: React.FormEvent) {
49+
e.preventDefault()
50+
if (!text.trim()) return
51+
try {
52+
await send(kind, text)
53+
setText('')
54+
setSent(true)
55+
setFailed(false)
56+
} catch {
57+
// Almost always means the `reports` rules haven't been published yet.
58+
// Failing visibly beats a form that appears to do nothing.
59+
setFailed(true)
60+
setSent(false)
61+
}
62+
}
63+
64+
return (
65+
<dialog ref={ref} className="sheet" onClose={onClose}>
66+
<div className="flex max-h-[88svh] flex-col">
67+
<header className="flex items-center justify-between border-b-[3px] border-[var(--color-ink)] bg-[var(--color-paper)] px-3 py-2">
68+
<h2 className="font-[family-name:var(--font-display)] text-lg font-bold">
69+
broken? or an idea?
70+
</h2>
71+
<button type="button" className="pixel-btn legend px-2 py-1" onClick={onClose}>
72+
close
73+
</button>
74+
</header>
75+
76+
<div className="min-h-0 flex-1 overflow-y-auto">
77+
<form onSubmit={submit} className="space-y-3 border-b-[3px] border-[var(--color-ink)] p-3">
78+
<fieldset>
79+
<legend className="legend mb-1.5">what kind</legend>
80+
<div className="grid grid-cols-2 gap-2">
81+
{KINDS.map(([id, label]) => (
82+
<button
83+
key={id}
84+
type="button"
85+
onClick={() => setKind(id)}
86+
aria-pressed={kind === id}
87+
className={[
88+
'pixel-btn px-2 py-2 text-xs',
89+
kind === id ? 'pixel-btn-primary' : '',
90+
].join(' ')}
91+
>
92+
{label}
93+
</button>
94+
))}
95+
</div>
96+
</fieldset>
97+
98+
<label className="block space-y-1.5">
99+
<span className="legend">tell us</span>
100+
<textarea
101+
ref={textRef}
102+
className="pixel-input min-h-24"
103+
value={text}
104+
onChange={(e) => {
105+
setText(e.target.value)
106+
setSent(false)
107+
setFailed(false)
108+
}}
109+
placeholder={
110+
kind === 'bug'
111+
? 'the map went blank when I tapped a pin'
112+
: 'let us add photos after we go'
113+
}
114+
rows={3}
115+
/>
116+
</label>
117+
118+
{/* Say what's attached rather than collecting it silently. */}
119+
<details className="pixel-box-sm p-2">
120+
<summary className="legend cursor-pointer">what gets sent with it</summary>
121+
<ul className="prose mt-2 space-y-0.5 text-xs text-[var(--color-ink)]/70">
122+
<li>screen: {captureContext().screen}</li>
123+
<li>where you are: {captureContext().view}</li>
124+
<li>your phone or browser</li>
125+
<li>the time</li>
126+
</ul>
127+
</details>
128+
129+
<div aria-live="polite">
130+
{sent && (
131+
<p className="pixel-box-sm bg-[var(--color-aqua)] px-3 py-2 text-sm">
132+
Sent. It shows up for both of you below.
133+
</p>
134+
)}
135+
{failed && (
136+
<p className="pixel-box-sm px-3 py-2 text-sm text-[var(--color-deep)]">
137+
Couldn&rsquo;t save that. The database is refusing reports —
138+
the rules for them may not be published yet.
139+
</p>
140+
)}
141+
</div>
142+
143+
<button
144+
type="submit"
145+
className="pixel-btn pixel-btn-primary w-full px-4 py-2"
146+
disabled={!text.trim()}
147+
>
148+
send it
149+
</button>
150+
</form>
151+
152+
<div className="space-y-4 p-3">
153+
<ReportList
154+
title="open"
155+
reports={openReports}
156+
empty="Nothing reported. Suspiciously good."
157+
onDone={(id) => setDone(id, true)}
158+
onRemove={remove}
159+
/>
160+
{doneReports.length > 0 && (
161+
<ReportList
162+
title="sorted"
163+
reports={doneReports}
164+
onDone={(id) => setDone(id, false)}
165+
onRemove={remove}
166+
/>
167+
)}
168+
</div>
169+
</div>
170+
</div>
171+
</dialog>
172+
)
173+
}
174+
175+
function ReportList({
176+
title,
177+
reports,
178+
empty,
179+
onDone,
180+
onRemove,
181+
}: {
182+
title: string
183+
reports: Report[]
184+
empty?: string
185+
onDone: (id: string) => void
186+
onRemove: (id: string) => void
187+
}) {
188+
return (
189+
<section className="space-y-2">
190+
<h3 className="flex items-baseline gap-2">
191+
<span className="font-[family-name:var(--font-display)] font-bold">{title}</span>
192+
{reports.length > 0 && (
193+
<span className="legend text-[var(--color-ink)]/60">{reports.length}</span>
194+
)}
195+
</h3>
196+
197+
{reports.length === 0 ? (
198+
<p className="prose text-sm text-[var(--color-ink)]/60">{empty}</p>
199+
) : (
200+
<ul className="space-y-2">
201+
{reports.map((r) => (
202+
<li key={r.id} className="pixel-box-sm space-y-2 p-2">
203+
<p className="flex items-start gap-2">
204+
<span className="legend shrink-0 border-2 border-[var(--color-ink)] px-1.5 py-1">
205+
{r.kind === 'bug' ? 'broke' : 'idea'}
206+
</span>
207+
<span className={r.done ? 'text-sm text-[var(--color-mute)] line-through' : 'text-sm'}>
208+
{r.text}
209+
</span>
210+
</p>
211+
212+
<p className="legend text-[var(--color-ink)]/60">
213+
{r.context.view} · {r.context.screen} ·{' '}
214+
{new Date(r.createdAt).toLocaleDateString()}
215+
</p>
216+
217+
<div className="flex gap-2">
218+
<button
219+
type="button"
220+
className="pixel-btn legend px-2 py-1"
221+
onClick={() => onDone(r.id)}
222+
>
223+
{r.done ? 'reopen' : 'sorted'}
224+
</button>
225+
<button
226+
type="button"
227+
className="pixel-btn legend ml-auto px-2 py-1 text-[var(--color-deep)]"
228+
onClick={() => onRemove(r.id)}
229+
>
230+
bin it
231+
</button>
232+
</div>
233+
</li>
234+
))}
235+
</ul>
236+
)}
237+
</section>
238+
)
239+
}

0 commit comments

Comments
 (0)