fix: 修复提醒时间相关的 UTC 时区和继承问题

主要修复:
1. 时间存储格式统一使用 UTC 时区
   - HomePage.tsx: 修复 formDate 处理,使用 new Date() 自动转换
   - 解决用户设置"14:00"保存后显示"22:00"问题

2. 重复计算函数统一使用 UTC
   - calculateNextDueDate: 使用 getUTC*() 和 Date.UTC()
   - calculateReminderTimes: 使用 UTC 时间计算提醒点
   - getReminderValueFromTimes: 使用 UTC 时间戳反推选项

3. 修复重复提醒创建时继承 reminder_times
   - createNextRecurringEventData: 根据新日期重新计算 reminder_times
   - 修改接口类型支持 reminder_times 为 null
   - 解决原提醒"提前15分钟"新提醒变"准时"问题

影响:
- 提醒时间显示正确(无时区偏差)
- 跨天/跨月提醒计算正确
- 重复提醒自动创建时正确继承提醒设置

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
ddshi 2026-02-10 11:07:38 +08:00
parent 79ef45b4ad
commit b39bc5c8bc
4 changed files with 389 additions and 78 deletions

View File

@ -27,7 +27,7 @@ import { ReminderList } from '../components/reminder/ReminderList';
import { NoteEditor } from '../components/note/NoteEditor'; import { NoteEditor } from '../components/note/NoteEditor';
import { FloatingAIChat } from '../components/ai/FloatingAIChat'; import { FloatingAIChat } from '../components/ai/FloatingAIChat';
import type { Event, EventType, RepeatType, PriorityType } from '../types'; import type { Event, EventType, RepeatType, PriorityType } from '../types';
import { calculateNextReminderDate } from '../utils/repeatCalculator'; import { calculateNextReminderDate, getReminderOptions, getDefaultReminderValue, calculateReminderTimes, getReminderValueFromTimes, formatReminderTimeDisplay } from '../utils/repeatCalculator';
export function HomePage() { export function HomePage() {
const navigate = useNavigate(); const navigate = useNavigate();
@ -54,6 +54,7 @@ export function HomePage() {
const [formRepeatType, setFormRepeatType] = useState<RepeatType>('none'); const [formRepeatType, setFormRepeatType] = useState<RepeatType>('none');
const [formIsHoliday, setFormIsHoliday] = useState(false); const [formIsHoliday, setFormIsHoliday] = useState(false);
const [formPriority, setFormPriority] = useState<PriorityType>('none'); const [formPriority, setFormPriority] = useState<PriorityType>('none');
const [formReminderValue, setFormReminderValue] = useState('0'); // 提醒选项值
// Initialize auth and data on mount // Initialize auth and data on mount
useEffect(() => { useEffect(() => {
@ -81,7 +82,10 @@ export function HomePage() {
const eventDate = new Date(event.date); const eventDate = new Date(event.date);
const hours = eventDate.getHours(); const hours = eventDate.getHours();
const minutes = eventDate.getMinutes(); const minutes = eventDate.getMinutes();
setFormTime(hours === 0 && minutes === 0 ? '' : `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`); const hasTime = hours !== 0 || minutes !== 0;
setFormTime(hasTime ? `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}` : '');
// 从 reminder_times 反推用户选择的提醒选项值
setFormReminderValue(getReminderValueFromTimes(event.reminder_times, event.date, hasTime));
open(); open();
}; };
@ -97,6 +101,7 @@ export function HomePage() {
setFormRepeatType('none'); setFormRepeatType('none');
setFormIsHoliday(type === 'anniversary'); setFormIsHoliday(type === 'anniversary');
setFormPriority('none'); setFormPriority('none');
setFormReminderValue('0');
open(); open();
}; };
@ -112,13 +117,15 @@ export function HomePage() {
const day = String(dateObj.getDate()).padStart(2, '0'); const day = String(dateObj.getDate()).padStart(2, '0');
if (formTime) { if (formTime) {
// 有时间:构建本地时间格式 // 有时间:构建 UTC 时间格式(用户选择的是本地时间,需要转换为 UTC 存储)
const hours = String(parseInt(formTime.split(':')[0])).padStart(2, '0'); const hours = parseInt(formTime.split(':')[0]);
const minutes = String(parseInt(formTime.split(':')[1])).padStart(2, '0'); const minutes = parseInt(formTime.split(':')[1]);
dateStr = `${year}-${month}-${day}T${hours}:${minutes}:00`; // 先用本地时间创建日期对象,然后获取 UTC 时间
const localDate = new Date(year, dateObj.getMonth(), day, hours, minutes);
dateStr = localDate.toISOString();
} else { } else {
// 无时间:只保存日期部分 // 无时间:使用 UTC 日期格式
dateStr = `${year}-${month}-${day}T00:00:00`; dateStr = `${year}-${month}-${day}T00:00:00.000Z`;
} }
// 构建事件数据,确保不包含 undefined 值 // 构建事件数据,确保不包含 undefined 值
@ -139,6 +146,12 @@ export function HomePage() {
eventData.content = formContent; eventData.content = formContent;
} }
// 计算提醒时间点(仅对提醒事项)
if (formType === 'reminder') {
const hasTime = !!formTime;
eventData.reminder_times = calculateReminderTimes(dateStr, hasTime, formReminderValue);
}
if (isEdit && selectedEvent) { if (isEdit && selectedEvent) {
await updateEventById(selectedEvent.id, eventData); await updateEventById(selectedEvent.id, eventData);
} else { } else {
@ -273,6 +286,7 @@ export function HomePage() {
setFormRepeatType('none'); setFormRepeatType('none');
setFormIsHoliday(false); setFormIsHoliday(false);
setFormPriority('none'); setFormPriority('none');
setFormReminderValue('0');
setSelectedEvent(null); setSelectedEvent(null);
setIsEdit(false); setIsEdit(false);
}; };
@ -415,11 +429,13 @@ export function HomePage() {
]} ]}
value={formType} value={formType}
onChange={(value) => value && setFormType(value as EventType)} onChange={(value) => value && setFormType(value as EventType)}
disabled={isEdit} readOnly={isEdit}
rightSectionPointerEvents={isEdit ? 'none' : 'auto'}
styles={{ styles={{
input: { input: {
borderRadius: 2, borderRadius: 2,
background: '#faf9f7', background: '#faf9f7',
pointerEvents: isEdit ? 'none' : 'auto',
}, },
}} }}
/> />
@ -427,14 +443,13 @@ export function HomePage() {
{/* Title */} {/* Title */}
<TextInput <TextInput
label={ label={
<Text size="xs" c="#666" style={{ letterSpacing: '0.05em', marginBottom: 4 }}> <Text size="xs" c="#666" style={{ letterSpacing: '0.05em' }}>
<Box component="span" c="red">*</Box>
</Text> </Text>
} }
placeholder="输入标题" placeholder="输入标题"
value={formTitle} value={formTitle}
onChange={(e) => setFormTitle(e.target.value)} onChange={(e) => setFormTitle(e.target.value)}
required
styles={{ styles={{
input: { input: {
borderRadius: 2, borderRadius: 2,
@ -491,11 +506,6 @@ export function HomePage() {
position="bottom" position="bottom"
withArrow withArrow
shadow="md" shadow="md"
onOpenChange={(opened) => {
if (!opened && formDate) {
// Keep the date
}
}}
> >
<Popover.Target> <Popover.Target>
<TextInput <TextInput
@ -598,29 +608,64 @@ export function HomePage() {
/> />
)} )}
{/* Repeat type */} {/* Repeat and Reminder on same row */}
<Select <Group gap={12} grow>
label={ {/* Repeat type */}
<Text size="xs" c="#666" style={{ letterSpacing: '0.05em', marginBottom: 4 }}> <Select
label={
</Text> <Text size="xs" c="#666" style={{ letterSpacing: '0.05em' }}>
}
data={[ </Text>
{ value: 'none', label: '不重复' }, }
{ value: 'daily', label: '每天' }, data={[
{ value: 'weekly', label: '每周' }, { value: 'none', label: '不重复' },
{ value: 'monthly', label: '每月' }, { value: 'daily', label: '每天' },
{ value: 'yearly', label: '每年' }, { value: 'weekly', label: '每周' },
]} { value: 'monthly', label: '每月' },
value={formRepeatType} { value: 'yearly', label: '每年' },
onChange={(value) => value && setFormRepeatType(value as RepeatType)} ]}
styles={{ value={formRepeatType}
input: { onChange={(value) => value && setFormRepeatType(value as RepeatType)}
borderRadius: 2, styles={{
background: '#faf9f7', input: {
}, borderRadius: 2,
}} background: '#faf9f7',
/> },
}}
/>
{/* Reminder options (only for reminders) */}
{formType === 'reminder' && (
<Select
label={
<Text size="xs" c="#666" style={{ letterSpacing: '0.05em' }}>
<Box component="span" size="xs" c="dimmed" ml={4}>
{formDate ? formatReminderTimeDisplay(
calculateReminderTimes(
`${formDate.getFullYear()}-${String(formDate.getMonth() + 1).padStart(2, '0')}-${String(formDate.getDate()).padStart(2, '0')}T${formTime ? formTime : '00:00'}:00`,
!!formTime,
formReminderValue
)
) : ''}
</Box>
</Text>
}
data={getReminderOptions(!!formTime).map(opt => ({
value: opt.value,
label: opt.label,
}))}
value={formReminderValue}
onChange={(value) => value && setFormReminderValue(value)}
styles={{
input: {
borderRadius: 2,
background: '#faf9f7',
},
}}
/>
)}
</Group>
{/* Color (only for reminders) */} {/* Color (only for reminders) */}
{formType === 'reminder' && ( {formType === 'reminder' && (

View File

@ -2,7 +2,7 @@ import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware'; import { persist, createJSONStorage } from 'zustand/middleware';
import type { User, Event, Note, AIConversation, EventType, RepeatType } from '../types'; import type { User, Event, Note, AIConversation, EventType, RepeatType } from '../types';
import { api } from '../services/api'; import { api } from '../services/api';
import { calculateNextReminderDate, findNextValidReminderDate, isDuplicateReminder } from '../utils/repeatCalculator'; import { calculateNextReminderDate, findNextValidReminderDate, isDuplicateReminder, createNextRecurringEventData } from '../utils/repeatCalculator';
// 应用设置类型 // 应用设置类型
interface AppSettings { interface AppSettings {
@ -171,29 +171,11 @@ export const useAppStore = create<AppState>()(
); );
if (!exists) { if (!exists) {
// 计算新提醒的 next_reminder_date // 使用统一的重复事件创建函数,自动继承所有字段(包括 priority 和 reminder_times
const newNextReminderDate = calculateNextReminderDate( const newEventData = createNextRecurringEventData(
nextValidDate, currentEvent,
currentEvent.repeat_type as RepeatType, nextValidDate
currentEvent.repeat_interval
); );
// 创建下一周期的新事件 - 确保所有字段都不包含 undefined
const newEventData: Record<string, any> = {
type: currentEvent.type,
title: currentEvent.title,
date: nextValidDate,
is_lunar: currentEvent.is_lunar ?? false,
repeat_type: currentEvent.repeat_type,
// 确保 repeat_interval 为 null 而不是 undefined
repeat_interval: currentEvent.repeat_interval ?? null,
next_reminder_date: newNextReminderDate,
is_holiday: currentEvent.is_holiday ?? false,
};
// 只有当 content 有值时才包含
if (currentEvent.content) {
newEventData.content = currentEvent.content;
}
const newEvent = await api.events.create(newEventData); const newEvent = await api.events.create(newEventData);
// 添加新事件到本地状态 // 添加新事件到本地状态

View File

@ -31,6 +31,7 @@ export interface Event {
is_holiday?: boolean; // Only for anniversaries is_holiday?: boolean; // Only for anniversaries
is_completed?: boolean; // Only for reminders is_completed?: boolean; // Only for reminders
priority?: PriorityType; // Priority level for reminders priority?: PriorityType; // Priority level for reminders
reminder_times?: string[]; // 提醒时间点列表ISO 时间戳数组)
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }

View File

@ -1,11 +1,11 @@
import type { Event, RepeatType } from '../types'; import type { Event, RepeatType, PriorityType, EventType } from '../types';
/** /**
* *
* @param currentDate ISO * @param currentDate ISO
* @param repeatType * @param repeatType
* @param interval weekly 使1 * @param interval weekly 使1
* @returns ISO * @returns ISO UTC
*/ */
export function calculateNextDueDate( export function calculateNextDueDate(
currentDate: string, currentDate: string,
@ -13,36 +13,36 @@ export function calculateNextDueDate(
interval: number = 1 interval: number = 1
): string { ): string {
const date = new Date(currentDate); const date = new Date(currentDate);
const year = date.getFullYear(); const year = date.getUTCFullYear();
const month = date.getMonth(); const month = date.getUTCMonth();
const day = date.getDate(); const day = date.getUTCDate();
const hours = date.getHours(); const hours = date.getUTCHours();
const minutes = date.getMinutes(); const minutes = date.getUTCMinutes();
switch (repeatType) { switch (repeatType) {
case 'daily': case 'daily':
// 每天加1天 // 每天加1天
return new Date(year, month, day + 1, hours, minutes).toISOString(); return new Date(Date.UTC(year, month, day + 1, hours, minutes)).toISOString();
case 'weekly': case 'weekly':
// 每周加7天 * interval // 每周加7天 * interval
return new Date(year, month, day + 7 * interval, hours, minutes).toISOString(); return new Date(Date.UTC(year, month, day + 7 * interval, hours, minutes)).toISOString();
case 'monthly': case 'monthly':
// 每月:下月同日 // 每月:下月同日
const nextMonth = new Date(year, month + 1, day, hours, minutes); const nextMonth = new Date(Date.UTC(year, month + 1, day, hours, minutes));
// 处理月末日期如3月31日 -> 4月30日 // 处理月末日期如3月31日 -> 4月30日
if (nextMonth.getDate() !== day) { if (nextMonth.getUTCDate() !== day) {
return new Date(year, month + 1, 0, hours, minutes).toISOString(); return new Date(Date.UTC(year, month + 1, 0, hours, minutes)).toISOString();
} }
return nextMonth.toISOString(); return nextMonth.toISOString();
case 'yearly': case 'yearly':
// 每年:明年同日 // 每年:明年同日
const nextYearDate = new Date(year + 1, month, day, hours, minutes); const nextYearDate = new Date(Date.UTC(year + 1, month, day, hours, minutes));
// 处理闰年如2月29日 -> 2月28日 // 处理闰年如2月29日 -> 2月28日
if (nextYearDate.getMonth() !== month) { if (nextYearDate.getUTCMonth() !== month) {
return new Date(year + 1, month, 0, hours, minutes).toISOString(); return new Date(Date.UTC(year + 1, month, 0, hours, minutes)).toISOString();
} }
return nextYearDate.toISOString(); return nextYearDate.toISOString();
@ -231,3 +231,286 @@ export function formatDateDisplay(dateStr: string, showTime: boolean = true): st
day: 'numeric', day: 'numeric',
}); });
} }
// ==========================================
// 提醒时间计算相关类型和函数
// ==========================================
/**
*
*/
export type ReminderOptionNoTime = {
type: 'no_time';
label: string; // 显示文本,如 "当日9:00"
value: string; // 值,如 "0" 表示当天
hours: number; // 提醒小时9
days: number; // 提前天数0表示当天
};
/**
*
*/
export type ReminderOptionWithTime = {
type: 'with_time';
label: string; // 显示文本,如 "准时"
value: string; // 值,如 "0" 表示准时
minutes: number; // 提前分钟数0表示准时
};
/**
*
*/
export const REMINDER_OPTIONS_NO_TIME: ReminderOptionNoTime[] = [
{ type: 'no_time', label: '当日9:00', value: '0', hours: 9, days: 0 },
{ type: 'no_time', label: '提前1天9:00', value: '1', hours: 9, days: 1 },
{ type: 'no_time', label: '提前2天9:00', value: '2', hours: 9, days: 2 },
{ type: 'no_time', label: '提前3天9:00', value: '3', hours: 9, days: 3 },
{ type: 'no_time', label: '提前1周9:00', value: '7', hours: 9, days: 7 },
];
/**
*
*/
export const REMINDER_OPTIONS_WITH_TIME: ReminderOptionWithTime[] = [
{ type: 'with_time', label: '准时', value: '0', minutes: 0 },
{ type: 'with_time', label: '提前5分钟', value: '5', minutes: 5 },
{ type: 'with_time', label: '提前15分钟', value: '15', minutes: 15 },
{ type: 'with_time', label: '提前1小时', value: '60', minutes: 60 },
{ type: 'with_time', label: '提前1天', value: '1440', minutes: 1440 },
];
/**
*
* @param eventDateStr ISO格式
* @param hasTime
* @param reminderValue
* @returns ISO格式字符串数组
*/
export function calculateReminderTimes(
eventDateStr: string,
hasTime: boolean,
reminderValue: string
): string[] {
const eventDate = new Date(eventDateStr);
const reminderTimes: string[] = [];
if (!hasTime) {
// 无时间:根据选项计算提前天数,使用 UTC 时间避免时区问题
const option = REMINDER_OPTIONS_NO_TIME.find(opt => opt.value === reminderValue);
if (option) {
const reminderDate = new Date(Date.UTC(
eventDate.getUTCFullYear(),
eventDate.getUTCMonth(),
eventDate.getUTCDate() - option.days,
option.hours, 0, 0, 0
));
reminderTimes.push(reminderDate.toISOString());
}
} else {
// 有时间:根据选项计算提前分钟数,使用 UTC 时间避免时区问题
const option = REMINDER_OPTIONS_WITH_TIME.find(opt => opt.value === reminderValue);
if (option) {
const reminderDate = new Date(Date.UTC(
eventDate.getUTCFullYear(),
eventDate.getUTCMonth(),
eventDate.getUTCDate(),
eventDate.getUTCHours(),
eventDate.getUTCMinutes() - option.minutes,
0, 0
));
reminderTimes.push(reminderDate.toISOString());
}
}
return reminderTimes;
}
/**
*
*/
export function getReminderOptions(hasTime: boolean) {
return hasTime ? REMINDER_OPTIONS_WITH_TIME : REMINDER_OPTIONS_NO_TIME;
}
/**
*
*/
export function getDefaultReminderValue(hasTime: boolean): string {
return hasTime ? '0' : '0'; // 都是 "0",表示当天/准时
}
/**
*
* @param reminderTimes
* @param eventDateStr
* @param hasTime
* @returns
*/
export function getReminderValueFromTimes(
reminderTimes: string[] | undefined,
eventDateStr: string,
hasTime: boolean
): string {
if (!reminderTimes || reminderTimes.length === 0) {
return getDefaultReminderValue(hasTime);
}
// 取第一个提醒时间点
const reminderTime = new Date(reminderTimes[0]);
const eventDate = new Date(eventDateStr);
if (!hasTime) {
// 无时间:使用 UTC 日期部分计算,避免时区问题
const reminderUTCDate = new Date(Date.UTC(
reminderTime.getUTCFullYear(),
reminderTime.getUTCMonth(),
reminderTime.getUTCDate()
));
const eventUTCDate = new Date(Date.UTC(
eventDate.getUTCFullYear(),
eventDate.getUTCMonth(),
eventDate.getUTCDate()
));
const reminderHours = reminderTime.getUTCHours();
const diffDays = Math.round((eventUTCDate.getTime() - reminderUTCDate.getTime()) / (1000 * 60 * 60 * 24));
// 查找匹配的选项
for (const option of REMINDER_OPTIONS_NO_TIME) {
if (option.hours === reminderHours && option.days === diffDays) {
return option.value;
}
}
// 默认返回 "0"
return '0';
} else {
// 有时间:计算总提前分钟数,包括跨天
// 使用 UTC 时间戳计算差值,确保兼容本地时间和 UTC 时间
// eventDateStr 可能是不同时区格式,统一使用 UTC 组件计算
const eventTime = Date.UTC(
eventDate.getUTCFullYear(),
eventDate.getUTCMonth(),
eventDate.getUTCDate(),
eventDate.getUTCHours(),
eventDate.getUTCMinutes(),
eventDate.getUTCSeconds()
);
const reminderTimeVal = reminderTime.getTime();
const diffMs = eventTime - reminderTimeVal;
const diffMinutes = Math.round(diffMs / (1000 * 60));
// 查找匹配的选项
for (const option of REMINDER_OPTIONS_WITH_TIME) {
if (option.minutes === diffMinutes) {
return option.value;
}
}
// 默认返回 "0"
return '0';
}
}
/**
*
* @param reminderTimes
* @returns "2月9日 09:00"
*/
export function formatReminderTimeDisplay(reminderTimes: string[] | undefined): string {
if (!reminderTimes || reminderTimes.length === 0) {
return '';
}
const reminderTime = new Date(reminderTimes[0]);
return reminderTime.toLocaleString('zh-CN', {
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
}
// ==========================================
// 统一的重复事件创建函数
// ==========================================
/**
*
*
*/
export interface CreateNextEventData {
type: EventType;
title: string;
date: string; // 下一次提醒日期
is_lunar: boolean;
repeat_type: RepeatType;
repeat_interval: number | null;
next_reminder_date: string | null;
is_holiday?: boolean;
content?: string;
priority?: PriorityType;
reminder_times?: string[] | null;
}
/**
*
* next_reminder_date
* reminder_times
*/
export function createNextRecurringEventData(
currentEvent: Event,
nextValidDate: string
): CreateNextEventData {
// 计算新的 next_reminder_date
const newNextReminderDate = calculateNextReminderDate(
nextValidDate,
currentEvent.repeat_type as RepeatType,
currentEvent.repeat_interval
);
// 根据原事件的 reminder_times 判断是有时间还是无时间
// 并重新计算基于新日期的 reminder_times
let newReminderTimes: string[] | null = null;
if (currentEvent.reminder_times && currentEvent.reminder_times.length > 0) {
// 判断原始事件是否有时间(日期中有 T 且包含有效时间)
const hasTime = currentEvent.date.includes('T') &&
!currentEvent.date.endsWith('T00:00:00.000Z');
// 从 reminder_times 反推提醒选项值
const reminderValue = getReminderValueFromTimes(
currentEvent.reminder_times,
currentEvent.date,
hasTime
);
// 根据新日期重新计算 reminder_times
newReminderTimes = calculateReminderTimes(
nextValidDate,
hasTime,
reminderValue
);
}
// 构建完整的事件数据
const eventData: CreateNextEventData = {
type: currentEvent.type,
title: currentEvent.title,
date: nextValidDate,
is_lunar: currentEvent.is_lunar ?? false,
repeat_type: currentEvent.repeat_type,
repeat_interval: currentEvent.repeat_interval ?? null,
next_reminder_date: newNextReminderDate,
is_holiday: currentEvent.is_holiday ?? false,
priority: currentEvent.priority,
// 重新计算的 reminder_times
reminder_times: newReminderTimes,
};
// 只有当 content 有值时才包含
if (currentEvent.content) {
eventData.content = currentEvent.content;
}
return eventData;
}