diff --git a/src/components/common/CommandPalette/CommandPalette.css b/src/components/common/CommandPalette/CommandPalette.css new file mode 100644 index 0000000..a761484 --- /dev/null +++ b/src/components/common/CommandPalette/CommandPalette.css @@ -0,0 +1,204 @@ +/* CommandPalette.css */ +.command-palette-overlay { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background-color: rgba(0, 0, 0, 0.4); + backdrop-filter: blur(4px); + z-index: 9999; + display: flex; + align-items: flex-start; + justify-content: center; + padding-top: 20vh; + opacity: 0; + animation: fadeIn 0.2s ease-out forwards; +} + +.command-palette-modal { + width: 100%; + max-width: 600px; + background: var(--glass-panel-bg); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border-radius: var(--radius-xl); + border: 1px solid var(--glass-border); + box-shadow: var(--shadow-2xl); + overflow: hidden; + display: flex; + flex-direction: column; + transform: translateY(-20px) scale(0.95); + animation: slideIn 0.2s ease-out forwards; +} + +.command-palette-header { + padding: var(--space-4); + border-bottom: 1px solid var(--glass-border); + display: flex; + align-items: center; + gap: var(--space-3); +} + +.command-palette-search-icon { + color: var(--text-secondary); + flex-shrink: 0; +} + +.command-palette-input { + flex: 1; + background: transparent; + border: none; + font-size: var(--text-lg); + color: var(--text-primary); + outline: none; + font-family: var(--font-sans); +} + +.command-palette-input::placeholder { + color: var(--text-muted); +} + +.command-palette-results { + max-height: 400px; + overflow-y: auto; + padding: var(--space-2); +} + +/* Scrollbar styling */ +.command-palette-results::-webkit-scrollbar { + width: 6px; +} + +.command-palette-results::-webkit-scrollbar-track { + background: transparent; +} + +.command-palette-results::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.1); + border-radius: 3px; +} + +.command-palette-group-title { + padding: var(--space-2) var(--space-3); + font-size: var(--text-xs); + font-weight: var(--font-medium); + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-top: var(--space-2); +} + +.command-palette-item { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-3); + border-radius: var(--radius-md); + cursor: pointer; + transition: all 0.1s ease; + color: var(--text-primary); + text-decoration: none; +} + +.command-palette-item:hover, +.command-palette-item.active { + background-color: var(--primary-light); + /* Or a subtle background */ + background: rgba(var(--color-primary-rgb), 0.1); + /* Fallback or specific opacity */ +} + +/* Ensure we use defined variables */ +.command-palette-item:hover, +.command-palette-item.active { + background-color: var(--bg-hover); +} + +.command-palette-item.active { + background-color: var(--primary-lighter); + color: var(--color-primary); +} + + +.command-palette-item-icon { + display: flex; + align-items: center; + justify-content: center; + color: var(--text-secondary); +} + +.command-palette-item.active .command-palette-item-icon { + color: var(--color-primary); +} + +.command-palette-item-content { + flex: 1; + min-width: 0; +} + +.command-palette-item-title { + font-weight: var(--font-medium); + font-size: var(--text-sm); + display: block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.command-palette-item-subtitle { + font-size: var(--text-xs); + color: var(--text-muted); + display: block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.command-palette-item-action { + font-size: var(--text-xs); + color: var(--text-muted); + background: var(--bg-secondary); + padding: 2px 6px; + border-radius: 4px; +} + +.command-palette-footer { + padding: var(--space-2) var(--space-4); + background: rgba(0, 0, 0, 0.02); + border-top: 1px solid var(--glass-border); + display: flex; + justify-content: flex-end; + gap: var(--space-4); + font-size: var(--text-xs); + color: var(--text-muted); +} + +.command-palette-key { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 3px; + padding: 0 4px; + font-family: var(--font-mono); + box-shadow: 0 1px 0 var(--border-color); +} + +@keyframes fadeIn { + to { + opacity: 1; + } +} + +@keyframes slideIn { + to { + transform: translateY(0) scale(1); + } +} + +/* Empty state */ +.command-palette-empty { + padding: var(--space-8); + text-align: center; + color: var(--text-muted); + font-size: var(--text-sm); +} \ No newline at end of file diff --git a/src/components/common/CommandPalette/CommandPalette.tsx b/src/components/common/CommandPalette/CommandPalette.tsx new file mode 100644 index 0000000..2e5bb98 --- /dev/null +++ b/src/components/common/CommandPalette/CommandPalette.tsx @@ -0,0 +1,278 @@ +/** + * CommandPalette Component + * Global search and command execution using Ctrl+K + */ + +import React, { useState, useEffect, useRef } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useTheme } from '../../../hooks'; +import { Icon } from '@iconify/react'; +import { useKey } from 'react-use'; +import { getTransactions } from '../../../services/transactionService'; +import type { Transaction } from '../../../types'; +import { formatCurrency } from '../../../utils/format'; +import './CommandPalette.css'; + +interface CommandItem { + id: string; + title: string; + subtitle?: string; + icon: string; + group: 'navigation' | 'actions' | 'transactions'; + action: () => void; +} + +export const CommandPalette: React.FC = () => { + const [isOpen, setIsOpen] = useState(false); + const [query, setQuery] = useState(''); + const [activeIndex, setActiveIndex] = useState(0); + const [transactionResults, setTransactionResults] = useState([]); + const inputRef = useRef(null); + const resultsRef = useRef(null); + + const navigate = useNavigate(); + const { toggleTheme, isDark } = useTheme(); + + // Toggle open with Ctrl+K or Cmd+K + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key === 'k') { + e.preventDefault(); + setIsOpen((prev) => !prev); + } + if (e.key === 'Escape' && isOpen) { + setIsOpen(false); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [isOpen]); + + // Focus input when opened + useEffect(() => { + if (isOpen) { + setQuery(''); + setTransactionResults([]); + setActiveIndex(0); + // Small timeout to ensure render + setTimeout(() => inputRef.current?.focus(), 50); + } + }, [isOpen]); + + // Static items + const staticItems: CommandItem[] = [ + { + id: 'nav-home', + title: '首页', + subtitle: 'Dashboard & Briefing', + icon: 'solar:home-smile-bold-duotone', + group: 'navigation', + action: () => navigate('/'), + }, + { + id: 'nav-transactions', + title: '交易明细', + subtitle: 'View all transactions', + icon: 'solar:bill-list-bold-duotone', + group: 'navigation', + action: () => navigate('/transactions'), + }, + { + id: 'nav-budget', + title: '预算 & 存钱罐', + subtitle: 'Manage budgets', + icon: 'solar:pie-chart-2-bold-duotone', + group: 'navigation', + action: () => navigate('/budget'), + }, + { + id: 'nav-reports', + title: '报表分析', + subtitle: 'Financial insights', + icon: 'solar:chart-square-bold-duotone', + group: 'navigation', + action: () => navigate('/reports'), + }, + { + id: 'nav-settings', + title: '设置', + subtitle: 'App preferences', + icon: 'solar:settings-bold-duotone', + group: 'navigation', + action: () => navigate('/settings'), + }, + { + id: 'act-theme', + title: isDark ? '切换到亮色模式' : '切换到暗色模式', + subtitle: 'Toggle global theme', + icon: isDark ? 'solar:sun-bold-duotone' : 'solar:moon-bold-duotone', + group: 'actions', + action: toggleTheme, + }, + { + id: 'act-create', + title: '记一笔', + subtitle: 'Create new transaction', + icon: 'solar:add-circle-bold-duotone', + group: 'actions', + action: () => navigate('/transactions'), // Ideally trigger modal + }, + ]; + + // Fetch transactions on query change + useEffect(() => { + if (!query || query.length < 2) { + setTransactionResults([]); + return; + } + + const timer = setTimeout(async () => { + try { + const res = await getTransactions({ search: query, pageSize: 5 }); + setTransactionResults(res.items); + } catch (err) { + console.error('Search transactions failed', err); + } + }, 300); + + return () => clearTimeout(timer); + }, [query]); + + // Combined items + const filteredStatic = staticItems.filter((item) => + item.title.toLowerCase().includes(query.toLowerCase()) || + item.subtitle?.toLowerCase().includes(query.toLowerCase()) + ); + + const transactionItems: CommandItem[] = transactionResults.map((t) => ({ + id: `tx-${t.id}`, + title: t.note || '无备注交易', + subtitle: `${formatCurrency(t.amount, t.currency)} • ${t.transactionDate.split('T')[0]}`, + icon: t.type === 'expense' ? 'solar:minus-circle-bold-duotone' : 'solar:add-circle-bold-duotone', + group: 'transactions', + action: () => navigate('/transactions'), // Ideally scroll to transaction? + })); + + const allItems = [...filteredStatic, ...transactionItems]; + + // Key navigation + useKey( + (e) => isOpen && (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter'), + (e) => { + if (allItems.length === 0) return; + + if (e.key === 'ArrowDown') { + e.preventDefault(); + setActiveIndex((prev) => (prev + 1) % allItems.length); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setActiveIndex((prev) => (prev - 1 + allItems.length) % allItems.length); + } else if (e.key === 'Enter') { + e.preventDefault(); + const item = allItems[activeIndex]; + if (item) { + item.action(); + setIsOpen(false); + } + } + }, + { event: 'keydown' }, + [isOpen, allItems, activeIndex] + ); + + // Keep active item in view + useEffect(() => { + if (resultsRef.current) { + const activeEl = resultsRef.current.querySelector('.active'); + if (activeEl) { + activeEl.scrollIntoView({ block: 'nearest' }); + } + } + }, [activeIndex]); + + + if (!isOpen) return null; + + return ( +
setIsOpen(false)}> +
e.stopPropagation()} + > +
+ + { + setQuery(e.target.value); + setActiveIndex(0); + }} + /> +
ESC
+
+ +
+ {allItems.length === 0 ? ( +
No results found
+ ) : ( + <> + {Object.entries( + allItems.reduce((acc, item) => { + acc[item.group] = [...(acc[item.group] || []), item]; + return acc; + }, {} as Record) + ).map(([group, items]) => ( +
+
{group}
+ {items.map((item) => { + const index = allItems.indexOf(item); + return ( +
{ + item.action(); + setIsOpen(false); + }} + onMouseEnter={() => setActiveIndex(index)} + > +
+ +
+
+ {item.title} + {item.subtitle} +
+ {index === activeIndex && ( +
+ Enter +
+ )} +
+ ); + })} +
+ ))} + + )} +
+ +
+ Search transactions with 2+ characters +
+ + + to navigate + + to select +
+
+
+
+ ); +}; diff --git a/src/components/common/CommandPalette/index.ts b/src/components/common/CommandPalette/index.ts new file mode 100644 index 0000000..95c1305 --- /dev/null +++ b/src/components/common/CommandPalette/index.ts @@ -0,0 +1 @@ +export { CommandPalette } from './CommandPalette'; diff --git a/src/components/common/Layout/Layout.tsx b/src/components/common/Layout/Layout.tsx index 46712d9..2f93210 100644 --- a/src/components/common/Layout/Layout.tsx +++ b/src/components/common/Layout/Layout.tsx @@ -3,6 +3,7 @@ import { Outlet, useNavigate } from 'react-router-dom'; import { useTheme } from '../../../hooks'; import { Icon } from '@iconify/react'; import Navigation from '../Navigation'; +import { CommandPalette } from '../CommandPalette'; import authService from '../../../services/authService'; import type { User } from '../../../services/authService'; import './Layout.css'; @@ -74,6 +75,7 @@ function Layout() { + ); } diff --git a/src/components/home/HealthScoreModal/HealthScoreModal.css b/src/components/home/HealthScoreModal/HealthScoreModal.css new file mode 100644 index 0000000..1688fec --- /dev/null +++ b/src/components/home/HealthScoreModal/HealthScoreModal.css @@ -0,0 +1,256 @@ +/** + * HealthScoreModal Styles + */ + +.health-modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 1rem; + opacity: 0; + animation: fadeIn 0.3s forwards; +} + +@keyframes fadeIn { + to { + opacity: 1; + } +} + +.health-modal-content { + background: var(--glass-panel-bg); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + width: 100%; + max-width: 400px; + border-radius: 32px; + padding: 2.5rem 2rem 2rem; + position: relative; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2); + border: 1px solid var(--glass-border); + transform: scale(0.9) translateY(20px); + opacity: 0; + transition: all 0.5s cubic-bezier(0.19, 1, 0.22, 1); +} + +.health-modal-content.animate-in { + transform: scale(1) translateY(0); + opacity: 1; +} + +.health-modal-close { + position: absolute; + top: 1.25rem; + right: 1.25rem; + background: transparent; + border: none; + color: var(--text-tertiary); + cursor: pointer; + padding: 0.25rem; + transition: all 0.2s ease; + border-radius: 50%; +} + +.health-modal-close:hover { + color: var(--text-primary); + background: rgba(0, 0, 0, 0.05); +} + +.health-modal-header { + display: flex; + flex-direction: column; + align-items: center; + margin-bottom: 2rem; +} + +.health-score-ring-large { + position: relative; + width: 160px; + height: 160px; + margin-bottom: 1.5rem; +} + +.health-ring-progress { + transition: stroke-dasharray 2s cubic-bezier(0.19, 1, 0.22, 1); + transform-origin: center; +} + +.health-score-value-container { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + display: flex; + flex-direction: column; + align-items: center; +} + +.health-score-value { + font-size: 3.5rem; + font-weight: 800; + font-family: 'Outfit', sans-serif; + line-height: 1; + text-shadow: 0 4px 12px rgba(0, 0, 0, 0.05); +} + +.health-score-label { + font-size: 0.875rem; + color: var(--text-secondary); + font-weight: 600; + margin-top: 4px; + letter-spacing: 0.05em; +} + +.health-level-badge { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + border-radius: 20px; + font-size: 0.95rem; + font-weight: 700; +} + +.health-metrics-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; + margin-bottom: 2rem; +} + +.health-metric-card { + background: rgba(255, 255, 255, 0.5); + border-radius: 20px; + padding: 1rem; + display: flex; + flex-direction: column; + gap: 0.75rem; + border: 1px solid rgba(0, 0, 0, 0.05); +} + +.metric-icon { + width: 36px; + height: 36px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 0.25rem; +} + +.metric-icon.debt { + background: rgba(239, 68, 68, 0.1); + color: var(--color-error); +} + +.metric-icon.spend { + background: rgba(245, 158, 11, 0.1); + color: var(--color-warning); +} + +.metric-info { + display: flex; + flex-direction: column; +} + +.metric-label { + font-size: 0.8rem; + color: var(--text-secondary); + font-weight: 500; + margin-bottom: 0.25rem; +} + +.metric-value-row { + display: flex; + align-items: baseline; + justify-content: space-between; +} + +.metric-value { + font-size: 1.125rem; + font-weight: 700; + color: var(--text-primary); + font-family: 'Outfit', sans-serif; +} + +.metric-status { + font-size: 0.75rem; + font-weight: 600; +} + +.metric-trend { + font-size: 0.7rem; + margin-top: 4px; +} + +.metric-trend.up { + color: var(--color-error); + /* Higher spend is usually bad */ +} + +.metric-trend.down { + color: var(--color-success); + /* Lower spend is usually good */ +} + +.health-suggestion-box { + background: linear-gradient(135deg, rgba(59, 130, 246, 0.1), rgba(37, 99, 235, 0.05)); + border-radius: 20px; + padding: 1.25rem; + margin-bottom: 2rem; + border: 1px solid rgba(59, 130, 246, 0.1); +} + +.suggestion-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.95rem; + font-weight: 700; + color: var(--text-primary); + margin-top: 0; + margin-bottom: 0.5rem; +} + +.suggestion-text { + font-size: 0.9rem; + color: var(--text-secondary); + line-height: 1.6; + margin: 0; +} + +.health-actions { + display: flex; + justify-content: center; +} + +.health-action-btn { + width: 100%; + padding: 1rem; + border-radius: 16px; + font-weight: 600; + font-size: 1rem; + border: none; + cursor: pointer; + transition: all 0.2s; +} + +.health-action-btn.primary { + background: var(--text-primary); + color: var(--bg-primary); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); +} + +.health-action-btn.primary:hover { + transform: translateY(-2px); + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15); +} \ No newline at end of file diff --git a/src/components/home/HealthScoreModal/HealthScoreModal.tsx b/src/components/home/HealthScoreModal/HealthScoreModal.tsx new file mode 100644 index 0000000..ac71c61 --- /dev/null +++ b/src/components/home/HealthScoreModal/HealthScoreModal.tsx @@ -0,0 +1,162 @@ +/** + * HealthScoreModal Component + * Displays detailed financial health analysis + * Phase 3 Requirement: Emotional interface & Smart feedback + */ + +import React, { useEffect, useState } from 'react'; +import { Icon } from '@iconify/react'; +import { formatCurrency } from '../../../utils/format'; +import './HealthScoreModal.css'; + +interface HealthScoreModalProps { + isOpen: boolean; + onClose: () => void; + score: number; + totalAssets: number; + totalLiabilities: number; + todaySpend: number; + yesterdaySpend: number; +} + +export const HealthScoreModal: React.FC = ({ + isOpen, + onClose, + score, + totalAssets, + totalLiabilities, + todaySpend, + yesterdaySpend, +}) => { + const [animate, setAnimate] = useState(false); + + useEffect(() => { + if (isOpen) { + setTimeout(() => setAnimate(true), 100); + } else { + setAnimate(false); + } + }, [isOpen]); + + if (!isOpen) return null; + + // Analysis Logic + const debtRatio = totalAssets > 0 ? (totalLiabilities / totalAssets) * 100 : 0; + let debtLevel = '优秀'; + let debtColor = 'var(--color-success)'; + if (debtRatio > 30) { + debtLevel = '一般'; + debtColor = 'var(--color-warning)'; + } + if (debtRatio > 60) { + debtLevel = '危险'; + debtColor = 'var(--color-error)'; + } + + const spendDiff = todaySpend - yesterdaySpend; + const spendTrend = spendDiff > 0 ? 'up' : 'down'; + + const getLevel = (s: number) => { + if (s >= 90) return { label: '卓越', color: '#10b981', icon: 'solar:cup-star-bold-duotone' }; + if (s >= 80) return { label: '优秀', color: '#3b82f6', icon: 'solar:medal-star-bold-duotone' }; + if (s >= 60) return { label: '良好', color: '#f59e0b', icon: 'solar:check-circle-bold-duotone' }; + return { label: '需努力', color: '#ef4444', icon: 'solar:danger-circle-bold-duotone' }; + }; + + const level = getLevel(score); + + return ( +
+
e.stopPropagation()} + > + + +
+
+ + + + +
+ {score} + 健康分 +
+
+ +
+ + {level.label}状态 +
+
+ +
+
+
+ +
+
+ 负债率 +
+ {debtRatio.toFixed(1)}% + {debtLevel} +
+
+
+ +
+
+ +
+
+ 今日消费 +
+ {formatCurrency(todaySpend)} +
+ + {spendTrend === 'up' ? '比昨天多' : '比昨天少'} {formatCurrency(Math.abs(spendDiff))} + +
+
+
+ +
+

+ + 智能建议 +

+

+ {score >= 80 + ? '您的财务状况非常健康!建议继续保持低负债率,并考虑适当增加投资比例以抵抗通胀。' + : score >= 60 + ? '财务状况良好,但还有提升空间。试着控制非必要支出,提高每月的储蓄比例。' + : '请注意控制支出!建议优先偿还高息债务,并审视近期的消费习惯。'} +

+
+ +
+ +
+
+
+ ); +}; diff --git a/src/pages/Budget/Budget.tsx b/src/pages/Budget/Budget.tsx index d8f9b5a..f5a9c5d 100644 --- a/src/pages/Budget/Budget.tsx +++ b/src/pages/Budget/Budget.tsx @@ -1,4 +1,5 @@ import { useState, useEffect } from 'react'; +import { Confetti } from '../../components/common/Confetti'; import { BudgetCard, BudgetForm, @@ -51,6 +52,8 @@ function Budget() { type: 'deposit' | 'withdraw'; } | null>(null); + const [showConfetti, setShowConfetti] = useState(false); + // Load budgets, categories, and accounts useEffect(() => { loadData(); @@ -262,6 +265,12 @@ function Budget() { ...updated, progress: updated.progress ?? 0, } : pb))); + + // Trigger celebration if goal reached or exceeded on deposit + if (type === 'deposit' && piggyBank.currentAmount < piggyBank.targetAmount && updated.currentAmount >= updated.targetAmount) { + setShowConfetti(true); + } + setTransactionModal(null); } catch (err) { console.error('Failed to process transaction:', err); @@ -398,6 +407,11 @@ function Budget() { isLoading={isSubmitting} /> )} + + setShowConfetti(false)} + /> ); } diff --git a/src/pages/Home/Home.tsx b/src/pages/Home/Home.tsx index d08cf42..ea659b3 100644 --- a/src/pages/Home/Home.tsx +++ b/src/pages/Home/Home.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import './Home.css'; import { getAccounts, calculateTotalAssets, calculateTotalLiabilities } from '../../services/accountService'; -import { getTransactions } from '../../services/transactionService'; +import { getTransactions, calculateTotalExpense } from '../../services/transactionService'; import { getCategories } from '../../services/categoryService'; import { getLedgers, reorderLedgers } from '../../services/ledgerService'; import { getSettings, updateSettings } from '../../services/settingsService'; @@ -15,6 +15,7 @@ import { CreateFirstAccountModal } from '../../components/account/CreateFirstAcc import { AccountForm } from '../../components/account/AccountForm/AccountForm'; import { createAccount } from '../../services/accountService'; import { Confetti } from '../../components/common/Confetti'; +import { HealthScoreModal } from '../../components/home/HealthScoreModal/HealthScoreModal'; import type { Account, Transaction, Category, Ledger, UserSettings } from '../../types'; @@ -38,6 +39,9 @@ function Home() { const [voiceModalOpen, setVoiceModalOpen] = useState(false); const [showAccountForm, setShowAccountForm] = useState(false); const [showConfetti, setShowConfetti] = useState(false); + const [showHealthModal, setShowHealthModal] = useState(false); + const [todaySpend, setTodaySpend] = useState(0); + const [yesterdaySpend, setYesterdaySpend] = useState(0); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -51,13 +55,23 @@ function Home() { setLoading(true); setError(null); - // Load accounts, recent transactions, categories, ledgers, and settings in parallel - const [accountsData, transactionsData, categoriesData, ledgersData, settingsData] = await Promise.all([ + // Calculate dates for today and yesterday + const today = new Date(); + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + + const todayStr = today.toISOString().split('T')[0]; + const yesterdayStr = yesterday.toISOString().split('T')[0]; + + // Load accounts, recent transactions, today/yesterday stats + const [accountsData, transactionsData, categoriesData, ledgersData, settingsData, todayData, yesterdayData] = await Promise.all([ getAccounts(), - getTransactions({ page: 1, pageSize: 5 }), // Get 5 most recent transactions + getTransactions({ page: 1, pageSize: 5 }), // Recent transactions getCategories(), - getLedgers().catch(() => []), // Gracefully handle if ledgers not available - getSettings().catch(() => null), // Gracefully handle if settings not available + getLedgers().catch(() => []), + getSettings().catch(() => null), + getTransactions({ startDate: todayStr, endDate: todayStr, type: 'expense', pageSize: 100 }), + getTransactions({ startDate: yesterdayStr, endDate: yesterdayStr, type: 'expense', pageSize: 100 }), ]); setAccounts(accountsData || []); @@ -65,6 +79,10 @@ function Home() { setCategories(categoriesData || []); setLedgers(ledgersData || []); setSettings(settingsData); + + // Calculate daily spends + setTodaySpend(calculateTotalExpense(todayData.items)); + setYesterdaySpend(calculateTotalExpense(yesterdayData.items)); } catch (err) { setError(err instanceof Error ? err.message : '加载数据失败'); console.error('Failed to load home page data:', err); @@ -176,17 +194,6 @@ function Home() { // Phase 3: Daily Briefing Logic const getDailyBriefing = () => { const hour = new Date().getHours(); - const todaySpend = recentTransactions - .filter(t => { - const tDate = new Date(t.transactionDate); - const today = new Date(); - return tDate.getDate() === today.getDate() && - tDate.getMonth() === today.getMonth() && - tDate.getFullYear() === today.getFullYear() && - t.type === 'expense'; - }) - .reduce((sum, t) => sum + Math.abs(t.amount), 0); - let greeting = '你好'; if (hour < 5) greeting = '夜深了'; else if (hour < 11) greeting = '早上好'; @@ -196,7 +203,24 @@ function Home() { let insight = '今天还没有记账哦'; if (todaySpend > 0) { - insight = `今天已支出 ${formatCurrency(todaySpend)}`; + if (yesterdaySpend > 0) { + const diff = todaySpend - yesterdaySpend; + const diffPercent = Math.abs((diff / yesterdaySpend) * 100); + + if (Math.abs(diff) < 5) { + insight = `今日支出 ${formatCurrency(todaySpend)},与昨天持平`; + } else if (diff < 0) { + insight = `今日支出 ${formatCurrency(todaySpend)},比昨天节省了 ${diffPercent.toFixed(0)}%`; + } else { + insight = `今日支出 ${formatCurrency(todaySpend)},比昨天多 ${diffPercent.toFixed(0)}%`; + } + } else { + insight = `今天已支出 ${formatCurrency(todaySpend)}`; + } + } else { + if (yesterdaySpend > 0) { + insight = '新的一天,保持理智消费'; + } } return { greeting, insight }; @@ -282,7 +306,7 @@ function Home() {

-