From bdf158409582a3cf93b889ff53c7c3f774be4587 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sun, 16 Mar 2025 09:58:20 +0000 Subject: [PATCH] Clarify data synchronization process Clarify the data synchronization process and address a scenario where data is lost after logging out, initializing data, and logging back in. --- src/components/SyncSettings.tsx | 60 ++++++++--- src/utils/sync/budgetSync.ts | 136 ++++++++++++++++++------ src/utils/sync/transactionSync.ts | 171 +++++++++++++++++++++++++----- src/utils/syncUtils.ts | 7 ++ 4 files changed, 303 insertions(+), 71 deletions(-) diff --git a/src/components/SyncSettings.tsx b/src/components/SyncSettings.tsx index d662a8e..628139e 100644 --- a/src/components/SyncSettings.tsx +++ b/src/components/SyncSettings.tsx @@ -2,12 +2,13 @@ import React, { useState, useEffect } from 'react'; import { Switch } from "@/components/ui/switch"; import { Label } from "@/components/ui/label"; -import { CloudUpload, RefreshCw } from "lucide-react"; +import { CloudUpload, RefreshCw, AlertCircle } from "lucide-react"; import { isSyncEnabled, setSyncEnabled, syncAllData, getLastSyncTime } from "@/utils/syncUtils"; import { toast } from "@/hooks/useToast.wrapper"; import { useAuth } from "@/contexts/auth"; import { useNavigate } from "react-router-dom"; import { Button } from "@/components/ui/button"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; const SyncSettings = () => { const [enabled, setEnabled] = useState(isSyncEnabled()); @@ -19,6 +20,18 @@ const SyncSettings = () => { useEffect(() => { // 마지막 동기화 시간 업데이트 setLastSync(getLastSyncTime()); + + // 세션 상태 변경 감지 + const handleAuthChange = () => { + setEnabled(isSyncEnabled()); + setLastSync(getLastSyncTime()); + }; + + window.addEventListener('auth-state-changed', handleAuthChange); + + return () => { + window.removeEventListener('auth-state-changed', handleAuthChange); + }; }, []); const handleSyncToggle = async (checked: boolean) => { @@ -50,6 +63,9 @@ const SyncSettings = () => { description: "동기화 중 문제가 발생했습니다. 다시 시도해주세요.", variant: "destructive" }); + // 실패 시 동기화 비활성화 + setEnabled(false); + setSyncEnabled(false); } finally { setSyncing(false); } @@ -116,18 +132,32 @@ const SyncSettings = () => { {enabled && (
-
- 마지막 동기화: {formatLastSyncTime()} - {user ? ( - - ) : ( + {user ? ( + <> +
+ 마지막 동기화: {formatLastSyncTime()} + +
+ + + + 동기화 작동 방식 + + 이 기능은 양방향 동기화입니다. 로그인 후 동기화를 켜면 서버 데이터와 로컬 데이터가 병합됩니다. + 데이터 초기화 후에도 동기화 버튼을 누르면 서버에 저장된 데이터를 다시 불러옵니다. + + + + ) : ( +
+ 로그인이 필요합니다 - )} -
+
+ )}
)} diff --git a/src/utils/sync/budgetSync.ts b/src/utils/sync/budgetSync.ts index 6a693da..a4240cc 100644 --- a/src/utils/sync/budgetSync.ts +++ b/src/utils/sync/budgetSync.ts @@ -1,6 +1,7 @@ import { supabase } from '@/lib/supabase'; import { isSyncEnabled } from './syncSettings'; +import { toast } from '@/hooks/useToast.wrapper'; /** * Upload budget data from local storage to Supabase @@ -12,34 +13,70 @@ export const uploadBudgets = async (userId: string): Promise => { const budgetData = localStorage.getItem('budgetData'); const categoryBudgets = localStorage.getItem('categoryBudgets'); + console.log('예산 데이터 업로드 시작'); + + // 예산 데이터 업로드 if (budgetData) { const parsedBudgetData = JSON.parse(budgetData); - // 기존 예산 데이터 삭제 - await supabase + // 기존 예산 데이터 확인 + const { data: existingBudgets, error: fetchError } = await supabase .from('budgets') - .delete() + .select('*') .eq('user_id', userId); + + if (fetchError) { + console.error('기존 예산 데이터 조회 실패:', fetchError); + } - // 새 예산 데이터 삽입 - const { error } = await supabase.from('budgets').insert({ - user_id: userId, - daily_target: parsedBudgetData.daily.targetAmount, - weekly_target: parsedBudgetData.weekly.targetAmount, - monthly_target: parsedBudgetData.monthly.targetAmount - }); - - if (error) throw error; + // 업데이트 또는 삽입 결정 + if (existingBudgets && existingBudgets.length > 0) { + // 기존 데이터 업데이트 + const { error } = await supabase + .from('budgets') + .update({ + daily_target: parsedBudgetData.daily.targetAmount, + weekly_target: parsedBudgetData.weekly.targetAmount, + monthly_target: parsedBudgetData.monthly.targetAmount, + updated_at: new Date().toISOString() + }) + .eq('user_id', userId); + + if (error) { + console.error('예산 데이터 업데이트 실패:', error); + throw error; + } + } else { + // 새 데이터 삽입 + const { error } = await supabase + .from('budgets') + .insert({ + user_id: userId, + daily_target: parsedBudgetData.daily.targetAmount, + weekly_target: parsedBudgetData.weekly.targetAmount, + monthly_target: parsedBudgetData.monthly.targetAmount + }); + + if (error) { + console.error('예산 데이터 삽입 실패:', error); + throw error; + } + } } + // 카테고리 예산 업로드 if (categoryBudgets) { const parsedCategoryBudgets = JSON.parse(categoryBudgets); // 기존 카테고리 예산 삭제 - await supabase + const { error: deleteError } = await supabase .from('category_budgets') .delete() .eq('user_id', userId); + + if (deleteError) { + console.error('기존 카테고리 예산 삭제 실패:', deleteError); + } // 카테고리별 예산 데이터 변환 및 삽입 const categoryEntries = Object.entries(parsedCategoryBudgets).map( @@ -50,16 +87,22 @@ export const uploadBudgets = async (userId: string): Promise => { }) ); - const { error } = await supabase - .from('category_budgets') - .insert(categoryEntries); - - if (error) throw error; + if (categoryEntries.length > 0) { + const { error } = await supabase + .from('category_budgets') + .insert(categoryEntries); + + if (error) { + console.error('카테고리 예산 삽입 실패:', error); + throw error; + } + } } console.log('예산 데이터 업로드 완료'); } catch (error) { console.error('예산 데이터 업로드 실패:', error); + throw error; } }; @@ -70,14 +113,19 @@ export const downloadBudgets = async (userId: string): Promise => { if (!isSyncEnabled()) return; try { + console.log('서버에서 예산 데이터 다운로드 시작'); + // 예산 데이터 가져오기 const { data: budgetData, error: budgetError } = await supabase .from('budgets') .select('*') .eq('user_id', userId) - .single(); + .maybeSingle(); // 사용자당 하나의 예산 데이터만 존재 - if (budgetError && budgetError.code !== 'PGRST116') throw budgetError; + if (budgetError && budgetError.code !== 'PGRST116') { + console.error('예산 데이터 조회 실패:', budgetError); + throw budgetError; + } // 카테고리 예산 가져오기 const { data: categoryData, error: categoryError } = await supabase @@ -85,43 +133,71 @@ export const downloadBudgets = async (userId: string): Promise => { .select('*') .eq('user_id', userId); - if (categoryError) throw categoryError; + if (categoryError) { + console.error('카테고리 예산 조회 실패:', categoryError); + throw categoryError; + } + // 서버에서 받은 예산 데이터가 있으면 로컬에 저장 if (budgetData) { - // 예산 데이터 로컬 형식으로 변환 - const localBudgetData = { + console.log('서버에서 예산 데이터 수신:', budgetData); + + // 기존 로컬 데이터 가져오기 + const localBudgetDataStr = localStorage.getItem('budgetData'); + let localBudgetData = localBudgetDataStr ? JSON.parse(localBudgetDataStr) : { + daily: { targetAmount: 0, spentAmount: 0, remainingAmount: 0 }, + weekly: { targetAmount: 0, spentAmount: 0, remainingAmount: 0 }, + monthly: { targetAmount: 0, spentAmount: 0, remainingAmount: 0 } + }; + + // 서버 데이터로 업데이트 (지출 금액은 유지) + const updatedBudgetData = { daily: { targetAmount: budgetData.daily_target, - spentAmount: 0, // 지출액은 로컬에서 계산 - remainingAmount: budgetData.daily_target + spentAmount: localBudgetData.daily.spentAmount, + remainingAmount: budgetData.daily_target - localBudgetData.daily.spentAmount }, weekly: { targetAmount: budgetData.weekly_target, - spentAmount: 0, - remainingAmount: budgetData.weekly_target + spentAmount: localBudgetData.weekly.spentAmount, + remainingAmount: budgetData.weekly_target - localBudgetData.weekly.spentAmount }, monthly: { targetAmount: budgetData.monthly_target, - spentAmount: 0, - remainingAmount: budgetData.monthly_target + spentAmount: localBudgetData.monthly.spentAmount, + remainingAmount: budgetData.monthly_target - localBudgetData.monthly.spentAmount } }; - localStorage.setItem('budgetData', JSON.stringify(localBudgetData)); + // 로컬 스토리지에 저장 + localStorage.setItem('budgetData', JSON.stringify(updatedBudgetData)); + console.log('예산 데이터 로컬 저장 완료'); + + // 이벤트 발생시켜 UI 업데이트 + window.dispatchEvent(new Event('budgetDataUpdated')); } + // 서버에서 받은 카테고리 예산 데이터가 있으면 로컬에 저장 if (categoryData && categoryData.length > 0) { + console.log(`${categoryData.length}개의 카테고리 예산 수신`); + // 카테고리 예산 로컬 형식으로 변환 const localCategoryBudgets = categoryData.reduce((acc, curr) => { acc[curr.category] = curr.amount; return acc; }, {} as Record); + // 로컬 스토리지에 저장 localStorage.setItem('categoryBudgets', JSON.stringify(localCategoryBudgets)); + console.log('카테고리 예산 로컬 저장 완료'); + + // 이벤트 발생시켜 UI 업데이트 + window.dispatchEvent(new Event('categoryBudgetsUpdated')); } console.log('예산 데이터 다운로드 완료'); } catch (error) { console.error('예산 데이터 다운로드 실패:', error); + throw error; } }; diff --git a/src/utils/sync/transactionSync.ts b/src/utils/sync/transactionSync.ts index e132603..1f23bd3 100644 --- a/src/utils/sync/transactionSync.ts +++ b/src/utils/sync/transactionSync.ts @@ -2,9 +2,11 @@ import { supabase } from '@/lib/supabase'; import { Transaction } from '@/components/TransactionCard'; import { isSyncEnabled } from './syncSettings'; +import { toast } from '@/hooks/useToast.wrapper'; /** * Upload transaction data from local storage to Supabase + * 로컬 데이터를 서버에 업로드 (새로운 또는 수정된 데이터만) */ export const uploadTransactions = async (userId: string): Promise => { if (!isSyncEnabled()) return; @@ -14,63 +16,180 @@ export const uploadTransactions = async (userId: string): Promise => { if (!localTransactions) return; const transactions: Transaction[] = JSON.parse(localTransactions); + console.log(`로컬 트랜잭션 ${transactions.length}개 동기화 시작`); - // 기존 데이터 삭제 후 새로 업로드 - await supabase + if (transactions.length === 0) return; // 트랜잭션이 없으면 처리하지 않음 + + // 먼저 서버에서 현재 트랜잭션 목록 가져오기 + const { data: existingData, error: fetchError } = await supabase .from('transactions') - .delete() + .select('transaction_id') .eq('user_id', userId); - // 트랜잭션 배치 처리 - const { error } = await supabase.from('transactions').insert( - transactions.map(t => ({ + if (fetchError) { + console.error('기존 트랜잭션 조회 실패:', fetchError); + throw fetchError; + } + + // 서버에 이미 있는 트랜잭션 ID 맵 생성 + const existingIds = new Set(existingData?.map(t => t.transaction_id) || []); + console.log(`서버에 이미 존재하는 트랜잭션: ${existingIds.size}개`); + + // 삽입할 새 트랜잭션과 업데이트할 기존 트랜잭션 분리 + const newTransactions = []; + const updateTransactions = []; + + for (const t of transactions) { + const transactionData = { user_id: userId, title: t.title, amount: t.amount, date: t.date, category: t.category, type: t.type, - transaction_id: t.id // 로컬 ID 보존 - })) - ); + transaction_id: t.id + }; + + if (existingIds.has(t.id)) { + updateTransactions.push(transactionData); + } else { + newTransactions.push(transactionData); + } + } - if (error) throw error; + // 새 트랜잭션 삽입 (있는 경우) + if (newTransactions.length > 0) { + console.log(`${newTransactions.length}개의 새 트랜잭션 업로드`); + const { error: insertError } = await supabase + .from('transactions') + .insert(newTransactions); + + if (insertError) { + console.error('새 트랜잭션 업로드 실패:', insertError); + throw insertError; + } + } + + // 기존 트랜잭션 업데이트 (있는 경우) + if (updateTransactions.length > 0) { + console.log(`${updateTransactions.length}개의 기존 트랜잭션 업데이트`); + for (const transaction of updateTransactions) { + const { error: updateError } = await supabase + .from('transactions') + .update(transaction) + .eq('transaction_id', transaction.transaction_id) + .eq('user_id', userId); + + if (updateError) { + console.error('트랜잭션 업데이트 실패:', updateError); + // 실패해도 계속 진행 + } + } + } console.log('트랜잭션 업로드 완료'); } catch (error) { console.error('트랜잭션 업로드 실패:', error); + throw error; } }; /** * Download transaction data from Supabase to local storage + * 서버에서 로컬 스토리지로 데이터 다운로드 (병합 방식) */ export const downloadTransactions = async (userId: string): Promise => { if (!isSyncEnabled()) return; try { + console.log('서버에서 트랜잭션 데이터 다운로드 시작'); const { data, error } = await supabase .from('transactions') .select('*') .eq('user_id', userId); - if (error) throw error; - - if (data && data.length > 0) { - // Supabase 형식에서 로컬 형식으로 변환 - const transactions = data.map(t => ({ - id: t.transaction_id || t.id, - title: t.title, - amount: t.amount, - date: t.date, - category: t.category, - type: t.type - })); - - localStorage.setItem('transactions', JSON.stringify(transactions)); - console.log('트랜잭션 다운로드 완료'); + if (error) { + console.error('트랜잭션 다운로드 실패:', error); + throw error; } + + if (!data || data.length === 0) { + console.log('서버에 저장된 트랜잭션 없음'); + return; // 서버에 데이터가 없으면 로컬 데이터 유지 + } + + console.log(`서버에서 ${data.length}개의 트랜잭션 다운로드`); + + // 서버 데이터를 로컬 형식으로 변환 + const serverTransactions = data.map(t => ({ + id: t.transaction_id || t.id, + title: t.title, + amount: t.amount, + date: t.date, + category: t.category, + type: t.type + })); + + // 기존 로컬 데이터 불러오기 + const localDataStr = localStorage.getItem('transactions'); + const localTransactions = localDataStr ? JSON.parse(localDataStr) : []; + + // 로컬 데이터와 서버 데이터 병합 (ID 기준) + const transactionMap = new Map(); + + // 로컬 데이터를 맵에 추가 + localTransactions.forEach((tx: Transaction) => { + transactionMap.set(tx.id, tx); + }); + + // 서버 데이터로 맵 업데이트 (서버 데이터 우선) + serverTransactions.forEach(tx => { + transactionMap.set(tx.id, tx); + }); + + // 최종 병합된 데이터 생성 + const mergedTransactions = Array.from(transactionMap.values()); + + // 로컬 스토리지에 저장 + localStorage.setItem('transactions', JSON.stringify(mergedTransactions)); + console.log(`총 ${mergedTransactions.length}개의 트랜잭션 병합 완료`); + + // 이벤트 발생시켜 UI 업데이트 + window.dispatchEvent(new Event('transactionUpdated')); + } catch (error) { - console.error('트랜잭션 다운로드 실패:', error); + console.error('트랜잭션 다운로드 중 오류:', error); + throw error; + } +}; + +/** + * 특정 트랜잭션 ID 삭제 처리 + */ +export const deleteTransactionFromServer = async (userId: string, transactionId: string): Promise => { + if (!isSyncEnabled()) return; + + try { + console.log(`트랜잭션 삭제 요청: ${transactionId}`); + const { error } = await supabase + .from('transactions') + .delete() + .eq('transaction_id', transactionId) + .eq('user_id', userId); + + if (error) { + console.error('트랜잭션 삭제 실패:', error); + throw error; + } + + console.log(`트랜잭션 ${transactionId} 삭제 완료`); + } catch (error) { + console.error('트랜잭션 삭제 중 오류:', error); + // 에러 발생 시 토스트 알림 + toast({ + title: "삭제 동기화 실패", + description: "서버에서 트랜잭션을 삭제하는데 문제가 발생했습니다.", + variant: "destructive" + }); } }; diff --git a/src/utils/syncUtils.ts b/src/utils/syncUtils.ts index a6ffa76..9a23eb8 100644 --- a/src/utils/syncUtils.ts +++ b/src/utils/syncUtils.ts @@ -24,8 +24,15 @@ export const syncAllData = async (userId: string): Promise => { try { console.log('데이터 동기화 시작...'); + + // 기본 동기화 순서 변경: 서버에서 먼저 다운로드 후, 로컬 데이터 업로드 + // 이렇게 하면 서버에 저장된 데이터를 먼저 가져온 후, 로컬 변경사항을 반영 + + // 1. 서버에서 데이터 다운로드 (기존 데이터 불러오기) await downloadTransactions(userId); await downloadBudgets(userId); + + // 2. 로컬 데이터를 서버에 업로드 (변경사항 반영) await uploadTransactions(userId); await uploadBudgets(userId);