Skip to content

Commit 4d83110

Browse files
Kjubikstronkclaude
andcommitted
Fix a bundle regression, silent save failures and duplicate dates
Quality pass. Three real bugs, one of which I introduced last change. The report sheet was imported eagerly by App, and it pulls in the Firestore SDK — which silently undid the entry-chunk split and took first load from 103 KB back to 236 KB gzipped. A signed-out visitor was downloading the whole database SDK again. It's lazy now, like Home, and the entry chunk is back to 103 KB. No write path handled failure. Firestore applies a write locally and rolls it back if the server rejects it, so a refused save looked like the date appearing and then vanishing by itself, with nothing said. Failures now surface as a dismissable alert. Double-tapping "add it" created duplicate dates. The first fix for this was wrong and the test caught it: setSaving(true) lands on the next render, so three taps in one tick all read false. It uses a ref now, which updates synchronously — verified three taps produce one date. Also: "back to today" on the calendar, appearing only once you've paged away from the current month, and dropped the "what gets sent with it" disclosure from the report form. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent d85b822 commit 4d83110

6 files changed

Lines changed: 102 additions & 31 deletions

File tree

‎src/App.tsx‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { Suspense, lazy, useState } from 'react'
22
import Device from './components/Device'
33
import PixelHeart from './components/PixelHeart'
4-
import ReportSheet from './components/ReportSheet'
54
import Login from './screens/Login'
65
import { AuthProvider, useAuth } from './lib/auth'
76
import { isConfigured } from './lib/firebase'
@@ -15,6 +14,13 @@ import { PREVIEW } from './lib/preview'
1514
*/
1615
const Home = lazy(() => import('./screens/Home'))
1716

17+
/**
18+
* Lazy for the same reason as Home: it pulls in the Firestore SDK, and a
19+
* signed-out visitor must not download that. Importing it eagerly here silently
20+
* undid the entry-chunk split and more than doubled first load.
21+
*/
22+
const ReportSheet = lazy(() => import('./components/ReportSheet'))
23+
1824
export default function App() {
1925
return (
2026
<AuthProvider>
@@ -51,7 +57,11 @@ function Gate() {
5157
<Suspense fallback={<Booting />}>
5258
<Home />
5359
</Suspense>
54-
<ReportSheet openRequest={reportReq} onClose={() => setReportReq(0)} />
60+
{reportReq > 0 && (
61+
<Suspense fallback={null}>
62+
<ReportSheet openRequest={reportReq} onClose={() => setReportReq(0)} />
63+
</Suspense>
64+
)}
5565
</Device>
5666
)
5767
}

‎src/components/Calendar.tsx‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -94,9 +94,20 @@ export default function Calendar({
9494
◀
9595
</button>
9696

97-
<h2 className="font-[family-name:var(--font-display)] text-base font-bold sm:text-lg">
98-
{format(month, 'MMMM yyyy').toLowerCase()}
99-
</h2>
97+
<span className="flex min-w-0 flex-col items-center">
98+
<h2 className="truncate font-[family-name:var(--font-display)] text-base font-bold sm:text-lg">
99+
{format(month, 'MMMM yyyy').toLowerCase()}
100+
</h2>
101+
{!isSameMonth(month, new Date()) && (
102+
<button
103+
type="button"
104+
className="legend mt-0.5 px-2 py-1 text-[var(--color-deep)] underline underline-offset-2"
105+
onClick={() => onMonthChange(startOfMonth(new Date()))}
106+
>
107+
back to today
108+
</button>
109+
)}
110+
</span>
100111

101112
<button
102113
type="button"

‎src/components/EditSheet.tsx‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ export default function EditSheet({
4848
const [place, setPlace] = useState<Place | null>(null)
4949
const [day, setDay] = useState('')
5050
const [time, setTime] = useState('')
51+
const [saving, setSaving] = useState(false)
52+
// A ref, not the state above: state updates on the next render, so three
53+
// taps in one tick would all read `saving === false` and each save a copy.
54+
const savingRef = useRef(false)
5155

5256
// Reset the form whenever the sheet opens, so a cancelled edit never leaks
5357
// into the next one.
@@ -59,6 +63,8 @@ export default function EditSheet({
5963
setPlace(editing?.place ?? defaultPlace ?? null)
6064
setDay(editing?.scheduledFor ?? defaultDay ?? '')
6165
setTime(editing?.time ?? '')
66+
setSaving(false)
67+
savingRef.current = false
6268
// Keyed on openRequest so reopening always starts from a clean form.
6369
}, [openRequest, open, editing, defaultDay, defaultPlace])
6470

@@ -81,7 +87,11 @@ export default function EditSheet({
8187

8288
function submit(e: React.FormEvent) {
8389
e.preventDefault()
84-
if (!title.trim()) return
90+
// A double tap on a phone is easy, and onSave isn't awaited — without this
91+
// guard the second tap created a duplicate date.
92+
if (!title.trim() || savingRef.current) return
93+
savingRef.current = true
94+
setSaving(true)
8595

8696
// Scheduling is what promotes a wish into a plan. An existing `done` or
8797
// `cancelled` record keeps its status — re-saving shouldn't rewrite history.
@@ -206,7 +216,7 @@ export default function EditSheet({
206216
<button
207217
type="submit"
208218
className="pixel-btn pixel-btn-primary flex-1 px-4 py-2"
209-
disabled={!title.trim()}
219+
disabled={!title.trim() || saving}
210220
>
211221
{editing ? 'save' : 'add it'}
212222
</button>

‎src/components/ReportSheet.tsx‎

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useEffect, useRef, useState } from 'react'
2-
import { captureContext, useReports, type Report } from '../lib/reports'
2+
import { useReports, type Report } from '../lib/reports'
33

44
type Props = {
55
/** Counter, not a boolean — see the note on EditSheet's `openRequest`. */
@@ -115,17 +115,6 @@ export default function ReportSheet({ openRequest, onClose }: Props) {
115115
/>
116116
</label>
117117

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-
129118
<div aria-live="polite">
130119
{sent && (
131120
<p className="pixel-box-sm bg-[var(--color-aqua)] px-3 py-2 text-sm">

‎src/lib/dates.ts‎

Lines changed: 44 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,26 @@ export function useDates() {
3535
const [items, setItems] = useState<DateIdea[]>(PREVIEW ? SAMPLE_DATES : [])
3636
const [loading, setLoading] = useState(!PREVIEW)
3737
const [error, setError] = useState<string | null>(null)
38+
const [writeError, setWriteError] = useState<string | null>(null)
39+
40+
/**
41+
* Firestore applies a write locally first and rolls it back if the server
42+
* rejects it. Without this, a refused save looked like the date appearing
43+
* and then vanishing on its own.
44+
*/
45+
const guard = useCallback(async (run: () => Promise<unknown>) => {
46+
try {
47+
await run()
48+
setWriteError(null)
49+
} catch (err) {
50+
const code = (err as { code?: string })?.code
51+
setWriteError(
52+
code === 'permission-denied'
53+
? "That didn't save — this account isn't allowed to write here."
54+
: "That didn't save. Check your connection and try again.",
55+
)
56+
}
57+
}, [])
3858

3959
useEffect(() => {
4060
if (PREVIEW) return
@@ -77,13 +97,15 @@ export function useDates() {
7797
}
7898
if (!db || !user) return
7999
// The rules require this stamp to match the caller, so it can't be forged.
80-
await addDoc(collection(db, COLLECTION), {
81-
...draft,
82-
createdBy: user.uid,
83-
createdAt: Date.now(),
84-
})
100+
await guard(() =>
101+
addDoc(collection(db, COLLECTION), {
102+
...draft,
103+
createdBy: user.uid,
104+
createdAt: Date.now(),
105+
}),
106+
)
85107
},
86-
[user],
108+
[user, guard],
87109
)
88110

89111
const update = useCallback(async (id: string, patch: Partial<DateDraft>) => {
@@ -94,17 +116,17 @@ export function useDates() {
94116
if (!db) return
95117
// `createdBy` and `createdAt` are deliberately not patchable — the rules
96118
// reject an update that changes authorship anyway.
97-
await updateDoc(doc(db, COLLECTION, id), patch)
98-
}, [])
119+
await guard(() => updateDoc(doc(db, COLLECTION, id), patch))
120+
}, [guard])
99121

100122
const remove = useCallback(async (id: string) => {
101123
if (PREVIEW) {
102124
setItems((prev) => prev.filter((it) => it.id !== id))
103125
return
104126
}
105127
if (!db) return
106-
await deleteDoc(doc(db, COLLECTION, id))
107-
}, [])
128+
await guard(() => deleteDoc(doc(db, COLLECTION, id)))
129+
}, [guard])
108130

109131
/** Scheduled dates bucketed by `yyyy-MM-dd`, which is what the grid needs. */
110132
const byDay = useMemo(() => {
@@ -128,5 +150,16 @@ export function useDates() {
128150
[items],
129151
)
130152

131-
return { items, byDay, ideas, loading, error, add, update, remove }
153+
return {
154+
items,
155+
byDay,
156+
ideas,
157+
loading,
158+
error,
159+
writeError,
160+
dismissWriteError: () => setWriteError(null),
161+
add,
162+
update,
163+
remove,
164+
}
132165
}

‎src/screens/Home.tsx‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ import type { DateIdea, Place } from '../types'
1414
type View = 'calendar' | 'map' | 'ideas'
1515

1616
export default function Home() {
17-
const { items, byDay, loading, error, add, update, remove } = useDates()
17+
const { items, byDay, loading, error, writeError, dismissWriteError, add, update, remove } =
18+
useDates()
1819

1920
const [month, setMonth] = useState(() => startOfMonth(new Date()))
2021
const [selected, setSelected] = useState<string | null>(null)
@@ -216,6 +217,23 @@ export default function Home() {
216217
<div className="relative flex min-h-0 flex-1 flex-col">
217218
{body}
218219

220+
{/* A refused write used to just revert with no explanation. */}
221+
{writeError && (
222+
<div
223+
role="alert"
224+
className="absolute inset-x-3 top-3 z-40 flex items-start gap-2 border-[3px] border-[var(--color-ink)] bg-[var(--color-card)] p-3 shadow-[4px_4px_0_var(--color-ink)]"
225+
>
226+
<p className="flex-1 text-sm text-[var(--color-deep)]">{writeError}</p>
227+
<button
228+
type="button"
229+
className="pixel-btn legend shrink-0 px-2 py-1"
230+
onClick={dismissWriteError}
231+
>
232+
ok
233+
</button>
234+
</div>
235+
)}
236+
219237
{/* Adding a date is the one thing you do most, so it gets a permanent
220238
thumb-reachable button rather than living behind a menu.
221239

0 commit comments

Comments
 (0)