mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-21 22:31:46 +00:00
fix(journey): fix issue #704 — active logic, archive, places rename, search, trip reminders
- Derive journey lifecycle from linked trip dates (live/upcoming/completed/draft) instead of relying solely on status field; status=archived always wins - Add Archive/Restore Journey action in journey settings dialog - Rename cities → places end-to-end (SQL alias, TS types, stats field, all locales) - Wire up search icon: toggles inline input, filters by title+subtitle client-side - Fix channelConfigured check: trip reminders enabled by default since inapp is always available; remove channel check, controlled solely by admin setting - Expose notify_trip_reminder toggle in Admin → Settings → Notifications - Add trip_date_min/trip_date_max to listJourneys SQL for client-side lifecycle - Add archived status to Journey type (server + client) - Update all 15 locale files with new keys (search, archive, places, trip reminders)
This commit is contained in:
@@ -1180,6 +1180,7 @@ export default function AdminPage(): React.ReactElement {
|
||||
const emailActive = activeChans.includes('email')
|
||||
const webhookActive = activeChans.includes('webhook')
|
||||
const ntfyActive = activeChans.includes('ntfy')
|
||||
const tripRemindersActive = smtpValues.notify_trip_reminder !== 'false'
|
||||
|
||||
const setChannels = async (email: boolean, webhook: boolean, ntfy: boolean) => {
|
||||
const chans = [email && 'email', webhook && 'webhook', ntfy && 'ntfy'].filter(Boolean).join(',') || 'none'
|
||||
@@ -1338,6 +1339,37 @@ export default function AdminPage(): React.ReactElement {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trip Reminders Toggle */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<div className="px-6 py-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="font-semibold text-slate-900">{t('admin.notifications.tripReminders.title')}</h2>
|
||||
<p className="text-xs text-slate-400 mt-1">{t('admin.notifications.tripReminders.hint')}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const next = !tripRemindersActive
|
||||
setSmtpValues(prev => ({ ...prev, notify_trip_reminder: next ? 'true' : 'false' }))
|
||||
try {
|
||||
await authApi.updateAppSettings({ notify_trip_reminder: next ? 'true' : 'false' })
|
||||
toast.success(next ? t('admin.notifications.tripReminders.enabled') : t('admin.notifications.tripReminders.disabled'))
|
||||
authApi.getAppConfig().then((c: { trip_reminders_enabled?: boolean }) => {
|
||||
if (c?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(c.trip_reminders_enabled)
|
||||
}).catch(() => {})
|
||||
} catch {
|
||||
setSmtpValues(prev => ({ ...prev, notify_trip_reminder: tripRemindersActive ? 'true' : 'false' }))
|
||||
toast.error(t('common.error'))
|
||||
}
|
||||
}}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors flex-shrink-0"
|
||||
style={{ background: tripRemindersActive ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||
>
|
||||
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||
style={{ transform: tripRemindersActive ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Admin Webhook Panel */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-slate-100">
|
||||
|
||||
@@ -176,7 +176,7 @@ const mockJourneyDetail = {
|
||||
avatar: null,
|
||||
},
|
||||
],
|
||||
stats: { entries: 2, photos: 1, cities: 2 },
|
||||
stats: { entries: 2, photos: 1, places: 2 },
|
||||
};
|
||||
|
||||
// ── MSW Handlers ─────────────────────────────────────────────────────────────
|
||||
@@ -362,12 +362,12 @@ describe('JourneyDetailPage', () => {
|
||||
expect(screen.getAllByText('Days').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText('Entries').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText('Photos').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText('Cities').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText('Places').length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('renders stat values', async () => {
|
||||
await renderAndWait();
|
||||
// stats.entries = 2, stats.photos = 1, stats.cities = 2
|
||||
// stats.entries = 2, stats.photos = 1, stats.places = 2
|
||||
// Entries count appears in hero and sidebar
|
||||
const twos = screen.getAllByText('2');
|
||||
expect(twos.length).toBeGreaterThanOrEqual(1);
|
||||
@@ -474,7 +474,7 @@ describe('JourneyDetailPage', () => {
|
||||
// ── FE-PAGE-JOURNEYDETAIL-018 ──────────────────────────────────────────
|
||||
describe('FE-PAGE-JOURNEYDETAIL-018: Empty state when no entries', () => {
|
||||
it('shows "No entries yet" when journey has no entries', async () => {
|
||||
setupDefaultHandlers({ entries: [], stats: { entries: 0, photos: 0, cities: 0 } });
|
||||
setupDefaultHandlers({ entries: [], stats: { entries: 0, photos: 0, places: 0 } });
|
||||
|
||||
render(<JourneyDetailPage />);
|
||||
|
||||
@@ -484,7 +484,7 @@ describe('JourneyDetailPage', () => {
|
||||
});
|
||||
|
||||
it('shows hint text to add a trip', async () => {
|
||||
setupDefaultHandlers({ entries: [], stats: { entries: 0, photos: 0, cities: 0 } });
|
||||
setupDefaultHandlers({ entries: [], stats: { entries: 0, photos: 0, places: 0 } });
|
||||
|
||||
render(<JourneyDetailPage />);
|
||||
|
||||
@@ -567,7 +567,7 @@ describe('JourneyDetailPage', () => {
|
||||
};
|
||||
setupDefaultHandlers({
|
||||
entries: [multiPhotoEntry, mockJourneyDetail.entries[1]],
|
||||
stats: { entries: 2, photos: 3, cities: 2 },
|
||||
stats: { entries: 2, photos: 3, places: 2 },
|
||||
});
|
||||
|
||||
render(<JourneyDetailPage />);
|
||||
@@ -610,7 +610,7 @@ describe('JourneyDetailPage', () => {
|
||||
};
|
||||
setupDefaultHandlers({
|
||||
entries: [...mockJourneyDetail.entries, skeletonEntry],
|
||||
stats: { entries: 3, photos: 1, cities: 3 },
|
||||
stats: { entries: 3, photos: 1, places: 3 },
|
||||
});
|
||||
|
||||
render(<JourneyDetailPage />);
|
||||
@@ -650,7 +650,7 @@ describe('JourneyDetailPage', () => {
|
||||
};
|
||||
setupDefaultHandlers({
|
||||
entries: [...mockJourneyDetail.entries, checkinEntry],
|
||||
stats: { entries: 3, photos: 1, cities: 2 },
|
||||
stats: { entries: 3, photos: 1, places: 2 },
|
||||
});
|
||||
|
||||
render(<JourneyDetailPage />);
|
||||
@@ -707,15 +707,26 @@ describe('JourneyDetailPage', () => {
|
||||
|
||||
// ── FE-PAGE-JOURNEYDETAIL-030 ──────────────────────────────────────────
|
||||
describe('FE-PAGE-JOURNEYDETAIL-030: Active status badge shows Live indicator', () => {
|
||||
it('renders a "Live" badge for active journeys', async () => {
|
||||
it('renders a "Live" badge when linked trip spans today', async () => {
|
||||
setupDefaultHandlers({
|
||||
trips: [{ trip_id: 5, added_at: now, title: 'Current Trip', start_date: '2020-01-01', end_date: '2099-12-31', cover_image: null, currency: 'EUR', place_count: 8 }],
|
||||
});
|
||||
await renderAndWait();
|
||||
expect(screen.getByText('Live')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render "Live" badge when linked trip is in the past', async () => {
|
||||
await renderAndWait();
|
||||
expect(screen.queryByText('Live')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── FE-PAGE-JOURNEYDETAIL-031 ──────────────────────────────────────────
|
||||
describe('FE-PAGE-JOURNEYDETAIL-031: Synced with Trips badge renders', () => {
|
||||
it('renders the "Synced with Trips" text in the hero', async () => {
|
||||
it('renders the "Synced with Trips" text in the hero for live journeys', async () => {
|
||||
setupDefaultHandlers({
|
||||
trips: [{ trip_id: 5, added_at: now, title: 'Current Trip', start_date: '2020-01-01', end_date: '2099-12-31', cover_image: null, currency: 'EUR', place_count: 8 }],
|
||||
});
|
||||
await renderAndWait();
|
||||
expect(screen.getByText('Synced with Trips')).toBeInTheDocument();
|
||||
});
|
||||
@@ -741,7 +752,7 @@ describe('JourneyDetailPage', () => {
|
||||
it('shows the place count in the sidebar map', async () => {
|
||||
await renderAndWait();
|
||||
// The sidebar map shows "N Places" text
|
||||
expect(screen.getByText(/Places/)).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/Places/).length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1717,7 +1728,7 @@ describe('JourneyDetailPage', () => {
|
||||
};
|
||||
setupDefaultHandlers({
|
||||
entries: [emptyEntry],
|
||||
stats: { entries: 1, photos: 0, cities: 1 },
|
||||
stats: { entries: 1, photos: 0, places: 1 },
|
||||
});
|
||||
|
||||
render(<JourneyDetailPage />);
|
||||
@@ -1930,7 +1941,7 @@ describe('JourneyDetailPage', () => {
|
||||
{ ...mockJourneyDetail.entries[0], id: 10, entry_date: '2026-03-15' },
|
||||
{ ...mockJourneyDetail.entries[1], id: 11, entry_date: '2026-03-15', location_lat: 41.95, location_lng: 12.55 },
|
||||
];
|
||||
setupDefaultHandlers({ entries: twoOnSameDay, stats: { entries: 2, photos: 1, cities: 2 } });
|
||||
setupDefaultHandlers({ entries: twoOnSameDay, stats: { entries: 2, photos: 1, places: 2 } });
|
||||
|
||||
render(<JourneyDetailPage />);
|
||||
await waitFor(() => {
|
||||
@@ -2005,7 +2016,7 @@ describe('JourneyDetailPage', () => {
|
||||
};
|
||||
setupDefaultHandlers({
|
||||
entries: [immichEntry, mockJourneyDetail.entries[1]],
|
||||
stats: { entries: 2, photos: 1, cities: 2 },
|
||||
stats: { entries: 2, photos: 1, places: 2 },
|
||||
});
|
||||
|
||||
render(<JourneyDetailPage />);
|
||||
@@ -2039,7 +2050,7 @@ describe('JourneyDetailPage', () => {
|
||||
};
|
||||
setupDefaultHandlers({
|
||||
entries: [synologyEntry, mockJourneyDetail.entries[1]],
|
||||
stats: { entries: 2, photos: 1, cities: 2 },
|
||||
stats: { entries: 2, photos: 1, places: 2 },
|
||||
});
|
||||
|
||||
render(<JourneyDetailPage />);
|
||||
@@ -2636,7 +2647,7 @@ describe('JourneyDetailPage', () => {
|
||||
};
|
||||
setupDefaultHandlers({
|
||||
entries: [multiPhotoEntry, mockJourneyDetail.entries[1]],
|
||||
stats: { entries: 2, photos: 5, cities: 2 },
|
||||
stats: { entries: 2, photos: 5, places: 2 },
|
||||
});
|
||||
|
||||
render(<JourneyDetailPage />);
|
||||
@@ -2661,7 +2672,7 @@ describe('JourneyDetailPage', () => {
|
||||
};
|
||||
setupDefaultHandlers({
|
||||
entries: [twoPhotoEntry, mockJourneyDetail.entries[1]],
|
||||
stats: { entries: 2, photos: 2, cities: 2 },
|
||||
stats: { entries: 2, photos: 2, places: 2 },
|
||||
});
|
||||
|
||||
render(<JourneyDetailPage />);
|
||||
@@ -3045,7 +3056,7 @@ describe('JourneyDetailPage', () => {
|
||||
};
|
||||
setupDefaultHandlers({
|
||||
entries: [mockJourneyDetail.entries[0], noLocEntry],
|
||||
stats: { entries: 2, photos: 1, cities: 1 },
|
||||
stats: { entries: 2, photos: 1, places: 1 },
|
||||
});
|
||||
|
||||
render(<JourneyDetailPage />);
|
||||
@@ -3528,7 +3539,7 @@ describe('JourneyDetailPage', () => {
|
||||
};
|
||||
setupDefaultHandlers({
|
||||
entries: [entryWithMultiPhotos, mockJourneyDetail.entries[1]],
|
||||
stats: { entries: 2, photos: 2, cities: 2 },
|
||||
stats: { entries: 2, photos: 2, places: 2 },
|
||||
});
|
||||
|
||||
server.use(
|
||||
@@ -3620,7 +3631,7 @@ describe('JourneyDetailPage', () => {
|
||||
};
|
||||
setupDefaultHandlers({
|
||||
entries: [mockJourneyDetail.entries[0], noTitleEntry],
|
||||
stats: { entries: 2, photos: 1, cities: 2 },
|
||||
stats: { entries: 2, photos: 1, places: 2 },
|
||||
});
|
||||
|
||||
render(<JourneyDetailPage />);
|
||||
|
||||
@@ -24,6 +24,7 @@ import MobileMapTimeline from '../components/Journey/MobileMapTimeline'
|
||||
import MobileEntryView from '../components/Journey/MobileEntryView'
|
||||
import { useIsMobile } from '../hooks/useIsMobile'
|
||||
import type { JourneyEntry, JourneyPhoto, JourneyDetail } from '../store/journeyStore'
|
||||
import { computeJourneyLifecycle } from '../utils/journeyLifecycle'
|
||||
|
||||
const GRADIENTS = [
|
||||
'linear-gradient(135deg, #0F172A 0%, #6366F1 45%, #EC4899 100%)',
|
||||
@@ -207,6 +208,14 @@ export default function JourneyDetailPage() {
|
||||
const dayGroups = groupByDate(timelineEntries)
|
||||
const sortedDates = [...dayGroups.keys()].sort()
|
||||
|
||||
const tripDateMin = current.trips.length
|
||||
? current.trips.reduce((min: string, t: any) => t.start_date && (!min || t.start_date < min) ? t.start_date : min, '')
|
||||
: null
|
||||
const tripDateMax = current.trips.length
|
||||
? current.trips.reduce((max: string, t: any) => t.end_date && (!max || t.end_date > max) ? t.end_date : max, '')
|
||||
: null
|
||||
const lifecycle = computeJourneyLifecycle(current.status, tripDateMin || null, tripDateMax || null)
|
||||
|
||||
const showMobileCombined = isMobile && view === 'timeline'
|
||||
|
||||
return (
|
||||
@@ -283,16 +292,28 @@ export default function JourneyDetailPage() {
|
||||
<div className="relative z-[3] flex items-center justify-between mb-5">
|
||||
{/* Desktop: badges */}
|
||||
<div className="hidden md:flex items-center gap-2">
|
||||
{current.status === 'active' && (
|
||||
{lifecycle === 'live' && (
|
||||
<div className="inline-flex items-center gap-2 px-2.5 py-1 bg-white/15 backdrop-blur rounded-full text-[10px] font-semibold uppercase">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
|
||||
Live
|
||||
{t('journey.frontpage.live')}
|
||||
</div>
|
||||
)}
|
||||
{lifecycle !== 'archived' && current.trips.length > 0 && (
|
||||
<div className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/[0.12] backdrop-blur border border-white/15 rounded-full text-[11px] font-medium">
|
||||
<RefreshCw size={11} />
|
||||
{t('journey.detail.syncedWithTrips')}
|
||||
</div>
|
||||
)}
|
||||
{lifecycle !== 'live' && lifecycle !== 'archived' && (
|
||||
<div className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/[0.12] backdrop-blur border border-white/15 rounded-full text-[11px] font-medium">
|
||||
{t(`journey.status.${lifecycle === 'upcoming' ? 'upcoming' : lifecycle === 'draft' ? 'draft' : 'completed'}`)}
|
||||
</div>
|
||||
)}
|
||||
{lifecycle === 'archived' && (
|
||||
<div className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/[0.12] backdrop-blur border border-white/15 rounded-full text-[11px] font-medium">
|
||||
{t('journey.status.archived')}
|
||||
</div>
|
||||
)}
|
||||
<div className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/[0.12] backdrop-blur border border-white/15 rounded-full text-[11px] font-medium">
|
||||
<RefreshCw size={11} />
|
||||
{t('journey.detail.syncedWithTrips')}
|
||||
</div>
|
||||
</div>
|
||||
{/* Mobile: back button on the left */}
|
||||
<button
|
||||
@@ -331,7 +352,7 @@ export default function JourneyDetailPage() {
|
||||
<div className="flex gap-8">
|
||||
{[
|
||||
{ value: sortedDates.length, label: t('journey.stats.days') },
|
||||
{ value: current.stats.cities, label: t('journey.stats.cities') },
|
||||
{ value: current.stats.places, label: t('journey.stats.places') },
|
||||
{ value: current.stats.entries, label: t('journey.stats.entries') },
|
||||
{ value: current.stats.photos, label: t('journey.stats.photos') },
|
||||
].map(s => (
|
||||
@@ -494,7 +515,7 @@ export default function JourneyDetailPage() {
|
||||
{ value: sortedDates.length, label: t('journey.stats.days') },
|
||||
{ value: current.stats.entries, label: t('journey.stats.entries') },
|
||||
{ value: current.stats.photos, label: t('journey.stats.photos') },
|
||||
{ value: current.stats.cities, label: t('journey.stats.cities') },
|
||||
{ value: current.stats.places, label: t('journey.stats.places') },
|
||||
].map(s => (
|
||||
<div key={s.label} className="rounded-lg bg-zinc-50 dark:bg-zinc-800/60 border border-zinc-100 dark:border-zinc-700/50 px-3 py-2.5">
|
||||
<div className="text-[18px] font-bold tracking-[-0.02em] text-zinc-900 dark:text-white leading-none mb-0.5">{s.value}</div>
|
||||
@@ -2820,6 +2841,21 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
|
||||
}
|
||||
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [archiving, setArchiving] = useState(false)
|
||||
|
||||
const handleArchiveToggle = async () => {
|
||||
setArchiving(true)
|
||||
try {
|
||||
const newStatus = journey.status === 'archived' ? 'active' : 'archived'
|
||||
await updateJourney(journey.id, { status: newStatus })
|
||||
toast.success(newStatus === 'archived' ? t('journey.settings.archived') : t('journey.settings.reopened'))
|
||||
onSaved()
|
||||
} catch {
|
||||
toast.error(t('journey.settings.saveFailed'))
|
||||
} finally {
|
||||
setArchiving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
@@ -2947,11 +2983,19 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
|
||||
<div className="flex flex-wrap items-center gap-2 px-6 py-4 pb-6 md:pb-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50">
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="flex items-center gap-1.5 text-[12px] font-medium text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg px-2.5 py-2 mr-auto"
|
||||
className="flex items-center gap-1.5 text-[12px] font-medium text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg px-2.5 py-2"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
{t('journey.settings.delete')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleArchiveToggle}
|
||||
disabled={archiving}
|
||||
className="flex items-center gap-1.5 text-[12px] font-medium text-zinc-600 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-700 rounded-lg px-2.5 py-2 mr-auto disabled:opacity-40"
|
||||
title={t('journey.settings.endDescription')}
|
||||
>
|
||||
{journey.status === 'archived' ? t('journey.settings.reopenJourney') : t('journey.settings.endJourney')}
|
||||
</button>
|
||||
<button onClick={onClose} className="px-3.5 py-2 rounded-lg border border-zinc-200 dark:border-zinc-600 text-[13px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700">{t('common.cancel')}</button>
|
||||
<button onClick={handleSave} disabled={saving || !title.trim()} className="px-3.5 py-2 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[13px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-40">
|
||||
{saving ? t('common.saving') : t('common.save')}
|
||||
|
||||
@@ -43,7 +43,9 @@ function buildJourneyListItem(overrides: Record<string, unknown> = {}) {
|
||||
status: 'draft' as const,
|
||||
entry_count: 0,
|
||||
photo_count: 0,
|
||||
city_count: 0,
|
||||
place_count: 0,
|
||||
trip_date_min: null as string | null,
|
||||
trip_date_max: null as string | null,
|
||||
created_at: Date.now(),
|
||||
updated_at: Date.now(),
|
||||
...overrides,
|
||||
@@ -194,7 +196,7 @@ describe('JourneyPage', () => {
|
||||
|
||||
// FE-PAGE-JOURNEY-008
|
||||
it('FE-PAGE-JOURNEY-008: shows active journey hero when active journey exists', async () => {
|
||||
const active = buildJourneyListItem({ id: 10, title: 'Active Trip', status: 'active' });
|
||||
const active = buildJourneyListItem({ id: 10, title: 'Active Trip', status: 'active', trip_date_min: '2020-01-01', trip_date_max: '2099-12-31' });
|
||||
const other = buildJourneyListItem({ id: 11, title: 'Completed Trip', status: 'completed' });
|
||||
setupDefaultHandlers([active, other]);
|
||||
|
||||
@@ -320,13 +322,13 @@ describe('JourneyPage', () => {
|
||||
});
|
||||
|
||||
// FE-PAGE-JOURNEY-013
|
||||
it('FE-PAGE-JOURNEY-013: journey card shows entry/photo/city counts', async () => {
|
||||
it('FE-PAGE-JOURNEY-013: journey card shows entry/photo/place counts', async () => {
|
||||
const j1 = buildJourneyListItem({
|
||||
id: 20,
|
||||
title: 'Stats Journey',
|
||||
entry_count: 12,
|
||||
photo_count: 47,
|
||||
city_count: 5,
|
||||
place_count: 5,
|
||||
});
|
||||
setupDefaultHandlers([j1]);
|
||||
|
||||
@@ -335,7 +337,7 @@ describe('JourneyPage', () => {
|
||||
expect(screen.getByText('Stats Journey')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// The card renders entry_count, photo_count, city_count values
|
||||
// The card renders entry_count, photo_count, place_count values
|
||||
expect(screen.getByText('12')).toBeInTheDocument();
|
||||
expect(screen.getByText('47')).toBeInTheDocument();
|
||||
expect(screen.getByText('5')).toBeInTheDocument();
|
||||
@@ -361,6 +363,8 @@ describe('JourneyPage', () => {
|
||||
id: 40,
|
||||
title: 'Recent Active',
|
||||
status: 'active',
|
||||
trip_date_min: '2020-01-01',
|
||||
trip_date_max: '2099-12-31',
|
||||
updated_at: Date.now() - 60000, // 1 minute ago
|
||||
});
|
||||
setupDefaultHandlers([active]);
|
||||
@@ -380,6 +384,8 @@ describe('JourneyPage', () => {
|
||||
id: 41,
|
||||
title: 'Hours Active',
|
||||
status: 'active',
|
||||
trip_date_min: '2020-01-01',
|
||||
trip_date_max: '2099-12-31',
|
||||
updated_at: Date.now() - 3 * 3600000, // 3 hours ago
|
||||
});
|
||||
setupDefaultHandlers([active]);
|
||||
@@ -399,6 +405,8 @@ describe('JourneyPage', () => {
|
||||
id: 42,
|
||||
title: 'Days Active',
|
||||
status: 'active',
|
||||
trip_date_min: '2020-01-01',
|
||||
trip_date_max: '2099-12-31',
|
||||
updated_at: Date.now() - 5 * 24 * 3600000, // 5 days ago
|
||||
});
|
||||
setupDefaultHandlers([active]);
|
||||
@@ -414,7 +422,7 @@ describe('JourneyPage', () => {
|
||||
|
||||
// FE-PAGE-JOURNEY-018
|
||||
it('FE-PAGE-JOURNEY-018: active journey hero shows "Continue writing" button', async () => {
|
||||
const active = buildJourneyListItem({ id: 50, title: 'Writing Journey', status: 'active' });
|
||||
const active = buildJourneyListItem({ id: 50, title: 'Writing Journey', status: 'active', trip_date_min: '2020-01-01', trip_date_max: '2099-12-31' });
|
||||
setupDefaultHandlers([active]);
|
||||
|
||||
render(<JourneyPage />);
|
||||
@@ -427,7 +435,7 @@ describe('JourneyPage', () => {
|
||||
|
||||
// FE-PAGE-JOURNEY-019
|
||||
it('FE-PAGE-JOURNEY-019: active journey hero shows Live and Synced badges', async () => {
|
||||
const active = buildJourneyListItem({ id: 51, title: 'Live Journey', status: 'active' });
|
||||
const active = buildJourneyListItem({ id: 51, title: 'Live Journey', status: 'active', trip_date_min: '2020-01-01', trip_date_max: '2099-12-31' });
|
||||
setupDefaultHandlers([active]);
|
||||
|
||||
render(<JourneyPage />);
|
||||
@@ -442,7 +450,7 @@ describe('JourneyPage', () => {
|
||||
// FE-PAGE-JOURNEY-020
|
||||
it('FE-PAGE-JOURNEY-020: clicking active journey hero navigates to its detail page', async () => {
|
||||
const user = userEvent.setup();
|
||||
const active = buildJourneyListItem({ id: 60, title: 'Clickable Hero', status: 'active' });
|
||||
const active = buildJourneyListItem({ id: 60, title: 'Clickable Hero', status: 'active', trip_date_min: '2020-01-01', trip_date_max: '2099-12-31' });
|
||||
setupDefaultHandlers([active]);
|
||||
|
||||
render(<JourneyPage />);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState, useMemo } from 'react'
|
||||
import { useEffect, useState, useMemo, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useJourneyStore } from '../store/journeyStore'
|
||||
import { journeyApi } from '../api/client'
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Check, X, ChevronRight, RefreshCw, Users,
|
||||
} from 'lucide-react'
|
||||
import type { Journey } from '../store/journeyStore'
|
||||
import { computeJourneyLifecycle } from '../utils/journeyLifecycle'
|
||||
|
||||
const GRADIENTS = [
|
||||
'linear-gradient(135deg, #0F172A 0%, #6366F1 45%, #EC4899 100%)',
|
||||
@@ -43,6 +44,9 @@ export default function JourneyPage() {
|
||||
const [newTitle, setNewTitle] = useState('')
|
||||
const [availableTrips, setAvailableTrips] = useState<any[]>([])
|
||||
const [selectedTripIds, setSelectedTripIds] = useState<Set<number>>(new Set())
|
||||
const [searchOpen, setSearchOpen] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// suggestion
|
||||
const [suggestions, setSuggestions] = useState<any[]>([])
|
||||
@@ -56,12 +60,22 @@ export default function JourneyPage() {
|
||||
const activeSuggestion = suggestions.find(s => !dismissedSuggestions.has(s.id))
|
||||
|
||||
const activeJourney = useMemo(() => {
|
||||
return journeys.find(j => j.status === 'active') || null
|
||||
}, [journeys])
|
||||
if (searchQuery.trim()) return null
|
||||
return journeys.find(j => {
|
||||
const j2 = j as any
|
||||
return computeJourneyLifecycle(j.status, j2.trip_date_min, j2.trip_date_max) === 'live'
|
||||
}) || null
|
||||
}, [journeys, searchQuery])
|
||||
|
||||
const otherJourneys = useMemo(() => {
|
||||
return journeys.filter(j => j.id !== activeJourney?.id)
|
||||
}, [journeys, activeJourney])
|
||||
const filteredJourneys = useMemo(() => {
|
||||
const q = searchQuery.trim().toLowerCase()
|
||||
if (!q) return journeys.filter(j => j.id !== activeJourney?.id)
|
||||
return journeys.filter(j => {
|
||||
const inTitle = j.title.toLowerCase().includes(q)
|
||||
const inSubtitle = j.subtitle?.toLowerCase().includes(q) ?? false
|
||||
return inTitle || inSubtitle
|
||||
})
|
||||
}, [journeys, activeJourney, searchQuery])
|
||||
|
||||
const openCreateModal = async (preSelectedTripId?: number) => {
|
||||
setShowCreate(true)
|
||||
@@ -99,15 +113,41 @@ export default function JourneyPage() {
|
||||
<div style={{ paddingTop: 'var(--nav-h, 56px)' }}>
|
||||
<div className="max-w-[1440px] mx-auto">
|
||||
|
||||
{/* Header — mobile: just a create button */}
|
||||
<div className="md:hidden px-5 pt-5 pb-4">
|
||||
<button
|
||||
onClick={() => openCreateModal()}
|
||||
className="w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[14px] font-semibold active:scale-[0.98] transition-transform"
|
||||
>
|
||||
<Plus size={16} strokeWidth={2.5} />
|
||||
{t('journey.frontpage.createJourney')}
|
||||
</button>
|
||||
{/* Header — mobile */}
|
||||
<div className="md:hidden px-5 pt-5 pb-4 flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (searchOpen) {
|
||||
setSearchOpen(false)
|
||||
setSearchQuery('')
|
||||
} else {
|
||||
setSearchOpen(true)
|
||||
setTimeout(() => searchInputRef.current?.focus(), 50)
|
||||
}
|
||||
}}
|
||||
className="w-10 h-10 rounded-xl border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 flex items-center justify-center text-zinc-500 hover:bg-zinc-50 dark:hover:bg-zinc-700 flex-shrink-0"
|
||||
>
|
||||
{searchOpen ? <X size={15} /> : <Search size={15} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openCreateModal()}
|
||||
className="flex-1 flex items-center justify-center gap-2 py-2.5 rounded-xl bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[14px] font-semibold active:scale-[0.98] transition-transform"
|
||||
>
|
||||
<Plus size={16} strokeWidth={2.5} />
|
||||
{t('journey.frontpage.createJourney')}
|
||||
</button>
|
||||
</div>
|
||||
{searchOpen && (
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Escape') { setSearchQuery(''); setSearchOpen(false) } }}
|
||||
placeholder={t('journey.search.placeholder')}
|
||||
className="w-full px-3.5 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-xl text-[14px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white focus:border-zinc-400 focus:outline-none"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Header — desktop */}
|
||||
@@ -117,8 +157,24 @@ export default function JourneyPage() {
|
||||
<p className="text-[13px] text-zinc-500 mt-1.5">{t("journey.frontpage.subtitle")}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="w-9 h-9 rounded-[10px] border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 flex items-center justify-center text-zinc-500 hover:bg-zinc-50 dark:hover:bg-zinc-700">
|
||||
<Search size={15} />
|
||||
{searchOpen && (
|
||||
<input
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Escape') { setSearchQuery(''); setSearchOpen(false) } }}
|
||||
placeholder={t('journey.search.placeholder')}
|
||||
autoFocus
|
||||
className="w-52 px-3 py-2 border border-zinc-200 dark:border-zinc-700 rounded-[10px] text-[13px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white focus:border-zinc-400 focus:outline-none"
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
setSearchOpen(s => !s)
|
||||
if (searchOpen) setSearchQuery('')
|
||||
}}
|
||||
className={`w-9 h-9 rounded-[10px] border border-zinc-200 dark:border-zinc-700 flex items-center justify-center text-zinc-500 transition-colors ${searchOpen ? 'bg-zinc-100 dark:bg-zinc-700' : 'bg-white dark:bg-zinc-800 hover:bg-zinc-50 dark:hover:bg-zinc-700'}`}
|
||||
>
|
||||
{searchOpen ? <X size={15} /> : <Search size={15} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openCreateModal()}
|
||||
@@ -226,7 +282,7 @@ export default function JourneyPage() {
|
||||
{[
|
||||
{ val: (activeJourney as any).entry_count ?? '--', label: t("journey.stats.entries") },
|
||||
{ val: (activeJourney as any).photo_count ?? '--', label: t("journey.stats.photos") },
|
||||
{ val: (activeJourney as any).city_count ?? '--', label: t("journey.stats.cities") },
|
||||
{ val: (activeJourney as any).place_count ?? '--', label: t("journey.stats.places") },
|
||||
].map(s => (
|
||||
<div key={s.label} className="flex flex-col gap-1">
|
||||
<span className="text-[28px] font-extrabold tracking-[-0.02em] leading-none">{s.val}</span>
|
||||
@@ -243,11 +299,24 @@ export default function JourneyPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search results info */}
|
||||
{searchQuery.trim() && (
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<span className="text-[13px] text-zinc-500">
|
||||
{filteredJourneys.length === 0
|
||||
? t('journey.search.noResults', { query: searchQuery.trim() })
|
||||
: `${filteredJourneys.length} ${t('journey.frontpage.journeys')}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* All Journeys */}
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<span className="text-[11px] font-bold tracking-[0.14em] uppercase text-zinc-500">{t("journey.frontpage.allJourneys")}</span>
|
||||
<span className="text-[11px] text-zinc-400">{journeys.length} {t('journey.frontpage.journeys')}</span>
|
||||
</div>
|
||||
{!searchQuery.trim() && (
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<span className="text-[11px] font-bold tracking-[0.14em] uppercase text-zinc-500">{t("journey.frontpage.allJourneys")}</span>
|
||||
<span className="text-[11px] text-zinc-400">{journeys.length} {t('journey.frontpage.journeys')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && journeys.length === 0 ? (
|
||||
<div className="flex justify-center py-16">
|
||||
@@ -255,7 +324,7 @@ export default function JourneyPage() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-[18px]">
|
||||
{otherJourneys.map(j => (
|
||||
{filteredJourneys.map(j => (
|
||||
<JourneyCard key={j.id} journey={j} onClick={() => navigate(`/journey/${j.id}`)} />
|
||||
))}
|
||||
|
||||
@@ -386,12 +455,13 @@ export default function JourneyPage() {
|
||||
)
|
||||
}
|
||||
|
||||
function JourneyCard({ journey, onClick }: { journey: Journey & { entry_count?: number; photo_count?: number; city_count?: number }; onClick: () => void }) {
|
||||
function JourneyCard({ journey, onClick }: { journey: Journey & { entry_count?: number; photo_count?: number; place_count?: number; trip_date_min?: string | null; trip_date_max?: string | null }; onClick: () => void }) {
|
||||
const { t } = useTranslation()
|
||||
const j = journey
|
||||
const entryCount = j.entry_count ?? 0
|
||||
const photoCount = j.photo_count ?? 0
|
||||
const cityCount = j.city_count ?? 0
|
||||
const placeCount = j.place_count ?? 0
|
||||
const lifecycle = computeJourneyLifecycle(j.status, j.trip_date_min, j.trip_date_max)
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -424,15 +494,22 @@ function JourneyCard({ journey, onClick }: { journey: Journey & { entry_count?:
|
||||
{j.subtitle && (
|
||||
<p className="text-[12px] text-zinc-500 mt-1">{j.subtitle}</p>
|
||||
)}
|
||||
{j.status === 'draft' && (
|
||||
<span className="inline-flex self-start mt-1.5 px-2 py-0.5 rounded-full bg-zinc-100 dark:bg-zinc-800 text-[10px] font-medium text-zinc-500 uppercase tracking-wide">{t('journey.status.draft')}</span>
|
||||
{lifecycle !== 'live' && (
|
||||
<span className={`inline-flex self-start mt-1.5 px-2 py-0.5 rounded-full text-[10px] font-medium uppercase tracking-wide ${
|
||||
lifecycle === 'archived' ? 'bg-zinc-100 dark:bg-zinc-800 text-zinc-500' :
|
||||
lifecycle === 'upcoming' ? 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400' :
|
||||
lifecycle === 'completed' ? 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-400' :
|
||||
'bg-zinc-100 dark:bg-zinc-800 text-zinc-500'
|
||||
}`}>
|
||||
{t(`journey.status.${lifecycle}`)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-3 gap-2.5 mt-auto pt-3.5 border-t border-zinc-100 dark:border-zinc-800" style={{ marginTop: j.subtitle ? 14 : 'auto' }}>
|
||||
{[
|
||||
{ val: entryCount, label: t('journey.stats.entries') },
|
||||
{ val: photoCount, label: t('journey.stats.photos') },
|
||||
{ val: cityCount, label: t('journey.stats.cities') },
|
||||
{ val: placeCount, label: t('journey.stats.places') },
|
||||
].map(s => (
|
||||
<div key={s.label} className="flex flex-col gap-1">
|
||||
<span className={`text-[16px] font-bold leading-none tracking-[-0.01em] ${s.val > 0 ? 'text-zinc-900 dark:text-white' : 'text-zinc-300 dark:text-zinc-600'}`}>
|
||||
|
||||
@@ -109,7 +109,7 @@ const mockJourneyData = {
|
||||
stats: {
|
||||
entries: 2,
|
||||
photos: 1,
|
||||
cities: 2,
|
||||
places: 2,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -354,7 +354,7 @@ describe('JourneyPublicPage', () => {
|
||||
],
|
||||
},
|
||||
],
|
||||
stats: { entries: 1, photos: 3, cities: 0 },
|
||||
stats: { entries: 1, photos: 3, places: 0 },
|
||||
};
|
||||
|
||||
server.use(
|
||||
@@ -383,7 +383,7 @@ describe('JourneyPublicPage', () => {
|
||||
it('FE-PAGE-PUBLICJOURNEY-015: stats display shows entries, photos, and cities counts', async () => {
|
||||
const customData = {
|
||||
...mockJourneyData,
|
||||
stats: { entries: 14, photos: 83, cities: 7 },
|
||||
stats: { entries: 14, photos: 83, places: 7 },
|
||||
};
|
||||
server.use(
|
||||
http.get('/api/public/journey/test-share-token', () => HttpResponse.json(customData)),
|
||||
|
||||
@@ -176,7 +176,7 @@ export default function JourneyPublicPage() {
|
||||
<span style={{ fontSize: 11, opacity: 0.4 }}>·</span>
|
||||
<span style={{ fontSize: 12, fontWeight: 500, opacity: 0.8, display: 'flex', alignItems: 'center', gap: 5 }}><Camera size={12} /> {stats.photos} {t('journey.stats.photos')}</span>
|
||||
<span style={{ fontSize: 11, opacity: 0.4 }}>·</span>
|
||||
<span style={{ fontSize: 12, fontWeight: 500, opacity: 0.8, display: 'flex', alignItems: 'center', gap: 5 }}><MapPin size={12} /> {stats.cities} {t('journey.stats.places')}</span>
|
||||
<span style={{ fontSize: 12, fontWeight: 500, opacity: 0.8, display: 'flex', alignItems: 'center', gap: 5 }}><MapPin size={12} /> {stats.places} {t('journey.stats.places')}</span>
|
||||
</div>
|
||||
|
||||
<div className="relative" style={{ marginTop: 12, fontSize: 9, fontWeight: 500, letterSpacing: 1.5, textTransform: 'uppercase', opacity: 0.25 }}>{t('journey.public.readOnly')}</div>
|
||||
|
||||
Reference in New Issue
Block a user