diff --git a/backend/alembic/versions/011_add_event_templates.py b/backend/alembic/versions/011_add_event_templates.py new file mode 100644 index 0000000..c3daed8 --- /dev/null +++ b/backend/alembic/versions/011_add_event_templates.py @@ -0,0 +1,40 @@ +"""add event_templates table + +Revision ID: 011 +Revises: 010 +Create Date: 2026-02-22 + +""" +from alembic import op +import sqlalchemy as sa + +revision = "011" +down_revision = "010" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "event_templates", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(255), nullable=False), + sa.Column("title", sa.String(255), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("duration_minutes", sa.Integer(), nullable=False, server_default="60"), + sa.Column("calendar_id", sa.Integer(), nullable=True), + sa.Column("recurrence_rule", sa.Text(), nullable=True), + sa.Column("all_day", sa.Boolean(), nullable=False, server_default="false"), + sa.Column("location_id", sa.Integer(), nullable=True), + sa.Column("is_starred", sa.Boolean(), nullable=False, server_default="false"), + sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()), + sa.ForeignKeyConstraint(["calendar_id"], ["calendars.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["location_id"], ["locations.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_event_templates_id", "event_templates", ["id"]) + + +def downgrade() -> None: + op.drop_index("ix_event_templates_id", table_name="event_templates") + op.drop_table("event_templates") diff --git a/backend/app/main.py b/backend/app/main.py index 1f055d4..bef7bcb 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -3,7 +3,7 @@ from fastapi.middleware.cors import CORSMiddleware from contextlib import asynccontextmanager from app.database import engine -from app.routers import auth, todos, events, calendars, reminders, projects, people, locations, settings as settings_router, dashboard, weather +from app.routers import auth, todos, events, calendars, reminders, projects, people, locations, settings as settings_router, dashboard, weather, event_templates @asynccontextmanager @@ -40,6 +40,7 @@ app.include_router(locations.router, prefix="/api/locations", tags=["Locations"] app.include_router(settings_router.router, prefix="/api/settings", tags=["Settings"]) app.include_router(dashboard.router, prefix="/api", tags=["Dashboard"]) app.include_router(weather.router, prefix="/api/weather", tags=["Weather"]) +app.include_router(event_templates.router, prefix="/api/event-templates", tags=["Event Templates"]) @app.get("/") diff --git a/backend/app/models/event_template.py b/backend/app/models/event_template.py new file mode 100644 index 0000000..9ab2857 --- /dev/null +++ b/backend/app/models/event_template.py @@ -0,0 +1,25 @@ +from sqlalchemy import String, Text, Integer, Boolean, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column +from datetime import datetime +from typing import Optional +from app.database import Base + + +class EventTemplate(Base): + __tablename__ = "event_templates" + + id: Mapped[int] = mapped_column(primary_key=True, index=True) + name: Mapped[str] = mapped_column(String(255), nullable=False) + title: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + duration_minutes: Mapped[int] = mapped_column(Integer, default=60) + calendar_id: Mapped[Optional[int]] = mapped_column( + Integer, ForeignKey("calendars.id", ondelete="SET NULL"), nullable=True + ) + recurrence_rule: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + all_day: Mapped[bool] = mapped_column(Boolean, default=False) + location_id: Mapped[Optional[int]] = mapped_column( + Integer, ForeignKey("locations.id", ondelete="SET NULL"), nullable=True + ) + is_starred: Mapped[bool] = mapped_column(Boolean, default=False) + created_at: Mapped[datetime] = mapped_column(default=datetime.now) diff --git a/backend/app/routers/event_templates.py b/backend/app/routers/event_templates.py new file mode 100644 index 0000000..246a753 --- /dev/null +++ b/backend/app/routers/event_templates.py @@ -0,0 +1,75 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select + +from app.database import get_db +from app.routers.auth import get_current_session +from app.models.event_template import EventTemplate +from app.schemas.event_template import ( + EventTemplateCreate, + EventTemplateUpdate, + EventTemplateResponse, +) + +router = APIRouter() + + +@router.get("/", response_model=list[EventTemplateResponse]) +async def list_templates( + db: AsyncSession = Depends(get_db), + _: str = Depends(get_current_session), +): + result = await db.execute(select(EventTemplate).order_by(EventTemplate.name)) + return result.scalars().all() + + +@router.post("/", response_model=EventTemplateResponse, status_code=status.HTTP_201_CREATED) +async def create_template( + payload: EventTemplateCreate, + db: AsyncSession = Depends(get_db), + _: str = Depends(get_current_session), +): + template = EventTemplate(**payload.model_dump()) + db.add(template) + await db.commit() + await db.refresh(template) + return template + + +@router.put("/{template_id}", response_model=EventTemplateResponse) +async def update_template( + template_id: int, + payload: EventTemplateUpdate, + db: AsyncSession = Depends(get_db), + _: str = Depends(get_current_session), +): + result = await db.execute( + select(EventTemplate).where(EventTemplate.id == template_id) + ) + template = result.scalar_one_or_none() + if template is None: + raise HTTPException(status_code=404, detail="Template not found") + + for field, value in payload.model_dump(exclude_unset=True).items(): + setattr(template, field, value) + + await db.commit() + await db.refresh(template) + return template + + +@router.delete("/{template_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_template( + template_id: int, + db: AsyncSession = Depends(get_db), + _: str = Depends(get_current_session), +): + result = await db.execute( + select(EventTemplate).where(EventTemplate.id == template_id) + ) + template = result.scalar_one_or_none() + if template is None: + raise HTTPException(status_code=404, detail="Template not found") + + await db.delete(template) + await db.commit() diff --git a/backend/app/schemas/event_template.py b/backend/app/schemas/event_template.py new file mode 100644 index 0000000..4afe417 --- /dev/null +++ b/backend/app/schemas/event_template.py @@ -0,0 +1,43 @@ +from pydantic import BaseModel, ConfigDict +from datetime import datetime +from typing import Optional + + +class EventTemplateCreate(BaseModel): + name: str + title: str + description: Optional[str] = None + duration_minutes: int = 60 + calendar_id: Optional[int] = None + recurrence_rule: Optional[str] = None + all_day: bool = False + location_id: Optional[int] = None + is_starred: bool = False + + +class EventTemplateUpdate(BaseModel): + name: Optional[str] = None + title: Optional[str] = None + description: Optional[str] = None + duration_minutes: Optional[int] = None + calendar_id: Optional[int] = None + recurrence_rule: Optional[str] = None + all_day: Optional[bool] = None + location_id: Optional[int] = None + is_starred: Optional[bool] = None + + +class EventTemplateResponse(BaseModel): + id: int + name: str + title: str + description: Optional[str] + duration_minutes: int + calendar_id: Optional[int] + recurrence_rule: Optional[str] + all_day: bool + location_id: Optional[int] + is_starred: bool + created_at: datetime + + model_config = ConfigDict(from_attributes=True) diff --git a/frontend/src/components/calendar/CalendarForm.tsx b/frontend/src/components/calendar/CalendarForm.tsx index dfa2b84..c26a7dd 100644 --- a/frontend/src/components/calendar/CalendarForm.tsx +++ b/frontend/src/components/calendar/CalendarForm.tsx @@ -96,6 +96,7 @@ export default function CalendarForm({ calendar, onClose }: CalendarFormProps) { onChange={(e) => setName(e.target.value)} placeholder="Calendar name" required + disabled={calendar?.is_system} /> diff --git a/frontend/src/components/calendar/CalendarPage.tsx b/frontend/src/components/calendar/CalendarPage.tsx index 20b4e3f..d0d93d3 100644 --- a/frontend/src/components/calendar/CalendarPage.tsx +++ b/frontend/src/components/calendar/CalendarPage.tsx @@ -8,7 +8,7 @@ import interactionPlugin from '@fullcalendar/interaction'; import type { EventClickArg, DateSelectArg, EventDropArg, DatesSetArg } from '@fullcalendar/core'; import { ChevronLeft, ChevronRight } from 'lucide-react'; import api, { getErrorMessage } from '@/lib/api'; -import type { CalendarEvent } from '@/types'; +import type { CalendarEvent, EventTemplate } from '@/types'; import { useCalendars } from '@/hooks/useCalendars'; import { useSettings } from '@/hooks/useSettings'; import { Button } from '@/components/ui/button'; @@ -42,6 +42,8 @@ export default function CalendarPage() { const [currentView, setCurrentView] = useState('dayGridMonth'); const [calendarTitle, setCalendarTitle] = useState(''); + const [templateEvent, setTemplateEvent] = useState | null>(null); + // Scope dialog state const [scopeDialogOpen, setScopeDialogOpen] = useState(false); const [scopeAction, setScopeAction] = useState('edit'); @@ -63,6 +65,26 @@ export default function CalendarPage() { return () => observer.disconnect(); }, []); + // Scroll wheel navigation in month view + useEffect(() => { + const el = calendarContainerRef.current; + if (!el) return; + let debounceTimer: ReturnType | null = null; + const handleWheel = (e: WheelEvent) => { + const api = calendarRef.current?.getApi(); + if (!api || api.view.type !== 'dayGridMonth') return; + e.preventDefault(); + if (debounceTimer) return; + debounceTimer = setTimeout(() => { + debounceTimer = null; + }, 300); + if (e.deltaY > 0) api.next(); + else if (e.deltaY < 0) api.prev(); + }; + el.addEventListener('wheel', handleWheel, { passive: false }); + return () => el.removeEventListener('wheel', handleWheel); + }, []); + const { data: events = [] } = useQuery({ queryKey: ['calendar-events'], queryFn: async () => { @@ -261,12 +283,27 @@ export default function CalendarPage() { calendarRef.current?.getApi().unselect(); setShowForm(false); setEditingEvent(null); + setTemplateEvent(null); setActiveEditScope(null); setSelectedStart(null); setSelectedEnd(null); setSelectedAllDay(false); }; + const handleUseTemplate = (template: EventTemplate) => { + setTemplateEvent({ + title: template.title, + description: template.description || '', + all_day: template.all_day, + calendar_id: template.calendar_id || 0, + location_id: template.location_id || undefined, + is_starred: template.is_starred, + recurrence_rule: template.recurrence_rule || undefined, + } as Partial); + setEditingEvent(null); + setShowForm(true); + }; + const handleDatesSet = (arg: DatesSetArg) => { setCalendarTitle(arg.view.title); setCurrentView(arg.view.type as CalendarView); @@ -279,7 +316,7 @@ export default function CalendarPage() { return (
- +
{/* Custom toolbar — h-16 matches sidebar header */} @@ -348,7 +385,7 @@ export default function CalendarPage() { {showForm && ( void; +} + +export default function CalendarSidebar({ onUseTemplate }: CalendarSidebarProps) { const queryClient = useQueryClient(); const { data: calendars = [] } = useCalendars(); const [showForm, setShowForm] = useState(false); const [editingCalendar, setEditingCalendar] = useState(null); + const [showTemplateForm, setShowTemplateForm] = useState(false); + const [editingTemplate, setEditingTemplate] = useState(null); + + const { data: templates = [] } = useQuery({ + queryKey: ['event-templates'], + queryFn: async () => { + const { data } = await api.get('/event-templates'); + return data; + }, + }); const toggleMutation = useMutation({ mutationFn: async ({ id, is_visible }: { id: number; is_visible: boolean }) => { @@ -28,6 +43,19 @@ export default function CalendarSidebar() { }, }); + const deleteTemplateMutation = useMutation({ + mutationFn: async (id: number) => { + await api.delete(`/event-templates/${id}`); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['event-templates'] }); + toast.success('Template deleted'); + }, + onError: (error) => { + toast.error(getErrorMessage(error, 'Failed to delete template')); + }, + }); + const handleToggle = (calendar: Calendar) => { toggleMutation.mutate({ id: calendar.id, is_visible: !calendar.is_visible }); }; @@ -55,37 +83,91 @@ export default function CalendarSidebar() {
-
- {calendars.map((cal) => ( -
- handleToggle(cal)} - className="shrink-0" - style={{ - accentColor: cal.color, - borderColor: cal.is_visible ? cal.color : undefined, - backgroundColor: cal.is_visible ? cal.color : undefined, - }} - /> - - {cal.name} - {!cal.is_system && ( +
+ {/* Calendars list */} +
+ {calendars.map((cal) => ( +
+ handleToggle(cal)} + className="shrink-0" + style={{ + accentColor: cal.color, + borderColor: cal.is_visible ? cal.color : undefined, + backgroundColor: cal.is_visible ? cal.color : undefined, + }} + /> + + {cal.name} - )} +
+ ))} +
+ + {/* Templates section */} +
+
+ + Templates + +
- ))} + {templates.length === 0 ? ( +

No templates yet

+ ) : ( +
+ {templates.map((tmpl) => ( +
onUseTemplate?.(tmpl)} + > + + {tmpl.name} + + +
+ ))} +
+ )} +
{showForm && ( @@ -94,6 +176,13 @@ export default function CalendarSidebar() { onClose={handleCloseForm} /> )} + + {showTemplateForm && ( + { setShowTemplateForm(false); setEditingTemplate(null); }} + /> + )}
); } diff --git a/frontend/src/components/calendar/TemplateForm.tsx b/frontend/src/components/calendar/TemplateForm.tsx new file mode 100644 index 0000000..b016efd --- /dev/null +++ b/frontend/src/components/calendar/TemplateForm.tsx @@ -0,0 +1,176 @@ +import { useState, FormEvent } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import api, { getErrorMessage } from '@/lib/api'; +import type { EventTemplate } from '@/types'; +import { useCalendars } from '@/hooks/useCalendars'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, + DialogClose, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { Select } from '@/components/ui/select'; +import { Label } from '@/components/ui/label'; +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; + +interface TemplateFormProps { + template: EventTemplate | null; + onClose: () => void; +} + +export default function TemplateForm({ template, onClose }: TemplateFormProps) { + const queryClient = useQueryClient(); + const { data: calendars = [] } = useCalendars(); + const selectableCalendars = calendars.filter((c) => !c.is_system); + + const [formData, setFormData] = useState({ + name: template?.name || '', + title: template?.title || '', + description: template?.description || '', + duration_minutes: template?.duration_minutes?.toString() || '60', + calendar_id: template?.calendar_id?.toString() || '', + all_day: template?.all_day || false, + is_starred: template?.is_starred || false, + }); + + const mutation = useMutation({ + mutationFn: async (data: typeof formData) => { + const payload = { + name: data.name, + title: data.title, + description: data.description || null, + duration_minutes: parseInt(data.duration_minutes) || 60, + calendar_id: data.calendar_id ? parseInt(data.calendar_id) : null, + all_day: data.all_day, + is_starred: data.is_starred, + }; + if (template) { + const { data: res } = await api.put(`/event-templates/${template.id}`, payload); + return res; + } else { + const { data: res } = await api.post('/event-templates', payload); + return res; + } + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['event-templates'] }); + toast.success(template ? 'Template updated' : 'Template created'); + onClose(); + }, + onError: (error) => { + toast.error(getErrorMessage(error, 'Failed to save template')); + }, + }); + + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + if (!formData.name.trim() || !formData.title.trim()) return; + mutation.mutate(formData); + }; + + return ( + + + + + {template ? 'Edit Template' : 'New Template'} + +
+
+ + setFormData({ ...formData, name: e.target.value })} + placeholder="e.g., Weekly standup" + required + /> +
+ +
+ + setFormData({ ...formData, title: e.target.value })} + placeholder="Title for created events" + required + /> +
+ +
+ +