From 1806e15487b4879513f70a46c9eeec6be23370b0 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Wed, 25 Feb 2026 01:04:20 +0800 Subject: [PATCH] Address all QA review warnings and suggestions for entity pages Warnings fixed: - 3.1: _compute_display_name stale-data bug on all-names-clear - 3.3: Location getValue unsafe type cast replaced with typed helper - 3.5: Explicit updated_at timestamp refresh in locations router - 3.6: Drop deprecated relationship column (migration 021, model, schema, TS type) Suggestions fixed: - 4.1: CategoryAutocomplete keyboard navigation (ArrowUp/Down, Enter, Escape) - 4.2: Mobile detail panel backdrop click-to-close on both pages - 4.3: PersonCreate whitespace bypass in require_some_name validator - 4.5/4.6: Extract SortIcon, DataRow, SectionHeader from EntityTable render body - 4.8: PersonForm sends null instead of empty string for birthday - 4.10: Remove unnecessary executeDelete wrapper in EntityDetailPanel Also includes previously completed fixes from prior session: - 2.1: Remove Z suffix from naive timestamp in formatUpdatedAt - 3.2: Drag-then-click conflict prevention in SortableCategoryChip - 3.4: localStorage JSON shape validation in useCategoryOrder - 4.4: Category chip styling consistency (both pages use inline hsl styles) - 4.9: restrictToHorizontalAxis modifier on CategoryFilterBar drag Co-Authored-By: Claude Opus 4.6 --- .../021_drop_person_relationship_column.py | 21 +++ backend/app/models/person.py | 5 +- backend/app/routers/locations.py | 4 + backend/app/routers/people.py | 5 +- backend/app/schemas/person.py | 8 +- frontend/package-lock.json | 77 ++++++++ frontend/package.json | 29 ++-- .../components/locations/LocationsPage.tsx | 37 +++- frontend/src/components/people/PeoplePage.tsx | 37 +++- frontend/src/components/people/PersonForm.tsx | 2 +- .../shared/CategoryAutocomplete.tsx | 61 ++++++- .../components/shared/CategoryFilterBar.tsx | 164 +++++++++++++++--- .../components/shared/EntityDetailPanel.tsx | 4 +- .../src/components/shared/EntityTable.tsx | 96 ++++++---- frontend/src/components/shared/utils.ts | 4 +- frontend/src/hooks/useCategoryOrder.ts | 41 +++++ frontend/src/types/index.ts | 1 - 17 files changed, 495 insertions(+), 101 deletions(-) create mode 100644 backend/alembic/versions/021_drop_person_relationship_column.py create mode 100644 frontend/src/hooks/useCategoryOrder.ts diff --git a/backend/alembic/versions/021_drop_person_relationship_column.py b/backend/alembic/versions/021_drop_person_relationship_column.py new file mode 100644 index 0000000..4c222ee --- /dev/null +++ b/backend/alembic/versions/021_drop_person_relationship_column.py @@ -0,0 +1,21 @@ +"""Drop deprecated relationship column from people table + +Revision ID: 021 +Revises: 020 +Create Date: 2026-02-25 +""" +from alembic import op +import sqlalchemy as sa + +revision = '021' +down_revision = '020' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.drop_column('people', 'relationship') + + +def downgrade() -> None: + op.add_column('people', sa.Column('relationship', sa.String(100), nullable=True)) diff --git a/backend/app/models/person.py b/backend/app/models/person.py index d489879..fbf7984 100644 --- a/backend/app/models/person.py +++ b/backend/app/models/person.py @@ -1,5 +1,5 @@ from sqlalchemy import String, Text, Date, Boolean, func, text -from sqlalchemy.orm import Mapped, mapped_column, relationship as sa_relationship +from sqlalchemy.orm import Mapped, mapped_column, relationship from datetime import datetime, date from typing import Optional, List from app.database import Base @@ -14,7 +14,6 @@ class Person(Base): phone: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) address: Mapped[Optional[str]] = mapped_column(Text, nullable=True) 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) @@ -29,4 +28,4 @@ class Person(Base): updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now()) # Relationships - assigned_tasks: Mapped[List["ProjectTask"]] = sa_relationship(back_populates="person") + assigned_tasks: Mapped[List["ProjectTask"]] = relationship(back_populates="person") diff --git a/backend/app/routers/locations.py b/backend/app/routers/locations.py index 644769b..1329f18 100644 --- a/backend/app/routers/locations.py +++ b/backend/app/routers/locations.py @@ -1,6 +1,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, or_ +from datetime import datetime, timezone from typing import Optional, List import asyncio import json @@ -151,6 +152,9 @@ async def update_location( for key, value in update_data.items(): setattr(location, key, value) + # Guarantee timestamp refresh regardless of DB driver behaviour + location.updated_at = datetime.now(timezone.utc).replace(tzinfo=None) + await db.commit() await db.refresh(location) diff --git a/backend/app/routers/people.py b/backend/app/routers/people.py index 962f767..451aca0 100644 --- a/backend/app/routers/people.py +++ b/backend/app/routers/people.py @@ -19,13 +19,14 @@ def _compute_display_name( nickname: Optional[str], name: Optional[str], ) -> str: - """Denormalise a display name. Nickname wins; else 'First Last'; else legacy name.""" + """Denormalise a display name. Nickname wins; else 'First Last'; else legacy name; else empty.""" if nickname: return nickname full = ((first_name or '') + ' ' + (last_name or '')).strip() if full: return full - return name or '' + # Don't fall back to stale `name` if all fields were explicitly cleared + return (name or '').strip() if name else '' @router.get("/", response_model=List[PersonResponse]) diff --git a/backend/app/schemas/person.py b/backend/app/schemas/person.py index 1004efc..ebe37c7 100644 --- a/backend/app/schemas/person.py +++ b/backend/app/schemas/person.py @@ -24,7 +24,12 @@ class PersonCreate(BaseModel): @model_validator(mode='after') def require_some_name(self) -> 'PersonCreate': - if not any([self.name, self.first_name, self.last_name, self.nickname]): + if not any([ + self.name and self.name.strip(), + self.first_name and self.first_name.strip(), + self.last_name and self.last_name.strip(), + self.nickname and self.nickname.strip(), + ]): raise ValueError('At least one name field is required') return self @@ -72,7 +77,6 @@ class PersonResponse(BaseModel): address: Optional[str] birthday: Optional[date] category: Optional[str] - relationship: Optional[str] # deprecated — kept for legacy calendar/birthday compat, will be removed is_favourite: bool company: Optional[str] job_title: Optional[str] diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 342227e..83aa677 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,10 @@ "name": "umbra", "version": "1.0.0", "dependencies": { + "@dnd-kit/core": "^6.1.0", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^8.0.0", + "@dnd-kit/utilities": "^3.2.2", "@fullcalendar/daygrid": "^6.1.15", "@fullcalendar/interaction": "^6.1.15", "@fullcalendar/react": "^6.1.15", @@ -330,6 +334,73 @@ "node": ">=6.9.0" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/modifiers": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-9.0.0.tgz", + "integrity": "sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-8.0.0.tgz", + "integrity": "sha512-U3jk5ebVXe1Lr7c2wU7SBZjcWdQP+j7peHJfCspnA81enlu88Mgd7CC8Q+pub9ubP7eKVETzJW+IBAhsqbSu/g==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.1.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -3055,6 +3126,12 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index e9be121..4e34964 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,24 +9,25 @@ "preview": "vite preview" }, "dependencies": { + "@dnd-kit/core": "^6.1.0", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^8.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@fullcalendar/daygrid": "^6.1.15", + "@fullcalendar/interaction": "^6.1.15", + "@fullcalendar/react": "^6.1.15", + "@fullcalendar/timegrid": "^6.1.15", + "@tanstack/react-query": "^5.62.0", + "axios": "^1.7.9", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "date-fns": "^4.1.0", + "lucide-react": "^0.468.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.28.0", - "@tanstack/react-query": "^5.62.0", - "axios": "^1.7.9", - "@fullcalendar/react": "^6.1.15", - "@fullcalendar/daygrid": "^6.1.15", - "@fullcalendar/timegrid": "^6.1.15", - "@fullcalendar/interaction": "^6.1.15", - "lucide-react": "^0.468.0", - "date-fns": "^4.1.0", "sonner": "^1.7.1", - "clsx": "^2.1.1", - "tailwind-merge": "^2.6.0", - "class-variance-authority": "^0.7.1", - "@dnd-kit/core": "^6.1.0", - "@dnd-kit/sortable": "^8.0.0", - "@dnd-kit/utilities": "^3.2.2" + "tailwind-merge": "^2.6.0" }, "devDependencies": { "@types/react": "^18.3.12", diff --git a/frontend/src/components/locations/LocationsPage.tsx b/frontend/src/components/locations/LocationsPage.tsx index 9970ff1..ea5c090 100644 --- a/frontend/src/components/locations/LocationsPage.tsx +++ b/frontend/src/components/locations/LocationsPage.tsx @@ -14,6 +14,7 @@ import { type PanelField, } from '@/components/shared'; import { useTableVisibility } from '@/hooks/useTableVisibility'; +import { useCategoryOrder } from '@/hooks/useCategoryOrder'; import LocationForm from './LocationForm'; export default function LocationsPage() { @@ -74,6 +75,8 @@ export default function LocationsPage() { [locations] ); + const { orderedCategories, reorder: reorderCategories } = useCategoryOrder('locations', allCategories); + const sortedLocations = useMemo(() => { return [...locations].sort((a, b) => { const aVal = String(a[sortKey as keyof Location] ?? ''); @@ -107,19 +110,20 @@ export default function LocationsPage() { [sortedLocations, activeFilters, search, showPinned] ); - // Build row groups for the table + // Build row groups for the table — ordered by custom category order const groups = useMemo(() => { if (activeFilters.length <= 1) { const label = activeFilters.length === 1 ? activeFilters[0] : 'All'; return [{ label, rows: filteredLocations }]; } - return activeFilters + return orderedCategories + .filter((cat) => activeFilters.includes(cat)) .map((cat) => ({ label: cat, rows: filteredLocations.filter((l) => l.category === cat), })) .filter((g) => g.rows.length > 0); - }, [activeFilters, filteredLocations]); + }, [activeFilters, filteredLocations, orderedCategories]); const selectedLocation = useMemo( () => locations.find((l) => l.id === selectedLocationId) ?? null, @@ -165,6 +169,10 @@ export default function LocationsPage() { prev.includes(cat) ? prev.filter((c) => c !== cat) : [...prev, cat] ); }; + const selectAllCategories = () => { + const allSelected = orderedCategories.every((c) => activeFilters.includes(c)); + setActiveFilters(allSelected ? [] : [...orderedCategories]); + }; const columns: ColumnDef[] = [ { @@ -262,9 +270,12 @@ export default function LocationsPage() { )} getUpdatedAt={(l) => l.updated_at} - getValue={(l, key) => - (l[key as keyof Location] as string | undefined) ?? undefined - } + getValue={(l, key) => { + const val = l[key as keyof Location]; + if (val == null || val === '') return undefined; + if (typeof val === 'boolean') return undefined; + return String(val); + }} isFavourite={selectedLocation?.is_frequent} onToggleFavourite={() => selectedLocation && toggleFrequentMutation.mutate(selectedLocation)} favouriteLabel="frequent" @@ -279,13 +290,15 @@ export default function LocationsPage() {
setActiveFilters([])} onTogglePinned={() => setShowPinned((v) => !v)} onToggleCategory={handleToggleCategory} + onSelectAllCategories={selectAllCategories} + onReorderCategories={reorderCategories} searchValue={search} onSearchChange={setSearch} /> @@ -362,8 +375,14 @@ export default function LocationsPage() { {/* Mobile detail panel overlay */} {panelOpen && selectedLocation && ( -
-
+
setSelectedLocationId(null)} + > +
e.stopPropagation()} + > {renderPanel()}
diff --git a/frontend/src/components/people/PeoplePage.tsx b/frontend/src/components/people/PeoplePage.tsx index 7e21498..54ae755 100644 --- a/frontend/src/components/people/PeoplePage.tsx +++ b/frontend/src/components/people/PeoplePage.tsx @@ -21,6 +21,7 @@ import { getDaysUntilBirthday, } from '@/components/shared/utils'; import { useTableVisibility } from '@/hooks/useTableVisibility'; +import { useCategoryOrder } from '@/hooks/useCategoryOrder'; import PersonForm from './PersonForm'; // --------------------------------------------------------------------------- @@ -150,7 +151,13 @@ const columns: ColumnDef[] = [ visibilityLevel: 'all', render: (p) => p.category ? ( - + {p.category} ) : ( @@ -207,6 +214,8 @@ export default function PeoplePage() { return Array.from(cats).sort(); }, [people]); + const { orderedCategories, reorder: reorderCategories } = useCategoryOrder('people', allCategories); + // Favourites (pinned section) — sorted const favourites = useMemo( () => sortPeople(people.filter((p) => p.is_favourite), sortKey, sortDir), @@ -239,19 +248,21 @@ export default function PeoplePage() { return sortPeople(list, sortKey, sortDir); }, [people, showPinned, activeFilters, search, sortKey, sortDir]); - // Build row groups for the table + // Build row groups for the table — ordered by custom category order const groups = useMemo(() => { if (activeFilters.length <= 1) { const label = activeFilters.length === 1 ? activeFilters[0] : 'All'; return [{ label, rows: filteredPeople }]; } - return activeFilters + // Use orderedCategories to control section order, filtered to active only + return orderedCategories + .filter((cat) => activeFilters.includes(cat)) .map((cat) => ({ label: cat, rows: filteredPeople.filter((p) => p.category === cat), })) .filter((g) => g.rows.length > 0); - }, [activeFilters, filteredPeople]); + }, [activeFilters, filteredPeople, orderedCategories]); // Stats const totalCount = people.length; @@ -288,6 +299,10 @@ export default function PeoplePage() { prev.includes(cat) ? prev.filter((c) => c !== cat) : [...prev, cat] ); }; + const selectAllCategories = () => { + const allSelected = orderedCategories.every((c) => activeFilters.includes(c)); + setActiveFilters(allSelected ? [] : [...orderedCategories]); + }; // Delete mutation const deleteMutation = useMutation({ @@ -393,10 +408,12 @@ export default function PeoplePage() { activeFilters={activeFilters} pinnedLabel="Favourites" showPinned={showPinned} - categories={allCategories} + categories={orderedCategories} onToggleAll={toggleAll} onTogglePinned={togglePinned} onToggleCategory={toggleCategory} + onSelectAllCategories={selectAllCategories} + onReorderCategories={reorderCategories} searchValue={search} onSearchChange={setSearch} /> @@ -517,8 +534,14 @@ export default function PeoplePage() { {/* Mobile detail panel overlay */} {panelOpen && selectedPerson && ( -
-
+
setSelectedPersonId(null)} + > +
e.stopPropagation()} + > {renderPanel()}
diff --git a/frontend/src/components/people/PersonForm.tsx b/frontend/src/components/people/PersonForm.tsx index d6adf1d..df46b82 100644 --- a/frontend/src/components/people/PersonForm.tsx +++ b/frontend/src/components/people/PersonForm.tsx @@ -89,7 +89,7 @@ export default function PersonForm({ person, categories, onClose }: PersonFormPr const handleSubmit = (e: FormEvent) => { e.preventDefault(); - mutation.mutate(formData); + mutation.mutate({ ...formData, birthday: formData.birthday || null } as typeof formData); }; return ( diff --git a/frontend/src/components/shared/CategoryAutocomplete.tsx b/frontend/src/components/shared/CategoryAutocomplete.tsx index 0f7603e..e4fb8ea 100644 --- a/frontend/src/components/shared/CategoryAutocomplete.tsx +++ b/frontend/src/components/shared/CategoryAutocomplete.tsx @@ -17,12 +17,19 @@ export default function CategoryAutocomplete({ id, }: CategoryAutocompleteProps) { const [open, setOpen] = useState(false); + const [activeIndex, setActiveIndex] = useState(-1); const containerRef = useRef(null); + const listRef = useRef(null); const filtered = categories.filter( (c) => c.toLowerCase().includes(value.toLowerCase()) && c.toLowerCase() !== value.toLowerCase() ); + // Reset active index when filtered list changes + useEffect(() => { + setActiveIndex(-1); + }, [filtered.length, value]); + // Close on outside click useEffect(() => { const handleMouseDown = (e: MouseEvent) => { @@ -34,6 +41,14 @@ export default function CategoryAutocomplete({ return () => document.removeEventListener('mousedown', handleMouseDown); }, []); + // Scroll active item into view + useEffect(() => { + if (activeIndex >= 0 && listRef.current) { + const item = listRef.current.children[activeIndex] as HTMLElement | undefined; + item?.scrollIntoView({ block: 'nearest' }); + } + }, [activeIndex]); + const handleBlur = () => { const match = categories.find((c) => c.toLowerCase() === value.toLowerCase()); if (match && match !== value) onChange(match); @@ -43,8 +58,37 @@ export default function CategoryAutocomplete({ const handleSelect = (cat: string) => { onChange(cat); setOpen(false); + setActiveIndex(-1); }; + const handleKeyDown = (e: React.KeyboardEvent) => { + if (!open || filtered.length === 0) return; + + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + setActiveIndex((prev) => (prev < filtered.length - 1 ? prev + 1 : 0)); + break; + case 'ArrowUp': + e.preventDefault(); + setActiveIndex((prev) => (prev > 0 ? prev - 1 : filtered.length - 1)); + break; + case 'Enter': + e.preventDefault(); + if (activeIndex >= 0 && activeIndex < filtered.length) { + handleSelect(filtered[activeIndex]); + } + break; + case 'Escape': + e.preventDefault(); + setOpen(false); + setActiveIndex(-1); + break; + } + }; + + const activeDescendantId = activeIndex >= 0 ? `${id || 'cat'}-option-${activeIndex}` : undefined; + return (
setOpen(true)} onBlur={handleBlur} + onKeyDown={handleKeyDown} autoComplete="off" + role="combobox" aria-autocomplete="list" aria-expanded={open && filtered.length > 0} + aria-controls={open && filtered.length > 0 ? `${id || 'cat'}-listbox` : undefined} + aria-activedescendant={activeDescendantId} /> {open && filtered.length > 0 && (
    - {filtered.map((cat) => ( + {filtered.map((cat, idx) => (
  • { e.preventDefault(); handleSelect(cat); }} - className="px-3 py-1.5 text-sm hover:bg-card-elevated cursor-pointer transition-colors duration-150" + className={`px-3 py-1.5 text-sm cursor-pointer transition-colors duration-150 ${ + idx === activeIndex + ? 'bg-card-elevated text-foreground' + : 'hover:bg-card-elevated' + }`} > {cat}
  • diff --git a/frontend/src/components/shared/CategoryFilterBar.tsx b/frontend/src/components/shared/CategoryFilterBar.tsx index b3b8289..7a1f6f0 100644 --- a/frontend/src/components/shared/CategoryFilterBar.tsx +++ b/frontend/src/components/shared/CategoryFilterBar.tsx @@ -1,6 +1,22 @@ import { useState, useRef, useEffect } from 'react'; import { Search } from 'lucide-react'; import { Input } from '@/components/ui/input'; +import { + DndContext, + closestCenter, + PointerSensor, + useSensor, + useSensors, + type DragEndEvent, +} from '@dnd-kit/core'; +import { restrictToHorizontalAxis } from '@dnd-kit/modifiers'; +import { + SortableContext, + horizontalListSortingStrategy, + useSortable, + arrayMove, +} from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; interface CategoryFilterBarProps { activeFilters: string[]; @@ -10,6 +26,8 @@ interface CategoryFilterBarProps { onToggleAll: () => void; onTogglePinned: () => void; onToggleCategory: (cat: string) => void; + onSelectAllCategories?: () => void; + onReorderCategories?: (order: string[]) => void; searchValue: string; onSearchChange: (val: string) => void; } @@ -22,6 +40,70 @@ const activePillStyle = { color: 'hsl(var(--accent-color))', }; +// --------------------------------------------------------------------------- +// SortableCategoryChip +// --------------------------------------------------------------------------- +function SortableCategoryChip({ + id, + isActive, + onToggle, +}: { + id: string; + isActive: boolean; + onToggle: () => void; +}) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id }); + + const wasDragging = useRef(false); + + useEffect(() => { + if (isDragging) wasDragging.current = true; + }, [isDragging]); + + const handleClick = () => { + if (wasDragging.current) { + wasDragging.current = false; + return; + } + onToggle(); + }; + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + ...(isActive ? activePillStyle : {}), + }; + + return ( + + ); +} + +// --------------------------------------------------------------------------- +// CategoryFilterBar +// --------------------------------------------------------------------------- export default function CategoryFilterBar({ activeFilters, pinnedLabel, @@ -30,6 +112,8 @@ export default function CategoryFilterBar({ onToggleAll, onTogglePinned, onToggleCategory, + onSelectAllCategories, + onReorderCategories, searchValue, onSearchChange, }: CategoryFilterBarProps) { @@ -38,6 +122,12 @@ export default function CategoryFilterBar({ const searchInputRef = useRef(null); const isAllActive = activeFilters.length === 0; + const allCategoriesSelected = + categories.length > 0 && categories.every((c) => activeFilters.includes(c)); + + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), + ); // Collapse search if there are many categories useEffect(() => { @@ -49,6 +139,15 @@ export default function CategoryFilterBar({ setTimeout(() => searchInputRef.current?.focus(), 50); }; + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + if (!over || active.id === over.id || !onReorderCategories) return; + const oldIndex = categories.indexOf(String(active.id)); + const newIndex = categories.indexOf(String(over.id)); + if (oldIndex === -1 || newIndex === -1) return; + onReorderCategories(arrayMove(categories, oldIndex, newIndex)); + }; + return (
    {/* All pill */} @@ -81,7 +180,7 @@ export default function CategoryFilterBar({ - {/* Other pill + expandable chips */} + {/* Categories pill + expandable chips */} {categories.length > 0 && ( <> @@ -104,28 +203,49 @@ export default function CategoryFilterBar({ overflow: 'hidden', }} > - {categories.map((cat) => { - const isActive = activeFilters.includes(cat); - return ( - - ); - })} + All + + + )} + + {/* Draggable category chips */} + + + {categories.map((cat) => ( + onToggleCategory(cat)} + /> + ))} + +
    )} diff --git a/frontend/src/components/shared/EntityDetailPanel.tsx b/frontend/src/components/shared/EntityDetailPanel.tsx index a280dcf..b118350 100644 --- a/frontend/src/components/shared/EntityDetailPanel.tsx +++ b/frontend/src/components/shared/EntityDetailPanel.tsx @@ -1,4 +1,3 @@ -import { useCallback } from 'react'; import { X, Pencil, Trash2, Star, StarOff } from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; import { Button } from '@/components/ui/button'; @@ -43,8 +42,7 @@ export function EntityDetailPanel({ onToggleFavourite, favouriteLabel = 'favourite', }: EntityDetailPanelProps) { - const executeDelete = useCallback(() => onDelete(), [onDelete]); - const { confirming, handleClick: handleDelete } = useConfirmAction(executeDelete); + const { confirming, handleClick: handleDelete } = useConfirmAction(onDelete); if (!item) return null; diff --git a/frontend/src/components/shared/EntityTable.tsx b/frontend/src/components/shared/EntityTable.tsx index 0146d04..de4a244 100644 --- a/frontend/src/components/shared/EntityTable.tsx +++ b/frontend/src/components/shared/EntityTable.tsx @@ -48,34 +48,35 @@ function SkeletonRow({ colCount }: { colCount: number }) { ); } -export function EntityTable({ - columns, - groups, - pinnedRows, - pinnedLabel, - showPinned, - selectedId, - onRowClick, +function SortIcon({ sortKey, sortDir, - onSort, - visibilityMode, - loading = false, -}: EntityTableProps) { - const visibleColumns = columns.filter((col) => isVisible(col.visibilityLevel, visibilityMode)); - const colCount = visibleColumns.length; - const showPinnedSection = showPinned && pinnedRows.length > 0; + colKey, +}: { + sortKey: string; + sortDir: 'asc' | 'desc'; + colKey: string; +}) { + if (sortKey !== colKey) return ; + return sortDir === 'asc' ? ( + + ) : ( + + ); +} - const SortIcon = ({ colKey }: { colKey: string }) => { - if (sortKey !== colKey) return ; - return sortDir === 'asc' ? ( - - ) : ( - - ); - }; - - const DataRow = ({ item }: { item: T }) => ( +function DataRow({ + item, + visibleColumns, + selectedId, + onRowClick, +}: { + item: T; + visibleColumns: ColumnDef[]; + selectedId: number | null; + onRowClick: (id: number) => void; +}) { + return ( ({ ))} ); +} - const SectionHeader = ({ label }: { label: string }) => ( +function SectionHeader({ label, colCount }: { label: string; colCount: number }) { + return ( ({ ); +} + +export function EntityTable({ + columns, + groups, + pinnedRows, + pinnedLabel, + showPinned, + selectedId, + onRowClick, + sortKey, + sortDir, + onSort, + visibilityMode, + loading = false, +}: EntityTableProps) { + const visibleColumns = columns.filter((col) => isVisible(col.visibilityLevel, visibilityMode)); + const colCount = visibleColumns.length; + const showPinnedSection = showPinned && pinnedRows.length > 0; return (
    @@ -128,7 +150,7 @@ export function EntityTable({ className="flex items-center hover:text-foreground transition-colors duration-150" > {col.label} - + ) : ( col.label @@ -144,9 +166,15 @@ export function EntityTable({ <> {showPinnedSection && ( <> - + {pinnedRows.map((item) => ( - + ))} )} @@ -154,9 +182,15 @@ export function EntityTable({ {group.rows.length > 0 && ( <> - + {group.rows.map((item) => ( - + ))} )} diff --git a/frontend/src/components/shared/utils.ts b/frontend/src/components/shared/utils.ts index effb9f8..5430fa4 100644 --- a/frontend/src/components/shared/utils.ts +++ b/frontend/src/components/shared/utils.ts @@ -29,9 +29,7 @@ export function getAvatarColor(name: string): string { export function formatUpdatedAt(updatedAt: string): string { try { - // 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 })}`; + return `Updated ${formatDistanceToNow(parseISO(updatedAt), { addSuffix: true })}`; } catch { return ''; } diff --git a/frontend/src/hooks/useCategoryOrder.ts b/frontend/src/hooks/useCategoryOrder.ts new file mode 100644 index 0000000..71e1a31 --- /dev/null +++ b/frontend/src/hooks/useCategoryOrder.ts @@ -0,0 +1,41 @@ +import { useState, useCallback, useMemo } from 'react'; + +const STORAGE_PREFIX = 'umbra-'; + +export function useCategoryOrder(key: string, categories: string[]) { + const storageKey = `${STORAGE_PREFIX}${key}-category-order`; + + const [savedOrder, setSavedOrder] = useState(() => { + try { + const raw = localStorage.getItem(storageKey); + if (!raw) return []; + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed.filter((x): x is string => typeof x === 'string') : []; + } catch { + return []; + } + }); + + const orderedCategories = useMemo(() => { + const catSet = new Set(categories); + // Keep saved items that still exist, in saved order + const ordered = savedOrder.filter((c) => catSet.has(c)); + // Append any new categories not in saved order + const remaining = categories.filter((c) => !savedOrder.includes(c)); + return [...ordered, ...remaining]; + }, [categories, savedOrder]); + + const reorder = useCallback( + (newOrder: string[]) => { + setSavedOrder(newOrder); + try { + localStorage.setItem(storageKey, JSON.stringify(newOrder)); + } catch { + // localStorage full — silently ignore + } + }, + [storageKey], + ); + + return { orderedCategories, reorder }; +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index d490331..7bd7268 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -151,7 +151,6 @@ export interface Person { address?: string; birthday?: string; category?: string; - relationship?: string; is_favourite: boolean; company?: string; job_title?: string;