使用 React Hook 和 LocalStorage 构建目标连续打卡系统
概述
连续打卡(Streak)是健康应用激励用户的重要机制。本文将介绍如何构建一个完善的 React Hook 来管理目标连续打卡,包括数据持久化、时区处理、数据恢复等高级功能。
核心功能设计
功能需求
- 记录打卡状态:每日是否完成目标
- 计算连续天数:当前连续打卡天数
- 历史统计:最长连续、总打卡天数
- 数据持久化:使用 LocalStorage 保存
- 时区支持:按用户时区判断日期
- 数据恢复:支持数据导入导出
基础 Hook 实现
类型定义
code
// lib/types/streak.ts
export interface StreakData {
// 当前连续天数
currentStreak: number;
// 最长连续天数
longestStreak: number;
// 总打卡天数
totalDays: number;
// 打卡记录(日期字符串 -> 是否完成)
records: Record<string, boolean>;
// 最后打卡日期
lastCheckIn: string | null;
// 时区
timezone: string;
}
export interface StreakHookResult {
// 状态数据
currentStreak: number;
longestStreak: number;
totalDays: number;
isCompletedToday: boolean;
streakHistory: Array<{ date: string; completed: boolean }>;
// 操作方法
checkIn: () => void;
undoCheckIn: () => void;
resetStreak: () => void;
exportData: () => string;
importData: (data: string) => boolean;
isLoading: boolean;
error: string | null;
}
export interface StreakOptions {
// 存储键名
storageKey?: string;
// 是否启用自动保存
autoSave?: boolean;
// 自定义时区
timezone?: string;
// 数据变化回调
onDataChange?: (data: StreakData) => void;
}
Code collapsed
核心 Hook 实现
code
// hooks/useGoalStreak.ts
import { useState, useEffect, useCallback, useMemo } from 'react';
import type { StreakData, StreakHookResult, StreakOptions } from '@/lib/types/streak';
const DEFAULT_STORAGE_KEY = 'goal-streak-data';
const STREAK_VERSION = '1.0';
export const useGoalStreak = (options: StreakOptions = {}): StreakHookResult => {
const {
storageKey = DEFAULT_STORAGE_KEY,
autoSave = true,
timezone = Intl.DateTimeFormat().resolvedOptions().timeZone,
onDataChange
} = options;
const [data, setData] = useState<StreakData | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// 获取当前日期字符串(考虑时区)
const getTodayString = useCallback((): string => {
return new Date().toLocaleDateString('en-CA', { timeZone: timezone });
}, [timezone]);
// 计算连续天数
const calculateStreak = useCallback((records: Record<string, boolean>): {
current: number;
longest: number;
} => {
const sortedDates = Object.keys(records)
.filter(date => records[date])
.sort()
.reverse();
if (sortedDates.length === 0) {
return { current: 0, longest: 0 };
}
// 计算当前连续
let currentStreak = 0;
const today = getTodayString();
for (let i = 0; i < sortedDates.length; i++) {
const date = sortedDates[i];
const checkDate = new Date();
checkDate.setDate(checkDate.getDate() - i);
const dateString = checkDate.toLocaleDateString('en-CA', { timeZone: timezone });
if (records[dateString]) {
currentStreak++;
} else {
break;
}
}
// 计算最长连续
let longestStreak = 0;
let tempStreak = 0;
let prevDate: Date | null = null;
for (const dateStr of sortedDates.reverse()) {
const currentDate = new Date(dateStr + 'T00:00:00');
if (prevDate) {
const diffDays = Math.floor(
(currentDate.getTime() - prevDate.getTime()) / (1000 * 60 * 60 * 24)
);
if (diffDays === 1) {
tempStreak++;
} else {
longestStreak = Math.max(longestStreak, tempStreak);
tempStreak = 1;
}
} else {
tempStreak = 1;
}
prevDate = currentDate;
}
longestStreak = Math.max(longestStreak, tempStreak);
return { current: currentStreak, longest: longestStreak };
}, [timezone, getTodayString]);
// 从 LocalStorage 加载数据
useEffect(() => {
try {
const stored = localStorage.getItem(storageKey);
if (stored) {
const parsed = JSON.parse(stored) as StreakData;
// 验证数据版本
if (parsed.version !== STREAK_VERSION) {
// 处理版本迁移
console.warn('Streak data version mismatch, migrating...');
}
setData(parsed);
} else {
// 初始化空数据
const initialData: StreakData = {
currentStreak: 0,
longestStreak: 0,
totalDays: 0,
records: {},
lastCheckIn: null,
timezone,
version: STREAK_VERSION
};
setData(initialData);
}
setIsLoading(false);
} catch (err) {
setError(err instanceof Error ? err.message : '加载数据失败');
setIsLoading(false);
}
}, [storageKey, timezone]);
// 保存到 LocalStorage
const saveData = useCallback((newData: StreakData) => {
if (!autoSave) return;
try {
localStorage.setItem(storageKey, JSON.stringify(newData));
onDataChange?.(newData);
} catch (err) {
setError(err instanceof Error ? err.message : '保存数据失败');
}
}, [storageKey, autoSave, onDataChange]);
// 打卡
const checkIn = useCallback(() => {
if (!data) return;
const today = getTodayString();
setData(prevData => {
if (!prevData || prevData.records[today]) {
return prevData;
}
const newData: StreakData = {
...prevData,
records: {
...prevData.records,
[today]: true
},
lastCheckIn: today,
totalDays: prevData.totalDays + 1
};
const { current, longest } = calculateStreak(newData.records);
newData.currentStreak = current;
newData.longestStreak = Math.max(prevData.longestStreak, longest);
saveData(newData);
return newData;
});
}, [data, getTodayString, calculateStreak, saveData]);
// 撤销打卡
const undoCheckIn = useCallback(() => {
if (!data) return;
const today = getTodayString();
setData(prevData => {
if (!prevData || !prevData.records[today]) {
return prevData;
}
const newRecords = { ...prevData.records };
delete newRecords[today];
const newData: StreakData = {
...prevData,
records: newRecords,
lastCheckIn: Object.keys(newRecords)
.sort()
.reverse()[0] || null,
totalDays: prevData.totalDays - 1
};
const { current, longest } = calculateStreak(newData.records);
newData.currentStreak = current;
newData.longestStreak = Math.max(prevData.longestStreak, longest);
saveData(newData);
return newData;
});
}, [data, getTodayString, calculateStreak, saveData]);
// 重置连续记录
const resetStreak = useCallback(() => {
if (!data) return;
const newData: StreakData = {
currentStreak: 0,
longestStreak: data.longestStreak,
totalDays: 0,
records: {},
lastCheckIn: null,
timezone,
version: STREAK_VERSION
};
setData(newData);
saveData(newData);
}, [data, timezone, saveData]);
// 导出数据
const exportData = useCallback((): string => {
if (!data) return '';
return JSON.stringify({
...data,
exportedAt: new Date().toISOString()
}, null, 2);
}, [data]);
// 导入数据
const importData = useCallback((jsonData: string): boolean => {
try {
const parsed = JSON.parse(jsonData) as Partial<StreakData>;
// 验证数据
if (!parsed.records || typeof parsed.records !== 'object') {
throw new Error('Invalid data format');
}
const { current, longest } = calculateStreak(parsed.records);
const newData: StreakData = {
currentStreak: current,
longestStreak: parsed.longestStreak || longest,
totalDays: parsed.totalDays || Object.values(parsed.records).filter(Boolean).length,
records: parsed.records,
lastCheckIn: parsed.lastCheckIn || null,
timezone: parsed.timezone || timezone,
version: STREAK_VERSION
};
setData(newData);
saveData(newData);
setError(null);
return true;
} catch (err) {
setError(err instanceof Error ? err.message : '导入数据失败');
return false;
}
}, [calculateStreak, timezone, saveData]);
// 计算派生状态
const isCompletedToday = useMemo(() => {
if (!data) return false;
const today = getTodayString();
return data.records[today] === true;
}, [data, getTodayString]);
const streakHistory = useMemo(() => {
if (!data) return [];
return Object.entries(data.records)
.map(([date, completed]) => ({ date, completed }))
.sort((a, b) => b.date.localeCompare(a.date));
}, [data]);
return {
currentStreak: data?.currentStreak || 0,
longestStreak: data?.longestStreak || 0,
totalDays: data?.totalDays || 0,
isCompletedToday,
streakHistory,
checkIn,
undoCheckIn,
resetStreak,
exportData,
importData,
isLoading,
error
};
};
Code collapsed
UI 组件实现
打卡按钮组件
code
// components/streak/CheckInButton.tsx
import React, { useState } from 'react';
import { useGoalStreak } from '@/hooks/useGoalStreak';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { FlameIcon, CalendarIcon, TrophyIcon } from 'lucide-react';
interface CheckInButtonProps {
goalName?: string;
targetDays?: number;
showStats?: boolean;
}
export const CheckInButton: React.FC<CheckInButtonProps> = ({
goalName = '每日目标',
targetDays = 30,
showStats = true
}) => {
const {
currentStreak,
longestStreak,
totalDays,
isCompletedToday,
checkIn,
undoCheckIn,
isLoading
} = useGoalStreak({
storageKey: `streak-${goalName.replace(/\s+/g, '-').toLowerCase()}`,
autoSave: true
});
const [showConfetti, setShowConfetti] = useState(false);
const handleCheckIn = () => {
if (isCompletedToday) {
undoCheckIn();
} else {
checkIn();
setShowConfetti(true);
setTimeout(() => setShowConfetti(false), 2000);
}
};
const progress = Math.min((currentStreak / targetDays) * 100, 100);
if (isLoading) {
return <div>加载中...</div>;
}
return (
<Card className="w-full max-w-md mx-auto">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FlameIcon className={currentStreak > 0 ? 'text-orange-500' : 'text-gray-400'} />
{goalName}
</CardTitle>
<CardDescription>
{currentStreak > 0
? `已连续 ${currentStreak} 天!继续加油!`
: '开始你的连续打卡之旅'}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* 连续天数显示 */}
<div className="text-center">
<div className="text-6xl font-bold text-orange-500">
{currentStreak}
</div>
<div className="text-sm text-gray-500 mt-2">当前连续天数</div>
</div>
{/* 进度条 */}
{targetDays > 0 && (
<div>
<div className="flex justify-between text-sm text-gray-600 mb-2">
<span>进度</span>
<span>{currentStreak} / {targetDays} 天</span>
</div>
<Progress value={progress} className="h-2" />
</div>
)}
{/* 打卡按钮 */}
<Button
onClick={handleCheckIn}
variant={isCompletedToday ? 'outline' : 'default'}
className="w-full"
size="lg"
>
{isCompletedToday ? '撤销打卡' : '今日打卡'}
</Button>
{/* 统计信息 */}
{showStats && (
<div className="grid grid-cols-2 gap-4 pt-4 border-t">
<div className="text-center">
<div className="flex items-center justify-center gap-1">
<TrophyIcon className="w-4 h-4 text-yellow-500" />
<span className="text-lg font-semibold">{longestStreak}</span>
</div>
<div className="text-xs text-gray-500">最长连续</div>
</div>
<div className="text-center">
<div className="flex items-center justify-center gap-1">
<CalendarIcon className="w-4 h-4 text-blue-500" />
<span className="text-lg font-semibold">{totalDays}</span>
</div>
<div className="text-xs text-gray-500">累计打卡</div>
</div>
</div>
)}
</CardContent>
{/* 庆祝动画 */}
{showConfetti && <ConfettiAnimation />}
</Card>
);
};
// 简单的庆祝动画
const ConfettiAnimation: React.FC = () => {
return (
<div className="fixed inset-0 pointer-events-none flex items-center justify-center">
<div className="text-6xl animate-bounce">🎉</div>
</div>
);
};
Code collapsed
日历视图组件
code
// components/streak/StreakCalendar.tsx
import React, { useMemo } from 'react';
import { useGoalStreak } from '@/hooks/useGoalStreak';
import { cn } from '@/lib/utils';
interface StreakCalendarProps {
year?: number;
month?: number;
}
export const StreakCalendar: React.FC<StreakCalendarProps> = ({
year = new Date().getFullYear(),
month = new Date().getMonth()
}) => {
const { streakHistory, isCompletedToday } = useGoalStreak();
const completedDates = useMemo(() => {
return new Set(
streakHistory
.filter(h => h.completed)
.map(h => h.date)
);
}, [streakHistory]);
const calendarDays = useMemo(() => {
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
const daysInMonth = lastDay.getDate();
const startDayOfWeek = firstDay.getDay();
const days = [];
// 填充月初空白
for (let i = 0; i < startDayOfWeek; i++) {
days.push(null);
}
// 填充日期
for (let day = 1; day <= daysInMonth; day++) {
const date = new Date(year, month, day);
const dateString = date.toLocaleDateString('en-CA');
days.push({
day,
dateString,
isCompleted: completedDates.has(dateString),
isToday: dateString === new Date().toLocaleDateString('en-CA')
});
}
return days;
}, [year, month, completedDates]);
const weekDays = ['日', '一', '二', '三', '四', '五', '六'];
return (
<div className="w-full max-w-md mx-auto">
<div className="grid grid-cols-7 gap-1 mb-2">
{weekDays.map(day => (
<div key={day} className="text-center text-sm text-gray-500 py-2">
{day}
</div>
))}
</div>
<div className="grid grid-cols-7 gap-1">
{calendarDays.map((dayData, index) => {
if (!dayData) {
return <div key={index} className="aspect-square" />;
}
return (
<div
key={index}
className={cn(
'aspect-square rounded flex items-center justify-center text-sm relative',
dayData.isToday && 'ring-2 ring-blue-500 ring-inset',
dayData.isCompleted && 'bg-green-100 text-green-700',
!dayData.isCompleted && 'bg-gray-50',
'hover:bg-opacity-80 transition-colors'
)}
title={dayData.dateString}
>
<span>{dayData.day}</span>
{dayData.isCompleted && (
<div className="absolute bottom-1 w-1 h-1 bg-green-500 rounded-full" />
)}
</div>
);
})}
</div>
<div className="flex items-center justify-center gap-6 mt-4 text-sm">
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-green-100 rounded" />
<span>已完成</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-gray-50 rounded" />
<span>未完成</span>
</div>
</div>
</div>
);
};
Code collapsed
数据管理组件
code
// components/streak/StDataManager.tsx
import React, { useRef } from 'react';
import { useGoalStreak } from '@/hooks/useGoalStreak';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { DownloadIcon, UploadIcon, Trash2Icon } from 'lucide-react';
export const StreakDataManager: React.FC = () => {
const { exportData, importData, resetStreak } = useGoalStreak();
const fileInputRef = useRef<HTMLInputElement>(null);
const handleExport = () => {
const data = exportData();
if (!data) return;
const blob = new Blob([data], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `streak-backup-${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
const handleImport = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
const content = e.target?.result as string;
const success = importData(content);
if (success) {
alert('数据导入成功!');
} else {
alert('数据导入失败,请检查文件格式。');
}
};
reader.readAsText(file);
// 重置 input
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const handleReset = () => {
if (confirm('确定要重置所有连续打卡记录吗?此操作不可恢复。')) {
resetStreak();
}
};
return (
<Card>
<CardHeader>
<CardTitle>数据管理</CardTitle>
<CardDescription>
导出、导入或重置你的打卡数据
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-col gap-2">
<Label>备份数据</Label>
<Button
onClick={handleExport}
variant="outline"
className="w-full justify-start"
>
<DownloadIcon className="w-4 h-4 mr-2" />
导出数据
</Button>
</div>
<div className="flex flex-col gap-2">
<Label>恢复数据</Label>
<Input
ref={fileInputRef}
type="file"
accept=".json"
onChange={handleImport}
className="hidden"
/>
<Button
onClick={() => fileInputRef.current?.click()}
variant="outline"
className="w-full justify-start"
>
<UploadIcon className="w-4 h-4 mr-2" />
导入数据
</Button>
</div>
<div className="flex flex-col gap-2">
<Label className="text-red-500">危险操作</Label>
<Button
onClick={handleReset}
variant="destructive"
className="w-full justify-start"
>
<Trash2Icon className="w-4 h-4 mr-2" />
重置记录
</Button>
</div>
</CardContent>
</Card>
);
};
Code collapsed
多目标追踪
code
// hooks/useMultipleGoalStreaks.ts
import { useMemo } from 'react';
import { useGoalStreak } from './useGoalStreak';
interface GoalConfig {
id: string;
name: string;
targetDays?: number;
}
export const useMultipleGoalStreaks = (goals: GoalConfig[]) => {
const streaks = useMemo(() => {
return goals.map(goal => ({
...goal,
hook: useGoalStreak({
storageKey: `streak-${goal.id}`,
autoSave: true
})
}));
}, [goals]);
const allCompletedToday = useMemo(() => {
return streaks.every(s => s.hook.isCompletedToday);
}, [streaks]);
const totalCompletedToday = useMemo(() => {
return streaks.filter(s => s.hook.isCompletedToday).length;
}, [streaks]);
const overallProgress = useMemo(() => {
return totalCompletedToday / streaks.length;
}, [totalCompletedToday, streaks.length]);
return {
streaks,
allCompletedToday,
totalCompletedToday,
overallProgress
};
};
Code collapsed