From 60ef7653805c0d5ea0f95d3ee526f31ad1872b4a Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sat, 22 Mar 2025 06:57:50 +0000 Subject: [PATCH] Implement personalized data handling Implement personalized data handling based on the number of recent expense records. --- src/components/expenses/ExpenseForm.tsx | 16 +- .../transaction/TransactionFormFields.tsx | 17 +- .../budget/hooks/useTransactionState.ts | 13 ++ src/utils/userTitlePreferences.ts | 186 ++++++++++++++++++ 4 files changed, 224 insertions(+), 8 deletions(-) create mode 100644 src/utils/userTitlePreferences.ts diff --git a/src/components/expenses/ExpenseForm.tsx b/src/components/expenses/ExpenseForm.tsx index e20029e..17608f1 100644 --- a/src/components/expenses/ExpenseForm.tsx +++ b/src/components/expenses/ExpenseForm.tsx @@ -1,13 +1,13 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { useForm } from 'react-hook-form'; import { Form, FormField, FormItem, FormLabel } from '@/components/ui/form'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; import { Loader2 } from 'lucide-react'; import ExpenseCategorySelector from './ExpenseCategorySelector'; -import { CATEGORY_TITLE_SUGGESTIONS } from '@/constants/categoryIcons'; import { Badge } from '@/components/ui/badge'; +import { getPersonalizedTitleSuggestions } from '@/utils/userTitlePreferences'; export interface ExpenseFormValues { title: string; @@ -33,8 +33,16 @@ const ExpenseForm: React.FC = ({ onSubmit, onCancel, isSubmitt // 현재 선택된 카테고리 가져오기 const selectedCategory = form.watch('category'); - // 선택된 카테고리에 대한 제목 제안 목록 가져오기 - const titleSuggestions = selectedCategory ? CATEGORY_TITLE_SUGGESTIONS[selectedCategory] : []; + // 선택된 카테고리에 대한 개인화된 제목 제안 목록 상태 + const [titleSuggestions, setTitleSuggestions] = useState([]); + + // 카테고리가 변경될 때마다 개인화된 제목 목록 업데이트 + useEffect(() => { + if (selectedCategory) { + const suggestions = getPersonalizedTitleSuggestions(selectedCategory); + setTitleSuggestions(suggestions); + } + }, [selectedCategory]); // 제안된 제목 클릭 시 제목 필드에 설정 const handleTitleSuggestionClick = (suggestion: string) => { diff --git a/src/components/transaction/TransactionFormFields.tsx b/src/components/transaction/TransactionFormFields.tsx index c9b35e5..b7952f0 100644 --- a/src/components/transaction/TransactionFormFields.tsx +++ b/src/components/transaction/TransactionFormFields.tsx @@ -1,11 +1,12 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { FormField, FormItem, FormLabel, FormControl, FormMessage } from '@/components/ui/form'; import { Input } from '@/components/ui/input'; import { UseFormReturn } from 'react-hook-form'; import { z } from 'zod'; -import { categoryIcons, EXPENSE_CATEGORIES, CATEGORY_TITLE_SUGGESTIONS } from '@/constants/categoryIcons'; +import { categoryIcons, EXPENSE_CATEGORIES } from '@/constants/categoryIcons'; import { Badge } from '@/components/ui/badge'; +import { getPersonalizedTitleSuggestions } from '@/utils/userTitlePreferences'; // Form schema for validation - 카테고리를 4개로 확장 export const transactionFormSchema = z.object({ @@ -35,8 +36,16 @@ const TransactionFormFields: React.FC = ({ form }) = // 현재 선택된 카테고리 가져오기 const selectedCategory = form.watch('category'); - // 선택된 카테고리에 대한 제목 제안 목록 가져오기 - const titleSuggestions = selectedCategory ? CATEGORY_TITLE_SUGGESTIONS[selectedCategory] : []; + // 선택된 카테고리에 대한 개인화된 제목 제안 목록 상태 + const [titleSuggestions, setTitleSuggestions] = useState([]); + + // 카테고리가 변경될 때마다 개인화된 제목 목록 업데이트 + useEffect(() => { + if (selectedCategory) { + const suggestions = getPersonalizedTitleSuggestions(selectedCategory); + setTitleSuggestions(suggestions); + } + }, [selectedCategory]); // 제안된 제목 클릭 시 제목 필드에 설정 const handleTitleSuggestionClick = (suggestion: string) => { diff --git a/src/contexts/budget/hooks/useTransactionState.ts b/src/contexts/budget/hooks/useTransactionState.ts index 73b2070..6da37df 100644 --- a/src/contexts/budget/hooks/useTransactionState.ts +++ b/src/contexts/budget/hooks/useTransactionState.ts @@ -8,6 +8,10 @@ import { } from '../storage'; import { toast } from '@/hooks/useToast.wrapper'; // 래퍼 사용 import { addToDeletedTransactions } from '@/utils/sync/transaction/deletedTransactionsTracker'; +import { + updateTitleUsage, + analyzeTransactionTitles +} from '@/utils/userTitlePreferences'; // 트랜잭션 상태 관리 훅 export const useTransactionState = () => { @@ -31,6 +35,9 @@ export const useTransactionState = () => { // 상태 업데이트를 마이크로태스크로 지연 queueMicrotask(() => { setTransactions(storedTransactions); + + // 사용자 제목 선호도 분석 실행 (최근 50개 트랜잭션) + analyzeTransactionTitles(storedTransactions, 50); }); } catch (error) { console.error('[트랜잭션 상태] 트랜잭션 로드 오류:', error); @@ -70,6 +77,9 @@ export const useTransactionState = () => { localTimestamp: new Date().toISOString() }; + // 사용자 제목 선호도 업데이트 + updateTitleUsage(transactionWithTimestamp); + setTransactions(prev => { const updated = [transactionWithTimestamp, ...prev]; saveTransactionsToStorage(updated); @@ -87,6 +97,9 @@ export const useTransactionState = () => { localTimestamp: new Date().toISOString() }; + // 사용자 제목 선호도 업데이트 + updateTitleUsage(transactionWithTimestamp); + setTransactions(prev => { const updated = prev.map(transaction => transaction.id === updatedTransaction.id ? transactionWithTimestamp : transaction diff --git a/src/utils/userTitlePreferences.ts b/src/utils/userTitlePreferences.ts new file mode 100644 index 0000000..f617a0e --- /dev/null +++ b/src/utils/userTitlePreferences.ts @@ -0,0 +1,186 @@ + +import { Transaction } from '@/components/TransactionCard'; +import { CATEGORY_TITLE_SUGGESTIONS } from '@/constants/categoryIcons'; + +// 지출 제목 사용 빈도를 저장하는 로컬 스토리지 키 +const TITLE_PREFERENCES_KEY = 'userTitlePreferences'; + +// 기본 분석 대상 트랜잭션 수 +const DEFAULT_ANALYSIS_COUNT = 50; + +// 사용자 제목 선호도 타입 정의 +export interface TitlePreference { + count: number; // 사용 횟수 + lastUsed: string; // 마지막 사용 일시 (ISO 문자열) +} + +// 카테고리별 제목 선호도 +export interface CategoryTitlePreferences { + [title: string]: TitlePreference; +} + +// 전체 제목 선호도 데이터 구조 +export interface UserTitlePreferences { + 음식: CategoryTitlePreferences; + 쇼핑: CategoryTitlePreferences; + 교통: CategoryTitlePreferences; + 기타: CategoryTitlePreferences; + [key: string]: CategoryTitlePreferences; +} + +/** + * 로컬 스토리지에서 사용자 제목 선호도 데이터 로드 + */ +export const loadUserTitlePreferences = (): UserTitlePreferences => { + try { + const storedPreferences = localStorage.getItem(TITLE_PREFERENCES_KEY); + if (storedPreferences) { + return JSON.parse(storedPreferences); + } + } catch (error) { + console.error('제목 선호도 데이터 로드 중 오류:', error); + } + + // 기본값 반환 - 기본 카테고리 구조 생성 + return { + 음식: {}, + 쇼핑: {}, + 교통: {}, + 기타: {} + }; +}; + +/** + * 사용자 제목 선호도 데이터 저장 + */ +export const saveUserTitlePreferences = (preferences: UserTitlePreferences): void => { + try { + localStorage.setItem(TITLE_PREFERENCES_KEY, JSON.stringify(preferences)); + } catch (error) { + console.error('제목 선호도 데이터 저장 중 오류:', error); + } +}; + +/** + * 트랜잭션에서 제목 사용 업데이트 + * 새로운 트랜잭션이 추가되거나 수정될 때 호출 + */ +export const updateTitleUsage = (transaction: Transaction): void => { + // 타입이 expense가 아니거나 제목이 없으면 무시 + if (transaction.type !== 'expense' || !transaction.title) return; + + const { category, title } = transaction; + const preferences = loadUserTitlePreferences(); + + // 해당 카테고리가 없으면 초기화 + if (!preferences[category]) { + preferences[category] = {}; + } + + // 해당 제목이 없으면 초기화 + if (!preferences[category][title]) { + preferences[category][title] = { + count: 0, + lastUsed: new Date().toISOString() + }; + } + + // 카운트 증가 및 마지막 사용 시간 업데이트 + preferences[category][title].count += 1; + preferences[category][title].lastUsed = new Date().toISOString(); + + // 저장 + saveUserTitlePreferences(preferences); +}; + +/** + * 트랜잭션 목록에서 제목 사용 빈도 분석하여 업데이트 + * 앱 초기화 시 또는 주기적으로 호출하여 최신 데이터 반영 + */ +export const analyzeTransactionTitles = ( + transactions: Transaction[], + analysisCount: number = DEFAULT_ANALYSIS_COUNT +): void => { + // 지출 항목만 필터링하고 최신 순으로 정렬 + const recentTransactions = transactions + .filter(tx => tx.type === 'expense') + .sort((a, b) => { + const dateA = a.localTimestamp ? new Date(a.localTimestamp).getTime() : 0; + const dateB = b.localTimestamp ? new Date(b.localTimestamp).getTime() : 0; + return dateB - dateA; + }) + .slice(0, analysisCount); // 최신 N개만 분석 + + // 선호도 데이터 초기화 (기존 데이터 유지를 원하면 이 부분 제거) + let preferences: UserTitlePreferences = { + 음식: {}, + 쇼핑: {}, + 교통: {}, + 기타: {} + }; + + // 트랜잭션 분석 + recentTransactions.forEach(tx => { + if (!tx.category || !tx.title) return; + + if (!preferences[tx.category]) { + preferences[tx.category] = {}; + } + + if (!preferences[tx.category][tx.title]) { + preferences[tx.category][tx.title] = { + count: 0, + lastUsed: tx.localTimestamp || new Date().toISOString() + }; + } + + preferences[tx.category][tx.title].count += 1; + + // 가장 최근 사용일자 업데이트 + if (tx.localTimestamp) { + preferences[tx.category][tx.title].lastUsed = tx.localTimestamp; + } + }); + + // 저장 + saveUserTitlePreferences(preferences); +}; + +/** + * 카테고리별 추천 제목 목록 가져오기 + * 사용자 선호도 + 기본 추천 제목 결합 + */ +export const getPersonalizedTitleSuggestions = (category: string): string[] => { + // 기본 제목 목록 + const defaultSuggestions = CATEGORY_TITLE_SUGGESTIONS[category] || []; + + try { + const preferences = loadUserTitlePreferences(); + const categoryPreferences = preferences[category] || {}; + + // 사용 횟수 기준으로 정렬된 사용자 정의 제목 목록 + const personalizedTitles = Object.entries(categoryPreferences) + .sort((a, b) => { + // 우선 사용 횟수로 정렬 (내림차순) + const countDiff = b[1].count - a[1].count; + if (countDiff !== 0) return countDiff; + + // 사용 횟수가 같으면 최근 사용일자로 정렬 (내림차순) + const dateA = new Date(a[1].lastUsed).getTime(); + const dateB = new Date(b[1].lastUsed).getTime(); + return dateB - dateA; + }) + .map(([title]) => title); + + // 사용자 선호 제목에는 없지만 기본 제목에 있는 항목 추가 + const remainingDefaultTitles = defaultSuggestions.filter( + title => !personalizedTitles.includes(title) + ); + + // 최종 개인화된 제목 목록 (선호도 순 + 기본 제목) + return [...personalizedTitles, ...remainingDefaultTitles]; + } catch (error) { + console.error('개인화된 제목 목록 생성 중 오류:', error); + return defaultSuggestions; + } +};