diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 25cde2f..f88b706 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -24,12 +24,14 @@ import { useTravelRouteTracking, } from "@/hooks/useTravelRouteTracking"; import { useAuthStore } from "@/store/authStore"; +import { useHomeFilterStore } from "@/store/homeFilterStore"; import { usePlayerStore } from "@/store/playerStore"; import { queryClient } from "@/providers/queryClient"; import { useTravelSessionStore } from "@/store/travelSessionStore"; import { useUserProfileStore } from "@/store/userProfileStore"; import type { TravelMode } from "@/types/domain"; import { requestForegroundLocationWithStatus } from "@/utils/location"; +import { getMoodTagsFromFilter } from "@/utils/moodTags"; const NEARBY_TOUR_RADIUS_METERS = 2000; @@ -39,6 +41,7 @@ export default function MapHomeScreen() { const { isHydrated: authHydrated, status } = useAuthStore(); const { isHydrated, profile } = useUserProfileStore(); const { currentTrack } = usePlayerStore(); + const { selectedMoodFilter } = useHomeFilterStore(); const [isModeSheetVisible, setIsModeSheetVisible] = useState(false); const [isStartingTravel, setIsStartingTravel] = useState(false); const [isEndConfirmVisible, setIsEndConfirmVisible] = useState(false); @@ -109,6 +112,10 @@ export default function MapHomeScreen() { ? "error" : "empty"; const sessionMomentCount = sessionMomentsQuery.data?.length ?? 0; + const selectedMoodTags = useMemo( + () => getMoodTagsFromFilter(selectedMoodFilter), + [selectedMoodFilter], + ); useEffect( function synchronizeRecommendationMode() { @@ -310,8 +317,22 @@ export default function MapHomeScreen() { return; } + const backgroundLocation = + latestSessionLogs[0]?.location ?? + currentLocation ?? + activeCurrentPlace?.location; + const backgroundSuggestion = backgroundLocation + ? await recapApi.getBackgroundSuggestion({ + location: backgroundLocation, + moodTags: selectedMoodTags, + travelMode: selectedMode, + }) + : undefined; + const recap = await recapApi.createRecap( { + backgroundImageUrl: + backgroundSuggestion?.backgroundImageUrl ?? undefined, momentLogIds: latestSessionLogs.map((log) => log.id), routePoints: endingSession.routePoints, sessionId: endingSession.id, diff --git a/src/api/recapApi.ts b/src/api/recapApi.ts index 9f9832f..6d0fae3 100644 --- a/src/api/recapApi.ts +++ b/src/api/recapApi.ts @@ -4,6 +4,9 @@ import { shouldAttemptAuthenticatedApi, } from '@/api/client'; import type { + GeoPoint, + MoodTag, + RecapBackgroundSuggestion, RecapItem, RecapMapMarker, RecapMapScope, @@ -11,10 +14,12 @@ import type { RecapTemplateId, RecapVisibility, RoutePoint, + TravelMode, } from '@/types/domain'; import { sanitizeRecapItem } from '@/utils/trackSanitizer'; type CreateRecapInput = { + backgroundImageUrl?: string; momentLogIds?: string[]; representativeTrackId?: string; routePoints?: RoutePoint[]; @@ -95,6 +100,23 @@ export const recapApi = { }, ); }, + getBackgroundSuggestion: async (input: { + location: GeoPoint; + moodTags?: MoodTag[]; + travelMode?: TravelMode; + }) => { + if (!shouldAttemptAuthenticatedApi()) { + return Promise.resolve(undefined); + } + + return requestApi( + '/v1/recaps/background-suggestion', + { + body: input, + method: 'POST', + }, + ).catch(() => undefined); + }, createRecap: async (input: CreateRecapInput, idempotencyKey?: string) => { if (!shouldAttemptAuthenticatedApi()) { return Promise.resolve(undefined); diff --git a/src/components/moment-capture/MomentCaptureScreen.tsx b/src/components/moment-capture/MomentCaptureScreen.tsx index 4c74843..4caf523 100644 --- a/src/components/moment-capture/MomentCaptureScreen.tsx +++ b/src/components/moment-capture/MomentCaptureScreen.tsx @@ -280,7 +280,6 @@ export function MomentCaptureScreen() { }); const idempotencyKey = saveIdempotencyKey ?? `recap-capture:${createdAt}`; const savedLog = await momentLogApi.createMomentLog({ - createStandaloneRecap: !activeSessionId, createdAt, idempotencyKey, location: locationSnapshot, @@ -300,9 +299,18 @@ export function MomentCaptureScreen() { throw new Error("recap_save_failed"); } - if (!activeSessionId && !savedLog.recapId) { - const fallbackRecap = await recapApi.createRecap( + if (!activeSessionId) { + const backgroundSuggestion = savedLog.location + ? await recapApi.getBackgroundSuggestion({ + location: savedLog.location, + moodTags: reviewMoodTags, + travelMode: activeTravelMode, + }) + : undefined; + const standaloneRecap = await recapApi.createRecap( { + backgroundImageUrl: + backgroundSuggestion?.backgroundImageUrl ?? undefined, momentLogIds: [savedLog.id], templateId: reviewTemplate, visibility: recapVisibility, @@ -310,7 +318,7 @@ export function MomentCaptureScreen() { `standalone-recap:${idempotencyKey}`, ); - if (!fallbackRecap) { + if (!standaloneRecap) { throw new Error("standalone_recap_save_failed"); } } diff --git a/src/test/__tests__/recapBackgroundSuggestion.test.ts b/src/test/__tests__/recapBackgroundSuggestion.test.ts new file mode 100644 index 0000000..d346629 --- /dev/null +++ b/src/test/__tests__/recapBackgroundSuggestion.test.ts @@ -0,0 +1,152 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { recapApi } from '@/api/recapApi'; +import { useAuthStore } from '@/store/authStore'; + +function seedAuthenticated() { + useAuthStore.setState({ + accessToken: 'access-token', + refreshToken: 'refresh-token', + status: 'authenticated', + user: { displayName: 'Test', id: 'user-a', provider: 'email' }, + } as never); +} + +beforeEach(() => { + process.env.EXPO_PUBLIC_SOUNDLOG_API_BASE_URL = 'https://api.test.local'; + useAuthStore.setState({ + accessToken: undefined, + refreshToken: undefined, + status: 'unauthenticated', + user: undefined, + } as never); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('recapApi.getBackgroundSuggestion', () => { + it('requests a location-based suggestion with the current travel context', async () => { + seedAuthenticated(); + + const fetchMock = vi.fn(async (input: unknown, init?: RequestInit) => { + expect(String(input)).toBe( + 'https://api.test.local/v1/recaps/background-suggestion', + ); + expect(init?.method).toBe('POST'); + expect(JSON.parse(String(init?.body))).toEqual({ + location: { lat: 35.1532, lng: 129.1187 }, + moodTags: ['fresh'], + travelMode: 'ocean', + }); + + return new Response( + JSON.stringify({ + data: { + backgroundImageUrl: 'https://example.com/gwangalli.jpg', + placeName: '광안리해수욕장', + placeType: '관광지', + source: 'poi_image', + }, + }), + { status: 200 }, + ); + }); + + vi.stubGlobal('fetch', fetchMock); + + await expect( + recapApi.getBackgroundSuggestion({ + location: { lat: 35.1532, lng: 129.1187 }, + moodTags: ['fresh'], + travelMode: 'ocean', + }), + ).resolves.toEqual({ + backgroundImageUrl: 'https://example.com/gwangalli.jpg', + placeName: '광안리해수욕장', + placeType: '관광지', + source: 'poi_image', + }); + }); + + it('does not block recap creation when the suggestion request fails', async () => { + seedAuthenticated(); + vi.stubGlobal( + 'fetch', + vi.fn( + async () => + new Response( + JSON.stringify({ error: { message: 'ML unavailable' } }), + { status: 503 }, + ), + ), + ); + + await expect( + recapApi.getBackgroundSuggestion({ + location: { lat: 35.1532, lng: 129.1187 }, + }), + ).resolves.toBeUndefined(); + }); + + it('skips the request while unauthenticated', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + await expect( + recapApi.getBackgroundSuggestion({ + location: { lat: 35.1532, lng: 129.1187 }, + }), + ).resolves.toBeUndefined(); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +describe('recapApi.createRecap background', () => { + it('forwards the suggested background while keeping the request idempotent', async () => { + seedAuthenticated(); + + const fetchMock = vi.fn(async (_input: unknown, init?: RequestInit) => { + const headers = new Headers(init?.headers); + + expect(headers.get('Idempotency-Key')).toBe('standalone-recap:capture-a'); + expect(JSON.parse(String(init?.body))).toMatchObject({ + backgroundImageUrl: 'https://example.com/gwangalli.jpg', + momentLogIds: ['capture-a'], + }); + + return new Response( + JSON.stringify({ + data: { + backgroundImageUrl: 'https://example.com/gwangalli.jpg', + createdAt: '2026-09-01T00:00:00.000Z', + id: 'recap-a', + placeName: '광안리해수욕장', + representativeTrack: { + artist: 'Artist', + id: 'track-a', + title: 'Track', + }, + title: '광안리 로그', + }, + }), + { status: 201 }, + ); + }); + + vi.stubGlobal('fetch', fetchMock); + + const recap = await recapApi.createRecap( + { + backgroundImageUrl: 'https://example.com/gwangalli.jpg', + momentLogIds: ['capture-a'], + templateId: 'film', + visibility: 'private', + }, + 'standalone-recap:capture-a', + ); + + expect(recap?.backgroundImageUrl).toBe('https://example.com/gwangalli.jpg'); + }); +}); diff --git a/src/types/domain.ts b/src/types/domain.ts index 9125ec1..458ff01 100644 --- a/src/types/domain.ts +++ b/src/types/domain.ts @@ -153,6 +153,13 @@ export type RecapItem = { moderationStatus?: 'approved' | 'pending' | 'rejected'; }; +export type RecapBackgroundSuggestion = { + backgroundImageUrl: string | null; + placeName: string | null; + placeType: string | null; + source: 'gallery' | 'poi_image' | null; +}; + export type RecapTemplateId = 'album' | 'film' | 'lp' | 'map'; export type RecapVisibility = 'private' | 'public';