diff --git a/backend/alembic/versions/019_extend_person_model.py b/backend/alembic/versions/019_extend_person_model.py new file mode 100644 index 0000000..7464216 --- /dev/null +++ b/backend/alembic/versions/019_extend_person_model.py @@ -0,0 +1,40 @@ +"""Extend person model with new fields + +Revision ID: 019 +Revises: 018 +Create Date: 2026-02-24 +""" +from alembic import op +import sqlalchemy as sa + +revision = '019' +down_revision = '018' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column('people', sa.Column('first_name', sa.String(100), nullable=True)) + op.add_column('people', sa.Column('last_name', sa.String(100), nullable=True)) + op.add_column('people', sa.Column('nickname', sa.String(100), nullable=True)) + op.add_column('people', sa.Column('is_favourite', sa.Boolean(), nullable=False, server_default='false')) + op.add_column('people', sa.Column('company', sa.String(255), nullable=True)) + op.add_column('people', sa.Column('job_title', sa.String(255), nullable=True)) + op.add_column('people', sa.Column('mobile', sa.String(50), nullable=True)) + op.add_column('people', sa.Column('category', sa.String(100), nullable=True)) + + # Data migration: seed category from existing relationship field + op.execute("UPDATE people SET category = relationship WHERE category IS NULL AND relationship IS NOT NULL") + # Belt-and-suspenders: ensure no NULL on is_favourite despite server_default + op.execute("UPDATE people SET is_favourite = FALSE WHERE is_favourite IS NULL") + + +def downgrade() -> None: + op.drop_column('people', 'category') + op.drop_column('people', 'mobile') + op.drop_column('people', 'job_title') + op.drop_column('people', 'company') + op.drop_column('people', 'is_favourite') + op.drop_column('people', 'nickname') + op.drop_column('people', 'last_name') + op.drop_column('people', 'first_name') diff --git a/backend/alembic/versions/020_extend_location_model.py b/backend/alembic/versions/020_extend_location_model.py new file mode 100644 index 0000000..4fe921d --- /dev/null +++ b/backend/alembic/versions/020_extend_location_model.py @@ -0,0 +1,28 @@ +"""Extend location model with new fields + +Revision ID: 020 +Revises: 019 +Create Date: 2026-02-24 +""" +from alembic import op +import sqlalchemy as sa + +revision = '020' +down_revision = '019' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column('locations', sa.Column('is_frequent', sa.Boolean(), nullable=False, server_default='false')) + op.add_column('locations', sa.Column('contact_number', sa.String(50), nullable=True)) + op.add_column('locations', sa.Column('email', sa.String(255), nullable=True)) + + # Belt-and-suspenders: ensure no NULL on is_frequent despite server_default + op.execute("UPDATE locations SET is_frequent = FALSE WHERE is_frequent IS NULL") + + +def downgrade() -> None: + op.drop_column('locations', 'email') + op.drop_column('locations', 'contact_number') + op.drop_column('locations', 'is_frequent') diff --git a/backend/app/models/location.py b/backend/app/models/location.py index f9a116a..2830fc3 100644 --- a/backend/app/models/location.py +++ b/backend/app/models/location.py @@ -1,4 +1,4 @@ -from sqlalchemy import String, Text, func +from sqlalchemy import String, Text, Boolean, func from sqlalchemy.orm import Mapped, mapped_column, relationship from datetime import datetime from typing import Optional, List @@ -12,6 +12,9 @@ class Location(Base): name: Mapped[str] = mapped_column(String(255), nullable=False) address: Mapped[str] = mapped_column(Text, nullable=False) category: Mapped[str] = mapped_column(String(100), default="other") + is_frequent: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + contact_number: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) + email: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True) created_at: Mapped[datetime] = mapped_column(default=func.now()) updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now()) diff --git a/backend/app/models/person.py b/backend/app/models/person.py index 05118db..afae961 100644 --- a/backend/app/models/person.py +++ b/backend/app/models/person.py @@ -1,4 +1,4 @@ -from sqlalchemy import String, Text, Date, func +from sqlalchemy import String, Text, Date, Boolean, func from sqlalchemy.orm import Mapped, mapped_column, relationship as sa_relationship from datetime import datetime, date from typing import Optional, List @@ -16,6 +16,15 @@ class Person(Base): birthday: Mapped[Optional[date]] = mapped_column(Date, nullable=True) relationship: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + # Extended fields + first_name: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) + last_name: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) + nickname: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) + is_favourite: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + company: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) + job_title: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) + mobile: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) + category: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) created_at: Mapped[datetime] = mapped_column(default=func.now()) updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now()) diff --git a/backend/app/routers/people.py b/backend/app/routers/people.py index 890dd55..a87bec7 100644 --- a/backend/app/routers/people.py +++ b/backend/app/routers/people.py @@ -1,6 +1,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select +from datetime import datetime, timezone from typing import Optional, List from app.database import get_db @@ -12,17 +13,35 @@ from app.models.settings import Settings router = APIRouter() +def _compute_display_name( + first_name: Optional[str], + last_name: Optional[str], + nickname: Optional[str], + name: Optional[str], +) -> str: + """Denormalise a display name. Nickname wins; else 'First Last'; else legacy name.""" + if nickname: + return nickname + full = ((first_name or '') + ' ' + (last_name or '')).strip() + if full: + return full + return name or '' + + @router.get("/", response_model=List[PersonResponse]) async def get_people( search: Optional[str] = Query(None), + category: Optional[str] = Query(None), db: AsyncSession = Depends(get_db), current_user: Settings = Depends(get_current_session) ): - """Get all people with optional search.""" + """Get all people with optional search and category filter.""" query = select(Person) if search: query = query.where(Person.name.ilike(f"%{search}%")) + if category: + query = query.where(Person.category == category) query = query.order_by(Person.name.asc()) @@ -38,8 +57,14 @@ async def create_person( db: AsyncSession = Depends(get_db), current_user: Settings = Depends(get_current_session) ): - """Create a new person.""" + """Create a new person with denormalised display name.""" new_person = Person(**person.model_dump()) + new_person.name = _compute_display_name( + new_person.first_name, + new_person.last_name, + new_person.nickname, + new_person.name, + ) db.add(new_person) await db.commit() await db.refresh(new_person) @@ -70,7 +95,7 @@ async def update_person( db: AsyncSession = Depends(get_db), current_user: Settings = Depends(get_current_session) ): - """Update a person.""" + """Update a person and refresh the denormalised display name.""" result = await db.execute(select(Person).where(Person.id == person_id)) person = result.scalar_one_or_none() @@ -82,6 +107,16 @@ async def update_person( for key, value in update_data.items(): setattr(person, key, value) + # Recompute display name after applying updates + person.name = _compute_display_name( + person.first_name, + person.last_name, + person.nickname, + person.name, + ) + # Guarantee timestamp refresh regardless of DB driver behaviour + person.updated_at = datetime.now(timezone.utc).replace(tzinfo=None) + await db.commit() await db.refresh(person) diff --git a/backend/app/schemas/location.py b/backend/app/schemas/location.py index 35c0a89..43df9f4 100644 --- a/backend/app/schemas/location.py +++ b/backend/app/schemas/location.py @@ -15,6 +15,9 @@ class LocationCreate(BaseModel): address: str category: str = "other" notes: Optional[str] = None + is_frequent: bool = False + contact_number: Optional[str] = None + email: Optional[str] = None class LocationUpdate(BaseModel): @@ -22,6 +25,9 @@ class LocationUpdate(BaseModel): address: Optional[str] = None category: Optional[str] = None notes: Optional[str] = None + is_frequent: Optional[bool] = None + contact_number: Optional[str] = None + email: Optional[str] = None class LocationResponse(BaseModel): @@ -30,6 +36,9 @@ class LocationResponse(BaseModel): address: str category: str notes: Optional[str] + is_frequent: bool + contact_number: Optional[str] + email: Optional[str] created_at: datetime updated_at: datetime diff --git a/backend/app/schemas/person.py b/backend/app/schemas/person.py index 379c833..fb4850a 100644 --- a/backend/app/schemas/person.py +++ b/backend/app/schemas/person.py @@ -1,36 +1,64 @@ -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, model_validator from datetime import datetime, date from typing import Optional class PersonCreate(BaseModel): - name: str + name: Optional[str] = None + first_name: Optional[str] = None + last_name: Optional[str] = None + nickname: Optional[str] = None email: Optional[str] = None phone: Optional[str] = None + mobile: Optional[str] = None address: Optional[str] = None birthday: Optional[date] = None - relationship: Optional[str] = None + category: Optional[str] = None + is_favourite: bool = False + company: Optional[str] = None + job_title: Optional[str] = None notes: Optional[str] = None + @model_validator(mode='after') + def require_some_name(self) -> 'PersonCreate': + if not any([self.name, self.first_name, self.last_name, self.nickname]): + raise ValueError('At least one name field is required') + return self + class PersonUpdate(BaseModel): name: Optional[str] = None + first_name: Optional[str] = None + last_name: Optional[str] = None + nickname: Optional[str] = None email: Optional[str] = None phone: Optional[str] = None + mobile: Optional[str] = None address: Optional[str] = None birthday: Optional[date] = None - relationship: Optional[str] = None + category: Optional[str] = None + is_favourite: Optional[bool] = None + company: Optional[str] = None + job_title: Optional[str] = None notes: Optional[str] = None class PersonResponse(BaseModel): id: int name: str + first_name: Optional[str] + last_name: Optional[str] + nickname: Optional[str] email: Optional[str] phone: Optional[str] + mobile: Optional[str] address: Optional[str] birthday: Optional[date] + category: Optional[str] relationship: Optional[str] + is_favourite: bool + company: Optional[str] + job_title: Optional[str] notes: Optional[str] created_at: datetime updated_at: datetime diff --git a/frontend/src/components/locations/LocationCard.tsx b/frontend/src/components/locations/LocationCard.tsx deleted file mode 100644 index 551d82f..0000000 --- a/frontend/src/components/locations/LocationCard.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import { useCallback } from 'react'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { toast } from 'sonner'; -import { MapPin, Trash2, Pencil } from 'lucide-react'; -import api, { getErrorMessage } from '@/lib/api'; -import type { Location } from '@/types'; -import { Card, CardContent, CardHeader } from '@/components/ui/card'; -import { Badge } from '@/components/ui/badge'; -import { Button } from '@/components/ui/button'; -import { useConfirmAction } from '@/hooks/useConfirmAction'; -import { getCategoryColor } from './constants'; - -interface LocationCardProps { - location: Location; - onEdit: (location: Location) => void; -} - -const QUERY_KEYS = [['locations'], ['dashboard'], ['upcoming']] as const; - -export default function LocationCard({ location, onEdit }: LocationCardProps) { - const queryClient = useQueryClient(); - - const deleteMutation = useMutation({ - mutationFn: async () => { - await api.delete(`/locations/${location.id}`); - }, - onSuccess: () => { - QUERY_KEYS.forEach((key) => queryClient.invalidateQueries({ queryKey: [...key] })); - toast.success('Location deleted'); - }, - onError: (error) => { - toast.error(getErrorMessage(error, 'Failed to delete location')); - }, - }); - - const executeDelete = useCallback(() => deleteMutation.mutate(), [deleteMutation]); - const { confirming: confirmingDelete, handleClick: handleDelete } = useConfirmAction(executeDelete); - - return ( - - -
-
-

- - {location.name} -

- - {location.category} - -
-
- - {confirmingDelete ? ( - - ) : ( - - )} -
-
-
- - {location.address && ( -

{location.address}

- )} - {location.notes && ( -

{location.notes}

- )} -
-
- ); -} diff --git a/frontend/src/components/locations/LocationForm.tsx b/frontend/src/components/locations/LocationForm.tsx index 2d3bf6d..0c5be5e 100644 --- a/frontend/src/components/locations/LocationForm.tsx +++ b/frontend/src/components/locations/LocationForm.tsx @@ -1,6 +1,7 @@ import { useState, FormEvent } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; +import { Star, StarOff } from 'lucide-react'; import api, { getErrorMessage } from '@/lib/api'; import type { Location } from '@/types'; import { @@ -13,23 +14,27 @@ import { } from '@/components/ui/sheet'; 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 LocationPicker from '@/components/ui/location-picker'; -import { CATEGORIES } from './constants'; +import { CategoryAutocomplete } from '@/components/shared'; interface LocationFormProps { location: Location | null; + categories: string[]; onClose: () => void; } -export default function LocationForm({ location, onClose }: LocationFormProps) { +export default function LocationForm({ location, categories, onClose }: LocationFormProps) { const queryClient = useQueryClient(); + const [formData, setFormData] = useState({ name: location?.name || '', address: location?.address || '', category: location?.category || 'other', + contact_number: location?.contact_number || '', + email: location?.email || '', + is_frequent: location?.is_frequent ?? false, notes: location?.notes || '', }); @@ -51,7 +56,12 @@ export default function LocationForm({ location, onClose }: LocationFormProps) { onClose(); }, onError: (error) => { - toast.error(getErrorMessage(error, location ? 'Failed to update location' : 'Failed to create location')); + toast.error( + getErrorMessage( + error, + location ? 'Failed to update location' : 'Failed to create location' + ) + ); }, }); @@ -65,24 +75,46 @@ export default function LocationForm({ location, onClose }: LocationFormProps) { - {location ? 'Edit Location' : 'New Location'} +
+ {location ? 'Edit Location' : 'New Location'} + +
+
+ {/* Location Name */}
- + setFormData({ ...formData, name: e.target.value })} + placeholder="e.g. Home, Office, Coffee Shop" required />
+ {/* Address */}
- + setFormData({ ...formData, address: val })} onSelect={(result) => { @@ -96,27 +128,52 @@ export default function LocationForm({ location, onClose }: LocationFormProps) { />
-
- - + {/* Contact Number + Email */} +
+
+ + + setFormData({ ...formData, contact_number: e.target.value }) + } + placeholder="+44..." + /> +
+
+ + setFormData({ ...formData, email: e.target.value })} + placeholder="info@..." + /> +
+ {/* Category */}
- + + setFormData({ ...formData, category: val })} + categories={categories} + placeholder="e.g. work, restaurant, gym..." + /> +
+ + {/* Notes */} +
+