康心伴Logo
康心伴WellAlly
前端开发

使用 React Hook 和 LocalStorage 构建目标连续打卡系统

5 分钟阅读

使用 React Hook 和 LocalStorage 构建目标连续打卡系统

概述

连续打卡(Streak)是健康应用激励用户的重要机制。本文将介绍如何构建一个完善的 React Hook 来管理目标连续打卡,包括数据持久化、时区处理、数据恢复等高级功能。

核心功能设计

功能需求

  1. 记录打卡状态:每日是否完成目标
  2. 计算连续天数:当前连续打卡天数
  3. 历史统计:最长连续、总打卡天数
  4. 数据持久化:使用 LocalStorage 保存
  5. 时区支持:按用户时区判断日期
  6. 数据恢复:支持数据导入导出

基础 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

参考资料

#

文章标签

react
hooks
localstorage
目标追踪
健康习惯

觉得这篇文章有帮助?

立即体验康心伴,开始您的健康管理之旅