diff --git a/__tests__/post-generation-store.test.ts b/__tests__/post-generation-store.test.ts new file mode 100644 index 0000000..9bfc9de --- /dev/null +++ b/__tests__/post-generation-store.test.ts @@ -0,0 +1,32 @@ +import type { DroppedTrack } from '../lib/deck-generator'; +import { + setPostGenerationDrops, + takePostGenerationDrops, +} from '../lib/post-generation-store'; + +const sampleDrops = (): DroppedTrack[] => [ + { artist: 'Queen', title: 'Lost Track', reason: "Couldn't find a release year" }, +]; + +describe('post-generation-store', () => { + test('takePostGenerationDrops returns drops set for the deck id', () => { + const drops = sampleDrops(); + setPostGenerationDrops('deck-take-1', drops); + + expect(takePostGenerationDrops('deck-take-1')).toEqual(drops); + }); + + test('takePostGenerationDrops consumes the entry so a second call returns []', () => { + setPostGenerationDrops('deck-take-2', sampleDrops()); + + const first = takePostGenerationDrops('deck-take-2'); + const second = takePostGenerationDrops('deck-take-2'); + + expect(first).toHaveLength(1); + expect(second).toEqual([]); + }); + + test('takePostGenerationDrops returns [] for an unknown deck id', () => { + expect(takePostGenerationDrops('deck-unknown')).toEqual([]); + }); +}); diff --git a/app/_layout.tsx b/app/_layout.tsx index 002b0d7..3f94955 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -83,11 +83,7 @@ export default function RootLayout() { name="resolving" options={{ title: '', headerBackVisible: false, gestureEnabled: false }} /> - - + ({ kind: 'kept', card })); + for (const track of drops) rows.push({ kind: 'dropped', track }); + return rows; +} + +function dropPercent(kept: number, dropped: number): number { + const total = kept + dropped; + if (total === 0) return 0; + return Math.round((dropped / total) * 100); +} + +function formatCreatedAt(epochMs: number): string { + return new Date(epochMs).toLocaleDateString('en-US', SUBTITLE_DATE_OPTIONS); +} + +function buildSubtitle( + keptCount: number, + droppedCount: number, + createdAt: number, +): string { + const cards = `${keptCount} ${keptCount === 1 ? 'card' : 'cards'}`; + if (droppedCount > 0) return `${cards} · ${droppedCount} skipped`; + return `${cards} · created ${formatCreatedAt(createdAt)}`; +} + export default function DeckDetailScreen() { + const router = useRouter(); const { id } = useLocalSearchParams<{ id: string }>(); const [state, setState] = useState({ kind: 'loading' }); + const [editingIsrc, setEditingIsrc] = useState(null); + + // Single-shot: takePostGenerationDrops consumes from the store; capture once on mount. + const dropsRef = useRef(null); + + const reload = useCallback( + async (deckId: string): Promise => { + try { + const deck = await deckLibrary.load(deckId); + if (deck === null) { + setState({ kind: 'not-found' }); + return; + } + const drops = dropsRef.current ?? []; + setState({ kind: 'loaded', deck, drops }); + } catch { + setState({ kind: 'not-found' }); + } + }, + [], + ); useEffect(() => { - let cancelled = false; if (!id) { setState({ kind: 'not-found' }); return; } - deckLibrary - .load(id) - .then((deck) => { - if (cancelled) return; - setState(deck === null ? { kind: 'not-found' } : { kind: 'loaded', deck }); - }) - .catch(() => { - if (cancelled) return; - setState({ kind: 'not-found' }); - }); - return () => { - cancelled = true; - }; - }, [id]); + if (dropsRef.current === null) { + dropsRef.current = takePostGenerationDrops(id); + } + void reload(id); + }, [id, reload]); + + // Dismiss the year editor on Android hardware back without committing. + useEffect(() => { + if (editingIsrc === null) return; + const sub = BackHandler.addEventListener('hardwareBackPress', () => { + setEditingIsrc(null); + return true; + }); + return () => sub.remove(); + }, [editingIsrc]); + + // Tapping outside the input collapses the soft keyboard via FlatList's + // keyboardShouldPersistTaps="handled". Mirror that to dismiss the editor. + useEffect(() => { + if (editingIsrc === null) return; + const sub = Keyboard.addListener('keyboardDidHide', () => { + setEditingIsrc(null); + }); + return () => sub.remove(); + }, [editingIsrc]); + + const onCommitOverride = useCallback( + async (deckId: string, isrc: string, year: number): Promise => { + await deckLibrary.updateYearOverride(deckId, isrc, year); + setEditingIsrc(null); + await reload(deckId); + }, + [reload], + ); return ( - - {state.kind === 'loading' && ( - - - - Loading… - - - )} - {state.kind === 'not-found' && ( + {state.kind === 'loading' && ( + + + + )} + + {state.kind === 'not-found' && ( + Deck not found. - )} - {state.kind === 'loaded' && ( - - - {state.deck.name} - - - {state.deck.cards.length === 1 - ? '1 card' - : `${state.deck.cards.length} cards`} - - router.replace('/')} + accessibilityLabel="Back to library" + > + Back to library + + + )} + + {state.kind === 'loaded' && ( + setEditingIsrc(isrc)} + onCommitOverride={onCommitOverride} + onOpenCard={(isrc) => router.push(`/card/${state.deck.id}/${isrc}`)} + onExport={() => router.push(`/export/${state.deck.id}`)} + /> + )} + + ); +} + +type LoadedDeckProps = { + deck: Deck; + drops: DroppedTrack[]; + editingIsrc: string | null; + onStartEdit: (isrc: string) => void; + onCommitOverride: (deckId: string, isrc: string, year: number) => Promise; + onOpenCard: (isrc: string) => void; + onExport: () => void; +}; + +type FilterKind = 'all' | 'kept' | 'dropped'; + +function LoadedDeck({ + deck, + drops, + editingIsrc, + onStartEdit, + onCommitOverride, + onOpenCard, + onExport, +}: LoadedDeckProps): React.ReactElement { + const rows = useMemo(() => buildRows(deck, drops), [deck, drops]); + const keptCount = deck.cards.length; + const droppedCount = drops.length; + const hasDrops = droppedCount > 0; + const total = keptCount + droppedCount; + const percent = dropPercent(keptCount, droppedCount); + const isSparse = total > 0 && percent > 30; + const hasMinorDrops = !isSparse && hasDrops; + const firstDrop = drops[0]; + const subtitle = buildSubtitle(keptCount, droppedCount, deck.createdAt); + const [filter, setFilter] = useState('all'); + const visibleRows = useMemo( + () => (filter === 'all' ? rows : rows.filter((r) => r.kind === filter)), + [rows, filter], + ); + + // The min-height: 0 flexbox fix referenced by #5: parent flex containers + // must allow children to shrink below their content. On RN, putting a + // FlatList directly inside a `flex-1` View works; the gotcha to avoid is + // wrapping the list in a ScrollView, which silently breaks scrolling. + return ( + + ( + + + {deck.name} + + + {subtitle} + + + ), + headerRight: () => ( + } > - Detail screen coming in a follow-up. - - - )} + Export PDF + + ), + }} + /> + + rowKey(row, idx)} + contentContainerClassName="px-5 pt-2 pb-12 gap-2" + keyboardShouldPersistTaps="handled" + ListHeaderComponent={ + hasDrops ? ( + + {isSparse && ( + + + + {percent}% of tracks dropped. + + {' '}That's a sparse deck. Consider a different playlist. + + + )} + {hasMinorDrops && firstDrop && ( + + + + {droppedCount === 1 ? '1 skipped.' : `${droppedCount} skipped.`} + + {' '}Example: “{firstDrop.artist} — {firstDrop.title}”. + + + )} + + + + setFilter((prev) => (prev === 'kept' ? 'all' : 'kept')) + } + role="button" + accessibilityLabel={ + filter === 'kept' ? 'Show all' : 'Filter to kept cards' + } + accessibilityState={{ selected: filter === 'kept' }} + style={{ opacity: filter === 'dropped' ? 0.4 : 1 }} + > + + + + {`${keptCount} ${keptCount === 1 ? 'card' : 'cards'}`} + + + + + setFilter((prev) => + prev === 'dropped' ? 'all' : 'dropped', + ) + } + role="button" + accessibilityLabel={ + filter === 'dropped' + ? 'Show all' + : 'Filter to skipped tracks' + } + accessibilityState={{ selected: filter === 'dropped' }} + style={{ opacity: filter === 'kept' ? 0.4 : 1 }} + > + + + + {`${droppedCount} skipped`} + + + + + + ) : null + } + renderItem={({ item }) => { + if (item.kind === 'dropped') { + return ; + } + const isEditing = editingIsrc === item.card.isrc; + return ( + onStartEdit(item.card.isrc)} + onCommit={(year) => onCommitOverride(deck.id, item.card.isrc, year)} + onOpenCard={() => onOpenCard(item.card.isrc)} + /> + ); + }} + /> + + ); +} + +function DroppedRow({ track }: { track: DroppedTrack }): React.ReactElement { + return ( + + + + + {track.title} + + + {dropReasonCopy(track.reason)} + + - + ); } + +function dropReasonCopy(reason: DroppedTrack['reason']): string { + // The DropReason union itself is plain English already; the wrapper exists + // so the screen never has to reach for the underlying enum value and so + // future reasons can be remapped here without touching the row component. + return reason; +} diff --git a/app/resolving.tsx b/app/resolving.tsx index 009a01b..ac825a2 100644 --- a/app/resolving.tsx +++ b/app/resolving.tsx @@ -154,7 +154,7 @@ export default function ResolvingScreen() { if (cancelled) return; setPostGenerationDrops(result.deck.id, result.droppedTracks); - router.replace(`/review/${result.deck.id}`); + router.replace(`/deck/${result.deck.id}`); } catch (err) { if (cancelled) return; if ((err as { name?: string }).name === 'AbortError') return; diff --git a/app/review/[id].tsx b/app/review/[id].tsx deleted file mode 100644 index 6598e0d..0000000 --- a/app/review/[id].tsx +++ /dev/null @@ -1,410 +0,0 @@ -import { Ionicons } from '@expo/vector-icons'; -import { Stack, useLocalSearchParams, useRouter } from 'expo-router'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { - ActivityIndicator, - FlatList, - Pressable, - Text as RNText, - View, -} from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; - -import { KeptRow } from '@/components/deck/KeptRow'; -import { Dot } from '@/components/ui/dot'; -import { InlineAlert } from '@/components/ui/inline-alert'; -import { Pill } from '@/components/ui/pill'; -import { PushButton } from '@/components/ui/push-button'; -import { Text } from '@/components/ui/text'; -import type { DroppedTrack } from '@/lib/deck-generator'; -import { deckLibrary } from '@/lib/deck-library'; -import { takePostGenerationDrops } from '@/lib/post-generation-store'; -import type { Card, Deck } from '@/lib/types'; -import { tokens } from '@/theme/tokens'; - -type LoadState = - | { kind: 'loading' } - | { kind: 'loaded'; deck: Deck; drops: DroppedTrack[] } - | { kind: 'not-found' }; - -type ReviewRow = - | { kind: 'kept'; card: Card } - | { kind: 'dropped'; track: DroppedTrack }; - -function rowKey(row: ReviewRow, index: number): string { - if (row.kind === 'kept') return `k:${row.card.isrc}`; - return `d:${index}:${row.track.artist}:${row.track.title}`; -} - -function buildRows(deck: Deck, drops: DroppedTrack[]): ReviewRow[] { - const rows: ReviewRow[] = deck.cards.map((card) => ({ kind: 'kept', card })); - for (const track of drops) rows.push({ kind: 'dropped', track }); - return rows; -} - -function dropPercent(kept: number, dropped: number): number { - const total = kept + dropped; - if (total === 0) return 0; - return Math.round((dropped / total) * 100); -} - -export default function ReviewScreen() { - const router = useRouter(); - const { id } = useLocalSearchParams<{ id: string }>(); - const [state, setState] = useState({ kind: 'loading' }); - const [editingIsrc, setEditingIsrc] = useState(null); - - // Read drops once on mount; re-renders must not re-take from the store. - const dropsRef = useRef(null); - - const reload = useCallback( - async (deckId: string): Promise => { - try { - const deck = await deckLibrary.load(deckId); - if (deck === null) { - setState({ kind: 'not-found' }); - return; - } - const drops = dropsRef.current ?? []; - setState({ kind: 'loaded', deck, drops }); - } catch { - setState({ kind: 'not-found' }); - } - }, - [], - ); - - useEffect(() => { - if (!id) { - setState({ kind: 'not-found' }); - return; - } - if (dropsRef.current === null) { - dropsRef.current = takePostGenerationDrops(id); - } - void reload(id); - }, [id, reload]); - - const onCommitOverride = useCallback( - async (deckId: string, isrc: string, year: number): Promise => { - await deckLibrary.updateYearOverride(deckId, isrc, year); - setEditingIsrc(null); - await reload(deckId); - }, - [reload], - ); - - return ( - - {state.kind === 'loading' && ( - - - - )} - - {state.kind === 'not-found' && ( - - - Deck not found. - - router.replace('/')} - accessibilityLabel="Back to library" - > - Back to library - - - )} - - {state.kind === 'loaded' && ( - setEditingIsrc(isrc)} - onCommitOverride={onCommitOverride} - onOpenCard={(isrc) => router.push(`/card/${state.deck.id}/${isrc}`)} - onExport={() => router.push(`/export/${state.deck.id}`)} - /> - )} - - ); -} - -type LoadedReviewProps = { - deck: Deck; - drops: DroppedTrack[]; - editingIsrc: string | null; - onStartEdit: (isrc: string) => void; - onCommitOverride: (deckId: string, isrc: string, year: number) => Promise; - onOpenCard: (isrc: string) => void; - onExport: () => void; -}; - -type FilterKind = 'all' | 'kept' | 'dropped'; - -function LoadedReview({ - deck, - drops, - editingIsrc, - onStartEdit, - onCommitOverride, - onOpenCard, - onExport, -}: LoadedReviewProps): React.ReactElement { - const rows = useMemo(() => buildRows(deck, drops), [deck, drops]); - const keptCount = deck.cards.length; - const droppedCount = drops.length; - const total = keptCount + droppedCount; - const percent = dropPercent(keptCount, droppedCount); - const isSparse = total > 0 && percent > 30; - const hasMinorDrops = !isSparse && droppedCount > 0; - const firstDrop = drops[0]; - const [filter, setFilter] = useState('all'); - const visibleRows = useMemo( - () => (filter === 'all' ? rows : rows.filter((r) => r.kind === filter)), - [rows, filter], - ); - - // The min-height: 0 flexbox fix referenced by #5: parent flex containers - // must allow children to shrink below their content. On RN, putting a - // FlatList directly inside a `flex-1` View works; the gotcha to avoid is - // wrapping the list in a ScrollView, which silently breaks scrolling. - return ( - - ( - - - Review deck - - - {`${keptCount} cards${droppedCount ? ` · ${droppedCount} skipped` : ''}`} - - - ), - headerRight: () => ( - } - > - Export PDF - - ), - }} - /> - - rowKey(row, idx)} - contentContainerClassName="px-5 pt-2 pb-12 gap-2" - keyboardShouldPersistTaps="handled" - ListHeaderComponent={ - - {isSparse && ( - - - - {percent}% of tracks dropped. - - {' '}That's a sparse deck. Consider a different playlist. - - - )} - {hasMinorDrops && firstDrop && ( - - - - {droppedCount === 1 ? '1 skipped.' : `${droppedCount} skipped.`} - - {' '}Example: “{firstDrop.artist} — {firstDrop.title}”. - - - )} - - - - setFilter((prev) => (prev === 'kept' ? 'all' : 'kept')) - } - role="button" - accessibilityLabel={ - filter === 'kept' ? 'Show all' : 'Filter to kept cards' - } - accessibilityState={{ selected: filter === 'kept' }} - style={{ opacity: filter === 'dropped' ? 0.4 : 1 }} - > - - - - {`${keptCount} ${keptCount === 1 ? 'card' : 'cards'}`} - - - - {droppedCount > 0 && ( - - setFilter((prev) => - prev === 'dropped' ? 'all' : 'dropped', - ) - } - role="button" - accessibilityLabel={ - filter === 'dropped' - ? 'Show all' - : 'Filter to skipped tracks' - } - accessibilityState={{ selected: filter === 'dropped' }} - style={{ opacity: filter === 'kept' ? 0.4 : 1 }} - > - - - - {`${droppedCount} skipped`} - - - - )} - - - } - renderItem={({ item }) => { - if (item.kind === 'dropped') { - return ; - } - const isEditing = editingIsrc === item.card.isrc; - return ( - onStartEdit(item.card.isrc)} - onCommit={(year) => onCommitOverride(deck.id, item.card.isrc, year)} - onOpenCard={() => onOpenCard(item.card.isrc)} - /> - ); - }} - /> - - ); -} - -function DroppedRow({ track }: { track: DroppedTrack }): React.ReactElement { - return ( - - - - - {track.title} - - - {dropReasonCopy(track.reason)} - - - - - ); -} - -function dropReasonCopy(reason: DroppedTrack['reason']): string { - // The DropReason union itself is plain English already; the wrapper exists - // so the screen never has to reach for the underlying enum value and so - // future reasons can be remapped here without touching the row component. - return reason; -} diff --git a/lib/post-generation-store.ts b/lib/post-generation-store.ts index 3bcc589..de50e9e 100644 --- a/lib/post-generation-store.ts +++ b/lib/post-generation-store.ts @@ -21,5 +21,7 @@ export function setPostGenerationDrops(deckId: string, drops: DroppedTrack[]): v } export function takePostGenerationDrops(deckId: string): DroppedTrack[] { - return store.get(deckId) ?? []; + const drops = store.get(deckId) ?? []; + store.delete(deckId); + return drops; }