Implement personalized data handling
Implement personalized data handling based on the number of recent expense records.
This commit is contained in:
@@ -1,13 +1,13 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { Form, FormField, FormItem, FormLabel } from '@/components/ui/form';
|
import { Form, FormField, FormItem, FormLabel } from '@/components/ui/form';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
import ExpenseCategorySelector from './ExpenseCategorySelector';
|
import ExpenseCategorySelector from './ExpenseCategorySelector';
|
||||||
import { CATEGORY_TITLE_SUGGESTIONS } from '@/constants/categoryIcons';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { getPersonalizedTitleSuggestions } from '@/utils/userTitlePreferences';
|
||||||
|
|
||||||
export interface ExpenseFormValues {
|
export interface ExpenseFormValues {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -33,8 +33,16 @@ const ExpenseForm: React.FC<ExpenseFormProps> = ({ onSubmit, onCancel, isSubmitt
|
|||||||
// 현재 선택된 카테고리 가져오기
|
// 현재 선택된 카테고리 가져오기
|
||||||
const selectedCategory = form.watch('category');
|
const selectedCategory = form.watch('category');
|
||||||
|
|
||||||
// 선택된 카테고리에 대한 제목 제안 목록 가져오기
|
// 선택된 카테고리에 대한 개인화된 제목 제안 목록 상태
|
||||||
const titleSuggestions = selectedCategory ? CATEGORY_TITLE_SUGGESTIONS[selectedCategory] : [];
|
const [titleSuggestions, setTitleSuggestions] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// 카테고리가 변경될 때마다 개인화된 제목 목록 업데이트
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedCategory) {
|
||||||
|
const suggestions = getPersonalizedTitleSuggestions(selectedCategory);
|
||||||
|
setTitleSuggestions(suggestions);
|
||||||
|
}
|
||||||
|
}, [selectedCategory]);
|
||||||
|
|
||||||
// 제안된 제목 클릭 시 제목 필드에 설정
|
// 제안된 제목 클릭 시 제목 필드에 설정
|
||||||
const handleTitleSuggestionClick = (suggestion: string) => {
|
const handleTitleSuggestionClick = (suggestion: string) => {
|
||||||
|
|||||||
@@ -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 { FormField, FormItem, FormLabel, FormControl, FormMessage } from '@/components/ui/form';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { UseFormReturn } from 'react-hook-form';
|
import { UseFormReturn } from 'react-hook-form';
|
||||||
import { z } from 'zod';
|
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 { Badge } from '@/components/ui/badge';
|
||||||
|
import { getPersonalizedTitleSuggestions } from '@/utils/userTitlePreferences';
|
||||||
|
|
||||||
// Form schema for validation - 카테고리를 4개로 확장
|
// Form schema for validation - 카테고리를 4개로 확장
|
||||||
export const transactionFormSchema = z.object({
|
export const transactionFormSchema = z.object({
|
||||||
@@ -35,8 +36,16 @@ const TransactionFormFields: React.FC<TransactionFormFieldsProps> = ({ form }) =
|
|||||||
// 현재 선택된 카테고리 가져오기
|
// 현재 선택된 카테고리 가져오기
|
||||||
const selectedCategory = form.watch('category');
|
const selectedCategory = form.watch('category');
|
||||||
|
|
||||||
// 선택된 카테고리에 대한 제목 제안 목록 가져오기
|
// 선택된 카테고리에 대한 개인화된 제목 제안 목록 상태
|
||||||
const titleSuggestions = selectedCategory ? CATEGORY_TITLE_SUGGESTIONS[selectedCategory] : [];
|
const [titleSuggestions, setTitleSuggestions] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// 카테고리가 변경될 때마다 개인화된 제목 목록 업데이트
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedCategory) {
|
||||||
|
const suggestions = getPersonalizedTitleSuggestions(selectedCategory);
|
||||||
|
setTitleSuggestions(suggestions);
|
||||||
|
}
|
||||||
|
}, [selectedCategory]);
|
||||||
|
|
||||||
// 제안된 제목 클릭 시 제목 필드에 설정
|
// 제안된 제목 클릭 시 제목 필드에 설정
|
||||||
const handleTitleSuggestionClick = (suggestion: string) => {
|
const handleTitleSuggestionClick = (suggestion: string) => {
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ import {
|
|||||||
} from '../storage';
|
} from '../storage';
|
||||||
import { toast } from '@/hooks/useToast.wrapper'; // 래퍼 사용
|
import { toast } from '@/hooks/useToast.wrapper'; // 래퍼 사용
|
||||||
import { addToDeletedTransactions } from '@/utils/sync/transaction/deletedTransactionsTracker';
|
import { addToDeletedTransactions } from '@/utils/sync/transaction/deletedTransactionsTracker';
|
||||||
|
import {
|
||||||
|
updateTitleUsage,
|
||||||
|
analyzeTransactionTitles
|
||||||
|
} from '@/utils/userTitlePreferences';
|
||||||
|
|
||||||
// 트랜잭션 상태 관리 훅
|
// 트랜잭션 상태 관리 훅
|
||||||
export const useTransactionState = () => {
|
export const useTransactionState = () => {
|
||||||
@@ -31,6 +35,9 @@ export const useTransactionState = () => {
|
|||||||
// 상태 업데이트를 마이크로태스크로 지연
|
// 상태 업데이트를 마이크로태스크로 지연
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
setTransactions(storedTransactions);
|
setTransactions(storedTransactions);
|
||||||
|
|
||||||
|
// 사용자 제목 선호도 분석 실행 (최근 50개 트랜잭션)
|
||||||
|
analyzeTransactionTitles(storedTransactions, 50);
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[트랜잭션 상태] 트랜잭션 로드 오류:', error);
|
console.error('[트랜잭션 상태] 트랜잭션 로드 오류:', error);
|
||||||
@@ -70,6 +77,9 @@ export const useTransactionState = () => {
|
|||||||
localTimestamp: new Date().toISOString()
|
localTimestamp: new Date().toISOString()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 사용자 제목 선호도 업데이트
|
||||||
|
updateTitleUsage(transactionWithTimestamp);
|
||||||
|
|
||||||
setTransactions(prev => {
|
setTransactions(prev => {
|
||||||
const updated = [transactionWithTimestamp, ...prev];
|
const updated = [transactionWithTimestamp, ...prev];
|
||||||
saveTransactionsToStorage(updated);
|
saveTransactionsToStorage(updated);
|
||||||
@@ -87,6 +97,9 @@ export const useTransactionState = () => {
|
|||||||
localTimestamp: new Date().toISOString()
|
localTimestamp: new Date().toISOString()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 사용자 제목 선호도 업데이트
|
||||||
|
updateTitleUsage(transactionWithTimestamp);
|
||||||
|
|
||||||
setTransactions(prev => {
|
setTransactions(prev => {
|
||||||
const updated = prev.map(transaction =>
|
const updated = prev.map(transaction =>
|
||||||
transaction.id === updatedTransaction.id ? transactionWithTimestamp : transaction
|
transaction.id === updatedTransaction.id ? transactionWithTimestamp : transaction
|
||||||
|
|||||||
186
src/utils/userTitlePreferences.ts
Normal file
186
src/utils/userTitlePreferences.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user