Skip to content

Commit 97f8829

Browse files
authored
Merge pull request #38 from theredmoose/feature/error-handling-mutations
Add: error handling for mutation operations with dismissible toast
2 parents dcd8b74 + dba9e62 commit 97f8829

3 files changed

Lines changed: 245 additions & 167 deletions

File tree

TODO.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@
5757
- [x] nordic-combi in skillLevels but not in SPORTS array — `SportSizing.tsx` now omits nordic-combi when persisting skill levels
5858
- [x] US shoe size conversion inconsistency — `sizing.ts` now uses `getShoeSizesFromFootLength()` from shoeSize service
5959
- [ ] Potential undefined skillLevel access — `skillLevels[currentSport.id]` in SportSizing has no fallback
60-
- [ ] No loading state for gear operations — gear delete/submit in App.tsx show no loading indicators
60+
- [x] No loading state for gear operations — gear delete/submit in App.tsx show no loading indicators; mutations now catch errors and surface a dismissible toast
6161
- [x] Race condition in useAuth — `setLoading(false)` and `setError()` now guarded by mounted ref
6262
- [ ] No email verification on signup — email accounts created without verifying address
6363

src/App.tsx

Lines changed: 154 additions & 116 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,16 @@
11
import { useState } from 'react';
2+
3+
function getOperationErrorMessage(err: unknown): string {
4+
const code = (err as { code?: string })?.code ?? '';
5+
if (code === 'unavailable' || code === 'network-request-failed') {
6+
return 'Unable to save — check your connection and try again.';
7+
}
8+
if (code === 'permission-denied') {
9+
return 'Permission denied. Please sign out and sign back in.';
10+
}
11+
if (err instanceof Error && err.message) return err.message;
12+
return 'Something went wrong. Please try again.';
13+
}
214
import { Settings } from 'lucide-react';
315
import {
416
MemberForm,
@@ -51,6 +63,7 @@ function App() {
5163

5264
const [view, setView] = useState<View>('home');
5365
const [activeTab, setActiveTab] = useState<TopLevelTab>('family');
66+
const [operationError, setOperationError] = useState<string | null>(null);
5467
const [selectedMember, setSelectedMember] = useState<FamilyMember | null>(null);
5568
const [selectedGearItem, setSelectedGearItem] = useState<GearItem | null>(null);
5669
const [gearOwnerId, setGearOwnerId] = useState<string | null>(null);
@@ -70,25 +83,37 @@ function App() {
7083
data: Omit<FamilyMember, 'id' | 'userId' | 'createdAt' | 'updatedAt'>
7184
) => {
7285
if (!user) return;
73-
await addMember({ ...data, userId: user.uid });
74-
setView('home');
86+
try {
87+
await addMember({ ...data, userId: user.uid });
88+
setView('home');
89+
} catch (err) {
90+
setOperationError(getOperationErrorMessage(err));
91+
}
7592
};
7693

7794
const handleUpdateMember = async (
7895
data: Omit<FamilyMember, 'id' | 'userId' | 'createdAt' | 'updatedAt'>
7996
) => {
8097
if (selectedMember) {
81-
await updateMember(selectedMember.id, data);
82-
setSelectedMember({ ...selectedMember, ...data });
83-
setView('detail');
98+
try {
99+
await updateMember(selectedMember.id, data);
100+
setSelectedMember({ ...selectedMember, ...data });
101+
setView('detail');
102+
} catch (err) {
103+
setOperationError(getOperationErrorMessage(err));
104+
}
84105
}
85106
};
86107

87108
const handleDeleteMember = async (member: FamilyMember) => {
88-
await deleteMember(member.id);
89-
if (selectedMember?.id === member.id) {
90-
setSelectedMember(null);
91-
setView('home');
109+
try {
110+
await deleteMember(member.id);
111+
if (selectedMember?.id === member.id) {
112+
setSelectedMember(null);
113+
setView('home');
114+
}
115+
} catch (err) {
116+
setOperationError(getOperationErrorMessage(err));
92117
}
93118
};
94119

@@ -134,15 +159,19 @@ function App() {
134159
data: Omit<GearItem, 'id' | 'userId' | 'createdAt' | 'updatedAt'>
135160
) => {
136161
if (!user) return;
137-
if (selectedGearItem) {
138-
await updateGearItem(selectedGearItem.id, data);
139-
} else {
140-
await addGearItem({ ...data, userId: user.uid });
141-
}
142-
if (gearDefaultSport && selectedMember) {
143-
setView('sizing');
144-
} else {
145-
setView('home');
162+
try {
163+
if (selectedGearItem) {
164+
await updateGearItem(selectedGearItem.id, data);
165+
} else {
166+
await addGearItem({ ...data, userId: user.uid });
167+
}
168+
if (gearDefaultSport && selectedMember) {
169+
setView('sizing');
170+
} else {
171+
setView('home');
172+
}
173+
} catch (err) {
174+
setOperationError(getOperationErrorMessage(err));
146175
}
147176
};
148177

@@ -181,100 +210,6 @@ function App() {
181210
);
182211
}
183212

184-
// ── Full-screen overlay views (no bottom nav) ────────────────────
185-
if (view === 'settings') {
186-
return (
187-
<div className="app">
188-
<SettingsScreen
189-
settings={settings}
190-
user={user}
191-
onUpdateSettings={updateSettings}
192-
onResetSettings={resetSettings}
193-
onSignOut={signOut}
194-
onSendPasswordReset={sendPasswordReset}
195-
onBack={() => setView('home')}
196-
/>
197-
</div>
198-
);
199-
}
200-
201-
if (view === 'add' || view === 'edit') {
202-
return (
203-
<div className="app">
204-
<MemberForm
205-
member={view === 'edit' ? selectedMember ?? undefined : undefined}
206-
onSubmit={view === 'edit' ? handleUpdateMember : handleAddMember}
207-
onCancel={() => setView(selectedMember ? 'detail' : 'home')}
208-
/>
209-
</div>
210-
);
211-
}
212-
213-
if (view === 'detail' && selectedMember) {
214-
const memberGear = gearItems.filter((item) => item.ownerId === selectedMember.id);
215-
return (
216-
<div className="app">
217-
<MemberDetail
218-
member={selectedMember}
219-
gearItems={memberGear}
220-
settings={settings}
221-
onBack={() => { setSelectedMember(null); setView('home'); }}
222-
onEdit={() => setView('edit')}
223-
onGetSizing={() => setView('sizing')}
224-
onOpenConverter={() => setView('converter')}
225-
onAddGear={() => handleAddGearFromSizing('alpine')}
226-
onEditGear={handleEditGear}
227-
/>
228-
</div>
229-
);
230-
}
231-
232-
if (view === 'sizing' && selectedMember) {
233-
const memberGear = gearItems.filter((item) => item.ownerId === selectedMember.id);
234-
return (
235-
<div className="app">
236-
<SportSizing
237-
member={selectedMember}
238-
gearItems={memberGear}
239-
onBack={() => setView('detail')}
240-
onSkillLevelChange={(levels) => updateSkillLevels(selectedMember.id, levels)}
241-
onAddGear={handleAddGearFromSizing}
242-
onEditGear={handleEditGear}
243-
onDeleteGear={handleDeleteGear}
244-
/>
245-
</div>
246-
);
247-
}
248-
249-
if (view === 'converter' && selectedMember) {
250-
const footLength = Math.max(
251-
selectedMember.measurements.footLengthLeft,
252-
selectedMember.measurements.footLengthRight
253-
);
254-
return (
255-
<div className="app">
256-
<ShoeSizeConverter
257-
footLengthCm={footLength}
258-
onClose={() => setView('detail')}
259-
/>
260-
</div>
261-
);
262-
}
263-
264-
if ((view === 'add-gear' || view === 'edit-gear') && gearOwnerId) {
265-
return (
266-
<div className="app">
267-
<GearForm
268-
item={view === 'edit-gear' ? selectedGearItem ?? undefined : undefined}
269-
ownerId={gearOwnerId}
270-
defaultSport={gearDefaultSport}
271-
onSubmit={handleGearSubmit}
272-
onCancel={handleGearCancel}
273-
/>
274-
</div>
275-
);
276-
}
277-
278213
// ── Main tabbed shell ────────────────────────────────────────────
279214
const renderTabContent = () => {
280215
switch (activeTab) {
@@ -360,12 +295,115 @@ function App() {
360295
}
361296
};
362297

298+
const renderView = () => {
299+
if (view === 'settings') {
300+
return (
301+
<SettingsScreen
302+
settings={settings}
303+
user={user}
304+
onUpdateSettings={updateSettings}
305+
onResetSettings={resetSettings}
306+
onSignOut={signOut}
307+
onSendPasswordReset={sendPasswordReset}
308+
onBack={() => setView('home')}
309+
/>
310+
);
311+
}
312+
313+
if (view === 'add' || view === 'edit') {
314+
return (
315+
<MemberForm
316+
member={view === 'edit' ? selectedMember ?? undefined : undefined}
317+
onSubmit={view === 'edit' ? handleUpdateMember : handleAddMember}
318+
onCancel={() => setView(selectedMember ? 'detail' : 'home')}
319+
/>
320+
);
321+
}
322+
323+
if (view === 'detail' && selectedMember) {
324+
const memberGear = gearItems.filter((item) => item.ownerId === selectedMember.id);
325+
return (
326+
<MemberDetail
327+
member={selectedMember}
328+
gearItems={memberGear}
329+
settings={settings}
330+
onBack={() => { setSelectedMember(null); setView('home'); }}
331+
onEdit={() => setView('edit')}
332+
onGetSizing={() => setView('sizing')}
333+
onOpenConverter={() => setView('converter')}
334+
onAddGear={() => handleAddGearFromSizing('alpine')}
335+
onEditGear={handleEditGear}
336+
/>
337+
);
338+
}
339+
340+
if (view === 'sizing' && selectedMember) {
341+
const memberGear = gearItems.filter((item) => item.ownerId === selectedMember.id);
342+
return (
343+
<SportSizing
344+
member={selectedMember}
345+
gearItems={memberGear}
346+
onBack={() => setView('detail')}
347+
onSkillLevelChange={(levels) => updateSkillLevels(selectedMember.id, levels)}
348+
onAddGear={handleAddGearFromSizing}
349+
onEditGear={handleEditGear}
350+
onDeleteGear={handleDeleteGear}
351+
/>
352+
);
353+
}
354+
355+
if (view === 'converter' && selectedMember) {
356+
const footLength = Math.max(
357+
selectedMember.measurements.footLengthLeft,
358+
selectedMember.measurements.footLengthRight
359+
);
360+
return (
361+
<ShoeSizeConverter
362+
footLengthCm={footLength}
363+
onClose={() => setView('detail')}
364+
/>
365+
);
366+
}
367+
368+
if ((view === 'add-gear' || view === 'edit-gear') && gearOwnerId) {
369+
return (
370+
<GearForm
371+
item={view === 'edit-gear' ? selectedGearItem ?? undefined : undefined}
372+
ownerId={gearOwnerId}
373+
defaultSport={gearDefaultSport}
374+
onSubmit={handleGearSubmit}
375+
onCancel={handleGearCancel}
376+
/>
377+
);
378+
}
379+
380+
// Default: main tabbed shell
381+
return (
382+
<>
383+
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
384+
{renderTabContent()}
385+
</div>
386+
<BottomNav activeTab={activeTab} onChange={handleTabChange} />
387+
</>
388+
);
389+
};
390+
363391
return (
364392
<div className="app flex flex-col" style={{ minHeight: '100dvh' }}>
365-
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
366-
{renderTabContent()}
367-
</div>
368-
<BottomNav activeTab={activeTab} onChange={handleTabChange} />
393+
{renderView()}
394+
{operationError && (
395+
<div
396+
role="alert"
397+
className="fixed bottom-20 left-4 right-4 z-50 bg-red-600 text-white px-4 py-3 rounded-xl shadow-lg flex items-center justify-between"
398+
>
399+
<span className="text-sm font-semibold">{operationError}</span>
400+
<button
401+
onClick={() => setOperationError(null)}
402+
aria-label="Dismiss error"
403+
className="ml-3 text-white hover:text-red-200 font-bold text-lg leading-none"
404+
></button>
405+
</div>
406+
)}
369407
</div>
370408
);
371409
}

0 commit comments

Comments
 (0)