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)
+}