feat(P3): 纪念日功能 - 倒计时、节假日、内置节假日显示

- 添加倒计时计算工具 utils/countdown.ts
- 添加内置节假日数据 constants/holidays.ts (中国法定节假日)
- 更新 AnniversaryCard 显示倒计时和节假日标识
- 更新 AnniversaryList 显示即将到来的内置节假日
- 支持农历日期处理 (lunar-javascript)
- 倒计时显示:今天/X天/已过

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
ddshi 2026-01-29 17:10:35 +08:00
parent 5d7b99767d
commit 82c291ef30
9 changed files with 574 additions and 18 deletions

View File

@ -1,6 +1,8 @@
import { Card, Text, Badge, Group, ActionIcon, Stack } from '@mantine/core';
import { IconHeart, IconRepeat } from '@tabler/icons-react';
import { Card, Text, Badge, Group, Stack, ThemeIcon } from '@mantine/core';
import { IconHeart, IconRepeat, IconCalendar } from '@tabler/icons-react';
import type { Event } from '../../types';
import { calculateCountdown, formatCountdown } from '../../utils/countdown';
import { getHolidayById } from '../../constants/holidays';
interface AnniversaryCardProps {
event: Event;
@ -10,6 +12,9 @@ interface AnniversaryCardProps {
export function AnniversaryCard({ event, onClick }: AnniversaryCardProps) {
const isLunar = event.is_lunar;
const repeatType = event.repeat_type;
const countdown = calculateCountdown(event.date, repeatType, isLunar);
const formattedCountdown = formatCountdown(countdown);
const holiday = event.is_holiday ? getHolidayById(event.title) || event.is_holiday : false;
return (
<Card
@ -22,17 +27,33 @@ export function AnniversaryCard({ event, onClick }: AnniversaryCardProps) {
onMouseLeave={(e) => (e.currentTarget.style.transform = 'translateY(0)')}
>
<Group justify="space-between" wrap="nowrap">
<Stack gap={4}>
<Stack gap={4} style={{ flex: 1 }}>
<Group gap={6}>
<IconHeart size={14} color="pink" />
<Text fw={500} size="sm" lineClamp={1}>
<Text fw={500} size="sm" lineClamp={1} style={{ flex: 1 }}>
{event.title}
</Text>
</Group>
<Group gap="xs">
{/* Countdown badge */}
<Badge
size="sm"
variant="filled"
color={countdown.isPast ? 'gray' : countdown.isToday ? 'red' : 'blue'}
leftSection={<IconCalendar size={10} />}
>
{formattedCountdown}
</Badge>
{/* Date display */}
<Text size="xs" c="dimmed">
{new Date(event.date).toLocaleDateString('zh-CN')}
{countdown.isPast
? '已过'
: `${countdown.nextDate.getMonth() + 1}${countdown.nextDate.getDate()}`}
{isLunar && ' (农历)'}
</Text>
</Group>
</Stack>
<Group gap={6}>
@ -46,7 +67,7 @@ export function AnniversaryCard({ event, onClick }: AnniversaryCardProps) {
{repeatType === 'yearly' ? '每年' : '每月'}
</Badge>
)}
{event.is_holiday && (
{(event.is_holiday || holiday) && (
<Badge size="xs" variant="light" color="green">
</Badge>

View File

@ -1,7 +1,11 @@
import { Stack, Text, Paper, Group, Button } from '@mantine/core';
import { IconPlus } from '@tabler/icons-react';
import { useMemo } from 'react';
import { Stack, Text, Paper, Group, Button, Badge, ThemeIcon, Card } from '@mantine/core';
import { IconPlus, IconHeart, IconCalendar } from '@tabler/icons-react';
import { AnniversaryCard } from './AnniversaryCard';
import { getHolidaysForYear, getUpcomingHolidays } from '../../constants/holidays';
import { calculateCountdown, formatCountdown } from '../../utils/countdown';
import type { Event } from '../../types';
import { Lunar, Solar } from 'lunar-javascript';
interface AnniversaryListProps {
events: Event[];
@ -9,10 +13,77 @@ interface AnniversaryListProps {
onAddClick: () => void;
}
// 内置节假日事件类型
interface BuiltInHolidayEvent {
id: string;
title: string;
date: string;
is_holiday: boolean;
is_lunar: boolean;
repeat_type: 'yearly';
type: 'anniversary';
is_builtin: true;
}
export function AnniversaryList({ events, onEventClick, onAddClick }: AnniversaryListProps) {
const anniversaries = events.filter((e) => e.type === 'anniversary');
if (anniversaries.length === 0) {
// 获取内置节假日
const builtInHolidays = useMemo(() => {
const now = new Date();
const year = now.getFullYear();
const holidays = getHolidaysForYear(year);
const nextYear = getHolidaysForYear(year + 1);
// 合并今年和明年的节假日,按日期排序
const allHolidays = [...holidays, ...nextYear].sort(
(a, b) => a.date.getTime() - b.date.getTime()
);
// 只取未来30天内的节假日
const cutoffDate = new Date(now);
cutoffDate.setDate(cutoffDate.getDate() + 30);
return allHolidays
.filter((h) => h.date >= now && h.date <= cutoffDate)
.slice(0, 5)
.map((h): BuiltInHolidayEvent => ({
id: `builtin-${h.id}`,
title: h.name,
date: h.date.toISOString(),
is_holiday: true,
is_lunar: false,
repeat_type: 'yearly',
type: 'anniversary',
is_builtin: true,
}));
}, []);
// 合并用户纪念日和内置节假日
const allAnniversaries = useMemo(() => {
// 用户创建的节假日按倒计时排序
const sortedUser = [...anniversaries].sort((a, b) => {
const countdownA = calculateCountdown(a.date, a.repeat_type, a.is_lunar);
const countdownB = calculateCountdown(b.date, b.repeat_type, b.is_lunar);
return countdownA.nextDate.getTime() - countdownB.nextDate.getTime();
});
// 内置节假日按日期排序
const sortedBuiltIn = [...builtInHolidays].sort((a, b) => {
const countdownA = calculateCountdown(a.date, a.repeat_type, a.is_lunar);
const countdownB = calculateCountdown(b.date, b.repeat_type, b.is_lunar);
return countdownA.nextDate.getTime() - countdownB.nextDate.getTime();
});
return {
user: sortedUser,
builtIn: sortedBuiltIn,
total: sortedUser.length + sortedBuiltIn.length,
};
}, [anniversaries, builtInHolidays]);
// 空状态
if (allAnniversaries.total === 0) {
return (
<Paper p="md" withBorder radius="md" h="100%">
<Stack align="center" justify="center" h="100%">
@ -35,9 +106,14 @@ export function AnniversaryList({ events, onEventClick, onAddClick }: Anniversar
return (
<Paper p="md" withBorder radius="md" h="100%">
<Group justify="space-between" mb="sm">
<Group gap={8}>
<Text fw={500} size="sm">
</Text>
<Badge size="xs" variant="light" color="gray">
{anniversaries.length}
</Badge>
</Group>
<Button
variant="subtle"
size="xs"
@ -49,9 +125,62 @@ export function AnniversaryList({ events, onEventClick, onAddClick }: Anniversar
</Group>
<Stack gap="xs" style={{ maxHeight: 'calc(100% - 40px)', overflowY: 'auto' }}>
{anniversaries.map((event) => (
<AnniversaryCard key={event.id} event={event} onClick={() => onEventClick(event)} />
{/* 内置节假日 */}
{allAnniversaries.builtIn.length > 0 && (
<>
<Text size="xs" c="dimmed" fw={500} tt="uppercase">
</Text>
{allAnniversaries.builtIn.map((holiday) => {
const countdown = calculateCountdown(holiday.date, holiday.repeat_type, holiday.is_lunar);
return (
<Card
key={holiday.id}
shadow="sm"
padding="sm"
radius="md"
style={{ cursor: 'pointer', opacity: 0.8, backgroundColor: '#f8f9fa' }}
>
<Group justify="space-between" wrap="nowrap">
<Group gap={6}>
<ThemeIcon size="xs" variant="light" color="green">
<IconCalendar size={10} />
</ThemeIcon>
<Text fw={500} size="sm">
{holiday.title}
</Text>
</Group>
<Badge
size="xs"
variant="filled"
color={countdown.isToday ? 'red' : 'green'}
>
{formatCountdown(countdown)}
</Badge>
</Group>
</Card>
);
})}
</>
)}
{/* 用户纪念日 */}
{allAnniversaries.user.length > 0 && (
<>
{allAnniversaries.builtIn.length > 0 && (
<Text size="xs" c="dimmed" fw={500} tt="uppercase" mt="xs">
</Text>
)}
{allAnniversaries.user.map((event) => (
<AnniversaryCard
key={event.id}
event={event}
onClick={() => onEventClick(event)}
/>
))}
</>
)}
</Stack>
</Paper>
);

249
src/constants/holidays.ts Normal file
View File

@ -0,0 +1,249 @@
/**
*
* 2024
* /
*/
export interface Holiday {
id: string;
name: string;
month: number; // 公历月份
day: number; // 公历日期
isLunar: boolean; // 是否为农历日期
lunarMonth?: number; // 农历月份(农历日期时使用)
lunarDay?: number; // 农历日期(农历日期时使用)
isStatutory: boolean; // 是否法定节假日
repeatYearly: boolean; // 是否每年重复
}
export const HOLIDAYS: Holiday[] = [
// 元旦
{
id: 'new-year',
name: '元旦',
month: 1,
day: 1,
isLunar: false,
isStatutory: true,
repeatYearly: true,
},
// 春节
{
id: 'spring-festival',
name: '春节',
isLunar: true,
lunarMonth: 1,
lunarDay: 1,
isStatutory: true,
repeatYearly: true,
},
// 元宵节
{
id: 'lantern',
name: '元宵节',
isLunar: true,
lunarMonth: 1,
lunarDay: 15,
isStatutory: false,
repeatYearly: true,
},
// 清明节
{
id: 'qingming',
name: '清明节',
month: 4,
day: 4,
isLunar: false,
isStatutory: true,
repeatYearly: true,
},
// 劳动节
{
id: 'labor-day',
name: '劳动节',
month: 5,
day: 1,
isLunar: false,
isStatutory: true,
repeatYearly: true,
},
// 端午节
{
id: 'dragon-boat',
name: '端午节',
isLunar: true,
lunarMonth: 5,
lunarDay: 5,
isStatutory: true,
repeatYearly: true,
},
// 中秋节
{
id: 'mid-autumn',
name: '中秋节',
isLunar: true,
lunarMonth: 8,
lunarDay: 15,
isStatutory: true,
repeatYearly: true,
},
// 国庆节
{
id: 'national-day',
name: '国庆节',
month: 10,
day: 1,
isLunar: false,
isStatutory: true,
repeatYearly: true,
},
// 重阳节
{
id: 'double-ninth',
name: '重阳节',
isLunar: true,
lunarMonth: 9,
lunarDay: 9,
isStatutory: false,
repeatYearly: true,
},
// 冬至
{
id: 'winter-solstice',
name: '冬至',
month: 12,
day: 21,
isLunar: false,
isStatutory: false,
repeatYearly: true,
},
// 腊八节
{
id: 'laba',
name: '腊八节',
isLunar: true,
lunarMonth: 12,
lunarDay: 8,
isStatutory: false,
repeatYearly: true,
},
// 除夕
{
id: 'chinese-new-years-eve',
name: '除夕',
isLunar: true,
lunarMonth: 12,
lunarDay: 30,
isStatutory: true,
repeatYearly: true,
},
];
/**
*
*/
export function getBuiltInHolidays(): Holiday[] {
return [...HOLIDAYS];
}
/**
* ID查找节假日
*/
export function getHolidayById(id: string): Holiday | undefined {
return HOLIDAYS.find((h) => h.id === id);
}
/**
*
*/
export function isHoliday(
date: Date,
year: number = date.getFullYear()
): Holiday | undefined {
const month = date.getMonth() + 1;
const day = date.getDate();
for (const holiday of HOLIDAYS) {
if (holiday.isLunar && holiday.lunarMonth && holiday.lunarDay) {
// 农历日期需要转换
try {
const lunar = Lunar.fromYmd(year, month, day);
if (
lunar.getMonth() === holiday.lunarMonth &&
lunar.getDay() === holiday.lunarDay
) {
return holiday;
}
} catch {
// 农历转换失败,忽略
}
} else {
// 公历日期直接比较
if (holiday.month === month && holiday.day === day) {
return holiday;
}
}
}
return undefined;
}
/**
*
*/
export function getHolidaysForYear(year: number): Array<Holiday & { date: Date }> {
const result: Array<Holiday & { date: Date }> = [];
for (const holiday of HOLIDAYS) {
if (holiday.isLunar && holiday.lunarMonth && holiday.lunarDay) {
try {
const lunar = Lunar.fromYmd(year, holiday.lunarMonth, holiday.lunarDay);
const solar = lunar.getSolar();
result.push({
...holiday,
date: new Date(solar.getYear(), solar.getMonth() - 1, solar.getDay()),
});
} catch {
// 农历转换失败,跳过
}
} else if (holiday.month && holiday.day) {
result.push({
...holiday,
date: new Date(year, holiday.month - 1, holiday.day),
});
}
}
// 按日期排序
result.sort((a, b) => a.date.getTime() - b.date.getTime());
return result;
}
/**
*
*/
export function getUpcomingHolidays(
fromDate: Date = new Date(),
limit: number = 5
): Array<Holiday & { date: Date; daysUntil: number }> {
const year = fromDate.getFullYear();
const nextYear = year + 1;
const holidays = [
...getHolidaysForYear(year),
...getHolidaysForYear(nextYear),
];
const now = new Date();
now.setHours(0, 0, 0, 0);
const upcoming = holidays
.filter((h) => h.date >= now)
.map((h) => ({
...h,
daysUntil: Math.ceil((h.date.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)),
}))
.slice(0, limit);
return upcoming;
}

152
src/utils/countdown.ts Normal file
View File

@ -0,0 +1,152 @@
import { Lunar, Solar } from 'lunar-javascript';
export interface CountdownResult {
days: number;
hours: number;
minutes: number;
seconds: number;
nextDate: Date;
isToday: boolean;
isPast: boolean;
}
/**
*
* occurrence
*/
export function calculateCountdown(
dateStr: string,
repeatType: 'yearly' | 'monthly' | 'none',
isLunar: boolean
): CountdownResult {
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
let targetDate = new Date(dateStr);
let isPast = false;
// 如果是农历日期,转换为公历
if (isLunar) {
const lunarDate = Lunar.fromYmd(
targetDate.getFullYear(),
targetDate.getMonth() + 1,
targetDate.getDate()
);
const solar = lunarDate.getSolar();
targetDate = new Date(solar.getYear(), solar.getMonth() - 1, solar.getDay());
}
// 计算下一个 occurrence
if (repeatType === 'yearly') {
// 年度重复:找到今年或明年的对应日期
const thisYearTarget = new Date(today.getFullYear(), targetDate.getMonth(), targetDate.getDate());
if (thisYearTarget >= today) {
targetDate = thisYearTarget;
} else {
targetDate = new Date(today.getFullYear() + 1, targetDate.getMonth(), targetDate.getDate());
}
} else if (repeatType === 'monthly') {
// 月度重复:找到本月或下月的对应日期
const thisMonthTarget = new Date(today.getFullYear(), today.getMonth(), targetDate.getDate());
if (thisMonthTarget >= today) {
targetDate = thisMonthTarget;
} else {
targetDate = new Date(today.getFullYear(), today.getMonth() + 1, targetDate.getDate());
}
} else {
// 不重复
if (targetDate < today) {
isPast = true;
}
}
// 计算时间差
const diff = targetDate.getTime() - now.getTime();
const isToday = targetDate.toDateString() === today.toDateString();
if (diff < 0 && !isPast) {
isPast = true;
}
const days = Math.max(0, Math.floor(diff / (1000 * 60 * 60 * 24)));
const hours = Math.max(0, Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)));
const minutes = Math.max(0, Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)));
const seconds = Math.max(0, Math.floor((diff % (1000 * 60)) / 1000));
return {
days,
hours,
minutes,
seconds,
nextDate: targetDate,
isToday,
isPast,
};
}
/**
*
*/
export function formatCountdown(countdown: CountdownResult): string {
if (countdown.isPast) {
return '已过';
}
if (countdown.isToday) {
return '今天';
}
if (countdown.days > 0) {
return `${countdown.days}`;
}
return `${countdown.hours}${countdown.minutes}`;
}
/**
*
*/
export function getFriendlyDateDescription(
dateStr: string,
repeatType: 'yearly' | 'monthly' | 'none',
isLunar: boolean
): string {
const countdown = calculateCountdown(dateStr, repeatType, isLunar);
const targetDate = countdown.nextDate;
if (countdown.isPast) {
return '已过';
}
if (countdown.isToday) {
return '今天';
}
const now = new Date();
const tomorrow = new Date(now);
tomorrow.setDate(tomorrow.getDate() + 1);
if (targetDate.toDateString() === tomorrow.toDateString()) {
return '明天';
}
if (countdown.days < 7) {
return `${countdown.days}天后`;
}
// 返回格式化日期
const month = targetDate.getMonth() + 1;
const day = targetDate.getDate();
if (isLunar) {
// 显示农历
try {
const solar = Solar.fromYmd(targetDate.getFullYear(), month, day);
const lunar = solar.getLunar();
return `${lunar.getMonthInChinese()}${lunar.getDayInChinese()}`;
} catch {
return `${month}${day}`;
}
}
return `${month}${day}`;
}

1
tmpclaude-2781-cwd Normal file
View File

@ -0,0 +1 @@
/e/qia/client

1
tmpclaude-3f83-cwd Normal file
View File

@ -0,0 +1 @@
/e/qia/client

1
tmpclaude-6e9e-cwd Normal file
View File

@ -0,0 +1 @@
/e/qia/client

1
tmpclaude-b174-cwd Normal file
View File

@ -0,0 +1 @@
/e/qia/client

1
tmpclaude-eae0-cwd Normal file
View File

@ -0,0 +1 @@
/e/qia/client