feat: 新增连续记账天数统计与热力图展示功能

This commit is contained in:
2026-01-28 10:33:19 +08:00
parent 0ee7c13ed8
commit e0cd9028e7
5 changed files with 556 additions and 4 deletions

View File

@@ -0,0 +1,221 @@
/**
* Contribution Heatmap Graph Style
*/
.contribution-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.6);
display: flex;
justify-content: center;
align-items: center;
z-index: 1200;
backdrop-filter: blur(8px);
animation: fadeIn 0.3s ease;
}
.contribution-modal-content {
background: var(--glass-panel-bg);
border: 1px solid var(--glass-border);
border-radius: var(--radius-xl);
padding: var(--spacing-xl);
width: 90%;
max-width: 800px;
box-shadow: var(--shadow-2xl);
animation: scaleIn 0.3s cubic-bezier(0.16, 1, 0.3, 1);
position: relative;
color: var(--text-primary);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-lg);
}
.modal-title {
font-size: 1.25rem;
font-weight: 700;
margin: 0;
display: flex;
align-items: center;
gap: 8px;
}
.close-btn {
background: none;
border: none;
color: var(--text-tertiary);
cursor: pointer;
padding: 4px;
border-radius: 50%;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.close-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.contribution-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--spacing-md);
margin-bottom: var(--spacing-xl);
}
.stat-card {
background: var(--bg-secondary);
padding: var(--spacing-md);
border-radius: var(--radius-lg);
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.stat-value {
font-family: 'Outfit', sans-serif;
font-size: 1.5rem;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 4px;
}
.stat-label {
font-size: 0.8rem;
color: var(--text-secondary);
}
.graph-container {
overflow-x: auto;
padding-bottom: var(--spacing-md);
}
.contribution-graph {
display: flex;
gap: 4px;
min-width: fit-content;
}
.week-column {
display: flex;
flex-direction: column;
gap: 4px;
}
.day-cell {
width: 12px;
height: 12px;
border-radius: 2px;
background-color: var(--bg-tertiary);
transition: all 0.2s ease;
cursor: pointer;
position: relative;
}
.day-cell:hover {
transform: scale(1.4);
z-index: 10;
border: 1px solid rgba(0, 0, 0, 0.1);
}
/* Tooltip */
.day-cell[data-tooltip]:hover::after {
content: attr(data-tooltip);
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 4px 8px;
border-radius: 4px;
font-size: 0.75rem;
white-space: nowrap;
z-index: 20;
margin-bottom: 6px;
pointer-events: none;
}
/* Legend */
.graph-legend {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
margin-top: var(--spacing-md);
font-size: 0.75rem;
color: var(--text-tertiary);
}
.legend-cell {
width: 12px;
height: 12px;
border-radius: 2px;
}
/* Levels */
.level-0 {
background-color: var(--bg-tertiary);
}
.level-1 {
background-color: #fca5a5;
}
/* Light Red */
.level-2 {
background-color: #f87171;
}
.level-3 {
background-color: #ef4444;
}
.level-4 {
background-color: #dc2626;
}
/* Deep Red */
@media (prefers-color-scheme: dark) {
.level-0 {
background-color: #2d2d2d;
}
.level-1 {
background-color: #451a1a;
}
.level-2 {
background-color: #7f1d1d;
}
.level-3 {
background-color: #b91c1c;
}
.level-4 {
background-color: #ef4444;
}
}
@keyframes scaleIn {
from {
transform: scale(0.95);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}

View File

@@ -0,0 +1,169 @@
import React, { useEffect, useState } from 'react';
import { Icon } from '@iconify/react';
import { getContributionData, type StreakInfoFormatted } from '../../../services/streakService';
import './ContributionModal.css';
interface ContributionModalProps {
isOpen: boolean;
onClose: () => void;
streakInfo: StreakInfoFormatted | null;
}
interface DayData {
date: string;
count: number;
level: number;
}
export const ContributionModal: React.FC<ContributionModalProps> = ({ isOpen, onClose, streakInfo }) => {
const [contributions, setContributions] = useState<Map<string, number>>(new Map());
const [loading, setLoading] = useState(false);
const [graphData, setGraphData] = useState<DayData[][]>([]); // Array of weeks, each week adds days
useEffect(() => {
if (isOpen) {
loadData();
}
}, [isOpen]);
const loadData = async () => {
setLoading(true);
try {
const data = await getContributionData();
const map = new Map<string, number>();
data.forEach(item => map.set(item.date, item.count));
setContributions(map);
generateGraphData(map);
} catch (err) {
console.error('Failed to load contribution data', err);
} finally {
setLoading(false);
}
};
const getLevel = (count: number) => {
if (count === 0) return 0;
if (count <= 2) return 1;
if (count <= 5) return 2;
if (count <= 9) return 3;
return 4;
};
const generateGraphData = (dataMap: Map<string, number>) => {
const weeks: DayData[][] = [];
const today = new Date();
// Go back 52 weeks (approx 1 year)
const startDate = new Date(today);
startDate.setDate(today.getDate() - 365);
// Adjust start date to previous Sunday to align grid properly if we want strict Sunday start
// GitHub actually shifts rows based on day of week.
// Let's stick to GitHub style: Columns are weeks. Rows are Days (Sun -> Sat)
// We need to find the Sunday before or equal to startDate
const dayOfWeek = startDate.getDay(); // 0 (Sun) - 6 (Sat)
const offset = dayOfWeek;
startDate.setDate(startDate.getDate() - offset);
let currentDate = new Date(startDate);
const endDate = new Date(today);
let currentWeek: DayData[] = [];
while (currentDate <= endDate) {
const dateStr = currentDate.toISOString().split('T')[0];
const count = dataMap.get(dateStr) || 0;
currentWeek.push({
date: dateStr,
count: count,
level: getLevel(count)
});
if (currentWeek.length === 7) {
weeks.push(currentWeek);
currentWeek = [];
}
// Move to next day
currentDate.setDate(currentDate.getDate() + 1);
}
// Push last partial week if any
if (currentWeek.length > 0) {
weeks.push(currentWeek);
}
setGraphData(weeks);
};
if (!isOpen) return null;
// Calculate total contributions
const totalContributions = Array.from(contributions.values()).reduce((a, b) => a + b, 0);
return (
<div className="contribution-modal-overlay" onClick={onClose}>
<div className="contribution-modal-content" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h2 className="modal-title">
<Icon icon="solar:calendar-date-bold-duotone" width="24" color="var(--accent-primary)" />
</h2>
<button className="close-btn" onClick={onClose}>
<Icon icon="solar:close-circle-bold" width="24" />
</button>
</div>
<div className="contribution-stats">
<div className="stat-card">
<span className="stat-value">{totalContributions}</span>
<span className="stat-label"> ()</span>
</div>
<div className="stat-card">
<span className="stat-value">{streakInfo?.longestStreak || 0}</span>
<span className="stat-label"> ()</span>
</div>
<div className="stat-card">
<span className="stat-value">{streakInfo?.currentStreak || 0}</span>
<span className="stat-label"> ()</span>
</div>
</div>
{loading ? (
<div style={{ padding: '2rem', textAlign: 'center', color: 'var(--text-tertiary)' }}>...</div>
) : (
<>
<div className="graph-container">
<div className="contribution-graph">
{graphData.map((week, wIndex) => (
<div key={wIndex} className="week-column">
{week.map((day) => (
<div
key={day.date}
className={`day-cell level-${day.level}`}
data-tooltip={`${day.date}: ${day.count} 笔交易`}
title={`${day.date}: ${day.count} 笔交易`}
/>
))}
</div>
))}
</div>
</div>
<div className="graph-legend">
<span>Less</span>
<div className="legend-cell level-0"></div>
<div className="legend-cell level-1"></div>
<div className="legend-cell level-2"></div>
<div className="legend-cell level-3"></div>
<div className="legend-cell level-4"></div>
<span>More</span>
</div>
</>
)}
</div>
</div>
);
};

View File

@@ -86,6 +86,49 @@
color: var(--accent-primary);
}
/* Streak Badge - 连续记账天数徽章 */
.streak-badge {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
background: linear-gradient(135deg, #ff6b6b, #ee5a5a);
border-radius: var(--radius-full);
font-weight: 700;
font-size: 0.875rem;
color: white;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
box-shadow: 0 2px 8px rgba(238, 90, 90, 0.35);
}
.streak-badge:hover {
transform: scale(1.08);
box-shadow: 0 4px 16px rgba(238, 90, 90, 0.5);
}
.streak-icon {
color: white;
animation: heartbeat 1.5s ease-in-out infinite;
}
.streak-count {
font-family: 'Outfit', sans-serif;
letter-spacing: -0.5px;
}
@keyframes heartbeat {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.15);
}
}
/* Header Actions & Health Score */
.header-actions {
display: flex;

View File

@@ -6,6 +6,7 @@ import { getAccounts, calculateTotalAssets, calculateTotalLiabilities } from '..
import { getTransactions, calculateTotalExpense } from '../../services/transactionService';
import { getCategories } from '../../services/categoryService';
import { getLedgers, reorderLedgers } from '../../services/ledgerService';
import { getStreakInfo, type StreakInfoFormatted } from '../../services/streakService';
import { getSettings, updateSettings } from '../../services/settingsService';
import { Icon } from '@iconify/react';
import { SpendingTrendChart } from '../../components/charts/SpendingTrendChart';
@@ -16,8 +17,8 @@ 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 { LikeButton } from '../../components/common/MicroInteraction/LikeButton';
import { HealthScoreModal } from '../../components/home/HealthScoreModal/HealthScoreModal';
import { ContributionModal } from '../../components/common/ContributionGraph/ContributionModal'; // Import Component
import type { Account, Transaction, Category, Ledger, UserSettings } from '../../types';
@@ -42,8 +43,10 @@ function Home() {
const [showAccountForm, setShowAccountForm] = useState(false);
const [showConfetti, setShowConfetti] = useState(false);
const [showHealthModal, setShowHealthModal] = useState(false);
const [showContributionModal, setShowContributionModal] = useState(false); // Add State
const [todaySpend, setTodaySpend] = useState(0);
const [yesterdaySpend, setYesterdaySpend] = useState(0);
const [streakInfo, setStreakInfo] = useState<StreakInfoFormatted | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -66,7 +69,7 @@ function Home() {
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([
const [accountsData, transactionsData, categoriesData, ledgersData, settingsData, todayData, yesterdayData, streakData] = await Promise.all([
getAccounts(),
getTransactions({ page: 1, pageSize: 5 }), // Recent transactions
getCategories(),
@@ -74,6 +77,7 @@ function Home() {
getSettings().catch(() => null),
getTransactions({ startDate: todayStr, endDate: todayStr, type: 'expense', pageSize: 100 }),
getTransactions({ startDate: yesterdayStr, endDate: yesterdayStr, type: 'expense', pageSize: 100 }),
getStreakInfo().catch(() => null),
]);
setAccounts(accountsData || []);
@@ -85,6 +89,9 @@ function Home() {
// Calculate daily spends
setTodaySpend(calculateTotalExpense(todayData.items));
setYesterdaySpend(calculateTotalExpense(yesterdayData.items));
// Set streak info
setStreakInfo(streakData);
} catch (err) {
setError(err instanceof Error ? err.message : '加载数据失败');
console.error('Failed to load home page data:', err);
@@ -304,9 +311,18 @@ function Home() {
</h1>
<p className="greeting-insight animate-slide-up delay-100">
<Icon icon="solar:bell-bing-bold-duotone" width="16" className="insight-icon" />
{insight}
{streakInfo?.message || insight}
<div className="insight-actions" style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center' }}>
<LikeButton size="sm" initialCount={Math.floor(Math.random() * 20)} />
{streakInfo && streakInfo.currentStreak > 0 && (
<div
className="streak-badge"
onClick={() => setShowContributionModal(true)}
title={`最长连续: ${streakInfo.longestStreak}\n累计记账: ${streakInfo.totalRecordDays}\n点击查看详情`}
>
<Icon icon="solar:heart-bold" width="16" className="streak-icon" />
<span className="streak-count">{streakInfo.currentStreak}</span>
</div>
)}
</div>
</p>
</div>
@@ -518,6 +534,13 @@ function Home() {
todaySpend={todaySpend}
yesterdaySpend={yesterdaySpend}
/>
{/* Contribution Heatmap Modal */}
<ContributionModal
isOpen={showContributionModal}
onClose={() => setShowContributionModal(false)}
streakInfo={streakInfo}
/>
</div>
);
}

View File

@@ -0,0 +1,96 @@
/**
* Streak Service - API calls for user streak management
* 连续记账功能服务
*/
import api from './api';
import type { ApiResponse } from '../types';
/**
* Streak info returned from backend
*/
export interface StreakInfo {
current_streak: number; // 当前连续天数
longest_streak: number; // 最长连续记录
total_record_days: number; // 累计记账天数
has_record_today: boolean; // 今天是否已记账
message: string; // 提示信息
}
/**
* Daily contribution data
*/
export interface DailyContribution {
date: string;
count: number;
}
/**
* Streak info in camelCase for frontend
*/
export interface StreakInfoFormatted {
currentStreak: number;
longestStreak: number;
totalRecordDays: number;
hasRecordToday: boolean;
message: string;
}
/**
* Map streak data from API (snake_case) to frontend (camelCase)
*/
function mapStreakFromApi(data: StreakInfo): StreakInfoFormatted {
return {
currentStreak: data.current_streak,
longestStreak: data.longest_streak,
totalRecordDays: data.total_record_days,
hasRecordToday: data.has_record_today,
message: data.message,
};
}
/**
* Get current user's streak info
* 获取当前用户的连续记账信息
*/
export async function getStreakInfo(): Promise<StreakInfoFormatted> {
const response = await api.get<ApiResponse<StreakInfo>>('/user/streak');
if (!response.data) {
// Return default values if no streak data
return {
currentStreak: 0,
longestStreak: 0,
totalRecordDays: 0,
hasRecordToday: false,
message: '开始记录你的第一笔账吧!',
};
}
return mapStreakFromApi(response.data);
}
/**
* Recalculate streak from transaction history
* 重新计算连续记账天数(基于交易历史)
*/
export async function recalculateStreak(): Promise<StreakInfoFormatted> {
const response = await api.post<ApiResponse<StreakInfo>>('/user/streak/recalculate');
if (!response.data) {
throw new Error(response.error || 'Failed to recalculate streak');
}
return mapStreakFromApi(response.data);
}
/**
* Get daily contribution data for heatmap
* 获取热力图数据
*/
export async function getContributionData(): Promise<DailyContribution[]> {
const response = await api.get<ApiResponse<DailyContribution[]>>('/user/streak/contribution');
return response.data || [];
}
export default {
getStreakInfo,
recalculateStreak,
getContributionData,
};