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;
}