feat: Add CI/CD pipeline and code quality improvements

- Add GitHub Actions workflow for automated CI/CD
- Configure Node.js 18.x and 20.x matrix testing
- Add TypeScript type checking step
- Add ESLint code quality checks with enhanced rules
- Add Prettier formatting verification
- Add production build validation
- Upload build artifacts for deployment
- Set up automated testing on push/PR
- Replace console.log with environment-aware logger
- Add pre-commit hooks for code quality
- Exclude archive folder from linting

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
hansoo
2025-07-12 15:27:54 +09:00
parent 6a208d6b06
commit 9851627ff1
411 changed files with 14458 additions and 8680 deletions

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback } from 'react';
import { account } from '@/lib/appwrite';
import { ID } from 'appwrite';
import { useState, useEffect, useCallback } from "react";
import { account } from "@/lib/appwrite";
import { ID } from "appwrite";
// 인증 상태 인터페이스
interface AuthState {
@@ -29,7 +29,7 @@ export const useAppwriteAuth = () => {
const [authState, setAuthState] = useState<AuthState>({
user: null,
loading: true,
error: null
error: null,
});
// 컴포넌트 마운트 상태 추적
@@ -43,7 +43,7 @@ export const useAppwriteAuth = () => {
setAuthState({
user,
loading: false,
error: null
error: null,
});
}
return user;
@@ -52,7 +52,7 @@ export const useAppwriteAuth = () => {
setAuthState({
user: null,
loading: false,
error: error as Error
error: error as Error,
});
}
return null;
@@ -60,97 +60,101 @@ export const useAppwriteAuth = () => {
}, [isMounted]);
// 이메일/비밀번호로 로그인
const login = useCallback(async ({ email, password }: LoginCredentials) => {
try {
setAuthState(prev => ({ ...prev, loading: true, error: null }));
// 비동기 작업 시작 전 UI 스레드 차단 방지
await new Promise(resolve => setTimeout(resolve, 0));
const session = await account.createEmailPasswordSession(email, password);
const user = await account.get();
if (isMounted) {
setAuthState({
user,
loading: false,
error: null
});
const login = useCallback(
async ({ email, password }: LoginCredentials) => {
try {
setAuthState((prev) => ({ ...prev, loading: true, error: null }));
// 비동기 작업 시작 전 UI 스레드 차단 방지
await new Promise((resolve) => setTimeout(resolve, 0));
const session = await account.createEmailPasswordSession(
email,
password
);
const user = await account.get();
if (isMounted) {
setAuthState({
user,
loading: false,
error: null,
});
}
return { user, session };
} catch (error) {
if (isMounted) {
setAuthState((prev) => ({
...prev,
loading: false,
error: error as Error,
}));
}
throw error;
}
return { user, session };
} catch (error) {
if (isMounted) {
setAuthState(prev => ({
...prev,
loading: false,
error: error as Error
}));
}
throw error;
}
}, [isMounted]);
},
[isMounted]
);
// 회원가입
const signup = useCallback(async ({ email, password, name }: SignupCredentials) => {
try {
setAuthState(prev => ({ ...prev, loading: true, error: null }));
// 비동기 작업 시작 전 UI 스레드 차단 방지
await new Promise(resolve => setTimeout(resolve, 0));
const user = await account.create(
ID.unique(),
email,
password,
name
);
// 회원가입 후 자동 로그인
await account.createEmailPasswordSession(email, password);
if (isMounted) {
setAuthState({
user,
loading: false,
error: null
});
const signup = useCallback(
async ({ email, password, name }: SignupCredentials) => {
try {
setAuthState((prev) => ({ ...prev, loading: true, error: null }));
// 비동기 작업 시작 전 UI 스레드 차단 방지
await new Promise((resolve) => setTimeout(resolve, 0));
const user = await account.create(ID.unique(), email, password, name);
// 회원가입 후 자동 로그인
await account.createEmailPasswordSession(email, password);
if (isMounted) {
setAuthState({
user,
loading: false,
error: null,
});
}
return user;
} catch (error) {
if (isMounted) {
setAuthState((prev) => ({
...prev,
loading: false,
error: error as Error,
}));
}
throw error;
}
return user;
} catch (error) {
if (isMounted) {
setAuthState(prev => ({
...prev,
loading: false,
error: error as Error
}));
}
throw error;
}
}, [isMounted]);
},
[isMounted]
);
// 로그아웃
const logout = useCallback(async () => {
try {
setAuthState(prev => ({ ...prev, loading: true }));
setAuthState((prev) => ({ ...prev, loading: true }));
// 현재 세션 삭제
await account.deleteSession('current');
await account.deleteSession("current");
if (isMounted) {
setAuthState({
user: null,
loading: false,
error: null
error: null,
});
}
} catch (error) {
if (isMounted) {
setAuthState(prev => ({
setAuthState((prev) => ({
...prev,
loading: false,
error: error as Error
error: error as Error,
}));
}
throw error;
@@ -161,7 +165,7 @@ export const useAppwriteAuth = () => {
useEffect(() => {
setIsMounted(true);
getCurrentUser();
// 정리 함수
return () => {
setIsMounted(false);
@@ -175,7 +179,7 @@ export const useAppwriteAuth = () => {
login,
signup,
logout,
getCurrentUser
getCurrentUser,
};
};

View File

@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import { EXPENSE_CATEGORIES } from '@/constants/categoryIcons';
import { useState, useEffect } from "react";
import { logger } from "@/utils/logger";
import { EXPENSE_CATEGORIES } from "@/constants/categoryIcons";
interface BudgetData {
targetAmount: number;
@@ -11,7 +11,10 @@ interface BudgetData {
interface UseBudgetTabContentProps {
data: BudgetData;
calculatePercentage: (spent: number, target: number) => number;
onSaveBudget: (amount: number, categoryBudgets?: Record<string, number>) => void;
onSaveBudget: (
amount: number,
categoryBudgets?: Record<string, number>
) => void;
}
interface UseBudgetTabContentReturn {
@@ -33,53 +36,64 @@ interface UseBudgetTabContentReturn {
export const useBudgetTabContent = ({
data,
calculatePercentage,
onSaveBudget
onSaveBudget,
}: UseBudgetTabContentProps): UseBudgetTabContentReturn => {
const [categoryBudgets, setCategoryBudgets] = useState<Record<string, number>>({});
const [categoryBudgets, setCategoryBudgets] = useState<
Record<string, number>
>({});
const spentAmount = data.spentAmount;
const targetAmount = data.targetAmount;
// 로그 추가 - 받은 데이터 확인
useEffect(() => {
console.log(`BudgetTabContent 수신 데이터:`, data);
logger.info(`BudgetTabContent 수신 데이터:`, data);
}, [data]);
// 전역 예산 데이터가 변경되었을 때 로컬 상태 갱신
useEffect(() => {
const handleBudgetDataUpdated = () => {
console.log(`BudgetTabContent: 전역 예산 데이터 이벤트 감지, 현재 targetAmount=${targetAmount}`);
logger.info(
`BudgetTabContent: 전역 예산 데이터 이벤트 감지, 현재 targetAmount=${targetAmount}`
);
};
window.addEventListener('budgetDataUpdated', handleBudgetDataUpdated);
return () => window.removeEventListener('budgetDataUpdated', handleBudgetDataUpdated);
window.addEventListener("budgetDataUpdated", handleBudgetDataUpdated);
return () =>
window.removeEventListener("budgetDataUpdated", handleBudgetDataUpdated);
}, [targetAmount]);
// 예산 설정 여부 확인 - 데이터 targetAmount가 실제로 0보다 큰지 확인
const isBudgetSet = targetAmount > 0;
// 실제 백분율 계산 (초과해도 실제 퍼센트로 표시)
const actualPercentage = targetAmount > 0 ? Math.round((spentAmount / targetAmount) * 100) : 0;
const actualPercentage =
targetAmount > 0 ? Math.round((spentAmount / targetAmount) * 100) : 0;
const percentage = Math.min(actualPercentage, 100); // 대시보드 표시용으로는 100% 제한
// 예산 초과 여부 계산
const isOverBudget = spentAmount > targetAmount && targetAmount > 0;
// 예산이 얼마 남지 않은 경우 (10% 미만)
const isLowBudget = targetAmount > 0 && actualPercentage >= 90 && actualPercentage < 100;
const isLowBudget =
targetAmount > 0 && actualPercentage >= 90 && actualPercentage < 100;
// 프로그레스 바 색상 결정
const progressBarColor = isOverBudget ? 'bg-red-500' : isLowBudget ? 'bg-yellow-400' : 'bg-neuro-income';
const progressBarColor = isOverBudget
? "bg-red-500"
: isLowBudget
? "bg-yellow-400"
: "bg-neuro-income";
// 남은 예산 또는 초과 예산 텍스트 및 금액
const budgetStatusText = isOverBudget ? '예산 초과: ' : '남은 예산: ';
const budgetAmount = isOverBudget ?
Math.abs(targetAmount - spentAmount).toLocaleString() :
Math.max(0, targetAmount - spentAmount).toLocaleString();
const budgetStatusText = isOverBudget ? "예산 초과: " : "남은 예산: ";
const budgetAmount = isOverBudget
? Math.abs(targetAmount - spentAmount).toLocaleString()
: Math.max(0, targetAmount - spentAmount).toLocaleString();
const handleCategoryInputChange = (value: string, category: string) => {
const numValue = parseInt(value.replace(/,/g, ''), 10) || 0;
setCategoryBudgets(prev => ({
const numValue = parseInt(value.replace(/,/g, ""), 10) || 0;
setCategoryBudgets((prev) => ({
...prev,
[category]: numValue
[category]: numValue,
}));
};
@@ -87,10 +101,10 @@ export const useBudgetTabContent = ({
const calculateTotalBudget = () => {
// 모든 EXPENSE_CATEGORIES에 있는 카테고리 포함해서 합계 계산
let total = 0;
EXPENSE_CATEGORIES.forEach(category => {
EXPENSE_CATEGORIES.forEach((category) => {
total += categoryBudgets[category] || 0;
});
console.log('카테고리 예산 총합:', total, categoryBudgets);
logger.info("카테고리 예산 총합:", total, categoryBudgets);
return total;
};
@@ -98,25 +112,29 @@ export const useBudgetTabContent = ({
const handleSaveCategoryBudgets = () => {
// 카테고리 예산 기본값 설정 - 모든 카테고리 포함
const updatedCategoryBudgets: Record<string, number> = {};
EXPENSE_CATEGORIES.forEach(category => {
EXPENSE_CATEGORIES.forEach((category) => {
updatedCategoryBudgets[category] = categoryBudgets[category] || 0;
});
const totalBudget = calculateTotalBudget();
console.log('카테고리 예산 저장 및 총 예산 설정:', totalBudget, updatedCategoryBudgets);
logger.info(
"카테고리 예산 저장 및 총 예산 설정:",
totalBudget,
updatedCategoryBudgets
);
// 총액이 0이 아닐 때만 저장 처리
if (totalBudget > 0) {
// 명시적으로 월간 예산으로 설정 - 항상 월간 예산만 저장
onSaveBudget(totalBudget, updatedCategoryBudgets);
// 이벤트 발생 추가 (데이터 저장 후 즉시 UI 업데이트를 위해)
setTimeout(() => {
console.log('예산 데이터 저장 후 이벤트 발생');
window.dispatchEvent(new Event('budgetDataUpdated'));
logger.info("예산 데이터 저장 후 이벤트 발생");
window.dispatchEvent(new Event("budgetDataUpdated"));
}, 200);
} else {
alert('예산을 입력해주세요.');
alert("예산을 입력해주세요.");
}
};
@@ -124,14 +142,14 @@ export const useBudgetTabContent = ({
useEffect(() => {
// 로컬 스토리지에서 카테고리 예산 불러오기
try {
const storedCategoryBudgets = localStorage.getItem('categoryBudgets');
const storedCategoryBudgets = localStorage.getItem("categoryBudgets");
if (storedCategoryBudgets) {
const parsedBudgets = JSON.parse(storedCategoryBudgets);
console.log('저장된 카테고리 예산 불러옴:', parsedBudgets);
logger.info("저장된 카테고리 예산 불러옴:", parsedBudgets);
setCategoryBudgets(parsedBudgets);
}
} catch (error) {
console.error('카테고리 예산 불러오기 오류:', error);
logger.error("카테고리 예산 불러오기 오류:", error);
}
}, []);
@@ -139,7 +157,10 @@ export const useBudgetTabContent = ({
const budgetButtonText = isBudgetSet ? "예산 수정하기" : "예산 입력하기";
// 화면에 표시할 내용 - 디버깅을 위한 로그 추가
console.log(`BudgetTabContent 렌더링: targetAmount=${targetAmount}, isBudgetSet=${isBudgetSet}, 표시될 화면:`, isBudgetSet ? "예산 진행 상황" : "예산 입력하기 버튼");
logger.info(
`BudgetTabContent 렌더링: targetAmount=${targetAmount}, isBudgetSet=${isBudgetSet}, 표시될 화면:`,
isBudgetSet ? "예산 진행 상황" : "예산 입력하기 버튼"
);
return {
categoryBudgets,
@@ -154,6 +175,6 @@ export const useBudgetTabContent = ({
budgetStatusText,
budgetAmount,
budgetButtonText,
calculateTotalBudget
calculateTotalBudget,
};
};

View File

@@ -1,10 +1,9 @@
// 주요 동기화 훅 내보내기
export * from './useSyncToggle';
export * from './useManualSync';
export * from './useSyncStatus';
export * from './syncTime';
export * from './syncResultHandler';
export * from './syncPerformer';
export * from './syncNetworkChecker';
export * from './syncBackupUtils';
export * from "./useSyncToggle";
export * from "./useManualSync";
export * from "./useSyncStatus";
export * from "./syncTime";
export * from "./syncResultHandler";
export * from "./syncPerformer";
export * from "./syncNetworkChecker";
export * from "./syncBackupUtils";

View File

@@ -1,22 +1,21 @@
/**
* 로컬 데이터 백업 만들기
*/
export const createLocalDataBackup = () => {
const budgetDataBackup = localStorage.getItem('budgetData');
const categoryBudgetsBackup = localStorage.getItem('categoryBudgets');
const transactionsBackup = localStorage.getItem('transactions');
console.log('로컬 데이터 백업:', {
budgetData: budgetDataBackup ? '있음' : '없음',
categoryBudgets: categoryBudgetsBackup ? '있음' : '없음',
transactions: transactionsBackup ? '있음' : '없음'
const budgetDataBackup = localStorage.getItem("budgetData");
const categoryBudgetsBackup = localStorage.getItem("categoryBudgets");
const transactionsBackup = localStorage.getItem("transactions");
syncLogger.info("로컬 데이터 백업:", {
budgetData: budgetDataBackup ? "있음" : "없음",
categoryBudgets: categoryBudgetsBackup ? "있음" : "없음",
transactions: transactionsBackup ? "있음" : "없음",
});
return {
budgetDataBackup,
categoryBudgetsBackup,
transactionsBackup
transactionsBackup,
};
};
@@ -24,29 +23,30 @@ export const createLocalDataBackup = () => {
* 로컬 데이터 복원하기
*/
export const restoreLocalDataBackup = (backup: {
budgetDataBackup: string | null,
categoryBudgetsBackup: string | null,
transactionsBackup: string | null
budgetDataBackup: string | null;
categoryBudgetsBackup: string | null;
transactionsBackup: string | null;
}) => {
const { budgetDataBackup, categoryBudgetsBackup, transactionsBackup } = backup;
console.log('로컬 데이터 복원 시도');
const { budgetDataBackup, categoryBudgetsBackup, transactionsBackup } =
backup;
syncLogger.info("로컬 데이터 복원 시도");
// 오류 발생 시 백업 데이터 복원
if (budgetDataBackup) {
localStorage.setItem('budgetData', budgetDataBackup);
localStorage.setItem("budgetData", budgetDataBackup);
}
if (categoryBudgetsBackup) {
localStorage.setItem('categoryBudgets', categoryBudgetsBackup);
localStorage.setItem("categoryBudgets", categoryBudgetsBackup);
}
if (transactionsBackup) {
localStorage.setItem('transactions', transactionsBackup);
localStorage.setItem("transactions", transactionsBackup);
}
// 이벤트 발생시켜 UI 업데이트
window.dispatchEvent(new Event('budgetDataUpdated'));
window.dispatchEvent(new Event('categoryBudgetsUpdated'));
window.dispatchEvent(new Event('transactionUpdated'));
console.log('로컬 데이터 복원 완료');
window.dispatchEvent(new Event("budgetDataUpdated"));
window.dispatchEvent(new Event("categoryBudgetsUpdated"));
window.dispatchEvent(new Event("transactionUpdated"));
syncLogger.info("로컬 데이터 복원 완료");
};

View File

@@ -1,6 +1,6 @@
import { checkNetworkStatus } from '@/utils/network/checker';
import { toast } from '@/hooks/useToast.wrapper';
import { checkNetworkStatus } from "@/utils/network/checker";
import { syncLogger } from "@/utils/logger";
import { toast } from "@/hooks/useToast.wrapper";
/**
* 동기화를 위한 네트워크 상태 확인
@@ -8,20 +8,27 @@ import { toast } from '@/hooks/useToast.wrapper';
export const checkSyncNetworkStatus = async (): Promise<boolean> => {
// 기본 네트워크 상태 확인 - navigator.onLine 우선 사용
const navigatorOnline = navigator.onLine;
console.log(`[동기화] 기본 네트워크 상태 확인: ${navigatorOnline ? '온라인' : '오프라인'}`);
syncLogger.info(
`[동기화] 기본 네트워크 상태 확인: ${navigatorOnline ? "온라인" : "오프라인"}`
);
if (!navigatorOnline) {
return false;
}
// 강화된 네트워크 확인 시도 (실패해도 계속 진행)
try {
const isOnline = await checkNetworkStatus();
console.log(`[동기화] 강화된 네트워크 확인 결과: ${isOnline ? '온라인' : '오프라인'}`);
syncLogger.info(
`[동기화] 강화된 네트워크 확인 결과: ${isOnline ? "온라인" : "오프라인"}`
);
return isOnline;
} catch (error) {
// 네트워크 확인 실패해도 navigator.onLine이 true면 진행
console.warn('[동기화] 강화된 네트워크 확인 실패, 기본 상태 사용:', error);
syncLogger.warn(
"[동기화] 강화된 네트워크 확인 실패, 기본 상태 사용:",
error
);
return navigatorOnline;
}
};
@@ -34,12 +41,12 @@ export const showNetworkErrorNotification = (
) => {
const title = "네트워크 연결 필요";
const description = "동기화를 위해 인터넷 연결이 필요합니다.";
toast({
title,
description,
variant: "destructive"
variant: "destructive",
});
addNotification(title, description);
};

View File

@@ -1,44 +1,46 @@
import { trySyncAllData } from '@/utils/syncUtils';
import { setLastSyncTime } from '@/utils/syncUtils';
import { trySyncAllData } from "@/utils/syncUtils";
import { syncLogger } from "@/utils/logger";
import { setLastSyncTime } from "@/utils/syncUtils";
/**
* 실제 동기화 수행 함수 (최대 2회까지 자동 재시도)
*/
export const performSync = async (userId: string) => {
if (!userId) return;
if (!userId) {
return;
}
let attempts = 0;
const maxAttempts = 2;
while (attempts < maxAttempts) {
try {
attempts++;
console.log(`동기화 시도 ${attempts}/${maxAttempts}`);
syncLogger.info(`동기화 시도 ${attempts}/${maxAttempts}`);
// 네트워크 상태 확인 - 기본 navigator.onLine 사용
if (!navigator.onLine) {
console.log('네트워크 연결 없음, 동기화 건너뜀');
throw new Error('네트워크 연결 필요');
syncLogger.info("네트워크 연결 없음, 동기화 건너뜀");
throw new Error("네트워크 연결 필요");
}
const result = await trySyncAllData(userId);
// 동기화 성공 시 마지막 동기화 시간 업데이트
if (result && result.success) {
const currentTime = new Date().toISOString();
console.log('동기화 성공, 시간 업데이트:', currentTime);
syncLogger.info("동기화 성공, 시간 업데이트:", currentTime);
setLastSyncTime(currentTime);
}
return result;
} catch (error) {
console.error(`동기화 시도 ${attempts} 실패:`, error);
syncLogger.error(`동기화 시도 ${attempts} 실패:`, error);
if (attempts < maxAttempts) {
// 재시도 전 잠시 대기
await new Promise(resolve => setTimeout(resolve, 2000));
console.log(`${attempts+1}번째 동기화 재시도 중...`);
await new Promise((resolve) => setTimeout(resolve, 2000));
syncLogger.info(`${attempts + 1}번째 동기화 재시도 중...`);
} else {
throw error;
}

View File

@@ -1,14 +1,16 @@
import React, { useEffect } from 'react';
import { SyncResult } from '@/utils/sync/data';
import { toast } from '@/hooks/useToast.wrapper';
import useNotifications from '@/hooks/useNotifications';
import React, { useEffect } from "react";
import { syncLogger } from "@/utils/logger";
import { SyncResult } from "@/utils/sync/data";
import { toast } from "@/hooks/useToast.wrapper";
import useNotifications from "@/hooks/useNotifications";
// 알림 인스턴스 얻기 위한 전역 변수
let notificationAdder: ((title: string, message: string) => void) | null = null;
// 알림 함수 설정
export const setSyncNotificationAdder = (adder: (title: string, message: string) => void) => {
export const setSyncNotificationAdder = (
adder: (title: string, message: string) => void
) => {
notificationAdder = adder;
};
@@ -21,10 +23,10 @@ export const handleSyncResult = (result: SyncResult) => {
if (result.success) {
// 성공 시 실패 카운터 초기화
syncFailureCount = 0;
let title = '';
let description = '';
let title = "";
let description = "";
if (result.uploadSuccess && result.downloadSuccess) {
// 양방향 동기화 성공
title = "동기화 완료";
@@ -38,53 +40,54 @@ export const handleSyncResult = (result: SyncResult) => {
title = "동기화 완료";
description = "클라우드 데이터가 기기에 다운로드되었습니다.";
}
// 토스트 표시
toast({
title,
description,
});
// 알림 추가 (설정된 경우)
if (notificationAdder) {
notificationAdder(title, description);
}
// 상세 결과 로깅
console.log("동기화 세부 결과:", {
예산업로드: result.details?.budgetUpload ? '성공' : '실패',
예산다운로드: result.details?.budgetDownload ? '성공' : '실패',
트랜잭션업로드: result.details?.transactionUpload ? '성공' : '실패',
트랜잭션다운로드: result.details?.transactionDownload ? '성공' : '실패'
syncLogger.info("동기화 세부 결과:", {
예산업로드: result.details?.budgetUpload ? "성공" : "실패",
예산다운로드: result.details?.budgetDownload ? "성공" : "실패",
트랜잭션업로드: result.details?.transactionUpload ? "성공" : "실패",
트랜잭션다운로드: result.details?.transactionDownload ? "성공" : "실패",
});
return true;
} else {
// 동기화 실패
console.error("동기화 실패 세부 결과:", result.details);
syncLogger.error("동기화 실패 세부 결과:", result.details);
// 실패 카운터 증가 및 최대 알림 횟수 제한
syncFailureCount++;
if (syncFailureCount <= MAX_SYNC_FAILURE_NOTIFICATIONS) {
const title = "동기화 실패";
const description = "데이터 동기화 중 문제가 발생했습니다. 나중에 다시 시도합니다.";
const description =
"데이터 동기화 중 문제가 발생했습니다. 나중에 다시 시도합니다.";
// 토스트 표시
toast({
title,
description,
variant: "destructive"
variant: "destructive",
});
// 알림 추가 (설정된 경우)
if (notificationAdder) {
notificationAdder(title, description);
}
} else {
console.log(`동기화 실패 알림 제한 (${syncFailureCount}회 실패)`);
syncLogger.info(`동기화 실패 알림 제한 (${syncFailureCount}회 실패)`);
}
return false;
}
};
@@ -92,7 +95,7 @@ export const handleSyncResult = (result: SyncResult) => {
// 커스텀 훅: 동기화 알림 관리
export const useSyncNotifications = () => {
const { addNotification } = useNotifications();
// 컴포넌트 마운트 시 알림 함수 설정
useEffect(() => {
setSyncNotificationAdder(addNotification);

View File

@@ -1,3 +1,2 @@
export * from './useSyncTimeFormatting';
export * from './useSyncTimeEvents';
export * from "./useSyncTimeFormatting";
export * from "./useSyncTimeEvents";

View File

@@ -1,12 +1,12 @@
import { useCallback } from 'react';
import { getLastSyncTime } from '@/utils/syncUtils';
import { useCallback } from "react";
import { syncLogger } from "@/utils/logger";
import { getLastSyncTime } from "@/utils/syncUtils";
/**
* 동기화 시간 관련 이벤트를 처리하는 커스텀 훅
*/
export const useSyncTimeEvents = (
lastSync: string | null,
lastSync: string | null,
setLastSync: React.Dispatch<React.SetStateAction<string | null>>
) => {
/**
@@ -15,44 +15,49 @@ export const useSyncTimeEvents = (
const setupSyncTimeEventListeners = useCallback(() => {
const updateLastSyncTime = (event?: Event | CustomEvent) => {
const newTime = getLastSyncTime();
const eventDetails = event instanceof CustomEvent ? ` (이벤트 상세: ${JSON.stringify(event.detail)})` : '';
console.log(`마지막 동기화 시간 업데이트됨: ${newTime} ${eventDetails}`);
const eventDetails =
event instanceof CustomEvent
? ` (이벤트 상세: ${JSON.stringify(event.detail)})`
: "";
syncLogger.info(
`마지막 동기화 시간 업데이트됨: ${newTime} ${eventDetails}`
);
setLastSync(newTime);
};
// 이벤트 리스너 등록 - 커스텀 이벤트 사용
window.addEventListener('syncTimeUpdated', updateLastSyncTime);
window.addEventListener("syncTimeUpdated", updateLastSyncTime);
// 스토리지 이벤트도 모니터링
const handleStorageChange = (event: StorageEvent) => {
if (event.key === 'lastSync' || event.key === null) {
console.log('스토리지 변경 감지 (lastSync):', event.newValue);
if (event.key === "lastSync" || event.key === null) {
syncLogger.info("스토리지 변경 감지 (lastSync):", event.newValue);
updateLastSyncTime();
}
};
window.addEventListener('storage', handleStorageChange);
window.addEventListener("storage", handleStorageChange);
// 동기화 완료 이벤트도 모니터링
window.addEventListener('syncComplete', updateLastSyncTime);
// 초기 상태 업데이트
window.addEventListener("syncComplete", updateLastSyncTime);
// 초기 상태 업데이트
updateLastSyncTime();
// 주기적 시간 확인 기능 설정
const checkInterval = setupPeriodicTimeCheck(lastSync, setLastSync);
// 정리 함수 반환
return () => {
window.removeEventListener('syncTimeUpdated', updateLastSyncTime);
window.removeEventListener('storage', handleStorageChange);
window.removeEventListener('syncComplete', updateLastSyncTime);
window.removeEventListener("syncTimeUpdated", updateLastSyncTime);
window.removeEventListener("storage", handleStorageChange);
window.removeEventListener("syncComplete", updateLastSyncTime);
clearInterval(checkInterval);
};
}, [lastSync, setLastSync]);
return {
setupSyncTimeEventListeners
setupSyncTimeEventListeners,
};
};
@@ -60,14 +65,14 @@ export const useSyncTimeEvents = (
* 주기적으로 동기화 시간을 확인하는 기능 설정
*/
const setupPeriodicTimeCheck = (
lastSync: string | null,
lastSync: string | null,
setLastSync: React.Dispatch<React.SetStateAction<string | null>>
): number => {
// 1초마다 업데이트 상태 확인 (문제 해결을 위한 임시 방안)
return window.setInterval(() => {
const currentTime = getLastSyncTime();
if (currentTime !== lastSync) {
console.log('주기적 확인에서 동기화 시간 변경 감지:', currentTime);
syncLogger.info("주기적 확인에서 동기화 시간 변경 감지:", currentTime);
setLastSync(currentTime);
}
}, 1000);

View File

@@ -1,4 +1,3 @@
/**
* 마지막 동기화 시간 포맷팅을 위한 커스텀 훅
*/
@@ -8,26 +7,26 @@ export const useLastSyncTimeFormatting = (lastSync: string | null) => {
*/
const formatLastSyncTime = (): string => {
if (!lastSync) {
return '없음';
return "없음";
}
try {
const date = new Date(lastSync);
// 유효한 날짜인지 확인
if (isNaN(date.getTime())) {
return '없음';
return "없음";
}
return formatDateByRelativeTime(date);
} catch (error) {
console.error('날짜 포맷 오류:', error);
return '없음';
syncLogger.error("날짜 포맷 오류:", error);
return "없음";
}
};
return {
formatLastSyncTime
formatLastSyncTime,
};
};
@@ -38,21 +37,21 @@ const formatDateByRelativeTime = (date: Date): string => {
// 오늘 날짜인 경우
const today = new Date();
const isToday = isSameDay(date, today);
if (isToday) {
// 시간만 표시
return `오늘 ${formatTime(date)}`;
}
// 어제 날짜인 경우
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
const isYesterday = isSameDay(date, yesterday);
if (isYesterday) {
return `어제 ${formatTime(date)}`;
}
// 그 외 날짜
return `${formatFullDate(date)} ${formatTime(date)}`;
};
@@ -61,21 +60,23 @@ const formatDateByRelativeTime = (date: Date): string => {
* 두 날짜가 같은 날인지 확인
*/
const isSameDay = (date1: Date, date2: Date): boolean => {
return date1.getDate() === date2.getDate() &&
date1.getMonth() === date2.getMonth() &&
date1.getFullYear() === date2.getFullYear();
return (
date1.getDate() === date2.getDate() &&
date1.getMonth() === date2.getMonth() &&
date1.getFullYear() === date2.getFullYear()
);
};
/**
* 시간을 HH:MM 형식으로 포맷팅
*/
const formatTime = (date: Date): string => {
return `${date.getHours()}:${String(date.getMinutes()).padStart(2, '0')}`;
return `${date.getHours()}:${String(date.getMinutes()).padStart(2, "0")}`;
};
/**
* 전체 날짜를 YYYY/MM/DD 형식으로 포맷팅
*/
const formatFullDate = (date: Date): string => {
return `${date.getFullYear()}/${String(date.getMonth() + 1).padStart(2, '0')}/${String(date.getDate()).padStart(2, '0')}`;
return `${date.getFullYear()}/${String(date.getMonth() + 1).padStart(2, "0")}/${String(date.getDate()).padStart(2, "0")}`;
};

View File

@@ -1,14 +1,15 @@
import { useState } from 'react';
import { toast } from '@/hooks/useToast.wrapper';
import { trySyncAllData, setLastSyncTime } from '@/utils/syncUtils';
import { handleSyncResult } from './syncResultHandler';
import useNotifications from '@/hooks/useNotifications';
import { useState } from "react";
import { syncLogger } from "@/utils/logger";
import { toast } from "@/hooks/useToast.wrapper";
import { trySyncAllData, setLastSyncTime } from "@/utils/syncUtils";
import { handleSyncResult } from "./syncResultHandler";
import useNotifications from "@/hooks/useNotifications";
import { Models } from "appwrite";
/**
* 수동 동기화 기능을 위한 커스텀 훅
*/
export const useManualSync = (user: any) => {
export const useManualSync = (user: Models.User<Models.Preferences> | null) => {
const [syncing, setSyncing] = useState(false);
const { addNotification } = useNotifications();
@@ -18,67 +19,66 @@ export const useManualSync = (user: any) => {
toast({
title: "로그인 필요",
description: "데이터 동기화를 위해 로그인이 필요합니다.",
variant: "destructive"
variant: "destructive",
});
addNotification(
"로그인 필요",
"로그인 필요",
"데이터 동기화를 위해 로그인이 필요합니다."
);
return;
}
// 이미 동기화 중이면 중복 실행 방지
if (syncing) {
console.log('이미 동기화가 진행 중입니다.');
syncLogger.info("이미 동기화가 진행 중입니다.");
return;
}
await performSync(user.id);
};
// 실제 동기화 수행 함수
const performSync = async (userId: string) => {
if (!userId) return;
if (!userId) {
return;
}
try {
setSyncing(true);
console.log('수동 동기화 시작');
addNotification(
"동기화 시작",
"데이터 동기화가 시작되었습니다."
);
syncLogger.info("수동 동기화 시작");
addNotification("동기화 시작", "데이터 동기화가 시작되었습니다.");
// 동기화 실행
const result = await trySyncAllData(userId);
// 동기화 결과 처리
handleSyncResult(result);
// 동기화 시간 업데이트
if (result.success) {
const currentTime = new Date().toISOString();
console.log('수동 동기화 성공, 시간 업데이트:', currentTime);
syncLogger.info("수동 동기화 성공, 시간 업데이트:", currentTime);
setLastSyncTime(currentTime);
}
return result;
} catch (error) {
console.error('동기화 오류:', error);
syncLogger.error("동기화 오류:", error);
toast({
title: "동기화 오류",
description: "동기화 중 문제가 발생했습니다. 다시 시도해주세요.",
variant: "destructive"
variant: "destructive",
});
addNotification(
"동기화 오류",
"동기화 오류",
"동기화 중 문제가 발생했습니다. 다시 시도해주세요."
);
} finally {
setSyncing(false);
console.log('수동 동기화 종료');
syncLogger.info("수동 동기화 종료");
}
};

View File

@@ -1,8 +1,8 @@
import { useState, useEffect } from 'react';
import { getLastSyncTime } from '@/utils/syncUtils';
import { useLastSyncTimeFormatting } from './syncTime/useSyncTimeFormatting';
import { useSyncTimeEvents } from './syncTime/useSyncTimeEvents';
import { useState, useEffect } from "react";
import { syncLogger } from "@/utils/logger";
import { getLastSyncTime } from "@/utils/syncUtils";
import { useLastSyncTimeFormatting } from "./syncTime/useSyncTimeFormatting";
import { useSyncTimeEvents } from "./syncTime/useSyncTimeEvents";
/**
* 동기화 상태 관리를 위한 커스텀 훅
@@ -10,20 +10,26 @@ import { useSyncTimeEvents } from './syncTime/useSyncTimeEvents';
export const useSyncStatus = () => {
const [lastSync, setLastSync] = useState<string | null>(getLastSyncTime());
const { formatLastSyncTime } = useLastSyncTimeFormatting(lastSync);
const { setupSyncTimeEventListeners } = useSyncTimeEvents(lastSync, setLastSync);
const { setupSyncTimeEventListeners } = useSyncTimeEvents(
lastSync,
setLastSync
);
// 동기화 시간이 변경될 때 상태 업데이트 및 이벤트 리스너 설정
useEffect(() => {
console.log('useSyncStatus 훅 초기화, 현재 마지막 동기화 시간:', lastSync);
syncLogger.info(
"useSyncStatus 훅 초기화, 현재 마지막 동기화 시간:",
lastSync
);
// 이벤트 리스너 및 주기적 확인 설정
const cleanup = setupSyncTimeEventListeners();
return cleanup;
}, [lastSync, setupSyncTimeEventListeners]);
return {
lastSync,
formatLastSyncTime
formatLastSyncTime,
};
};

View File

@@ -1,20 +1,26 @@
import { useState, useEffect } from 'react';
import { useAuth } from '@/contexts/auth';
import { toast } from '@/hooks/useToast.wrapper';
import {
isSyncEnabled,
import { useState, useEffect } from "react";
import { syncLogger } from "@/utils/logger";
import { useAuth } from "@/contexts/auth";
import { toast } from "@/hooks/useToast.wrapper";
import {
isSyncEnabled,
setSyncEnabled,
setLastSyncTime
} from '@/utils/syncUtils';
import useNotifications from '@/hooks/useNotifications';
import { resetSyncFailureCount } from './syncResultHandler';
import { checkSyncNetworkStatus, showNetworkErrorNotification } from './syncNetworkChecker';
import { performSync } from './syncPerformer';
import { createLocalDataBackup, restoreLocalDataBackup } from './syncBackupUtils';
setLastSyncTime,
} from "@/utils/syncUtils";
import useNotifications from "@/hooks/useNotifications";
import { resetSyncFailureCount } from "./syncResultHandler";
import {
checkSyncNetworkStatus,
showNetworkErrorNotification,
} from "./syncNetworkChecker";
import { performSync } from "./syncPerformer";
import {
createLocalDataBackup,
restoreLocalDataBackup,
} from "./syncBackupUtils";
/**
* 동기화 토글 기능을 위한 커스텀 훅
* 동기화 토글 기능을 위한 커스텀 훅
*/
export const useSyncToggle = () => {
const [enabled, setEnabled] = useState(isSyncEnabled());
@@ -30,35 +36,38 @@ export const useSyncToggle = () => {
// 사용자가 로그아웃했고 동기화가 활성화되어 있으면 비활성화
setSyncEnabled(false);
setEnabled(false);
console.log('로그아웃으로 인해 동기화 설정이 비활성화되었습니다.');
syncLogger.info("로그아웃으로 인해 동기화 설정이 비활성화되었습니다.");
}
// 동기화 상태 업데이트
setEnabled(isSyncEnabled());
// 로그인/로그아웃 시 실패 카운터 초기화
resetSyncFailureCount();
};
// 초기 호출
updateSyncState();
// 인증 상태 변경 이벤트 리스너
window.addEventListener('auth-state-changed', updateSyncState);
window.addEventListener("auth-state-changed", updateSyncState);
// 스토리지 변경 이벤트에도 동기화 상태 확인 추가
const handleStorageChange = (event: StorageEvent) => {
if (event.key === 'syncEnabled' || event.key === null) {
if (event.key === "syncEnabled" || event.key === null) {
setEnabled(isSyncEnabled());
console.log('스토리지 변경으로 동기화 상태 업데이트:', isSyncEnabled() ? '활성화' : '비활성화');
syncLogger.info(
"스토리지 변경으로 동기화 상태 업데이트:",
isSyncEnabled() ? "활성화" : "비활성화"
);
}
};
window.addEventListener('storage', handleStorageChange);
window.addEventListener("storage", handleStorageChange);
return () => {
window.removeEventListener('auth-state-changed', updateSyncState);
window.removeEventListener('storage', handleStorageChange);
window.removeEventListener("auth-state-changed", updateSyncState);
window.removeEventListener("storage", handleStorageChange);
};
}, [user]);
@@ -68,16 +77,16 @@ export const useSyncToggle = () => {
toast({
title: "로그인 필요",
description: "데이터 동기화를 위해 로그인이 필요합니다.",
variant: "destructive"
variant: "destructive",
});
addNotification(
"로그인 필요",
"로그인 필요",
"데이터 동기화를 위해 로그인이 필요합니다."
);
return;
}
try {
// 네트워크 상태 확인
if (checked) {
@@ -87,56 +96,57 @@ export const useSyncToggle = () => {
return;
}
}
// 현재 로컬 데이터 백업
const dataBackup = createLocalDataBackup();
// 동기화 설정 변경
setEnabled(checked);
setSyncEnabled(checked);
// 실패 카운터 초기화
resetSyncFailureCount();
// 동기화 활성화/비활성화 알림 추가
addNotification(
checked ? "동기화 활성화" : "동기화 비활성화",
checked
? "데이터가 클라우드와 동기화됩니다."
checked ? "동기화 활성화" : "동기화 비활성화",
checked
? "데이터가 클라우드와 동기화됩니다."
: "클라우드 동기화가 중지되었습니다."
);
// 이벤트 트리거
window.dispatchEvent(new Event('auth-state-changed'));
window.dispatchEvent(new Event("auth-state-changed"));
if (checked && user) {
try {
// 동기화 수행
await performSync(user.id);
} catch (error) {
console.error('동기화 중 오류, 로컬 데이터 복원 시도:', error);
syncLogger.error("동기화 중 오류, 로컬 데이터 복원 시도:", error);
// 오류 발생 시 백업 데이터 복원
restoreLocalDataBackup(dataBackup);
toast({
title: "동기화 오류",
description: "동기화 중 문제가 발생하여 로컬 데이터가 복원되었습니다.",
variant: "destructive"
description:
"동기화 중 문제가 발생하여 로컬 데이터가 복원되었습니다.",
variant: "destructive",
});
addNotification(
"동기화 오류",
"동기화 오류",
"동기화 중 문제가 발생하여 로컬 데이터가 복원되었습니다."
);
}
}
} catch (error) {
console.error('동기화 설정 변경 중 예상치 못한 오류:', error);
syncLogger.error("동기화 설정 변경 중 예상치 못한 오류:", error);
toast({
title: "동기화 설정 오류",
description: "설정 변경 중 문제가 발생했습니다. 다시 시도해 주세요.",
variant: "destructive"
variant: "destructive",
});
}
};

View File

@@ -1,3 +1,2 @@
export const TOAST_LIMIT = 5 // 최대 5개로
export const TOAST_REMOVE_DELAY = 3000 // 3초 후 DOM에서 제거
export const TOAST_LIMIT = 5; // 최대 5개로 제한
export const TOAST_REMOVE_DELAY = 3000; // 3초 후 DOM에서

View File

@@ -1,22 +1,22 @@
import * as React from "react";
import { Toast, ToasterToast, State } from "./types";
import { actionTypes } from "./types";
import { listeners, memoryState } from "./store";
import { genId, dispatch } from "./toastManager";
import * as React from "react"
import { Toast, ToasterToast, State } from "./types"
import { actionTypes } from "./types"
import { listeners, memoryState } from "./store"
import { genId, dispatch } from "./toastManager"
export { TOAST_LIMIT, TOAST_REMOVE_DELAY } from "./constants"
export type { ToasterToast } from "./types"
export { TOAST_LIMIT, TOAST_REMOVE_DELAY } from "./constants";
export type { ToasterToast } from "./types";
function toast({ ...props }: Toast) {
const id = genId()
const id = genId();
const update = (props: ToasterToast) =>
dispatch({
type: actionTypes.UPDATE_TOAST,
toast: { ...props, id },
})
const dismiss = () => dispatch({ type: actionTypes.DISMISS_TOAST, toastId: id })
});
const dismiss = () =>
dispatch({ type: actionTypes.DISMISS_TOAST, toastId: id });
dispatch({
type: actionTypes.ADD_TOAST,
@@ -25,37 +25,40 @@ function toast({ ...props }: Toast) {
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss()
if (!open) {
dismiss();
}
},
duration: props.duration || 3000, // 기본 지속 시간 3초로 설정
},
})
});
return {
id,
dismiss,
update,
}
};
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState)
const [state, setState] = React.useState<State>(memoryState);
React.useEffect(() => {
listeners.push(setState)
listeners.push(setState);
return () => {
const index = listeners.indexOf(setState)
const index = listeners.indexOf(setState);
if (index > -1) {
listeners.splice(index, 1)
listeners.splice(index, 1);
}
}
}, [state])
};
}, [state]);
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: actionTypes.DISMISS_TOAST, toastId }),
}
dismiss: (toastId?: string) =>
dispatch({ type: actionTypes.DISMISS_TOAST, toastId }),
};
}
export { useToast, toast }
export { useToast, toast };

View File

@@ -1,10 +1,9 @@
import { TOAST_REMOVE_DELAY, TOAST_LIMIT } from './constants'
import { Action, State, actionTypes } from './types'
import { dispatch } from './toastManager'
import { TOAST_REMOVE_DELAY, TOAST_LIMIT } from "./constants";
import { Action, State, actionTypes } from "./types";
import { dispatch } from "./toastManager";
// 토스트 타임아웃 맵
export const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
export const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
// 토스트 자동 제거 함수
export const addToRemoveQueue = (toastId: string) => {
@@ -15,15 +14,15 @@ export const addToRemoveQueue = (toastId: string) => {
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
toastTimeouts.delete(toastId);
dispatch({
type: actionTypes.REMOVE_TOAST,
toastId: toastId,
})
}, TOAST_REMOVE_DELAY)
});
}, TOAST_REMOVE_DELAY);
toastTimeouts.set(toastId, timeout)
}
toastTimeouts.set(toastId, timeout);
};
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
@@ -35,7 +34,7 @@ export const reducer = (state: State, action: Action): State => {
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}
};
case actionTypes.UPDATE_TOAST:
return {
@@ -43,17 +42,17 @@ export const reducer = (state: State, action: Action): State => {
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
}
};
case actionTypes.DISMISS_TOAST: {
const { toastId } = action
const { toastId } = action;
if (toastId) {
addToRemoveQueue(toastId)
addToRemoveQueue(toastId);
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
addToRemoveQueue(toast.id);
});
}
return {
@@ -66,20 +65,20 @@ export const reducer = (state: State, action: Action): State => {
}
: t
),
}
};
}
case actionTypes.REMOVE_TOAST:
if (action.toastId === undefined) {
return {
...state,
toasts: [],
}
};
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
}
};
default:
return state
return state;
}
}
};

View File

@@ -1,8 +1,7 @@
import { State } from './types'
import { State } from "./types";
// 전역 상태 및 리스너
export const listeners: Array<(state: State) => void> = []
export const listeners: Array<(state: State) => void> = [];
// memoryState와 lastAction은 toastManager.ts에서 관리
export { memoryState } from './toastManager';
export { memoryState } from "./toastManager";

View File

@@ -1,52 +1,52 @@
import { Action, actionTypes } from './types'
import { TOAST_LIMIT } from './constants'
import { reducer } from './reducer'
import { listeners } from './store'
import { Action, actionTypes } from "./types";
import { logger } from "@/utils/logger";
import { TOAST_LIMIT } from "./constants";
import { reducer } from "./reducer";
import { listeners } from "./store";
// 전역 상태 관리
let memoryState = { toasts: [] };
let lastAction = null;
// ID 생성기
let count = 0
let count = 0;
export function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER
return count.toString()
count = (count + 1) % Number.MAX_SAFE_INTEGER;
return count.toString();
}
export function dispatch(action: Action) {
// 마지막 액션 정보 추출
let actionId: string | undefined = undefined;
if ('toast' in action && action.toast) {
if ("toast" in action && action.toast) {
actionId = action.toast.id;
} else if ('toastId' in action) {
} else if ("toastId" in action) {
actionId = action.toastId;
}
// 동일한 토스트에 대한 중복 액션 방지
const now = Date.now();
const isSameAction = lastAction &&
lastAction.type === action.type &&
((action.type === actionTypes.ADD_TOAST &&
lastAction.time > now - 1000) || // ADD 액션은 1초 내 중복 방지
(action.type !== actionTypes.ADD_TOAST &&
actionId === lastAction.id &&
lastAction.time > now - 300)); // 다른 액션은 300ms 내 중복 방지
const isSameAction =
lastAction &&
lastAction.type === action.type &&
((action.type === actionTypes.ADD_TOAST && lastAction.time > now - 1000) || // ADD 액션은 1초 내 중복 방지
(action.type !== actionTypes.ADD_TOAST &&
actionId === lastAction.id &&
lastAction.time > now - 300)); // 다른 액션은 300ms 내 중복 방지
if (isSameAction) {
console.log('중복 토스트 액션 무시:', action.type);
logger.info("중복 토스트 액션 무시:", action.type);
return;
}
// 액션 추적 업데이트
lastAction = {
type: action.type,
id: actionId,
time: now
lastAction = {
type: action.type,
id: actionId,
time: now,
};
// REMOVE_TOAST 액션 우선순위 높임
if (action.type === actionTypes.REMOVE_TOAST) {
// 즉시 처리
@@ -56,7 +56,7 @@ export function dispatch(action: Action) {
});
return;
}
// 실제 상태 업데이트 및 리스너 호출
memoryState = reducer(memoryState, action);
listeners.forEach((listener) => {

View File

@@ -1,46 +1,45 @@
import * as React from "react"
import * as React from "react";
export type ToasterToast = {
id: string
title?: React.ReactNode
description?: React.ReactNode
action?: React.ReactNode
variant?: "default" | "destructive"
duration?: number
open?: boolean
onOpenChange?: (open: boolean) => void
}
id: string;
title?: React.ReactNode;
description?: React.ReactNode;
action?: React.ReactNode;
variant?: "default" | "destructive";
duration?: number;
open?: boolean;
onOpenChange?: (open: boolean) => void;
};
export const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const
} as const;
export type ActionType = typeof actionTypes
export type ActionType = typeof actionTypes;
export type Action =
| {
type: ActionType["ADD_TOAST"]
toast: ToasterToast
type: ActionType["ADD_TOAST"];
toast: ToasterToast;
}
| {
type: ActionType["UPDATE_TOAST"]
toast: Partial<ToasterToast> & { id: string }
type: ActionType["UPDATE_TOAST"];
toast: Partial<ToasterToast> & { id: string };
}
| {
type: ActionType["DISMISS_TOAST"]
toastId?: string
type: ActionType["DISMISS_TOAST"];
toastId?: string;
}
| {
type: ActionType["REMOVE_TOAST"]
toastId?: string
}
type: ActionType["REMOVE_TOAST"];
toastId?: string;
};
export interface State {
toasts: ToasterToast[]
toasts: ToasterToast[];
}
export type Toast = Omit<ToasterToast, "id">
export type Toast = Omit<ToasterToast, "id">;

View File

@@ -1,13 +1,23 @@
import { format, parse, addMonths, subMonths } from 'date-fns';
import { ko } from 'date-fns/locale';
import { format, parse, addMonths, subMonths } from "date-fns";
import { logger } from "@/utils/logger";
import { ko } from "date-fns/locale";
/**
* 월 이름 배열 (한국어)
*/
export const MONTHS_KR = [
'1월', '2월', '3월', '4월', '5월', '6월',
'7월', '8월', '9월', '10월', '11월', '12월'
"1월",
"2월",
"3월",
"4월",
"5월",
"6월",
"7월",
"8월",
"9월",
"10월",
"11월",
"12월",
];
/**
@@ -22,7 +32,7 @@ export const isValidMonth = (month: string): boolean => {
* 현재 년월 가져오기
*/
export const getCurrentMonth = (): string => {
return format(new Date(), 'yyyy-MM');
return format(new Date(), "yyyy-MM");
};
/**
@@ -31,19 +41,19 @@ export const getCurrentMonth = (): string => {
export const getPrevMonth = (month: string): string => {
// 입력값 검증
if (!isValidMonth(month)) {
console.warn('유효하지 않은 월 형식:', month);
logger.warn("유효하지 않은 월 형식:", month);
return getCurrentMonth();
}
try {
// 월 문자열을 날짜로 파싱
const date = parse(month, 'yyyy-MM', new Date());
const date = parse(month, "yyyy-MM", new Date());
// 한 달 이전
const prevMonth = subMonths(date, 1);
// yyyy-MM 형식으로 반환
return format(prevMonth, 'yyyy-MM');
return format(prevMonth, "yyyy-MM");
} catch (error) {
console.error('이전 월 계산 중 오류:', error);
logger.error("이전 월 계산 중 오류:", error);
return getCurrentMonth();
}
};
@@ -54,19 +64,19 @@ export const getPrevMonth = (month: string): string => {
export const getNextMonth = (month: string): string => {
// 입력값 검증
if (!isValidMonth(month)) {
console.warn('유효하지 않은 월 형식:', month);
logger.warn("유효하지 않은 월 형식:", month);
return getCurrentMonth();
}
try {
// 월 문자열을 날짜로 파싱
const date = parse(month, 'yyyy-MM', new Date());
const date = parse(month, "yyyy-MM", new Date());
// 한 달 이후
const nextMonth = addMonths(date, 1);
// yyyy-MM 형식으로 반환
return format(nextMonth, 'yyyy-MM');
return format(nextMonth, "yyyy-MM");
} catch (error) {
console.error('다음 월 계산 중 오류:', error);
logger.error("다음 월 계산 중 오류:", error);
return getCurrentMonth();
}
};
@@ -78,16 +88,16 @@ export const formatMonthForDisplay = (month: string): string => {
try {
// 입력값 검증
if (!isValidMonth(month)) {
console.warn('유효하지 않은 월 형식:', month);
return format(new Date(), 'yyyy년 MM월', { locale: ko });
logger.warn("유효하지 않은 월 형식:", month);
return format(new Date(), "yyyy년 MM월", { locale: ko });
}
// 월 문자열을 날짜로 파싱
const date = parse(month, 'yyyy-MM', new Date());
const date = parse(month, "yyyy-MM", new Date());
// yyyy년 MM월 형식으로 반환 (한국어 로케일)
return format(date, 'yyyy년 MM월', { locale: ko });
return format(date, "yyyy년 MM월", { locale: ko });
} catch (error) {
console.error('월 형식 변환 중 오류:', error);
logger.error("월 형식 변환 중 오류:", error);
return month;
}
};

View File

@@ -1,11 +1,11 @@
import { useCallback } from 'react';
import { Transaction } from '@/components/TransactionCard';
import { useAuth } from '@/contexts/auth/useAuth';
import { toast } from '@/hooks/useToast.wrapper';
import { saveTransactionsToStorage } from './storageUtils';
import { deleteTransactionFromSupabase } from './supabaseUtils';
import { addToDeletedTransactions } from '@/utils/sync/transaction/deletedTransactionsTracker';
import { useCallback } from "react";
import { logger } from "@/utils/logger";
import { Transaction } from "@/components/TransactionCard";
import { useAuth } from "@/contexts/auth/useAuth";
import { toast } from "@/hooks/useToast.wrapper";
import { saveTransactionsToStorage } from "./storageUtils";
import { deleteTransactionFromSupabase } from "./supabaseUtils";
import { addToDeletedTransactions } from "@/utils/sync/transaction/deletedTransactionsTracker";
/**
* 트랜잭션 삭제 기능을 위한 훅
@@ -19,72 +19,87 @@ export const useDeleteTransaction = (
/**
* 트랜잭션 삭제 처리
*/
const deleteTransaction = useCallback(async (transactionId: string): Promise<boolean> => {
try {
console.log(`[트랜잭션 삭제] 시작: ID=${transactionId}`);
// 트랜잭션 존재 확인
const transaction = transactions.find(t => t.id === transactionId);
if (!transaction) {
console.warn(`[트랜잭션 삭제] 트랜잭션을 찾을 수 없음: ${transactionId}`);
const deleteTransaction = useCallback(
async (transactionId: string): Promise<boolean> => {
try {
logger.info(`[트랜잭션 삭제] 시작: ID=${transactionId}`);
// 트랜잭션 존재 확인
const transaction = transactions.find((t) => t.id === transactionId);
if (!transaction) {
logger.warn(
`[트랜잭션 삭제] 트랜잭션을 찾을 수 없음: ${transactionId}`
);
return false;
}
logger.info(
`[트랜잭션 삭제] 삭제 대상: "${transaction.title}", 금액: ${transaction.amount}`
);
// 트랜잭션 목록에서 제거
const updatedTransactions = transactions.filter(
(t) => t.id !== transactionId
);
// 로컬 스토리지 업데이트
saveTransactionsToStorage(updatedTransactions);
logger.info(`[트랜잭션 삭제] 로컬 저장소 업데이트 완료`);
// 상태 업데이트
setTransactions(updatedTransactions);
// 클라우드 동기화 (Supabase)
if (user) {
try {
logger.info(`[트랜잭션 삭제] Supabase 삭제 시작: ${transactionId}`);
await deleteTransactionFromSupabase(user, transactionId);
logger.info(`[트랜잭션 삭제] Supabase 삭제 성공: ${transactionId}`);
} catch (syncError) {
logger.error(`[트랜잭션 삭제] Supabase 삭제 실패:`, syncError);
// 삭제 실패하더라도 로컬에서는 삭제됨, 추적 목록에 추가
addToDeletedTransactions(transactionId);
logger.info(
`[트랜잭션 삭제] 삭제 트랜잭션 추적 목록에 추가: ${transactionId}`
);
}
} else {
// 오프라인 상태이거나 로그인되지 않은 경우 추적 목록에 추가
addToDeletedTransactions(transactionId);
logger.info(
`[트랜잭션 삭제] 오프라인 삭제: 추적 목록에 추가 ${transactionId}`
);
}
// 이벤트 발생
window.dispatchEvent(new Event("transactionUpdated"));
window.dispatchEvent(
new CustomEvent("transactionDeleted", {
detail: { id: transactionId },
})
);
// 토스트 메시지 표시
toast({
title: "지출이 삭제되었습니다",
description: `${transaction.title} 항목이 삭제되었습니다.`,
duration: 3000,
});
logger.info(`[트랜잭션 삭제] 완료: ${transactionId}`);
return true;
} catch (error) {
logger.error(`[트랜잭션 삭제] 오류 발생:`, error);
toast({
title: "삭제 실패",
description: "지출 항목 삭제 중 오류가 발생했습니다.",
variant: "destructive",
});
return false;
}
console.log(`[트랜잭션 삭제] 삭제 대상: "${transaction.title}", 금액: ${transaction.amount}`);
// 트랜잭션 목록에서 제거
const updatedTransactions = transactions.filter(t => t.id !== transactionId);
// 로컬 스토리지 업데이트
saveTransactionsToStorage(updatedTransactions);
console.log(`[트랜잭션 삭제] 로컬 저장소 업데이트 완료`);
// 상태 업데이트
setTransactions(updatedTransactions);
// 클라우드 동기화 (Supabase)
if (user) {
try {
console.log(`[트랜잭션 삭제] Supabase 삭제 시작: ${transactionId}`);
await deleteTransactionFromSupabase(user, transactionId);
console.log(`[트랜잭션 삭제] Supabase 삭제 성공: ${transactionId}`);
} catch (syncError) {
console.error(`[트랜잭션 삭제] Supabase 삭제 실패:`, syncError);
// 삭제 실패하더라도 로컬에서는 삭제됨, 추적 목록에 추가
addToDeletedTransactions(transactionId);
console.log(`[트랜잭션 삭제] 삭제 트랜잭션 추적 목록에 추가: ${transactionId}`);
}
} else {
// 오프라인 상태이거나 로그인되지 않은 경우 추적 목록에 추가
addToDeletedTransactions(transactionId);
console.log(`[트랜잭션 삭제] 오프라인 삭제: 추적 목록에 추가 ${transactionId}`);
}
// 이벤트 발생
window.dispatchEvent(new Event('transactionUpdated'));
window.dispatchEvent(new CustomEvent('transactionDeleted', {
detail: { id: transactionId }
}));
// 토스트 메시지 표시
toast({
title: "지출이 삭제되었습니다",
description: `${transaction.title} 항목이 삭제되었습니다.`,
duration: 3000
});
console.log(`[트랜잭션 삭제] 완료: ${transactionId}`);
return true;
} catch (error) {
console.error(`[트랜잭션 삭제] 오류 발생:`, error);
toast({
title: "삭제 실패",
description: "지출 항목 삭제 중 오류가 발생했습니다.",
variant: "destructive"
});
return false;
}
}, [transactions, setTransactions, user]);
},
[transactions, setTransactions, user]
);
return { deleteTransaction };
};

View File

@@ -1,9 +1,13 @@
import { useCallback, useEffect } from 'react';
import { Transaction } from '@/contexts/budget/types';
import { getCurrentMonth, getPrevMonth, getNextMonth } from '../dateUtils';
import { filterTransactionsByMonth, filterTransactionsByQuery, calculateTotalExpenses } from '../filterUtils';
import { parseTransactionDate } from '@/utils/dateParser';
import { useCallback, useEffect } from "react";
import { logger } from "@/utils/logger";
import { Transaction } from "@/contexts/budget/types";
import { getCurrentMonth, getPrevMonth, getNextMonth } from "../dateUtils";
import {
filterTransactionsByMonth,
filterTransactionsByQuery,
calculateTotalExpenses,
} from "../filterUtils";
import { parseTransactionDate } from "@/utils/dateParser";
interface UseTransactionsFilteringProps {
transactions: Transaction[];
@@ -22,27 +26,32 @@ export const useTransactionsFiltering = ({
selectedMonth,
setSelectedMonth,
searchQuery,
setFilteredTransactions
setFilteredTransactions,
}: UseTransactionsFilteringProps) => {
// 필터링 적용
useEffect(() => {
console.log('트랜잭션 필터링 적용:', { 선택된월: selectedMonth, 검색어: searchQuery });
logger.info("트랜잭션 필터링 적용:", {
선택된월: selectedMonth,
검색어: searchQuery,
});
try {
// 먼저 월별 필터링 - 개선된 날짜 처리 기능 사용
const monthFiltered = filterTransactionsByMonth(transactions, selectedMonth);
console.log('월별 필터링 결과:', monthFiltered.length);
const monthFiltered = filterTransactionsByMonth(
transactions,
selectedMonth
);
logger.info("월별 필터링 결과:", monthFiltered.length);
// 그 다음 검색어 필터링
const searchFiltered = searchQuery
const searchFiltered = searchQuery
? filterTransactionsByQuery(monthFiltered, searchQuery)
: monthFiltered;
console.log('최종 필터링 결과:', searchFiltered.length);
logger.info("최종 필터링 결과:", searchFiltered.length);
setFilteredTransactions(searchFiltered);
} catch (error) {
console.error('트랜잭션 필터링 중 오류 발생:', error);
logger.error("트랜잭션 필터링 중 오류 발생:", error);
// 오류 발생 시 원본 데이터 유지
setFilteredTransactions(transactions);
}
@@ -59,16 +68,19 @@ export const useTransactionsFiltering = ({
}, [selectedMonth, setSelectedMonth]);
// 총 지출 계산 - 개선된 계산 로직 사용
const getTotalExpenses = useCallback((filteredTransactions: Transaction[]): number => {
console.log('총 지출 계산 중...', filteredTransactions.length);
const total = calculateTotalExpenses(filteredTransactions);
console.log('계산된 총 지출:', total);
return total;
}, []);
const getTotalExpenses = useCallback(
(filteredTransactions: Transaction[]): number => {
logger.info("총 지출 계산 중...", filteredTransactions.length);
const total = calculateTotalExpenses(filteredTransactions);
logger.info("계산된 총 지출:", total);
return total;
},
[]
);
return {
handlePrevMonth,
handleNextMonth,
getTotalExpenses
getTotalExpenses,
};
};

View File

@@ -1,5 +1,4 @@
import { Transaction } from '@/components/TransactionCard';
import { Transaction } from "@/components/TransactionCard";
export interface FilteringProps {
transactions: Transaction[];

View File

@@ -1,55 +1,65 @@
import { useCallback, useEffect } from 'react';
import { Transaction } from '@/components/TransactionCard';
import { FilteringProps } from './types';
import { MONTHS_KR } from '../dateUtils';
import { useCallback, useEffect } from "react";
import { logger } from "@/utils/logger";
import { Transaction } from "@/components/TransactionCard";
import { FilteringProps } from "./types";
import { MONTHS_KR } from "../dateUtils";
/**
* 거래 필터링 로직
* 선택된 월과 검색어를 기준으로 거래를 필터링합니다.
*/
export const useFilterApplication = ({
transactions,
selectedMonth,
searchQuery,
setFilteredTransactions
}: Pick<FilteringProps, 'transactions' | 'selectedMonth' | 'searchQuery' | 'setFilteredTransactions'>) => {
export const useFilterApplication = ({
transactions,
selectedMonth,
searchQuery,
setFilteredTransactions,
}: Pick<
FilteringProps,
"transactions" | "selectedMonth" | "searchQuery" | "setFilteredTransactions"
>) => {
// 거래 필터링 함수
const filterTransactions = useCallback(() => {
try {
console.log('필터링 시작, 전체 트랜잭션:', transactions.length);
console.log('선택된 월:', selectedMonth);
logger.info("필터링 시작, 전체 트랜잭션:", transactions.length);
logger.info("선택된 월:", selectedMonth);
// 선택된 월 정보 파싱
const selectedMonthName = selectedMonth;
const monthNumber = MONTHS_KR.findIndex(month => month === selectedMonthName) + 1;
const monthNumber =
MONTHS_KR.findIndex((month) => month === selectedMonthName) + 1;
// 월별 필터링
let filtered = transactions.filter(transaction => {
if (!transaction.date) return false;
console.log(`트랜잭션 날짜 확인: "${transaction.date}", 타입: ${typeof transaction.date}`);
let filtered = transactions.filter((transaction) => {
if (!transaction.date) {
return false;
}
logger.info(
`트랜잭션 날짜 확인: "${transaction.date}", 타입: ${typeof transaction.date}`
);
// 다양한 날짜 형식 처리
if (transaction.date.includes(selectedMonthName)) {
return true; // 선택된 월 이름이 포함된 경우
}
if (transaction.date.includes('오늘')) {
if (transaction.date.includes("오늘")) {
// 오늘 날짜가 해당 월인지 확인
const today = new Date();
const currentMonth = today.getMonth() + 1; // 0부터 시작하므로 +1
return currentMonth === monthNumber;
}
// 다른 형식의 날짜도 시도
try {
// ISO 형식이 아닌 경우 처리
if (transaction.date.includes('년') || transaction.date.includes('월')) {
if (
transaction.date.includes("년") ||
transaction.date.includes("월")
) {
return transaction.date.includes(selectedMonthName);
}
// 표준 날짜 문자열 처리 시도
const date = new Date(transaction.date);
if (!isNaN(date.getTime())) {
@@ -57,31 +67,32 @@ export const useFilterApplication = ({
return transactionMonth === monthNumber;
}
} catch (e) {
console.error('날짜 파싱 오류:', e);
logger.error("날짜 파싱 오류:", e);
}
// 기본적으로 모든 트랜잭션 포함
return true;
});
console.log(`월별 필터링 결과: ${filtered.length} 트랜잭션`);
logger.info(`월별 필터링 결과: ${filtered.length} 트랜잭션`);
// 검색어에 따른 필터링
if (searchQuery.trim()) {
const searchLower = searchQuery.toLowerCase();
filtered = filtered.filter(transaction =>
transaction.title.toLowerCase().includes(searchLower) ||
transaction.category.toLowerCase().includes(searchLower) ||
transaction.amount.toString().includes(searchQuery)
filtered = filtered.filter(
(transaction) =>
transaction.title.toLowerCase().includes(searchLower) ||
transaction.category.toLowerCase().includes(searchLower) ||
transaction.amount.toString().includes(searchQuery)
);
console.log(`검색어 필터링 결과: ${filtered.length} 트랜잭션`);
logger.info(`검색어 필터링 결과: ${filtered.length} 트랜잭션`);
}
// 결과 설정
setFilteredTransactions(filtered);
console.log('최종 필터링 결과:', filtered);
logger.info("최종 필터링 결과:", filtered);
} catch (error) {
console.error('거래 필터링 중 오류:', error);
logger.error("거래 필터링 중 오류:", error);
setFilteredTransactions([]);
}
}, [transactions, selectedMonth, searchQuery, setFilteredTransactions]);
@@ -92,6 +103,6 @@ export const useFilterApplication = ({
}, [transactions, selectedMonth, searchQuery, filterTransactions]);
return {
filterTransactions
filterTransactions,
};
};

View File

@@ -1,34 +1,34 @@
import { useCallback } from 'react';
import { getPrevMonth, getNextMonth } from '../dateUtils';
import { useCallback } from "react";
import { logger } from "@/utils/logger";
import { getPrevMonth, getNextMonth } from "../dateUtils";
/**
* 월 선택 관련 훅
* 이전/다음 월 이동 기능을 제공합니다.
*/
export const useMonthSelection = ({
selectedMonth,
setSelectedMonth
}: {
export const useMonthSelection = ({
selectedMonth,
setSelectedMonth,
}: {
selectedMonth: string;
setSelectedMonth: (month: string) => void;
}) => {
// 이전 월로 이동
const handlePrevMonth = useCallback(() => {
const prevMonth = getPrevMonth(selectedMonth);
console.log(`월 변경: ${selectedMonth} -> ${prevMonth}`);
logger.info(`월 변경: ${selectedMonth} -> ${prevMonth}`);
setSelectedMonth(prevMonth);
}, [selectedMonth, setSelectedMonth]);
// 다음 월로 이동
const handleNextMonth = useCallback(() => {
const nextMonth = getNextMonth(selectedMonth);
console.log(`월 변경: ${selectedMonth} -> ${nextMonth}`);
logger.info(`월 변경: ${selectedMonth} -> ${nextMonth}`);
setSelectedMonth(nextMonth);
}, [selectedMonth, setSelectedMonth]);
return {
handlePrevMonth,
handleNextMonth
handleNextMonth,
};
};

View File

@@ -1,6 +1,5 @@
import { Transaction } from '@/components/TransactionCard';
import { calculateTotalExpenses } from '../filterUtils';
import { Transaction } from "@/components/TransactionCard";
import { calculateTotalExpenses } from "../filterUtils";
/**
* 총 지출 계산 관련 훅
@@ -13,6 +12,6 @@ export const useTotalCalculation = () => {
};
return {
getTotalExpenses
getTotalExpenses,
};
};

View File

@@ -1,45 +1,54 @@
import { Transaction } from '@/contexts/budget/types';
import { parseTransactionDate } from '@/utils/dateParser';
import { format } from 'date-fns';
import { Transaction } from "@/contexts/budget/types";
import { logger } from "@/utils/logger";
import { parseTransactionDate } from "@/utils/dateParser";
import { format } from "date-fns";
/**
* 트랜잭션을 월별로 필터링
*/
export const filterTransactionsByMonth = (transactions: Transaction[], selectedMonth: string): Transaction[] => {
export const filterTransactionsByMonth = (
transactions: Transaction[],
selectedMonth: string
): Transaction[] => {
if (!transactions || transactions.length === 0) {
return [];
}
console.log(`월별 필터링 시작: ${selectedMonth}, 트랜잭션 수: ${transactions.length}`);
logger.info(
`월별 필터링 시작: ${selectedMonth}, 트랜잭션 수: ${transactions.length}`
);
try {
const [year, month] = selectedMonth.split('-').map(Number);
const filtered = transactions.filter(transaction => {
const [year, month] = selectedMonth.split("-").map(Number);
const filtered = transactions.filter((transaction) => {
const date = parseTransactionDate(transaction.date);
if (!date) {
console.warn(`날짜를 파싱할 수 없음: ${transaction.date}, 트랜잭션 ID: ${transaction.id}`);
logger.warn(
`날짜를 파싱할 수 없음: ${transaction.date}, 트랜잭션 ID: ${transaction.id}`
);
return false;
}
const transactionYear = date.getFullYear();
const transactionMonth = date.getMonth() + 1; // JavaScript 월은 0부터 시작하므로 +1
const match = transactionYear === year && transactionMonth === month;
if (match) {
console.log(`트랜잭션 매칭: ${transaction.id}, 제목: ${transaction.title}, 날짜: ${transaction.date}`);
logger.info(
`트랜잭션 매칭: ${transaction.id}, 제목: ${transaction.title}, 날짜: ${transaction.date}`
);
}
return match;
});
console.log(`월별 필터링 결과: ${filtered.length}개 트랜잭션`);
logger.info(`월별 필터링 결과: ${filtered.length}개 트랜잭션`);
return filtered;
} catch (error) {
console.error('월별 필터링 중 오류:', error);
logger.error("월별 필터링 중 오류:", error);
return [];
}
};
@@ -47,18 +56,25 @@ export const filterTransactionsByMonth = (transactions: Transaction[], selectedM
/**
* 트랜잭션을 검색어로 필터링
*/
export const filterTransactionsByQuery = (transactions: Transaction[], searchQuery: string): Transaction[] => {
if (!searchQuery || searchQuery.trim() === '') {
export const filterTransactionsByQuery = (
transactions: Transaction[],
searchQuery: string
): Transaction[] => {
if (!searchQuery || searchQuery.trim() === "") {
return transactions;
}
const normalizedQuery = searchQuery.toLowerCase().trim();
return transactions.filter(transaction => {
const titleMatch = transaction.title.toLowerCase().includes(normalizedQuery);
const categoryMatch = transaction.category.toLowerCase().includes(normalizedQuery);
return transactions.filter((transaction) => {
const titleMatch = transaction.title
.toLowerCase()
.includes(normalizedQuery);
const categoryMatch = transaction.category
.toLowerCase()
.includes(normalizedQuery);
const amountMatch = transaction.amount.toString().includes(normalizedQuery);
return titleMatch || categoryMatch || amountMatch;
});
};
@@ -68,49 +84,55 @@ export const filterTransactionsByQuery = (transactions: Transaction[], searchQue
*/
export const calculateTotalExpenses = (transactions: Transaction[]): number => {
if (!transactions || transactions.length === 0) {
console.log('계산할 트랜잭션이 없습니다.');
logger.info("계산할 트랜잭션이 없습니다.");
return 0;
}
console.log(`총 지출 계산 시작: 트랜잭션 ${transactions.length}`);
logger.info(`총 지출 계산 시작: 트랜잭션 ${transactions.length}`);
// 지출 타입만 필터링하고 합산
const expenses = transactions
.filter(t => t.type === 'expense')
.filter((t) => t.type === "expense")
.reduce((sum, transaction) => {
const amount = Number(transaction.amount);
if (isNaN(amount)) {
console.warn(`유효하지 않은 금액: ${transaction.amount}, 트랜잭션 ID: ${transaction.id}`);
logger.warn(
`유효하지 않은 금액: ${transaction.amount}, 트랜잭션 ID: ${transaction.id}`
);
return sum;
}
return sum + amount;
}, 0);
console.log(`총 지출 계산 결과: ${expenses}`);
logger.info(`총 지출 계산 결과: ${expenses}`);
return expenses;
};
/**
* 트랜잭션을 날짜별로 그룹화
*/
export const groupTransactionsByDate = (transactions: Transaction[]): Record<string, Transaction[]> => {
export const groupTransactionsByDate = (
transactions: Transaction[]
): Record<string, Transaction[]> => {
const groups: Record<string, Transaction[]> = {};
transactions.forEach(transaction => {
transactions.forEach((transaction) => {
const date = parseTransactionDate(transaction.date);
if (!date) {
console.warn(`날짜를 파싱할 수 없음: ${transaction.date}, 트랜잭션 ID: ${transaction.id}`);
logger.warn(
`날짜를 파싱할 수 없음: ${transaction.date}, 트랜잭션 ID: ${transaction.id}`
);
return;
}
const formattedDate = format(date, 'yyyy-MM-dd');
const formattedDate = format(date, "yyyy-MM-dd");
if (!groups[formattedDate]) {
groups[formattedDate] = [];
}
groups[formattedDate].push(transaction);
});
return groups;
};

View File

@@ -1,5 +1,13 @@
// 트랜잭션 관련 모든 훅과 유틸리티 함수를 재내보내기
export { useTransactions } from './useTransactions';
export { MONTHS_KR, getCurrentMonth, getPrevMonth, getNextMonth } from './dateUtils';
export { filterTransactionsByMonth, filterTransactionsByQuery, calculateTotalExpenses } from './filterUtils';
export { useTransactions } from "./useTransactions";
export {
MONTHS_KR,
getCurrentMonth,
getPrevMonth,
getNextMonth,
} from "./dateUtils";
export {
filterTransactionsByMonth,
filterTransactionsByQuery,
calculateTotalExpenses,
} from "./filterUtils";

View File

@@ -1,90 +1,94 @@
import { Transaction } from '@/contexts/budget/types';
import { toast } from '@/hooks/useToast.wrapper';
import { EXPENSE_CATEGORIES } from '@/constants/categoryIcons';
import { Transaction } from "@/contexts/budget/types";
import { storageLogger } from "@/utils/logger";
import { toast } from "@/hooks/useToast.wrapper";
import { EXPENSE_CATEGORIES } from "@/constants/categoryIcons";
// 로컬 스토리지에서 트랜잭션 데이터 로드
export const loadTransactionsFromStorage = (): Transaction[] => {
try {
// 로컬 스토리지에서 트랜잭션 데이터 가져오기
const localDataStr = localStorage.getItem('transactions');
console.log('로컬 트랜잭션 데이터:', localDataStr);
const localDataStr = localStorage.getItem("transactions");
storageLogger.info("로컬 트랜잭션 데이터:", localDataStr);
if (localDataStr) {
try {
const localData = JSON.parse(localDataStr);
// 지원되는 카테고리로 필터링 및 카테고리명 변환
const filteredData = localData.map((transaction: Transaction) => {
if (transaction.type === 'expense') {
if (transaction.type === "expense") {
// 기존 카테고리명 변환
if (transaction.category === '식비') {
return {
...transaction,
category: '음식',
paymentMethod: transaction.paymentMethod || '신용카드' // 기존 데이터에 없으면 기본값 추가
if (transaction.category === "식비") {
return {
...transaction,
category: "음식",
paymentMethod: transaction.paymentMethod || "신용카드", // 기존 데이터에 없으면 기본값 추가
};
} else if (transaction.category === '생활비') {
return {
...transaction,
category: '쇼핑',
paymentMethod: transaction.paymentMethod || '신용카드' // 기존 데이터에 없으면 기본값 추가
} else if (transaction.category === "생활비") {
return {
...transaction,
category: "쇼핑",
paymentMethod: transaction.paymentMethod || "신용카드", // 기존 데이터에 없으면 기본값 추가
};
} else if (!EXPENSE_CATEGORIES.includes(transaction.category)) {
return {
...transaction,
category: '쇼핑',
paymentMethod: transaction.paymentMethod || '신용카드' // 기존 데이터에 없으면 기본값 추가
return {
...transaction,
category: "쇼핑",
paymentMethod: transaction.paymentMethod || "신용카드", // 기존 데이터에 없으면 기본값 추가
}; // 지원되지 않는 카테고리는 '쇼핑'으로
}
// 기존 데이터에 paymentMethod가 없으면 기본값 추가
if (!transaction.paymentMethod) {
return {
...transaction,
paymentMethod: '신용카드'
paymentMethod: "신용카드",
};
}
}
return transaction;
});
console.log('필터링된 트랜잭션:', filteredData.length);
storageLogger.info("필터링된 트랜잭션:", filteredData.length);
return filteredData;
} catch (parseError) {
console.error('트랜잭션 데이터 파싱 오류:', parseError);
storageLogger.error("트랜잭션 데이터 파싱 오류:", parseError);
return [];
}
}
} catch (err) {
console.error('트랜잭션 로드 중 오류:', err);
storageLogger.error("트랜잭션 로드 중 오류:", err);
}
console.log('로컬 트랜잭션 데이터 없음');
storageLogger.info("로컬 트랜잭션 데이터 없음");
return [];
};
// 로컬 스토리지에 트랜잭션 데이터 저장
export const saveTransactionsToStorage = (transactions: Transaction[]): void => {
export const saveTransactionsToStorage = (
transactions: Transaction[]
): void => {
try {
const dataString = JSON.stringify(transactions);
localStorage.setItem('transactions', dataString);
localStorage.setItem('transactions_backup', dataString); // 백업도 저장
localStorage.setItem("transactions", dataString);
localStorage.setItem("transactions_backup", dataString); // 백업도 저장
// 이벤트 발생
window.dispatchEvent(new Event('transactionUpdated'));
window.dispatchEvent(new StorageEvent('storage', {
key: 'transactions',
newValue: dataString
}));
console.log('트랜잭션 저장 완료:', transactions.length, '개');
window.dispatchEvent(new Event("transactionUpdated"));
window.dispatchEvent(
new StorageEvent("storage", {
key: "transactions",
newValue: dataString,
})
);
storageLogger.info("트랜잭션 저장 완료:", transactions.length, "개");
} catch (error) {
console.error('트랜잭션 저장 오류:', error);
storageLogger.error("트랜잭션 저장 오류:", error);
toast({
title: "데이터 저장 실패",
description: "트랜잭션 데이터를 저장하는데 실패했습니다.",
variant: "destructive"
variant: "destructive",
});
}
};
@@ -92,13 +96,13 @@ export const saveTransactionsToStorage = (transactions: Transaction[]): void =>
// 예산 데이터 로드
export const loadBudgetFromStorage = (): number => {
try {
const budgetDataStr = localStorage.getItem('budgetData');
const budgetDataStr = localStorage.getItem("budgetData");
if (budgetDataStr) {
const budgetData = JSON.parse(budgetDataStr);
return budgetData.monthly.targetAmount;
}
} catch (e) {
console.error('예산 데이터 파싱 오류:', e);
storageLogger.error("예산 데이터 파싱 오류:", e);
}
return 0;
};

View File

@@ -1,32 +1,42 @@
import { useCallback } from 'react';
import { Transaction } from '@/contexts/budget/types';
import { useBudget } from '@/contexts/budget/BudgetContext';
import { useCallback } from "react";
import { logger } from "@/utils/logger";
import { Transaction } from "@/contexts/budget/types";
import { useBudget } from "@/contexts/budget/BudgetContext";
export const useTransactionsOperations = (transactions: Transaction[]) => {
const { updateTransaction: budgetUpdateTransaction, deleteTransaction: budgetDeleteTransaction } = useBudget();
const {
updateTransaction: budgetUpdateTransaction,
deleteTransaction: budgetDeleteTransaction,
} = useBudget();
// 트랜잭션 업데이트 함수
const updateTransaction = useCallback((updatedTransaction: Transaction): void => {
try {
budgetUpdateTransaction(updatedTransaction);
} catch (error) {
console.error('트랜잭션 업데이트 중 오류:', error);
}
}, [budgetUpdateTransaction]);
const updateTransaction = useCallback(
(updatedTransaction: Transaction): void => {
try {
budgetUpdateTransaction(updatedTransaction);
} catch (error) {
logger.error("트랜잭션 업데이트 중 오류:", error);
}
},
[budgetUpdateTransaction]
);
// 트랜잭션 삭제 함수
const deleteTransaction = useCallback(async (id: string): Promise<boolean> => {
try {
budgetDeleteTransaction(id);
return true;
} catch (error) {
console.error('트랜잭션 삭제 중 오류:', error);
return false;
}
}, [budgetDeleteTransaction]);
const deleteTransaction = useCallback(
async (id: string): Promise<boolean> => {
try {
budgetDeleteTransaction(id);
return true;
} catch (error) {
logger.error("트랜잭션 삭제 중 오류:", error);
return false;
}
},
[budgetDeleteTransaction]
);
return {
updateTransaction,
deleteTransaction
deleteTransaction,
};
};

View File

@@ -1,5 +1,4 @@
import { Transaction } from '@/components/TransactionCard';
import { Transaction } from "@/components/TransactionCard";
export interface TransactionOperationProps {
transactions: Transaction[];

View File

@@ -1,12 +1,12 @@
import { useCallback } from 'react';
import { Transaction } from '@/components/TransactionCard';
import { useAuth } from '@/contexts/auth/useAuth';
import { toast } from '@/hooks/useToast.wrapper';
import { saveTransactionsToStorage } from '../storageUtils';
import { updateTransactionInSupabase } from '../supabaseUtils';
import { TransactionOperationProps } from './types';
import { normalizeDate } from '@/utils/sync/transaction/dateUtils';
import { useCallback } from "react";
import { logger } from "@/utils/logger";
import { Transaction } from "@/components/TransactionCard";
import { useAuth } from "@/contexts/auth/useAuth";
import { toast } from "@/hooks/useToast.wrapper";
import { saveTransactionsToStorage } from "../storageUtils";
import { updateTransactionInSupabase } from "../supabaseUtils";
import { TransactionOperationProps } from "./types";
import { normalizeDate } from "@/utils/sync/transaction/dateUtils";
/**
* 트랜잭션 업데이트 기능
@@ -18,88 +18,104 @@ export const useUpdateTransaction = (
) => {
const { user } = useAuth();
return useCallback((updatedTransaction: Transaction) => {
try {
console.log(`[트랜잭션] 업데이트 시작: ID=${updatedTransaction.id}, 제목=${updatedTransaction.title}`);
// 트랜잭션 존재 여부 확인
const existingIndex = transactions.findIndex(t => t.id === updatedTransaction.id);
if (existingIndex === -1) {
console.warn(`[트랜잭션] 업데이트 오류: ID ${updatedTransaction.id}를 찾을 수 없음`);
toast({
title: "업데이트 실패",
description: "해당 지출 항목을 찾을 수 없습니다.",
variant: "destructive"
});
return;
}
// 기존 데이터와 변경 감지
const oldTransaction = transactions[existingIndex];
const hasChanges = JSON.stringify(oldTransaction) !== JSON.stringify(updatedTransaction);
if (!hasChanges) {
console.log(`[트랜잭션] 변경사항 없음: ${updatedTransaction.id}`);
return;
}
// 변경 내용 로깅
console.log(`[트랜잭션] 변경 감지:
return useCallback(
(updatedTransaction: Transaction) => {
try {
logger.info(
`[트랜잭션] 업데이트 시작: ID=${updatedTransaction.id}, 제목=${updatedTransaction.title}`
);
// 트랜잭션 존재 여부 확인
const existingIndex = transactions.findIndex(
(t) => t.id === updatedTransaction.id
);
if (existingIndex === -1) {
logger.warn(
`[트랜잭션] 업데이트 오류: ID ${updatedTransaction.id}를 찾을 수 없음`
);
toast({
title: "업데이트 실패",
description: "해당 지출 항목을 찾을 수 없습니다.",
variant: "destructive",
});
return;
}
// 기존 데이터와 변경 감지
const oldTransaction = transactions[existingIndex];
const hasChanges =
JSON.stringify(oldTransaction) !== JSON.stringify(updatedTransaction);
if (!hasChanges) {
logger.info(`[트랜잭션] 변경사항 없음: ${updatedTransaction.id}`);
return;
}
// 변경 내용 로깅
logger.info(`[트랜잭션] 변경 감지:
제목: ${oldTransaction.title} -> ${updatedTransaction.title}
금액: ${oldTransaction.amount} -> ${updatedTransaction.amount}
카테고리: ${oldTransaction.category} -> ${updatedTransaction.category}
날짜: ${oldTransaction.date} -> ${updatedTransaction.date}
`);
// 로컬 스토리지 업데이트
const updatedTransactions = transactions.map(transaction =>
transaction.id === updatedTransaction.id ? updatedTransaction : transaction
);
saveTransactionsToStorage(updatedTransactions);
console.log(`[트랜잭션] 로컬 저장소 업데이트 완료`);
// 상태 업데이트
setTransactions(updatedTransactions);
// Supabase 업데이트 시도 (날짜 형식 변환 추가)
if (user) {
// ISO 형식으로 날짜 변환
const transactionWithIsoDate = {
...updatedTransaction,
dateForSync: normalizeDate(updatedTransaction.date)
};
console.log(`[트랜잭션] Supabase 업데이트 시작: ${updatedTransaction.id}`);
updateTransactionInSupabase(user, transactionWithIsoDate)
.then(() => {
console.log(`[트랜잭션] Supabase 업데이트 성공: ${updatedTransaction.id}`);
})
.catch(err => {
console.error(`[트랜잭션] Supabase 업데이트 실패:`, err);
// 로컬 스토리지 업데이트
const updatedTransactions = transactions.map((transaction) =>
transaction.id === updatedTransaction.id
? updatedTransaction
: transaction
);
saveTransactionsToStorage(updatedTransactions);
logger.info(`[트랜잭션] 로컬 저장소 업데이트 완료`);
// 상태 업데이트
setTransactions(updatedTransactions);
// Supabase 업데이트 시도 (날짜 형식 변환 추가)
if (user) {
// ISO 형식으로 날짜 변환
const transactionWithIsoDate = {
...updatedTransaction,
dateForSync: normalizeDate(updatedTransaction.date),
};
logger.info(
`[트랜잭션] Supabase 업데이트 시작: ${updatedTransaction.id}`
);
updateTransactionInSupabase(user, transactionWithIsoDate)
.then(() => {
logger.info(
`[트랜잭션] Supabase 업데이트 성공: ${updatedTransaction.id}`
);
})
.catch((err) => {
logger.error(`[트랜잭션] Supabase 업데이트 실패:`, err);
});
} else {
logger.info(`[트랜잭션] 로그인 상태 아님: Supabase 업데이트 건너뜀`);
}
// 이벤트 발생
window.dispatchEvent(new Event("transactionUpdated"));
// 약간의 지연을 두고 토스트 표시
setTimeout(() => {
toast({
title: "지출이 수정되었습니다",
description: `${updatedTransaction.title} 항목이 업데이트되었습니다.`,
duration: 3000,
});
} else {
console.log(`[트랜잭션] 로그인 상태 아님: Supabase 업데이트 건너뜀`);
}
// 이벤트 발생
window.dispatchEvent(new Event('transactionUpdated'));
// 약간의 지연을 두고 토스트 표시
setTimeout(() => {
}, 100);
} catch (error) {
logger.error(`[트랜잭션] 업데이트 중 오류 발생:`, error);
toast({
title: "지출이 수정되었습니다",
description: `${updatedTransaction.title} 항목이 업데이트되었습니다.`,
duration: 3000
title: "업데이트 실패",
description: "지출 수정 중 오류가 발생했습니다.",
variant: "destructive",
});
}, 100);
} catch (error) {
console.error(`[트랜잭션] 업데이트 중 오류 발생:`, error);
toast({
title: "업데이트 실패",
description: "지출 수정 중 오류가 발생했습니다.",
variant: "destructive"
});
}
}, [transactions, setTransactions, user]);
}
},
[transactions, setTransactions, user]
);
};

View File

@@ -1,32 +1,42 @@
import { useCallback } from 'react';
import { Transaction } from '@/contexts/budget/types';
import { useBudget } from '@/contexts/budget/BudgetContext';
import { useCallback } from "react";
import { logger } from "@/utils/logger";
import { Transaction } from "@/contexts/budget/types";
import { useBudget } from "@/contexts/budget/BudgetContext";
export const useTransactionsOperations = (transactions: Transaction[]) => {
const { updateTransaction: budgetUpdateTransaction, deleteTransaction: budgetDeleteTransaction } = useBudget();
const {
updateTransaction: budgetUpdateTransaction,
deleteTransaction: budgetDeleteTransaction,
} = useBudget();
// 트랜잭션 업데이트 함수
const updateTransaction = useCallback((updatedTransaction: Transaction): void => {
try {
budgetUpdateTransaction(updatedTransaction);
} catch (error) {
console.error('트랜잭션 업데이트 중 오류:', error);
}
}, [budgetUpdateTransaction]);
const updateTransaction = useCallback(
(updatedTransaction: Transaction): void => {
try {
budgetUpdateTransaction(updatedTransaction);
} catch (error) {
logger.error("트랜잭션 업데이트 중 오류:", error);
}
},
[budgetUpdateTransaction]
);
// 트랜잭션 삭제 함수
const deleteTransaction = useCallback(async (id: string): Promise<boolean> => {
try {
budgetDeleteTransaction(id);
return true;
} catch (error) {
console.error('트랜잭션 삭제 중 오류:', error);
return false;
}
}, [budgetDeleteTransaction]);
const deleteTransaction = useCallback(
async (id: string): Promise<boolean> => {
try {
budgetDeleteTransaction(id);
return true;
} catch (error) {
logger.error("트랜잭션 삭제 중 오류:", error);
return false;
}
},
[budgetDeleteTransaction]
);
return {
updateTransaction,
deleteTransaction
deleteTransaction,
};
};

View File

@@ -1,137 +1,157 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { Transaction } from '@/components/TransactionCard';
import {
syncTransactionsWithAppwrite,
updateTransactionInAppwrite,
import { useState, useEffect, useCallback, useRef } from "react";
import { appwriteLogger } from "@/utils/logger";
import { Transaction } from "@/components/TransactionCard";
import {
syncTransactionsWithAppwrite,
updateTransactionInAppwrite,
deleteTransactionFromAppwrite,
debouncedDeleteTransaction
} from '@/utils/appwriteTransactionUtils';
import { toast } from '@/hooks/useToast.wrapper';
import { isSyncEnabled } from '@/utils/syncUtils';
debouncedDeleteTransaction,
} from "@/utils/appwriteTransactionUtils";
import { toast } from "@/hooks/useToast.wrapper";
import { isSyncEnabled } from "@/utils/syncUtils";
/**
* Appwrite 트랜잭션 관리 훅
* 트랜잭션 동기화, 추가, 수정, 삭제 기능 제공
*/
export const useAppwriteTransactions = (user: any, localTransactions: Transaction[]) => {
export const useAppwriteTransactions = (
user: any,
localTransactions: Transaction[]
) => {
// 트랜잭션 상태 관리
const [transactions, setTransactions] = useState<Transaction[]>(localTransactions);
const [transactions, setTransactions] =
useState<Transaction[]>(localTransactions);
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<Error | null>(null);
// 컴포넌트 마운트 상태 추적
const isMountedRef = useRef<boolean>(true);
// 진행 중인 작업 추적
const pendingOperations = useRef<Set<string>>(new Set());
// 트랜잭션 동기화
const syncTransactions = useCallback(async () => {
if (!user || !isSyncEnabled()) return localTransactions;
if (!user || !isSyncEnabled()) {
return localTransactions;
}
try {
setLoading(true);
setError(null);
// UI 스레드 차단 방지
await new Promise(resolve => setTimeout(resolve, 0));
const syncedTransactions = await syncTransactionsWithAppwrite(user, localTransactions);
await new Promise((resolve) => setTimeout(resolve, 0));
const syncedTransactions = await syncTransactionsWithAppwrite(
user,
localTransactions
);
if (isMountedRef.current) {
setTransactions(syncedTransactions);
setLoading(false);
}
return syncedTransactions;
} catch (err) {
console.error('트랜잭션 동기화 오류:', err);
appwriteLogger.error("트랜잭션 동기화 오류:", err);
if (isMountedRef.current) {
setError(err as Error);
setLoading(false);
}
return localTransactions;
}
}, [user, localTransactions]);
// 트랜잭션 추가/수정
const saveTransaction = useCallback(async (transaction: Transaction) => {
if (!user || !isSyncEnabled()) return;
try {
// 작업 추적 시작
pendingOperations.current.add(transaction.id);
// UI 스레드 차단 방지
await new Promise(resolve => requestAnimationFrame(resolve));
await updateTransactionInAppwrite(user, transaction);
if (!isMountedRef.current) return;
// 로컬 상태 업데이트
setTransactions(prev => {
const index = prev.findIndex(t => t.id === transaction.id);
if (index >= 0) {
const updated = [...prev];
updated[index] = transaction;
return updated;
} else {
return [...prev, transaction];
const saveTransaction = useCallback(
async (transaction: Transaction) => {
if (!user || !isSyncEnabled()) {
return;
}
try {
// 작업 추적 시작
pendingOperations.current.add(transaction.id);
// UI 스레드 차단 방지
await new Promise((resolve) => requestAnimationFrame(resolve));
await updateTransactionInAppwrite(user, transaction);
if (!isMountedRef.current) {
return;
}
});
} catch (err) {
console.error('트랜잭션 저장 오류:', err);
if (isMountedRef.current) {
toast({
title: '저장 실패',
description: '트랜잭션을 저장하는 중 오류가 발생했습니다.',
variant: 'destructive'
// 로컬 상태 업데이트
setTransactions((prev) => {
const index = prev.findIndex((t) => t.id === transaction.id);
if (index >= 0) {
const updated = [...prev];
updated[index] = transaction;
return updated;
} else {
return [...prev, transaction];
}
});
} catch (err) {
appwriteLogger.error("트랜잭션 저장 오류:", err);
if (isMountedRef.current) {
toast({
title: "저장 실패",
description: "트랜잭션을 저장하는 중 오류가 발생했습니다.",
variant: "destructive",
});
}
} finally {
// 작업 추적 종료
pendingOperations.current.delete(transaction.id);
}
} finally {
// 작업 추적 종료
pendingOperations.current.delete(transaction.id);
}
}, [user]);
},
[user]
);
// 트랜잭션 삭제
const removeTransaction = useCallback(async (transactionId: string) => {
if (!user || !isSyncEnabled()) return;
try {
// 작업 추적 시작
pendingOperations.current.add(transactionId);
// 로컬 상태 먼저 업데이트 (낙관적 UI 업데이트)
setTransactions(prev => prev.filter(t => t.id !== transactionId));
// 디바운스된 삭제 작업 실행 (여러 번 연속 호출 방지)
await debouncedDeleteTransaction(user, transactionId);
} catch (err) {
console.error('트랜잭션 삭제 오류:', err);
if (isMountedRef.current) {
toast({
title: '삭제 실패',
description: '트랜잭션을 삭제하는 중 오류가 발생했습니다.',
variant: 'destructive'
});
// 실패 시 트랜잭션 복원 (서버에서 가져오기)
syncTransactions();
const removeTransaction = useCallback(
async (transactionId: string) => {
if (!user || !isSyncEnabled()) {
return;
}
} finally {
// 작업 추적 종료
pendingOperations.current.delete(transactionId);
}
}, [user, syncTransactions]);
try {
// 작업 추적 시작
pendingOperations.current.add(transactionId);
// 로컬 상태 먼저 업데이트 (낙관적 UI 업데이트)
setTransactions((prev) => prev.filter((t) => t.id !== transactionId));
// 디바운스된 삭제 작업 실행 (여러 번 연속 호출 방지)
await debouncedDeleteTransaction(user, transactionId);
} catch (err) {
appwriteLogger.error("트랜잭션 삭제 오류:", err);
if (isMountedRef.current) {
toast({
title: "삭제 실패",
description: "트랜잭션을 삭제하는 중 오류가 발생했습니다.",
variant: "destructive",
});
// 실패 시 트랜잭션 복원 (서버에서 가져오기)
syncTransactions();
}
} finally {
// 작업 추적 종료
pendingOperations.current.delete(transactionId);
}
},
[user, syncTransactions]
);
// 초기 동기화
useEffect(() => {
if (user && isSyncEnabled()) {
@@ -140,14 +160,14 @@ export const useAppwriteTransactions = (user: any, localTransactions: Transactio
setTransactions(localTransactions);
}
}, [user, localTransactions, syncTransactions]);
// 컴포넌트 언마운트 시 정리
useEffect(() => {
return () => {
isMountedRef.current = false;
};
}, []);
return {
transactions,
loading,
@@ -155,7 +175,7 @@ export const useAppwriteTransactions = (user: any, localTransactions: Transactio
syncTransactions,
saveTransaction,
removeTransaction,
hasPendingOperations: pendingOperations.current.size > 0
hasPendingOperations: pendingOperations.current.size > 0,
};
};

View File

@@ -1,16 +1,16 @@
import { useState, useRef, useEffect } from "react";
import { useState, useRef, useEffect } from 'react';
import { logger } from "@/utils/logger";
/**
* 트랜잭션 삭제 알림 관련 로직을 담당하는 커스텀 훅
*/
export const useDeleteAlert = (onDelete: () => Promise<boolean> | boolean) => {
const [isOpen, setIsOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
// 타임아웃 참조 저장 (메모리 누수 방지용)
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// 클린업 함수 - 메모리 누수 방지
const clearTimeouts = () => {
if (timeoutRef.current) {
@@ -18,32 +18,34 @@ export const useDeleteAlert = (onDelete: () => Promise<boolean> | boolean) => {
timeoutRef.current = null;
}
};
// 컴포넌트 언마운트 시 모든 타임아웃 제거
useEffect(() => {
return () => {
clearTimeouts();
};
}, []);
const handleDelete = async () => {
// 이미 삭제 중이면 중복 실행 방지
if (isDeleting) return;
if (isDeleting) {
return;
}
try {
// 삭제 상태 활성화
setIsDeleting(true);
// 다이얼로그 즉시 닫기 (UI 응답성 개선)
setIsOpen(false);
// UI 애니메이션 완료 후 삭제 실행
timeoutRef.current = setTimeout(async () => {
try {
// 삭제 함수 실행
await onDelete();
} catch (error) {
console.error('삭제 처리 오류:', error);
logger.error("삭제 처리 오류:", error);
} finally {
// 모든 작업 완료 후 상태 초기화 (약간 지연)
timeoutRef.current = setTimeout(() => {
@@ -52,23 +54,25 @@ export const useDeleteAlert = (onDelete: () => Promise<boolean> | boolean) => {
}
}, 150);
} catch (error) {
console.error('삭제 핸들러 오류:', error);
logger.error("삭제 핸들러 오류:", error);
setIsDeleting(false);
setIsOpen(false);
}
};
// 다이얼로그 상태 관리
const handleOpenChange = (open: boolean) => {
// 삭제 중에는 상태 변경 방지
if (isDeleting && !open) return;
if (isDeleting && !open) {
return;
}
setIsOpen(open);
};
return {
isOpen,
isDeleting,
handleDelete,
handleOpenChange
handleOpenChange,
};
};

View File

@@ -1,6 +1,6 @@
import { useCallback, useRef, useState } from 'react';
import { toast } from '@/hooks/useToast.wrapper';
import { useCallback, useRef, useState } from "react";
import { logger } from "@/utils/logger";
import { toast } from "@/hooks/useToast.wrapper";
/**
* 최근 거래내역 관련 로직을 처리하는 커스텀 훅
@@ -10,7 +10,7 @@ export const useRecentTransactions = (
deleteTransaction: (id: string) => void
) => {
const [isDeleting, setIsDeleting] = useState(false);
// 삭제 중인 ID 추적
const deletingIdRef = useRef<string | null>(null);
@@ -21,101 +21,107 @@ export const useRecentTransactions = (
const lastDeleteTimeRef = useRef<Record<string, number>>({});
// 완전히 새로운 삭제 처리 함수
const handleDeleteTransaction = useCallback(async (id: string): Promise<boolean> => {
return new Promise(resolve => {
try {
// 삭제 진행 중인지 확인
if (isDeleting || deletingIdRef.current === id) {
console.log('이미 삭제 작업이 진행 중입니다');
const handleDeleteTransaction = useCallback(
async (id: string): Promise<boolean> => {
return new Promise((resolve) => {
try {
// 삭제 진행 중인지 확인
if (isDeleting || deletingIdRef.current === id) {
logger.info("이미 삭제 작업이 진행 중입니다");
resolve(true);
return;
}
// 급발진 방지 (300ms)
const now = Date.now();
if (
lastDeleteTimeRef.current[id] &&
now - lastDeleteTimeRef.current[id] < 300
) {
logger.warn("삭제 요청이 너무 빠릅니다. 무시합니다.");
resolve(true);
return;
}
// 타임스탬프 업데이트
lastDeleteTimeRef.current[id] = now;
// 삭제 상태 설정
setIsDeleting(true);
deletingIdRef.current = id;
// 안전장치: 타임아웃 설정 (최대 900ms)
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
logger.warn("삭제 타임아웃 - 상태 초기화");
setIsDeleting(false);
deletingIdRef.current = null;
resolve(true); // UI 응답성 위해 성공 간주
}, 900);
// 비동기 작업을 동기적으로 처리하여 UI 블로킹 방지
setTimeout(() => {
try {
// BudgetContext의 deleteTransaction 함수 호출
deleteTransaction(id);
// 안전장치 타임아웃 제거
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
// 상태 초기화 (지연 적용)
setTimeout(() => {
setIsDeleting(false);
deletingIdRef.current = null;
}, 100);
// 성공 메시지 표시
toast({
title: "항목이 삭제되었습니다",
description: "지출 내역이 성공적으로 삭제되었습니다.",
duration: 1500,
});
} catch (err) {
logger.error("삭제 처리 오류:", err);
// 에러 메시지 표시
toast({
title: "삭제 실패",
description: "항목을 삭제하는 중 오류가 발생했습니다.",
variant: "destructive",
duration: 1500,
});
}
}, 0);
// 즉시 성공 반환 (UI 응답성 향상)
resolve(true);
return;
}
} catch (error) {
logger.error("삭제 처리 전체 오류:", error);
// 급발진 방지 (300ms)
const now = Date.now();
if (lastDeleteTimeRef.current[id] && now - lastDeleteTimeRef.current[id] < 300) {
console.warn('삭제 요청이 너무 빠릅니다. 무시합니다.');
resolve(true);
return;
}
// 타임스탬프 업데이트
lastDeleteTimeRef.current[id] = now;
// 삭제 상태 설정
setIsDeleting(true);
deletingIdRef.current = id;
// 안전장치: 타임아웃 설정 (최대 900ms)
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
console.warn('삭제 타임아웃 - 상태 초기화');
// 항상 상태 정리
setIsDeleting(false);
deletingIdRef.current = null;
resolve(true); // UI 응답성 위해 성공 간주
}, 900);
// 비동기 작업을 동기적으로 처리하여 UI 블로킹 방지
setTimeout(() => {
try {
// BudgetContext의 deleteTransaction 함수 호출
deleteTransaction(id);
// 안전장치 타임아웃 제거
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
// 상태 초기화 (지연 적용)
setTimeout(() => {
setIsDeleting(false);
deletingIdRef.current = null;
}, 100);
// 성공 메시지 표시
toast({
title: "항목이 삭제되었습니다",
description: "지출 내역이 성공적으로 삭제되었습니다.",
duration: 1500
});
} catch (err) {
console.error('삭제 처리 오류:', err);
// 에러 메시지 표시
toast({
title: "삭제 실패",
description: "항목을 삭제하는 중 오류가 발생했습니다.",
variant: "destructive",
duration: 1500
});
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
}, 0);
// 즉시 성공 반환 (UI 응답성 향상)
resolve(true);
} catch (error) {
console.error('삭제 처리 전체 오류:', error);
// 항상 상태 정리
setIsDeleting(false);
deletingIdRef.current = null;
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
toast({
title: "오류 발생",
description: "처리 중 문제가 발생했습니다.",
variant: "destructive",
duration: 1500,
});
resolve(false);
}
toast({
title: "오류 발생",
description: "처리 중 문제가 발생했습니다.",
variant: "destructive",
duration: 1500
});
resolve(false);
}
});
}, [deleteTransaction, isDeleting]);
});
},
[deleteTransaction, isDeleting]
);
// 컴포넌트 언마운트 시 타임아웃 정리 (리액트 컴포넌트에서 처리해야함)
const cleanupTimeouts = useCallback(() => {
@@ -128,6 +134,6 @@ export const useRecentTransactions = (
return {
handleDeleteTransaction,
isDeleting,
cleanupTimeouts
cleanupTimeouts,
};
};

View File

@@ -1,12 +1,12 @@
import { useState } from 'react';
import { Transaction } from '@/contexts/budget/types';
import { useState } from "react";
import { Transaction } from "@/contexts/budget/types";
/**
* 최근 거래내역의 다이얼로그 상태를 관리하는 커스텀 훅
*/
export const useRecentTransactionsDialog = () => {
const [selectedTransaction, setSelectedTransaction] = useState<Transaction | null>(null);
const [selectedTransaction, setSelectedTransaction] =
useState<Transaction | null>(null);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const handleTransactionClick = (transaction: Transaction) => {
@@ -27,6 +27,6 @@ export const useRecentTransactionsDialog = () => {
isDialogOpen,
handleTransactionClick,
handleCloseDialog,
setIsDialogOpen
setIsDialogOpen,
};
};

View File

@@ -1,5 +1,4 @@
import { useTransactionsCore } from './useTransactionsCore';
import { useTransactionsCore } from "./useTransactionsCore";
/**
* 메인 트랜잭션 훅

View File

@@ -1,10 +1,10 @@
import { useCallback } from 'react';
import { useTransactionsState } from './useTransactionsState';
import { useTransactionsFiltering } from './useTransactionsFiltering';
import { useTransactionsLoader } from './useTransactionsLoader';
import { useTransactionsOperations } from './transactionOperations/useTransactionsOperations';
import { useTransactionsEvents } from './useTransactionsEvents';
import { useCallback } from "react";
import { logger } from "@/utils/logger";
import { useTransactionsState } from "./useTransactionsState";
import { useTransactionsFiltering } from "./useTransactionsFiltering";
import { useTransactionsLoader } from "./useTransactionsLoader";
import { useTransactionsOperations } from "./transactionOperations/useTransactionsOperations";
import { useTransactionsEvents } from "./useTransactionsEvents";
/**
* 핵심 트랜잭션 훅 - 성능 및 안정성 최적화 버전
@@ -12,7 +12,7 @@ import { useTransactionsEvents } from './useTransactionsEvents';
*/
export const useTransactionsCore = () => {
// 상태 관리
const {
const {
transactions,
setTransactions,
filteredTransactions,
@@ -28,42 +28,37 @@ export const useTransactionsCore = () => {
totalBudget,
setTotalBudget,
refreshKey,
setRefreshKey
setRefreshKey,
} = useTransactionsState();
// 데이터 로딩
const { loadTransactions } = useTransactionsLoader(
setTransactions,
setTotalBudget,
setIsLoading,
setTransactions,
setTotalBudget,
setIsLoading,
setError
);
// 필터링 - 성능 개선 버전
const {
handlePrevMonth,
handleNextMonth,
getTotalExpenses
} = useTransactionsFiltering({
transactions,
selectedMonth,
setSelectedMonth,
searchQuery,
setFilteredTransactions
});
const { handlePrevMonth, handleNextMonth, getTotalExpenses } =
useTransactionsFiltering({
transactions,
selectedMonth,
setSelectedMonth,
searchQuery,
setFilteredTransactions,
});
// 트랜잭션 작업 - 단순화된 버전
const {
deleteTransaction
} = useTransactionsOperations(transactions);
const { deleteTransaction } = useTransactionsOperations(transactions);
// 이벤트 리스너 - 메모리 누수 방지 버전
useTransactionsEvents(loadTransactions, refreshKey);
// 데이터 강제 새로고침 - 성능 최적화
const refreshTransactions = useCallback(() => {
console.log('[트랜잭션 코어] 강제 새로고침');
setRefreshKey(prev => prev + 1);
logger.info("[트랜잭션 코어] 강제 새로고침");
setRefreshKey((prev) => prev + 1);
loadTransactions();
}, [loadTransactions, setRefreshKey]);
@@ -71,26 +66,26 @@ export const useTransactionsCore = () => {
// 데이터
transactions: filteredTransactions,
allTransactions: transactions,
// 상태
isLoading,
error,
totalBudget,
// 필터링
selectedMonth,
searchQuery,
setSearchQuery,
handlePrevMonth,
handleNextMonth,
// 작업
deleteTransaction,
// 합계
totalExpenses: getTotalExpenses(filteredTransactions),
// 새로고침
refreshTransactions
refreshTransactions,
};
};

View File

@@ -1,6 +1,6 @@
import { useEffect, useRef } from "react";
import { useEffect, useRef } from 'react';
import { logger } from "@/utils/logger";
/**
* 트랜잭션 이벤트 리스너 훅 - 성능 및 메모리 누수 방지 개선 버전
*/
@@ -11,76 +11,86 @@ export const useTransactionsEvents = (
// 바운싱 방지 및 이벤트 제어를 위한 참조
const isProcessingRef = useRef(false);
const timeoutIdsRef = useRef<number[]>([]);
// 타임아웃 클리어 도우미 함수
const clearAllTimeouts = () => {
timeoutIdsRef.current.forEach(id => window.clearTimeout(id));
timeoutIdsRef.current.forEach((id) => window.clearTimeout(id));
timeoutIdsRef.current = [];
};
useEffect(() => {
console.log('[이벤트] 이벤트 리스너 설정');
logger.info("[이벤트] 이벤트 리스너 설정");
// 이벤트 핸들러 - 부하 조절(throttle) 적용
const handleEvent = (name: string, delay: number = 200) => {
const handleEvent = (name: string, delay = 200) => {
return (e?: any) => {
// 이미 처리 중인 경우 건너뜀
if (isProcessingRef.current) return;
console.log(`[이벤트] ${name} 이벤트 감지:`, e?.detail?.type || '');
if (isProcessingRef.current) {
return;
}
logger.info(`[이벤트] ${name} 이벤트 감지:`, e?.detail?.type || "");
isProcessingRef.current = true;
// 딜레이 적용 (이벤트 폭주 방지)
const timeoutId = window.setTimeout(() => {
loadTransactions();
isProcessingRef.current = false;
// 타임아웃 ID 목록에서 제거
timeoutIdsRef.current = timeoutIdsRef.current.filter(id => id !== timeoutId);
timeoutIdsRef.current = timeoutIdsRef.current.filter(
(id) => id !== timeoutId
);
}, delay);
// 타임아웃 ID 기록 (나중에 정리하기 위함)
timeoutIdsRef.current.push(timeoutId);
};
};
// 각 이벤트별 핸들러 생성
const handleTransactionUpdate = handleEvent('트랜잭션 업데이트', 150);
const handleTransactionDelete = handleEvent('트랜잭션 삭제', 200);
const handleTransactionChange = handleEvent('트랜잭션 변경', 150);
const handleTransactionUpdate = handleEvent("트랜잭션 업데이트", 150);
const handleTransactionDelete = handleEvent("트랜잭션 삭제", 200);
const handleTransactionChange = handleEvent("트랜잭션 변경", 150);
const handleStorageEvent = (e: StorageEvent) => {
if (e.key === 'transactions' || e.key === null) {
handleEvent('스토리지', 150)();
if (e.key === "transactions" || e.key === null) {
handleEvent("스토리지", 150)();
}
};
const handleFocus = handleEvent('포커스', 200);
const handleFocus = handleEvent("포커스", 200);
// 이벤트 리스너 등록
window.addEventListener('transactionUpdated', handleTransactionUpdate);
window.addEventListener('transactionDeleted', handleTransactionDelete);
window.addEventListener('transactionChanged', handleTransactionChange as EventListener);
window.addEventListener('storage', handleStorageEvent);
window.addEventListener('focus', handleFocus);
window.addEventListener("transactionUpdated", handleTransactionUpdate);
window.addEventListener("transactionDeleted", handleTransactionDelete);
window.addEventListener(
"transactionChanged",
handleTransactionChange as EventListener
);
window.addEventListener("storage", handleStorageEvent);
window.addEventListener("focus", handleFocus);
// 초기 데이터 로드
if (!isProcessingRef.current) {
loadTransactions();
}
// 클린업 함수
return () => {
console.log('[이벤트] 이벤트 리스너 정리');
logger.info("[이벤트] 이벤트 리스너 정리");
// 모든 이벤트 리스너 제거
window.removeEventListener('transactionUpdated', handleTransactionUpdate);
window.removeEventListener('transactionDeleted', handleTransactionDelete);
window.removeEventListener('transactionChanged', handleTransactionChange as EventListener);
window.removeEventListener('storage', handleStorageEvent);
window.removeEventListener('focus', handleFocus);
window.removeEventListener("transactionUpdated", handleTransactionUpdate);
window.removeEventListener("transactionDeleted", handleTransactionDelete);
window.removeEventListener(
"transactionChanged",
handleTransactionChange as EventListener
);
window.removeEventListener("storage", handleStorageEvent);
window.removeEventListener("focus", handleFocus);
// 모든 진행 중인 타임아웃 정리
clearAllTimeouts();
// 처리 상태 초기화
isProcessingRef.current = false;
};

View File

@@ -1,5 +1,4 @@
import { useTransactionsFiltering } from './filterOperations';
import { useTransactionsFiltering } from "./filterOperations";
// 기존 훅을 그대로 내보내기
export { useTransactionsFiltering };

View File

@@ -1,9 +1,7 @@
import { useCallback } from 'react';
import { toast } from '@/hooks/useToast.wrapper';
import {
loadTransactionsFromStorage
} from './storageUtils';
import { useCallback } from "react";
import { logger } from "@/utils/logger";
import { toast } from "@/hooks/useToast.wrapper";
import { loadTransactionsFromStorage } from "./storageUtils";
/**
* 트랜잭션 로딩 관련 훅
@@ -19,41 +17,45 @@ export const useTransactionsLoader = (
const loadTransactions = useCallback(() => {
setIsLoading(true);
setError(null);
try {
const localTransactions = loadTransactionsFromStorage();
setTransactions(localTransactions);
// 예산 데이터에서 직접 월간 예산 값을 가져옴
try {
const budgetDataStr = localStorage.getItem('budgetData');
const budgetDataStr = localStorage.getItem("budgetData");
if (budgetDataStr) {
const budgetData = JSON.parse(budgetDataStr);
// 월간 예산 값만 사용
if (budgetData && budgetData.monthly && typeof budgetData.monthly.targetAmount === 'number') {
if (
budgetData &&
budgetData.monthly &&
typeof budgetData.monthly.targetAmount === "number"
) {
const monthlyBudget = budgetData.monthly.targetAmount;
setTotalBudget(monthlyBudget);
console.log('월간 예산 설정:', monthlyBudget);
logger.info("월간 예산 설정:", monthlyBudget);
} else {
console.log('유효한 월간 예산 데이터가 없습니다. 기본값 0 사용');
logger.info("유효한 월간 예산 데이터가 없습니다. 기본값 0 사용");
setTotalBudget(0);
}
} else {
console.log('예산 데이터가 없습니다. 기본값 0 사용');
logger.info("예산 데이터가 없습니다. 기본값 0 사용");
setTotalBudget(0);
}
} catch (budgetErr) {
console.error('예산 데이터 파싱 오류:', budgetErr);
logger.error("예산 데이터 파싱 오류:", budgetErr);
setTotalBudget(0);
}
} catch (err) {
console.error('트랜잭션 로드 중 오류:', err);
setError('데이터를 불러오는 중 문제가 발생했습니다.');
logger.error("트랜잭션 로드 중 오류:", err);
setError("데이터를 불러오는 중 문제가 발생했습니다.");
toast({
title: "데이터 로드 실패",
description: "지출 내역을 불러오는데 실패했습니다.",
variant: "destructive",
duration: 4000
duration: 4000,
});
} finally {
// 로딩 상태를 약간 지연시켜 UI 업데이트가 원활하게 이루어지도록 함
@@ -62,6 +64,6 @@ export const useTransactionsLoader = (
}, [setTransactions, setTotalBudget, setIsLoading, setError]);
return {
loadTransactions
loadTransactions,
};
};

View File

@@ -1,18 +1,22 @@
import { useCallback } from 'react';
import { Transaction } from '@/components/TransactionCard';
import { useCallback } from "react";
import { Transaction } from "@/components/TransactionCard";
export const useTransactionsOperations = (
transactions: Transaction[],
setTransactions: React.Dispatch<React.SetStateAction<Transaction[]>>
) => {
const updateTransaction = useCallback((updatedTransaction: Transaction) => {
setTransactions(prev =>
prev.map(t => t.id === updatedTransaction.id ? updatedTransaction : t)
);
}, [setTransactions]);
const updateTransaction = useCallback(
(updatedTransaction: Transaction) => {
setTransactions((prev) =>
prev.map((t) =>
t.id === updatedTransaction.id ? updatedTransaction : t
)
);
},
[setTransactions]
);
return {
updateTransaction
updateTransaction,
};
};

View File

@@ -1,7 +1,6 @@
import { useState } from 'react';
import { Transaction } from '@/components/TransactionCard';
import { getCurrentMonth } from './dateUtils';
import { useState } from "react";
import { Transaction } from "@/components/TransactionCard";
import { getCurrentMonth } from "./dateUtils";
/**
* 트랜잭션 관련 상태 관리 훅
@@ -10,19 +9,21 @@ import { getCurrentMonth } from './dateUtils';
export const useTransactionsState = () => {
// 트랜잭션 상태
const [transactions, setTransactions] = useState<Transaction[]>([]);
const [filteredTransactions, setFilteredTransactions] = useState<Transaction[]>([]);
const [filteredTransactions, setFilteredTransactions] = useState<
Transaction[]
>([]);
// 필터링 상태
const [selectedMonth, setSelectedMonth] = useState(getCurrentMonth());
const [searchQuery, setSearchQuery] = useState('');
const [searchQuery, setSearchQuery] = useState("");
// 로딩 및 에러 상태
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// 예산 상태
const [totalBudget, setTotalBudget] = useState(0);
// 새로고침 키
const [refreshKey, setRefreshKey] = useState(0);
@@ -32,25 +33,25 @@ export const useTransactionsState = () => {
setTransactions,
filteredTransactions,
setFilteredTransactions,
// 필터링 상태
selectedMonth,
setSelectedMonth,
searchQuery,
setSearchQuery,
// 로딩 및 에러 상태
isLoading,
setIsLoading,
error,
setError,
// 예산 상태
totalBudget,
setTotalBudget,
// 새로고침 키
refreshKey,
setRefreshKey
setRefreshKey,
};
};

View File

@@ -1,36 +1,39 @@
import * as React from "react";
import * as React from "react"
const MOBILE_BREAKPOINT = 768
const MOBILE_BREAKPOINT = 768;
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean>(
typeof window !== 'undefined' ? window.innerWidth < MOBILE_BREAKPOINT : false
)
typeof window !== "undefined"
? window.innerWidth < MOBILE_BREAKPOINT
: false
);
React.useEffect(() => {
if (typeof window === 'undefined') return;
if (typeof window === "undefined") {
return;
}
const checkMobile = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
// 모바일 화면인 경우 body에 클래스 추가
if (window.innerWidth < MOBILE_BREAKPOINT) {
document.body.classList.add('is-mobile');
document.body.classList.add("is-mobile");
} else {
document.body.classList.remove('is-mobile');
document.body.classList.remove("is-mobile");
}
}
// 초기 확인
checkMobile()
// 리사이즈 이벤트 리스너 추가
window.addEventListener('resize', checkMobile)
// 클린업 함수
return () => window.removeEventListener('resize', checkMobile)
}, [])
};
return isMobile
// 초기 확인
checkMobile();
// 리사이즈 이벤트 리스너 추가
window.addEventListener("resize", checkMobile);
// 클린업 함수
return () => window.removeEventListener("resize", checkMobile);
}, []);
return isMobile;
}

View File

@@ -1,4 +1,3 @@
// 이 파일은 기존 import 경로 호환성을 위한 리디렉션입니다
export { useToast, toast, TOAST_LIMIT, TOAST_REMOVE_DELAY } from "./toast";
export type { ToasterToast } from "./toast/types";

View File

@@ -1,6 +1,6 @@
import { useEffect } from "react";
import { useEffect } from 'react';
import { logger } from "@/utils/logger";
/**
* 앱이 포커스를 얻었을 때나 가시성이 변경될 때 데이터를 새로고침하는 커스텀 훅
*/
@@ -8,68 +8,70 @@ export const useAppFocusEvents = () => {
useEffect(() => {
const handleFocus = () => {
try {
console.log('창이 포커스를 얻음 - 데이터 새로고침');
logger.info("창이 포커스를 얻음 - 데이터 새로고침");
// 이미 리프레시 중인지 확인하는 플래그
if (sessionStorage.getItem('isRefreshing') === 'true') {
console.log('이미 리프레시 진행 중, 중복 실행 방지');
if (sessionStorage.getItem("isRefreshing") === "true") {
logger.info("이미 리프레시 진행 중, 중복 실행 방지");
return;
}
try {
sessionStorage.setItem('isRefreshing', 'true');
sessionStorage.setItem("isRefreshing", "true");
// 이벤트 발생시켜 데이터 새로고침
window.dispatchEvent(new Event('storage'));
window.dispatchEvent(new Event('transactionUpdated'));
window.dispatchEvent(new Event('budgetDataUpdated'));
window.dispatchEvent(new Event('categoryBudgetsUpdated'));
window.dispatchEvent(new Event("storage"));
window.dispatchEvent(new Event("transactionUpdated"));
window.dispatchEvent(new Event("budgetDataUpdated"));
window.dispatchEvent(new Event("categoryBudgetsUpdated"));
// 리프레시 완료 표시 (300ms 후에 플래그 해제)
setTimeout(() => {
sessionStorage.setItem('isRefreshing', 'false');
sessionStorage.setItem("isRefreshing", "false");
}, 300);
} catch (e) {
console.error('이벤트 발생 오류:', e);
sessionStorage.setItem('isRefreshing', 'false');
logger.error("이벤트 발생 오류:", e);
sessionStorage.setItem("isRefreshing", "false");
}
} catch (error) {
console.error('포커스 이벤트 처리 중 오류:', error);
logger.error("포커스 이벤트 처리 중 오류:", error);
}
};
// 포커스 이벤트
window.addEventListener('focus', handleFocus);
window.addEventListener("focus", handleFocus);
// 가시성 변경 이벤트 (백그라운드에서 전경으로 돌아올 때)
const handleVisibilityChange = () => {
try {
if (document.visibilityState === 'visible') {
console.log('페이지가 다시 보임 - 데이터 새로고침');
if (document.visibilityState === "visible") {
logger.info("페이지가 다시 보임 - 데이터 새로고침");
handleFocus();
}
} catch (error) {
console.error('가시성 이벤트 처리 중 오류:', error);
logger.error("가시성 이벤트 처리 중 오류:", error);
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
document.addEventListener("visibilitychange", handleVisibilityChange);
// 정기적인 데이터 새로고침 (60초마다로 변경 - 너무 빈번한 리프레시 방지)
const refreshInterval = setInterval(() => {
try {
if (document.visibilityState === 'visible' &&
sessionStorage.getItem('isRefreshing') !== 'true') {
console.log('정기 새로고침 - 데이터 업데이트');
if (
document.visibilityState === "visible" &&
sessionStorage.getItem("isRefreshing") !== "true"
) {
logger.info("정기 새로고침 - 데이터 업데이트");
handleFocus();
}
} catch (error) {
console.error('정기 새로고침 처리 중 오류:', error);
logger.error("정기 새로고침 처리 중 오류:", error);
}
}, 60000); // 60초마다
}, 60000); // 60초마다
return () => {
window.removeEventListener('focus', handleFocus);
document.removeEventListener('visibilitychange', handleVisibilityChange);
window.removeEventListener("focus", handleFocus);
document.removeEventListener("visibilitychange", handleVisibilityChange);
clearInterval(refreshInterval);
};
}, []);

View File

@@ -1,138 +1,158 @@
import { useState, useEffect, useCallback } from 'react';
import { resetAllData } from '@/contexts/budget/storage';
import { resetAllStorageData } from '@/utils/storageUtils';
import { clearCloudData } from '@/utils/sync/clearCloudData';
import { useAuth } from '@/contexts/auth';
import { useState, useEffect, useCallback } from "react";
import { logger } from "@/utils/logger";
import { resetAllData } from "@/contexts/budget/storage";
import { resetAllStorageData } from "@/utils/storageUtils";
import { clearCloudData } from "@/utils/sync/clearCloudData";
import { useAuth } from "@/contexts/auth";
export const useDataInitialization = (resetBudgetData?: () => void) => {
const [isInitialized, setIsInitialized] = useState(false);
const { user } = useAuth();
// 모든 데이터 초기화 함수
const initializeAllData = useCallback(async () => {
try {
// 중요: 이미 방문한 적이 있으면 절대 초기화하지 않음
const hasVisitedBefore = localStorage.getItem('hasVisitedBefore') === 'true';
const hasVisitedBefore =
localStorage.getItem("hasVisitedBefore") === "true";
if (hasVisitedBefore) {
console.log('이미 앱을 방문한 적이 있으므로 데이터를 초기화하지 않습니다.');
logger.info(
"이미 앱을 방문한 적이 있으므로 데이터를 초기화하지 않습니다."
);
setIsInitialized(true);
return true;
}
console.log('첫 방문: 모든 데이터 초기화 시작');
logger.info("첫 방문: 모든 데이터 초기화 시작");
// 현재 dontShowWelcome 값 백업
const dontShowWelcomeValue = localStorage.getItem('dontShowWelcome');
console.log('useDataInitialization - 초기화 전 dontShowWelcome 값:', dontShowWelcomeValue);
const dontShowWelcomeValue = localStorage.getItem("dontShowWelcome");
logger.info(
"useDataInitialization - 초기화 전 dontShowWelcome 값:",
dontShowWelcomeValue
);
try {
// 로그인 상태라면 클라우드 데이터도 초기화 (첫 방문 시)
if (user) {
console.log('로그인 상태: 클라우드 데이터도 초기화 시도');
logger.info("로그인 상태: 클라우드 데이터도 초기화 시도");
await clearCloudData(user.id);
}
// 모든 데이터 완전히 삭제 및 초기화 (한 번만 실행)
resetAllData();
resetAllStorageData();
// 컨텍스트 데이터 리셋 (필요한 경우)
if (resetBudgetData) {
resetBudgetData();
}
// 초기화 후 dontShowWelcome 값 확인
const afterResetValue = localStorage.getItem('dontShowWelcome');
console.log('useDataInitialization - 초기화 후 dontShowWelcome 값:', afterResetValue);
const afterResetValue = localStorage.getItem("dontShowWelcome");
logger.info(
"useDataInitialization - 초기화 후 dontShowWelcome 값:",
afterResetValue
);
// 값이 유지되지 않았다면 복원
if (dontShowWelcomeValue && afterResetValue !== dontShowWelcomeValue) {
console.log('useDataInitialization - dontShowWelcome 값 복원:', dontShowWelcomeValue);
localStorage.setItem('dontShowWelcome', dontShowWelcomeValue);
logger.info(
"useDataInitialization - dontShowWelcome 값 복원:",
dontShowWelcomeValue
);
localStorage.setItem("dontShowWelcome", dontShowWelcomeValue);
}
console.log('모든 데이터 초기화 완료');
logger.info("모든 데이터 초기화 완료");
return true;
} catch (error) {
console.error('데이터 초기화 중 오류 발생:', error);
logger.error("데이터 초기화 중 오류 발생:", error);
return false;
}
} catch (error) {
console.error('initializeAllData 함수 실행 중 오류:', error);
logger.error("initializeAllData 함수 실행 중 오류:", error);
setIsInitialized(true); // 오류가 발생해도 앱을 사용할 수 있도록 초기화 완료로 설정
return false;
}
}, [resetBudgetData, user]);
// 분석 페이지 데이터 초기화 함수
const clearAllAnalyticsData = useCallback(() => {
try {
// 분석 관련 데이터만 선택적으로 삭제 (전체 데이터는 건드리지 않음)
const analyticsKeys = [
'analytics', 'monthlyTotals', 'chartData',
'expenseHistory', 'budgetHistory', 'categorySpending',
'monthlyData', 'expenseData', 'analyticData'
"analytics",
"monthlyTotals",
"chartData",
"expenseHistory",
"budgetHistory",
"categorySpending",
"monthlyData",
"expenseData",
"analyticData",
];
analyticsKeys.forEach(key => {
analyticsKeys.forEach((key) => {
localStorage.removeItem(key);
});
// 월간, 차트, 분석 관련 키워드가 포함된 항목만 삭제
for (let i = localStorage.length - 1; i >= 0; i--) {
const key = localStorage.key(i);
if (key && (
key.includes('month') ||
key.includes('chart') ||
key.includes('analytics') ||
key.includes('expense') ||
key.includes('budget') ||
key.includes('total')
) &&
// 핵심 데이터는 건드리지 않도록 제외
!key.includes('budgetData') &&
!key.includes('transactions') &&
!key.includes('categoryBudgets')) {
console.log(`분석 데이터 삭제: ${key}`);
if (
key &&
(key.includes("month") ||
key.includes("chart") ||
key.includes("analytics") ||
key.includes("expense") ||
key.includes("budget") ||
key.includes("total")) &&
// 핵심 데이터는 건드리지 않도록 제외
!key.includes("budgetData") &&
!key.includes("transactions") &&
!key.includes("categoryBudgets")
) {
logger.info(`분석 데이터 삭제: ${key}`);
localStorage.removeItem(key);
}
}
return true;
} catch (error) {
console.error('분석 데이터 초기화 중 오류:', error);
logger.error("분석 데이터 초기화 중 오류:", error);
return false;
}
}, []);
// 데이터 초기화 실행 - 첫 방문시에만
useEffect(() => {
try {
if (!isInitialized) {
// 이미 방문한 적이 있는지 체크 (이미 있다면 초기화하지 않음)
const hasVisitedBefore = localStorage.getItem('hasVisitedBefore') === 'true';
const hasVisitedBefore =
localStorage.getItem("hasVisitedBefore") === "true";
if (hasVisitedBefore) {
console.log('이미 방문 기록이 있어 초기화를 건너뜁니다.');
logger.info("이미 방문 기록이 있어 초기화를 건너뜁니다.");
setIsInitialized(true);
} else {
initializeAllData().then(result => {
initializeAllData().then((result) => {
setIsInitialized(result);
});
}
}
// 첫 방문 여부 체크용 키 설정 (항상 true로 설정)
localStorage.setItem('hasVisitedBefore', 'true');
localStorage.setItem("hasVisitedBefore", "true");
} catch (error) {
console.error('데이터 초기화 useEffect 내 오류:', error);
logger.error("데이터 초기화 useEffect 내 오류:", error);
setIsInitialized(true); // 오류 발생해도 앱을 사용할 수 있도록 초기화 완료로 설정
}
}, [isInitialized, initializeAllData]);
return {
isInitialized,
initializeAllData,
clearAllAnalyticsData
clearAllAnalyticsData,
};
};

View File

@@ -1,11 +1,11 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useToast } from '@/hooks/useToast.wrapper';
import { resetAllStorageData } from '@/utils/storageUtils';
import { clearCloudData } from '@/utils/sync/clearCloudData';
import { useAuth } from '@/contexts/auth';
import { isSyncEnabled, setSyncEnabled } from '@/utils/sync/syncSettings';
import { useState } from "react";
import { logger } from "@/utils/logger";
import { useNavigate } from "react-router-dom";
import { useToast } from "@/hooks/useToast.wrapper";
import { resetAllStorageData } from "@/utils/storageUtils";
import { clearCloudData } from "@/utils/sync/clearCloudData";
import { useAuth } from "@/contexts/auth";
import { isSyncEnabled, setSyncEnabled } from "@/utils/sync/syncSettings";
export interface DataResetResult {
isCloudResetSuccess: boolean | null;
@@ -13,7 +13,9 @@ export interface DataResetResult {
export const useDataReset = () => {
const [isResetting, setIsResetting] = useState(false);
const [isCloudResetSuccess, setIsCloudResetSuccess] = useState<boolean | null>(null);
const [isCloudResetSuccess, setIsCloudResetSuccess] = useState<
boolean | null
>(null);
const { toast } = useToast();
const navigate = useNavigate();
const { user } = useAuth();
@@ -21,122 +23,131 @@ export const useDataReset = () => {
const resetAllData = async (): Promise<DataResetResult> => {
try {
setIsResetting(true);
console.log('모든 데이터 초기화 시작');
logger.info("모든 데이터 초기화 시작");
// 현재 동기화 설정 저장
const syncWasEnabled = isSyncEnabled();
console.log('데이터 초기화 전 동기화 상태:', syncWasEnabled ? '활성화' : '비활성화');
logger.info(
"데이터 초기화 전 동기화 상태:",
syncWasEnabled ? "활성화" : "비활성화"
);
// 중요: 클라우드 데이터 초기화 먼저 시도 (로그인 상태인 경우)
let cloudResetSuccess = false;
if (user) {
console.log('로그인 상태: 클라우드 데이터 초기화 시도');
logger.info("로그인 상태: 클라우드 데이터 초기화 시도");
// 여러 번 시도하여 클라우드 데이터 초기화 확실히 하기
for (let attempt = 1; attempt <= 3; attempt++) {
console.log(`클라우드 데이터 초기화 시도 ${attempt}/3`);
logger.info(`클라우드 데이터 초기화 시도 ${attempt}/3`);
cloudResetSuccess = await clearCloudData(user.id);
if (cloudResetSuccess) {
console.log('클라우드 데이터 초기화 성공');
logger.info("클라우드 데이터 초기화 성공");
break;
} else {
console.warn(`클라우드 데이터 초기화 시도 ${attempt} 실패`);
logger.warn(`클라우드 데이터 초기화 시도 ${attempt} 실패`);
// 잠시 대기 후 재시도
if (attempt < 3) {
await new Promise(resolve => setTimeout(resolve, 500));
await new Promise((resolve) => setTimeout(resolve, 500));
}
}
}
setIsCloudResetSuccess(cloudResetSuccess);
} else {
console.log('로그인하지 않음: 클라우드 초기화 건너뜀');
logger.info("로그인하지 않음: 클라우드 초기화 건너뜀");
setIsCloudResetSuccess(null);
}
// 초기화 실행 전에 사용자 설정 백업
const dontShowWelcomeValue = localStorage.getItem('dontShowWelcome');
const hasVisitedBefore = localStorage.getItem('hasVisitedBefore');
const dontShowWelcomeValue = localStorage.getItem("dontShowWelcome");
const hasVisitedBefore = localStorage.getItem("hasVisitedBefore");
// 로그인 관련 설정 백업 (supabase 관련 모든 설정)
const authBackupItems: Record<string, string | null> = {};
// 로그인 관련 항목 수집
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && (
key.includes('supabase') ||
key.includes('auth') ||
key.includes('sb-') ||
key.includes('token') ||
key.includes('user') ||
key.includes('session')
)) {
if (
key &&
(key.includes("supabase") ||
key.includes("auth") ||
key.includes("sb-") ||
key.includes("token") ||
key.includes("user") ||
key.includes("session"))
) {
authBackupItems[key] = localStorage.getItem(key);
console.log(`백업 항목: ${key}`);
logger.info(`백업 항목: ${key}`);
}
}
// 데이터 초기화 (개선된 메소드 사용)
resetAllStorageData();
// 추가 초기화를 위해 빈 데이터 명시적 설정
localStorage.setItem('transactions', JSON.stringify([]));
localStorage.setItem('budgetData', JSON.stringify({
daily: {targetAmount: 0, spentAmount: 0, remainingAmount: 0},
weekly: {targetAmount: 0, spentAmount: 0, remainingAmount: 0},
monthly: {targetAmount: 0, spentAmount: 0, remainingAmount: 0}
}));
localStorage.setItem('categoryBudgets', JSON.stringify({}));
localStorage.setItem("transactions", JSON.stringify([]));
localStorage.setItem(
"budgetData",
JSON.stringify({
daily: { targetAmount: 0, spentAmount: 0, remainingAmount: 0 },
weekly: { targetAmount: 0, spentAmount: 0, remainingAmount: 0 },
monthly: { targetAmount: 0, spentAmount: 0, remainingAmount: 0 },
})
);
localStorage.setItem("categoryBudgets", JSON.stringify({}));
// 사용자 설정 복원
if (dontShowWelcomeValue) {
localStorage.setItem('dontShowWelcome', dontShowWelcomeValue);
localStorage.setItem("dontShowWelcome", dontShowWelcomeValue);
}
if (hasVisitedBefore) {
localStorage.setItem('hasVisitedBefore', hasVisitedBefore);
localStorage.setItem("hasVisitedBefore", hasVisitedBefore);
}
// 로그인 관련 설정 복원 (로그인 화면이 나타나지 않도록)
Object.entries(authBackupItems).forEach(([key, value]) => {
if (value) {
localStorage.setItem(key, value);
console.log(`복원 항목: ${key}`);
logger.info(`복원 항목: ${key}`);
}
});
// 중요: 동기화 설정은 초기화 후 강제로 비활성화
setSyncEnabled(false);
console.log('동기화 설정이 비활성화되었습니다.');
logger.info("동기화 설정이 비활성화되었습니다.");
// 마지막 동기화 시간은 초기화
localStorage.removeItem('lastSync');
localStorage.removeItem("lastSync");
// 삭제 플래그 초기화 (강제로 삭제 목록 초기화)
localStorage.removeItem('deletedTransactions');
localStorage.removeItem('modifiedBudgets');
localStorage.removeItem("deletedTransactions");
localStorage.removeItem("modifiedBudgets");
// 스토리지 이벤트 트리거하여 다른 컴포넌트에 변경 알림
window.dispatchEvent(new Event('transactionUpdated'));
window.dispatchEvent(new Event('budgetDataUpdated'));
window.dispatchEvent(new Event('categoryBudgetsUpdated'));
window.dispatchEvent(new StorageEvent('storage'));
window.dispatchEvent(new Event('auth-state-changed')); // 동기화 상태 변경 이벤트 추가
window.dispatchEvent(new Event("transactionUpdated"));
window.dispatchEvent(new Event("budgetDataUpdated"));
window.dispatchEvent(new Event("categoryBudgetsUpdated"));
window.dispatchEvent(new StorageEvent("storage"));
window.dispatchEvent(new Event("auth-state-changed")); // 동기화 상태 변경 이벤트 추가
// 클라우드 초기화 상태에 따라 다른 메시지 표시
if (user) {
if (cloudResetSuccess) {
toast({
title: "모든 데이터가 초기화되었습니다.",
description: "로컬 및 클라우드의 모든 데이터가 초기화되었으며, 동기화 설정이 비활성화되었습니다.",
description:
"로컬 및 클라우드의 모든 데이터가 초기화되었으며, 동기화 설정이 비활성화되었습니다.",
});
} else {
toast({
title: "로컬 데이터만 초기화됨",
description: "로컬 데이터는 초기화되었지만, 클라우드 데이터 초기화 중 문제가 발생했습니다. 동기화 설정이 비활성화되었습니다.",
variant: "destructive"
description:
"로컬 데이터는 초기화되었지만, 클라우드 데이터 초기화 중 문제가 발생했습니다. 동기화 설정이 비활성화되었습니다.",
variant: "destructive",
});
}
} else {
@@ -145,17 +156,17 @@ export const useDataReset = () => {
description: "모든 예산, 지출 내역, 설정이 초기화되었습니다.",
});
}
console.log('모든 데이터 초기화 완료');
logger.info("모든 데이터 초기화 완료");
// 페이지 리프레시를 위해 잠시 후에 새로고침
setTimeout(() => {
window.location.reload();
}, 500);
return { isCloudResetSuccess: cloudResetSuccess };
} catch (error) {
console.error('데이터 초기화 실패:', error);
logger.error("데이터 초기화 실패:", error);
toast({
title: "데이터 초기화 실패",
description: "데이터를 초기화하는 중 문제가 발생했습니다.",
@@ -170,6 +181,6 @@ export const useDataReset = () => {
return {
isResetting,
isCloudResetSuccess,
resetAllData
resetAllData,
};
};

View File

@@ -1,57 +1,62 @@
import { useEffect } from "react";
import { useEffect } from 'react';
import { logger } from "@/utils/logger";
/**
* 앱 첫 실행 시 로컬스토리지 데이터를 로드하는 커스텀 훅
*/
export const useInitialDataLoading = () => {
useEffect(() => {
try {
console.log('Index 페이지 마운트, 데이터 확인 중...');
logger.info("Index 페이지 마운트, 데이터 확인 중...");
// 페이지 첫 마운트 시에만 실행되는 로직
const isFirstMount = sessionStorage.getItem('initialDataLoaded') !== 'true';
const isFirstMount =
sessionStorage.getItem("initialDataLoaded") !== "true";
if (isFirstMount) {
try {
// 백업된 데이터 복구 확인 (메인 데이터가 없는 경우만)
if (!localStorage.getItem('budgetData')) {
const budgetBackup = localStorage.getItem('budgetData_backup');
if (!localStorage.getItem("budgetData")) {
const budgetBackup = localStorage.getItem("budgetData_backup");
if (budgetBackup) {
console.log('예산 데이터 백업에서 복구');
localStorage.setItem('budgetData', budgetBackup);
logger.info("예산 데이터 백업에서 복구");
localStorage.setItem("budgetData", budgetBackup);
}
}
if (!localStorage.getItem('categoryBudgets')) {
const categoryBackup = localStorage.getItem('categoryBudgets_backup');
if (!localStorage.getItem("categoryBudgets")) {
const categoryBackup = localStorage.getItem(
"categoryBudgets_backup"
);
if (categoryBackup) {
console.log('카테고리 예산 백업에서 복구');
localStorage.setItem('categoryBudgets', categoryBackup);
logger.info("카테고리 예산 백업에서 복구");
localStorage.setItem("categoryBudgets", categoryBackup);
}
}
if (!localStorage.getItem('transactions')) {
const transactionBackup = localStorage.getItem('transactions_backup');
if (!localStorage.getItem("transactions")) {
const transactionBackup = localStorage.getItem(
"transactions_backup"
);
if (transactionBackup) {
console.log('트랜잭션 백업에서 복구');
localStorage.setItem('transactions', transactionBackup);
logger.info("트랜잭션 백업에서 복구");
localStorage.setItem("transactions", transactionBackup);
}
}
// 한 번만 이벤트 발생
window.dispatchEvent(new Event('transactionUpdated'));
window.dispatchEvent(new Event('budgetDataUpdated'));
window.dispatchEvent(new Event('categoryBudgetsUpdated'));
window.dispatchEvent(new Event("transactionUpdated"));
window.dispatchEvent(new Event("budgetDataUpdated"));
window.dispatchEvent(new Event("categoryBudgetsUpdated"));
// 초기 로드 완료 표시
sessionStorage.setItem('initialDataLoaded', 'true');
sessionStorage.setItem("initialDataLoaded", "true");
} catch (error) {
console.error('백업 복구 시도 중 오류:', error);
logger.error("백업 복구 시도 중 오류:", error);
}
}
} catch (error) {
console.error('Index 페이지 초기화 중 오류:', error);
logger.error("Index 페이지 초기화 중 오류:", error);
}
}, []); // 컴포넌트 마운트 시 한 번만 실행
}, []); // 컴포넌트 마운트 시 한 번만 실행
};

View File

@@ -1,5 +1,5 @@
import { useState } from "react";
import { logger } from "@/utils/logger";
import { useNavigate } from "react-router-dom";
import { useToast } from "@/hooks/useToast.wrapper";
import { useAuth } from "@/contexts/auth";
@@ -11,7 +11,7 @@ export function useLogin() {
const [showPassword, setShowPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [loginError, setLoginError] = useState<string | null>(null);
const navigate = useNavigate();
const { toast } = useToast();
const { signIn } = useAuth();
@@ -20,75 +20,77 @@ export function useLogin() {
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setLoginError(null);
if (!email || !password) {
toast({
title: "입력 오류",
description: "이메일과 비밀번호를 모두 입력해주세요.",
variant: "destructive"
variant: "destructive",
});
return;
}
setIsLoading(true);
try {
const { error, user } = await signIn(email, password);
if (error) {
console.error("로그인 실패:", error);
logger.error("로그인 실패:", error);
let errorMessage = "로그인에 실패했습니다.";
if (error.message) {
if (error.message.includes("Invalid login credentials")) {
errorMessage = "이메일 또는 비밀번호가 올바르지 않습니다.";
} else if (error.message.includes("Email not confirmed")) {
errorMessage = "이메일 인증이 완료되지 않았습니다. 이메일을 확인해주세요.";
errorMessage =
"이메일 인증이 완료되지 않았습니다. 이메일을 확인해주세요.";
} else {
errorMessage = `오류: ${error.message}`;
}
}
setLoginError(errorMessage);
toast({
title: "로그인 실패",
description: errorMessage,
variant: "destructive"
variant: "destructive",
});
} else if (user) {
// 로그인 성공
toast({
title: "로그인 성공",
description: "환영합니다! 대시보드로 이동합니다.",
variant: "default"
variant: "default",
});
await setupTables();
navigate("/");
} else {
// user가 없지만 error도 없는 경우 (드문 경우)
console.warn("로그인 성공했지만 사용자 정보가 없습니다.");
logger.warn("로그인 성공했지만 사용자 정보가 없습니다.");
toast({
title: "로그인 상태 확인 중",
description: "로그인은 성공했지만 사용자 정보를 확인하지 못했습니다. 페이지를 새로고침해보세요.",
variant: "default"
description:
"로그인은 성공했지만 사용자 정보를 확인하지 못했습니다. 페이지를 새로고침해보세요.",
variant: "default",
});
navigate("/");
}
} catch (err: any) {
console.error("로그인 과정에서 예외 발생:", err);
logger.error("로그인 과정에서 예외 발생:", err);
const errorMessage = err.message || "알 수 없는 오류가 발생했습니다.";
setLoginError(errorMessage);
toast({
title: "로그인 오류",
description: errorMessage,
variant: "destructive"
variant: "destructive",
});
} finally {
setIsLoading(false);
@@ -106,6 +108,6 @@ export function useLogin() {
isSettingUpTables,
loginError,
setLoginError,
handleLogin
handleLogin,
};
}

View File

@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react';
import { v4 as uuidv4 } from 'uuid';
import { Notification } from '@/components/notification/NotificationPopover';
import { useState, useEffect } from "react";
import { logger } from "@/utils/logger";
import { v4 as uuidv4 } from "uuid";
import { Notification } from "@/components/notification/NotificationPopover";
export const useNotifications = () => {
const [notifications, setNotifications] = useState<Notification[]>([]);
@@ -9,18 +9,20 @@ export const useNotifications = () => {
// 로컬 스토리지에서 알림 불러오기
useEffect(() => {
try {
const savedNotifications = localStorage.getItem('notifications');
const savedNotifications = localStorage.getItem("notifications");
if (savedNotifications) {
const parsedNotifications = JSON.parse(savedNotifications);
// 시간 문자열을 Date 객체로 변환
const formattedNotifications = parsedNotifications.map((notification: any) => ({
...notification,
timestamp: new Date(notification.timestamp)
}));
const formattedNotifications = parsedNotifications.map(
(notification: any) => ({
...notification,
timestamp: new Date(notification.timestamp),
})
);
setNotifications(formattedNotifications);
}
} catch (error) {
console.error('알림 데이터 로드 중 오류 발생:', error);
logger.error("알림 데이터 로드 중 오류 발생:", error);
}
}, []);
@@ -31,25 +33,31 @@ export const useNotifications = () => {
title,
message,
timestamp: new Date(),
read: false
read: false,
};
setNotifications(prevNotifications => {
setNotifications((prevNotifications) => {
const updatedNotifications = [newNotification, ...prevNotifications];
// 로컬 스토리지 업데이트
localStorage.setItem('notifications', JSON.stringify(updatedNotifications));
localStorage.setItem(
"notifications",
JSON.stringify(updatedNotifications)
);
return updatedNotifications;
});
};
// 알림 읽음 표시
const markAsRead = (id: string) => {
setNotifications(prevNotifications => {
const updatedNotifications = prevNotifications.map(notification =>
setNotifications((prevNotifications) => {
const updatedNotifications = prevNotifications.map((notification) =>
notification.id === id ? { ...notification, read: true } : notification
);
// 로컬 스토리지 업데이트
localStorage.setItem('notifications', JSON.stringify(updatedNotifications));
localStorage.setItem(
"notifications",
JSON.stringify(updatedNotifications)
);
return updatedNotifications;
});
};
@@ -57,14 +65,14 @@ export const useNotifications = () => {
// 모든 알림 삭제
const clearAllNotifications = () => {
setNotifications([]);
localStorage.removeItem('notifications');
localStorage.removeItem("notifications");
};
return {
notifications,
addNotification,
markAsRead,
clearAllNotifications
clearAllNotifications,
};
};

View File

@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react';
import { useAuth } from '@/contexts/auth';
import { useSyncToggle, useManualSync, useSyncStatus } from './sync';
import { useState, useEffect } from "react";
import { syncLogger } from "@/utils/logger";
import { useAuth } from "@/contexts/auth";
import { useSyncToggle, useManualSync, useSyncStatus } from "./sync";
/**
* 동기화 설정 관리를 위한 커스텀 훅
@@ -11,14 +11,14 @@ export const useSyncSettings = () => {
const { enabled, setEnabled, handleSyncToggle } = useSyncToggle();
const { syncing, handleManualSync } = useManualSync(user);
const { lastSync, formatLastSyncTime } = useSyncStatus();
// 콘솔에 상태 기록
useEffect(() => {
console.log(`[동기화설정] 상태 변경:
- 활성화: ${enabled ? '예' : '아니오'}
- 진행중: ${syncing ? '예' : '아니오'}
- 마지막동기화: ${lastSync || '없음'}
- 사용자: ${user ? '로그인됨' : '로그인안됨'}`);
syncLogger.info(`[동기화설정] 상태 변경:
- 활성화: ${enabled ? "예" : "아니오"}
- 진행중: ${syncing ? "예" : "아니오"}
- 마지막동기화: ${lastSync || "없음"}
- 사용자: ${user ? "로그인됨" : "로그인안됨"}`);
}, [enabled, syncing, lastSync, user]);
return {

View File

@@ -1,5 +1,5 @@
import { useState } from "react";
import { logger } from "@/utils/logger";
import { useToast } from "@/hooks/useToast.wrapper";
import { createRequiredTables } from "@/archive/lib/supabase/setup";
@@ -17,23 +17,24 @@ export function useTableSetup() {
try {
setIsSettingUpTables(true);
const { success, message } = await createRequiredTables();
if (success) {
console.log("테이블 설정 성공:", message);
logger.info("테이블 설정 성공:", message);
return true;
} else {
console.warn("테이블 설정 문제:", message);
logger.warn("테이블 설정 문제:", message);
// 사용자에게 경고 표시 (선택적)
toast({
title: "테이블 설정 문제",
description: "일부 테이블 설정에 문제가 있었지만, 기본 기능은 사용할 수 있습니다.",
variant: "default"
description:
"일부 테이블 설정에 문제가 있었지만, 기본 기능은 사용할 수 있습니다.",
variant: "default",
});
// 테이블 설정 실패해도 로그인은 진행
return false;
}
} catch (setupError) {
console.error("테이블 설정 중 오류:", setupError);
logger.error("테이블 설정 중 오류:", setupError);
return false;
} finally {
setIsSettingUpTables(false);
@@ -42,6 +43,6 @@ export function useTableSetup() {
return {
isSettingUpTables,
setupTables
setupTables,
};
}

View File

@@ -1,25 +1,28 @@
import { useToast as useOriginalToast, toast as originalToast } from '@/hooks/toast';
import type { ToasterToast } from '@/hooks/toast/types';
import {
useToast as useOriginalToast,
toast as originalToast,
} from "@/hooks/toast";
import { logger } from "@/utils/logger";
import type { ToasterToast } from "@/hooks/toast/types";
/**
* 토스트 중복 방지를 위한 설정값
*/
const TOAST_CONFIG = {
DEFAULT_DURATION: 3000, // 기본 토스트 표시 시간 (ms)
DEBOUNCE_TIME: 1500, // 동일 메시지 무시 시간 (ms)
HISTORY_LIMIT: 10, // 히스토리에 저장할 최대 토스트 수
CLEANUP_INTERVAL: 30000, // 히스토리 정리 주기 (ms)
HISTORY_RETENTION: 10000 // 히스토리 보관 기간 (ms)
DEFAULT_DURATION: 3000, // 기본 토스트 표시 시간 (ms)
DEBOUNCE_TIME: 1500, // 동일 메시지 무시 시간 (ms)
HISTORY_LIMIT: 10, // 히스토리에 저장할 최대 토스트 수
CLEANUP_INTERVAL: 30000, // 히스토리 정리 주기 (ms)
HISTORY_RETENTION: 10000, // 히스토리 보관 기간 (ms)
};
/**
* 토스트 메시지 히스토리 인터페이스
*/
interface ToastHistoryItem {
message: string; // 메시지 내용 (title + description)
timestamp: number; // 생성 시간
variant?: string; // 토스트 종류 (default/destructive)
message: string; // 메시지 내용 (title + description)
timestamp: number; // 생성 시간
variant?: string; // 토스트 종류 (default/destructive)
}
/**
@@ -32,7 +35,7 @@ class ToastHistoryManager {
constructor() {
// 주기적으로 오래된 히스토리 정리
this.cleanupInterval = setInterval(
() => this.cleanup(),
() => this.cleanup(),
TOAST_CONFIG.CLEANUP_INTERVAL
);
}
@@ -44,7 +47,7 @@ class ToastHistoryManager {
this.history.push({
message,
timestamp: Date.now(),
variant
variant,
});
// 히스토리 크기 제한
@@ -59,7 +62,7 @@ class ToastHistoryManager {
cleanup(): void {
const now = Date.now();
this.history = this.history.filter(
item => (now - item.timestamp) < TOAST_CONFIG.HISTORY_RETENTION
(item) => now - item.timestamp < TOAST_CONFIG.HISTORY_RETENTION
);
}
@@ -68,11 +71,12 @@ class ToastHistoryManager {
*/
isDuplicate(message: string, variant?: string): boolean {
const now = Date.now();
return this.history.some(item =>
item.message === message &&
item.variant === variant &&
(now - item.timestamp) < TOAST_CONFIG.DEBOUNCE_TIME
return this.history.some(
(item) =>
item.message === message &&
item.variant === variant &&
now - item.timestamp < TOAST_CONFIG.DEBOUNCE_TIME
);
}
@@ -82,7 +86,7 @@ class ToastHistoryManager {
clear(): void {
this.history = [];
}
/**
* 정리 타이머 해제 (메모리 누수 방지)
*/
@@ -98,10 +102,9 @@ const toastHistory = new ToastHistoryManager();
* 메시지 내용 추출 (title + description)
*/
const extractMessage = (params: Omit<ToasterToast, "id">): string => {
return [
params.title?.toString() || '',
params.description?.toString() || ''
].filter(Boolean).join(' - ');
return [params.title?.toString() || "", params.description?.toString() || ""]
.filter(Boolean)
.join(" - ");
};
/**
@@ -109,22 +112,22 @@ const extractMessage = (params: Omit<ToasterToast, "id">): string => {
*/
const debouncedToast = (params: Omit<ToasterToast, "id">) => {
const message = extractMessage(params);
// 빈 메시지 무시
if (!message.trim()) {
console.warn('빈 토스트 메시지가 무시되었습니다');
logger.warn("빈 토스트 메시지가 무시되었습니다");
return;
}
// 중복 검사
if (toastHistory.isDuplicate(message, params.variant)) {
console.log('중복 토스트 감지로 무시됨:', message);
logger.info("중복 토스트 감지로 무시됨:", message);
return;
}
// 히스토리에 추가
toastHistory.add(message, params.variant);
// 실제 토스트 표시
originalToast({
...params,

View File

@@ -1,12 +1,11 @@
// 이 파일은 이제 단순히 새로운 구조의 파일들을 재내보내기만 합니다
export {
export {
useTransactions,
MONTHS_KR,
getCurrentMonth,
getPrevMonth,
getPrevMonth,
getNextMonth,
filterTransactionsByMonth,
filterTransactionsByQuery,
calculateTotalExpenses
} from './transactions';
calculateTotalExpenses,
} from "./transactions";

View File

@@ -1,60 +1,69 @@
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect, useCallback } from 'react';
import { logger } from "@/utils/logger";
export const useWelcomeDialog = () => {
const [showWelcome, setShowWelcome] = useState(false);
// 환영 다이얼로그 표시 여부 확인
const checkWelcomeDialogState = useCallback(() => {
// 현재 세션에서 이미 환영 메시지를 닫았는지 확인
const sessionClosed = sessionStorage.getItem('welcomeClosedThisSession') === 'true';
const sessionClosed =
sessionStorage.getItem("welcomeClosedThisSession") === "true";
if (sessionClosed) {
console.log('useWelcomeDialog - 이번 세션에서 이미 환영 메시지를 닫았습니다');
logger.info(
"useWelcomeDialog - 이번 세션에서 이미 환영 메시지를 닫았습니다"
);
setShowWelcome(false);
return;
}
const dontShowWelcome = localStorage.getItem('dontShowWelcome');
console.log('useWelcomeDialog - dontShowWelcome 값:', dontShowWelcome);
const dontShowWelcome = localStorage.getItem("dontShowWelcome");
logger.info("useWelcomeDialog - dontShowWelcome 값:", dontShowWelcome);
// 명시적으로 'true' 문자열인 경우에만 숨김 처리
if (dontShowWelcome === 'true') {
console.log('useWelcomeDialog - 환영 메시지 표시하지 않음 (저장된 설정)');
if (dontShowWelcome === "true") {
logger.info("useWelcomeDialog - 환영 메시지 표시하지 않음 (저장된 설정)");
setShowWelcome(false);
} else {
console.log('useWelcomeDialog - 환영 메시지 표시함');
logger.info("useWelcomeDialog - 환영 메시지 표시함");
setShowWelcome(true);
}
}, []);
// 환영 다이얼로그 닫기 핸들러
const handleCloseWelcome = useCallback((dontShowAgain: boolean) => {
setShowWelcome(false);
// 이번 세션에서 닫았음을 기록
sessionStorage.setItem('welcomeClosedThisSession', 'true');
sessionStorage.setItem("welcomeClosedThisSession", "true");
// 사용자가 더 이상 보지 않기를 선택한 경우
if (dontShowAgain) {
localStorage.setItem('dontShowWelcome', 'true');
sessionStorage.setItem('dontShowWelcome', 'true');
console.log('useWelcomeDialog - 환영 팝업 더 이상 표시하지 않기 설정됨:', dontShowAgain);
localStorage.setItem("dontShowWelcome", "true");
sessionStorage.setItem("dontShowWelcome", "true");
logger.info(
"useWelcomeDialog - 환영 팝업 더 이상 표시하지 않기 설정됨:",
dontShowAgain
);
// 설정 확인
const savedValue = localStorage.getItem('dontShowWelcome');
console.log('useWelcomeDialog - 설정 후 dontShowWelcome 저장값:', savedValue);
const savedValue = localStorage.getItem("dontShowWelcome");
logger.info(
"useWelcomeDialog - 설정 후 dontShowWelcome 저장값:",
savedValue
);
} else {
// 체크하지 않은 경우 명시적으로 false 저장
localStorage.setItem('dontShowWelcome', 'false');
sessionStorage.setItem('dontShowWelcome', 'false');
localStorage.setItem("dontShowWelcome", "false");
sessionStorage.setItem("dontShowWelcome", "false");
}
}, []);
return {
showWelcome,
setShowWelcome,
checkWelcomeDialogState,
handleCloseWelcome
handleCloseWelcome,
};
};

View File

@@ -1,7 +1,7 @@
import { useEffect } from 'react';
import { useAuth } from '@/contexts/auth';
import useNotifications from '@/hooks/useNotifications';
import { useEffect } from "react";
import { logger } from "@/utils/logger";
import { useAuth } from "@/contexts/auth";
import useNotifications from "@/hooks/useNotifications";
/**
* 앱 초기화 후 환영 메시지 알림을 표시하는 커스텀 훅
@@ -13,23 +13,25 @@ export const useWelcomeNotification = (isInitialized: boolean) => {
useEffect(() => {
try {
// 환영 메시지가 이미 표시되었는지 확인하는 키
const welcomeNotificationSent = sessionStorage.getItem('welcomeNotificationSent');
const welcomeNotificationSent = sessionStorage.getItem(
"welcomeNotificationSent"
);
if (isInitialized && user && !welcomeNotificationSent) {
// 사용자 로그인 시 알림 예시 (한 번만 실행)
const timeoutId = setTimeout(() => {
addNotification(
'환영합니다!',
'젤리의 적자탈출에 오신 것을 환영합니다. 예산을 설정하고 지출을 기록해보세요.'
"환영합니다!",
"젤리의 적자탈출에 오신 것을 환영합니다. 예산을 설정하고 지출을 기록해보세요."
);
// 세션 스토리지에 환영 메시지 표시 여부 저장
sessionStorage.setItem('welcomeNotificationSent', 'true');
sessionStorage.setItem("welcomeNotificationSent", "true");
}, 2000);
return () => clearTimeout(timeoutId);
}
} catch (error) {
console.error('환영 메시지 알림 표시 중 오류:', error);
logger.error("환영 메시지 알림 표시 중 오류:", error);
}
}, [isInitialized, user, addNotification]);
};