Finish the NestJS migration — drop the legacy Express app

NestJS now serves the whole surface: every /api domain plus the platform
routes (uploads, /mcp, the OAuth/MCP SDK + /.well-known metadata and the
production SPA fallback). Removed server/src/app.ts, all of
server/src/routes/* and the strangler dispatcher; index.ts and the
integration suite share a single buildApp() bootstrap so prod and tests
can't drift.

- Platform/transport routes extracted to nest/platform/platform.routes.ts
  and mounted before app.init() — Nest's router answers an unmatched
  request with a 404, so a route registered after init is never reached.
  The SPA fallback is a NotFoundException filter and the catch-all uses a
  RegExp (Express 5's path-to-regexp rejects a bare '*').
- New modules: memories (/api/integrations/memories — the Journey
  gallery's Immich/Synology proxy), addons (GET /api/addons) and the
  cross-trip GET /api/reservations/upcoming.
- TrekExceptionFilter reproduces the old multer / err.statusCode handling
  so upload rejections keep their 400/413 { error } body and non-ASCII
  filenames survive (defParamCharset).
- addTripToJourney and the MCP get_journey_share_link tool gained the
  trip-access check they were missing.
- Re-pointed the 34 integration tests + the websocket test onto the Nest
  app; removed the now-meaningless Express-vs-Nest parity tests and a few
  orphaned client components.
This commit is contained in:
Maurice
2026-05-31 13:29:22 +02:00
parent fc7d8b5d12
commit bfe52579df
138 changed files with 2289 additions and 12666 deletions
@@ -1,95 +0,0 @@
import { useState, useEffect, useCallback } from 'react'
import { ArrowRightLeft, RefreshCw } from 'lucide-react'
import { useTranslation } from '../../i18n'
import CustomSelect from '../shared/CustomSelect'
const CURRENCIES = [
'AED', 'AFN', 'ALL', 'AMD', 'ANG', 'AOA', 'ARS', 'AUD', 'AWG', 'AZN', 'BAM', 'BBD', 'BDT', 'BGN', 'BHD',
'BIF', 'BMD', 'BND', 'BOB', 'BRL', 'BSD', 'BTN', 'BWP', 'BYN', 'BZD', 'CAD', 'CDF', 'CHF', 'CLF', 'CLP',
'CNH', 'CNY', 'COP', 'CRC', 'CUP', 'CVE', 'CZK', 'DJF', 'DKK', 'DOP', 'DZD', 'EGP', 'ERN', 'ETB', 'EUR',
'FJD', 'FKP', 'FOK', 'GBP', 'GEL', 'GGP', 'GHS', 'GIP', 'GMD', 'GNF', 'GTQ', 'GYD', 'HKD', 'HNL', 'HRK',
'HTG', 'HUF', 'IDR', 'ILS', 'IMP', 'INR', 'IQD', 'ISK', 'JEP', 'JMD', 'JOD', 'JPY', 'KES', 'KGS', 'KHR',
'KID', 'KMF', 'KRW', 'KWD', 'KYD', 'KZT', 'LAK', 'LBP', 'LKR', 'LRD', 'LSL', 'LYD', 'MAD', 'MDL', 'MGA',
'MKD', 'MMK', 'MNT', 'MOP', 'MRU', 'MUR', 'MVR', 'MWK', 'MXN', 'MYR', 'MZN', 'NAD', 'NGN', 'NIO', 'NOK',
'NPR', 'NZD', 'OMR', 'PAB', 'PEN', 'PGK', 'PHP', 'PKR', 'PLN', 'PYG', 'QAR', 'RON', 'RSD', 'RUB', 'RWF',
'SAR', 'SBD', 'SCR', 'SDG', 'SEK', 'SGD', 'SHP', 'SLE', 'SOS', 'SRD', 'SSP', 'STN', 'SYP', 'SZL', 'THB',
'TJS', 'TMT', 'TND', 'TOP', 'TRY', 'TTD', 'TVD', 'TWD', 'TZS', 'UAH', 'UGX', 'USD', 'UYU', 'UZS', 'VES',
'VND', 'VUV', 'WST', 'XAF', 'XCD', 'XDR', 'XOF', 'XPF', 'YER', 'ZAR', 'ZMW', 'ZWL'
]
const CURRENCY_OPTIONS = CURRENCIES.map(c => ({ value: c, label: c }))
export default function CurrencyWidget() {
const { t, locale } = useTranslation()
const [from, setFrom] = useState(() => localStorage.getItem('currency_from') || 'EUR')
const [to, setTo] = useState(() => localStorage.getItem('currency_to') || 'USD')
const [amount, setAmount] = useState('100')
const [rate, setRate] = useState(null)
const [loading, setLoading] = useState(false)
const fetchRate = useCallback(async () => {
if (from === to) { setRate(1); return }
setLoading(true)
try {
const resp = await fetch(`https://api.exchangerate-api.com/v4/latest/${from}`)
const data = await resp.json()
setRate(data.rates?.[to] || null)
} catch { setRate(null) }
finally { setLoading(false) }
}, [from, to])
useEffect(() => { fetchRate() }, [fetchRate])
useEffect(() => { localStorage.setItem('currency_from', from) }, [from])
useEffect(() => { localStorage.setItem('currency_to', to) }, [to])
const swap = () => { setFrom(to); setTo(from) }
const rawResult = rate && amount ? (parseFloat(amount) * rate).toFixed(2) : null
const formatNumber = (num) => {
if (!num || num === '—') return '—'
return parseFloat(num).toLocaleString(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
const result = rawResult
return (
<div className="rounded-2xl border p-4" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
<div className="flex items-center justify-between mb-3">
<span className="text-xs font-semibold uppercase tracking-wide" style={{ color: 'var(--text-faint)' }}>{t('dashboard.currency')}</span>
<button onClick={fetchRate} className="p-1 rounded-md transition-colors" style={{ color: 'var(--text-faint)' }}>
<RefreshCw size={12} className={loading ? 'animate-spin' : ''} />
</button>
</div>
{/* Amount */}
<div className="rounded-xl px-4 py-3 mb-3" style={{ background: 'var(--bg-secondary)', border: '1px solid var(--border-primary)' }}>
<input
type="number"
value={amount}
onChange={e => setAmount(e.target.value)}
className="w-full text-2xl font-black tabular-nums outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none"
style={{ color: 'var(--text-primary)', background: 'transparent', border: 'none' }}
/>
</div>
{/* From / Swap / To */}
<div className="flex items-center gap-2 mb-3">
<div className="flex-1" style={{ '--bg-input': 'transparent', '--border-primary': 'transparent' }}>
<CustomSelect value={from} onChange={setFrom} options={CURRENCY_OPTIONS} searchable size="sm" />
</div>
<button onClick={swap} className="p-1.5 rounded-lg shrink-0 transition-colors" style={{ color: 'var(--text-muted)' }}>
<ArrowRightLeft size={13} />
</button>
<div className="flex-1" style={{ '--bg-input': 'transparent', '--border-primary': 'transparent' }}>
<CustomSelect value={to} onChange={setTo} options={CURRENCY_OPTIONS} searchable size="sm" />
</div>
</div>
{/* Result */}
<div className="rounded-xl p-3" style={{ background: 'var(--bg-secondary)' }}>
<p className="text-xl font-black tabular-nums" style={{ color: 'var(--text-primary)' }}>
{formatNumber(result)} <span className="text-sm font-semibold" style={{ color: 'var(--text-muted)' }}>{to}</span>
</p>
{rate && <p className="text-[10px] mt-0.5" style={{ color: 'var(--text-faint)' }}>1 {from} = {rate.toFixed(4)} {to}</p>}
</div>
</div>
)
}
@@ -1,149 +0,0 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen } from '../../../tests/helpers/render'
import userEvent from '@testing-library/user-event'
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
import { useSettingsStore } from '../../store/settingsStore'
import TimezoneWidget from './TimezoneWidget'
beforeEach(() => {
resetAllStores()
vi.clearAllMocks()
localStorage.clear()
seedStore(useSettingsStore, { settings: { time_format: '24h' } } as any)
})
describe('TimezoneWidget', () => {
it('FE-COMP-TIMEZONE-001: renders without crashing with default zones', () => {
render(<TimezoneWidget />)
expect(document.body).toBeInTheDocument()
expect(screen.getByText('New York')).toBeInTheDocument()
expect(screen.getByText('Tokyo')).toBeInTheDocument()
})
it('FE-COMP-TIMEZONE-002: shows local time text', () => {
render(<TimezoneWidget />)
const timeElements = screen.getAllByText(/\d{1,2}:\d{2}/)
expect(timeElements.length).toBeGreaterThan(0)
})
it('FE-COMP-TIMEZONE-003: shows timezone section label', () => {
render(<TimezoneWidget />)
expect(screen.getByText(/timezones/i)).toBeInTheDocument()
})
it('FE-COMP-TIMEZONE-004: default zones render on first load (no localStorage)', () => {
localStorage.clear()
render(<TimezoneWidget />)
expect(screen.getByText('New York')).toBeInTheDocument()
expect(screen.getByText('Tokyo')).toBeInTheDocument()
})
it('FE-COMP-TIMEZONE-005: zones saved in localStorage are restored', () => {
localStorage.setItem('dashboard_timezones', JSON.stringify([{ label: 'Berlin', tz: 'Europe/Berlin' }]))
render(<TimezoneWidget />)
expect(screen.getByText('Berlin')).toBeInTheDocument()
expect(screen.queryByText('New York')).toBeNull()
})
it('FE-COMP-TIMEZONE-006: clicking the Plus button opens the add-zone panel', async () => {
const user = userEvent.setup()
render(<TimezoneWidget />)
const allButtons = screen.getAllByRole('button')
await user.click(allButtons[0])
expect(await screen.findByText('Custom Timezone')).toBeInTheDocument()
})
it('FE-COMP-TIMEZONE-007: adding a popular zone from the dropdown adds it to the list', async () => {
const user = userEvent.setup()
render(<TimezoneWidget />)
// Open add panel
const allButtons = screen.getAllByRole('button')
await user.click(allButtons[0])
// Find and click Berlin in the popular zones list
const berlinButton = await screen.findByRole('button', { name: /Berlin/i })
await user.click(berlinButton)
expect(screen.getByText('Berlin')).toBeInTheDocument()
// Panel should be closed
expect(screen.queryByText('Custom Timezone')).toBeNull()
})
it('FE-COMP-TIMEZONE-008: adding a custom valid timezone with label shows in the list', async () => {
const user = userEvent.setup()
render(<TimezoneWidget />)
// Open add panel
const allButtons = screen.getAllByRole('button')
await user.click(allButtons[0])
// Type label and timezone
const labelInput = screen.getByPlaceholderText('Label (optional)')
const tzInput = screen.getByPlaceholderText('e.g. America/New_York')
await user.type(labelInput, 'My City')
await user.type(tzInput, 'Europe/Paris')
// Click Add
const addButton = screen.getByRole('button', { name: 'Add' })
await user.click(addButton)
expect(await screen.findByText('My City')).toBeInTheDocument()
})
it('FE-COMP-TIMEZONE-009: adding a custom invalid timezone shows an error', async () => {
const user = userEvent.setup()
render(<TimezoneWidget />)
const allButtons = screen.getAllByRole('button')
await user.click(allButtons[0])
const tzInput = screen.getByPlaceholderText('e.g. America/New_York')
await user.type(tzInput, 'Invalid/Timezone')
const addButton = screen.getByRole('button', { name: 'Add' })
await user.click(addButton)
expect(await screen.findByText(/invalid timezone/i)).toBeInTheDocument()
})
it('FE-COMP-TIMEZONE-010: adding a duplicate timezone shows a duplicate error', async () => {
const user = userEvent.setup()
render(<TimezoneWidget />)
// Default zones include New York (America/New_York)
const allButtons = screen.getAllByRole('button')
await user.click(allButtons[0])
const tzInput = screen.getByPlaceholderText('e.g. America/New_York')
await user.type(tzInput, 'America/New_York')
const addButton = screen.getByRole('button', { name: 'Add' })
await user.click(addButton)
expect(await screen.findByText(/already added/i)).toBeInTheDocument()
})
it('FE-COMP-TIMEZONE-011: remove button removes a zone from the list', async () => {
const user = userEvent.setup()
render(<TimezoneWidget />)
expect(screen.getByText('New York')).toBeInTheDocument()
// The remove buttons are always in the DOM (opacity-0 in CSS, not hidden from DOM)
// There are 2 zone rows (New York, Tokyo), plus the Plus button = 3 buttons total
// Remove buttons for New York and Tokyo come after the Plus button
const allButtons = screen.getAllByRole('button')
// allButtons[0] = Plus, allButtons[1] = remove New York, allButtons[2] = remove Tokyo
await user.click(allButtons[1])
expect(screen.queryByText('New York')).toBeNull()
expect(screen.getByText('Tokyo')).toBeInTheDocument()
})
it('FE-COMP-TIMEZONE-012: adding a zone persists to localStorage', async () => {
const user = userEvent.setup()
render(<TimezoneWidget />)
const allButtons = screen.getAllByRole('button')
await user.click(allButtons[0])
const berlinButton = await screen.findByRole('button', { name: /Berlin/i })
await user.click(berlinButton)
const saved = JSON.parse(localStorage.getItem('dashboard_timezones') || '[]')
expect(saved.some((z: { tz: string }) => z.tz === 'Europe/Berlin')).toBe(true)
})
it('FE-COMP-TIMEZONE-013: Enter key in custom tz input triggers addCustomZone', async () => {
const user = userEvent.setup()
render(<TimezoneWidget />)
const allButtons = screen.getAllByRole('button')
await user.click(allButtons[0])
const labelInput = screen.getByPlaceholderText('Label (optional)')
const tzInput = screen.getByPlaceholderText('e.g. America/New_York')
await user.type(labelInput, 'Singapore')
await user.type(tzInput, 'Asia/Singapore')
await user.keyboard('{Enter}')
expect(await screen.findByText('Singapore')).toBeInTheDocument()
})
})
@@ -1,167 +0,0 @@
import { useState, useEffect } from 'react'
import { Clock, Plus, X } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useSettingsStore } from '../../store/settingsStore'
const POPULAR_ZONES = [
{ label: 'New York', tz: 'America/New_York' },
{ label: 'London', tz: 'Europe/London' },
{ label: 'Berlin', tz: 'Europe/Berlin' },
{ label: 'Paris', tz: 'Europe/Paris' },
{ label: 'Dubai', tz: 'Asia/Dubai' },
{ label: 'Mumbai', tz: 'Asia/Kolkata' },
{ label: 'Bangkok', tz: 'Asia/Bangkok' },
{ label: 'Tokyo', tz: 'Asia/Tokyo' },
{ label: 'Sydney', tz: 'Australia/Sydney' },
{ label: 'Los Angeles', tz: 'America/Los_Angeles' },
{ label: 'Chicago', tz: 'America/Chicago' },
{ label: 'São Paulo', tz: 'America/Sao_Paulo' },
{ label: 'Istanbul', tz: 'Europe/Istanbul' },
{ label: 'Singapore', tz: 'Asia/Singapore' },
{ label: 'Hong Kong', tz: 'Asia/Hong_Kong' },
{ label: 'Seoul', tz: 'Asia/Seoul' },
{ label: 'Moscow', tz: 'Europe/Moscow' },
{ label: 'Cairo', tz: 'Africa/Cairo' },
]
function getTime(tz, locale, is12h) {
try {
return new Date().toLocaleTimeString(locale, { timeZone: tz, hour: '2-digit', minute: '2-digit', hour12: is12h })
} catch { return '—' }
}
function getOffset(tz) {
try {
const now = new Date()
const local = new Date(now.toLocaleString('en-US', { timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone }))
const remote = new Date(now.toLocaleString('en-US', { timeZone: tz }))
const diff = (remote - local) / 3600000
const sign = diff >= 0 ? '+' : ''
return `${sign}${diff}h`
} catch { return '' }
}
export default function TimezoneWidget() {
const { t, locale } = useTranslation()
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
const [zones, setZones] = useState(() => {
const saved = localStorage.getItem('dashboard_timezones')
return saved ? JSON.parse(saved) : [
{ label: 'New York', tz: 'America/New_York' },
{ label: 'Tokyo', tz: 'Asia/Tokyo' },
]
})
const [now, setNow] = useState(Date.now())
const [showAdd, setShowAdd] = useState(false)
const [customLabel, setCustomLabel] = useState('')
const [customTz, setCustomTz] = useState('')
const [customError, setCustomError] = useState('')
useEffect(() => {
const i = setInterval(() => setNow(Date.now()), 10000)
return () => clearInterval(i)
}, [])
useEffect(() => {
localStorage.setItem('dashboard_timezones', JSON.stringify(zones))
}, [zones])
const isValidTz = (tz: string) => {
try { Intl.DateTimeFormat('en-US', { timeZone: tz }).format(new Date()); return true } catch { return false }
}
const addCustomZone = () => {
const tz = customTz.trim()
if (!tz) { setCustomError(t('dashboard.timezoneCustomErrorEmpty')); return }
if (!isValidTz(tz)) { setCustomError(t('dashboard.timezoneCustomErrorInvalid')); return }
if (zones.find(z => z.tz === tz)) { setCustomError(t('dashboard.timezoneCustomErrorDuplicate')); return }
const label = customLabel.trim() || tz.split('/').pop()?.replace(/_/g, ' ') || tz
setZones([...zones, { label, tz }])
setCustomLabel(''); setCustomTz(''); setCustomError(''); setShowAdd(false)
}
const addZone = (zone) => {
if (!zones.find(z => z.tz === zone.tz)) {
setZones([...zones, zone])
}
setShowAdd(false)
}
const removeZone = (tz) => setZones(zones.filter(z => z.tz !== tz))
const localTime = new Date().toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: is12h })
const rawZone = Intl.DateTimeFormat().resolvedOptions().timeZone
const localZone = rawZone.split('/').pop().replace(/_/g, ' ')
// Show abbreviated timezone name (e.g. CET, CEST, EST)
const tzAbbr = new Date().toLocaleTimeString('en-US', { timeZoneName: 'short' }).split(' ').pop()
return (
<div className="rounded-2xl border p-4" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
<div className="flex items-center justify-between mb-3">
<span className="text-xs font-semibold uppercase tracking-wide" style={{ color: 'var(--text-faint)' }}>{t('dashboard.timezone')}</span>
<button onClick={() => setShowAdd(!showAdd)} className="p-1 rounded-md transition-colors" style={{ color: 'var(--text-faint)' }}>
<Plus size={12} />
</button>
</div>
{/* Local time */}
<div className="mb-3 pb-3" style={{ borderBottom: '1px solid var(--border-secondary)' }}>
<p className="text-2xl font-black tabular-nums" style={{ color: 'var(--text-primary)' }}>{localTime}</p>
<p className="text-[10px] font-semibold uppercase tracking-wide" style={{ color: 'var(--text-faint)' }}>{localZone} ({tzAbbr}) · {t('dashboard.localTime')}</p>
</div>
{/* Zone list */}
<div className="space-y-2">
{zones.map(z => (
<div key={z.tz} className="flex items-center justify-between group">
<div>
<p className="text-lg font-bold tabular-nums" style={{ color: 'var(--text-primary)' }}>{getTime(z.tz, locale, is12h)}</p>
<p className="text-[10px]" style={{ color: 'var(--text-faint)' }}>{z.label} <span style={{ color: 'var(--text-muted)' }}>{getOffset(z.tz)}</span></p>
</div>
<button onClick={() => removeZone(z.tz)} className="opacity-0 group-hover:opacity-100 p-1 rounded transition-all" style={{ color: 'var(--text-faint)' }}>
<X size={11} />
</button>
</div>
))}
</div>
{/* Add zone dropdown */}
{showAdd && (
<div className="mt-2 rounded-xl p-2 max-h-[280px] overflow-auto" style={{ background: 'var(--bg-secondary)' }}>
{/* Custom timezone */}
<div className="px-2 py-2 mb-2 rounded-lg" style={{ background: 'var(--bg-card)' }}>
<p className="text-[10px] font-semibold uppercase tracking-wide mb-2" style={{ color: 'var(--text-faint)' }}>{t('dashboard.timezoneCustomTitle')}</p>
<div className="space-y-1.5">
<input value={customLabel} onChange={e => setCustomLabel(e.target.value)}
placeholder={t('dashboard.timezoneCustomLabelPlaceholder')}
className="w-full px-2 py-1.5 rounded-lg text-xs outline-none"
style={{ background: 'var(--bg-secondary)', color: 'var(--text-primary)', border: '1px solid var(--border-secondary)' }} />
<input value={customTz} onChange={e => { setCustomTz(e.target.value); setCustomError('') }}
placeholder={t('dashboard.timezoneCustomTzPlaceholder')}
className="w-full px-2 py-1.5 rounded-lg text-xs outline-none"
style={{ background: 'var(--bg-secondary)', color: 'var(--text-primary)', border: `1px solid ${customError ? '#ef4444' : 'var(--border-secondary)'}` }}
onKeyDown={e => { if (e.key === 'Enter') addCustomZone() }} />
{customError && <p className="text-[10px]" style={{ color: '#ef4444' }}>{customError}</p>}
<button onClick={addCustomZone}
className="w-full py-1.5 rounded-lg text-xs font-medium transition-colors"
style={{ background: 'var(--text-primary)', color: 'var(--bg-primary)' }}>
{t('dashboard.timezoneCustomAdd')}
</button>
</div>
</div>
{/* Popular zones */}
{POPULAR_ZONES.filter(z => !zones.find(existing => existing.tz === z.tz)).map(z => (
<button key={z.tz} onClick={() => addZone(z)}
className="w-full flex items-center justify-between px-2 py-1.5 rounded-lg text-xs text-left transition-colors"
style={{ color: 'var(--text-primary)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
<span className="font-medium">{z.label}</span>
<span className="text-[10px]" style={{ color: 'var(--text-faint)' }}>{getTime(z.tz, locale, is12h)}</span>
</button>
))}
</div>
)}
</div>
)
}
@@ -1,32 +0,0 @@
// FE-COMP-MOBILETOPHEADER-001 to FE-COMP-MOBILETOPHEADER-004
import { describe, it, expect } from 'vitest';
import { render, screen } from '../../../tests/helpers/render';
import MobileTopHeader from './MobileTopHeader';
describe('MobileTopHeader', () => {
it('FE-COMP-MOBILETOPHEADER-001: renders title as h1', () => {
render(<MobileTopHeader title="Journeys" />);
const heading = screen.getByRole('heading', { level: 1 });
expect(heading).toBeInTheDocument();
expect(heading.textContent).toBe('Journeys');
});
it('FE-COMP-MOBILETOPHEADER-002: renders subtitle when provided', () => {
render(<MobileTopHeader title="Journeys" subtitle="3 trips" />);
expect(screen.getByText('3 trips')).toBeInTheDocument();
});
it('FE-COMP-MOBILETOPHEADER-003: does not render subtitle when omitted', () => {
const { container } = render(<MobileTopHeader title="Journeys" />);
const subtitleEl = container.querySelector('.text-xs.text-zinc-500');
expect(subtitleEl).not.toBeInTheDocument();
});
it('FE-COMP-MOBILETOPHEADER-004: renders action children when provided', () => {
render(
<MobileTopHeader title="Trips" actions={<button>Add</button>} />,
);
expect(screen.getByRole('button', { name: 'Add' })).toBeInTheDocument();
});
});
@@ -1,17 +0,0 @@
interface Props {
title: string
subtitle?: string
actions?: React.ReactNode
}
export default function MobileTopHeader({ title, subtitle, actions }: Props) {
return (
<div className="px-5 pt-4 pb-3 flex justify-between items-center bg-zinc-50 dark:bg-zinc-950 flex-shrink-0 md:hidden">
<div className="flex-1 min-w-0">
<h1 className="text-[28px] font-extrabold text-zinc-900 dark:text-white tracking-tight leading-none">{title}</h1>
{subtitle && <div className="text-xs text-zinc-500 mt-1">{subtitle}</div>}
</div>
{actions && <div className="flex gap-2 items-center flex-shrink-0">{actions}</div>}
</div>
)
}
@@ -1,789 +0,0 @@
// FE-COMP-MEMORIESPANEL-001 to FE-COMP-MEMORIESPANEL-027
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { render } from '../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { server } from '../../../tests/helpers/msw/server';
import { http, HttpResponse } from 'msw';
import { useAuthStore } from '../../store/authStore';
import { buildUser } from '../../../tests/helpers/factories';
import MemoriesPanel from './MemoriesPanel';
// Mock fetchImageAsBlob to avoid real HTTP calls for thumbnail/image rendering
vi.mock('../../api/authUrl', () => ({
fetchImageAsBlob: vi.fn().mockResolvedValue('blob:mock-url'),
clearImageQueue: vi.fn(),
}));
const defaultProps = {
tripId: 1,
startDate: '2025-03-01',
endDate: '2025-03-10',
};
// Reusable provider object to configure a connected Immich instance
const immichAddon = {
id: 'immich',
name: 'Immich',
type: 'photo_provider',
enabled: true,
config: { status_get: '/integrations/memories/immich/status' },
};
// Handlers that simulate a connected provider with no photos/links
const connectedHandlers = [
http.get('/api/addons', () =>
HttpResponse.json({ addons: [immichAddon] })
),
http.get('/api/integrations/memories/immich/status', () =>
HttpResponse.json({ connected: true })
),
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
HttpResponse.json({ photos: [] })
),
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () =>
HttpResponse.json({ links: [] })
),
];
beforeEach(() => {
resetAllStores();
// Seed a default logged-in user
seedStore(useAuthStore, { user: buildUser({ id: 1, username: 'me' }) });
});
describe('MemoriesPanel', () => {
it('FE-COMP-MEMORIESPANEL-001: Shows loading state on initial render', () => {
// Use a delayed response so loading stays true long enough to assert
server.use(
http.get('/api/addons', async () => {
await new Promise(resolve => setTimeout(resolve, 200));
return HttpResponse.json({ addons: [] });
}),
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
HttpResponse.json({ photos: [] })
),
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () =>
HttpResponse.json({ links: [] })
),
);
render(<MemoriesPanel {...defaultProps} />);
// Spinner is rendered synchronously — loading state starts as true
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
});
it('FE-COMP-MEMORIESPANEL-002: Shows not-connected state when no photo providers are enabled', async () => {
server.use(
http.get('/api/addons', () => HttpResponse.json({ addons: [] })),
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
HttpResponse.json({ photos: [] })
),
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () =>
HttpResponse.json({ links: [] })
),
);
render(<MemoriesPanel {...defaultProps} />);
// "Photo provider not connected" — no providers, falls back to generic label
await screen.findByText('Photo provider not connected');
});
it('FE-COMP-MEMORIESPANEL-003: Displays trip photos from other users', async () => {
server.use(
...connectedHandlers.filter(h => !h.info.path.includes('photos')),
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
HttpResponse.json({
photos: [
{
asset_id: 'abc',
provider: 'immich',
user_id: 2,
username: 'Alice',
shared: 1,
added_at: '2025-03-05T10:00:00Z',
},
],
})
),
);
render(<MemoriesPanel {...defaultProps} />);
// Alice's username is rendered as an avatar tooltip in the gallery
await screen.findByText('Alice');
});
it('FE-COMP-MEMORIESPANEL-004: Shows empty gallery state when connected but no photos', async () => {
server.use(...connectedHandlers);
render(<MemoriesPanel {...defaultProps} />);
// Provider is connected so the gallery renders — but no photos → empty state
await screen.findByText('No photos found');
});
it('FE-COMP-MEMORIESPANEL-005: Album links are displayed in the gallery header', async () => {
server.use(
...connectedHandlers.filter(h => !h.info.path.includes('album-links')),
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () =>
HttpResponse.json({
links: [
{
id: 1,
provider: 'immich',
album_id: 'a1',
album_name: 'Holidays',
user_id: 1,
username: 'me',
sync_enabled: 1,
last_synced_at: null,
},
],
})
),
);
render(<MemoriesPanel {...defaultProps} />);
await screen.findByText('Holidays');
});
it('FE-COMP-MEMORIESPANEL-006: Sync button calls the sync endpoint', async () => {
let syncCalled = false;
server.use(
...connectedHandlers.filter(h => !h.info.path.includes('album-links')),
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () =>
HttpResponse.json({
links: [
{
id: 1,
provider: 'immich',
album_id: 'a1',
album_name: 'Holidays',
user_id: 1,
username: 'me',
sync_enabled: 1,
last_synced_at: null,
},
],
})
),
http.post('/api/integrations/memories/:provider/trips/:tripId/album-links/:linkId/sync', () => {
syncCalled = true;
return HttpResponse.json({ ok: true });
}),
);
render(<MemoriesPanel {...defaultProps} />);
await screen.findByText('Holidays');
const syncBtn = screen.getByTitle('Sync album');
await userEvent.click(syncBtn);
await waitFor(() => expect(syncCalled).toBe(true));
});
it('FE-COMP-MEMORIESPANEL-007: Unlink button calls the delete endpoint', async () => {
let deleteCalled = false;
server.use(
...connectedHandlers.filter(h => !h.info.path.includes('album-links')),
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () =>
HttpResponse.json({
links: [
{
id: 1,
provider: 'immich',
album_id: 'a1',
album_name: 'Holidays',
user_id: 1,
username: 'me',
sync_enabled: 1,
last_synced_at: null,
},
],
})
),
http.delete('/api/integrations/memories/unified/trips/:tripId/album-links/:linkId', () => {
deleteCalled = true;
return HttpResponse.json({ ok: true });
}),
);
render(<MemoriesPanel {...defaultProps} />);
await screen.findByText('Holidays');
// The unlink button is only shown when link.user_id === currentUser.id
const unlinkBtn = screen.getByTitle('Unlink album');
await userEvent.click(unlinkBtn);
await waitFor(() => expect(deleteCalled).toBe(true));
});
it('FE-COMP-MEMORIESPANEL-008: Sort toggle switches between oldest-first and newest-first', async () => {
server.use(
...connectedHandlers.filter(h => !h.info.path.includes('photos')),
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
HttpResponse.json({
photos: [
{ photo_id: 1, asset_id: 'photo1', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-01T10:00:00Z' },
{ photo_id: 2, asset_id: 'photo2', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-10T10:00:00Z' },
],
})
),
);
render(<MemoriesPanel {...defaultProps} />);
// Default sort is ascending ("Oldest first")
const sortBtn = await screen.findByText('Oldest first');
await userEvent.click(sortBtn);
// After toggle, button label switches to "Newest first"
expect(screen.getByText('Newest first')).toBeInTheDocument();
});
it('FE-COMP-MEMORIESPANEL-009: Photo picker opens when "Add photos" is clicked', async () => {
server.use(
...connectedHandlers,
http.post('/api/integrations/memories/immich/search', () =>
HttpResponse.json({ assets: [] })
),
);
render(<MemoriesPanel {...defaultProps} />);
// Wait for the empty gallery to load
await screen.findByText('No photos found');
// Both the header button and gallery CTA say "Add photos" — click the first
const addBtns = screen.getAllByText('Add photos');
await userEvent.click(addBtns[0]);
// Picker header is now visible
await screen.findByText('Select photos from Immich');
});
it('FE-COMP-MEMORIESPANEL-010: Picker cancel button closes the picker', async () => {
server.use(
...connectedHandlers,
http.post('/api/integrations/memories/immich/search', () =>
HttpResponse.json({ assets: [] })
),
);
render(<MemoriesPanel {...defaultProps} />);
await screen.findByText('No photos found');
const addBtns = screen.getAllByText('Add photos');
await userEvent.click(addBtns[0]);
await screen.findByText('Select photos from Immich');
// Click Cancel in the picker header
await userEvent.click(screen.getByText('Cancel'));
// Gallery is restored
await screen.findByText('No photos found');
});
it('FE-COMP-MEMORIESPANEL-011: Album picker opens when "Link Album" is clicked', async () => {
server.use(
...connectedHandlers,
http.get('/api/integrations/memories/immich/albums', () =>
HttpResponse.json({ albums: [] })
),
);
render(<MemoriesPanel {...defaultProps} />);
await screen.findByText('No photos found');
await userEvent.click(screen.getByText('Link Album'));
// Album picker header appears
await screen.findByText('Select Immich Album');
});
it('FE-COMP-MEMORIESPANEL-012: Own photos render with share-toggle and private indicator', async () => {
server.use(
...connectedHandlers.filter(h => !h.info.path.includes('photos')),
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
HttpResponse.json({
photos: [
{
asset_id: 'photo1',
provider: 'immich',
user_id: 1,
username: 'me',
shared: 0,
added_at: '2025-03-05T10:00:00Z',
},
],
})
),
);
render(<MemoriesPanel {...defaultProps} />);
// Share-toggle button appears with correct title (not shared → "Share photos")
await screen.findByTitle('Share photos');
// "Private" label is shown on unshared own photos
expect(screen.getByText('Private')).toBeInTheDocument();
});
it('FE-COMP-MEMORIESPANEL-013: toggleSharing calls the PUT sharing endpoint', async () => {
let putCalled = false;
server.use(
...connectedHandlers.filter(h => !h.info.path.includes('photos')),
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
HttpResponse.json({
photos: [
{
asset_id: 'photo1',
provider: 'immich',
user_id: 1,
username: 'me',
shared: 0,
added_at: '2025-03-05T10:00:00Z',
},
],
})
),
http.put('/api/integrations/memories/unified/trips/:tripId/photos/sharing', () => {
putCalled = true;
return HttpResponse.json({ ok: true });
}),
);
render(<MemoriesPanel {...defaultProps} />);
const shareBtn = await screen.findByTitle('Share photos');
await userEvent.click(shareBtn);
await waitFor(() => expect(putCalled).toBe(true));
});
it('FE-COMP-MEMORIESPANEL-014: removePhoto calls the DELETE photos endpoint', async () => {
let deleteCalled = false;
server.use(
...connectedHandlers.filter(h => !h.info.path.includes('photos')),
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
HttpResponse.json({
photos: [
{
asset_id: 'photo1',
provider: 'immich',
user_id: 1,
username: 'me',
shared: 1,
added_at: '2025-03-05T10:00:00Z',
},
],
})
),
http.delete('/api/integrations/memories/unified/trips/:tripId/photos', () => {
deleteCalled = true;
return HttpResponse.json({ ok: true });
}),
);
render(<MemoriesPanel {...defaultProps} />);
// Wait for the share/stop-sharing button to confirm the gallery has rendered
await screen.findByTitle('Stop sharing');
// The remove button is the second action button in the hover overlay — no title, just an X icon
// Get all buttons and click the one after the share toggle
const allBtns = screen.getAllByRole('button');
const shareIdx = allBtns.findIndex(b => b.getAttribute('title') === 'Stop sharing');
// The remove button immediately follows the share button in the DOM
await userEvent.click(allBtns[shareIdx + 1]);
await waitFor(() => expect(deleteCalled).toBe(true));
});
it('FE-COMP-MEMORIESPANEL-015: Picker displays assets grouped by month', async () => {
server.use(
...connectedHandlers,
http.post('/api/integrations/memories/immich/search', () =>
HttpResponse.json({
assets: [
{ id: 'asset1', takenAt: '2025-03-05T10:00:00Z', city: 'Paris', country: 'France' },
],
})
),
);
render(<MemoriesPanel {...defaultProps} />);
await screen.findByText('No photos found');
const [firstAddBtn] = screen.getAllByText('Add photos');
await userEvent.click(firstAddBtn);
await screen.findByText('Select photos from Immich');
// Month group header appears after photos load
await screen.findByText(/March.*2025|2025.*March/);
});
it('FE-COMP-MEMORIESPANEL-016: Album picker lists available albums with asset count', async () => {
server.use(
...connectedHandlers,
http.get('/api/integrations/memories/immich/albums', () =>
HttpResponse.json({
albums: [
{ id: 'album1', albumName: 'Summer 2025', assetCount: 42 },
],
})
),
);
render(<MemoriesPanel {...defaultProps} />);
await screen.findByText('No photos found');
await userEvent.click(screen.getByText('Link Album'));
await screen.findByText('Summer 2025');
// Asset count is rendered next to the album name
expect(screen.getByText(/42/)).toBeInTheDocument();
});
it('FE-COMP-MEMORIESPANEL-017: ProviderTabs appear in picker when multiple providers are connected', async () => {
const immich2Addon = {
id: 'immich2',
name: 'Immich2',
type: 'photo_provider',
enabled: true,
config: { status_get: '/integrations/memories/immich2/status' },
};
server.use(
http.get('/api/addons', () =>
HttpResponse.json({ addons: [immichAddon, immich2Addon] })
),
http.get('/api/integrations/memories/immich/status', () => HttpResponse.json({ connected: true })),
http.get('/api/integrations/memories/immich2/status', () => HttpResponse.json({ connected: true })),
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () => HttpResponse.json({ photos: [] })),
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () => HttpResponse.json({ links: [] })),
http.post('/api/integrations/memories/immich/search', () => HttpResponse.json({ assets: [] })),
http.post('/api/integrations/memories/immich2/search', () => HttpResponse.json({ assets: [] })),
);
render(<MemoriesPanel {...defaultProps} />);
await screen.findByText('No photos found');
const [firstAddBtn] = screen.getAllByText('Add photos');
await userEvent.click(firstAddBtn);
// With multiple providers the picker header uses the "multiple" translation
await screen.findByText('Select Photos');
// Both provider name tabs are rendered inside the picker
expect(screen.getByRole('button', { name: 'Immich' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Immich2' })).toBeInTheDocument();
});
it('FE-COMP-MEMORIESPANEL-018: Location filter dropdown appears when photos have multiple cities', async () => {
server.use(
...connectedHandlers.filter(h => !h.info.path.includes('photos')),
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
HttpResponse.json({
photos: [
{ photo_id: 10, asset_id: 'p1', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-01T00:00:00Z', city: 'Paris' },
{ photo_id: 11, asset_id: 'p2', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-05T00:00:00Z', city: 'Lyon' },
],
})
),
);
render(<MemoriesPanel {...defaultProps} />);
// Location dropdown shows "All locations" option when there are 2+ distinct cities
await screen.findByText('All locations');
expect(screen.getByRole('combobox')).toBeInTheDocument();
});
it('FE-COMP-MEMORIESPANEL-019: Full picker flow: select photo → confirm dialog → execute add', async () => {
let addPhotosCalled = false;
server.use(
...connectedHandlers,
http.post('/api/integrations/memories/immich/search', () =>
HttpResponse.json({
assets: [
{ id: 'asset1', takenAt: '2025-03-05T10:00:00Z', city: null, country: null },
],
})
),
http.post('/api/integrations/memories/unified/trips/:tripId/photos', () => {
addPhotosCalled = true;
return HttpResponse.json({ ok: true });
}),
);
render(<MemoriesPanel {...defaultProps} />);
await screen.findByText('No photos found');
const [firstAddBtn] = screen.getAllByText('Add photos');
await userEvent.click(firstAddBtn);
await screen.findByText('Select photos from Immich');
// Wait for the picker asset thumbnail to render (ProviderImg sets src after blob resolves)
// img has alt="" so findByRole('img') won't work — use findByAltText instead
const thumbnail = await screen.findByAltText('');
// Click the thumbnail — bubbles up to the parent div's onClick to select it
await userEvent.click(thumbnail);
// "1 selected" count appears and "Add 1 photos" button is active
await screen.findByText(/1\s+selected/);
await userEvent.click(screen.getByText('Add 1 photos'));
// Confirm share dialog appears
await screen.findByText('Share with trip members?');
// Click the confirm "Share photos" button to execute
await userEvent.click(screen.getByText('Share photos'));
await waitFor(() => expect(addPhotosCalled).toBe(true));
});
it('FE-COMP-MEMORIESPANEL-020: "All photos" filter tab makes an unfiltered search', async () => {
let searchCount = 0;
server.use(
...connectedHandlers,
http.post('/api/integrations/memories/immich/search', () => {
searchCount++;
return HttpResponse.json({ assets: [] });
}),
);
render(<MemoriesPanel {...defaultProps} />);
await screen.findByText('No photos found');
const [firstAddBtn] = screen.getAllByText('Add photos');
await userEvent.click(firstAddBtn);
await screen.findByText('Select photos from Immich');
// Click "All photos" — triggers a second loadPickerPhotos(false) call
await userEvent.click(screen.getByText('All photos'));
await waitFor(() => expect(searchCount).toBeGreaterThan(1));
});
it('FE-COMP-MEMORIESPANEL-021: Picker with no trip dates shows only "All photos" tab', async () => {
server.use(
...connectedHandlers,
http.post('/api/integrations/memories/immich/search', () =>
HttpResponse.json({ assets: [] })
),
);
render(<MemoriesPanel tripId={1} startDate={null} endDate={null} />);
await screen.findByText('No photos found');
const [firstAddBtn] = screen.getAllByText('Add photos');
await userEvent.click(firstAddBtn);
await screen.findByText('Select photos from Immich');
// "Trip dates" tab is absent when dates are not set
expect(screen.queryByText(/Trip dates/)).not.toBeInTheDocument();
expect(screen.getByText('All photos')).toBeInTheDocument();
});
it('FE-COMP-MEMORIESPANEL-022: Provider with no status_get URL shows not-connected', async () => {
server.use(
http.get('/api/addons', () =>
HttpResponse.json({
addons: [
{ id: 'myapp', name: 'MyApp', type: 'photo_provider', enabled: true, config: {} },
],
})
),
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
HttpResponse.json({ photos: [] })
),
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () =>
HttpResponse.json({ links: [] })
),
);
render(<MemoriesPanel {...defaultProps} />);
// Provider name shown in the not-connected message when exactly 1 enabled provider
await screen.findByText('MyApp not connected');
});
it('FE-COMP-MEMORIESPANEL-023: Picker marks already-added photos with "Added" overlay', async () => {
server.use(
...connectedHandlers.filter(h => !h.info.path.includes('photos')),
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
HttpResponse.json({
photos: [
{
asset_id: 'asset1',
provider: 'immich',
user_id: 1,
username: 'me',
shared: 1,
added_at: '2025-03-05T10:00:00Z',
},
],
})
),
http.post('/api/integrations/memories/immich/search', () =>
HttpResponse.json({
assets: [
{ id: 'asset1', takenAt: '2025-03-05T10:00:00Z', city: null, country: null },
],
})
),
);
render(<MemoriesPanel {...defaultProps} />);
// Gallery shows own photo — "Stop sharing" title confirms it's loaded
await screen.findByTitle('Stop sharing');
// Open picker from the header button (only 1 "Add photos" button since photos > 0)
await userEvent.click(screen.getByText('Add photos'));
await screen.findByText('Select photos from Immich');
// The asset already in the gallery shows the "Added" overlay in the picker
await screen.findByText('Added');
});
it('FE-COMP-MEMORIESPANEL-024: Location filter select filters the visible photos', async () => {
server.use(
...connectedHandlers.filter(h => !h.info.path.includes('photos')),
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
HttpResponse.json({
photos: [
{ photo_id: 10, asset_id: 'p1', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-01T00:00:00Z', city: 'Paris' },
{ photo_id: 11, asset_id: 'p2', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-05T00:00:00Z', city: 'Lyon' },
],
})
),
);
render(<MemoriesPanel {...defaultProps} />);
const select = await screen.findByRole('combobox');
// Change filter to a specific city
await userEvent.selectOptions(select, 'Paris');
expect(select).toHaveValue('Paris');
});
it("FE-COMP-MEMORIESPANEL-025: Album link from another user shows username but no unlink button", async () => {
server.use(
...connectedHandlers.filter(h => !h.info.path.includes('album-links')),
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () =>
HttpResponse.json({
links: [
{
id: 1,
provider: 'immich',
album_id: 'a1',
album_name: 'Holidays',
user_id: 2,
username: 'Alice',
sync_enabled: 1,
last_synced_at: null,
},
],
})
),
);
render(<MemoriesPanel {...defaultProps} />);
await screen.findByText('Holidays');
// Other user's username is shown in parentheses
expect(screen.getByText('(Alice)')).toBeInTheDocument();
// Unlink button is NOT shown for another user's album link
expect(screen.queryByTitle('Unlink album')).not.toBeInTheDocument();
});
it('FE-COMP-MEMORIESPANEL-026: Linking an album calls the album-links POST endpoint', async () => {
let linkCalled = false;
// Track whether POST has been made so the GET can return different data
let albumLinked = false;
server.use(
...connectedHandlers.filter(h => !h.info.path.includes('album-links')),
http.get('/api/integrations/memories/immich/albums', () =>
HttpResponse.json({
albums: [{ id: 'album1', albumName: 'Summer 2025', assetCount: 10 }],
})
),
http.post('/api/integrations/memories/unified/trips/:tripId/album-links', () => {
linkCalled = true;
albumLinked = true;
return HttpResponse.json({ ok: true });
}),
// Return empty before POST, linked album after POST
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () => {
if (!albumLinked) return HttpResponse.json({ links: [] });
return HttpResponse.json({
links: [{ id: 1, provider: 'immich', album_id: 'album1', album_name: 'Summer 2025', user_id: 1, username: 'me', sync_enabled: 1, last_synced_at: null }],
});
}),
http.post('/api/integrations/memories/:provider/trips/:tripId/album-links/:linkId/sync', () =>
HttpResponse.json({ ok: true })
),
);
render(<MemoriesPanel {...defaultProps} />);
await screen.findByText('No photos found');
await userEvent.click(screen.getByText('Link Album'));
await screen.findByText('Summer 2025');
// Click the album button to link it (album is not yet linked → button is enabled)
await userEvent.click(screen.getByText('Summer 2025'));
await waitFor(() => expect(linkCalled).toBe(true));
});
it('FE-COMP-MEMORIESPANEL-027: Album picker cancel button returns to the gallery', async () => {
server.use(
...connectedHandlers,
http.get('/api/integrations/memories/immich/albums', () =>
HttpResponse.json({ albums: [] })
),
);
render(<MemoriesPanel {...defaultProps} />);
await screen.findByText('No photos found');
await userEvent.click(screen.getByText('Link Album'));
await screen.findByText('Select Immich Album');
// Click Cancel to dismiss without linking
await userEvent.click(screen.getByText('Cancel'));
// Gallery is restored
await screen.findByText('No photos found');
});
});
File diff suppressed because it is too large Load Diff
@@ -1,65 +0,0 @@
import React, { useCallback, useState } from 'react'
import { Copy, Check } from 'lucide-react'
interface CopyButtonProps {
value: string
size?: number
title?: string
className?: string
onCopy?: () => void
}
// Button that morphs between copy icon and check icon for 1.5s after click.
export function CopyButton({ value, size = 14, title, className, onCopy }: CopyButtonProps): React.ReactElement {
const [copied, setCopied] = useState(false)
const handleClick = useCallback(async (e: React.MouseEvent) => {
e.stopPropagation()
try {
await navigator.clipboard.writeText(value)
setCopied(true)
onCopy?.()
window.setTimeout(() => setCopied(false), 1500)
} catch {
// noop
}
}, [value, onCopy])
return (
<button
type="button"
onClick={handleClick}
title={title}
className={className}
style={{
position: 'relative',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: size + 12,
height: size + 12,
border: 'none',
background: 'transparent',
color: copied ? '#22c55e' : 'var(--text-muted)',
cursor: 'pointer',
borderRadius: 6,
}}
>
<Copy size={size} style={{
position: 'absolute',
transition: 'opacity 200ms cubic-bezier(0.23,1,0.32,1), transform 200ms cubic-bezier(0.23,1,0.32,1)',
opacity: copied ? 0 : 1,
transform: copied ? 'scale(0.6) rotate(-45deg)' : 'scale(1) rotate(0)',
}} />
<Check size={size} style={{
position: 'absolute',
transition: 'opacity 200ms cubic-bezier(0.23,1,0.32,1), transform 200ms cubic-bezier(0.23,1,0.32,1)',
opacity: copied ? 1 : 0,
transform: copied ? 'scale(1) rotate(0)' : 'scale(0.6) rotate(45deg)',
strokeWidth: 2.5,
}} />
</button>
)
}
export default CopyButton
@@ -1,36 +0,0 @@
import React, { useState, type ImgHTMLAttributes } from 'react'
interface LoadingImageProps extends ImgHTMLAttributes<HTMLImageElement> {
containerClassName?: string
containerStyle?: React.CSSProperties
}
// Image with shimmer-placeholder until loaded. Drops the shimmer once native load fires.
export function LoadingImage({
containerClassName, containerStyle, className, style, onLoad, ...imgProps
}: LoadingImageProps): React.ReactElement {
const [loaded, setLoaded] = useState(false)
return (
<div className={containerClassName} style={{ position: 'relative', overflow: 'hidden', ...containerStyle }}>
{!loaded && (
<div
className="trek-skeleton"
style={{ position: 'absolute', inset: 0, borderRadius: 0 }}
aria-hidden
/>
)}
<img
{...imgProps}
className={className}
style={{
...style,
opacity: loaded ? 1 : 0,
transition: 'opacity 300ms cubic-bezier(0.23, 1, 0.32, 1)',
}}
onLoad={e => { setLoaded(true); onLoad?.(e) }}
/>
</div>
)
}
export default LoadingImage
-43
View File
@@ -1,43 +0,0 @@
/**
* usePendingMutations — returns the set of entity IDs that have a pending
* or syncing mutation for a given trip.
*
* Components use this to render a clock/pending indicator on list rows.
* Polls Dexie every 2 s so the indicator clears automatically once synced.
*/
import { useState, useEffect } from 'react'
import { mutationQueue } from '../sync/mutationQueue'
const POLL_MS = 2_000
export function usePendingMutations(tripId: number): Set<number> {
const [pendingIds, setPendingIds] = useState<Set<number>>(new Set())
useEffect(() => {
let cancelled = false
async function refresh() {
const pending = await mutationQueue.pending(tripId)
if (cancelled) return
const ids = new Set<number>()
for (const m of pending) {
// Extract entity id from the mutation URL (last numeric segment)
const match = m.url.match(/\/(\d+)$/)
if (match) ids.add(Number(match[1]))
// Also include tempId for offline-created items
if (m.tempId !== undefined) ids.add(m.tempId)
}
setPendingIds(ids)
}
refresh()
const timer = setInterval(refresh, POLL_MS)
return () => {
cancelled = true
clearInterval(timer)
}
}, [tripId])
return pendingIds
}
-63
View File
@@ -1,63 +0,0 @@
import { describe, it, expect } from 'vitest';
import { swapItems } from '../../../src/utils/reorder';
// FE-UTIL-020 onwards
const items = [
{ id: 10 },
{ id: 20 },
{ id: 30 },
{ id: 40 },
];
describe('swapItems', () => {
it('FE-UTIL-020: swaps item up with its predecessor', () => {
const result = swapItems(items, 1, 'up');
expect(result).toEqual([20, 10, 30, 40]);
});
it('FE-UTIL-021: swaps item down with its successor', () => {
const result = swapItems(items, 1, 'down');
expect(result).toEqual([10, 30, 20, 40]);
});
it('FE-UTIL-022: returns null when moving first item up (out of bounds)', () => {
expect(swapItems(items, 0, 'up')).toBeNull();
});
it('FE-UTIL-023: returns null when moving last item down (out of bounds)', () => {
expect(swapItems(items, items.length - 1, 'down')).toBeNull();
});
it('FE-UTIL-024: swaps first and second items when moving index 1 up', () => {
const result = swapItems(items, 1, 'up');
expect(result![0]).toBe(20);
expect(result![1]).toBe(10);
});
it('FE-UTIL-025: returns an array of IDs (not objects)', () => {
const result = swapItems(items, 0, 'down');
expect(Array.isArray(result)).toBe(true);
expect(typeof result![0]).toBe('number');
});
it('FE-UTIL-026: does not mutate the original array', () => {
const original = [{ id: 1 }, { id: 2 }, { id: 3 }];
const snapshot = original.map((o) => o.id);
swapItems(original, 0, 'down');
expect(original.map((o) => o.id)).toEqual(snapshot);
});
it('FE-UTIL-027: returns null for a single-element array moving down', () => {
expect(swapItems([{ id: 5 }], 0, 'down')).toBeNull();
});
it('FE-UTIL-028: returns null for a single-element array moving up', () => {
expect(swapItems([{ id: 5 }], 0, 'up')).toBeNull();
});
it('FE-UTIL-029: swaps last two items when moving second-to-last down', () => {
const result = swapItems(items, items.length - 2, 'down');
expect(result).toEqual([10, 20, 40, 30]);
});
});