1
0

feat: pure scan-flow state machine

This commit is contained in:
2026-08-29 22:25:53 +02:00
parent 70b7fdbe1b
commit 890e7da3a7
2 changed files with 217 additions and 0 deletions

76
web/src/scan/reducer.ts Normal file
View File

@@ -0,0 +1,76 @@
import type { Candidate, Item, ReleasePreview } from '../types.js'
export type ScanErrorKind = 'not_found' | 'no_discogs_token' | 'rate_limited' | 'server'
export type ScanState =
| { phase: 'scan' }
| { phase: 'looking'; code: string }
| { phase: 'candidates'; code: string; candidates: Candidate[] }
| {
phase: 'confirm'
code: string | null
candidate: Candidate
preview: ReleasePreview | null
matchAlbumId: number | null
adding: boolean
addError: string | null
}
| { phase: 'added'; item: Item }
| { phase: 'error'; kind: ScanErrorKind; code: string | null }
export const INITIAL_SCAN_STATE: ScanState = { phase: 'scan' }
export type ScanAction =
| { type: 'DETECT'; code: string }
| { type: 'CANDIDATES'; code: string; candidates: Candidate[] }
| { type: 'NOT_FOUND'; code: string }
| { type: 'ERROR'; kind: ScanErrorKind; code: string | null }
| { type: 'SELECT'; candidate: Candidate }
| { type: 'PREVIEW'; preview: ReleasePreview }
| { type: 'SET_MATCH'; albumId: number }
| { type: 'CLEAR_MATCH' }
| { type: 'ADD_START' }
| { type: 'ADDED'; item: Item }
| { type: 'ADD_ERROR'; message: string }
| { type: 'RESET' }
export function scanReducer(state: ScanState, action: ScanAction): ScanState {
switch (action.type) {
case 'DETECT':
return state.phase === 'scan' ? { phase: 'looking', code: action.code } : state
case 'CANDIDATES':
return state.phase === 'looking' ? { phase: 'candidates', code: action.code, candidates: action.candidates } : state
case 'NOT_FOUND':
return state.phase === 'looking' ? { phase: 'error', kind: 'not_found', code: action.code } : state
case 'ERROR':
return state.phase === 'looking' || state.phase === 'candidates' || state.phase === 'confirm'
? { phase: 'error', kind: action.kind, code: action.code }
: state
case 'SELECT':
return state.phase === 'candidates'
? {
phase: 'confirm',
code: state.code,
candidate: action.candidate,
preview: null,
matchAlbumId: null,
adding: false,
addError: null,
}
: state
case 'PREVIEW':
return state.phase === 'confirm' ? { ...state, preview: action.preview } : state
case 'SET_MATCH':
return state.phase === 'confirm' ? { ...state, matchAlbumId: action.albumId } : state
case 'CLEAR_MATCH':
return state.phase === 'confirm' ? { ...state, matchAlbumId: null } : state
case 'ADD_START':
return state.phase === 'confirm' && state.preview ? { ...state, adding: true, addError: null } : state
case 'ADDED':
return state.phase === 'confirm' ? { phase: 'added', item: action.item } : state
case 'ADD_ERROR':
return state.phase === 'confirm' ? { ...state, adding: false, addError: action.message } : state
case 'RESET':
return INITIAL_SCAN_STATE
}
}