I finished the remaining polish so the admin app now feels fully “app‑like” across the core screens.

This commit is contained in:
Codex Agent
2025-12-28 20:48:32 +01:00
parent d3b6c6c029
commit 1e0c38fce4
23 changed files with 1250 additions and 112 deletions

View File

@@ -286,6 +286,7 @@
"general": "Allgemein"
},
"markAllRead": "Alle als gelesen markieren",
"markScopeRead": "Als gelesen markieren",
"markFailed": "Benachrichtigungen konnten nicht aktualisiert werden.",
"unread": "Ungelesen"
},
@@ -317,6 +318,38 @@
"draft": "Entwurf",
"archived": "Archiviert"
},
"list": {
"title": "Deine Events",
"subtitle": "Plane besondere Momente. Verwalte alles rund um deine Events.",
"filters": {
"all": "Alle",
"upcoming": "Bevorstehend",
"draft": "Entwurf",
"past": "Vergangen"
},
"empty": {
"filtered": "Keine Events passen zu diesem Filter.",
"filteredHint": "Wähle einen anderen Status oder lösche die Suche."
},
"actions": {
"create": "Neues Event",
"settings": "Einstellungen",
"open": "Event öffnen"
},
"stats": {
"photos": "Fotos",
"guests": "Gäste",
"tasks": "Tasks"
},
"overview": {
"title": "Übersicht",
"empty": "Noch keine Events lege dein erstes an.",
"count": "{{count}} {{count, plural, one {Event} other {Events}}} verwaltet.",
"badge": {
"dashboard": "Kundendashboard"
}
}
},
"errors": {
"missingSlug": "Kein Event ausgewählt.",
"loadFailed": "Event konnte nicht geladen werden.",
@@ -1775,6 +1808,32 @@
"draft": "Entwurf",
"archived": "Archiviert"
},
"list": {
"title": "Deine Events",
"subtitle": "Plane besondere Momente. Verwalte alles rund um deine Events.",
"filters": {
"all": "Alle",
"upcoming": "Bevorstehend",
"draft": "Entwurf",
"past": "Vergangen"
},
"empty": {
"filtered": "Keine Events passen zu diesem Filter.",
"filteredHint": "Wähle einen anderen Status oder lösche die Suche."
},
"actions": {
"create": "Neues Event",
"settings": "Einstellungen"
},
"overview": {
"title": "Übersicht",
"empty": "Noch keine Events lege dein erstes an.",
"count": "{{count}} {{count, plural, one {Event} other {Events}}} verwaltet.",
"badge": {
"dashboard": "Kundendashboard"
}
}
},
"errors": {
"missingSlug": "Kein Event-Slug angegeben.",
"missingType": "Event-Typ fehlt. Bitte speichere das Event erneut im Admin.",
@@ -1864,6 +1923,19 @@
"disabledTitle": "Task-Modus ist für dieses Event aus",
"disabledBody": "Gäste sehen nur den Fotofeed. Aktiviere Tasks in den Event-Einstellungen, um sie wieder anzuzeigen.",
"title": "Tasks & Checklisten",
"quickNav": "Schnellzugriff",
"sections": {
"assigned": "Zugewiesen",
"library": "Bibliothek",
"collections": "Sammlungen",
"emotions": "Emotionen"
},
"summary": {
"assigned": "Zugewiesen",
"library": "Bibliothek",
"collections": "Sammlungen",
"emotions": "Emotionen"
},
"actions": "Aktionen",
"assigned": "Task hinzugefügt",
"updateFailed": "Task konnte nicht gespeichert werden.",
@@ -2077,7 +2149,9 @@
"prompt": "Berechtigung nötig",
"unsupported": "Nicht unterstützt",
"persisted": "Geschützt",
"available": "Nicht geschützt"
"available": "Nicht geschützt",
"online": "Online",
"offline": "Offline"
},
"deviceStatus": {
"notifications": {
@@ -2091,6 +2165,10 @@
"storage": {
"label": "Offline-Speicher",
"description": "Schützt zwischengespeicherte Daten vor Löschung."
},
"connection": {
"label": "Verbindung",
"description": "Zeigt an, ob die App online oder offline ist."
}
},
"experienceTitle": "Erlebnis",

View File

@@ -281,6 +281,7 @@
"general": "General"
},
"markAllRead": "Mark all read",
"markScopeRead": "Mark read",
"markFailed": "Could not update notifications.",
"unread": "Unread"
},
@@ -315,9 +316,25 @@
"list": {
"title": "Your events",
"subtitle": "Plan memorable moments. Manage everything around your events here.",
"filters": {
"all": "All",
"upcoming": "Upcoming",
"draft": "Draft",
"past": "Past"
},
"empty": {
"filtered": "No events match this filter.",
"filteredHint": "Try a different status or clear your search."
},
"actions": {
"create": "New event",
"settings": "Settings"
"settings": "Settings",
"open": "Open event"
},
"stats": {
"photos": "Photos",
"guests": "Guests",
"tasks": "Tasks"
},
"overview": {
"title": "Overview",
@@ -1795,6 +1812,32 @@
"draft": "Draft",
"archived": "Archived"
},
"list": {
"title": "Your events",
"subtitle": "Plan memorable moments. Manage everything around your events here.",
"filters": {
"all": "All",
"upcoming": "Upcoming",
"draft": "Draft",
"past": "Past"
},
"empty": {
"filtered": "No events match this filter.",
"filteredHint": "Try a different status or clear your search."
},
"actions": {
"create": "New event",
"settings": "Settings"
},
"overview": {
"title": "Overview",
"empty": "No events yet create your first one to get started.",
"count": "{{count}} {{count, plural, one {event} other {events}}} managed.",
"badge": {
"dashboard": "Customer dashboard"
}
}
},
"errors": {
"missingSlug": "No event slug provided.",
"missingType": "Event type is missing. Please save the event again in the admin.",
@@ -1884,6 +1927,19 @@
"disabledTitle": "Task mode is off for this event",
"disabledBody": "Guests only see the photo feed. Enable tasks in the event settings to show them again.",
"title": "Tasks & checklists",
"quickNav": "Quick jump",
"sections": {
"assigned": "Assigned",
"library": "Library",
"collections": "Collections",
"emotions": "Emotions"
},
"summary": {
"assigned": "Assigned",
"library": "Library",
"collections": "Collections",
"emotions": "Emotions"
},
"actions": "Actions",
"assigned": "Task added",
"updateFailed": "Task could not be saved.",
@@ -2097,7 +2153,9 @@
"prompt": "Needs permission",
"unsupported": "Not supported",
"persisted": "Protected",
"available": "Not protected"
"available": "Not protected",
"online": "Online",
"offline": "Offline"
},
"deviceStatus": {
"notifications": {
@@ -2111,6 +2169,10 @@
"storage": {
"label": "Offline storage",
"description": "Protect cached data from eviction."
},
"connection": {
"label": "Connection",
"description": "Shows if the app is online or offline."
}
},
"experienceTitle": "Experience",

View File

@@ -1,11 +1,11 @@
import React from 'react';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Image as ImageIcon, RefreshCcw, Filter, Check } from 'lucide-react';
import { Image as ImageIcon, RefreshCcw, Filter, Check, Eye, EyeOff } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite';
import { AnimatePresence, motion } from 'framer-motion';
import { AnimatePresence, motion, useAnimationControls, type PanInfo } from 'framer-motion';
import { MobileShell, HeaderActionButton } from './components/MobileShell';
import { MobileCard, PillBadge, CTAButton, SkeletonCard } from './components/Primitives';
import { MobileField, MobileInput, MobileSelect } from './components/FormControls';
@@ -44,6 +44,7 @@ import {
replacePhotoQueue,
type PhotoModerationAction,
} from './lib/photoModerationQueue';
import { resolvePhotoSwipeAction, type SwipeModerationAction } from './lib/photoModerationSwipe';
import { ADMIN_EVENT_PHOTOS_PATH } from '../constants';
type FilterKey = 'all' | 'featured' | 'hidden' | 'pending';
@@ -775,10 +776,14 @@ export default function MobileEventPhotosPage() {
>
{photos.map((photo) => {
const isSelected = selectedIds.includes(photo.id);
const swipeDisabled = selectionMode || busyId === photo.id;
return (
<Pressable
<PhotoSwipeCard
key={photo.id}
onPress={() => (selectionMode ? toggleSelection(photo.id) : setLightboxWithUrl(photo.id))}
photo={photo}
disabled={swipeDisabled}
onOpen={() => (selectionMode ? toggleSelection(photo.id) : setLightboxWithUrl(photo.id))}
onModerate={(action) => handleModerationAction(action, photo)}
>
<YStack
borderRadius={10}
@@ -817,7 +822,7 @@ export default function MobileEventPhotosPage() {
</XStack>
) : null}
</YStack>
</Pressable>
</PhotoSwipeCard>
);
})}
</div>
@@ -1087,6 +1092,153 @@ export default function MobileEventPhotosPage() {
);
}
type PhotoSwipeCardProps = {
photo: TenantPhoto;
disabled?: boolean;
onOpen: () => void;
onModerate: (action: PhotoModerationAction['action']) => void;
children: React.ReactNode;
};
type SwipeActionConfig = {
label: string;
bg: string;
text: string;
icon: typeof Eye;
};
function PhotoSwipeCard({ photo, disabled = false, onOpen, onModerate, children }: PhotoSwipeCardProps) {
const { t } = useTranslation('management');
const theme = useTheme();
const controls = useAnimationControls();
const dragged = React.useRef(false);
const leftAction = resolvePhotoSwipeAction(photo, 'left');
const rightAction = resolvePhotoSwipeAction(photo, 'right');
const canSwipe = !disabled && (leftAction || rightAction);
const resolveActionConfig = (action: SwipeModerationAction): SwipeActionConfig | null => {
if (!action) {
return null;
}
if (action === 'approve') {
return {
label: t('photos.actions.approve', 'Approve'),
bg: String(theme.green3?.val ?? '#dcfce7'),
text: String(theme.green11?.val ?? '#166534'),
icon: Check,
};
}
if (action === 'hide') {
return {
label: t('photos.actions.hide', 'Hide'),
bg: String(theme.red3?.val ?? '#fee2e2'),
text: String(theme.red11?.val ?? '#b91c1c'),
icon: EyeOff,
};
}
return {
label: t('photos.actions.show', 'Show'),
bg: String(theme.blue3?.val ?? '#dbeafe'),
text: String(theme.blue11?.val ?? '#1d4ed8'),
icon: Eye,
};
};
const leftConfig = resolveActionConfig(leftAction);
const rightConfig = resolveActionConfig(rightAction);
const handleDrag = (_event: PointerEvent, info: PanInfo) => {
if (!canSwipe) {
return;
}
dragged.current = Math.abs(info.offset.x) > 6;
};
const handleDragEnd = (_event: PointerEvent, info: PanInfo) => {
if (!canSwipe) {
return;
}
const swipeThreshold = 64;
if (info.offset.x > swipeThreshold && rightAction) {
onModerate(rightAction);
} else if (info.offset.x < -swipeThreshold && leftAction) {
onModerate(leftAction);
}
dragged.current = false;
void controls.start({ x: 0, transition: { type: 'spring', stiffness: 320, damping: 26 } });
};
const handlePress = () => {
if (dragged.current) {
dragged.current = false;
return;
}
onOpen();
};
return (
<div style={{ position: 'relative' }}>
{leftConfig || rightConfig ? (
<XStack
alignItems="center"
justifyContent="space-between"
paddingHorizontal="$2"
borderRadius="$3"
pointerEvents="none"
style={{ position: 'absolute', inset: 0 }}
>
<XStack flex={1} alignItems="center" justifyContent="flex-start">
{rightConfig ? (
<XStack
alignItems="center"
space="$1"
paddingHorizontal="$2"
paddingVertical="$1"
borderRadius={999}
backgroundColor={rightConfig.bg}
>
<rightConfig.icon size={12} color={rightConfig.text} />
<Text fontSize="$xs" fontWeight="700" color={rightConfig.text}>
{rightConfig.label}
</Text>
</XStack>
) : null}
</XStack>
<XStack flex={1} alignItems="center" justifyContent="flex-end">
{leftConfig ? (
<XStack
alignItems="center"
space="$1"
paddingHorizontal="$2"
paddingVertical="$1"
borderRadius={999}
backgroundColor={leftConfig.bg}
>
<leftConfig.icon size={12} color={leftConfig.text} />
<Text fontSize="$xs" fontWeight="700" color={leftConfig.text}>
{leftConfig.label}
</Text>
</XStack>
) : null}
</XStack>
</XStack>
) : null}
<motion.div
drag={canSwipe ? 'x' : false}
dragElastic={0.2}
dragConstraints={{ left: -80, right: 80 }}
onDrag={handleDrag}
onDragEnd={handleDragEnd}
animate={controls}
initial={{ x: 0 }}
style={{ touchAction: 'pan-y', position: 'relative', zIndex: 1 }}
>
<Pressable onPress={handlePress}>{children}</Pressable>
</motion.div>
</div>
);
}
type LimitTranslator = (key: string, options?: Record<string, unknown>) => string;
function translateLimits(t: (key: string, defaultValue?: string, options?: Record<string, unknown>) => string): LimitTranslator {

View File

@@ -7,7 +7,7 @@ import { SizableText as Text } from '@tamagui/text';
import { ListItem } from '@tamagui/list-item';
import { Pressable } from '@tamagui/react-native-web-lite';
import { MobileShell, HeaderActionButton } from './components/MobileShell';
import { MobileCard, CTAButton, SkeletonCard } from './components/Primitives';
import { MobileCard, CTAButton, SkeletonCard, PillBadge } from './components/Primitives';
import { MobileField, MobileInput, MobileSelect, MobileTextArea } from './components/FormControls';
import {
getEvent,
@@ -39,12 +39,63 @@ import { useEventContext } from '../context/EventContext';
import { useTheme } from '@tamagui/core';
import { RadioGroup } from '@tamagui/radio-group';
import { useBackNavigation } from './hooks/useBackNavigation';
import { buildTaskSummary } from './lib/taskSummary';
import { buildTaskSectionCounts, type TaskSectionKey } from './lib/taskSectionCounts';
function InlineSeparator() {
const theme = useTheme();
return <XStack height={1} opacity={0.7} marginLeft="$3" backgroundColor={theme.borderColor?.val ?? '#e5e7eb'} />;
}
function TaskSummaryCard({
summary,
text,
muted,
border,
}: {
summary: ReturnType<typeof buildTaskSummary>;
text: string;
muted: string;
border: string;
}) {
const { t } = useTranslation('management');
return (
<MobileCard space="$2" borderColor={border}>
<XStack alignItems="center" justifyContent="space-between" space="$2">
<SummaryItem label={t('events.tasks.summary.assigned', 'Assigned')} value={summary.assigned} text={text} muted={muted} />
<SummaryItem label={t('events.tasks.summary.library', 'Library')} value={summary.library} text={text} muted={muted} />
</XStack>
<XStack alignItems="center" justifyContent="space-between" space="$2">
<SummaryItem label={t('events.tasks.summary.collections', 'Collections')} value={summary.collections} text={text} muted={muted} />
<SummaryItem label={t('events.tasks.summary.emotions', 'Emotions')} value={summary.emotions} text={text} muted={muted} />
</XStack>
</MobileCard>
);
}
function SummaryItem({
label,
value,
text,
muted,
}: {
label: string;
value: number;
text: string;
muted: string;
}) {
return (
<YStack flex={1} padding="$2" borderRadius={12} backgroundColor="rgba(15, 23, 42, 0.03)" space="$1">
<Text fontSize={11} color={muted}>
{label}
</Text>
<Text fontSize={16} fontWeight="800" color={text}>
{value}
</Text>
</YStack>
);
}
export default function MobileEventTasksPage() {
const { slug: slugParam } = useParams<{ slug?: string }>();
const { activeEvent, selectEvent } = useEventContext();
@@ -89,7 +140,16 @@ export default function MobileEventTasksPage() {
const [emotionForm, setEmotionForm] = React.useState({ name: '', color: String(border) });
const [savingEmotion, setSavingEmotion] = React.useState(false);
const [showEmotionFilterSheet, setShowEmotionFilterSheet] = React.useState(false);
const assignedRef = React.useRef<HTMLDivElement>(null);
const libraryRef = React.useRef<HTMLDivElement>(null);
const back = useBackNavigation(slug ? adminPath(`/mobile/events/${slug}`) : adminPath('/mobile/events'));
const summary = buildTaskSummary({
assigned: assignedTasks.length,
library: library.length,
collections: collections.length,
emotions: emotions.length,
});
const sectionCounts = React.useMemo(() => buildTaskSectionCounts(summary), [summary]);
React.useEffect(() => {
if (slugParam && activeEvent?.slug !== slugParam) {
selectEvent(slugParam);
@@ -102,6 +162,28 @@ export default function MobileEventTasksPage() {
setSearchTerm('');
}, [slug]);
const scrollToSection = (ref: React.RefObject<HTMLDivElement>) => {
if (ref.current) {
ref.current.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
};
const handleQuickNav = (key: TaskSectionKey) => {
if (key === 'assigned') {
scrollToSection(assignedRef);
return;
}
if (key === 'library') {
scrollToSection(libraryRef);
return;
}
if (key === 'collections') {
setShowCollectionSheet(true);
return;
}
setShowEmotionSheet(true);
};
const load = React.useCallback(async () => {
if (!slug) {
try {
@@ -374,6 +456,44 @@ export default function MobileEventTasksPage() {
</MobileCard>
) : null}
{!loading ? (
<TaskSummaryCard
summary={summary}
text={text}
muted={muted}
border={border}
/>
) : null}
{!loading ? (
<YStack space="$2">
<Text fontSize={12} fontWeight="700" color={muted}>
{t('events.tasks.quickNav', 'Quick jump')}
</Text>
<XStack space="$2" flexWrap="wrap">
{sectionCounts.map((section) => (
<Pressable key={section.key} onPress={() => handleQuickNav(section.key)} style={{ flexGrow: 1 }}>
<XStack
alignItems="center"
justifyContent="center"
space="$1.5"
paddingVertical="$2"
paddingHorizontal="$3"
borderRadius={14}
borderWidth={1}
borderColor={border}
>
<Text fontSize="$xs" fontWeight="700" color={text}>
{t(`events.tasks.sections.${section.key}`, section.key)}
</Text>
<PillBadge tone="muted">{section.count}</PillBadge>
</XStack>
</Pressable>
))}
</XStack>
</YStack>
) : null}
{loading ? (
<YStack space="$2">
{Array.from({ length: 4 }).map((_, idx) => (
@@ -471,6 +591,7 @@ export default function MobileEventTasksPage() {
</YStack>
) : (
<YStack space="$2">
<div ref={assignedRef} />
<YStack space="$2">
<MobileInput
type="search"
@@ -540,6 +661,7 @@ export default function MobileEventTasksPage() {
))}
</YStack>
<XStack justifyContent="space-between" alignItems="center" marginTop="$2">
<div ref={libraryRef} />
<Text fontSize={12.5} fontWeight="600" color={text}>
{t('events.tasks.library', 'Weitere Aufgaben')}
</Text>

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { CalendarDays, MapPin, Plus, Search } from 'lucide-react';
import { CalendarDays, MapPin, Plus, Search, Camera, Users, Sparkles } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite';
@@ -14,6 +14,8 @@ import { isAuthError } from '../auth/tokens';
import { getApiErrorMessage } from '../lib/apiError';
import { useTheme } from '@tamagui/core';
import { useBackNavigation } from './hooks/useBackNavigation';
import { buildEventStatusCounts, filterEventsByStatus, resolveEventStatusKey, type EventStatusKey } from './lib/eventFilters';
import { buildEventListStats } from './lib/eventListStats';
export default function MobileEventsPage() {
const { t } = useTranslation('management');
@@ -22,6 +24,7 @@ export default function MobileEventsPage() {
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [query, setQuery] = React.useState('');
const [statusFilter, setStatusFilter] = React.useState<EventStatusKey>('all');
const searchRef = React.useRef<HTMLInputElement>(null);
const back = useBackNavigation();
const theme = useTheme();
@@ -92,27 +95,14 @@ export default function MobileEventsPage() {
<CTAButton label={t('events.actions.create', 'Create New Event')} onPress={() => navigate(adminPath('/events/new'))} />
</MobileCard>
) : (
<YStack space="$3">
{events
.filter((event) => {
if (!query.trim()) return true;
const hay = `${event.name ?? ''} ${event.location ?? ''}`.toLowerCase();
return hay.includes(query.toLowerCase());
})
.map((event) => (
<EventRow
key={event.id}
event={event}
text={text}
muted={muted}
subtle={subtle}
border={border}
primary={primary}
onOpen={(slug) => navigate(adminPath(`/mobile/events/${slug}`))}
onEdit={(slug) => navigate(adminPath(`/mobile/events/${slug}/edit`))}
/>
))}
</YStack>
<EventsList
events={events}
query={query}
statusFilter={statusFilter}
onStatusChange={setStatusFilter}
onOpen={(slug) => navigate(adminPath(`/mobile/events/${slug}`))}
onEdit={(slug) => navigate(adminPath(`/mobile/events/${slug}/edit`))}
/>
)}
<FloatingActionButton
@@ -124,6 +114,127 @@ export default function MobileEventsPage() {
);
}
function EventsList({
events,
query,
statusFilter,
onStatusChange,
onOpen,
onEdit,
}: {
events: TenantEvent[];
query: string;
statusFilter: EventStatusKey;
onStatusChange: (value: EventStatusKey) => void;
onOpen: (slug: string) => void;
onEdit: (slug: string) => void;
}) {
const { t } = useTranslation('management');
const theme = useTheme();
const text = String(theme.color?.val ?? '#111827');
const muted = String(theme.gray?.val ?? '#4b5563');
const subtle = String(theme.gray8?.val ?? '#6b7280');
const border = String(theme.borderColor?.val ?? '#e5e7eb');
const primary = String(theme.primary?.val ?? '#007AFF');
const surface = String(theme.surface?.val ?? '#ffffff');
const activeBg = String(theme.blue3?.val ?? '#e0f2fe');
const activeBorder = String(theme.blue6?.val ?? '#bfdbfe');
const statusCounts = React.useMemo(() => buildEventStatusCounts(events), [events]);
const filteredByStatus = React.useMemo(
() => filterEventsByStatus(events, statusFilter),
[events, statusFilter]
);
const filteredEvents = React.useMemo(() => {
if (!query.trim()) return filteredByStatus;
const needle = query.toLowerCase();
return filteredByStatus.filter((event) => {
const hay = `${event.name ?? ''} ${event.location ?? ''}`.toLowerCase();
return hay.includes(needle);
});
}, [filteredByStatus, query]);
const filters: Array<{ key: EventStatusKey; label: string; count: number }> = [
{ key: 'all', label: t('events.list.filters.all', 'All'), count: statusCounts.all },
{ key: 'upcoming', label: t('events.list.filters.upcoming', 'Upcoming'), count: statusCounts.upcoming },
{ key: 'draft', label: t('events.list.filters.draft', 'Draft'), count: statusCounts.draft },
{ key: 'past', label: t('events.list.filters.past', 'Past'), count: statusCounts.past },
];
return (
<YStack space="$3">
<XStack space="$2" flexWrap="wrap">
{filters.map((filter) => {
const active = filter.key === statusFilter;
return (
<Pressable key={filter.key} onPress={() => onStatusChange(filter.key)} style={{ flexGrow: 1 }}>
<XStack
alignItems="center"
justifyContent="center"
space="$1.5"
paddingVertical="$2"
paddingHorizontal="$3"
borderRadius={14}
backgroundColor={active ? activeBg : surface}
borderWidth={1}
borderColor={active ? activeBorder : border}
>
<Text fontSize="$xs" fontWeight="700" color={active ? primary : muted}>
{filter.label}
</Text>
<PillBadge tone={active ? 'success' : 'muted'}>{filter.count}</PillBadge>
</XStack>
</Pressable>
);
})}
</XStack>
{filteredEvents.length === 0 ? (
<MobileCard alignItems="center" justifyContent="center" space="$2">
<Text fontSize="$sm" fontWeight="700" color={text}>
{t('events.list.empty.filtered', 'No events match this filter.')}
</Text>
<Text fontSize="$xs" color={muted} textAlign="center">
{t('events.list.empty.filteredHint', 'Try a different status or clear your search.')}
</Text>
<CTAButton
label={t('events.list.filters.all', 'All')}
tone="ghost"
fullWidth={false}
onPress={() => onStatusChange('all')}
/>
</MobileCard>
) : (
filteredEvents.map((event) => {
const statusKey = resolveEventStatusKey(event);
const statusLabel =
statusKey === 'draft'
? t('events.list.filters.draft', 'Draft')
: statusKey === 'past'
? t('events.list.filters.past', 'Past')
: t('events.list.filters.upcoming', 'Upcoming');
const statusTone = statusKey === 'draft' ? 'warning' : statusKey === 'past' ? 'muted' : 'success';
return (
<EventRow
key={event.id}
event={event}
text={text}
muted={muted}
subtle={subtle}
border={border}
primary={primary}
statusLabel={statusLabel}
statusTone={statusTone}
onOpen={onOpen}
onEdit={onEdit}
/>
);
})
)}
</YStack>
);
}
function EventRow({
event,
text,
@@ -131,6 +242,8 @@ function EventRow({
subtle,
border,
primary,
statusLabel,
statusTone,
onOpen,
onEdit,
}: {
@@ -140,10 +253,13 @@ function EventRow({
subtle: string;
border: string;
primary: string;
statusLabel: string;
statusTone: 'success' | 'warning' | 'muted';
onOpen: (slug: string) => void;
onEdit: (slug: string) => void;
}) {
const status = resolveStatus(event);
const { t } = useTranslation('management');
const stats = buildEventListStats(event);
return (
<MobileCard borderColor={border}>
<XStack justifyContent="space-between" alignItems="flex-start" space="$2">
@@ -163,7 +279,27 @@ function EventRow({
{resolveLocation(event)}
</Text>
</XStack>
<PillBadge tone={status.tone}>{status.label}</PillBadge>
<PillBadge tone={statusTone}>{statusLabel}</PillBadge>
<XStack alignItems="center" space="$2" flexWrap="wrap">
<EventStatChip
icon={Camera}
label={t('events.list.stats.photos', 'Photos')}
value={stats.photos}
muted={subtle}
/>
<EventStatChip
icon={Users}
label={t('events.list.stats.guests', 'Guests')}
value={stats.guests}
muted={subtle}
/>
<EventStatChip
icon={Sparkles}
label={t('events.list.stats.tasks', 'Tasks')}
value={stats.tasks}
muted={subtle}
/>
</XStack>
</YStack>
<Pressable onPress={() => onEdit(event.slug)}>
<Text fontSize="$xl" color={muted}>
@@ -176,7 +312,7 @@ function EventRow({
<XStack alignItems="center" justifyContent="flex-start" space="$2">
<Plus size={16} color={primary} />
<Text fontSize="$sm" color={primary} fontWeight="700">
Open event
{t('events.list.actions.open', 'Open event')}
</Text>
</XStack>
</Pressable>
@@ -184,14 +320,25 @@ function EventRow({
);
}
function resolveStatus(event: TenantEvent): { label: string; tone: 'success' | 'warning' | 'muted' } {
if (event.status === 'published') {
return { label: 'Upcoming', tone: 'success' };
}
if (event.status === 'draft') {
return { label: 'Draft', tone: 'warning' };
}
return { label: 'Past', tone: 'muted' };
function EventStatChip({
icon: Icon,
label,
value,
muted,
}: {
icon: typeof Camera;
label: string;
value: number;
muted: string;
}) {
return (
<XStack alignItems="center" space="$1">
<Icon size={12} color={muted} />
<Text fontSize="$xs" color={muted}>
{value} {label}
</Text>
</XStack>
);
}
function renderName(name: TenantEvent['name']): string {

View File

@@ -19,6 +19,9 @@ import { useTheme } from '@tamagui/core';
import { triggerHaptic } from './lib/haptics';
import { adminPath } from '../constants';
import { useBackNavigation } from './hooks/useBackNavigation';
import { groupNotificationsByScope, type NotificationScope, type NotificationGroup } from './lib/notificationGrouping';
import { collectUnreadIds } from './lib/notificationUnread';
import { formatRelativeTime } from './lib/relativeTime';
type NotificationItem = {
id: string;
@@ -27,8 +30,9 @@ type NotificationItem = {
time: string;
tone: 'info' | 'warning';
eventId?: number | null;
eventName?: string | null;
is_read?: boolean;
scope: 'photos' | 'guests' | 'gallery' | 'events' | 'package' | 'general';
scope: NotificationScope;
};
type NotificationSwipeRowProps = {
@@ -167,6 +171,7 @@ function formatLog(
time: log.sent_at ?? log.failed_at ?? '',
tone: 'warning',
eventId,
eventName,
is_read: isRead,
scope,
};
@@ -181,6 +186,7 @@ function formatLog(
time: log.sent_at ?? log.failed_at ?? '',
tone: 'warning',
eventId,
eventName,
is_read: isRead,
scope,
};
@@ -194,6 +200,7 @@ function formatLog(
time: log.sent_at ?? log.failed_at ?? '',
tone: 'warning',
eventId,
eventName,
is_read: isRead,
scope,
};
@@ -208,6 +215,7 @@ function formatLog(
time: log.sent_at ?? log.failed_at ?? '',
tone: 'warning',
eventId,
eventName,
is_read: isRead,
scope,
};
@@ -221,6 +229,7 @@ function formatLog(
time: log.sent_at ?? log.failed_at ?? '',
tone: 'warning',
eventId,
eventName,
is_read: isRead,
scope,
};
@@ -236,6 +245,7 @@ function formatLog(
time: log.sent_at ?? log.failed_at ?? '',
tone: 'info',
eventId,
eventName,
is_read: isRead,
scope,
};
@@ -251,6 +261,7 @@ function formatLog(
time: log.sent_at ?? log.failed_at ?? '',
tone: 'info',
eventId,
eventName,
is_read: isRead,
scope,
};
@@ -262,6 +273,7 @@ function formatLog(
time: log.sent_at ?? log.failed_at ?? '',
tone: 'info',
eventId,
eventName,
is_read: isRead,
scope,
};
@@ -389,10 +401,9 @@ export default function MobileNotificationsPage() {
return scoped.filter((item) => !item.is_read);
}, [scoped, statusParam]);
const unreadIds = React.useMemo(
() => scoped.filter((item) => !item.is_read).map((item) => Number(item.id)).filter((id) => Number.isFinite(id)),
[scoped]
);
const grouped = React.useMemo(() => groupNotificationsByScope(statusFiltered), [statusFiltered]);
const unreadIds = React.useMemo(() => collectUnreadIds(scoped), [scoped]);
const showFilterNotice = Boolean(slug) && filtered.length === 0 && notifications.length > 0;
@@ -421,6 +432,34 @@ export default function MobileNotificationsPage() {
}, [markNotificationRead, selectedNotification]);
const notificationListPath = adminPath('/mobile/notifications');
const updateFilters = React.useCallback(
(params: { scope?: string; status?: string; event?: string | null }) => {
const next = new URLSearchParams({
status: params.status ?? statusParam,
scope: params.scope ?? scopeParam,
event: params.event ?? slug ?? '',
});
navigate(`${notificationListPath}?${next.toString()}`, { replace: false });
},
[navigate, notificationListPath, scopeParam, slug, statusParam],
);
const markGroupRead = React.useCallback(
async (group: NotificationGroup<NotificationItem>) => {
const ids = collectUnreadIds(group.items);
if (!ids.length) {
return;
}
try {
await markNotificationLogs(ids, 'read');
void reload();
triggerHaptic('success');
} catch {
toast.error(t('notificationLogs.markFailed', 'Could not update notifications.'));
}
},
[reload, t],
);
const openNotification = React.useCallback(
(item: NotificationItem) => {
@@ -475,7 +514,7 @@ export default function MobileNotificationsPage() {
</Text>
<Pressable
onPress={() => {
navigate(notificationListPath, { replace: true });
updateFilters({ event: '' });
}}
>
<Text fontSize="$sm" color={primary} fontWeight="700">
@@ -488,15 +527,7 @@ export default function MobileNotificationsPage() {
<XStack space="$2" marginBottom="$2">
<MobileSelect
value={statusParam}
onChange={(e) =>
navigate(
`${notificationListPath}?${new URLSearchParams({
status: e.target.value,
scope: scopeParam,
event: slug ?? '',
}).toString()}`,
)
}
onChange={(e) => updateFilters({ status: e.target.value })}
compact
style={{ minWidth: 120 }}
>
@@ -504,28 +535,6 @@ export default function MobileNotificationsPage() {
<option value="read">{t('notificationLogs.filter.read', 'Read')}</option>
<option value="all">{t('notificationLogs.filter.all', 'All')}</option>
</MobileSelect>
<MobileSelect
value={scopeParam}
onChange={(e) =>
navigate(
`${notificationListPath}?${new URLSearchParams({
scope: e.target.value,
status: statusParam,
event: slug ?? '',
}).toString()}`,
)
}
compact
style={{ minWidth: 140 }}
>
<option value="all">{t('notificationLogs.scope.all', 'All scopes')}</option>
<option value="photos">{t('notificationLogs.scope.photos', 'Photos')}</option>
<option value="guests">{t('notificationLogs.scope.guests', 'Guests')}</option>
<option value="gallery">{t('notificationLogs.scope.gallery', 'Gallery')}</option>
<option value="events">{t('notificationLogs.scope.events', 'Events')}</option>
<option value="package">{t('notificationLogs.scope.package', 'Package')}</option>
<option value="general">{t('notificationLogs.scope.general', 'General')}</option>
</MobileSelect>
{unreadIds.length ? (
<CTAButton
label={t('notificationLogs.markAllRead', 'Mark all read')}
@@ -543,6 +552,38 @@ export default function MobileNotificationsPage() {
) : null}
</XStack>
<XStack space="$2" flexWrap="wrap" marginBottom="$2">
{([
{ key: 'all', label: t('notificationLogs.scope.all', 'All scopes') },
{ key: 'photos', label: t('notificationLogs.scope.photos', 'Photos') },
{ key: 'guests', label: t('notificationLogs.scope.guests', 'Guests') },
{ key: 'gallery', label: t('notificationLogs.scope.gallery', 'Gallery') },
{ key: 'events', label: t('notificationLogs.scope.events', 'Events') },
{ key: 'package', label: t('notificationLogs.scope.package', 'Package') },
{ key: 'general', label: t('notificationLogs.scope.general', 'General') },
] as Array<{ key: NotificationScope | 'all'; label: string }>).map((filter) => {
const active = scopeParam === filter.key;
return (
<Pressable key={filter.key} onPress={() => updateFilters({ scope: filter.key })} style={{ flexGrow: 1 }}>
<XStack
alignItems="center"
justifyContent="center"
paddingVertical="$2"
paddingHorizontal="$3"
borderRadius={14}
borderWidth={1}
borderColor={active ? primary : border}
backgroundColor={active ? String(theme.blue3?.val ?? '#e0f2fe') : 'transparent'}
>
<Text fontSize="$xs" fontWeight="700" color={active ? primary : muted}>
{filter.label}
</Text>
</XStack>
</Pressable>
);
})}
</XStack>
{loading ? (
<YStack space="$2">
{Array.from({ length: 4 }).map((_, idx) => (
@@ -574,38 +615,68 @@ export default function MobileNotificationsPage() {
</Text>
</Pressable>
) : null}
{statusFiltered.map((item) => (
<NotificationSwipeRow
key={item.id}
item={item}
onOpen={openNotification}
onMarkRead={markNotificationRead}
>
<MobileCard space="$2" borderColor={item.is_read ? border : primary}>
<XStack alignItems="center" space="$2">
<XStack
width={36}
height={36}
borderRadius={12}
alignItems="center"
justifyContent="center"
backgroundColor={item.tone === 'warning' ? warningBg : infoBg}
>
<Bell size={18} color={item.tone === 'warning' ? warningIcon : infoIcon} />
</XStack>
<YStack space="$0.5" flex={1}>
<Text fontSize="$sm" fontWeight="700" color={text}>
{item.title}
</Text>
<Text fontSize="$xs" color={muted}>
{item.body}
</Text>
</YStack>
{!item.is_read ? <PillBadge tone="warning">{t('notificationLogs.unread', 'Unread')}</PillBadge> : null}
<PillBadge tone={item.tone === 'warning' ? 'warning' : 'muted'}>{item.time}</PillBadge>
{grouped.map((group) => (
<YStack key={group.scope} space="$2">
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$xs" fontWeight="700" color={muted}>
{t(`notificationLogs.scope.${group.scope}`, group.scope)}
</Text>
<XStack space="$2" alignItems="center">
{group.unread > 0 ? (
<Pressable onPress={() => markGroupRead(group)}>
<Text fontSize="$xs" color={primary} fontWeight="700">
{t('notificationLogs.markScopeRead', 'Mark read')}
</Text>
</Pressable>
) : null}
{group.unread > 0 ? (
<PillBadge tone="warning">
{t('notificationLogs.unread', 'Unread')} {group.unread}
</PillBadge>
) : null}
<PillBadge tone="muted">{group.items.length}</PillBadge>
</XStack>
</MobileCard>
</NotificationSwipeRow>
</XStack>
{group.items.map((item) => {
const formattedTime = formatRelativeTime(item.time) || item.time || '—';
return (
<NotificationSwipeRow
key={item.id}
item={item}
onOpen={openNotification}
onMarkRead={markNotificationRead}
>
<MobileCard space="$2" borderColor={item.is_read ? border : primary}>
<XStack alignItems="center" space="$2">
<XStack
width={36}
height={36}
borderRadius={12}
alignItems="center"
justifyContent="center"
backgroundColor={item.tone === 'warning' ? warningBg : infoBg}
>
<Bell size={18} color={item.tone === 'warning' ? warningIcon : infoIcon} />
</XStack>
<YStack space="$0.5" flex={1}>
<Text fontSize="$sm" fontWeight="700" color={text}>
{item.title}
</Text>
<Text fontSize="$xs" color={muted}>
{item.body}
</Text>
{item.eventName ? (
<PillBadge tone="muted">{item.eventName}</PillBadge>
) : null}
</YStack>
{!item.is_read ? <PillBadge tone="warning">{t('notificationLogs.unread', 'Unread')}</PillBadge> : null}
<PillBadge tone={item.tone === 'warning' ? 'warning' : 'muted'}>{formattedTime}</PillBadge>
</XStack>
</MobileCard>
</NotificationSwipeRow>
);
})}
</YStack>
))}
</YStack>
)}
@@ -641,7 +712,7 @@ export default function MobileNotificationsPage() {
{!selectedNotification.is_read ? <PillBadge tone="warning">{t('notificationLogs.unread', 'Unread')}</PillBadge> : null}
</XStack>
<Text fontSize="$xs" color={muted}>
{selectedNotification.time}
{formatRelativeTime(selectedNotification.time) || selectedNotification.time || '—'}
</Text>
</YStack>
) : null}
@@ -665,7 +736,7 @@ export default function MobileNotificationsPage() {
onPress={() => {
setShowEventPicker(false);
if (ev.slug) {
navigate(`${notificationListPath}?event=${ev.slug}`);
updateFilters({ event: ev.slug });
}
}}
>

View File

@@ -26,6 +26,7 @@ import { getInstallBannerDismissed, setInstallBannerDismissed, shouldShowInstall
import { MobileInstallBanner } from './components/MobileInstallBanner';
import { setTourSeen } from './lib/mobileTour';
import { useBackNavigation } from './hooks/useBackNavigation';
import { useOnlineStatus } from './hooks/useOnlineStatus';
type PreferenceKey = keyof NotificationPreferences;
@@ -59,6 +60,7 @@ export default function MobileSettingsPage() {
const [storageError, setStorageError] = React.useState<string | null>(null);
const pushState = useAdminPushSubscription();
const devicePermissions = useDevicePermissions();
const online = useOnlineStatus();
const installPrompt = useInstallPrompt();
const back = useBackNavigation(adminPath('/mobile/profile'));
const [installBannerDismissed, setInstallBannerDismissedState] = React.useState(() => getInstallBannerDismissed());
@@ -109,6 +111,9 @@ export default function MobileSettingsPage() {
t(`mobileSettings.deviceStatusValues.${status}`, status);
const storageLabel = (status: StorageStatus) =>
t(`mobileSettings.deviceStatusValues.${status}`, status);
const connectionLabel = online
? t('mobileSettings.deviceStatusValues.online', 'Online')
: t('mobileSettings.deviceStatusValues.offline', 'Offline');
React.useEffect(() => {
(async () => {
@@ -375,6 +380,17 @@ export default function MobileSettingsPage() {
{storageLabel(devicePermissions.storage)}
</PillBadge>
</XStack>
<XStack alignItems="center" justifyContent="space-between" gap="$2">
<YStack flex={1} space="$1">
<Text fontSize="$sm" fontWeight="700" color={text}>
{t('mobileSettings.deviceStatus.connection.label', 'Connection')}
</Text>
<Text fontSize="$xs" color={muted}>
{t('mobileSettings.deviceStatus.connection.description', 'Shows if the app is online or offline.')}
</Text>
</YStack>
<PillBadge tone={online ? 'success' : 'warning'}>{connectionLabel}</PillBadge>
</XStack>
</YStack>
)}
{devicePermissions.storage === 'available' ? (

View File

@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest';
import type { TenantEvent } from '../../api';
import { buildEventStatusCounts, filterEventsByStatus, resolveEventStatusKey } from './eventFilters';
const baseEvent = (overrides: Partial<TenantEvent>): TenantEvent => ({
id: overrides.id ?? 1,
slug: overrides.slug ?? 'event',
name: overrides.name ?? 'Event',
event_date: overrides.event_date ?? null,
status: overrides.status ?? 'published',
settings: overrides.settings ?? {},
tasks_count: overrides.tasks_count ?? 0,
active_invites_count: overrides.active_invites_count ?? 0,
total_invites_count: overrides.total_invites_count ?? 0,
photo_count: overrides.photo_count ?? 0,
likes_sum: overrides.likes_sum ?? 0,
engagement_mode: overrides.engagement_mode ?? 'tasks',
});
describe('resolveEventStatusKey', () => {
it('returns draft when event is draft', () => {
const event = baseEvent({ status: 'draft' });
expect(resolveEventStatusKey(event)).toBe('draft');
});
it('returns past when published event date is in the past', () => {
const event = baseEvent({ status: 'published', event_date: new Date(Date.now() - 86400_000).toISOString() });
expect(resolveEventStatusKey(event)).toBe('past');
});
it('returns upcoming when published event date is in the future', () => {
const event = baseEvent({ status: 'published', event_date: new Date(Date.now() + 86400_000).toISOString() });
expect(resolveEventStatusKey(event)).toBe('upcoming');
});
});
describe('buildEventStatusCounts', () => {
it('counts events by status', () => {
const events: TenantEvent[] = [
baseEvent({ id: 1, status: 'draft' }),
baseEvent({ id: 2, status: 'published', event_date: new Date(Date.now() + 86400_000).toISOString() }),
baseEvent({ id: 3, status: 'published', event_date: new Date(Date.now() - 86400_000).toISOString() }),
];
const counts = buildEventStatusCounts(events);
expect(counts.all).toBe(3);
expect(counts.draft).toBe(1);
expect(counts.upcoming).toBe(1);
expect(counts.past).toBe(1);
});
});
describe('filterEventsByStatus', () => {
it('filters by status', () => {
const events: TenantEvent[] = [
baseEvent({ id: 1, status: 'draft' }),
baseEvent({ id: 2, status: 'published', event_date: new Date(Date.now() - 86400_000).toISOString() }),
];
const filtered = filterEventsByStatus(events, 'draft');
expect(filtered).toHaveLength(1);
expect(filtered[0]?.id).toBe(1);
});
});

View File

@@ -0,0 +1,43 @@
import type { TenantEvent } from '../../api';
import { isPastEvent } from '../eventDate';
export type EventStatusKey = 'all' | 'upcoming' | 'draft' | 'past';
export function resolveEventStatusKey(event: TenantEvent): Exclude<EventStatusKey, 'all'> {
if (event.status === 'draft') {
return 'draft';
}
if (event.status === 'published') {
if (event.event_date && isPastEvent(event.event_date)) {
return 'past';
}
return 'upcoming';
}
if (event.event_date && isPastEvent(event.event_date)) {
return 'past';
}
return 'upcoming';
}
export function buildEventStatusCounts(events: TenantEvent[]): Record<EventStatusKey, number> {
const counts: Record<EventStatusKey, number> = {
all: events.length,
upcoming: 0,
draft: 0,
past: 0,
};
events.forEach((event) => {
const key = resolveEventStatusKey(event);
counts[key] += 1;
});
return counts;
}
export function filterEventsByStatus(events: TenantEvent[], status: EventStatusKey): TenantEvent[] {
if (status === 'all') {
return events;
}
return events.filter((event) => resolveEventStatusKey(event) === status);
}

View File

@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest';
import type { TenantEvent } from '../../api';
import { buildEventListStats } from './eventListStats';
const baseEvent = (overrides: Partial<TenantEvent>): TenantEvent => ({
id: overrides.id ?? 1,
slug: overrides.slug ?? 'event',
name: overrides.name ?? 'Event',
event_date: overrides.event_date ?? null,
status: overrides.status ?? 'published',
settings: overrides.settings ?? {},
tasks_count: overrides.tasks_count ?? 0,
active_invites_count: overrides.active_invites_count ?? 0,
total_invites_count: overrides.total_invites_count ?? 0,
photo_count: overrides.photo_count ?? 0,
likes_sum: overrides.likes_sum ?? 0,
engagement_mode: overrides.engagement_mode ?? 'tasks',
});
describe('buildEventListStats', () => {
it('uses active invite count when available', () => {
const stats = buildEventListStats(
baseEvent({ photo_count: 12, tasks_count: 4, active_invites_count: 9, total_invites_count: 20 }),
);
expect(stats).toEqual({ photos: 12, guests: 9, tasks: 4 });
});
it('falls back to total invite count when active is missing', () => {
const event = {
...baseEvent({ photo_count: 3, total_invites_count: 6 }),
active_invites_count: undefined,
} as TenantEvent;
const stats = buildEventListStats(event);
expect(stats.guests).toBe(6);
});
});

View File

@@ -0,0 +1,15 @@
import type { TenantEvent } from '../../api';
export type EventListStats = {
photos: number;
guests: number;
tasks: number;
};
export function buildEventListStats(event: TenantEvent): EventListStats {
return {
photos: event.photo_count ?? 0,
guests: event.active_invites_count ?? event.total_invites_count ?? 0,
tasks: event.tasks_count ?? 0,
};
}

View File

@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest';
import { groupNotificationsByScope, type NotificationScope } from './notificationGrouping';
type Item = { id: string; scope: NotificationScope; is_read?: boolean };
describe('groupNotificationsByScope', () => {
it('groups items and counts unread', () => {
const items: Item[] = [
{ id: '1', scope: 'photos', is_read: false },
{ id: '2', scope: 'photos', is_read: true },
{ id: '3', scope: 'package', is_read: false },
];
const grouped = groupNotificationsByScope(items);
expect(grouped[0]?.scope).toBe('photos');
expect(grouped[0]?.items).toHaveLength(2);
expect(grouped[0]?.unread).toBe(1);
expect(grouped[1]?.scope).toBe('package');
expect(grouped[1]?.unread).toBe(1);
});
it('sorts groups by predefined scope order', () => {
const items: Item[] = [
{ id: '1', scope: 'general', is_read: true },
{ id: '2', scope: 'events', is_read: true },
{ id: '3', scope: 'photos', is_read: true },
];
const grouped = groupNotificationsByScope(items);
expect(grouped.map((group) => group.scope)).toEqual(['photos', 'events', 'general']);
});
});

View File

@@ -0,0 +1,49 @@
export type NotificationScope = 'photos' | 'guests' | 'gallery' | 'events' | 'package' | 'general';
export type ScopedNotification = {
scope: NotificationScope;
is_read?: boolean;
};
export type NotificationGroup<T extends ScopedNotification> = {
scope: NotificationScope;
items: T[];
unread: number;
};
const SCOPE_ORDER: NotificationScope[] = [
'photos',
'guests',
'gallery',
'events',
'package',
'general',
];
export function groupNotificationsByScope<T extends ScopedNotification>(items: T[]): NotificationGroup<T>[] {
const groups = new Map<NotificationScope, NotificationGroup<T>>();
items.forEach((item) => {
const scope = item.scope ?? 'general';
const existing = groups.get(scope);
if (existing) {
existing.items.push(item);
if (!item.is_read) {
existing.unread += 1;
}
return;
}
groups.set(scope, {
scope,
items: [item],
unread: item.is_read ? 0 : 1,
});
});
return Array.from(groups.values()).sort((a, b) => {
const aIndex = SCOPE_ORDER.indexOf(a.scope);
const bIndex = SCOPE_ORDER.indexOf(b.scope);
return aIndex - bIndex;
});
}

View File

@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { collectUnreadIds } from './notificationUnread';
describe('collectUnreadIds', () => {
it('returns numeric ids for unread items', () => {
const ids = collectUnreadIds([
{ id: '12', is_read: false },
{ id: 5, is_read: false },
{ id: 'x', is_read: false },
{ id: 9, is_read: true },
]);
expect(ids).toEqual([12, 5]);
});
it('returns empty array when all items are read', () => {
const ids = collectUnreadIds([
{ id: 1, is_read: true },
{ id: '2', is_read: true },
]);
expect(ids).toEqual([]);
});
});

View File

@@ -0,0 +1,11 @@
export type NotificationReadItem = {
id: string | number;
is_read?: boolean;
};
export function collectUnreadIds(items: NotificationReadItem[]): number[] {
return items
.filter((item) => !item.is_read)
.map((item) => (typeof item.id === 'string' ? Number(item.id) : item.id))
.filter((id) => Number.isFinite(id));
}

View File

@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest';
import type { TenantPhoto } from '../../api';
import { resolvePhotoSwipeAction } from './photoModerationSwipe';
const basePhoto = (overrides: Partial<TenantPhoto>): TenantPhoto => ({
id: overrides.id ?? 1,
filename: overrides.filename ?? null,
original_name: overrides.original_name ?? null,
mime_type: overrides.mime_type ?? null,
size: overrides.size ?? 1024,
url: overrides.url ?? null,
thumbnail_url: overrides.thumbnail_url ?? null,
status: overrides.status ?? 'approved',
is_featured: overrides.is_featured ?? false,
likes_count: overrides.likes_count ?? 0,
uploaded_at: overrides.uploaded_at ?? new Date().toISOString(),
uploader_name: overrides.uploader_name ?? null,
ingest_source: overrides.ingest_source ?? null,
caption: overrides.caption ?? null,
});
describe('resolvePhotoSwipeAction', () => {
it('approves pending photos on right swipe', () => {
const photo = basePhoto({ status: 'pending' });
expect(resolvePhotoSwipeAction(photo, 'right')).toBe('approve');
});
it('hides pending photos on left swipe', () => {
const photo = basePhoto({ status: 'pending' });
expect(resolvePhotoSwipeAction(photo, 'left')).toBe('hide');
});
it('shows hidden photos on right swipe', () => {
const photo = basePhoto({ status: 'hidden' });
expect(resolvePhotoSwipeAction(photo, 'right')).toBe('show');
});
it('returns null for hidden photos on left swipe', () => {
const photo = basePhoto({ status: 'hidden' });
expect(resolvePhotoSwipeAction(photo, 'left')).toBeNull();
});
it('hides visible photos on left swipe', () => {
const photo = basePhoto({ status: 'approved' });
expect(resolvePhotoSwipeAction(photo, 'left')).toBe('hide');
});
it('returns null for visible photos on right swipe', () => {
const photo = basePhoto({ status: 'approved' });
expect(resolvePhotoSwipeAction(photo, 'right')).toBeNull();
});
});

View File

@@ -0,0 +1,24 @@
import type { TenantPhoto } from '../../api';
import type { PhotoModerationAction } from './photoModerationQueue';
export type SwipeDirection = 'left' | 'right';
export type SwipeModerationAction = PhotoModerationAction['action'] | null;
export function resolvePhotoSwipeAction(photo: TenantPhoto, direction: SwipeDirection): SwipeModerationAction {
if (direction === 'right') {
if (photo.status === 'pending') {
return 'approve';
}
if (photo.status === 'hidden') {
return 'show';
}
return null;
}
if (photo.status !== 'hidden') {
return 'hide';
}
return null;
}

View File

@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest';
import { formatRelativeTime } from './relativeTime';
describe('formatRelativeTime', () => {
const now = new Date('2024-01-10T12:00:00Z');
it('returns empty string when value is missing', () => {
expect(formatRelativeTime(null, { now })).toBe('');
});
it('returns original value for invalid date strings', () => {
expect(formatRelativeTime('not-a-date', { now })).toBe('not-a-date');
});
it('formats recent timestamps', () => {
const result = formatRelativeTime('2024-01-10T11:59:30Z', { now, locale: 'en' });
expect(result).toContain('second');
});
it('formats hours ago', () => {
const result = formatRelativeTime('2024-01-10T11:00:00Z', { now, locale: 'en' });
expect(result).toBe('1 hour ago');
});
it('formats future days', () => {
const result = formatRelativeTime('2024-01-13T12:00:00Z', { now, locale: 'en' });
expect(result).toBe('in 3 days');
});
});

View File

@@ -0,0 +1,52 @@
type RelativeTimeOptions = {
now?: Date;
locale?: string;
};
export function formatRelativeTime(value: string | null | undefined, options?: RelativeTimeOptions): string {
if (!value) {
return '';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
const now = options?.now ?? new Date();
const diffSeconds = Math.round((date.getTime() - now.getTime()) / 1000);
const absSeconds = Math.abs(diffSeconds);
const rtf = new Intl.RelativeTimeFormat(options?.locale, { numeric: 'auto' });
if (absSeconds < 60) {
return rtf.format(diffSeconds, 'second');
}
const diffMinutes = Math.round(diffSeconds / 60);
if (Math.abs(diffMinutes) < 60) {
return rtf.format(diffMinutes, 'minute');
}
const diffHours = Math.round(diffMinutes / 60);
if (Math.abs(diffHours) < 24) {
return rtf.format(diffHours, 'hour');
}
const diffDays = Math.round(diffHours / 24);
if (Math.abs(diffDays) < 7) {
return rtf.format(diffDays, 'day');
}
const diffWeeks = Math.round(diffDays / 7);
if (Math.abs(diffWeeks) < 4) {
return rtf.format(diffWeeks, 'week');
}
const diffMonths = Math.round(diffDays / 30);
if (Math.abs(diffMonths) < 12) {
return rtf.format(diffMonths, 'month');
}
const diffYears = Math.round(diffDays / 365);
return rtf.format(diffYears, 'year');
}

View File

@@ -0,0 +1,14 @@
import { describe, expect, it } from 'vitest';
import { buildTaskSectionCounts } from './taskSectionCounts';
describe('buildTaskSectionCounts', () => {
it('maps summary values in a stable order', () => {
const counts = buildTaskSectionCounts({ assigned: 2, library: 5, collections: 3, emotions: 1 });
expect(counts).toEqual([
{ key: 'assigned', count: 2 },
{ key: 'library', count: 5 },
{ key: 'collections', count: 3 },
{ key: 'emotions', count: 1 },
]);
});
});

View File

@@ -0,0 +1,17 @@
import type { TaskSummary } from './taskSummary';
export type TaskSectionKey = 'assigned' | 'library' | 'collections' | 'emotions';
export type TaskSectionCount = {
key: TaskSectionKey;
count: number;
};
export function buildTaskSectionCounts(summary: TaskSummary): TaskSectionCount[] {
return [
{ key: 'assigned', count: summary.assigned },
{ key: 'library', count: summary.library },
{ key: 'collections', count: summary.collections },
{ key: 'emotions', count: summary.emotions },
];
}

View File

@@ -0,0 +1,9 @@
import { describe, expect, it } from 'vitest';
import { buildTaskSummary } from './taskSummary';
describe('buildTaskSummary', () => {
it('returns summary counts', () => {
const summary = buildTaskSummary({ assigned: 3, library: 5, collections: 2, emotions: 4 });
expect(summary).toEqual({ assigned: 3, library: 5, collections: 2, emotions: 4 });
});
});

View File

@@ -0,0 +1,20 @@
export type TaskSummary = {
assigned: number;
library: number;
collections: number;
emotions: number;
};
export function buildTaskSummary(params: {
assigned: number;
library: number;
collections: number;
emotions: number;
}): TaskSummary {
return {
assigned: params.assigned,
library: params.library,
collections: params.collections,
emotions: params.emotions,
};
}