diff --git a/backend/app/schemas/location.py b/backend/app/schemas/location.py index 43df9f4..e436d57 100644 --- a/backend/app/schemas/location.py +++ b/backend/app/schemas/location.py @@ -1,7 +1,10 @@ -from pydantic import BaseModel, ConfigDict +import re +from pydantic import BaseModel, ConfigDict, field_validator from datetime import datetime from typing import Optional, Literal +_EMAIL_RE = re.compile(r'^[^@\s]+@[^@\s]+\.[^@\s]+$') + class LocationSearchResult(BaseModel): source: Literal["local", "nominatim"] @@ -19,6 +22,13 @@ class LocationCreate(BaseModel): contact_number: Optional[str] = None email: Optional[str] = None + @field_validator('email') + @classmethod + def validate_email(cls, v: str | None) -> str | None: + if v and not _EMAIL_RE.match(v): + raise ValueError('Invalid email address') + return v + class LocationUpdate(BaseModel): name: Optional[str] = None @@ -29,6 +39,13 @@ class LocationUpdate(BaseModel): contact_number: Optional[str] = None email: Optional[str] = None + @field_validator('email') + @classmethod + def validate_email(cls, v: str | None) -> str | None: + if v and not _EMAIL_RE.match(v): + raise ValueError('Invalid email address') + return v + class LocationResponse(BaseModel): id: int diff --git a/backend/app/schemas/person.py b/backend/app/schemas/person.py index 9e9ac8d..1004efc 100644 --- a/backend/app/schemas/person.py +++ b/backend/app/schemas/person.py @@ -1,7 +1,10 @@ -from pydantic import BaseModel, ConfigDict, model_validator +import re +from pydantic import BaseModel, ConfigDict, model_validator, field_validator from datetime import datetime, date from typing import Optional +_EMAIL_RE = re.compile(r'^[^@\s]+@[^@\s]+\.[^@\s]+$') + class PersonCreate(BaseModel): name: Optional[str] = None # legacy fallback — auto-split into first/last if provided alone @@ -25,6 +28,13 @@ class PersonCreate(BaseModel): raise ValueError('At least one name field is required') return self + @field_validator('email') + @classmethod + def validate_email(cls, v: str | None) -> str | None: + if v and not _EMAIL_RE.match(v): + raise ValueError('Invalid email address') + return v + class PersonUpdate(BaseModel): # name is intentionally omitted — always computed from first/last/nickname @@ -42,6 +52,13 @@ class PersonUpdate(BaseModel): job_title: Optional[str] = None notes: Optional[str] = None + @field_validator('email') + @classmethod + def validate_email(cls, v: str | None) -> str | None: + if v and not _EMAIL_RE.match(v): + raise ValueError('Invalid email address') + return v + class PersonResponse(BaseModel): id: int diff --git a/frontend/src/components/locations/LocationForm.tsx b/frontend/src/components/locations/LocationForm.tsx index 0c5be5e..79dad1e 100644 --- a/frontend/src/components/locations/LocationForm.tsx +++ b/frontend/src/components/locations/LocationForm.tsx @@ -1,7 +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 { Star, StarOff, X } from 'lucide-react'; import api, { getErrorMessage } from '@/lib/api'; import type { Location } from '@/types'; import { @@ -10,7 +10,6 @@ import { SheetHeader, SheetTitle, SheetFooter, - SheetClose, } from '@/components/ui/sheet'; import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; @@ -73,26 +72,37 @@ export default function LocationForm({ location, categories, onClose }: Location return ( - -
+
{location ? 'Edit Location' : 'New Location'} - +
+ + +
@@ -139,7 +149,7 @@ export default function LocationForm({ location, categories, onClose }: Location onChange={(e) => setFormData({ ...formData, contact_number: e.target.value }) } - placeholder="+44..." + placeholder="+61..." />
diff --git a/frontend/src/components/locations/LocationsPage.tsx b/frontend/src/components/locations/LocationsPage.tsx index c1941fc..4d97836 100644 --- a/frontend/src/components/locations/LocationsPage.tsx +++ b/frontend/src/components/locations/LocationsPage.tsx @@ -63,8 +63,8 @@ export default function LocationsPage() { const sortedLocations = useMemo(() => { return [...locations].sort((a, b) => { - const aVal = String((a as unknown as Record)[sortKey] ?? ''); - const bVal = String((b as unknown as Record)[sortKey] ?? ''); + const aVal = String(a[sortKey as keyof Location] ?? ''); + const bVal = String(b[sortKey as keyof Location] ?? ''); const cmp = aVal.localeCompare(bVal); return sortDir === 'asc' ? cmp : -cmp; }); @@ -187,17 +187,20 @@ export default function LocationsPage() { label: 'Category', sortable: true, visibilityLevel: 'all', - render: (l) => ( - - {l.category} - - ), + render: (l) => + l.category && l.category.toLowerCase() !== 'other' ? ( + + {l.category} + + ) : ( + + ), }, ]; @@ -233,7 +236,7 @@ export default function LocationsPage() { )} getUpdatedAt={(l) => l.updated_at} getValue={(l, key) => - (l as unknown as Record)[key] ?? undefined + (l[key as keyof Location] as string | undefined) ?? undefined } /> ); diff --git a/frontend/src/components/locations/constants.ts b/frontend/src/components/locations/constants.ts deleted file mode 100644 index 7bf603b..0000000 --- a/frontend/src/components/locations/constants.ts +++ /dev/null @@ -1,13 +0,0 @@ -const FALLBACK = 'bg-gray-500/10 text-gray-400 border-gray-500/20'; - -export function getCategoryColor(category: string | undefined): string { - if (!category) return FALLBACK; - const colors: Record = { - home: 'bg-blue-500/10 text-blue-400 border-blue-500/20', - work: 'bg-purple-500/10 text-purple-400 border-purple-500/20', - restaurant: 'bg-orange-500/10 text-orange-400 border-orange-500/20', - shop: 'bg-green-500/10 text-green-400 border-green-500/20', - other: FALLBACK, - }; - return colors[category] ?? FALLBACK; -} diff --git a/frontend/src/components/people/PeoplePage.tsx b/frontend/src/components/people/PeoplePage.tsx index 31160fe..a48fb1c 100644 --- a/frontend/src/components/people/PeoplePage.tsx +++ b/frontend/src/components/people/PeoplePage.tsx @@ -63,8 +63,8 @@ function sortPeople(people: Person[], key: string, dir: 'asc' | 'desc'): Person[ const bD = b.birthday ? getDaysUntilBirthday(b.birthday) : Infinity; cmp = aD - bD; } else { - const aVal = (a as unknown as Record)[key]; - const bVal = (b as unknown as Record)[key]; + const aVal = a[key as keyof Person]; + const bVal = b[key as keyof Person]; const aStr = aVal != null ? String(aVal) : ''; const bStr = bVal != null ? String(bVal) : ''; cmp = aStr.localeCompare(bStr); @@ -323,7 +323,7 @@ export default function PeoplePage() { const age = differenceInYears(new Date(), parseISO(p.birthday)); return `${format(parseISO(p.birthday), 'MMM d, yyyy')} (${age})`; } - const val = (p as unknown as Record)[key]; + const val = p[key as keyof Person]; return val != null ? String(val) : undefined; }; diff --git a/frontend/src/components/people/PersonForm.tsx b/frontend/src/components/people/PersonForm.tsx index 979eb65..d6adf1d 100644 --- a/frontend/src/components/people/PersonForm.tsx +++ b/frontend/src/components/people/PersonForm.tsx @@ -1,7 +1,7 @@ import { useState, useMemo, FormEvent } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; -import { Star, StarOff } from 'lucide-react'; +import { Star, StarOff, X } from 'lucide-react'; import { parseISO, differenceInYears } from 'date-fns'; import api, { getErrorMessage } from '@/lib/api'; import type { Person } from '@/types'; @@ -11,7 +11,6 @@ import { SheetHeader, SheetTitle, SheetFooter, - SheetClose, } from '@/components/ui/sheet'; import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; @@ -96,24 +95,35 @@ export default function PersonForm({ person, categories, onClose }: PersonFormPr return ( -
{person ? 'Edit Person' : 'New Person'} - +
+ + +
diff --git a/frontend/src/components/people/constants.ts b/frontend/src/components/people/constants.ts deleted file mode 100644 index 318a017..0000000 --- a/frontend/src/components/people/constants.ts +++ /dev/null @@ -1,14 +0,0 @@ -// Legacy — kept for backward compatibility during transition -const FALLBACK = 'bg-gray-500/10 text-gray-400 border-gray-500/20'; - -export function getRelationshipColor(relationship: string | undefined): string { - if (!relationship) return FALLBACK; - const colors: Record = { - Family: 'bg-rose-500/10 text-rose-400 border-rose-500/20', - Friend: 'bg-blue-500/10 text-blue-400 border-blue-500/20', - Colleague: 'bg-purple-500/10 text-purple-400 border-purple-500/20', - Partner: 'bg-pink-500/10 text-pink-400 border-pink-500/20', - Other: FALLBACK, - }; - return colors[relationship] ?? FALLBACK; -} diff --git a/frontend/src/components/shared/CategoryAutocomplete.tsx b/frontend/src/components/shared/CategoryAutocomplete.tsx index 8d87a9c..0f7603e 100644 --- a/frontend/src/components/shared/CategoryAutocomplete.tsx +++ b/frontend/src/components/shared/CategoryAutocomplete.tsx @@ -35,12 +35,9 @@ export default function CategoryAutocomplete({ }, []); const handleBlur = () => { - // Normalise casing if input matches an existing category - setTimeout(() => { - const match = categories.find((c) => c.toLowerCase() === value.toLowerCase()); - if (match && match !== value) onChange(match); - setOpen(false); - }, 150); + const match = categories.find((c) => c.toLowerCase() === value.toLowerCase()); + if (match && match !== value) onChange(match); + setOpen(false); }; const handleSelect = (cat: string) => { @@ -74,7 +71,10 @@ export default function CategoryAutocomplete({ key={cat} role="option" aria-selected={cat === value} - onMouseDown={() => handleSelect(cat)} + onPointerDown={(e) => { + e.preventDefault(); + handleSelect(cat); + }} className="px-3 py-1.5 text-sm hover:bg-card-elevated cursor-pointer transition-colors duration-150" > {cat} diff --git a/frontend/src/components/shared/CategoryFilterBar.tsx b/frontend/src/components/shared/CategoryFilterBar.tsx index 72c6722..b3b8289 100644 --- a/frontend/src/components/shared/CategoryFilterBar.tsx +++ b/frontend/src/components/shared/CategoryFilterBar.tsx @@ -99,7 +99,7 @@ export default function CategoryFilterBar({
({ const DataRow = ({ item }: { item: T }) => ( onRowClick(item.id)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onRowClick(item.id); + } + }} + tabIndex={0} + role="row" aria-selected={selectedId === item.id} > {visibleColumns.map((col) => ( diff --git a/frontend/src/components/shared/utils.ts b/frontend/src/components/shared/utils.ts index 5430fa4..effb9f8 100644 --- a/frontend/src/components/shared/utils.ts +++ b/frontend/src/components/shared/utils.ts @@ -29,7 +29,9 @@ export function getAvatarColor(name: string): string { export function formatUpdatedAt(updatedAt: string): string { try { - return `Updated ${formatDistanceToNow(parseISO(updatedAt), { addSuffix: true })}`; + // Backend stores naive UTC timestamps — append Z so date-fns treats as UTC + const utcString = updatedAt.endsWith('Z') ? updatedAt : updatedAt + 'Z'; + return `Updated ${formatDistanceToNow(parseISO(utcString), { addSuffix: true })}`; } catch { return ''; }