From eb83f65bba2a84ec91c9e331851f50237481b1ca Mon Sep 17 00:00:00 2001 From: Ashton Anderson Date: Sat, 6 Jun 2026 13:36:03 -0400 Subject: [PATCH] Add saved drill presets to opening selection Introduce a saved-drill preset system in the opening/endgame selection modal, persisted to localStorage and keyed by a per-selection signature (opening, variation, rating, color, move count, endgame traits). - New src/lib/savedDrills.ts: read/write/upsert presets, signature and default-name helpers, with normalization and de-duplication. - Saved Drills category in the browse panel, filtered by the active category and search term, with select/remove. - Save acts on the queue when it has drills, otherwise on the drill currently configured in the preview, so tweaking a loaded preset's settings (e.g. rating) saves as a distinct drill rather than a no-op. - Selection titles show the variation as the primary label and the opening family as the secondary label, in both the saved-drills list and the queue. Co-Authored-By: Claude Opus 4.8 --- .../Openings/OpeningSelectionModal.tsx | 632 ++++++++++++++++-- src/lib/index.ts | 1 + src/lib/savedDrills.ts | 265 ++++++++ 3 files changed, 841 insertions(+), 57 deletions(-) create mode 100644 src/lib/savedDrills.ts diff --git a/src/components/Openings/OpeningSelectionModal.tsx b/src/components/Openings/OpeningSelectionModal.tsx index 289f2d20..d3b213cd 100644 --- a/src/components/Openings/OpeningSelectionModal.tsx +++ b/src/components/Openings/OpeningSelectionModal.tsx @@ -39,6 +39,14 @@ import { EndgameCategoryData, EndgameMotifData, } from 'src/lib/endgames' +import { + cloneDrillConfiguration, + getDrillConfigurationSignature, + readSavedDrillPresets, + SavedDrillPreset, + upsertSavedDrillPreset, + writeSavedDrillPresets, +} from 'src/lib/savedDrills' type MobileTab = 'browse' | 'selected' @@ -91,12 +99,16 @@ const SelectionTitle: React.FC<{ selection: OpeningSelection className?: string }> = ({ selection, className = 'text-[13px]' }) => ( -

- {selection.opening.name} +

+

+ {selection.variation ? selection.variation.name : selection.opening.name} +

{selection.variation && ( - : {selection.variation.name} +

+ {selection.opening.name} +

)} -

+
) const SelectionConfigurationLine: React.FC<{ @@ -374,6 +386,144 @@ const TabNavigation: React.FC<{ ) } +const getPresetSelection = (preset: SavedDrillPreset) => + preset.configuration.selections[0] ?? null + +const SavedDrillPresetRow: React.FC<{ + preset: SavedDrillPreset + isSelected: boolean + onSelect: (preset: SavedDrillPreset) => void + onRemove: (presetId: string) => void +}> = ({ preset, isSelected, onSelect, onRemove }) => { + const selection = getPresetSelection(preset) + + if (!selection) { + return null + } + + const isEndgame = getOpeningCategory(selection.opening) === 'endgame' + const detailLine = getSelectionDetailLine(selection) + + return ( +
+ + +
+ ) +} + +const SavedDrillsCategory: React.FC<{ + presets: SavedDrillPreset[] + selectedPresetId: string | null + isCollapsed: boolean + onToggle: () => void + onSelect: (preset: SavedDrillPreset) => void + onRemove: (presetId: string) => void +}> = ({ + presets, + selectedPresetId, + isCollapsed, + onToggle, + onSelect, + onRemove, +}) => { + if (!presets.length) { + return null + } + + return ( +
+
{ + if (event.key === 'Enter' || event.key === ' ') { + onToggle() + } + }} + > + + expand_more + +

+ Saved Drills + ({presets.length}) +

+
+ {!isCollapsed && + presets.map((preset) => ( + + ))} +
+ ) +} + // Left Panel - Opening Selection const BrowsePanel: React.FC<{ activeTab: MobileTab @@ -397,6 +547,11 @@ const BrowsePanel: React.FC<{ onAddCustomPosition: () => void categoryLabel: string categoryLabelPlural: string + savedDrillPresets: SavedDrillPreset[] + selectedSavedDrillPresetId: string | null + onSelectSavedDrillPreset: (preset: SavedDrillPreset) => void + onRemoveSavedDrillPreset: (presetId: string) => void + onClearSelectedSavedDrillPreset: () => void }> = ({ activeTab, filteredOpenings, @@ -419,6 +574,11 @@ const BrowsePanel: React.FC<{ onAddCustomPosition, categoryLabel, categoryLabelPlural, + savedDrillPresets, + selectedSavedDrillPresetId, + onSelectSavedDrillPreset, + onRemoveSavedDrillPreset, + onClearSelectedSavedDrillPreset, }) => { const { isMobile } = useContext(WindowSizeContext) const isCustomCategory = browseCategory === 'custom' @@ -455,6 +615,41 @@ const BrowsePanel: React.FC<{ const searchPlaceholder = `Search ${categoryLabelPlural.toLowerCase()}...` + const savedDrillCategoryPresets = useMemo(() => { + const activeCategory = + browseCategory === 'endgames' + ? 'endgame' + : browseCategory === 'custom' + ? 'custom' + : 'opening' + const loweredSearchTerm = searchTerm.trim().toLowerCase() + + return savedDrillPresets.filter((preset) => { + const selection = getPresetSelection(preset) + if (!selection) return false + if (getOpeningCategory(selection.opening) !== activeCategory) return false + + if (!loweredSearchTerm) return true + + const searchableText = [ + selection.opening.name, + selection.variation?.name, + selection.opening.description, + getMaiaOpponentName(selection.maiaVersion), + selection.playerColor, + selection.targetMoveNumber === null + ? 'infinite moves' + : `${selection.targetMoveNumber} moves`, + getSelectionDetailLine(selection), + ] + .filter(Boolean) + .join(' ') + .toLowerCase() + + return searchableText.includes(loweredSearchTerm) + }) + }, [browseCategory, savedDrillPresets, searchTerm]) + const renderTabs = () => (
{[ @@ -634,7 +829,18 @@ const BrowsePanel: React.FC<{
- {filteredOpenings.length === 0 ? ( + toggleCategoryCollapse('Saved Drills')} + onSelect={onSelectSavedDrillPreset} + onRemove={onRemoveSavedDrillPreset} + /> + {filteredOpenings.length === 0 && + savedDrillCategoryPresets.length === 0 ? (
No saved positions yet. Add a FEN or PGN above to get started.
@@ -658,6 +864,7 @@ const BrowsePanel: React.FC<{ tabIndex={0} className="flex-1 cursor-pointer px-2.5 py-[8px]" onClick={() => { + onClearSelectedSavedDrillPreset() setPreviewOpening(opening) setPreviewVariation(null) trackOpeningPreviewSelected( @@ -671,6 +878,7 @@ const BrowsePanel: React.FC<{ }} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { + onClearSelectedSavedDrillPreset() setPreviewOpening(opening) setPreviewVariation(null) trackOpeningPreviewSelected( @@ -762,6 +970,7 @@ const BrowsePanel: React.FC<{ opening: Opening, variation: OpeningVariation | null, ) => { + onClearSelectedSavedDrillPreset() setPreviewOpening(opening) setPreviewVariation(variation) trackOpeningPreviewSelected( @@ -801,6 +1010,16 @@ const BrowsePanel: React.FC<{ className="red-scrollbar flex flex-1 flex-col overflow-y-auto" style={{ userSelect: 'none' }} > + toggleCategoryCollapse('Saved Drills')} + onSelect={onSelectSavedDrillPreset} + onRemove={onRemoveSavedDrillPreset} + /> {categorizedOpenings.map((category) => { const isCategoryCollapsed = searchTerm ? false @@ -972,6 +1191,9 @@ const DrillStudioPanel: React.FC<{ removeSelection: (id: string) => void onSelectQueueItem: (selection: OpeningSelection) => void handleStartDrilling: () => void + handleSaveCurrentDrill: () => void + isCurrentDrillSaved: boolean + canSaveCurrentDrill: boolean selectedMaiaVersion: (typeof MAIA3_OPPONENT_RATINGS)[0] setSelectedMaiaVersion: (version: (typeof MAIA3_OPPONENT_RATINGS)[0]) => void targetMoveNumber: number | null @@ -996,6 +1218,9 @@ const DrillStudioPanel: React.FC<{ removeSelection, onSelectQueueItem, handleStartDrilling, + handleSaveCurrentDrill, + isCurrentDrillSaved, + canSaveCurrentDrill, selectedMaiaVersion, setSelectedMaiaVersion, targetMoveNumber, @@ -1273,14 +1498,28 @@ const DrillStudioPanel: React.FC<{ {/* Fixed footer: Start button */}
- +
+ + +
@@ -1292,6 +1531,9 @@ const SelectedPanel: React.FC<{ selections: OpeningSelection[] removeSelection: (id: string) => void handleStartDrilling: () => void + handleSaveCurrentDrill: () => void + isCurrentDrillSaved: boolean + canSaveCurrentDrill: boolean selectedMaiaVersion: (typeof MAIA3_OPPONENT_RATINGS)[0] setSelectedMaiaVersion: (version: (typeof MAIA3_OPPONENT_RATINGS)[0]) => void targetMoveNumber: number | null @@ -1304,6 +1546,9 @@ const SelectedPanel: React.FC<{ selections, removeSelection, handleStartDrilling, + handleSaveCurrentDrill, + isCurrentDrillSaved, + canSaveCurrentDrill, selectedMaiaVersion, setSelectedMaiaVersion, targetMoveNumber, @@ -1457,14 +1702,28 @@ const SelectedPanel: React.FC<{ )} - +
+ + +
) @@ -1689,6 +1948,17 @@ export const OpeningSelectionModal: React.FC = ({ initialCustomDraft?.input ?? '', ) const [customError, setCustomError] = useState(null) + const [savedDrillPresets, setSavedDrillPresets] = useState< + SavedDrillPreset[] + >([]) + const [selectedSavedDrillPresetId, setSelectedSavedDrillPresetId] = useState< + string | null + >(null) + + useEffect(() => { + setSavedDrillPresets(readSavedDrillPresets()) + }, []) + const getDefaultPreviewByCategory = useCallback( (category: 'openings' | 'endgames' | 'custom'): Opening => { if (category === 'openings') { @@ -1714,6 +1984,7 @@ export const OpeningSelectionModal: React.FC = ({ setMobilePopupOpen(false) setMobilePopupOpening(null) setMobilePopupVariation(null) + setSelectedSavedDrillPresetId(null) if (!preservePreview) { const nextPreview = getDefaultPreviewByCategory(category) @@ -2208,10 +2479,12 @@ export const OpeningSelectionModal: React.FC = ({ { playerColor = selectedColor, maiaVersion = selectedMaiaVersion.id, + targetMoves = targetMoveNumber, traits = [], }: { playerColor?: 'white' | 'black' maiaVersion?: string + targetMoves?: number | null traits?: EndgameTrait[] } = {}, ) => { @@ -2233,11 +2506,14 @@ export const OpeningSelectionModal: React.FC = ({ return existingTraits === normalizedTraits } - return selection.playerColor === playerColor + return ( + selection.playerColor === playerColor && + selection.targetMoveNumber === targetMoves + ) }) ?? null ) }, - [selectedColor, selectedMaiaVersion.id, selections], + [selectedColor, selectedMaiaVersion.id, selections, targetMoveNumber], ) const isDuplicateSelection = useCallback( @@ -2473,48 +2749,277 @@ export const OpeningSelectionModal: React.FC = ({ return !!findMatchingSelection(opening, variation, { traits }) } - const handleStartDrilling = () => { - if (selections.length === 0) { - return + const persistSavedDrillPresets = useCallback( + (nextPresets: SavedDrillPreset[]) => { + setSavedDrillPresets(writeSavedDrillPresets(nextPresets)) + }, + [], + ) + + // The drill currently configured in the preview (opening + variation + the + // live opponent / color / move-count settings). Saving uses this when the + // queue is empty so tweaking a loaded preset's settings can be saved as a + // distinct drill — its signature already keys on rating, color and moves. + const currentDrillSelection = useMemo(() => { + const category = getOpeningCategory(previewOpening) + + if (category === 'endgame') { + const selectedTraits = getSelectedEndgameTraits( + previewOpening, + previewVariation, + ) + if (!selectedTraits.length) return null + + const positions = buildEndgamePositions( + previewOpening, + previewVariation, + selectedTraits, + ) + if (!positions.length) return null + + return { + id: `endgame-${previewOpening.id}-${previewVariation?.id || 'all'}-${selectedTraits + .slice() + .sort() + .join('-')}-${selectedMaiaVersion.id}`, + opening: previewOpening, + variation: previewVariation, + playerColor: 'white', + maiaVersion: selectedMaiaVersion.id, + targetMoveNumber: null, + endgameTraits: selectedTraits, + endgamePositions: positions, + endgameScope: previewVariation ? 'motif' : 'category', + } } - const configuration: DrillConfiguration = { - selections, + return { + id: `${previewOpening.id}-${previewVariation?.id || 'main'}-${selectedColor}-${selectedMaiaVersion.id}`, + opening: previewOpening, + variation: previewVariation, + playerColor: selectedColor, + maiaVersion: selectedMaiaVersion.id, + targetMoveNumber, } + }, [ + previewOpening, + previewVariation, + selectedColor, + selectedMaiaVersion.id, + targetMoveNumber, + getSelectedEndgameTraits, + buildEndgamePositions, + ]) + + // What "Save" acts on: the queue when it has drills, otherwise the single + // drill configured in the preview. + const drillsToSave = useMemo(() => { + if (selections.length > 0) return selections + return currentDrillSelection ? [currentDrillSelection] : [] + }, [selections, currentDrillSelection]) + + const canSaveCurrentDrill = drillsToSave.length > 0 + + const currentSelectionSignatures = useMemo( + () => + drillsToSave.map((selection) => + getDrillConfigurationSignature({ selections: [selection] }), + ), + [drillsToSave], + ) + + const isCurrentDrillSaved = useMemo( + () => + currentSelectionSignatures.length > 0 && + currentSelectionSignatures.every((signature) => + savedDrillPresets.some((preset) => preset.signature === signature), + ), + [currentSelectionSignatures, savedDrillPresets], + ) + + const hydrateCustomOpeningsFromSelections = useCallback( + (nextSelections: OpeningSelection[]) => { + const savedCustomOpenings = nextSelections + .map((selection) => selection.opening) + .filter( + (opening) => + opening.isCustom || + (opening.categoryType ?? 'opening') === 'custom', + ) + .map((opening) => ({ + ...opening, + isCustom: true, + categoryType: 'custom' as const, + variations: (opening.variations || []).map((variation) => ({ + ...variation, + isCustom: true, + })), + })) + + if (!savedCustomOpenings.length) { + return + } - // Track drill configuration completion - const uniqueOpenings = new Set(selections.map((s) => s.opening.id)).size - const numericTargets = selections - .map((selection) => - typeof selection.targetMoveNumber === 'number' - ? selection.targetMoveNumber - : null, + setCustomOpenings((previousCustomOpenings) => { + const merged = new Map() + + previousCustomOpenings.forEach((opening) => { + merged.set(opening.id, opening) + }) + + savedCustomOpenings.forEach((opening) => { + merged.set(opening.id, opening) + }) + + return Array.from(merged.values()) + }) + }, + [], + ) + + const handleSelectSavedDrillPreset = useCallback( + (preset: SavedDrillPreset) => { + const configuration = cloneDrillConfiguration(preset.configuration) + const selection = configuration.selections[0] ?? null + + if (!selection) { + return + } + + hydrateCustomOpeningsFromSelections([selection]) + setSelectedSavedDrillPresetId(preset.id) + + if ( + getOpeningCategory(selection.opening) === 'endgame' && + selection.endgameTraits + ) { + const key = getTraitSelectionKey( + selection.opening.id, + selection.variation?.id ?? null, + ) + setEndgameTraitSelections((previousSelections) => ({ + ...previousSelections, + [key]: selection.endgameTraits ?? [], + })) + } + + const category = getOpeningCategory(selection.opening) + setBrowseCategory( + category === 'endgame' + ? 'endgames' + : category === 'custom' + ? 'custom' + : 'openings', ) - .filter((value): value is number => value !== null) - const averageTargetMoves = - numericTargets.length > 0 - ? numericTargets.reduce((sum, value) => sum + value, 0) / - numericTargets.length - : 0 - const maiaVersionsUsed = [...new Set(selections.map((s) => s.maiaVersion))] - const colorDistribution = selections.reduce( - (acc, s) => { - acc[s.playerColor]++ - return acc - }, - { white: 0, black: 0 }, - ) + setPreviewOpening(selection.opening) + setPreviewVariation(selection.variation ?? null) + setSelectedColor(category === 'endgame' ? 'white' : selection.playerColor) + setTargetMoveNumber(selection.targetMoveNumber) + setSelectedMaiaVersion( + MAIA3_OPPONENT_RATINGS.find( + (version) => version.id === selection.maiaVersion, + ) ?? defaultMaiaVersion, + ) + setSearchTerm('') + + if (isMobile) { + setMobilePopupOpening(selection.opening) + setMobilePopupVariation(selection.variation ?? null) + setMobilePopupOpen(true) + } else { + setMobilePopupOpen(false) + setMobilePopupOpening(null) + setMobilePopupVariation(null) + } + + setActiveTab('browse') + }, + [defaultMaiaVersion, hydrateCustomOpeningsFromSelections, isMobile], + ) + + const handleSaveCurrentDrill = useCallback(() => { + if (!drillsToSave.length) { + return + } - trackDrillConfigurationCompleted( - selections.length, - selections.length, // Use selections length for drill count - uniqueOpenings, - averageTargetMoves, - maiaVersionsUsed, - colorDistribution, + const nextPresets = upsertSavedDrillPreset( + { selections: drillsToSave }, + savedDrillPresets, ) + persistSavedDrillPresets(nextPresets) + }, [drillsToSave, persistSavedDrillPresets, savedDrillPresets]) - onComplete(configuration) + const handleRemoveSavedDrillPreset = useCallback( + (presetId: string) => { + persistSavedDrillPresets( + savedDrillPresets.filter((preset) => preset.id !== presetId), + ) + setSelectedSavedDrillPresetId((currentPresetId) => + currentPresetId === presetId ? null : currentPresetId, + ) + }, + [persistSavedDrillPresets, savedDrillPresets], + ) + + const startDrillConfiguration = useCallback( + (configuration: DrillConfiguration) => { + if (configuration.selections.length === 0) { + return + } + + const clonedConfiguration = cloneDrillConfiguration(configuration) + const drillSelections = clonedConfiguration.selections + + if (drillSelections.length === 0) { + return + } + + // Track drill configuration completion + const uniqueOpenings = new Set(drillSelections.map((s) => s.opening.id)) + .size + const numericTargets = drillSelections + .map((selection) => + typeof selection.targetMoveNumber === 'number' + ? selection.targetMoveNumber + : null, + ) + .filter((value): value is number => value !== null) + const averageTargetMoves = + numericTargets.length > 0 + ? numericTargets.reduce((sum, value) => sum + value, 0) / + numericTargets.length + : 0 + const maiaVersionsUsed = [ + ...new Set(drillSelections.map((s) => s.maiaVersion)), + ] + const colorDistribution = drillSelections.reduce( + (acc, s) => { + acc[s.playerColor]++ + return acc + }, + { white: 0, black: 0 }, + ) + + trackDrillConfigurationCompleted( + drillSelections.length, + drillSelections.length, + uniqueOpenings, + averageTargetMoves, + maiaVersionsUsed, + colorDistribution, + ) + + onComplete(clonedConfiguration) + }, + [onComplete], + ) + + const handleStartDrilling = () => { + if (selections.length === 0) { + return + } + + startDrillConfiguration({ selections }) } const previewCategoryType = getOpeningCategory(previewOpening) @@ -2676,6 +3181,13 @@ export const OpeningSelectionModal: React.FC = ({ onAddCustomPosition={handleAddCustomPosition} categoryLabel={categoryLabel} categoryLabelPlural={categoryLabelPlural} + savedDrillPresets={savedDrillPresets} + selectedSavedDrillPresetId={selectedSavedDrillPresetId} + onSelectSavedDrillPreset={handleSelectSavedDrillPreset} + onRemoveSavedDrillPreset={handleRemoveSavedDrillPreset} + onClearSelectedSavedDrillPreset={() => + setSelectedSavedDrillPresetId(null) + } /> = ({ } }} handleStartDrilling={handleStartDrilling} + handleSaveCurrentDrill={handleSaveCurrentDrill} + isCurrentDrillSaved={isCurrentDrillSaved} + canSaveCurrentDrill={canSaveCurrentDrill} selectedMaiaVersion={selectedMaiaVersion} setSelectedMaiaVersion={setSelectedMaiaVersion} targetMoveNumber={targetMoveNumber} @@ -2741,6 +3256,9 @@ export const OpeningSelectionModal: React.FC = ({ categoryLabel={categoryLabel} categoryLabelPlural={categoryLabelPlural} showTargetSlider={browseCategory === 'openings'} + handleSaveCurrentDrill={handleSaveCurrentDrill} + isCurrentDrillSaved={isCurrentDrillSaved} + canSaveCurrentDrill={canSaveCurrentDrill} /> diff --git a/src/lib/index.ts b/src/lib/index.ts index 25947bff..f52d8b7e 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -5,4 +5,5 @@ export * from './favorites' export * from './lichess' export * from './puzzle' export * from './ratingUtils' +export * from './savedDrills' export * from './settings' diff --git a/src/lib/savedDrills.ts b/src/lib/savedDrills.ts new file mode 100644 index 00000000..d8afb996 --- /dev/null +++ b/src/lib/savedDrills.ts @@ -0,0 +1,265 @@ +import { + DrillCategoryType, + DrillConfiguration, + OpeningSelection, +} from 'src/types/openings' + +export interface SavedDrillPreset { + id: string + name: string + configuration: DrillConfiguration + selectionCount: number + createdAt: string + updatedAt: string + signature: string +} + +export const SAVED_DRILL_PRESETS_STORAGE_KEY = 'maia-saved-drill-presets' + +const MAX_SAVED_DRILL_PRESETS = 24 + +export const cloneDrillConfiguration = ( + configuration: DrillConfiguration, +): DrillConfiguration => ({ + selections: JSON.parse( + JSON.stringify(configuration.selections ?? []), + ) as OpeningSelection[], +}) + +const getSelectionCategory = ( + selection: OpeningSelection, +): DrillCategoryType => { + if (selection.opening.categoryType) return selection.opening.categoryType + return selection.opening.isCustom ? 'custom' : 'opening' +} + +export const getDrillSelectionSignature = (selection: OpeningSelection) => { + const category = getSelectionCategory(selection) + const isCustom = category === 'custom' || selection.opening.isCustom + + return { + category, + openingId: selection.opening.id, + openingFen: isCustom ? selection.opening.fen : undefined, + openingPgn: isCustom ? selection.opening.pgn : undefined, + openingSetupFen: isCustom ? selection.opening.setupFen : undefined, + variationId: selection.variation?.id ?? null, + variationFen: isCustom ? selection.variation?.fen : undefined, + variationPgn: isCustom ? selection.variation?.pgn : undefined, + playerColor: selection.playerColor, + maiaVersion: selection.maiaVersion, + targetMoveNumber: selection.targetMoveNumber, + endgameTraits: [...(selection.endgameTraits ?? [])].sort(), + endgameScope: selection.endgameScope ?? null, + endgamePositions: + selection.endgamePositions?.map((position) => ({ + fen: position.fen, + trait: position.trait, + index: position.index, + })) ?? null, + } +} + +export const getDrillConfigurationSignature = ( + configuration: DrillConfiguration, +): string => + JSON.stringify( + (configuration.selections ?? []).map((selection) => + getDrillSelectionSignature(selection), + ), + ) + +const getSingleSelectionConfiguration = ( + selection: OpeningSelection, +): DrillConfiguration => cloneDrillConfiguration({ selections: [selection] }) + +export const getDefaultSavedDrillPresetName = ( + configuration: DrillConfiguration, +): string => { + const selections = configuration.selections ?? [] + const firstSelection = selections[0] + + if (!firstSelection) { + return 'Saved Drill' + } + + const firstName = firstSelection.variation + ? `${firstSelection.opening.name}: ${firstSelection.variation.name}` + : firstSelection.opening.name + + if (selections.length === 1) { + return firstName + } + + return `${firstName} + ${selections.length - 1}` +} + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null + +const createPresetFromSelection = ({ + selection, + id, + name, + createdAt, + updatedAt, +}: { + selection: OpeningSelection + id: string + name?: string + createdAt: string + updatedAt: string +}): SavedDrillPreset => { + const configuration = getSingleSelectionConfiguration(selection) + const signature = getDrillConfigurationSignature(configuration) + + return { + id, + name: name ?? getDefaultSavedDrillPresetName(configuration), + configuration, + selectionCount: 1, + createdAt, + updatedAt, + signature, + } +} + +const normalizeSavedDrillPreset = (value: unknown): SavedDrillPreset[] => { + if (!isRecord(value)) { + return [] + } + + if ( + typeof value.id !== 'string' || + typeof value.name !== 'string' || + typeof value.createdAt !== 'string' || + typeof value.updatedAt !== 'string' || + !isRecord(value.configuration) + ) { + return [] + } + + const rawSelections = value.configuration.selections + if (!Array.isArray(rawSelections)) { + return [] + } + + const presetId = value.id + const presetName = value.name + const createdAt = value.createdAt + const updatedAt = value.updatedAt + + const configuration = cloneDrillConfiguration({ + selections: rawSelections as OpeningSelection[], + }) + + return configuration.selections.map((selection, index) => + createPresetFromSelection({ + selection, + id: + configuration.selections.length === 1 + ? presetId + : `${presetId}-${index + 1}`, + name: + configuration.selections.length === 1 + ? presetName + : getDefaultSavedDrillPresetName({ selections: [selection] }), + createdAt, + updatedAt, + }), + ) +} + +export const readSavedDrillPresets = (): SavedDrillPreset[] => { + if (typeof window === 'undefined') { + return [] + } + + try { + const stored = window.localStorage.getItem(SAVED_DRILL_PRESETS_STORAGE_KEY) + if (!stored) { + return [] + } + + const parsed = JSON.parse(stored) + if (!Array.isArray(parsed)) { + return [] + } + + const uniquePresets = new Map() + + parsed.flatMap(normalizeSavedDrillPreset).forEach((preset) => { + if (!uniquePresets.has(preset.signature)) { + uniquePresets.set(preset.signature, preset) + } + }) + + return Array.from(uniquePresets.values()).slice(0, MAX_SAVED_DRILL_PRESETS) + } catch (error) { + console.warn('Failed to parse saved drill presets:', error) + return [] + } +} + +export const writeSavedDrillPresets = ( + presets: SavedDrillPreset[], +): SavedDrillPreset[] => { + const normalizedPresets = presets.slice(0, MAX_SAVED_DRILL_PRESETS) + + if (typeof window === 'undefined') { + return normalizedPresets + } + + try { + window.localStorage.setItem( + SAVED_DRILL_PRESETS_STORAGE_KEY, + JSON.stringify(normalizedPresets), + ) + } catch (error) { + console.warn('Failed to save drill presets:', error) + } + + return normalizedPresets +} + +export const upsertSavedDrillPreset = ( + configuration: DrillConfiguration, + existingPresets: SavedDrillPreset[], +): SavedDrillPreset[] => { + if (!configuration.selections.length) { + return existingPresets + } + + const clonedConfiguration = cloneDrillConfiguration(configuration) + const now = new Date().toISOString() + const nextPresets = clonedConfiguration.selections.map((selection, index) => { + const singleSelectionConfiguration = + getSingleSelectionConfiguration(selection) + const signature = getDrillConfigurationSignature( + singleSelectionConfiguration, + ) + const existingPreset = existingPresets.find( + (preset) => preset.signature === signature, + ) + + return createPresetFromSelection({ + selection, + id: + existingPreset?.id ?? + `saved-drill-${Date.now()}-${index}-${Math.random() + .toString(36) + .slice(2, 8)}`, + name: existingPreset?.name, + createdAt: existingPreset?.createdAt ?? now, + updatedAt: now, + }) + }) + + const nextSignatures = new Set(nextPresets.map((preset) => preset.signature)) + return [ + ...nextPresets, + ...existingPresets.filter( + (existing) => !nextSignatures.has(existing.signature), + ), + ].slice(0, MAX_SAVED_DRILL_PRESETS) +}