Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions app/(tabs)/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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);
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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,
Expand Down
22 changes: 22 additions & 0 deletions src/api/recapApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,22 @@ import {
shouldAttemptAuthenticatedApi,
} from '@/api/client';
import type {
GeoPoint,
MoodTag,
RecapBackgroundSuggestion,
RecapItem,
RecapMapMarker,
RecapMapScope,
RecapShare,
RecapTemplateId,
RecapVisibility,
RoutePoint,
TravelMode,
} from '@/types/domain';
import { sanitizeRecapItem } from '@/utils/trackSanitizer';

type CreateRecapInput = {
backgroundImageUrl?: string;
momentLogIds?: string[];
representativeTrackId?: string;
routePoints?: RoutePoint[];
Expand Down Expand Up @@ -95,6 +100,23 @@ export const recapApi = {
},
);
},
getBackgroundSuggestion: async (input: {
location: GeoPoint;
moodTags?: MoodTag[];
travelMode?: TravelMode;
}) => {
if (!shouldAttemptAuthenticatedApi()) {
return Promise.resolve<RecapBackgroundSuggestion | undefined>(undefined);
}

return requestApi<RecapBackgroundSuggestion>(
'/v1/recaps/background-suggestion',
{
body: input,
method: 'POST',
},
).catch(() => undefined);
},
createRecap: async (input: CreateRecapInput, idempotencyKey?: string) => {
if (!shouldAttemptAuthenticatedApi()) {
return Promise.resolve<RecapItem | undefined>(undefined);
Expand Down
16 changes: 12 additions & 4 deletions src/components/moment-capture/MomentCaptureScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,6 @@ export function MomentCaptureScreen() {
});
const idempotencyKey = saveIdempotencyKey ?? `recap-capture:${createdAt}`;
const savedLog = await momentLogApi.createMomentLog({
createStandaloneRecap: !activeSessionId,
createdAt,
idempotencyKey,
location: locationSnapshot,
Expand All @@ -300,17 +299,26 @@ 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,
},
`standalone-recap:${idempotencyKey}`,
);

if (!fallbackRecap) {
if (!standaloneRecap) {
throw new Error("standalone_recap_save_failed");
}
}
Expand Down
152 changes: 152 additions & 0 deletions src/test/__tests__/recapBackgroundSuggestion.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
7 changes: 7 additions & 0 deletions src/types/domain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading