Compare commits

...

2 Commits

Author SHA1 Message Date
e1e546c50c Add future roadmap and key features to CLAUDE.md
Document multi-user planning intent, reminder alerts pattern,
useConfirmAction hook, and naive datetime contract.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 04:01:59 +08:00
8aa662c096 Consolidate toast effects, extract useConfirmAction, extend index
- W3: Merge route-change and new-alert effects into single unified effect
- W6: Migration 018 extends due_lookup index with snoozed_until column
- S1: Extract useConfirmAction hook from TodoItem/ReminderItem
- S7: Update summary toast count on dismiss/snooze instead of dismissing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 04:01:50 +08:00
6 changed files with 108 additions and 43 deletions

View File

@ -31,6 +31,19 @@
- When required: For backend work invoke the 'backend-engineer' subagent, for work on the front end, invoke the 'frontend-engineer' subagent. To review work use the 'senior-code-reviewer' subagent and for any research use the 'research-analyst' subagent. - When required: For backend work invoke the 'backend-engineer' subagent, for work on the front end, invoke the 'frontend-engineer' subagent. To review work use the 'senior-code-reviewer' subagent and for any research use the 'research-analyst' subagent.
- For any frontend UI related work you MUST use the frontend design skill AND reference the [stylesheet](.claude/context/stylesheet.md) to ensure visual consistency. The stylesheet defines all colors, typography, spacing, component patterns, and design principles for UMBRA. Do not invent new patterns. - For any frontend UI related work you MUST use the frontend design skill AND reference the [stylesheet](.claude/context/stylesheet.md) to ensure visual consistency. The stylesheet defines all colors, typography, spacing, component patterns, and design principles for UMBRA. Do not invent new patterns.
## Future Roadmap
After the UI refresh is complete, the next major phases are:
- **Multi-user authentication** — replace single PIN auth with per-user accounts
- **Backend restructure** — add user_id foreign keys to all models, scope all queries per-user
- **Always build for scale.** Even though UMBRA is currently single-user, design features, indexes, validations, and state machines with multi-user in mind. Cutting corners now means rework later.
## Key Features & Patterns
- **Reminder alerts**: Real-time polling (30s) via `AlertsProvider` context. Dashboard shows `AlertBanner`, other pages get Sonner toasts (max 3 + summary). Snooze/dismiss with `client_now` for Docker UTC offset.
- **Two-click delete**: `useConfirmAction` hook in `hooks/useConfirmAction.ts` — first click shows "Sure?", auto-resets after 4s, second click executes. Used by TodoItem and ReminderItem.
- **Naive datetime contract**: All datetimes are naive (no timezone). Frontend sends `toLocalDatetime()` from `lib/date-utils.ts` when the backend needs "now". Docker container runs UTC; `client_now` bridges the gap.
## Known Issues ## Known Issues
- **Git push auth flake.** The first `git push` to the Gitea remote will fail with an authentication error. Simply retry the same push command — the second attempt succeeds. - **Git push auth flake.** The first `git push` to the Gitea remote will fail with an authentication error. Simply retry the same push command — the second attempt succeeds.

View File

@ -0,0 +1,33 @@
"""Extend due lookup index to include snoozed_until
Revision ID: 018
Revises: 017
Create Date: 2026-02-24
"""
from alembic import op
# revision identifiers, used by Alembic.
revision = "018"
down_revision = "017"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.drop_index("ix_reminders_due_lookup", table_name="reminders")
op.create_index(
"ix_reminders_due_lookup",
"reminders",
["is_active", "is_dismissed", "remind_at", "snoozed_until"],
)
def downgrade() -> None:
op.drop_index("ix_reminders_due_lookup", table_name="reminders")
op.create_index(
"ix_reminders_due_lookup",
"reminders",
["is_active", "is_dismissed", "remind_at"],
)

View File

@ -1,4 +1,4 @@
import { useState, useRef, useEffect } from 'react'; import { useCallback } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Bell, BellOff, Trash2, Pencil } from 'lucide-react'; import { Bell, BellOff, Trash2, Pencil } from 'lucide-react';
@ -7,6 +7,7 @@ import api from '@/lib/api';
import type { Reminder } from '@/types'; import type { Reminder } from '@/types';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useConfirmAction } from '@/hooks/useConfirmAction';
interface ReminderItemProps { interface ReminderItemProps {
reminder: Reminder; reminder: Reminder;
@ -23,9 +24,6 @@ const QUERY_KEYS = [['reminders'], ['dashboard'], ['upcoming']] as const;
export default function ReminderItem({ reminder, onEdit }: ReminderItemProps) { export default function ReminderItem({ reminder, onEdit }: ReminderItemProps) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [confirmingDelete, setConfirmingDelete] = useState(false);
const deleteTimerRef = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => () => clearTimeout(deleteTimerRef.current), []);
const remindDate = reminder.remind_at ? parseISO(reminder.remind_at) : null; const remindDate = reminder.remind_at ? parseISO(reminder.remind_at) : null;
const isOverdue = !reminder.is_dismissed && remindDate && isPast(remindDate) && !isToday(remindDate); const isOverdue = !reminder.is_dismissed && remindDate && isPast(remindDate) && !isToday(remindDate);
@ -69,16 +67,8 @@ export default function ReminderItem({ reminder, onEdit }: ReminderItemProps) {
}, },
}); });
const handleDelete = () => { const executeDelete = useCallback(() => deleteMutation.mutate(), [deleteMutation]);
if (!confirmingDelete) { const { confirming: confirmingDelete, handleClick: handleDelete } = useConfirmAction(executeDelete);
setConfirmingDelete(true);
deleteTimerRef.current = setTimeout(() => setConfirmingDelete(false), 4000);
return;
}
clearTimeout(deleteTimerRef.current);
deleteMutation.mutate();
setConfirmingDelete(false);
};
return ( return (
<div <div

View File

@ -1,4 +1,4 @@
import { useState, useRef, useEffect } from 'react'; import { useCallback } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Trash2, Pencil, Calendar, Clock, AlertCircle, RefreshCw } from 'lucide-react'; import { Trash2, Pencil, Calendar, Clock, AlertCircle, RefreshCw } from 'lucide-react';
@ -8,6 +8,7 @@ import type { Todo } from '@/types';
import { cn, isTodoOverdue } from '@/lib/utils'; import { cn, isTodoOverdue } from '@/lib/utils';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useConfirmAction } from '@/hooks/useConfirmAction';
interface TodoItemProps { interface TodoItemProps {
todo: Todo; todo: Todo;
@ -31,9 +32,6 @@ const QUERY_KEYS = [['todos'], ['dashboard'], ['upcoming']] as const;
export default function TodoItem({ todo, onEdit }: TodoItemProps) { export default function TodoItem({ todo, onEdit }: TodoItemProps) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [confirmingDelete, setConfirmingDelete] = useState(false);
const deleteTimerRef = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => () => clearTimeout(deleteTimerRef.current), []);
const toggleMutation = useMutation({ const toggleMutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
@ -75,16 +73,8 @@ export default function TodoItem({ todo, onEdit }: TodoItemProps) {
}, },
}); });
const handleDelete = () => { const executeDelete = useCallback(() => deleteMutation.mutate(), [deleteMutation]);
if (!confirmingDelete) { const { confirming: confirmingDelete, handleClick: handleDelete } = useConfirmAction(executeDelete);
setConfirmingDelete(true);
deleteTimerRef.current = setTimeout(() => setConfirmingDelete(false), 4000);
return;
}
clearTimeout(deleteTimerRef.current);
deleteMutation.mutate();
setConfirmingDelete(false);
};
const dueDate = todo.due_date ? parseISO(todo.due_date) : null; const dueDate = todo.due_date ? parseISO(todo.due_date) : null;
const isDueToday = dueDate ? isToday(dueDate) : false; const isDueToday = dueDate ? isToday(dueDate) : false;

View File

@ -83,19 +83,33 @@ export function AlertsProvider({ children }: { children: ReactNode }) {
}, },
}); });
const updateSummaryToast = useCallback(() => {
// Count alerts that are still fired but don't have individual toasts (overflow)
const totalFired = firedRef.current.size;
const overflow = totalFired - MAX_TOASTS;
if (overflow > 0) {
toast.custom(
(t) => renderSummaryToast(t, overflow),
{ id: 'reminder-summary', duration: Infinity }
);
} else {
toast.dismiss('reminder-summary');
}
}, []);
const handleDismiss = useCallback((id: number) => { const handleDismiss = useCallback((id: number) => {
toast.dismiss(`reminder-${id}`); toast.dismiss(`reminder-${id}`);
toast.dismiss('reminder-summary');
firedRef.current.delete(id); firedRef.current.delete(id);
updateSummaryToast();
dismissMutation.mutate(id); dismissMutation.mutate(id);
}, [dismissMutation]); }, [dismissMutation, updateSummaryToast]);
const handleSnooze = useCallback((id: number, minutes: 5 | 10 | 15) => { const handleSnooze = useCallback((id: number, minutes: 5 | 10 | 15) => {
toast.dismiss(`reminder-${id}`); toast.dismiss(`reminder-${id}`);
toast.dismiss('reminder-summary');
firedRef.current.delete(id); firedRef.current.delete(id);
updateSummaryToast();
snoozeMutation.mutate({ id, minutes }); snoozeMutation.mutate({ id, minutes });
}, [snoozeMutation]); }, [snoozeMutation, updateSummaryToast]);
// Store latest callbacks in refs so toast closures always call current versions // Store latest callbacks in refs so toast closures always call current versions
const dismissRef = useRef(handleDismiss); const dismissRef = useRef(handleDismiss);
@ -172,29 +186,28 @@ export function AlertsProvider({ children }: { children: ReactNode }) {
} }
} }
// Handle route changes // Unified toast management — single effect handles both route changes and new alerts
useEffect(() => { useEffect(() => {
const wasOnDashboard = prevPathnameRef.current === '/' || prevPathnameRef.current === '/dashboard'; const wasOnDashboard = prevPathnameRef.current === '/' || prevPathnameRef.current === '/dashboard';
const nowOnDashboard = isDashboard; const nowOnDashboard = isDashboard;
prevPathnameRef.current = location.pathname; prevPathnameRef.current = location.pathname;
if (nowOnDashboard) { if (nowOnDashboard) {
// Moving TO dashboard — dismiss all toasts, banner takes over // On dashboard — dismiss all toasts, banner takes over
alerts.forEach((a) => toast.dismiss(`reminder-${a.id}`)); alerts.forEach((a) => toast.dismiss(`reminder-${a.id}`));
toast.dismiss('reminder-summary'); toast.dismiss('reminder-summary');
firedRef.current.clear(); firedRef.current.clear();
} else if (wasOnDashboard && !nowOnDashboard) { return;
// Moving AWAY from dashboard — fire toasts for current alerts
firedRef.current.clear();
fireToasts(alerts);
} }
}, [location.pathname, isDashboard, alerts]);
// Fire toasts for new alerts on non-dashboard pages // Transitioning away from dashboard — reset fired set so all current alerts get toasts
useEffect(() => { if (wasOnDashboard && !nowOnDashboard) {
if (isDashboard) return; firedRef.current.clear();
}
// On non-dashboard page — fire toasts for any unfired alerts
fireToasts(alerts); fireToasts(alerts);
}, [alerts, isDashboard]); }, [location.pathname, isDashboard, alerts]);
return ( return (
<AlertsContext.Provider value={{ alerts, dismiss: handleDismiss, snooze: handleSnooze }}> <AlertsContext.Provider value={{ alerts, dismiss: handleDismiss, snooze: handleSnooze }}>

View File

@ -0,0 +1,26 @@
import { useState, useRef, useEffect, useCallback } from 'react';
/**
* Two-click confirmation pattern: first click shows "Sure?", second executes.
* Auto-resets after `timeoutMs` if the user doesn't confirm.
* Cleans up the timer on unmount.
*/
export function useConfirmAction(action: () => void, timeoutMs = 4000) {
const [confirming, setConfirming] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => () => clearTimeout(timerRef.current), []);
const handleClick = useCallback(() => {
if (!confirming) {
setConfirming(true);
timerRef.current = setTimeout(() => setConfirming(false), timeoutMs);
return;
}
clearTimeout(timerRef.current);
action();
setConfirming(false);
}, [confirming, action, timeoutMs]);
return { confirming, handleClick };
}