Compare commits
No commits in common. "e1e546c50c2262a054ba0c46efdd4ae2ec4a256b" and "17f331477f2b99f1c2d5c1391a7ba934d844fe31" have entirely different histories.
e1e546c50c
...
17f331477f
@ -31,19 +31,6 @@
|
|||||||
- 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.
|
||||||
|
|||||||
@ -1,33 +0,0 @@
|
|||||||
"""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"],
|
|
||||||
)
|
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { useCallback } from 'react';
|
import { useState, useRef, useEffect } 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,7 +7,6 @@ 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;
|
||||||
@ -24,6 +23,9 @@ 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);
|
||||||
@ -67,8 +69,16 @@ export default function ReminderItem({ reminder, onEdit }: ReminderItemProps) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const executeDelete = useCallback(() => deleteMutation.mutate(), [deleteMutation]);
|
const handleDelete = () => {
|
||||||
const { confirming: confirmingDelete, handleClick: handleDelete } = useConfirmAction(executeDelete);
|
if (!confirmingDelete) {
|
||||||
|
setConfirmingDelete(true);
|
||||||
|
deleteTimerRef.current = setTimeout(() => setConfirmingDelete(false), 4000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearTimeout(deleteTimerRef.current);
|
||||||
|
deleteMutation.mutate();
|
||||||
|
setConfirmingDelete(false);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useCallback } from 'react';
|
import { useState, useRef, useEffect } 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,7 +8,6 @@ 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;
|
||||||
@ -32,6 +31,9 @@ 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 () => {
|
||||||
@ -73,8 +75,16 @@ export default function TodoItem({ todo, onEdit }: TodoItemProps) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const executeDelete = useCallback(() => deleteMutation.mutate(), [deleteMutation]);
|
const handleDelete = () => {
|
||||||
const { confirming: confirmingDelete, handleClick: handleDelete } = useConfirmAction(executeDelete);
|
if (!confirmingDelete) {
|
||||||
|
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;
|
||||||
|
|||||||
@ -83,33 +83,19 @@ 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, updateSummaryToast]);
|
}, [dismissMutation]);
|
||||||
|
|
||||||
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, updateSummaryToast]);
|
}, [snoozeMutation]);
|
||||||
|
|
||||||
// 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);
|
||||||
@ -186,29 +172,30 @@ export function AlertsProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unified toast management — single effect handles both route changes and new alerts
|
// Handle route changes
|
||||||
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) {
|
||||||
// On dashboard — dismiss all toasts, banner takes over
|
// Moving TO 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();
|
||||||
return;
|
} else if (wasOnDashboard && !nowOnDashboard) {
|
||||||
}
|
// Moving AWAY from dashboard — fire toasts for current alerts
|
||||||
|
|
||||||
// Transitioning away from dashboard — reset fired set so all current alerts get toasts
|
|
||||||
if (wasOnDashboard && !nowOnDashboard) {
|
|
||||||
firedRef.current.clear();
|
firedRef.current.clear();
|
||||||
}
|
|
||||||
|
|
||||||
// On non-dashboard page — fire toasts for any unfired alerts
|
|
||||||
fireToasts(alerts);
|
fireToasts(alerts);
|
||||||
|
}
|
||||||
}, [location.pathname, isDashboard, alerts]);
|
}, [location.pathname, isDashboard, alerts]);
|
||||||
|
|
||||||
|
// Fire toasts for new alerts on non-dashboard pages
|
||||||
|
useEffect(() => {
|
||||||
|
if (isDashboard) return;
|
||||||
|
fireToasts(alerts);
|
||||||
|
}, [alerts, isDashboard]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AlertsContext.Provider value={{ alerts, dismiss: handleDismiss, snooze: handleSnooze }}>
|
<AlertsContext.Provider value={{ alerts, dismiss: handleDismiss, snooze: handleSnooze }}>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@ -1,26 +0,0 @@
|
|||||||
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 };
|
|
||||||
}
|
|
||||||
Loading…
x
Reference in New Issue
Block a user