mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
fix(journey): persist dismissed suggestions in local storage
This commit is contained in:
@@ -78,6 +78,8 @@ function setupDefaultHandlers(journeys: ReturnType<typeof buildJourneyListItem>[
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
window.localStorage.clear();
|
||||
delete window.__addToast;
|
||||
mockNavigate.mockReset();
|
||||
resetAllStores();
|
||||
seedDefaults();
|
||||
@@ -193,6 +195,77 @@ describe('JourneyPage', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-PAGE-JOURNEY-007c: keeps a dismissed suggestion hidden after remount', async () => {
|
||||
const user = userEvent.setup();
|
||||
const suggestions = [
|
||||
{ id: 991, title: 'Kyoto Weekend', start_date: '2026-03-01', end_date: '2026-03-02', place_count: 2 },
|
||||
{ id: 993, title: 'Lisbon Loop', start_date: '2026-04-01', end_date: '2026-04-02', place_count: 3 },
|
||||
];
|
||||
server.use(
|
||||
http.get('/api/journeys', () => HttpResponse.json({ journeys: [] })),
|
||||
http.get('/api/journeys/suggestions', () => HttpResponse.json({ trips: suggestions })),
|
||||
);
|
||||
|
||||
const firstRender = render(<JourneyPage />);
|
||||
const dismissButton = await screen.findByRole('button', { name: 'Dismiss' });
|
||||
await user.click(dismissButton);
|
||||
|
||||
expect(await screen.findByText(/Lisbon Loop/)).toBeInTheDocument();
|
||||
const dismissedRecord = JSON.parse(window.localStorage.getItem(`trek:journey:dismissed-suggestions:v1:${useAuthStore.getState().user?.id}`) || '[]');
|
||||
expect(dismissedRecord).toContain(991);
|
||||
firstRender.unmount();
|
||||
|
||||
render(<JourneyPage />);
|
||||
expect(await screen.findByText(/Lisbon Loop/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Kyoto Weekend/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PAGE-JOURNEY-007d: keeps the suggestion visible and reports a storage failure', async () => {
|
||||
const user = userEvent.setup();
|
||||
const suggestion = { id: 992, title: 'Coast Trip', start_date: '2026-03-01', end_date: '2026-03-02', place_count: 2 };
|
||||
const addToast = vi.fn();
|
||||
window.__addToast = addToast;
|
||||
const setItem = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
||||
throw new DOMException('Storage is full', 'QuotaExceededError');
|
||||
});
|
||||
server.use(
|
||||
http.get('/api/journeys', () => HttpResponse.json({ journeys: [] })),
|
||||
http.get('/api/journeys/suggestions', () => HttpResponse.json({ trips: [suggestion] })),
|
||||
);
|
||||
|
||||
render(<JourneyPage />);
|
||||
await user.click(await screen.findByRole('button', { name: 'Dismiss' }));
|
||||
|
||||
expect(addToast).toHaveBeenCalledWith('Could not dismiss this suggestion', 'error', undefined);
|
||||
expect(screen.getByText(/Trip just ended/i)).toBeInTheDocument();
|
||||
setItem.mockRestore();
|
||||
delete window.__addToast;
|
||||
});
|
||||
|
||||
it('FE-PAGE-JOURNEY-007e: deletes dismissed IDs absent from the suggestions response', async () => {
|
||||
const userId = useAuthStore.getState().user?.id;
|
||||
const absentTripId = 994;
|
||||
const stillDismissedTripId = 995;
|
||||
const returnedTripId = 996;
|
||||
const key = `trek:journey:dismissed-suggestions:v1:${userId}`;
|
||||
window.localStorage.setItem(key, JSON.stringify([absentTripId, stillDismissedTripId]));
|
||||
server.use(
|
||||
http.get('/api/journeys', () => HttpResponse.json({ journeys: [] })),
|
||||
http.get('/api/journeys/suggestions', () => HttpResponse.json({
|
||||
trips: [
|
||||
{ id: returnedTripId, title: 'Returned Trip', start_date: '2026-04-01', end_date: '2026-04-02', place_count: 2 },
|
||||
{ id: stillDismissedTripId, title: 'Still Dismissed', start_date: '2026-05-01', end_date: '2026-05-02', place_count: 2 },
|
||||
],
|
||||
})),
|
||||
);
|
||||
|
||||
render(<JourneyPage />);
|
||||
|
||||
expect(await screen.findByText(/Returned Trip/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Still Dismissed/)).not.toBeInTheDocument();
|
||||
expect(JSON.parse(window.localStorage.getItem(key) || '[]')).toEqual([stillDismissedTripId]);
|
||||
});
|
||||
|
||||
// FE-PAGE-JOURNEY-007b — XSS regression: the suggestion banner interpolates
|
||||
// a user-controlled trip title into an HTML template that is later passed to
|
||||
// dangerouslySetInnerHTML. The sanitiser in @trek/shared must drop any script
|
||||
@@ -266,7 +339,7 @@ describe('JourneyPage', () => {
|
||||
});
|
||||
|
||||
// Click dismiss
|
||||
await user.click(screen.getByText(/Dismiss/i));
|
||||
await user.click(screen.getByRole('button', { name: 'Dismiss' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/Trip just ended/i)).not.toBeInTheDocument();
|
||||
|
||||
@@ -45,7 +45,7 @@ function JourneyPageDesktop() {
|
||||
showCreate, setShowCreate, newTitle, setNewTitle, newSubtitle, setNewSubtitle,
|
||||
availableTrips, selectedTripIds, setSelectedTripIds,
|
||||
searchOpen, setSearchOpen, searchQuery, setSearchQuery, searchInputRef,
|
||||
activeSuggestion, setDismissedSuggestions,
|
||||
activeSuggestion, dismissSuggestion,
|
||||
activeJourney, activeJourneyIsLive, filteredJourneys,
|
||||
openCreateModal, handleCreate, totalPlaces,
|
||||
} = useJourney()
|
||||
@@ -112,7 +112,7 @@ function JourneyPageDesktop() {
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<button type="button"
|
||||
onClick={() => setDismissedSuggestions(prev => new Set([...prev, activeSuggestion.id]))}
|
||||
onClick={() => dismissSuggestion(activeSuggestion.id)}
|
||||
className="px-3 py-1.5 rounded-lg bg-white/10 border border-white/20 text-[12px] font-medium text-white hover:bg-white/20"
|
||||
>
|
||||
{t('journey.frontpage.dismiss')}
|
||||
|
||||
@@ -1,11 +1,48 @@
|
||||
import { useEffect, useState, useMemo, useRef } from 'react'
|
||||
import { useNavigate, useSearchParams } from 'react-router'
|
||||
import { useJourneyStore } from '../../store/journeyStore'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { journeyApi } from '../../api/client'
|
||||
import { useToast } from '../../components/shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { computeJourneyLifecycle } from '../../utils/journeyLifecycle'
|
||||
|
||||
function dismissedSuggestionsKey(userId: number | undefined): string {
|
||||
return `trek:journey:dismissed-suggestions:v1:${userId ?? 'anonymous'}`
|
||||
}
|
||||
|
||||
function readDismissedSuggestions(userId: number | undefined): Set<number> {
|
||||
try {
|
||||
const value: unknown = JSON.parse(window.localStorage.getItem(dismissedSuggestionsKey(userId)) || '[]')
|
||||
if (!Array.isArray(value)) return new Set()
|
||||
|
||||
const dismissed = new Set<number>()
|
||||
for (const item of value) {
|
||||
if (Number.isInteger(item)) {
|
||||
dismissed.add(item)
|
||||
continue
|
||||
}
|
||||
if (!item || typeof item !== 'object') {
|
||||
continue
|
||||
}
|
||||
const record = item as { id?: unknown; dismissedAt?: unknown }
|
||||
if (typeof record.id === 'number' && Number.isInteger(record.id)) dismissed.add(record.id)
|
||||
}
|
||||
return dismissed
|
||||
} catch {
|
||||
return new Set()
|
||||
}
|
||||
}
|
||||
|
||||
function saveDismissedSuggestions(userId: number | undefined, dismissed: Set<number>): void {
|
||||
const key = dismissedSuggestionsKey(userId)
|
||||
if (dismissed.size === 0) {
|
||||
window.localStorage.removeItem(key)
|
||||
return
|
||||
}
|
||||
window.localStorage.setItem(key, JSON.stringify([...dismissed]))
|
||||
}
|
||||
|
||||
/**
|
||||
* Journey list page logic — owns the journey store load, the create-journey
|
||||
* modal (title, available trips, selection), the search box, the trip
|
||||
@@ -18,6 +55,7 @@ export function useJourney() {
|
||||
const toast = useToast()
|
||||
const { t } = useTranslation()
|
||||
const { journeys, loading, loadJourneys, createJourney } = useJourneyStore()
|
||||
const userId = useAuthStore(s => s.user?.id)
|
||||
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [newTitle, setNewTitle] = useState('')
|
||||
@@ -30,15 +68,37 @@ export function useJourney() {
|
||||
|
||||
// suggestion
|
||||
const [suggestions, setSuggestions] = useState<any[]>([])
|
||||
const [dismissedSuggestions, setDismissedSuggestions] = useState<Set<number>>(new Set())
|
||||
const [dismissedSuggestions, setDismissedSuggestions] = useState<Set<number>>(() => readDismissedSuggestions(userId))
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
|
||||
useEffect(() => {
|
||||
loadJourneys()
|
||||
journeyApi.suggestions().then(d => setSuggestions(d.trips || [])).catch(() => {})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setSuggestions([])
|
||||
setDismissedSuggestions(readDismissedSuggestions(userId))
|
||||
journeyApi.suggestions().then(d => {
|
||||
if (cancelled) return
|
||||
const trips = d.trips || []
|
||||
const suggestionIds = new Set<number>(trips.map((trip: { id: number }) => trip.id))
|
||||
const stored = readDismissedSuggestions(userId)
|
||||
const retained = new Set([...stored].filter(id => suggestionIds.has(id)))
|
||||
setSuggestions(trips)
|
||||
setDismissedSuggestions(retained)
|
||||
if (retained.size !== stored.size) {
|
||||
try {
|
||||
saveDismissedSuggestions(userId, retained)
|
||||
} catch {
|
||||
toast.error(t('journey.frontpage.dismissError'))
|
||||
}
|
||||
}
|
||||
}).catch(() => {})
|
||||
return () => { cancelled = true }
|
||||
}, [userId])
|
||||
|
||||
// The bottom-nav "+" opens the new-journey modal via ?create=1.
|
||||
useEffect(() => {
|
||||
if (searchParams.get('create') === '1') {
|
||||
@@ -49,6 +109,17 @@ export function useJourney() {
|
||||
|
||||
const activeSuggestion = suggestions.find(s => !dismissedSuggestions.has(s.id))
|
||||
|
||||
const dismissSuggestion = (tripId: number) => {
|
||||
const next = new Set(dismissedSuggestions)
|
||||
next.add(tripId)
|
||||
try {
|
||||
saveDismissedSuggestions(userId, next)
|
||||
setDismissedSuggestions(next)
|
||||
} catch {
|
||||
toast.error(t('journey.frontpage.dismissError'))
|
||||
}
|
||||
}
|
||||
|
||||
// The frontpage always features one journey as the hero. A journey tied to a
|
||||
// currently-running trip wins; otherwise we fall back to the most recent one
|
||||
// (the list arrives sorted by updated_at DESC, so journeys[0] is the newest).
|
||||
@@ -118,7 +189,7 @@ export function useJourney() {
|
||||
showCreate, setShowCreate, newTitle, setNewTitle, newSubtitle, setNewSubtitle,
|
||||
availableTrips, selectedTripIds, setSelectedTripIds,
|
||||
searchOpen, setSearchOpen, searchQuery, setSearchQuery, searchInputRef,
|
||||
activeSuggestion, setDismissedSuggestions,
|
||||
activeSuggestion, dismissSuggestion,
|
||||
activeJourney, activeJourneyIsLive, filteredJourneys,
|
||||
openCreateModal, handleCreate, totalPlaces,
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ const journey: TranslationStrings = {
|
||||
'journey.frontpage.suggestionLabel': 'Trip just ended',
|
||||
'journey.frontpage.suggestionText': 'Turn <strong>{title}</strong> into a Journey',
|
||||
'journey.frontpage.dismiss': 'Dismiss',
|
||||
'journey.frontpage.dismissError': 'Could not dismiss this suggestion',
|
||||
'journey.frontpage.journeyName': 'Journey Name',
|
||||
'journey.frontpage.namePlaceholder': 'e.g. Southeast Asia 2026',
|
||||
'journey.frontpage.selectTrips': 'Select Trips',
|
||||
|
||||
@@ -69,6 +69,7 @@ const journey: TranslationStrings = {
|
||||
'journey.frontpage.suggestionLabel': '旅行刚结束',
|
||||
'journey.frontpage.suggestionText': '将 <strong>{title}</strong> 变成一段旅程',
|
||||
'journey.frontpage.dismiss': '忽略',
|
||||
'journey.frontpage.dismissError': '无法忽略这条建议,请重试',
|
||||
'journey.frontpage.journeyName': '旅程名称',
|
||||
'journey.frontpage.namePlaceholder': '例如 东南亚 2026',
|
||||
'journey.frontpage.selectTrips': '选择旅行',
|
||||
|
||||
Reference in New Issue
Block a user