feat: Reminder Manager UX — timeline-first grouped layout

Replace 3-row filter panel with a grouped feed (Überfällig / Diese Woche /
Dieser Monat / Später). KPI cards are now clickable shortcuts that filter to
their time horizon or type (Pre-Market). Filter bar condenses to one row:
search + type dropdown + archive toggle. Rows get a left priority-color
border (red/orange/yellow). Default status filter is ACTIVE+SNOOZED.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-05-24 22:42:37 +02:00
parent a9e038a64f
commit d804d1c923
6 changed files with 263 additions and 162 deletions
+116 -35
View File
@@ -1,5 +1,5 @@
import { useMemo, useCallback } from 'react'
import { Box, Typography } from '@mui/material'
import { Box, Chip, Typography } from '@mui/material'
import { useReminders } from '../../hooks/useReminders'
import { useShallow } from 'zustand/react/shallow'
import { useReminderStore } from '../../stores/reminderStore'
@@ -8,24 +8,47 @@ import { ReminderCard } from './ReminderCard'
import { ReminderSkeleton } from './ReminderSkeleton'
import { ReminderEmptyState } from './ReminderEmptyState'
import type { Reminder } from '../../domain/reminder'
import { ReminderStatus } from '../../domain/reminder'
import type { FilterHorizon } from '../../stores/reminderStore'
const LIST_HEADER_COLS = '100px 130px 1fr 140px 90px 100px 90px'
const MOCK_TODAY = new Date('2026-05-20')
function getHorizon(dueDate: string): FilterHorizon {
const days = Math.ceil((new Date(dueDate).getTime() - MOCK_TODAY.getTime()) / (1000 * 60 * 60 * 24))
if (days <= 0) return 'OVERDUE'
if (days <= 7) return 'THIS_WEEK'
if (days <= 30) return 'THIS_MONTH'
return 'LATER'
}
const HORIZON_CONFIG: { key: FilterHorizon; label: string; color: string; bg: string; border: string }[] = [
{ key: 'OVERDUE', label: 'Überfällig', color: '#dc2626', bg: '#fef2f2', border: '#fecaca' },
{ key: 'THIS_WEEK', label: 'Diese Woche', color: '#ea580c', bg: '#fff7ed', border: '#fed7aa' },
{ key: 'THIS_MONTH', label: 'Dieser Monat', color: '#0369a1', bg: '#f0f9ff', border: '#bae6fd' },
{ key: 'LATER', label: 'Später', color: '#64748b', bg: '#f8fafc', border: '#e2e8f0' },
]
const LIST_COLS = '100px 130px 1fr 140px 90px 100px 90px'
function applyFilters(
reminders: Reminder[],
filterType: string,
filterStatus: string,
filterPriority: string,
filterHorizon: string,
searchQuery: string,
): Reminder[] {
return reminders.filter(r => {
if (filterStatus === 'ACTIVE') {
if (r.status !== ReminderStatus.ACTIVE && r.status !== ReminderStatus.SNOOZED) return false
} else if (filterStatus !== 'ALL') {
if (r.status !== filterStatus) return false
}
if (filterType !== 'ALL' && r.type !== filterType) return false
if (filterStatus !== 'ALL' && r.status !== filterStatus) return false
if (filterPriority !== 'ALL' && r.priority !== filterPriority) return false
if (filterHorizon !== 'ALL' && getHorizon(r.dueDate) !== filterHorizon) return false
if (searchQuery) {
const q = searchQuery.toLowerCase()
const haystack = `${r.propertyTitle} ${r.propertyCity} ${r.tenantName}`.toLowerCase()
if (!haystack.includes(q)) return false
const hay = `${r.propertyTitle} ${r.propertyCity} ${r.tenantName}`.toLowerCase()
if (!hay.includes(q)) return false
}
return true
})
@@ -34,59 +57,83 @@ function applyFilters(
export function ReminderFeed() {
const { data, isLoading } = useReminders()
const {
filterType, filterStatus, filterPriority, searchQuery, viewMode,
setFilterType, setFilterStatus, setFilterPriority, setSearchQuery,
filterType, filterStatus, filterHorizon, searchQuery, viewMode,
setFilterType, setFilterStatus, setFilterHorizon, setSearchQuery,
} = useReminderStore(useShallow(s => ({
filterType: s.filterType, filterStatus: s.filterStatus,
filterPriority: s.filterPriority, searchQuery: s.searchQuery,
filterHorizon: s.filterHorizon, searchQuery: s.searchQuery,
viewMode: s.viewMode, setFilterType: s.setFilterType,
setFilterStatus: s.setFilterStatus, setFilterPriority: s.setFilterPriority,
setFilterStatus: s.setFilterStatus, setFilterHorizon: s.setFilterHorizon,
setSearchQuery: s.setSearchQuery,
})))
const reminders = data ?? []
const filtered = useMemo(
() => applyFilters(reminders, filterType, filterStatus, filterPriority, searchQuery),
[reminders, filterType, filterStatus, filterPriority, searchQuery],
() => applyFilters(reminders, filterType, filterStatus, filterHorizon, searchQuery),
[reminders, filterType, filterStatus, filterHorizon, searchQuery],
)
const grouped = useMemo(() => {
const map: Record<string, Reminder[]> = {
OVERDUE: [], THIS_WEEK: [], THIS_MONTH: [], LATER: [],
}
filtered.forEach(r => map[getHorizon(r.dueDate)].push(r))
return map
}, [filtered])
const resetFilters = useCallback(() => {
setFilterType('ALL')
setFilterStatus('ALL')
setFilterPriority('ALL')
setFilterStatus('ACTIVE')
setFilterHorizon('ALL')
setSearchQuery('')
}, [setFilterType, setFilterStatus, setFilterPriority, setSearchQuery])
}, [setFilterType, setFilterStatus, setFilterHorizon, setSearchQuery])
if (isLoading) return <ReminderSkeleton />
if (filtered.length === 0) {
return <ReminderEmptyState onReset={resetFilters} />
}
if (filtered.length === 0) return <ReminderEmptyState onReset={resetFilters} />
if (viewMode === 'card') {
return (
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))',
gap: 2,
}}
>
{filtered.map(r => (
<ReminderCard key={r.id} reminder={r} />
))}
<Box className="flex flex-col gap-4">
{HORIZON_CONFIG.map(({ key, label, color, bg, border }) => {
const group = grouped[key]
if (!group || group.length === 0) return null
return (
<Box key={key}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5, px: 0.5 }}>
<Box sx={{ width: 3, height: 16, borderRadius: 1, bgcolor: color, flexShrink: 0 }} />
<Typography variant="body2" sx={{ fontWeight: 700, color, fontSize: '0.8125rem' }}>
{label}
</Typography>
<Chip
label={group.length}
size="small"
sx={{ height: 18, fontSize: '0.65rem', bgcolor: color, color: 'white', ml: 0.25 }}
/>
</Box>
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))',
gap: 2,
}}
>
{group.map(r => <ReminderCard key={r.id} reminder={r} />)}
</Box>
</Box>
)
})}
</Box>
)
}
return (
<Box sx={{ border: '1px solid #e2e8f0', borderRadius: 1, overflow: 'hidden' }}>
<Box sx={{ border: '1px solid #e2e8f0', borderRadius: 1.5, overflow: 'hidden' }}>
{/* Table header */}
<Box
sx={{
display: 'grid',
gridTemplateColumns: LIST_HEADER_COLS,
gridTemplateColumns: LIST_COLS,
gap: 1,
px: 2,
py: 1,
@@ -101,9 +148,43 @@ export function ReminderFeed() {
))}
</Box>
{filtered.map(r => (
<ReminderListRow key={r.id} reminder={r} />
))}
{/* Grouped rows */}
{HORIZON_CONFIG.map(({ key, label, color, bg, border }) => {
const group = grouped[key]
if (!group || group.length === 0) return null
return (
<Box key={key}>
{/* Section divider */}
<Box
sx={{
px: 2,
py: 0.625,
bgcolor: bg,
borderTop: `1px solid ${border}`,
borderBottom: `1px solid ${border}`,
display: 'flex',
alignItems: 'center',
gap: 1,
}}
>
<Typography
variant="caption"
sx={{ fontWeight: 700, color, textTransform: 'uppercase', letterSpacing: 0.6, fontSize: '0.65rem' }}
>
{label}
</Typography>
<Chip
label={group.length}
size="small"
sx={{ height: 16, fontSize: '0.6rem', bgcolor: color, color: 'white', px: 0 }}
/>
</Box>
{/* Rows */}
{group.map(r => <ReminderListRow key={r.id} reminder={r} />)}
</Box>
)
})}
</Box>
)
}