Merge security/pentest-remediation-2026-02-26: remediate F-01, F-02, F-06

This commit is contained in:
Kyle 2026-02-26 02:43:11 +08:00
commit 132226120b
28 changed files with 2784 additions and 536 deletions

View File

@ -16,5 +16,17 @@ SECRET_KEY=change-this-to-a-random-secret-key-in-production
# Timezone (applied to backend + db containers via env_file) # Timezone (applied to backend + db containers via env_file)
TZ=Australia/Perth TZ=Australia/Perth
# Session cookie security
# Set to true when serving over HTTPS. Required before any TLS deployment.
# COOKIE_SECURE=true
# Integrations # Integrations
OPENWEATHERMAP_API_KEY=your-openweathermap-api-key OPENWEATHERMAP_API_KEY=your-openweathermap-api-key
# Production security checklist (enable all before any non-internal deployment):
# 1. Set SECRET_KEY to output of: openssl rand -hex 32
# 2. Set POSTGRES_PASSWORD to a strong unique value
# 3. Set ENVIRONMENT=production (disables Swagger/ReDoc on backend:8000)
# 4. Set COOKIE_SECURE=true (requires TLS termination at nginx or upstream)
# 5. Add HSTS to nginx.conf: add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
# 6. Complete user_id migration (migration 026) before enabling multi-user accounts

View File

@ -111,8 +111,8 @@ A self-hosted personal life administration app with a dark-themed UI. Manage you
- **Non-root containers** — both backend (`appuser:1000`) and frontend (`nginx-unprivileged`) run as non-root - **Non-root containers** — both backend (`appuser:1000`) and frontend (`nginx-unprivileged`) run as non-root
- **No external backend port** — port 8000 is internal-only; all traffic flows through nginx - **No external backend port** — port 8000 is internal-only; all traffic flows through nginx
- **Server version suppression**`server_tokens off` (nginx) and `--no-server-header` (uvicorn) - **Server version suppression**`server_tokens off` (nginx) and `--no-server-header` (uvicorn)
- **Rate limiting** — nginx `limit_req_zone` (10 req/min, burst=5) on `/api/auth/login`, `/verify-password`, `/change-password`, `/totp-verify` - **Rate limiting** — nginx `limit_req_zone` (10 req/min) on `/api/auth/login` (burst=5), `/verify-password` (burst=5), `/change-password` (burst=5), `/totp-verify` (burst=5), `/setup` (burst=3)
- **Application-level rate limiting** — in-memory IP-based rate limit (5 attempts / 5 min) + DB-backed account lockout (10 failures = 30-min lock) - **DB-backed account lockout** — 10 failed attempts triggers 30-minute lock per account
- **Dotfile blocking**`/.env`, `/.git/config`, etc. return 404 (`.well-known` preserved for ACME) - **Dotfile blocking**`/.env`, `/.git/config`, etc. return 404 (`.well-known` preserved for ACME)
- **CSP headers** — Content-Security-Policy on all responses, scoped for Google Fonts - **CSP headers** — Content-Security-Policy on all responses, scoped for Google Fonts
- **CORS** — configurable origins with explicit method/header allowlists - **CORS** — configurable origins with explicit method/header allowlists

View File

@ -9,14 +9,12 @@ Session flow:
GET /status verify user exists + session valid GET /status verify user exists + session valid
Security layers: Security layers:
1. IP-based in-memory rate limit (5 attempts / 5 min) outer guard, username enumeration 1. Nginx limit_req_zone (real-IP, 10 req/min burst 5) outer guard on all auth endpoints
2. DB-backed account lockout (10 failures 30-min lock, HTTP 423) per-user guard 2. DB-backed account lockout (10 failures 30-min lock, HTTP 423) per-user guard
3. Session revocation stored in DB (survives container restarts) 3. Session revocation stored in DB (survives container restarts)
4. bcryptArgon2id transparent upgrade on first login with migrated hash 4. bcryptArgon2id transparent upgrade on first login with migrated hash
""" """
import uuid import uuid
import time
from collections import defaultdict
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Optional from typing import Optional
@ -40,41 +38,6 @@ from app.config import settings as app_settings
router = APIRouter() router = APIRouter()
# ---------------------------------------------------------------------------
# IP-based in-memory rate limit (retained as outer layer for all login attempts)
# ---------------------------------------------------------------------------
_failed_attempts: dict[str, list[float]] = defaultdict(list)
_MAX_IP_ATTEMPTS = 5
_IP_WINDOW_SECONDS = 300 # 5 minutes
_MAX_TRACKED_IPS = 10000 # cap to prevent unbounded memory growth
def _check_ip_rate_limit(ip: str) -> None:
"""Raise 429 if the IP has exceeded the failure window."""
now = time.time()
# Purge all stale entries if the dict is oversized (spray attack defense)
if len(_failed_attempts) > _MAX_TRACKED_IPS:
stale_ips = [k for k, v in _failed_attempts.items() if all(now - t >= _IP_WINDOW_SECONDS for t in v)]
for k in stale_ips:
del _failed_attempts[k]
# If still over cap after purge, clear everything (all entries are within window
# but we can't let memory grow unbounded — login will still hit account lockout)
if len(_failed_attempts) > _MAX_TRACKED_IPS:
_failed_attempts.clear()
_failed_attempts[ip] = [t for t in _failed_attempts[ip] if now - t < _IP_WINDOW_SECONDS]
if not _failed_attempts[ip]:
_failed_attempts.pop(ip, None)
elif len(_failed_attempts[ip]) >= _MAX_IP_ATTEMPTS:
raise HTTPException(
status_code=429,
detail="Too many failed login attempts. Try again in a few minutes.",
)
def _record_ip_failure(ip: str) -> None:
_failed_attempts[ip].append(time.time())
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Cookie helper # Cookie helper
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -267,14 +230,12 @@ async def login(
HTTP 429 IP rate limited HTTP 429 IP rate limited
""" """
client_ip = request.client.host if request.client else "unknown" client_ip = request.client.host if request.client else "unknown"
_check_ip_rate_limit(client_ip)
# Lookup user — do NOT differentiate "user not found" from "wrong password" # Lookup user — do NOT differentiate "user not found" from "wrong password"
result = await db.execute(select(User).where(User.username == data.username)) result = await db.execute(select(User).where(User.username == data.username))
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
if not user: if not user:
_record_ip_failure(client_ip)
raise HTTPException(status_code=401, detail="Invalid username or password") raise HTTPException(status_code=401, detail="Invalid username or password")
await _check_account_lockout(user) await _check_account_lockout(user)
@ -283,7 +244,6 @@ async def login(
valid, new_hash = verify_password_with_upgrade(data.password, user.password_hash) valid, new_hash = verify_password_with_upgrade(data.password, user.password_hash)
if not valid: if not valid:
_record_ip_failure(client_ip)
await _record_failed_login(db, user) await _record_failed_login(db, user)
raise HTTPException(status_code=401, detail="Invalid username or password") raise HTTPException(status_code=401, detail="Invalid username or password")
@ -291,8 +251,6 @@ async def login(
if new_hash: if new_hash:
user.password_hash = new_hash user.password_hash = new_hash
# Clear IP failures and update user state
_failed_attempts.pop(client_ip, None)
await _record_successful_login(db, user) await _record_successful_login(db, user)
# If TOTP is enabled, issue a short-lived MFA challenge token instead of a full session # If TOTP is enabled, issue a short-lived MFA challenge token instead of a full session
@ -376,7 +334,6 @@ async def auth_status(
@router.post("/verify-password") @router.post("/verify-password")
async def verify_password( async def verify_password(
data: VerifyPasswordRequest, data: VerifyPasswordRequest,
request: Request,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
@ -384,20 +341,15 @@ async def verify_password(
Verify the current user's password without changing anything. Verify the current user's password without changing anything.
Used by the frontend lock screen to re-authenticate without a full login. Used by the frontend lock screen to re-authenticate without a full login.
Also handles transparent bcryptArgon2id upgrade. Also handles transparent bcryptArgon2id upgrade.
Shares the same rate-limit and lockout guards as /login. Shares the same lockout guards as /login. Nginx limit_req_zone handles IP rate limiting.
""" """
client_ip = request.client.host if request.client else "unknown"
_check_ip_rate_limit(client_ip)
await _check_account_lockout(current_user) await _check_account_lockout(current_user)
valid, new_hash = verify_password_with_upgrade(data.password, current_user.password_hash) valid, new_hash = verify_password_with_upgrade(data.password, current_user.password_hash)
if not valid: if not valid:
_record_ip_failure(client_ip)
await _record_failed_login(db, current_user) await _record_failed_login(db, current_user)
raise HTTPException(status_code=401, detail="Invalid password") raise HTTPException(status_code=401, detail="Invalid password")
_failed_attempts.pop(client_ip, None)
# Persist upgraded hash if migration happened # Persist upgraded hash if migration happened
if new_hash: if new_hash:
current_user.password_hash = new_hash current_user.password_hash = new_hash
@ -409,22 +361,17 @@ async def verify_password(
@router.post("/change-password") @router.post("/change-password")
async def change_password( async def change_password(
data: ChangePasswordRequest, data: ChangePasswordRequest,
request: Request,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
"""Change the current user's password. Requires old password verification.""" """Change the current user's password. Requires old password verification."""
client_ip = request.client.host if request.client else "unknown"
_check_ip_rate_limit(client_ip)
await _check_account_lockout(current_user) await _check_account_lockout(current_user)
valid, _ = verify_password_with_upgrade(data.old_password, current_user.password_hash) valid, _ = verify_password_with_upgrade(data.old_password, current_user.password_hash)
if not valid: if not valid:
_record_ip_failure(client_ip)
await _record_failed_login(db, current_user) await _record_failed_login(db, current_user)
raise HTTPException(status_code=401, detail="Invalid current password") raise HTTPException(status_code=401, detail="Invalid current password")
_failed_attempts.pop(client_ip, None)
current_user.password_hash = hash_password(data.new_password) current_user.password_hash = hash_password(data.new_password)
await db.commit() await db.commit()

View File

@ -17,13 +17,18 @@ logger = logging.getLogger(__name__)
NTFY_TIMEOUT = 8.0 # seconds — hard cap to prevent hung requests NTFY_TIMEOUT = 8.0 # seconds — hard cap to prevent hung requests
# Loopback + link-local only. Private IPs (RFC 1918) are intentionally allowed # Loopback, link-local, and all RFC 1918 private ranges are blocked to prevent
# because UMBRA is self-hosted and the user's ntfy server is typically on the same LAN. # SSRF against Docker-internal services. If a self-hosted ntfy server on the LAN
# is required, remove the RFC 1918 entries from _BLOCKED_NETWORKS and document the accepted risk.
_BLOCKED_NETWORKS = [ _BLOCKED_NETWORKS = [
ipaddress.ip_network("127.0.0.0/8"), ipaddress.ip_network("127.0.0.0/8"), # IPv4 loopback
ipaddress.ip_network("169.254.0.0/16"), ipaddress.ip_network("10.0.0.0/8"), # RFC 1918 private
ipaddress.ip_network("::1/128"), ipaddress.ip_network("172.16.0.0/12"), # RFC 1918 private — covers Docker bridge 172.17-31.x
ipaddress.ip_network("fe80::/10"), ipaddress.ip_network("192.168.0.0/16"), # RFC 1918 private
ipaddress.ip_network("169.254.0.0/16"), # IPv4 link-local
ipaddress.ip_network("::1/128"), # IPv6 loopback
ipaddress.ip_network("fe80::/10"), # IPv6 link-local
ipaddress.ip_network("fc00::/7"), # IPv6 ULA (covers fd00::/8)
] ]

View File

@ -46,6 +46,13 @@ server {
include /etc/nginx/proxy-params.conf; include /etc/nginx/proxy-params.conf;
} }
location /api/auth/setup {
# Tighter burst: setup is one-time-only, 3 attempts is sufficient
limit_req zone=auth_limit burst=3 nodelay;
limit_req_status 429;
include /etc/nginx/proxy-params.conf;
}
# API proxy # API proxy
location /api { location /api {
proxy_pass http://backend:8000; proxy_pass http://backend:8000;

View File

@ -1,4 +1,5 @@
import { useState, useRef, useEffect, useMemo } from 'react'; import { useState, useRef, useEffect, useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner'; import { toast } from 'sonner';
import FullCalendar from '@fullcalendar/react'; import FullCalendar from '@fullcalendar/react';
@ -6,20 +7,16 @@ import dayGridPlugin from '@fullcalendar/daygrid';
import timeGridPlugin from '@fullcalendar/timegrid'; import timeGridPlugin from '@fullcalendar/timegrid';
import interactionPlugin from '@fullcalendar/interaction'; import interactionPlugin from '@fullcalendar/interaction';
import type { EventClickArg, DateSelectArg, EventDropArg, DatesSetArg } from '@fullcalendar/core'; import type { EventClickArg, DateSelectArg, EventDropArg, DatesSetArg } from '@fullcalendar/core';
import { ChevronLeft, ChevronRight } from 'lucide-react'; import { ChevronLeft, ChevronRight, Plus, Search } from 'lucide-react';
import api, { getErrorMessage } from '@/lib/api'; import api, { getErrorMessage } from '@/lib/api';
import type { CalendarEvent, EventTemplate } from '@/types'; import type { CalendarEvent, EventTemplate, Location as LocationType } from '@/types';
import { useCalendars } from '@/hooks/useCalendars'; import { useCalendars } from '@/hooks/useCalendars';
import { useSettings } from '@/hooks/useSettings'; import { useSettings } from '@/hooks/useSettings';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import { Input } from '@/components/ui/input';
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import CalendarSidebar from './CalendarSidebar'; import CalendarSidebar from './CalendarSidebar';
import EventForm from './EventForm'; import EventDetailPanel from './EventDetailPanel';
import type { CreateDefaults } from './EventDetailPanel';
type CalendarView = 'dayGridMonth' | 'timeGridWeek' | 'timeGridDay'; type CalendarView = 'dayGridMonth' | 'timeGridWeek' | 'timeGridDay';
@ -29,32 +26,57 @@ const viewLabels: Record<CalendarView, string> = {
timeGridDay: 'Day', timeGridDay: 'Day',
}; };
type ScopeAction = 'edit' | 'delete';
export default function CalendarPage() { export default function CalendarPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const location = useLocation();
const calendarRef = useRef<FullCalendar>(null); const calendarRef = useRef<FullCalendar>(null);
const [showForm, setShowForm] = useState(false);
const [editingEvent, setEditingEvent] = useState<CalendarEvent | null>(null);
const [selectedStart, setSelectedStart] = useState<string | null>(null);
const [selectedEnd, setSelectedEnd] = useState<string | null>(null);
const [selectedAllDay, setSelectedAllDay] = useState(false);
const [currentView, setCurrentView] = useState<CalendarView>('dayGridMonth'); const [currentView, setCurrentView] = useState<CalendarView>('dayGridMonth');
const [calendarTitle, setCalendarTitle] = useState(''); const [calendarTitle, setCalendarTitle] = useState('');
const [templateEvent, setTemplateEvent] = useState<Partial<CalendarEvent> | null>(null); const [eventSearch, setEventSearch] = useState('');
const [templateName, setTemplateName] = useState<string | null>(null); const [searchFocused, setSearchFocused] = useState(false);
// Scope dialog state // Panel state
const [scopeDialogOpen, setScopeDialogOpen] = useState(false); const [selectedEventId, setSelectedEventId] = useState<number | string | null>(null);
const [scopeAction, setScopeAction] = useState<ScopeAction>('edit'); const [panelMode, setPanelMode] = useState<'closed' | 'view' | 'create'>('closed');
const [scopeEvent, setScopeEvent] = useState<CalendarEvent | null>(null); const [createDefaults, setCreateDefaults] = useState<CreateDefaults | null>(null);
const [activeEditScope, setActiveEditScope] = useState<'this' | 'this_and_future' | null>(null);
const { settings } = useSettings(); const { settings } = useSettings();
const { data: calendars = [] } = useCalendars(); const { data: calendars = [] } = useCalendars();
const calendarContainerRef = useRef<HTMLDivElement>(null); const calendarContainerRef = useRef<HTMLDivElement>(null);
// Location data for event panel
const { data: locations = [] } = useQuery({
queryKey: ['locations'],
queryFn: async () => {
const { data } = await api.get<LocationType[]>('/locations');
return data;
},
staleTime: 5 * 60 * 1000,
});
const locationMap = useMemo(() => {
const map = new Map<number, string>();
locations.forEach((l) => map.set(l.id, l.name));
return map;
}, [locations]);
// Handle navigation state from dashboard
useEffect(() => {
const state = location.state as { date?: string; view?: string; eventId?: number } | null;
if (!state) return;
const calApi = calendarRef.current?.getApi();
if (state.date && calApi) {
calApi.gotoDate(state.date);
if (state.view) calApi.changeView(state.view);
}
if (state.eventId) {
setSelectedEventId(state.eventId);
setPanelMode('view');
}
window.history.replaceState({}, '');
}, [location.state]);
// Resize FullCalendar when container size changes (e.g. sidebar collapse) // Resize FullCalendar when container size changes (e.g. sidebar collapse)
useEffect(() => { useEffect(() => {
const el = calendarContainerRef.current; const el = calendarContainerRef.current;
@ -66,6 +88,23 @@ export default function CalendarPage() {
return () => observer.disconnect(); return () => observer.disconnect();
}, []); }, []);
const panelOpen = panelMode !== 'closed';
// Continuously resize calendar during panel open/close CSS transition
useEffect(() => {
let rafId: number;
const start = performance.now();
const duration = 350; // slightly longer than the 300ms CSS transition
const tick = () => {
calendarRef.current?.getApi().updateSize();
if (performance.now() - start < duration) {
rafId = requestAnimationFrame(tick);
}
};
rafId = requestAnimationFrame(tick);
return () => cancelAnimationFrame(rafId);
}, [panelOpen]);
// Scroll wheel navigation in month view // Scroll wheel navigation in month view
useEffect(() => { useEffect(() => {
const el = calendarContainerRef.current; const el = calendarContainerRef.current;
@ -94,6 +133,21 @@ export default function CalendarPage() {
}, },
}); });
const selectedEvent = useMemo(
() => events.find((e) => e.id === selectedEventId) ?? null,
[selectedEventId, events],
);
// Escape key closes detail panel
useEffect(() => {
if (!panelOpen) return;
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') handlePanelClose();
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [panelOpen]);
const visibleCalendarIds = useMemo( const visibleCalendarIds = useMemo(
() => new Set(calendars.filter((c) => c.is_visible).map((c) => c.id)), () => new Set(calendars.filter((c) => c.is_visible).map((c) => c.id)),
[calendars], [calendars],
@ -152,28 +206,38 @@ export default function CalendarPage() {
}, },
}); });
const scopeDeleteMutation = useMutation({
mutationFn: async ({ id, scope }: { id: number; scope: string }) => {
await api.delete(`/events/${id}?scope=${scope}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
queryClient.invalidateQueries({ queryKey: ['upcoming'] });
toast.success('Event(s) deleted');
setScopeDialogOpen(false);
setScopeEvent(null);
},
onError: (error) => {
toast.error(getErrorMessage(error, 'Failed to delete event'));
},
});
const filteredEvents = useMemo(() => { const filteredEvents = useMemo(() => {
if (calendars.length === 0) return events; if (calendars.length === 0) return events;
return events.filter((e) => visibleCalendarIds.has(e.calendar_id)); return events.filter((e) => visibleCalendarIds.has(e.calendar_id));
}, [events, visibleCalendarIds, calendars.length]); }, [events, visibleCalendarIds, calendars.length]);
const searchResults = useMemo(() => {
if (!eventSearch.trim()) return [];
const q = eventSearch.toLowerCase();
return filteredEvents
.filter((e) => e.title.toLowerCase().includes(q))
.slice(0, 8);
}, [filteredEvents, eventSearch]);
const handleSearchSelect = (event: CalendarEvent) => {
const calApi = calendarRef.current?.getApi();
if (!calApi) return;
const startDate = new Date(event.start_datetime);
calApi.gotoDate(startDate);
if (event.all_day) {
calApi.changeView('dayGridMonth');
} else {
calApi.changeView('timeGridDay');
}
setEventSearch('');
setSearchFocused(false);
// Also open the event in the panel
if (!event.is_virtual) {
setSelectedEventId(event.id);
setPanelMode('view');
}
};
const calendarEvents = filteredEvents.map((event) => ({ const calendarEvents = filteredEvents.map((event) => ({
id: String(event.id), id: String(event.id),
title: event.title, title: event.title,
@ -189,10 +253,6 @@ export default function CalendarPage() {
}, },
})); }));
const isRecurring = (event: CalendarEvent): boolean => {
return !!(event.is_recurring || event.parent_event_id);
};
const handleEventClick = (info: EventClickArg) => { const handleEventClick = (info: EventClickArg) => {
const event = events.find((e) => String(e.id) === info.event.id); const event = events.find((e) => String(e.id) === info.event.id);
if (!event) return; if (!event) return;
@ -200,27 +260,8 @@ export default function CalendarPage() {
toast.info(`${event.title} — from People contacts`); toast.info(`${event.title} — from People contacts`);
return; return;
} }
setSelectedEventId(event.id);
if (isRecurring(event)) { setPanelMode('view');
setScopeEvent(event);
setScopeAction('edit');
setScopeDialogOpen(true);
} else {
setEditingEvent(event);
setShowForm(true);
}
};
const handleScopeChoice = (scope: 'this' | 'this_and_future') => {
if (!scopeEvent) return;
if (scopeAction === 'edit') {
setEditingEvent(scopeEvent);
setActiveEditScope(scope);
setShowForm(true);
setScopeDialogOpen(false);
} else if (scopeAction === 'delete') {
scopeDeleteMutation.mutate({ id: scopeEvent.id as number, scope });
}
}; };
const handleEventDrop = (info: EventDropArg) => { const handleEventDrop = (info: EventDropArg) => {
@ -228,7 +269,6 @@ export default function CalendarPage() {
info.revert(); info.revert();
return; return;
} }
// Prevent drag-drop on recurring events — user must use scope dialog via click
if (info.event.extendedProps.is_recurring || info.event.extendedProps.parent_event_id) { if (info.event.extendedProps.is_recurring || info.event.extendedProps.parent_event_id) {
info.revert(); info.revert();
toast.info('Click the event to edit recurring events'); toast.info('Click the event to edit recurring events');
@ -253,7 +293,6 @@ export default function CalendarPage() {
info.revert(); info.revert();
return; return;
} }
// Prevent resize on recurring events — user must use scope dialog via click
if (info.event.extendedProps.is_recurring || info.event.extendedProps.parent_event_id) { if (info.event.extendedProps.is_recurring || info.event.extendedProps.parent_event_id) {
info.revert(); info.revert();
toast.info('Click the event to edit recurring events'); toast.info('Click the event to edit recurring events');
@ -274,37 +313,43 @@ export default function CalendarPage() {
}; };
const handleDateSelect = (selectInfo: DateSelectArg) => { const handleDateSelect = (selectInfo: DateSelectArg) => {
setSelectedStart(selectInfo.startStr); setSelectedEventId(null);
setSelectedEnd(selectInfo.endStr); setPanelMode('create');
setSelectedAllDay(selectInfo.allDay); setCreateDefaults({
setShowForm(true); start: selectInfo.startStr,
end: selectInfo.endStr,
allDay: selectInfo.allDay,
});
}; };
const handleCloseForm = () => { const handleCreateNew = () => {
setSelectedEventId(null);
setPanelMode('create');
setCreateDefaults(null);
};
const handlePanelClose = () => {
calendarRef.current?.getApi().unselect(); calendarRef.current?.getApi().unselect();
setShowForm(false); setPanelMode('closed');
setEditingEvent(null); setSelectedEventId(null);
setTemplateEvent(null); setCreateDefaults(null);
setTemplateName(null);
setActiveEditScope(null);
setSelectedStart(null);
setSelectedEnd(null);
setSelectedAllDay(false);
}; };
const handleUseTemplate = (template: EventTemplate) => { const handleUseTemplate = (template: EventTemplate) => {
setTemplateEvent({ setSelectedEventId(null);
title: template.title, setPanelMode('create');
description: template.description || '', setCreateDefaults({
all_day: template.all_day, templateData: {
calendar_id: template.calendar_id ?? undefined, title: template.title,
location_id: template.location_id || undefined, description: template.description || '',
is_starred: template.is_starred, all_day: template.all_day,
recurrence_rule: template.recurrence_rule || undefined, calendar_id: template.calendar_id ?? undefined,
} as Partial<CalendarEvent>); location_id: template.location_id || undefined,
setTemplateName(template.name); is_starred: template.is_starred,
setEditingEvent(null); recurrence_rule: template.recurrence_rule || undefined,
setShowForm(true); } as Partial<CalendarEvent>,
templateName: template.name,
});
}; };
const handleDatesSet = (arg: DatesSetArg) => { const handleDatesSet = (arg: DatesSetArg) => {
@ -318,11 +363,11 @@ export default function CalendarPage() {
const changeView = (view: CalendarView) => calendarRef.current?.getApi().changeView(view); const changeView = (view: CalendarView) => calendarRef.current?.getApi().changeView(view);
return ( return (
<div className="flex h-full overflow-hidden"> <div className="flex h-full overflow-hidden animate-fade-in">
<CalendarSidebar onUseTemplate={handleUseTemplate} /> <CalendarSidebar onUseTemplate={handleUseTemplate} />
<div ref={calendarContainerRef} className="flex-1 flex flex-col overflow-hidden"> <div ref={calendarContainerRef} className="flex-1 flex flex-col overflow-hidden">
{/* Custom toolbar — h-16 matches sidebar header */} {/* Custom toolbar */}
<div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0"> <div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={navigatePrev}> <Button variant="ghost" size="icon" className="h-8 w-8" onClick={navigatePrev}>
@ -335,7 +380,7 @@ export default function CalendarPage() {
<Button variant="outline" size="sm" className="h-8" onClick={navigateToday}> <Button variant="outline" size="sm" className="h-8" onClick={navigateToday}>
Today Today
</Button> </Button>
<h2 className="text-lg font-semibold font-heading flex-1">{calendarTitle}</h2>
<div className="flex items-center rounded-md border border-border overflow-hidden"> <div className="flex items-center rounded-md border border-border overflow-hidden">
{(Object.entries(viewLabels) as [CalendarView, string][]).map(([view, label]) => ( {(Object.entries(viewLabels) as [CalendarView, string][]).map(([view, label]) => (
<button <button
@ -355,89 +400,126 @@ export default function CalendarPage() {
</button> </button>
))} ))}
</div> </div>
<h2 className="text-lg font-semibold font-heading">{calendarTitle}</h2>
<div className="flex-1" />
{/* Event search */}
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder="Search events..."
value={eventSearch}
onChange={(e) => setEventSearch(e.target.value)}
onFocus={() => setSearchFocused(true)}
onBlur={() => setTimeout(() => setSearchFocused(false), 200)}
className="w-52 h-8 pl-8 text-sm ring-inset"
/>
{searchFocused && searchResults.length > 0 && (
<div className="absolute z-50 mt-1 w-72 right-0 rounded-md border bg-popover shadow-lg overflow-hidden">
{searchResults.map((event) => (
<button
key={event.id}
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => handleSearchSelect(event)}
className="flex items-center gap-2.5 w-full px-3 py-2 text-sm text-left hover:bg-accent/10 transition-colors"
>
<div
className="w-2 h-2 rounded-full shrink-0"
style={{ backgroundColor: event.calendar_color || 'hsl(var(--accent-color))' }}
/>
<div className="min-w-0 flex-1">
<span className="font-medium truncate block">{event.title}</span>
<span className="text-xs text-muted-foreground">
{new Date(event.start_datetime).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}
</span>
</div>
</button>
))}
</div>
)}
</div>
<Button size="sm" onClick={handleCreateNew}>
<Plus className="mr-2 h-4 w-4" />
Create Event
</Button>
</div> </div>
{/* Calendar grid */} {/* Calendar grid + event detail panel */}
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-hidden flex">
<div className="h-full"> <div
<FullCalendar className={`overflow-y-auto transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${
key={`fc-${settings?.first_day_of_week ?? 0}`} panelOpen ? 'w-full lg:w-[55%]' : 'w-full'
ref={calendarRef} }`}
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]} >
initialView="dayGridMonth" <div className="h-full">
headerToolbar={false} <FullCalendar
firstDay={settings?.first_day_of_week ?? 0} key={`fc-${settings?.first_day_of_week ?? 0}`}
events={calendarEvents} ref={calendarRef}
editable={true} plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
selectable={true} initialView="dayGridMonth"
selectMirror={true} headerToolbar={false}
unselectAuto={false} firstDay={settings?.first_day_of_week ?? 0}
dayMaxEvents={true} events={calendarEvents}
weekends={true} editable={true}
nowIndicator={true} selectable={true}
eventClick={handleEventClick} selectMirror={true}
eventDrop={handleEventDrop} unselectAuto={false}
eventResize={handleEventResize} dayMaxEvents={true}
select={handleDateSelect} weekends={true}
datesSet={handleDatesSet} nowIndicator={true}
height="100%" eventClick={handleEventClick}
eventDrop={handleEventDrop}
eventResize={handleEventResize}
select={handleDateSelect}
datesSet={handleDatesSet}
height="100%"
/>
</div>
</div>
{/* Detail panel (desktop) */}
<div
className={`overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${
panelOpen ? 'hidden lg:block lg:w-[45%]' : 'w-0 opacity-0'
}`}
>
<EventDetailPanel
event={panelMode === 'view' ? selectedEvent : null}
isCreating={panelMode === 'create'}
createDefaults={createDefaults}
onClose={handlePanelClose}
onSaved={handlePanelClose}
locationName={selectedEvent?.location_id ? locationMap.get(selectedEvent.location_id) : undefined}
/> />
</div> </div>
</div> </div>
</div> </div>
{showForm && ( {/* Mobile detail panel overlay */}
<EventForm {panelOpen && (
event={editingEvent} <div
templateData={templateEvent} className="lg:hidden fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
templateName={templateName} onClick={handlePanelClose}
initialStart={selectedStart} >
initialEnd={selectedEnd} <div
initialAllDay={selectedAllDay} className="fixed inset-y-0 right-0 w-full sm:w-[400px] bg-card border-l border-border shadow-lg"
editScope={activeEditScope} onClick={(e) => e.stopPropagation()}
onClose={handleCloseForm} >
/> <EventDetailPanel
)} event={panelMode === 'view' ? selectedEvent : null}
isCreating={panelMode === 'create'}
{/* Recurring event scope dialog */} createDefaults={createDefaults}
<Dialog open={scopeDialogOpen} onOpenChange={setScopeDialogOpen}> onClose={handlePanelClose}
<DialogContent className="max-w-sm"> onSaved={handlePanelClose}
<DialogHeader> locationName={selectedEvent?.location_id ? locationMap.get(selectedEvent.location_id) : undefined}
<DialogTitle> />
{scopeAction === 'edit' ? 'Edit Recurring Event' : 'Delete Recurring Event'}
</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
This is a recurring event. How would you like to proceed?
</p>
<div className="flex flex-col gap-2 mt-2">
<Button
variant="outline"
className="w-full justify-center"
onClick={() => handleScopeChoice('this')}
>
This event only
</Button>
<Button
variant="outline"
className="w-full justify-center"
onClick={() => handleScopeChoice('this_and_future')}
>
This and all future events
</Button>
<Button
variant="ghost"
className="w-full justify-center"
onClick={() => {
setScopeDialogOpen(false);
setScopeEvent(null);
}}
>
Cancel
</Button>
</div> </div>
</DialogContent> </div>
</Dialog> )}
</div> </div>
); );
} }

View File

@ -0,0 +1,915 @@
import { useState, useEffect, useCallback } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { format, parseISO } from 'date-fns';
import {
X, Pencil, Trash2, Save, Clock, MapPin, AlignLeft, Repeat, Star, Calendar,
} from 'lucide-react';
import api, { getErrorMessage } from '@/lib/api';
import type { CalendarEvent, Location as LocationType, RecurrenceRule } from '@/types';
import { useCalendars } from '@/hooks/useCalendars';
import { useConfirmAction } from '@/hooks/useConfirmAction';
import { formatUpdatedAt } from '@/components/shared/utils';
import CopyableField from '@/components/shared/CopyableField';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Select } from '@/components/ui/select';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import LocationPicker from '@/components/ui/location-picker';
// --- Helpers ---
function toDateOnly(dt: string): string {
if (!dt) return '';
return dt.split('T')[0];
}
function toDatetimeLocal(dt: string, fallbackTime = '09:00'): string {
if (!dt) return '';
if (dt.includes('T')) return dt.slice(0, 16);
return `${dt}T${fallbackTime}`;
}
function formatForInput(dt: string, allDay: boolean, fallbackTime = '09:00'): string {
if (!dt) return '';
return allDay ? toDateOnly(dt) : toDatetimeLocal(dt, fallbackTime);
}
function adjustAllDayEndForDisplay(dateStr: string): string {
if (!dateStr) return '';
const d = new Date(dateStr.split('T')[0] + 'T12:00:00');
d.setDate(d.getDate() - 1);
const pad = (n: number) => n.toString().padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
function adjustAllDayEndForSave(dateStr: string): string {
if (!dateStr) return '';
const d = new Date(dateStr + 'T12:00:00');
d.setDate(d.getDate() + 1);
const pad = (n: number) => n.toString().padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
function nowLocal(): string {
const now = new Date();
const pad = (n: number) => n.toString().padStart(2, '0');
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(now.getHours())}:${pad(now.getMinutes())}`;
}
function plusOneHour(dt: string): string {
const d = new Date(dt);
d.setHours(d.getHours() + 1);
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())}`;
}
function formatRecurrenceRule(rule: string): string {
try {
const parsed = JSON.parse(rule);
const weekdays = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
switch (parsed.type) {
case 'every_n_days':
return parsed.interval === 1 ? 'Every day' : `Every ${parsed.interval} days`;
case 'weekly':
return parsed.weekday != null ? `Weekly on ${weekdays[parsed.weekday]}` : 'Weekly';
case 'monthly_nth_weekday':
return parsed.week && parsed.weekday != null
? `Monthly on week ${parsed.week}, ${weekdays[parsed.weekday]}`
: 'Monthly';
case 'monthly_date':
return parsed.day ? `Monthly on the ${parsed.day}${ordinal(parsed.day)}` : 'Monthly';
default:
return 'Recurring';
}
} catch {
return 'Recurring';
}
}
function ordinal(n: number): string {
const s = ['th', 'st', 'nd', 'rd'];
const v = n % 100;
return s[(v - 20) % 10] || s[v] || s[0];
}
function parseRecurrenceRule(raw?: string): RecurrenceRule | null {
if (!raw) return null;
try {
return JSON.parse(raw);
} catch {
return null;
}
}
// Python weekday: 0=Monday, 6=Sunday
const WEEKDAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
// --- Types ---
export interface CreateDefaults {
start?: string;
end?: string;
allDay?: boolean;
templateData?: Partial<CalendarEvent>;
templateName?: string;
}
interface EventDetailPanelProps {
event: CalendarEvent | null;
isCreating?: boolean;
createDefaults?: CreateDefaults | null;
onClose: () => void;
onSaved?: () => void;
onDeleted?: () => void;
locationName?: string;
}
interface EditState {
title: string;
description: string;
start_datetime: string;
end_datetime: string;
all_day: boolean;
location_id: string;
calendar_id: string;
is_starred: boolean;
recurrence_type: string;
recurrence_interval: number;
recurrence_weekday: number;
recurrence_week: number;
recurrence_day: number;
}
function buildEditStateFromEvent(event: CalendarEvent): EditState {
const rule = parseRecurrenceRule(event.recurrence_rule);
const isAllDay = event.all_day;
const displayEnd = isAllDay ? adjustAllDayEndForDisplay(event.end_datetime) : event.end_datetime;
return {
title: event.title,
description: event.description || '',
start_datetime: formatForInput(event.start_datetime, isAllDay, '09:00'),
end_datetime: formatForInput(displayEnd, isAllDay, '10:00'),
all_day: isAllDay,
location_id: event.location_id?.toString() || '',
calendar_id: event.calendar_id?.toString() || '',
is_starred: event.is_starred || false,
recurrence_type: rule?.type || '',
recurrence_interval: rule?.interval || 2,
recurrence_weekday: rule?.weekday ?? 1,
recurrence_week: rule?.week || 1,
recurrence_day: rule?.day || 1,
};
}
function buildCreateState(defaults: CreateDefaults | null, defaultCalendarId: string): EditState {
const source = defaults?.templateData;
const isAllDay = source?.all_day ?? defaults?.allDay ?? false;
const defaultStart = nowLocal();
const defaultEnd = plusOneHour(defaultStart);
const rawStart = defaults?.start || defaultStart;
const rawEnd = defaults?.end || defaultEnd;
const displayEnd = isAllDay ? adjustAllDayEndForDisplay(rawEnd) : rawEnd;
const rule = parseRecurrenceRule(source?.recurrence_rule);
return {
title: source?.title || '',
description: source?.description || '',
start_datetime: formatForInput(rawStart, isAllDay, '09:00'),
end_datetime: formatForInput(displayEnd, isAllDay, '10:00'),
all_day: isAllDay,
location_id: source?.location_id?.toString() || '',
calendar_id: source?.calendar_id?.toString() || defaultCalendarId,
is_starred: source?.is_starred || false,
recurrence_type: rule?.type || '',
recurrence_interval: rule?.interval || 2,
recurrence_weekday: rule?.weekday ?? 1,
recurrence_week: rule?.week || 1,
recurrence_day: rule?.day || 1,
};
}
function buildRecurrencePayload(state: EditState): RecurrenceRule | null {
if (!state.recurrence_type) return null;
switch (state.recurrence_type) {
case 'every_n_days':
return { type: 'every_n_days', interval: state.recurrence_interval };
case 'weekly':
return { type: 'weekly' };
case 'monthly_nth_weekday':
return { type: 'monthly_nth_weekday', week: state.recurrence_week, weekday: state.recurrence_weekday };
case 'monthly_date':
return { type: 'monthly_date', day: state.recurrence_day };
default:
return null;
}
}
// --- Component ---
export default function EventDetailPanel({
event,
isCreating = false,
createDefaults,
onClose,
onSaved,
onDeleted,
locationName,
}: EventDetailPanelProps) {
const queryClient = useQueryClient();
const { data: calendars = [] } = useCalendars();
const selectableCalendars = calendars.filter((c) => !c.is_system);
const defaultCalendar = calendars.find((c) => c.is_default);
const { data: locations = [] } = useQuery({
queryKey: ['locations'],
queryFn: async () => {
const { data } = await api.get<LocationType[]>('/locations');
return data;
},
staleTime: 5 * 60 * 1000,
});
const [isEditing, setIsEditing] = useState(false);
const [editState, setEditState] = useState<EditState>(() =>
isCreating
? buildCreateState(createDefaults ?? null, defaultCalendar?.id?.toString() || '')
: event
? buildEditStateFromEvent(event)
: buildCreateState(null, defaultCalendar?.id?.toString() || '')
);
const [scopeStep, setScopeStep] = useState<'edit' | 'delete' | null>(null);
const [editScope, setEditScope] = useState<'this' | 'this_and_future' | null>(null);
const [locationSearch, setLocationSearch] = useState('');
const isRecurring = !!(event?.is_recurring || event?.parent_event_id);
// Reset state when event changes
useEffect(() => {
setIsEditing(false);
setScopeStep(null);
setEditScope(null);
setLocationSearch('');
if (event) setEditState(buildEditStateFromEvent(event));
}, [event?.id]);
// Enter edit mode when creating
useEffect(() => {
if (isCreating) {
setIsEditing(true);
setEditState(buildCreateState(createDefaults ?? null, defaultCalendar?.id?.toString() || ''));
setLocationSearch('');
}
}, [isCreating, createDefaults]);
// Initialize location search text from existing location
useEffect(() => {
if (isEditing && !isCreating && event?.location_id) {
const loc = locations.find((l) => l.id === event.location_id);
if (loc) setLocationSearch(loc.name);
}
}, [isEditing, isCreating, event?.location_id, locations]);
const invalidateAll = useCallback(() => {
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
queryClient.invalidateQueries({ queryKey: ['upcoming'] });
}, [queryClient]);
// --- Mutations ---
const saveMutation = useMutation({
mutationFn: async (data: EditState) => {
const rule = buildRecurrencePayload(data);
let endDt = data.end_datetime;
if (data.all_day && endDt) endDt = adjustAllDayEndForSave(endDt);
const payload: Record<string, unknown> = {
title: data.title,
description: data.description || null,
start_datetime: data.start_datetime,
end_datetime: endDt,
all_day: data.all_day,
location_id: data.location_id ? parseInt(data.location_id) : null,
calendar_id: data.calendar_id ? parseInt(data.calendar_id) : null,
is_starred: data.is_starred,
recurrence_rule: rule,
};
if (event && !isCreating) {
if (editScope) payload.edit_scope = editScope;
return api.put(`/events/${event.id}`, payload);
} else {
return api.post('/events', payload);
}
},
onSuccess: () => {
invalidateAll();
toast.success(isCreating ? 'Event created' : 'Event updated');
if (isCreating) {
onClose();
} else {
setIsEditing(false);
setEditScope(null);
}
onSaved?.();
},
onError: (error) => {
toast.error(getErrorMessage(error, isCreating ? 'Failed to create event' : 'Failed to update event'));
},
});
const deleteMutation = useMutation({
mutationFn: async () => {
const scope = editScope ? `?scope=${editScope}` : '';
await api.delete(`/events/${event!.id}${scope}`);
},
onSuccess: () => {
invalidateAll();
toast.success('Event deleted');
onClose();
onDeleted?.();
},
onError: (error) => {
toast.error(getErrorMessage(error, 'Failed to delete event'));
},
});
const executeDelete = useCallback(() => deleteMutation.mutate(), [deleteMutation]);
const { confirming: confirmingDelete, handleClick: handleDeleteClick } = useConfirmAction(executeDelete);
// --- Handlers ---
const handleEditStart = () => {
if (isRecurring) {
setScopeStep('edit');
} else {
if (event) setEditState(buildEditStateFromEvent(event));
setIsEditing(true);
}
};
const handleScopeSelect = (scope: 'this' | 'this_and_future') => {
setEditScope(scope);
if (scopeStep === 'edit') {
if (event) setEditState(buildEditStateFromEvent(event));
setIsEditing(true);
setScopeStep(null);
} else if (scopeStep === 'delete') {
// Delete with scope — execute immediately
setScopeStep(null);
// The deleteMutation will read editScope, but we need to set it first
// Since setState is async, use the mutation directly with the scope
const scopeParam = `?scope=${scope}`;
api.delete(`/events/${event!.id}${scopeParam}`).then(() => {
invalidateAll();
toast.success('Event(s) deleted');
onClose();
onDeleted?.();
}).catch((error) => {
toast.error(getErrorMessage(error, 'Failed to delete event'));
});
}
};
const handleEditCancel = () => {
setIsEditing(false);
setEditScope(null);
setLocationSearch('');
if (isCreating) {
onClose();
} else if (event) {
setEditState(buildEditStateFromEvent(event));
}
};
const handleEditSave = () => {
saveMutation.mutate(editState);
};
const handleDeleteStart = () => {
if (isRecurring) {
setScopeStep('delete');
} else {
handleDeleteClick();
}
};
// --- Render helpers ---
const updateField = <K extends keyof EditState>(key: K, value: EditState[K]) => {
setEditState((s) => ({ ...s, [key]: value }));
};
// Empty state
if (!event && !isCreating) {
return (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<Calendar className="h-8 w-8 mb-3 opacity-40" />
<p className="text-sm">Select an event to view details</p>
</div>
);
}
// View mode data
const startDate = event ? parseISO(event.start_datetime) : null;
const endDate = event?.end_datetime ? parseISO(event.end_datetime) : null;
const startStr = startDate
? event!.all_day
? format(startDate, 'EEEE, MMMM d, yyyy')
: format(startDate, 'EEEE, MMMM d, yyyy · h:mm a')
: '';
const endStr = endDate
? event!.all_day
? format(endDate, 'EEEE, MMMM d, yyyy')
: format(endDate, 'h:mm a')
: null;
const panelTitle = isCreating
? createDefaults?.templateName
? `New Event from ${createDefaults.templateName}`
: 'New Event'
: event?.title || '';
return (
<div className="flex flex-col h-full bg-card border-l border-border overflow-hidden">
{/* Header */}
<div className="px-5 py-4 border-b border-border shrink-0">
<div className="flex items-start justify-between gap-3">
{isEditing ? (
<div className="flex-1 min-w-0">
{isCreating ? (
<h3 className="font-heading text-lg font-semibold">{panelTitle}</h3>
) : (
<Input
value={editState.title}
onChange={(e) => updateField('title', e.target.value)}
className="h-8 text-base font-semibold"
placeholder="Event title"
autoFocus
/>
)}
</div>
) : scopeStep ? (
<h3 className="font-heading text-sm font-semibold">
{scopeStep === 'edit' ? 'Edit Recurring Event' : 'Delete Recurring Event'}
</h3>
) : (
<div className="flex items-center gap-3 min-w-0 flex-1">
<div
className="w-3 h-3 rounded-full shrink-0"
style={{ backgroundColor: event?.calendar_color || 'hsl(var(--accent-color))' }}
/>
<div className="min-w-0">
<h3 className="font-heading text-lg font-semibold truncate">{event?.title}</h3>
<span className="text-xs text-muted-foreground">{event?.calendar_name}</span>
</div>
</div>
)}
<div className="flex items-center gap-1 shrink-0">
{scopeStep ? (
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => setScopeStep(null)}
title="Cancel"
>
<X className="h-3.5 w-3.5" />
</Button>
) : isEditing ? (
<>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-green-400 hover:text-green-300"
onClick={handleEditSave}
disabled={saveMutation.isPending}
title="Save"
>
<Save className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={handleEditCancel}
title="Cancel"
>
<X className="h-3.5 w-3.5" />
</Button>
</>
) : (
<>
{!event?.is_virtual && (
<>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={handleEditStart}
title="Edit event"
>
<Pencil className="h-3.5 w-3.5" />
</Button>
{confirmingDelete ? (
<Button
variant="ghost"
onClick={handleDeleteStart}
disabled={deleteMutation.isPending}
className="h-7 px-2 bg-destructive/20 text-destructive text-[11px] font-medium"
title="Confirm delete"
>
Sure?
</Button>
) : (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-destructive"
onClick={handleDeleteStart}
disabled={deleteMutation.isPending}
title="Delete event"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</>
)}
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={onClose}
title="Close panel"
>
<X className="h-3.5 w-3.5" />
</Button>
</>
)}
</div>
</div>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-3">
{scopeStep ? (
/* Scope selection step */
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
This is a recurring event. How would you like to proceed?
</p>
<div className="flex flex-col gap-2">
<Button
variant="outline"
className="w-full justify-center"
onClick={() => handleScopeSelect('this')}
>
This event only
</Button>
<Button
variant="outline"
className="w-full justify-center"
onClick={() => handleScopeSelect('this_and_future')}
>
This and all future events
</Button>
<Button
variant="ghost"
className="w-full justify-center"
onClick={() => setScopeStep(null)}
>
Cancel
</Button>
</div>
</div>
) : isEditing ? (
/* Edit / Create mode */
<div className="space-y-4">
{/* Title (only shown in body for create mode; edit mode has it in header) */}
{isCreating && (
<div className="space-y-1">
<Label htmlFor="panel-title" required>Title</Label>
<Input
id="panel-title"
value={editState.title}
onChange={(e) => updateField('title', e.target.value)}
placeholder="Event title"
required
autoFocus
/>
</div>
)}
<div className="space-y-1">
<Label htmlFor="panel-desc">Description</Label>
<Textarea
id="panel-desc"
value={editState.description}
onChange={(e) => updateField('description', e.target.value)}
placeholder="Add a description..."
rows={3}
className="text-sm resize-none"
/>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="panel-allday"
checked={editState.all_day}
onChange={(e) => {
const checked = (e.target as HTMLInputElement).checked;
updateField('all_day', checked);
updateField('start_datetime', formatForInput(editState.start_datetime, checked, '09:00'));
updateField('end_datetime', formatForInput(editState.end_datetime, checked, '10:00'));
}}
/>
<Label htmlFor="panel-allday">All day event</Label>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label htmlFor="panel-start" required>Start</Label>
<Input
id="panel-start"
type={editState.all_day ? 'date' : 'datetime-local'}
value={editState.start_datetime}
onChange={(e) => updateField('start_datetime', e.target.value)}
className="text-xs"
required
/>
</div>
<div className="space-y-1">
<Label htmlFor="panel-end">End</Label>
<Input
id="panel-end"
type={editState.all_day ? 'date' : 'datetime-local'}
value={editState.end_datetime}
onChange={(e) => updateField('end_datetime', e.target.value)}
className="text-xs"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label htmlFor="panel-calendar">Calendar</Label>
<Select
id="panel-calendar"
value={editState.calendar_id}
onChange={(e) => updateField('calendar_id', e.target.value)}
className="text-xs"
>
{selectableCalendars.map((cal) => (
<option key={cal.id} value={cal.id}>{cal.name}</option>
))}
</Select>
</div>
<div className="space-y-1">
<Label htmlFor="panel-location">Location</Label>
<LocationPicker
id="panel-location"
value={locationSearch}
onChange={(val) => {
setLocationSearch(val);
if (!val) updateField('location_id', '');
}}
onSelect={async (result) => {
if (result.source === 'local' && result.location_id) {
updateField('location_id', result.location_id.toString());
} else if (result.source === 'nominatim') {
try {
const { data: newLoc } = await api.post('/locations', {
name: result.name,
address: result.address,
category: 'other',
});
queryClient.invalidateQueries({ queryKey: ['locations'] });
updateField('location_id', newLoc.id.toString());
toast.success(`Location "${result.name}" created`);
} catch {
toast.error('Failed to create location');
}
}
}}
placeholder="Search location..."
/>
</div>
</div>
{/* Recurrence */}
<div className="space-y-1">
<Label htmlFor="panel-recurrence">Recurrence</Label>
<Select
id="panel-recurrence"
value={editState.recurrence_type}
onChange={(e) => updateField('recurrence_type', e.target.value)}
className="text-xs"
>
<option value="">None</option>
<option value="every_n_days">Every X days</option>
<option value="weekly">Weekly</option>
<option value="monthly_nth_weekday">Monthly (nth weekday)</option>
<option value="monthly_date">Monthly (date)</option>
</Select>
</div>
{editState.recurrence_type === 'every_n_days' && (
<div className="space-y-1">
<Label htmlFor="panel-interval">Every how many days?</Label>
<Input
id="panel-interval"
type="number"
min={1}
max={365}
value={editState.recurrence_interval}
onChange={(e) => updateField('recurrence_interval', parseInt(e.target.value) || 1)}
className="text-xs"
/>
</div>
)}
{editState.recurrence_type === 'weekly' && (
<p className="text-xs text-muted-foreground">
Repeats every week on the same day as the start date.
</p>
)}
{editState.recurrence_type === 'monthly_nth_weekday' && (
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label htmlFor="panel-week">Week of month</Label>
<Select
id="panel-week"
value={editState.recurrence_week.toString()}
onChange={(e) => updateField('recurrence_week', parseInt(e.target.value))}
className="text-xs"
>
<option value="1">1st</option>
<option value="2">2nd</option>
<option value="3">3rd</option>
<option value="4">4th</option>
</Select>
</div>
<div className="space-y-1">
<Label htmlFor="panel-weekday">Day of week</Label>
<Select
id="panel-weekday"
value={editState.recurrence_weekday.toString()}
onChange={(e) => updateField('recurrence_weekday', parseInt(e.target.value))}
className="text-xs"
>
{WEEKDAYS.map((name, i) => (
<option key={i} value={i}>{name}</option>
))}
</Select>
</div>
</div>
)}
{editState.recurrence_type === 'monthly_date' && (
<div className="space-y-1">
<Label htmlFor="panel-day">Day of month</Label>
<Input
id="panel-day"
type="number"
min={1}
max={31}
value={editState.recurrence_day}
onChange={(e) => updateField('recurrence_day', parseInt(e.target.value) || 1)}
className="text-xs"
/>
</div>
)}
<div className="flex items-center gap-2">
<Checkbox
id="panel-starred"
checked={editState.is_starred}
onChange={(e) => updateField('is_starred', (e.target as HTMLInputElement).checked)}
/>
<Label htmlFor="panel-starred">Star this event</Label>
</div>
{/* Save / Cancel buttons at bottom of form */}
<div className="flex items-center justify-end gap-2 pt-2 border-t border-border">
<Button variant="outline" size="sm" onClick={handleEditCancel}>
Cancel
</Button>
<Button size="sm" onClick={handleEditSave} disabled={saveMutation.isPending}>
{saveMutation.isPending ? 'Saving...' : isCreating ? 'Create' : 'Update'}
</Button>
</div>
</div>
) : (
/* View mode */
<>
{/* 2-column grid: Calendar, Starred, Start, End, Location, Recurrence */}
<div className="grid grid-cols-2 gap-3">
{/* Calendar */}
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
<Calendar className="h-3 w-3" />
Calendar
</div>
<div className="flex items-center gap-2">
<div
className="w-2 h-2 rounded-full shrink-0"
style={{ backgroundColor: event?.calendar_color || 'hsl(var(--accent-color))' }}
/>
<span className="text-sm">{event?.calendar_name}</span>
</div>
</div>
{/* Starred */}
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
<Star className="h-3 w-3" />
Starred
</div>
{event?.is_starred ? (
<p className="text-sm text-amber-200/90">Starred</p>
) : (
<p className="text-sm text-muted-foreground"></p>
)}
</div>
{/* Start */}
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
<Clock className="h-3 w-3" />
Start
</div>
<CopyableField value={startStr} icon={Clock} label="Start time" />
</div>
{/* End */}
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
<Clock className="h-3 w-3" />
End
</div>
{endStr ? (
<CopyableField value={endStr} icon={Clock} label="End time" />
) : (
<p className="text-sm text-muted-foreground"></p>
)}
</div>
{/* Location */}
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
<MapPin className="h-3 w-3" />
Location
</div>
{locationName ? (
<CopyableField value={locationName} icon={MapPin} label="Location" />
) : (
<p className="text-sm text-muted-foreground"></p>
)}
</div>
{/* Recurrence */}
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
<Repeat className="h-3 w-3" />
Recurrence
</div>
{isRecurring && event?.recurrence_rule ? (
<p className="text-sm">{formatRecurrenceRule(event.recurrence_rule)}</p>
) : isRecurring ? (
<p className="text-sm">Recurring event</p>
) : (
<p className="text-sm text-muted-foreground"></p>
)}
</div>
</div>
{/* Description — full width */}
{event?.description && (
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
<AlignLeft className="h-3 w-3" />
Description
</div>
<p className="text-sm whitespace-pre-wrap">{event.description}</p>
</div>
)}
{/* Updated at */}
{event && !event.is_virtual && (
<div className="pt-2 border-t border-border">
<span className="text-[11px] text-muted-foreground">
{formatUpdatedAt(event.updated_at)}
</span>
</div>
)}
</>
)}
</div>
</div>
);
}

View File

@ -1,4 +1,5 @@
import { format } from 'date-fns'; import { format } from 'date-fns';
import { useNavigate } from 'react-router-dom';
import { Calendar } from 'lucide-react'; import { Calendar } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@ -17,6 +18,9 @@ interface CalendarWidgetProps {
} }
export default function CalendarWidget({ events }: CalendarWidgetProps) { export default function CalendarWidget({ events }: CalendarWidgetProps) {
const navigate = useNavigate();
const todayStr = format(new Date(), 'yyyy-MM-dd');
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
@ -37,7 +41,8 @@ export default function CalendarWidget({ events }: CalendarWidgetProps) {
{events.map((event) => ( {events.map((event) => (
<div <div
key={event.id} key={event.id}
className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-card-elevated transition-colors duration-150" onClick={() => navigate('/calendar', { state: { date: todayStr, view: 'timeGridDay', eventId: event.id } })}
className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-white/5 transition-colors duration-150 cursor-pointer"
> >
<div <div
className="w-1.5 h-1.5 rounded-full shrink-0" className="w-1.5 h-1.5 rounded-full shrink-0"

View File

@ -1,4 +1,5 @@
import { differenceInCalendarDays } from 'date-fns'; import { differenceInCalendarDays, format } from 'date-fns';
import { useNavigate } from 'react-router-dom';
import { Star } from 'lucide-react'; import { Star } from 'lucide-react';
interface CountdownWidgetProps { interface CountdownWidgetProps {
@ -10,6 +11,7 @@ interface CountdownWidgetProps {
} }
export default function CountdownWidget({ events }: CountdownWidgetProps) { export default function CountdownWidget({ events }: CountdownWidgetProps) {
const navigate = useNavigate();
const visible = events.filter((e) => differenceInCalendarDays(new Date(e.start_datetime), new Date()) >= 0); const visible = events.filter((e) => differenceInCalendarDays(new Date(e.start_datetime), new Date()) >= 0);
if (visible.length === 0) return null; if (visible.length === 0) return null;
@ -18,8 +20,13 @@ export default function CountdownWidget({ events }: CountdownWidgetProps) {
{visible.map((event) => { {visible.map((event) => {
const days = differenceInCalendarDays(new Date(event.start_datetime), new Date()); const days = differenceInCalendarDays(new Date(event.start_datetime), new Date());
const label = days === 0 ? 'Today' : days === 1 ? '1 day' : `${days} days`; const label = days === 0 ? 'Today' : days === 1 ? '1 day' : `${days} days`;
const dateStr = format(new Date(event.start_datetime), 'yyyy-MM-dd');
return ( return (
<div key={event.id} className="flex items-center gap-2"> <div
key={event.id}
onClick={() => navigate('/calendar', { state: { date: dateStr, view: 'timeGridDay', eventId: event.id } })}
className="flex items-center gap-2 cursor-pointer hover:bg-amber-500/10 rounded px-1 -mx-1 transition-colors duration-150"
>
<Star className="h-3 w-3 text-amber-400 fill-amber-400 shrink-0" /> <Star className="h-3 w-3 text-amber-400 fill-amber-400 shrink-0" />
<span className="text-sm text-amber-200/90 truncate"> <span className="text-sm text-amber-200/90 truncate">
<span className="font-semibold tabular-nums">{label}</span> <span className="font-semibold tabular-nums">{label}</span>

View File

@ -1,4 +1,5 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { Bell, Plus, Calendar as CalIcon, ListTodo } from 'lucide-react'; import { Bell, Plus, Calendar as CalIcon, ListTodo } from 'lucide-react';
@ -33,6 +34,7 @@ function getGreeting(name?: string): string {
} }
export default function DashboardPage() { export default function DashboardPage() {
const navigate = useNavigate();
const { settings } = useSettings(); const { settings } = useSettings();
const { alerts, dismiss: dismissAlert, snooze: snoozeAlert } = useAlerts(); const { alerts, dismiss: dismissAlert, snooze: snoozeAlert } = useAlerts();
const [quickAddType, setQuickAddType] = useState<'event' | 'todo' | 'reminder' | null>(null); const [quickAddType, setQuickAddType] = useState<'event' | 'todo' | 'reminder' | null>(null);
@ -234,7 +236,8 @@ export default function DashboardPage() {
{futureReminders.map((reminder) => ( {futureReminders.map((reminder) => (
<div <div
key={reminder.id} key={reminder.id}
className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-card-elevated transition-colors duration-150" onClick={() => navigate('/reminders', { state: { reminderId: reminder.id } })}
className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-white/5 transition-colors duration-150 cursor-pointer"
> >
<div className="w-1.5 h-1.5 rounded-full bg-orange-400 shrink-0" /> <div className="w-1.5 h-1.5 rounded-full bg-orange-400 shrink-0" />
<span className="font-medium text-sm truncate flex-1 min-w-0">{reminder.title}</span> <span className="font-medium text-sm truncate flex-1 min-w-0">{reminder.title}</span>

View File

@ -1,3 +1,4 @@
import { useNavigate } from 'react-router-dom';
import { FolderKanban, TrendingUp, CheckSquare, CloudSun } from 'lucide-react'; import { FolderKanban, TrendingUp, CheckSquare, CloudSun } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
@ -11,6 +12,8 @@ interface StatsWidgetProps {
} }
export default function StatsWidget({ projectStats, totalIncompleteTodos, weatherData }: StatsWidgetProps) { export default function StatsWidget({ projectStats, totalIncompleteTodos, weatherData }: StatsWidgetProps) {
const navigate = useNavigate();
const statCards = [ const statCards = [
{ {
label: 'PROJECTS', label: 'PROJECTS',
@ -18,6 +21,7 @@ export default function StatsWidget({ projectStats, totalIncompleteTodos, weathe
icon: FolderKanban, icon: FolderKanban,
color: 'text-blue-400', color: 'text-blue-400',
glowBg: 'bg-blue-500/10', glowBg: 'bg-blue-500/10',
onClick: () => navigate('/projects'),
}, },
{ {
label: 'IN PROGRESS', label: 'IN PROGRESS',
@ -25,6 +29,7 @@ export default function StatsWidget({ projectStats, totalIncompleteTodos, weathe
icon: TrendingUp, icon: TrendingUp,
color: 'text-purple-400', color: 'text-purple-400',
glowBg: 'bg-purple-500/10', glowBg: 'bg-purple-500/10',
onClick: () => navigate('/projects', { state: { filter: 'in_progress' } }),
}, },
{ {
label: 'OPEN TODOS', label: 'OPEN TODOS',
@ -32,13 +37,18 @@ export default function StatsWidget({ projectStats, totalIncompleteTodos, weathe
icon: CheckSquare, icon: CheckSquare,
color: 'text-teal-400', color: 'text-teal-400',
glowBg: 'bg-teal-500/10', glowBg: 'bg-teal-500/10',
onClick: () => navigate('/todos'),
}, },
]; ];
return ( return (
<div className="grid gap-2.5 grid-cols-2 lg:grid-cols-4"> <div className="grid gap-2.5 grid-cols-2 lg:grid-cols-4">
{statCards.map((stat) => ( {statCards.map((stat) => (
<Card key={stat.label} className="bg-gradient-to-br from-accent/[0.03] to-transparent"> <Card
key={stat.label}
className="bg-gradient-to-br from-accent/[0.03] to-transparent cursor-pointer group transition-transform duration-150 hover:scale-[1.01]"
onClick={stat.onClick}
>
<CardContent className="px-3 py-2"> <CardContent className="px-3 py-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="space-y-0.5"> <div className="space-y-0.5">

View File

@ -1,4 +1,5 @@
import { format, isPast, endOfDay } from 'date-fns'; import { format, isPast, endOfDay } from 'date-fns';
import { useNavigate } from 'react-router-dom';
import { CheckCircle2 } from 'lucide-react'; import { CheckCircle2 } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@ -29,6 +30,8 @@ const dotColors: Record<string, string> = {
}; };
export default function TodoWidget({ todos }: TodoWidgetProps) { export default function TodoWidget({ todos }: TodoWidgetProps) {
const navigate = useNavigate();
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
@ -51,7 +54,8 @@ export default function TodoWidget({ todos }: TodoWidgetProps) {
return ( return (
<div <div
key={todo.id} key={todo.id}
className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-card-elevated transition-colors duration-150" onClick={() => navigate('/todos', { state: { todoId: todo.id } })}
className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-white/5 transition-colors duration-150 cursor-pointer"
> >
<div className={cn('w-1.5 h-1.5 rounded-full shrink-0', dotColors[todo.priority] || dotColors.medium)} /> <div className={cn('w-1.5 h-1.5 rounded-full shrink-0', dotColors[todo.priority] || dotColors.medium)} />
<span className="text-sm font-medium truncate flex-1 min-w-0">{todo.title}</span> <span className="text-sm font-medium truncate flex-1 min-w-0">{todo.title}</span>

View File

@ -1,4 +1,5 @@
import { format } from 'date-fns'; import { format } from 'date-fns';
import { useNavigate } from 'react-router-dom';
import { CheckSquare, Calendar, Bell, ArrowRight } from 'lucide-react'; import { CheckSquare, Calendar, Bell, ArrowRight } from 'lucide-react';
import type { UpcomingItem } from '@/types'; import type { UpcomingItem } from '@/types';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@ -17,6 +18,26 @@ const typeConfig: Record<string, { icon: typeof CheckSquare; color: string; labe
}; };
export default function UpcomingWidget({ items, days = 7 }: UpcomingWidgetProps) { export default function UpcomingWidget({ items, days = 7 }: UpcomingWidgetProps) {
const navigate = useNavigate();
const handleItemClick = (item: UpcomingItem) => {
switch (item.type) {
case 'event': {
const dateStr = item.datetime
? format(new Date(item.datetime), 'yyyy-MM-dd')
: item.date;
navigate('/calendar', { state: { date: dateStr, view: 'timeGridDay', eventId: item.id } });
break;
}
case 'todo':
navigate('/todos', { state: { todoId: item.id } });
break;
case 'reminder':
navigate('/reminders', { state: { reminderId: item.id } });
break;
}
};
return ( return (
<Card className="h-full"> <Card className="h-full">
<CardHeader> <CardHeader>
@ -44,7 +65,8 @@ export default function UpcomingWidget({ items, days = 7 }: UpcomingWidgetProps)
return ( return (
<div <div
key={`${item.type}-${item.id}-${index}`} key={`${item.type}-${item.id}-${index}`}
className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-card-elevated transition-colors duration-150" onClick={() => handleItemClick(item)}
className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-white/5 transition-colors duration-150 cursor-pointer"
> >
<Icon className={cn('h-3.5 w-3.5 shrink-0', config.color)} /> <Icon className={cn('h-3.5 w-3.5 shrink-0', config.color)} />
<span className="text-sm font-medium truncate flex-1 min-w-0">{item.title}</span> <span className="text-sm font-medium truncate flex-1 min-w-0">{item.title}</span>

View File

@ -1,4 +1,5 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { format, startOfWeek, addDays, isSameDay, isBefore, startOfDay } from 'date-fns'; import { format, startOfWeek, addDays, isSameDay, isBefore, startOfDay } from 'date-fns';
import type { UpcomingItem } from '@/types'; import type { UpcomingItem } from '@/types';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@ -14,6 +15,7 @@ const typeColors: Record<string, string> = {
}; };
export default function WeekTimeline({ items }: WeekTimelineProps) { export default function WeekTimeline({ items }: WeekTimelineProps) {
const navigate = useNavigate();
const today = useMemo(() => startOfDay(new Date()), []); const today = useMemo(() => startOfDay(new Date()), []);
const weekStart = useMemo(() => startOfWeek(today, { weekStartsOn: 1 }), [today]); const weekStart = useMemo(() => startOfWeek(today, { weekStartsOn: 1 }), [today]);
@ -41,12 +43,13 @@ export default function WeekTimeline({ items }: WeekTimelineProps) {
{days.map((day) => ( {days.map((day) => (
<div <div
key={day.key} key={day.key}
onClick={() => navigate('/calendar', { state: { date: day.key, view: 'timeGridDay' } })}
className={cn( className={cn(
'flex-1 flex flex-col items-center gap-1.5 rounded-lg py-3 px-2 transition-all duration-200 border', 'flex-1 flex flex-col items-center gap-1.5 rounded-lg py-3 px-2 transition-all duration-200 border cursor-pointer',
day.isToday day.isToday
? 'bg-accent/10 border-accent/30 shadow-[0_0_12px_hsl(var(--accent-color)/0.15)]' ? 'bg-accent/10 border-accent/30 shadow-[0_0_12px_hsl(var(--accent-color)/0.15)]'
: day.isPast : day.isPast
? 'border-transparent opacity-50' ? 'border-transparent opacity-50 hover:opacity-75'
: 'border-transparent hover:border-border/50' : 'border-transparent hover:border-border/50'
)} )}
> >

View File

@ -10,7 +10,10 @@ import LockOverlay from './LockOverlay';
export default function AppLayout() { export default function AppLayout() {
useTheme(); useTheme();
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(() => {
try { return JSON.parse(localStorage.getItem('umbra-sidebar-collapsed') || 'false'); }
catch { return false; }
});
const [mobileOpen, setMobileOpen] = useState(false); const [mobileOpen, setMobileOpen] = useState(false);
return ( return (
@ -19,7 +22,11 @@ export default function AppLayout() {
<div className="flex h-screen overflow-hidden bg-background"> <div className="flex h-screen overflow-hidden bg-background">
<Sidebar <Sidebar
collapsed={collapsed} collapsed={collapsed}
onToggle={() => setCollapsed(!collapsed)} onToggle={() => {
const next = !collapsed;
setCollapsed(next);
localStorage.setItem('umbra-sidebar-collapsed', JSON.stringify(next));
}}
mobileOpen={mobileOpen} mobileOpen={mobileOpen}
onMobileClose={() => setMobileOpen(false)} onMobileClose={() => setMobileOpen(false)}
/> />

View File

@ -1,4 +1,4 @@
import { useState } from 'react'; import { useState, useCallback } from 'react';
import { NavLink, useNavigate, useLocation } from 'react-router-dom'; import { NavLink, useNavigate, useLocation } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { import {
@ -20,6 +20,7 @@ import {
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
import { useLock } from '@/hooks/useLock'; import { useLock } from '@/hooks/useLock';
import { useConfirmAction } from '@/hooks/useConfirmAction';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import api from '@/lib/api'; import api from '@/lib/api';
import type { Project } from '@/types'; import type { Project } from '@/types';
@ -57,10 +58,12 @@ export default function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose
select: (data) => data.map(({ id, name }) => ({ id, name })), select: (data) => data.map(({ id, name }) => ({ id, name })),
}); });
const handleLogout = async () => { const doLogout = useCallback(async () => {
await logout(); await logout();
navigate('/login'); navigate('/login');
}; }, [logout, navigate]);
const { confirming: logoutConfirming, handleClick: handleLogout } = useConfirmAction(doLogout);
const isProjectsActive = location.pathname.startsWith('/projects'); const isProjectsActive = location.pathname.startsWith('/projects');
const showExpanded = !collapsed || mobileOpen; const showExpanded = !collapsed || mobileOpen;
@ -200,10 +203,15 @@ export default function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose
</NavLink> </NavLink>
<button <button
onClick={handleLogout} onClick={handleLogout}
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive" className={cn(
'flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
logoutConfirming
? 'bg-destructive/15 text-destructive'
: 'text-muted-foreground hover:bg-destructive/10 hover:text-destructive'
)}
> >
<LogOut className="h-5 w-5 shrink-0" /> <LogOut className="h-5 w-5 shrink-0" />
{showExpanded && <span>Logout</span>} {showExpanded && <span>{logoutConfirming ? 'Sure?' : 'Logout'}</span>}
</button> </button>
</div> </div>
</> </>

View File

@ -1,5 +1,5 @@
import { useState, useMemo, useRef, useEffect } from 'react'; import { useState, useMemo, useRef, useEffect } from 'react';
import { Plus, MapPin, Phone, Mail } from 'lucide-react'; import { Plus, MapPin, Phone, Mail, Tag, AlignLeft } from 'lucide-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner'; import { toast } from 'sonner';
import api, { getErrorMessage } from '@/lib/api'; import api, { getErrorMessage } from '@/lib/api';
@ -240,11 +240,11 @@ export default function LocationsPage() {
]; ];
const panelFields: PanelField[] = [ const panelFields: PanelField[] = [
{ label: 'Address', key: 'address', copyable: true, icon: MapPin },
{ label: 'Contact Number', key: 'contact_number', copyable: true, icon: Phone }, { label: 'Contact Number', key: 'contact_number', copyable: true, icon: Phone },
{ label: 'Email', key: 'email', copyable: true, icon: Mail }, { label: 'Email', key: 'email', copyable: true, icon: Mail },
{ label: 'Category', key: 'category' }, { label: 'Category', key: 'category', icon: Tag },
{ label: 'Notes', key: 'notes', multiline: true }, { label: 'Address', key: 'address', copyable: true, icon: MapPin, fullWidth: true },
{ label: 'Notes', key: 'notes', multiline: true, icon: AlignLeft, fullWidth: true },
]; ];
const renderPanel = () => ( const renderPanel = () => (
@ -283,7 +283,7 @@ export default function LocationsPage() {
); );
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full animate-fade-in">
{/* Header */} {/* Header */}
<div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0"> <div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0">
<h1 className="font-heading text-2xl font-bold tracking-tight">Locations</h1> <h1 className="font-heading text-2xl font-bold tracking-tight">Locations</h1>

View File

@ -1,5 +1,5 @@
import { useState, useMemo, useRef, useEffect } from 'react'; import { useState, useMemo, useRef, useEffect } from 'react';
import { Plus, Users, Star, Cake, Phone, Mail, MapPin } from 'lucide-react'; import { Plus, Users, Star, Cake, Phone, Mail, MapPin, Tag, Building2, Briefcase, AlignLeft } from 'lucide-react';
import type { LucideIcon } from 'lucide-react'; import type { LucideIcon } from 'lucide-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { format, parseISO, differenceInYears } from 'date-fns'; import { format, parseISO, differenceInYears } from 'date-fns';
@ -173,12 +173,12 @@ const panelFields: PanelField[] = [
{ label: 'Mobile', key: 'mobile', copyable: true, icon: Phone }, { label: 'Mobile', key: 'mobile', copyable: true, icon: Phone },
{ label: 'Phone', key: 'phone', copyable: true, icon: Phone }, { label: 'Phone', key: 'phone', copyable: true, icon: Phone },
{ label: 'Email', key: 'email', copyable: true, icon: Mail }, { label: 'Email', key: 'email', copyable: true, icon: Mail },
{ label: 'Address', key: 'address', copyable: true, icon: MapPin }, { label: 'Birthday', key: 'birthday_display', icon: Cake },
{ label: 'Birthday', key: 'birthday_display' }, { label: 'Category', key: 'category', icon: Tag },
{ label: 'Category', key: 'category' }, { label: 'Company', key: 'company', icon: Building2 },
{ label: 'Company', key: 'company' }, { label: 'Job Title', key: 'job_title', icon: Briefcase },
{ label: 'Job Title', key: 'job_title' }, { label: 'Address', key: 'address', copyable: true, icon: MapPin, fullWidth: true },
{ label: 'Notes', key: 'notes', multiline: true }, { label: 'Notes', key: 'notes', multiline: true, icon: AlignLeft, fullWidth: true },
]; ];
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -237,6 +237,9 @@ export default function PeoplePage() {
list = list.filter( list = list.filter(
(p) => (p) =>
p.name.toLowerCase().includes(q) || p.name.toLowerCase().includes(q) ||
p.first_name?.toLowerCase().includes(q) ||
p.last_name?.toLowerCase().includes(q) ||
p.nickname?.toLowerCase().includes(q) ||
p.email?.toLowerCase().includes(q) || p.email?.toLowerCase().includes(q) ||
p.mobile?.toLowerCase().includes(q) || p.mobile?.toLowerCase().includes(q) ||
p.phone?.toLowerCase().includes(q) || p.phone?.toLowerCase().includes(q) ||
@ -400,24 +403,25 @@ export default function PeoplePage() {
); );
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full animate-fade-in">
{/* Header */} {/* Header */}
<div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0"> <div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0">
<h1 className="font-heading text-2xl font-bold tracking-tight">People</h1> <h1 className="font-heading text-2xl font-bold tracking-tight">People</h1>
<CategoryFilterBar <div className="flex-1 min-w-0">
activeFilters={activeFilters} <CategoryFilterBar
pinnedLabel="Favourites" activeFilters={activeFilters}
showPinned={showPinned} pinnedLabel="Favourites"
categories={orderedCategories} showPinned={showPinned}
onToggleAll={toggleAll} categories={orderedCategories}
onTogglePinned={togglePinned} onToggleAll={toggleAll}
onToggleCategory={toggleCategory} onTogglePinned={togglePinned}
onSelectAllCategories={selectAllCategories} onToggleCategory={toggleCategory}
onReorderCategories={reorderCategories} onSelectAllCategories={selectAllCategories}
searchValue={search} onReorderCategories={reorderCategories}
onSearchChange={setSearch} searchValue={search}
/> onSearchChange={setSearch}
<div className="flex-1" /> />
</div>
<Button onClick={() => setShowForm(true)} size="sm" aria-label="Add person"> <Button onClick={() => setShowForm(true)} size="sm" aria-label="Add person">
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
Add Person Add Person

View File

@ -344,7 +344,7 @@ export default function ProjectDetail() {
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full animate-fade-in">
<div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0"> <div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0">
<Button variant="ghost" size="icon" onClick={() => navigate('/projects')}> <Button variant="ghost" size="icon" onClick={() => navigate('/projects')}>
<ArrowLeft className="h-5 w-5" /> <ArrowLeft className="h-5 w-5" />
@ -373,7 +373,7 @@ export default function ProjectDetail() {
project.due_date && project.status !== 'completed' && isPast(parseISO(project.due_date)); project.due_date && project.status !== 'completed' && isPast(parseISO(project.due_date));
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full animate-fade-in">
{/* Header */} {/* Header */}
<div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0"> <div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0">
<Button variant="ghost" size="icon" onClick={() => navigate('/projects')}> <Button variant="ghost" size="icon" onClick={() => navigate('/projects')}>

View File

@ -1,9 +1,11 @@
import { useState } from 'react'; import { useState, useMemo, useEffect } from 'react';
import { Plus, FolderKanban, Layers, PlayCircle, CheckCircle2 } from 'lucide-react'; import { useLocation } from 'react-router-dom';
import { Plus, FolderKanban, Layers, PlayCircle, CheckCircle2, Search } from 'lucide-react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import api from '@/lib/api'; import api from '@/lib/api';
import type { Project } from '@/types'; import type { Project } from '@/types';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { GridSkeleton } from '@/components/ui/skeleton'; import { GridSkeleton } from '@/components/ui/skeleton';
import { EmptyState } from '@/components/ui/empty-state'; import { EmptyState } from '@/components/ui/empty-state';
@ -18,9 +20,20 @@ const statusFilters = [
] as const; ] as const;
export default function ProjectsPage() { export default function ProjectsPage() {
const location = useLocation();
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
const [editingProject, setEditingProject] = useState<Project | null>(null); const [editingProject, setEditingProject] = useState<Project | null>(null);
const [statusFilter, setStatusFilter] = useState(''); const [statusFilter, setStatusFilter] = useState('');
const [search, setSearch] = useState('');
// Handle navigation state from dashboard
useEffect(() => {
const state = location.state as { filter?: string } | null;
if (state?.filter) {
setStatusFilter(state.filter);
window.history.replaceState({}, '');
}
}, [location.state]);
const { data: projects = [], isLoading } = useQuery({ const { data: projects = [], isLoading } = useQuery({
queryKey: ['projects'], queryKey: ['projects'],
@ -30,9 +43,16 @@ export default function ProjectsPage() {
}, },
}); });
const filteredProjects = statusFilter const filteredProjects = useMemo(() => {
? projects.filter((p) => p.status === statusFilter) let list = statusFilter ? projects.filter((p) => p.status === statusFilter) : projects;
: projects; if (search) {
const q = search.toLowerCase();
list = list.filter(
(p) => p.name.toLowerCase().includes(q) || p.description?.toLowerCase().includes(q)
);
}
return list;
}, [projects, statusFilter, search]);
const inProgressCount = projects.filter((p) => p.status === 'in_progress').length; const inProgressCount = projects.filter((p) => p.status === 'in_progress').length;
const completedCount = projects.filter((p) => p.status === 'completed').length; const completedCount = projects.filter((p) => p.status === 'completed').length;
@ -48,7 +68,7 @@ export default function ProjectsPage() {
}; };
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full animate-fade-in">
{/* Header */} {/* Header */}
<div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0"> <div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0">
<h1 className="font-heading text-2xl font-bold tracking-tight">Projects</h1> <h1 className="font-heading text-2xl font-bold tracking-tight">Projects</h1>
@ -75,6 +95,16 @@ export default function ProjectsPage() {
<div className="flex-1" /> <div className="flex-1" />
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder="Search..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-52 h-8 pl-8 text-sm"
/>
</div>
<Button onClick={() => setShowForm(true)} size="sm"> <Button onClick={() => setShowForm(true)} size="sm">
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
New Project New Project

View File

@ -7,6 +7,7 @@ import {
Calendar, User, Flag, Activity, Send, X, Save, Calendar, User, Flag, Activity, Send, X, Save,
} from 'lucide-react'; } from 'lucide-react';
import api, { getErrorMessage } from '@/lib/api'; import api, { getErrorMessage } from '@/lib/api';
import { formatUpdatedAt } from '@/components/shared/utils';
import type { ProjectTask, TaskComment, Person } from '@/types'; import type { ProjectTask, TaskComment, Person } from '@/types';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@ -558,6 +559,15 @@ export default function TaskDetailPanel({
<Send className="h-4 w-4" /> <Send className="h-4 w-4" />
</Button> </Button>
</div> </div>
{/* Updated at footer */}
{task.updated_at && (
<div className="pt-2 border-t border-border">
<span className="text-[11px] text-muted-foreground">
{formatUpdatedAt(task.updated_at)}
</span>
</div>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@ -0,0 +1,473 @@
import { useState, useEffect, useCallback } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { format, parseISO, isPast, isToday } from 'date-fns';
import {
X, Pencil, Trash2, Save, Bell, BellOff, Clock, Repeat, AlertCircle, AlignLeft,
} from 'lucide-react';
import api, { getErrorMessage } from '@/lib/api';
import type { Reminder } from '@/types';
import { useConfirmAction } from '@/hooks/useConfirmAction';
import { formatUpdatedAt } from '@/components/shared/utils';
import CopyableField from '@/components/shared/CopyableField';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Select } from '@/components/ui/select';
import { Label } from '@/components/ui/label';
// --- Types ---
interface ReminderDetailPanelProps {
reminder: Reminder | null;
isCreating?: boolean;
onClose: () => void;
onSaved?: () => void;
onDeleted?: () => void;
}
interface EditState {
title: string;
description: string;
remind_at: string;
recurrence_rule: string;
}
const recurrenceLabels: Record<string, string> = {
daily: 'Daily',
weekly: 'Weekly',
monthly: 'Monthly',
};
const QUERY_KEYS = [['reminders'], ['dashboard'], ['upcoming']] as const;
function buildEditState(reminder: Reminder): EditState {
return {
title: reminder.title,
description: reminder.description || '',
remind_at: reminder.remind_at ? reminder.remind_at.slice(0, 16) : '',
recurrence_rule: reminder.recurrence_rule || '',
};
}
function buildCreateState(): EditState {
return {
title: '',
description: '',
remind_at: '',
recurrence_rule: '',
};
}
// --- Component ---
export default function ReminderDetailPanel({
reminder,
isCreating = false,
onClose,
onSaved,
onDeleted,
}: ReminderDetailPanelProps) {
const queryClient = useQueryClient();
const [isEditing, setIsEditing] = useState(false);
const [editState, setEditState] = useState<EditState>(() =>
isCreating ? buildCreateState() : reminder ? buildEditState(reminder) : buildCreateState()
);
// Reset state when reminder changes
useEffect(() => {
setIsEditing(false);
if (reminder) setEditState(buildEditState(reminder));
}, [reminder?.id]);
// Enter edit mode when creating
useEffect(() => {
if (isCreating) {
setIsEditing(true);
setEditState(buildCreateState());
}
}, [isCreating]);
const invalidateAll = useCallback(() => {
QUERY_KEYS.forEach((key) => queryClient.invalidateQueries({ queryKey: [...key] }));
}, [queryClient]);
// --- Mutations ---
const saveMutation = useMutation({
mutationFn: async (data: EditState) => {
const payload = {
title: data.title,
description: data.description || null,
remind_at: data.remind_at || null,
recurrence_rule: data.recurrence_rule || null,
};
if (reminder && !isCreating) {
return api.put(`/reminders/${reminder.id}`, payload);
} else {
return api.post('/reminders', payload);
}
},
onSuccess: () => {
invalidateAll();
toast.success(isCreating ? 'Reminder created' : 'Reminder updated');
if (isCreating) {
onClose();
} else {
setIsEditing(false);
}
onSaved?.();
},
onError: (error) => {
toast.error(getErrorMessage(error, isCreating ? 'Failed to create reminder' : 'Failed to update reminder'));
},
});
const dismissMutation = useMutation({
mutationFn: async () => {
const { data } = await api.patch(`/reminders/${reminder!.id}/dismiss`);
return data;
},
onSuccess: () => {
invalidateAll();
toast.success('Reminder dismissed');
},
onError: () => {
toast.error('Failed to dismiss reminder');
},
});
const deleteMutation = useMutation({
mutationFn: async () => {
await api.delete(`/reminders/${reminder!.id}`);
},
onSuccess: () => {
invalidateAll();
toast.success('Reminder deleted');
onClose();
onDeleted?.();
},
onError: (error) => {
toast.error(getErrorMessage(error, 'Failed to delete reminder'));
},
});
const executeDelete = useCallback(() => deleteMutation.mutate(), [deleteMutation]);
const { confirming: confirmingDelete, handleClick: handleDeleteClick } = useConfirmAction(executeDelete);
// --- Handlers ---
const handleEditStart = () => {
if (reminder) setEditState(buildEditState(reminder));
setIsEditing(true);
};
const handleEditCancel = () => {
setIsEditing(false);
if (isCreating) {
onClose();
} else if (reminder) {
setEditState(buildEditState(reminder));
}
};
const handleEditSave = () => {
saveMutation.mutate(editState);
};
const updateField = <K extends keyof EditState>(key: K, value: EditState[K]) => {
setEditState((s) => ({ ...s, [key]: value }));
};
// Empty state
if (!reminder && !isCreating) {
return (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<Bell className="h-8 w-8 mb-3 opacity-40" />
<p className="text-sm">Select a reminder to view details</p>
</div>
);
}
// View data
const remindDate = reminder?.remind_at ? parseISO(reminder.remind_at) : null;
const isOverdue = !reminder?.is_dismissed && remindDate && isPast(remindDate) && !isToday(remindDate);
const isDueToday = remindDate ? isToday(remindDate) : false;
return (
<div className="flex flex-col h-full bg-card border-l border-border overflow-hidden">
{/* Header */}
<div className="px-5 py-4 border-b border-border shrink-0">
<div className="flex items-start justify-between gap-3">
{isEditing && !isCreating ? (
<Input
value={editState.title}
onChange={(e) => updateField('title', e.target.value)}
className="h-8 text-base font-semibold flex-1"
placeholder="Reminder title"
autoFocus
/>
) : isCreating ? (
<h3 className="font-heading text-lg font-semibold">New Reminder</h3>
) : (
<div className="flex items-center gap-3 min-w-0 flex-1">
<Bell
className={`h-4 w-4 shrink-0 ${
isOverdue ? 'text-red-400' : reminder!.is_dismissed ? 'text-muted-foreground' : 'text-orange-400'
}`}
/>
<h3 className={`font-heading text-lg font-semibold truncate ${reminder!.is_dismissed ? 'line-through text-muted-foreground' : ''}`}>
{reminder!.title}
</h3>
</div>
)}
<div className="flex items-center gap-1 shrink-0">
{isEditing ? (
<>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-green-400 hover:text-green-300"
onClick={handleEditSave}
disabled={saveMutation.isPending}
title="Save"
>
<Save className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={handleEditCancel}
title="Cancel"
>
<X className="h-3.5 w-3.5" />
</Button>
</>
) : (
<>
{!reminder!.is_dismissed && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 hover:bg-orange-500/10 hover:text-orange-400"
onClick={() => dismissMutation.mutate()}
disabled={dismissMutation.isPending}
title="Dismiss reminder"
>
<BellOff className="h-3.5 w-3.5" />
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={handleEditStart}
title="Edit reminder"
>
<Pencil className="h-3.5 w-3.5" />
</Button>
{confirmingDelete ? (
<Button
variant="ghost"
onClick={handleDeleteClick}
disabled={deleteMutation.isPending}
className="h-7 px-2 bg-destructive/20 text-destructive text-[11px] font-medium"
title="Confirm delete"
>
Sure?
</Button>
) : (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-destructive"
onClick={handleDeleteClick}
disabled={deleteMutation.isPending}
title="Delete reminder"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={onClose}
title="Close panel"
>
<X className="h-3.5 w-3.5" />
</Button>
</>
)}
</div>
</div>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-3">
{isEditing ? (
/* Edit / Create mode */
<div className="space-y-4">
{isCreating && (
<div className="space-y-1">
<Label htmlFor="reminder-title" required>Title</Label>
<Input
id="reminder-title"
value={editState.title}
onChange={(e) => updateField('title', e.target.value)}
placeholder="Reminder title"
required
autoFocus
/>
</div>
)}
<div className="space-y-1">
<Label htmlFor="reminder-desc">Description</Label>
<Textarea
id="reminder-desc"
value={editState.description}
onChange={(e) => updateField('description', e.target.value)}
placeholder="Add a description..."
rows={3}
className="text-sm resize-none"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label htmlFor="reminder-at">Remind At</Label>
<Input
id="reminder-at"
type="datetime-local"
value={editState.remind_at}
onChange={(e) => updateField('remind_at', e.target.value)}
className="text-xs"
/>
</div>
<div className="space-y-1">
<Label htmlFor="reminder-recurrence">Recurrence</Label>
<Select
id="reminder-recurrence"
value={editState.recurrence_rule}
onChange={(e) => updateField('recurrence_rule', e.target.value)}
className="text-xs"
>
<option value="">None</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
</Select>
</div>
</div>
{/* Save / Cancel at bottom */}
<div className="flex items-center justify-end gap-2 pt-2 border-t border-border">
<Button variant="outline" size="sm" onClick={handleEditCancel}>
Cancel
</Button>
<Button size="sm" onClick={handleEditSave} disabled={saveMutation.isPending}>
{saveMutation.isPending ? 'Saving...' : isCreating ? 'Create' : 'Update'}
</Button>
</div>
</div>
) : (
/* View mode */
<>
{/* 2-column grid: Status, Recurrence, Remind At, Snoozed Until */}
<div className="grid grid-cols-2 gap-3">
{/* Status */}
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
{reminder!.is_dismissed ? (
<BellOff className="h-3 w-3" />
) : isOverdue ? (
<AlertCircle className="h-3 w-3" />
) : (
<Bell className="h-3 w-3" />
)}
Status
</div>
{reminder!.is_dismissed ? (
<p className="text-sm text-muted-foreground">Dismissed</p>
) : isOverdue ? (
<p className="text-sm text-red-400">Overdue</p>
) : isDueToday ? (
<p className="text-sm text-yellow-400">Due today</p>
) : (
<p className="text-sm text-orange-400">Active</p>
)}
</div>
{/* Recurrence */}
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
<Repeat className="h-3 w-3" />
Recurrence
</div>
{reminder!.recurrence_rule ? (
<p className="text-sm">{recurrenceLabels[reminder!.recurrence_rule] || reminder!.recurrence_rule}</p>
) : (
<p className="text-sm text-muted-foreground"></p>
)}
</div>
{/* Remind At */}
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
<Clock className="h-3 w-3" />
Remind At
</div>
{remindDate ? (
<CopyableField
value={format(remindDate, 'EEEE, MMMM d, yyyy · h:mm a')}
icon={Clock}
label="Remind at"
/>
) : (
<p className="text-sm text-muted-foreground"></p>
)}
</div>
{/* Snoozed Until */}
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
<Clock className="h-3 w-3" />
Snoozed Until
</div>
{reminder!.snoozed_until ? (
<p className="text-sm">{format(parseISO(reminder!.snoozed_until), 'MMM d, h:mm a')}</p>
) : (
<p className="text-sm text-muted-foreground"></p>
)}
</div>
</div>
{/* Description — full width */}
{reminder!.description && (
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
<AlignLeft className="h-3 w-3" />
Description
</div>
<p className="text-sm whitespace-pre-wrap text-muted-foreground leading-relaxed">
{reminder!.description}
</p>
</div>
)}
{/* Updated at */}
<div className="pt-2 border-t border-border">
<span className="text-[11px] text-muted-foreground">
{formatUpdatedAt(reminder!.updated_at)}
</span>
</div>
</>
)}
</div>
</div>
);
}

View File

@ -1,4 +1,5 @@
import { useState, useMemo } from 'react'; import { useState, useMemo, useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { Plus, Bell, BellOff, AlertCircle, Search } from 'lucide-react'; import { Plus, Bell, BellOff, AlertCircle, Search } from 'lucide-react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { isPast, isToday, parseISO } from 'date-fns'; import { isPast, isToday, parseISO } from 'date-fns';
@ -9,7 +10,7 @@ import { Input } from '@/components/ui/input';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { ListSkeleton } from '@/components/ui/skeleton'; import { ListSkeleton } from '@/components/ui/skeleton';
import ReminderList from './ReminderList'; import ReminderList from './ReminderList';
import ReminderForm from './ReminderForm'; import ReminderDetailPanel from './ReminderDetailPanel';
const statusFilters = [ const statusFilters = [
{ value: 'active', label: 'Active' }, { value: 'active', label: 'Active' },
@ -20,11 +21,25 @@ const statusFilters = [
type StatusFilter = (typeof statusFilters)[number]['value']; type StatusFilter = (typeof statusFilters)[number]['value'];
export default function RemindersPage() { export default function RemindersPage() {
const [showForm, setShowForm] = useState(false); const location = useLocation();
const [editingReminder, setEditingReminder] = useState<Reminder | null>(null);
// Panel state
const [selectedReminderId, setSelectedReminderId] = useState<number | null>(null);
const [panelMode, setPanelMode] = useState<'closed' | 'view' | 'create'>('closed');
const [filter, setFilter] = useState<StatusFilter>('active'); const [filter, setFilter] = useState<StatusFilter>('active');
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
// Handle navigation state from dashboard
useEffect(() => {
const state = location.state as { reminderId?: number } | null;
if (state?.reminderId) {
setSelectedReminderId(state.reminderId);
setPanelMode('view');
window.history.replaceState({}, '');
}
}, [location.state]);
const { data: reminders = [], isLoading } = useQuery({ const { data: reminders = [], isLoading } = useQuery({
queryKey: ['reminders'], queryKey: ['reminders'],
queryFn: async () => { queryFn: async () => {
@ -50,18 +65,39 @@ export default function RemindersPage() {
).length; ).length;
const dismissedCount = reminders.filter((r) => r.is_dismissed).length; const dismissedCount = reminders.filter((r) => r.is_dismissed).length;
const handleEdit = (reminder: Reminder) => { const panelOpen = panelMode !== 'closed';
setEditingReminder(reminder); const selectedReminder = useMemo(
setShowForm(true); () => reminders.find((r) => r.id === selectedReminderId) ?? null,
[selectedReminderId, reminders],
);
const handleSelect = (reminder: Reminder) => {
setSelectedReminderId(reminder.id);
setPanelMode('view');
}; };
const handleCloseForm = () => { const handleCreateNew = () => {
setShowForm(false); setSelectedReminderId(null);
setEditingReminder(null); setPanelMode('create');
}; };
const handlePanelClose = () => {
setPanelMode('closed');
setSelectedReminderId(null);
};
// Escape key closes panel
useEffect(() => {
if (!panelOpen) return;
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') handlePanelClose();
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [panelOpen]);
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full animate-fade-in">
{/* Header */} {/* Header */}
<div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0"> <div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0">
<h1 className="font-heading text-2xl font-bold tracking-tight">Reminders</h1> <h1 className="font-heading text-2xl font-bold tracking-tight">Reminders</h1>
@ -87,82 +123,123 @@ export default function RemindersPage() {
))} ))}
</div> </div>
<div className="relative ml-2"> <div className="flex-1" />
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" /> <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input <Input
placeholder="Search..." placeholder="Search..."
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
className="w-52 h-8 pl-8 text-sm" className="w-52 h-8 pl-8 text-sm ring-inset"
/> />
</div> </div>
<div className="flex-1" /> <Button onClick={handleCreateNew} size="sm">
<Button onClick={() => setShowForm(true)} size="sm">
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
Add Reminder Add Reminder
</Button> </Button>
</div> </div>
<div className="flex-1 overflow-y-auto px-6 py-5"> {/* Main content — list + detail panel */}
{/* Summary stats */} <div className="flex-1 overflow-hidden flex">
{!isLoading && reminders.length > 0 && ( <div
<div className="grid gap-2.5 grid-cols-3 mb-5"> className={`overflow-y-auto transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${
<Card className="bg-gradient-to-br from-accent/[0.03] to-transparent"> panelOpen ? 'w-full lg:w-[55%]' : 'w-full'
<CardContent className="p-4 flex items-center gap-3"> }`}
<div className="p-1.5 rounded-md bg-orange-500/10"> >
<Bell className="h-4 w-4 text-orange-400" /> <div className="px-6 py-5">
</div> {/* Summary stats */}
<div> {!isLoading && reminders.length > 0 && (
<p className="text-[10px] tracking-wider uppercase text-muted-foreground"> <div className="grid gap-2.5 grid-cols-3 mb-5">
Active <Card className="bg-gradient-to-br from-accent/[0.03] to-transparent">
</p> <CardContent className="p-4 flex items-center gap-3">
<p className="font-heading text-xl font-bold tabular-nums">{activeCount}</p> <div className="p-1.5 rounded-md bg-orange-500/10">
</div> <Bell className="h-4 w-4 text-orange-400" />
</CardContent> </div>
</Card> <div>
<Card className="bg-gradient-to-br from-accent/[0.03] to-transparent"> <p className="text-[10px] tracking-wider uppercase text-muted-foreground">
<CardContent className="p-4 flex items-center gap-3"> Active
<div className="p-1.5 rounded-md bg-red-500/10"> </p>
<AlertCircle className="h-4 w-4 text-red-400" /> <p className="font-heading text-xl font-bold tabular-nums">{activeCount}</p>
</div> </div>
<div> </CardContent>
<p className="text-[10px] tracking-wider uppercase text-muted-foreground"> </Card>
Overdue <Card className="bg-gradient-to-br from-accent/[0.03] to-transparent">
</p> <CardContent className="p-4 flex items-center gap-3">
<p className="font-heading text-xl font-bold tabular-nums">{overdueCount}</p> <div className="p-1.5 rounded-md bg-red-500/10">
</div> <AlertCircle className="h-4 w-4 text-red-400" />
</CardContent> </div>
</Card> <div>
<Card className="bg-gradient-to-br from-accent/[0.03] to-transparent"> <p className="text-[10px] tracking-wider uppercase text-muted-foreground">
<CardContent className="p-4 flex items-center gap-3"> Overdue
<div className="p-1.5 rounded-md bg-gray-500/10"> </p>
<BellOff className="h-4 w-4 text-gray-400" /> <p className="font-heading text-xl font-bold tabular-nums">{overdueCount}</p>
</div> </div>
<div> </CardContent>
<p className="text-[10px] tracking-wider uppercase text-muted-foreground"> </Card>
Dismissed <Card className="bg-gradient-to-br from-accent/[0.03] to-transparent">
</p> <CardContent className="p-4 flex items-center gap-3">
<p className="font-heading text-xl font-bold tabular-nums">{dismissedCount}</p> <div className="p-1.5 rounded-md bg-gray-500/10">
</div> <BellOff className="h-4 w-4 text-gray-400" />
</CardContent> </div>
</Card> <div>
</div> <p className="text-[10px] tracking-wider uppercase text-muted-foreground">
)} Dismissed
</p>
<p className="font-heading text-xl font-bold tabular-nums">{dismissedCount}</p>
</div>
</CardContent>
</Card>
</div>
)}
{isLoading ? ( {isLoading ? (
<ListSkeleton rows={6} /> <ListSkeleton rows={6} />
) : ( ) : (
<ReminderList <ReminderList
reminders={filteredReminders} reminders={filteredReminders}
onEdit={handleEdit} onEdit={handleSelect}
onAdd={() => setShowForm(true)} onAdd={handleCreateNew}
/>
)}
</div>
</div>
{/* Detail panel (desktop) */}
<div
className={`overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${
panelOpen ? 'hidden lg:block lg:w-[45%]' : 'w-0 opacity-0'
}`}
>
<ReminderDetailPanel
reminder={panelMode === 'view' ? selectedReminder : null}
isCreating={panelMode === 'create'}
onClose={handlePanelClose}
onSaved={panelMode === 'create' ? handlePanelClose : undefined}
/> />
)} </div>
</div> </div>
{showForm && <ReminderForm reminder={editingReminder} onClose={handleCloseForm} />} {/* Mobile detail panel overlay */}
{panelOpen && (
<div
className="lg:hidden fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
onClick={handlePanelClose}
>
<div
className="fixed inset-y-0 right-0 w-full sm:w-[400px] bg-card border-l border-border shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<ReminderDetailPanel
reminder={panelMode === 'view' ? selectedReminder : null}
isCreating={panelMode === 'create'}
onClose={handlePanelClose}
onSaved={panelMode === 'create' ? handlePanelClose : undefined}
/>
</div>
</div>
)}
</div> </div>
); );
} }

View File

@ -210,7 +210,7 @@ export default function SettingsPage() {
}; };
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full animate-fade-in">
{/* Page header — matches Stage 4-5 pages */} {/* Page header — matches Stage 4-5 pages */}
<div className="border-b bg-card px-6 h-16 flex items-center gap-3 shrink-0"> <div className="border-b bg-card px-6 h-16 flex items-center gap-3 shrink-0">
<Settings className="h-5 w-5 text-accent" aria-hidden="true" /> <Settings className="h-5 w-5 text-accent" aria-hidden="true" />

View File

@ -118,7 +118,6 @@ export default function CategoryFilterBar({
onSearchChange, onSearchChange,
}: CategoryFilterBarProps) { }: CategoryFilterBarProps) {
const [otherOpen, setOtherOpen] = useState(false); const [otherOpen, setOtherOpen] = useState(false);
const [searchCollapsed, setSearchCollapsed] = useState(false);
const searchInputRef = useRef<HTMLInputElement>(null); const searchInputRef = useRef<HTMLInputElement>(null);
const isAllActive = activeFilters.length === 0; const isAllActive = activeFilters.length === 0;
@ -129,16 +128,6 @@ export default function CategoryFilterBar({
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
); );
// Collapse search if there are many categories
useEffect(() => {
setSearchCollapsed(categories.length >= 4);
}, [categories.length]);
const handleExpandSearch = () => {
setSearchCollapsed(false);
setTimeout(() => searchInputRef.current?.focus(), 50);
};
const handleDragEnd = (event: DragEndEvent) => { const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event; const { active, over } = event;
if (!over || active.id === over.id || !onReorderCategories) return; if (!over || active.id === over.id || !onReorderCategories) return;
@ -254,32 +243,18 @@ export default function CategoryFilterBar({
<div className="flex-1" /> <div className="flex-1" />
{/* Search */} {/* Search */}
{searchCollapsed ? ( <div className="relative shrink-0">
<button <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground pointer-events-none" />
type="button" <Input
onClick={handleExpandSearch} ref={searchInputRef}
aria-label="Expand search" type="search"
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-card-elevated transition-colors duration-150" placeholder="Search..."
> value={searchValue}
<Search className="h-4 w-4" /> onChange={(e) => onSearchChange(e.target.value)}
</button> className="w-52 h-8 pl-8 text-sm ring-inset"
) : ( aria-label="Search"
<div className="relative transition-all duration-200"> />
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground pointer-events-none" /> </div>
<Input
ref={searchInputRef}
type="search"
placeholder="Search..."
value={searchValue}
onChange={(e) => onSearchChange(e.target.value)}
onBlur={() => {
if (!searchValue && categories.length >= 4) setSearchCollapsed(true);
}}
className="w-44 h-8 pl-8 text-sm"
aria-label="Search"
/>
</div>
)}
</div> </div>
); );
} }

View File

@ -11,6 +11,7 @@ export interface PanelField {
copyable?: boolean; copyable?: boolean;
icon?: LucideIcon; icon?: LucideIcon;
multiline?: boolean; multiline?: boolean;
fullWidth?: boolean;
} }
interface EntityDetailPanelProps<T> { interface EntityDetailPanelProps<T> {
@ -81,29 +82,54 @@ export function EntityDetailPanel<T>({
{/* Body */} {/* Body */}
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-3"> <div className="flex-1 overflow-y-auto px-5 py-4 space-y-3">
{fields.map((field) => { {(() => {
const value = getValue(item, field.key); const gridFields = fields.filter((f) => !f.fullWidth && getValue(item, f.key));
if (!value) return null; const fullWidthFields = fields.filter((f) => f.fullWidth && getValue(item, f.key));
return ( return (
<div key={field.key}> <>
{field.copyable ? ( {gridFields.length > 0 && (
<div className="space-y-0.5"> <div className="grid grid-cols-2 gap-3">
<p className="text-[11px] uppercase tracking-wider text-muted-foreground"> {gridFields.map((field) => {
{field.label} const value = getValue(item, field.key)!;
</p> return (
<CopyableField value={value} icon={field.icon} label={field.label} /> <div key={field.key} className="space-y-1">
</div> <div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
) : ( {field.icon && <field.icon className="h-3 w-3" />}
<div className="space-y-0.5"> {field.label}
<p className="text-[11px] uppercase tracking-wider text-muted-foreground"> </div>
{field.label} {field.copyable ? (
</p> <CopyableField value={value} icon={field.icon} label={field.label} />
<p className={`text-sm ${field.multiline ? 'whitespace-pre-wrap' : ''}`}>{value}</p> ) : (
<p className="text-sm">{value}</p>
)}
</div>
);
})}
</div> </div>
)} )}
</div>
{fullWidthFields.map((field) => {
const value = getValue(item, field.key)!;
return (
<div key={field.key} className="space-y-1">
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
{field.icon && <field.icon className="h-3 w-3" />}
{field.label}
</div>
{field.copyable ? (
<CopyableField value={value} icon={field.icon} label={field.label} />
) : (
<p className={`text-sm ${field.multiline ? 'whitespace-pre-wrap text-muted-foreground leading-relaxed' : ''}`}>
{value}
</p>
)}
</div>
);
})}
</>
); );
})} })()}
</div> </div>
{/* Footer */} {/* Footer */}

View File

@ -0,0 +1,548 @@
import { useState, useEffect, useCallback } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { format, parseISO, isToday } from 'date-fns';
import {
X, Pencil, Trash2, Save, Clock, Calendar, Flag, Tag, Repeat, CheckSquare, AlertCircle, AlignLeft,
} from 'lucide-react';
import api, { getErrorMessage } from '@/lib/api';
import type { Todo } from '@/types';
import { isTodoOverdue } from '@/lib/utils';
import { useConfirmAction } from '@/hooks/useConfirmAction';
import { formatUpdatedAt } from '@/components/shared/utils';
import CopyableField from '@/components/shared/CopyableField';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Select } from '@/components/ui/select';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
// --- Types ---
export interface TodoCreateDefaults {
category?: string;
}
interface TodoDetailPanelProps {
todo: Todo | null;
isCreating?: boolean;
createDefaults?: TodoCreateDefaults | null;
onClose: () => void;
onSaved?: () => void;
onDeleted?: () => void;
}
interface EditState {
title: string;
description: string;
priority: string;
due_date: string;
due_time: string;
category: string;
recurrence_rule: string;
}
const priorityColors: Record<string, string> = {
none: 'bg-gray-500/20 text-gray-400',
low: 'bg-green-500/20 text-green-400',
medium: 'bg-yellow-500/20 text-yellow-400',
high: 'bg-red-500/20 text-red-400',
};
const recurrenceLabels: Record<string, string> = {
daily: 'Daily',
weekly: 'Weekly',
monthly: 'Monthly',
};
const QUERY_KEYS = [['todos'], ['dashboard'], ['upcoming']] as const;
function buildEditState(todo: Todo): EditState {
return {
title: todo.title,
description: todo.description || '',
priority: todo.priority,
due_date: todo.due_date ? todo.due_date.slice(0, 10) : '',
due_time: todo.due_time ? todo.due_time.slice(0, 5) : '',
category: todo.category || '',
recurrence_rule: todo.recurrence_rule || '',
};
}
function buildCreateState(defaults?: TodoCreateDefaults | null): EditState {
return {
title: '',
description: '',
priority: 'medium',
due_date: '',
due_time: '',
category: defaults?.category || '',
recurrence_rule: '',
};
}
// --- Component ---
export default function TodoDetailPanel({
todo,
isCreating = false,
createDefaults,
onClose,
onSaved,
onDeleted,
}: TodoDetailPanelProps) {
const queryClient = useQueryClient();
const [isEditing, setIsEditing] = useState(false);
const [editState, setEditState] = useState<EditState>(() =>
isCreating ? buildCreateState(createDefaults) : todo ? buildEditState(todo) : buildCreateState()
);
// Reset state when todo changes
useEffect(() => {
setIsEditing(false);
if (todo) setEditState(buildEditState(todo));
}, [todo?.id]);
// Enter edit mode when creating
useEffect(() => {
if (isCreating) {
setIsEditing(true);
setEditState(buildCreateState(createDefaults));
}
}, [isCreating, createDefaults]);
const invalidateAll = useCallback(() => {
QUERY_KEYS.forEach((key) => queryClient.invalidateQueries({ queryKey: [...key] }));
}, [queryClient]);
// --- Mutations ---
const saveMutation = useMutation({
mutationFn: async (data: EditState) => {
const payload = {
title: data.title,
description: data.description || null,
priority: data.priority,
due_date: data.due_date || null,
due_time: data.due_time || null,
category: data.category || null,
recurrence_rule: data.recurrence_rule || null,
};
if (todo && !isCreating) {
return api.put(`/todos/${todo.id}`, payload);
} else {
return api.post('/todos', payload);
}
},
onSuccess: () => {
invalidateAll();
toast.success(isCreating ? 'Todo created' : 'Todo updated');
if (isCreating) {
onClose();
} else {
setIsEditing(false);
}
onSaved?.();
},
onError: (error) => {
toast.error(getErrorMessage(error, isCreating ? 'Failed to create todo' : 'Failed to update todo'));
},
});
const toggleMutation = useMutation({
mutationFn: async () => {
const { data } = await api.patch(`/todos/${todo!.id}/toggle`);
return data;
},
onSuccess: () => {
invalidateAll();
toast.success(todo!.completed ? 'Todo marked incomplete' : 'Todo completed!');
},
onError: () => {
toast.error('Failed to update todo');
},
});
const deleteMutation = useMutation({
mutationFn: async () => {
await api.delete(`/todos/${todo!.id}`);
},
onSuccess: () => {
invalidateAll();
toast.success('Todo deleted');
onClose();
onDeleted?.();
},
onError: (error) => {
toast.error(getErrorMessage(error, 'Failed to delete todo'));
},
});
const executeDelete = useCallback(() => deleteMutation.mutate(), [deleteMutation]);
const { confirming: confirmingDelete, handleClick: handleDeleteClick } = useConfirmAction(executeDelete);
// --- Handlers ---
const handleEditStart = () => {
if (todo) setEditState(buildEditState(todo));
setIsEditing(true);
};
const handleEditCancel = () => {
setIsEditing(false);
if (isCreating) {
onClose();
} else if (todo) {
setEditState(buildEditState(todo));
}
};
const handleEditSave = () => {
saveMutation.mutate(editState);
};
const updateField = <K extends keyof EditState>(key: K, value: EditState[K]) => {
setEditState((s) => ({ ...s, [key]: value }));
};
// Empty state
if (!todo && !isCreating) {
return (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<CheckSquare className="h-8 w-8 mb-3 opacity-40" />
<p className="text-sm">Select a todo to view details</p>
</div>
);
}
// View data
const dueDate = todo?.due_date ? parseISO(todo.due_date) : null;
const isDueToday = dueDate ? isToday(dueDate) : false;
const isOverdue = todo ? isTodoOverdue(todo.due_date, todo.completed) : false;
return (
<div className="flex flex-col h-full bg-card border-l border-border overflow-hidden">
{/* Header */}
<div className="px-5 py-4 border-b border-border shrink-0">
<div className="flex items-start justify-between gap-3">
{isEditing && !isCreating ? (
<Input
value={editState.title}
onChange={(e) => updateField('title', e.target.value)}
className="h-8 text-base font-semibold flex-1"
placeholder="Todo title"
autoFocus
/>
) : isCreating ? (
<h3 className="font-heading text-lg font-semibold">New Todo</h3>
) : (
<div className="flex items-center gap-3 min-w-0 flex-1">
<span onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={todo!.completed}
onChange={() => toggleMutation.mutate()}
disabled={toggleMutation.isPending}
/>
</span>
<h3 className={`font-heading text-lg font-semibold truncate ${todo!.completed ? 'line-through text-muted-foreground' : ''}`}>
{todo!.title}
</h3>
</div>
)}
<div className="flex items-center gap-1 shrink-0">
{isEditing ? (
<>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-green-400 hover:text-green-300"
onClick={handleEditSave}
disabled={saveMutation.isPending}
title="Save"
>
<Save className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={handleEditCancel}
title="Cancel"
>
<X className="h-3.5 w-3.5" />
</Button>
</>
) : (
<>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={handleEditStart}
title="Edit todo"
>
<Pencil className="h-3.5 w-3.5" />
</Button>
{confirmingDelete ? (
<Button
variant="ghost"
onClick={handleDeleteClick}
disabled={deleteMutation.isPending}
className="h-7 px-2 bg-destructive/20 text-destructive text-[11px] font-medium"
title="Confirm delete"
>
Sure?
</Button>
) : (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-destructive"
onClick={handleDeleteClick}
disabled={deleteMutation.isPending}
title="Delete todo"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={onClose}
title="Close panel"
>
<X className="h-3.5 w-3.5" />
</Button>
</>
)}
</div>
</div>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-3">
{isEditing ? (
/* Edit / Create mode */
<div className="space-y-4">
{isCreating && (
<div className="space-y-1">
<Label htmlFor="todo-title" required>Title</Label>
<Input
id="todo-title"
value={editState.title}
onChange={(e) => updateField('title', e.target.value)}
placeholder="Todo title"
required
autoFocus
/>
</div>
)}
<div className="space-y-1">
<Label htmlFor="todo-desc">Description</Label>
<Textarea
id="todo-desc"
value={editState.description}
onChange={(e) => updateField('description', e.target.value)}
placeholder="Add a description..."
rows={3}
className="text-sm resize-none"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label htmlFor="todo-priority">Priority</Label>
<Select
id="todo-priority"
value={editState.priority}
onChange={(e) => updateField('priority', e.target.value)}
className="text-xs"
>
<option value="none">None</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</Select>
</div>
<div className="space-y-1">
<Label htmlFor="todo-category">Category</Label>
<Input
id="todo-category"
value={editState.category}
onChange={(e) => updateField('category', e.target.value)}
placeholder="e.g., Work"
className="text-xs"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label htmlFor="todo-due-date">Due Date</Label>
<Input
id="todo-due-date"
type="date"
value={editState.due_date}
onChange={(e) => updateField('due_date', e.target.value)}
className="text-xs"
/>
</div>
<div className="space-y-1">
<Label htmlFor="todo-due-time">Due Time</Label>
<Input
id="todo-due-time"
type="time"
value={editState.due_time}
onChange={(e) => updateField('due_time', e.target.value)}
className="text-xs"
/>
</div>
</div>
<div className="space-y-1">
<Label htmlFor="todo-recurrence">Recurrence</Label>
<Select
id="todo-recurrence"
value={editState.recurrence_rule}
onChange={(e) => updateField('recurrence_rule', e.target.value)}
className="text-xs"
>
<option value="">None</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
</Select>
</div>
{/* Save / Cancel at bottom */}
<div className="flex items-center justify-end gap-2 pt-2 border-t border-border">
<Button variant="outline" size="sm" onClick={handleEditCancel}>
Cancel
</Button>
<Button size="sm" onClick={handleEditSave} disabled={saveMutation.isPending}>
{saveMutation.isPending ? 'Saving...' : isCreating ? 'Create' : 'Update'}
</Button>
</div>
</div>
) : (
/* View mode */
<>
{/* 2-column grid: Priority, Category, Due Date, Recurrence */}
<div className="grid grid-cols-2 gap-3">
{/* Priority */}
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
<Flag className="h-3 w-3" />
Priority
</div>
<Badge className={`text-[9px] px-1.5 py-0.5 rounded-full ${priorityColors[todo!.priority] ?? ''}`}>
{todo!.priority}
</Badge>
</div>
{/* Category */}
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
<Tag className="h-3 w-3" />
Category
</div>
{todo!.category ? (
<Badge className="text-[9px] px-1.5 py-0.5 bg-blue-500/15 text-blue-400">
{todo!.category}
</Badge>
) : (
<p className="text-sm text-muted-foreground"></p>
)}
</div>
{/* Due Date */}
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
{isOverdue ? <AlertCircle className="h-3 w-3" /> : <Calendar className="h-3 w-3" />}
Due Date
</div>
{dueDate ? (
<CopyableField
value={`${isOverdue ? 'Overdue · ' : isDueToday ? 'Today · ' : ''}${format(dueDate, 'EEEE, MMMM d, yyyy')}${todo!.due_time ? ` at ${todo!.due_time.slice(0, 5)}` : ''}`}
icon={isOverdue ? AlertCircle : Calendar}
label="Due date"
/>
) : todo!.due_time ? (
<CopyableField value={todo!.due_time.slice(0, 5)} icon={Clock} label="Due time" />
) : (
<p className="text-sm text-muted-foreground"></p>
)}
</div>
{/* Recurrence */}
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
<Repeat className="h-3 w-3" />
Recurrence
</div>
{todo!.recurrence_rule ? (
<p className="text-sm">{recurrenceLabels[todo!.recurrence_rule] || todo!.recurrence_rule}</p>
) : (
<p className="text-sm text-muted-foreground"></p>
)}
</div>
</div>
{/* Completion status — full width */}
{todo!.completed && todo!.completed_at && (
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
<CheckSquare className="h-3 w-3" />
Completed
</div>
<p className="text-sm text-green-400">
{format(parseISO(todo!.completed_at), 'MMM d, yyyy · h:mm a')}
</p>
</div>
)}
{/* Reset info — full width */}
{todo!.completed && todo!.recurrence_rule && todo!.reset_at && (
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
<Repeat className="h-3 w-3" />
Resets
</div>
<p className="text-sm text-purple-400">
{format(parseISO(todo!.reset_at), 'EEE, MMM d')}
{todo!.next_due_date && ` · Next due ${format(parseISO(todo!.next_due_date), 'MMM d')}`}
</p>
</div>
)}
{/* Description — full width */}
{todo!.description && (
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
<AlignLeft className="h-3 w-3" />
Description
</div>
<p className="text-sm whitespace-pre-wrap text-muted-foreground leading-relaxed">
{todo!.description}
</p>
</div>
)}
{/* Updated at */}
<div className="pt-2 border-t border-border">
<span className="text-[11px] text-muted-foreground">
{formatUpdatedAt(todo!.updated_at)}
</span>
</div>
</>
)}
</div>
</div>
);
}

View File

@ -1,17 +1,17 @@
import { useState, useMemo } from 'react'; import { useState, useMemo, useEffect } from 'react';
import { Plus, CheckSquare, CheckCircle2, AlertCircle, Search, ChevronDown } from 'lucide-react'; import { useLocation } from 'react-router-dom';
import { Plus, CheckSquare, CheckCircle2, AlertCircle } from 'lucide-react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import api from '@/lib/api'; import api from '@/lib/api';
import type { Todo } from '@/types'; import type { Todo } from '@/types';
import { isTodoOverdue } from '@/lib/utils'; import { isTodoOverdue } from '@/lib/utils';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { ListSkeleton } from '@/components/ui/skeleton'; import { ListSkeleton } from '@/components/ui/skeleton';
import { CategoryFilterBar } from '@/components/shared';
import { useCategoryOrder } from '@/hooks/useCategoryOrder';
import TodoList from './TodoList'; import TodoList from './TodoList';
import TodoForm from './TodoForm'; import TodoDetailPanel from './TodoDetailPanel';
const priorityFilters = [ const priorityFilters = [
{ value: '', label: 'All' }, { value: '', label: 'All' },
@ -22,14 +22,27 @@ const priorityFilters = [
] as const; ] as const;
export default function TodosPage() { export default function TodosPage() {
const [showForm, setShowForm] = useState(false); const location = useLocation();
const [editingTodo, setEditingTodo] = useState<Todo | null>(null);
const [filters, setFilters] = useState({ // Panel state
priority: '', const [selectedTodoId, setSelectedTodoId] = useState<number | null>(null);
category: '', const [panelMode, setPanelMode] = useState<'closed' | 'view' | 'create'>('closed');
showCompleted: true,
search: '', // Filters
}); const [priorityFilter, setPriorityFilter] = useState('');
const [activeFilters, setActiveFilters] = useState<string[]>([]);
const [showCompleted, setShowCompleted] = useState(true);
const [search, setSearch] = useState('');
// Handle navigation state from dashboard
useEffect(() => {
const state = location.state as { todoId?: number } | null;
if (state?.todoId) {
setSelectedTodoId(state.todoId);
setPanelMode('view');
window.history.replaceState({}, '');
}
}, [location.state]);
const { data: todos = [], isLoading } = useQuery({ const { data: todos = [], isLoading } = useQuery({
queryKey: ['todos'], queryKey: ['todos'],
@ -39,7 +52,7 @@ export default function TodosPage() {
}, },
}); });
const categories = useMemo(() => { const allCategories = useMemo(() => {
const cats = new Set<string>(); const cats = new Set<string>();
todos.forEach((t) => { todos.forEach((t) => {
if (t.category) cats.add(t.category); if (t.category) cats.add(t.category);
@ -47,54 +60,92 @@ export default function TodosPage() {
return Array.from(cats).sort(); return Array.from(cats).sort();
}, [todos]); }, [todos]);
const { orderedCategories, reorder: reorderCategories } = useCategoryOrder('todos', allCategories);
const filteredTodos = useMemo( const filteredTodos = useMemo(
() => () =>
todos.filter((todo) => { todos.filter((todo) => {
if (filters.priority && todo.priority !== filters.priority) return false; if (priorityFilter && todo.priority !== priorityFilter) return false;
if (filters.category && todo.category?.toLowerCase() !== filters.category.toLowerCase()) if (activeFilters.length > 0) {
return false; if (!todo.category || !activeFilters.includes(todo.category)) return false;
if (!filters.showCompleted && todo.completed) return false; }
if (filters.search && !todo.title.toLowerCase().includes(filters.search.toLowerCase())) if (!showCompleted && todo.completed) return false;
if (search && !todo.title.toLowerCase().includes(search.toLowerCase()))
return false; return false;
return true; return true;
}), }),
[todos, filters] [todos, priorityFilter, activeFilters, showCompleted, search]
); );
const totalCount = filteredTodos.filter((t) => !t.completed).length; const totalCount = filteredTodos.filter((t) => !t.completed).length;
const completedCount = filteredTodos.filter((t) => t.completed).length; const completedCount = filteredTodos.filter((t) => t.completed).length;
const overdueCount = filteredTodos.filter((t) => isTodoOverdue(t.due_date, t.completed)).length; const overdueCount = filteredTodos.filter((t) => isTodoOverdue(t.due_date, t.completed)).length;
const handleEdit = (todo: Todo) => { const panelOpen = panelMode !== 'closed';
setEditingTodo(todo); const selectedTodo = useMemo(
setShowForm(true); () => todos.find((t) => t.id === selectedTodoId) ?? null,
[selectedTodoId, todos],
);
const handleSelect = (todo: Todo) => {
setSelectedTodoId(todo.id);
setPanelMode('view');
}; };
const handleCloseForm = () => { const handleCreateNew = () => {
setShowForm(false); setSelectedTodoId(null);
setEditingTodo(null); setPanelMode('create');
}; };
const handlePanelClose = () => {
setPanelMode('closed');
setSelectedTodoId(null);
};
// CategoryFilterBar handlers
const toggleAll = () => setActiveFilters([]);
const toggleCompleted = () => setShowCompleted((p) => !p);
const toggleCategory = (cat: string) => {
setActiveFilters((prev) =>
prev.includes(cat) ? prev.filter((c) => c !== cat) : [...prev, cat]
);
};
const selectAllCategories = () => {
const allSelected = orderedCategories.every((c) => activeFilters.includes(c));
setActiveFilters(allSelected ? [] : [...orderedCategories]);
};
// Escape key closes panel
useEffect(() => {
if (!panelOpen) return;
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') handlePanelClose();
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [panelOpen]);
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full animate-fade-in">
{/* Header */} {/* Header */}
<div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0"> <div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0">
<h1 className="font-heading text-2xl font-bold tracking-tight">Todos</h1> <h1 className="font-heading text-2xl font-bold tracking-tight">Todos</h1>
{/* Priority filter */}
<div className="flex items-center rounded-md border border-border overflow-hidden ml-4"> <div className="flex items-center rounded-md border border-border overflow-hidden ml-4">
{priorityFilters.map((pf) => ( {priorityFilters.map((pf) => (
<button <button
key={pf.value} key={pf.value}
onClick={() => setFilters({ ...filters, priority: pf.value })} onClick={() => setPriorityFilter(pf.value)}
className={`px-3 py-1.5 text-sm font-medium transition-colors duration-150 ${ className={`px-3 py-1.5 text-sm font-medium transition-colors duration-150 ${
filters.priority === pf.value priorityFilter === pf.value
? 'bg-accent/15 text-accent' ? 'bg-accent/15 text-accent'
: 'text-muted-foreground hover:text-foreground hover:bg-card-elevated' : 'text-muted-foreground hover:text-foreground hover:bg-card-elevated'
}`} }`}
style={{ style={{
backgroundColor: backgroundColor:
filters.priority === pf.value ? 'hsl(var(--accent-color) / 0.15)' : undefined, priorityFilter === pf.value ? 'hsl(var(--accent-color) / 0.15)' : undefined,
color: filters.priority === pf.value ? 'hsl(var(--accent-color))' : undefined, color: priorityFilter === pf.value ? 'hsl(var(--accent-color))' : undefined,
}} }}
> >
{pf.label} {pf.label}
@ -102,111 +153,128 @@ export default function TodosPage() {
))} ))}
</div> </div>
<div className="relative ml-2"> {/* Category filter bar (All + Completed + Categories with drag) */}
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" /> <div className="flex-1 min-w-0">
<Input <CategoryFilterBar
placeholder="Search..." activeFilters={activeFilters}
value={filters.search} pinnedLabel="Completed"
onChange={(e) => setFilters({ ...filters, search: e.target.value })} showPinned={showCompleted}
className="w-52 h-8 pl-8 text-sm" categories={orderedCategories}
onToggleAll={toggleAll}
onTogglePinned={toggleCompleted}
onToggleCategory={toggleCategory}
onSelectAllCategories={selectAllCategories}
onReorderCategories={reorderCategories}
searchValue={search}
onSearchChange={setSearch}
/> />
</div> </div>
<div className="relative"> <Button onClick={handleCreateNew} size="sm">
<select
value={filters.category}
onChange={(e) => setFilters({ ...filters, category: e.target.value })}
className="h-8 rounded-md border border-input bg-background px-3 pr-8 text-sm text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 appearance-none cursor-pointer"
>
<option value="">All Categories</option>
{categories.map((cat) => (
<option key={cat} value={cat}>
{cat}
</option>
))}
</select>
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground pointer-events-none" />
</div>
<div className="flex items-center gap-1.5">
<Checkbox
id="show-completed"
checked={filters.showCompleted}
onChange={(e) =>
setFilters({ ...filters, showCompleted: (e.target as HTMLInputElement).checked })
}
/>
<Label htmlFor="show-completed" className="text-xs text-muted-foreground cursor-pointer">
Completed
</Label>
</div>
<div className="flex-1" />
<Button onClick={() => setShowForm(true)} size="sm">
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
Add Todo Add Todo
</Button> </Button>
</div> </div>
<div className="flex-1 overflow-y-auto px-6 py-5"> {/* Main content — list + detail panel */}
{/* Summary stats */} <div className="flex-1 overflow-hidden flex">
{!isLoading && todos.length > 0 && ( <div
<div className="grid gap-2.5 grid-cols-3 mb-5"> className={`overflow-y-auto transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${
<Card className="bg-gradient-to-br from-accent/[0.03] to-transparent"> panelOpen ? 'w-full lg:w-[55%]' : 'w-full'
<CardContent className="p-4 flex items-center gap-3"> }`}
<div className="p-1.5 rounded-md bg-blue-500/10"> >
<CheckSquare className="h-4 w-4 text-blue-400" /> <div className="px-6 py-5">
</div> {/* Summary stats */}
<div> {!isLoading && todos.length > 0 && (
<p className="text-[10px] tracking-wider uppercase text-muted-foreground"> <div className="grid gap-2.5 grid-cols-3 mb-5">
Open <Card className="bg-gradient-to-br from-accent/[0.03] to-transparent">
</p> <CardContent className="p-4 flex items-center gap-3">
<p className="font-heading text-xl font-bold tabular-nums">{totalCount}</p> <div className="p-1.5 rounded-md bg-blue-500/10">
</div> <CheckSquare className="h-4 w-4 text-blue-400" />
</CardContent> </div>
</Card> <div>
<Card className="bg-gradient-to-br from-accent/[0.03] to-transparent"> <p className="text-[10px] tracking-wider uppercase text-muted-foreground">
<CardContent className="p-4 flex items-center gap-3"> Open
<div className="p-1.5 rounded-md bg-green-500/10"> </p>
<CheckCircle2 className="h-4 w-4 text-green-400" /> <p className="font-heading text-xl font-bold tabular-nums">{totalCount}</p>
</div> </div>
<div> </CardContent>
<p className="text-[10px] tracking-wider uppercase text-muted-foreground"> </Card>
Completed <Card className="bg-gradient-to-br from-accent/[0.03] to-transparent">
</p> <CardContent className="p-4 flex items-center gap-3">
<p className="font-heading text-xl font-bold tabular-nums">{completedCount}</p> <div className="p-1.5 rounded-md bg-green-500/10">
</div> <CheckCircle2 className="h-4 w-4 text-green-400" />
</CardContent> </div>
</Card> <div>
<Card className="bg-gradient-to-br from-accent/[0.03] to-transparent"> <p className="text-[10px] tracking-wider uppercase text-muted-foreground">
<CardContent className="p-4 flex items-center gap-3"> Completed
<div className="p-1.5 rounded-md bg-red-500/10"> </p>
<AlertCircle className="h-4 w-4 text-red-400" /> <p className="font-heading text-xl font-bold tabular-nums">{completedCount}</p>
</div> </div>
<div> </CardContent>
<p className="text-[10px] tracking-wider uppercase text-muted-foreground"> </Card>
Overdue <Card className="bg-gradient-to-br from-accent/[0.03] to-transparent">
</p> <CardContent className="p-4 flex items-center gap-3">
<p className="font-heading text-xl font-bold tabular-nums">{overdueCount}</p> <div className="p-1.5 rounded-md bg-red-500/10">
</div> <AlertCircle className="h-4 w-4 text-red-400" />
</CardContent> </div>
</Card> <div>
</div> <p className="text-[10px] tracking-wider uppercase text-muted-foreground">
)} Overdue
</p>
<p className="font-heading text-xl font-bold tabular-nums">{overdueCount}</p>
</div>
</CardContent>
</Card>
</div>
)}
{isLoading ? ( {isLoading ? (
<ListSkeleton rows={6} /> <ListSkeleton rows={6} />
) : ( ) : (
<TodoList <TodoList
todos={filteredTodos} todos={filteredTodos}
onEdit={handleEdit} onEdit={handleSelect}
onAdd={() => setShowForm(true)} onAdd={handleCreateNew}
/>
)}
</div>
</div>
{/* Detail panel (desktop) */}
<div
className={`overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${
panelOpen ? 'hidden lg:block lg:w-[45%]' : 'w-0 opacity-0'
}`}
>
<TodoDetailPanel
todo={panelMode === 'view' ? selectedTodo : null}
isCreating={panelMode === 'create'}
onClose={handlePanelClose}
onSaved={panelMode === 'create' ? handlePanelClose : undefined}
/> />
)} </div>
</div> </div>
{showForm && <TodoForm todo={editingTodo} onClose={handleCloseForm} />} {/* Mobile detail panel overlay */}
{panelOpen && (
<div
className="lg:hidden fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
onClick={handlePanelClose}
>
<div
className="fixed inset-y-0 right-0 w-full sm:w-[400px] bg-card border-l border-border shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<TodoDetailPanel
todo={panelMode === 'view' ? selectedTodo : null}
isCreating={panelMode === 'create'}
onClose={handlePanelClose}
onSaved={panelMode === 'create' ? handlePanelClose : undefined}
/>
</div>
</div>
)}
</div> </div>
); );
} }