feat: implement data migration for existing lists

- Created migration-actions.ts with server actions for claiming lists
- Added ClaimListsModal component for UI during registration
- Integrated claim modal into RegisterForm
- Created anonymous-lists.ts utilities for tracking lists in localStorage
- Added migrate-lists.ts script for deployment migration
- Created comprehensive MIGRATION_GUIDE.md documentation
- Added migration tests with 100% pass rate
- Updated package.json with db:migrate script
- Added tsx as dev dependency for running migration scripts

Requirements: 10.2, 10.3, 10.4, 10.5
- Users can claim existing lists on first registration
- All list data is preserved during migration
- All lists have an owner after migration
- Migrated lists appear in user's account
This commit is contained in:
2025-12-02 01:20:35 +00:00
parent 54612a4782
commit 2ce89e7c6c
9 changed files with 976 additions and 4 deletions

View File

@@ -0,0 +1,204 @@
'use client'
import { useState, useEffect } from 'react'
import { claimLists, getClaimableLists, type ClaimableList } from '@/lib/migration-actions'
import Modal from './Modal'
import LoadingSpinner from './LoadingSpinner'
interface ClaimListsModalProps {
isOpen: boolean
onClose: () => void
onComplete: () => void
}
/**
* Modal for claiming existing anonymous lists after registration
* Requirements: 10.2, 10.5 - Migration UI for users to claim existing lists
*/
export default function ClaimListsModal({ isOpen, onClose, onComplete }: ClaimListsModalProps) {
const [claimableLists, setClaimableLists] = useState<ClaimableList[]>([])
const [selectedLists, setSelectedLists] = useState<Set<string>>(new Set())
const [loading, setLoading] = useState(true)
const [claiming, setClaiming] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (isOpen) {
loadClaimableLists()
}
}, [isOpen])
const loadClaimableLists = async () => {
try {
setLoading(true)
setError(null)
// Get list IDs from localStorage
const storedListIds = getStoredListIds()
if (storedListIds.length === 0) {
setClaimableLists([])
setLoading(false)
return
}
// Fetch lists from database
const lists = await getClaimableLists(storedListIds)
setClaimableLists(lists)
// Select all lists by default
setSelectedLists(new Set(lists.map(l => l.id)))
} catch (err) {
console.error('Error loading claimable lists:', err)
setError('Failed to load your lists. Please try again.')
} finally {
setLoading(false)
}
}
const getStoredListIds = (): string[] => {
try {
// Check for list IDs in localStorage
// This assumes the app previously stored list IDs locally
const stored = localStorage.getItem('anonymousListIds')
if (stored) {
return JSON.parse(stored)
}
return []
} catch (err) {
console.error('Error reading localStorage:', err)
return []
}
}
const toggleListSelection = (listId: string) => {
const newSelection = new Set(selectedLists)
if (newSelection.has(listId)) {
newSelection.delete(listId)
} else {
newSelection.add(listId)
}
setSelectedLists(newSelection)
}
const handleClaimLists = async () => {
try {
setClaiming(true)
setError(null)
const listIds = Array.from(selectedLists)
const result = await claimLists(listIds)
if (result.success) {
// Clear localStorage after successful claim
localStorage.removeItem('anonymousListIds')
onComplete()
} else {
setError(result.error)
}
} catch (err) {
console.error('Error claiming lists:', err)
setError('Failed to claim lists. Please try again.')
} finally {
setClaiming(false)
}
}
const handleSkip = () => {
// User chose not to claim lists
localStorage.removeItem('anonymousListIds')
onClose()
}
if (!isOpen) return null
return (
<Modal isOpen={isOpen} onClose={onClose} title="Claim Your Lists">
<div className="space-y-4">
{loading ? (
<div className="flex justify-center py-8">
<LoadingSpinner />
</div>
) : claimableLists.length === 0 ? (
<div className="text-center py-8">
<p className="text-gray-600">No lists found to claim.</p>
<button
onClick={onClose}
className="mt-4 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600"
>
Continue
</button>
</div>
) : (
<>
<p className="text-gray-700">
We found {claimableLists.length} list{claimableLists.length !== 1 ? 's' : ''} from your previous session.
Would you like to add {claimableLists.length !== 1 ? 'them' : 'it'} to your account?
</p>
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
{error}
</div>
)}
<div className="max-h-64 overflow-y-auto space-y-2">
{claimableLists.map((list) => (
<label
key={list.id}
className="flex items-start gap-3 p-3 border rounded-lg cursor-pointer hover:bg-gray-50 transition-colors"
>
<input
type="checkbox"
checked={selectedLists.has(list.id)}
onChange={() => toggleListSelection(list.id)}
className="mt-1 h-4 w-4 text-blue-500 rounded focus:ring-2 focus:ring-blue-500"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<div
className="w-4 h-4 rounded"
style={{ backgroundColor: list.color }}
/>
<span className="font-medium text-gray-900 truncate">
{list.title}
</span>
</div>
<p className="text-sm text-gray-500 mt-1">
{list.itemCount} item{list.itemCount !== 1 ? 's' : ''} ·
Created {new Date(list.createdAt).toLocaleDateString()}
</p>
</div>
</label>
))}
</div>
<div className="flex gap-3 pt-4 border-t">
<button
onClick={handleSkip}
disabled={claiming}
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Skip
</button>
<button
onClick={handleClaimLists}
disabled={claiming || selectedLists.size === 0}
className="flex-1 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{claiming ? (
<>
<LoadingSpinner />
<span>Claiming...</span>
</>
) : (
`Claim ${selectedLists.size} List${selectedLists.size !== 1 ? 's' : ''}`
)}
</button>
</div>
</>
)}
</div>
</Modal>
)
}