Fix snooze/due using container UTC instead of client local time

Docker container datetime.now() returns UTC, but all stored datetimes
are naive local time from the browser. Both /due and /snooze now
accept client_now from the frontend, ensuring snooze computes from
the user's actual current time, not the container's clock.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kyle 2026-02-24 03:12:31 +08:00
parent 42e0fff40c
commit daf2a4d5f1
4 changed files with 16 additions and 5 deletions

View File

@ -40,11 +40,12 @@ async def get_reminders(
@router.get("/due", response_model=List[ReminderResponse]) @router.get("/due", response_model=List[ReminderResponse])
async def get_due_reminders( async def get_due_reminders(
client_now: Optional[datetime] = Query(None),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session) current_user: Settings = Depends(get_current_session)
): ):
"""Get reminders that are currently due for alerting.""" """Get reminders that are currently due for alerting."""
now = datetime.now() now = client_now or datetime.now()
query = select(Reminder).where( query = select(Reminder).where(
and_( and_(
Reminder.remind_at <= now, Reminder.remind_at <= now,
@ -82,7 +83,8 @@ async def snooze_reminder(
if reminder.is_dismissed or not reminder.is_active: if reminder.is_dismissed or not reminder.is_active:
raise HTTPException(status_code=409, detail="Cannot snooze a dismissed or inactive reminder") raise HTTPException(status_code=409, detail="Cannot snooze a dismissed or inactive reminder")
reminder.snoozed_until = datetime.now() + timedelta(minutes=body.minutes) base_time = body.client_now or datetime.now()
reminder.snoozed_until = base_time + timedelta(minutes=body.minutes)
await db.commit() await db.commit()
await db.refresh(reminder) await db.refresh(reminder)

View File

@ -22,6 +22,7 @@ class ReminderUpdate(BaseModel):
class ReminderSnooze(BaseModel): class ReminderSnooze(BaseModel):
minutes: Literal[5, 10, 15] minutes: Literal[5, 10, 15]
client_now: Optional[datetime] = None
@field_validator('minutes', mode='before') @field_validator('minutes', mode='before')
@classmethod @classmethod

View File

@ -4,7 +4,7 @@ import { useLocation } from 'react-router-dom';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Bell } from 'lucide-react'; import { Bell } from 'lucide-react';
import api from '@/lib/api'; import api from '@/lib/api';
import { getRelativeTime } from '@/lib/date-utils'; import { getRelativeTime, toLocalDatetime } from '@/lib/date-utils';
import SnoozeDropdown from '@/components/reminders/SnoozeDropdown'; import SnoozeDropdown from '@/components/reminders/SnoozeDropdown';
import type { Reminder } from '@/types'; import type { Reminder } from '@/types';
@ -36,7 +36,9 @@ export function AlertsProvider({ children }: { children: ReactNode }) {
const { data: alerts = [] } = useQuery<Reminder[]>({ const { data: alerts = [] } = useQuery<Reminder[]>({
queryKey: ['reminders', 'due'], queryKey: ['reminders', 'due'],
queryFn: async () => { queryFn: async () => {
const { data } = await api.get<Reminder[]>('/reminders/due'); const { data } = await api.get<Reminder[]>('/reminders/due', {
params: { client_now: toLocalDatetime() },
});
return data; return data;
}, },
refetchInterval: 30_000, refetchInterval: 30_000,
@ -66,7 +68,7 @@ export function AlertsProvider({ children }: { children: ReactNode }) {
const snoozeMutation = useMutation({ const snoozeMutation = useMutation({
mutationFn: ({ id, minutes }: { id: number; minutes: 5 | 10 | 15 }) => mutationFn: ({ id, minutes }: { id: number; minutes: 5 | 10 | 15 }) =>
api.patch(`/reminders/${id}/snooze`, { minutes }), api.patch(`/reminders/${id}/snooze`, { minutes, client_now: toLocalDatetime() }),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['reminders'] }); queryClient.invalidateQueries({ queryKey: ['reminders'] });
queryClient.invalidateQueries({ queryKey: ['dashboard'] }); queryClient.invalidateQueries({ queryKey: ['dashboard'] });

View File

@ -1,3 +1,9 @@
/** Format a Date as a naive local datetime string (no Z suffix). */
export function toLocalDatetime(d: Date = new Date()): string {
const pad = (n: number) => n.toString().padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
export function getRelativeTime(dateStr: string): string { export function getRelativeTime(dateStr: string): string {
const date = new Date(dateStr); const date = new Date(dateStr);
const now = new Date(); const now = new Date();