Calendar enhancements: scroll navigation, birthday color editing, event templates

- Add wheel scroll navigation in month view (debounced, prevents rapid scrolling)
- Allow editing color on system calendars (Birthdays) - name field disabled
- Event templates: full CRUD backend (model, schema, router, migration 011)
- Event templates: sidebar section with create/edit/delete, click to pre-fill EventForm
- Register event_templates router at /api/event-templates

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kyle 2026-02-22 17:34:16 +08:00
parent a11fcbcbcc
commit 80f3f3ed10
10 changed files with 533 additions and 32 deletions

View File

@ -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")

View File

@ -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("/")

View File

@ -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)

View File

@ -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()

View File

@ -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)

View File

@ -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}
/>
</div>

View File

@ -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<CalendarView>('dayGridMonth');
const [calendarTitle, setCalendarTitle] = useState('');
const [templateEvent, setTemplateEvent] = useState<Partial<CalendarEvent> | null>(null);
// Scope dialog state
const [scopeDialogOpen, setScopeDialogOpen] = useState(false);
const [scopeAction, setScopeAction] = useState<ScopeAction>('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<typeof setTimeout> | 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<CalendarEvent>);
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 (
<div className="flex h-full overflow-hidden">
<CalendarSidebar />
<CalendarSidebar onUseTemplate={handleUseTemplate} />
<div ref={calendarContainerRef} className="flex-1 flex flex-col overflow-hidden">
{/* Custom toolbar — h-16 matches sidebar header */}
@ -348,7 +385,7 @@ export default function CalendarPage() {
{showForm && (
<EventForm
event={editingEvent}
event={editingEvent || (templateEvent as CalendarEvent | null)}
initialStart={selectedStart}
initialEnd={selectedEnd}
initialAllDay={selectedAllDay}

View File

@ -1,19 +1,34 @@
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Pencil } from 'lucide-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Pencil, Trash2, FileText } from 'lucide-react';
import { toast } from 'sonner';
import api, { getErrorMessage } from '@/lib/api';
import type { Calendar } from '@/types';
import type { Calendar, EventTemplate } from '@/types';
import { useCalendars } from '@/hooks/useCalendars';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import CalendarForm from './CalendarForm';
import TemplateForm from './TemplateForm';
export default function CalendarSidebar() {
interface CalendarSidebarProps {
onUseTemplate?: (template: EventTemplate) => void;
}
export default function CalendarSidebar({ onUseTemplate }: CalendarSidebarProps) {
const queryClient = useQueryClient();
const { data: calendars = [] } = useCalendars();
const [showForm, setShowForm] = useState(false);
const [editingCalendar, setEditingCalendar] = useState<Calendar | null>(null);
const [showTemplateForm, setShowTemplateForm] = useState(false);
const [editingTemplate, setEditingTemplate] = useState<EventTemplate | null>(null);
const { data: templates = [] } = useQuery({
queryKey: ['event-templates'],
queryFn: async () => {
const { data } = await api.get<EventTemplate[]>('/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() {
<Plus className="h-4 w-4" />
</Button>
</div>
<div className="flex-1 overflow-y-auto p-3 space-y-0.5">
{calendars.map((cal) => (
<div
key={cal.id}
className="group flex items-center gap-2.5 rounded-md px-2 py-1.5 hover:bg-card-elevated transition-colors duration-150"
>
<Checkbox
checked={cal.is_visible}
onChange={() => handleToggle(cal)}
className="shrink-0"
style={{
accentColor: cal.color,
borderColor: cal.is_visible ? cal.color : undefined,
backgroundColor: cal.is_visible ? cal.color : undefined,
}}
/>
<span
className="h-2.5 w-2.5 rounded-full shrink-0"
style={{ backgroundColor: cal.color }}
/>
<span className="text-sm text-foreground truncate flex-1">{cal.name}</span>
{!cal.is_system && (
<div className="flex-1 overflow-y-auto p-3 space-y-4">
{/* Calendars list */}
<div className="space-y-0.5">
{calendars.map((cal) => (
<div
key={cal.id}
className="group flex items-center gap-2.5 rounded-md px-2 py-1.5 hover:bg-card-elevated transition-colors duration-150"
>
<Checkbox
checked={cal.is_visible}
onChange={() => handleToggle(cal)}
className="shrink-0"
style={{
accentColor: cal.color,
borderColor: cal.is_visible ? cal.color : undefined,
backgroundColor: cal.is_visible ? cal.color : undefined,
}}
/>
<span
className="h-2.5 w-2.5 rounded-full shrink-0"
style={{ backgroundColor: cal.color }}
/>
<span className="text-sm text-foreground truncate flex-1">{cal.name}</span>
<button
onClick={() => handleEdit(cal)}
className="opacity-0 group-hover:opacity-100 transition-opacity duration-150 text-muted-foreground hover:text-foreground"
>
<Pencil className="h-3.5 w-3.5" />
</button>
)}
</div>
))}
</div>
{/* Templates section */}
<div className="space-y-1.5">
<div className="flex items-center justify-between px-2">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
Templates
</span>
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={() => { setEditingTemplate(null); setShowTemplateForm(true); }}
>
<Plus className="h-3 w-3" />
</Button>
</div>
))}
{templates.length === 0 ? (
<p className="text-xs text-muted-foreground px-2">No templates yet</p>
) : (
<div className="space-y-0.5">
{templates.map((tmpl) => (
<div
key={tmpl.id}
className="group flex items-center gap-2 rounded-md px-2 py-1.5 hover:bg-card-elevated transition-colors duration-150 cursor-pointer"
onClick={() => onUseTemplate?.(tmpl)}
>
<FileText className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<span className="text-sm text-foreground truncate flex-1">{tmpl.name}</span>
<button
onClick={(e) => {
e.stopPropagation();
setEditingTemplate(tmpl);
setShowTemplateForm(true);
}}
className="opacity-0 group-hover:opacity-100 transition-opacity duration-150 text-muted-foreground hover:text-foreground"
>
<Pencil className="h-3 w-3" />
</button>
<button
onClick={(e) => {
e.stopPropagation();
if (!window.confirm(`Delete template "${tmpl.name}"?`)) return;
deleteTemplateMutation.mutate(tmpl.id);
}}
className="opacity-0 group-hover:opacity-100 transition-opacity duration-150 text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
))}
</div>
)}
</div>
</div>
{showForm && (
@ -94,6 +176,13 @@ export default function CalendarSidebar() {
onClose={handleCloseForm}
/>
)}
{showTemplateForm && (
<TemplateForm
template={editingTemplate}
onClose={() => { setShowTemplateForm(false); setEditingTemplate(null); }}
/>
)}
</div>
);
}

View File

@ -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 (
<Dialog open={true} onOpenChange={onClose}>
<DialogContent>
<DialogClose onClick={onClose} />
<DialogHeader>
<DialogTitle>{template ? 'Edit Template' : 'New Template'}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="tmpl-name" required>Template Name</Label>
<Input
id="tmpl-name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="e.g., Weekly standup"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="tmpl-title" required>Event Title</Label>
<Input
id="tmpl-title"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
placeholder="Title for created events"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="tmpl-desc">Description</Label>
<Textarea
id="tmpl-desc"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="min-h-[60px]"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="tmpl-duration">Duration (min)</Label>
<Input
id="tmpl-duration"
type="number"
min={5}
max={1440}
value={formData.duration_minutes}
onChange={(e) => setFormData({ ...formData, duration_minutes: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="tmpl-calendar">Calendar</Label>
<Select
id="tmpl-calendar"
value={formData.calendar_id}
onChange={(e) => setFormData({ ...formData, calendar_id: e.target.value })}
>
<option value="">Default</option>
{selectableCalendars.map((cal) => (
<option key={cal.id} value={cal.id}>{cal.name}</option>
))}
</Select>
</div>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Checkbox
id="tmpl-allday"
checked={formData.all_day}
onChange={(e) => setFormData({ ...formData, all_day: (e.target as HTMLInputElement).checked })}
/>
<Label htmlFor="tmpl-allday">All day</Label>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="tmpl-starred"
checked={formData.is_starred}
onChange={(e) => setFormData({ ...formData, is_starred: (e.target as HTMLInputElement).checked })}
/>
<Label htmlFor="tmpl-starred">Starred</Label>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
Cancel
</Button>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Saving...' : template ? 'Update' : 'Create'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@ -214,3 +214,17 @@ export interface UpcomingResponse {
days: number;
cutoff_date: string;
}
export interface EventTemplate {
id: number;
name: string;
title: string;
description?: string;
duration_minutes: number;
calendar_id?: number;
recurrence_rule?: string;
all_day: boolean;
location_id?: number;
is_starred: boolean;
created_at: string;
}