fix(journey): persist dismissed suggestions in local storage

This commit is contained in:
Luffy
2026-10-16 14:55:50 +08:00
committed by Maurice
parent cd2ecf897b
commit 5622113348
5 changed files with 152 additions and 6 deletions
+74 -1
View File
@@ -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();
+2 -2
View File
@@ -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')}
+74 -3
View File
@@ -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,
}
+1
View File
@@ -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',
+1
View File
@@ -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': '选择旅行',