Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
5743c12
fix(places): 사진 조회 실패 시 반복 요청하지 않도록 재시도 규칙 변경
KangShinGyu98 Oct 25, 2026
e9a63c0
feat(places): 사진 카드를 batch로 조회하고 실패한 장만 한 번 더 조회
KangShinGyu98 Oct 25, 2026
b08dc5c
feat(places): 지도 북마크 핀·북마크 미리보기를 batch로 조회
KangShinGyu98 Oct 25, 2026
b12a904
fix(route): 경로 batch의 경로 없음 답을 단건으로 다시 묻지 않음
KangShinGyu98 Oct 25, 2026
0763a36
fix(schedule): 일정 불러오기에서 같은 장소 미리보기를 반복 조회하지 않도록 수정
KangShinGyu98 Oct 25, 2026
056fe21
fix(schedule): 일정 불러오는 동안 일차에 빈 상태 대신 스켈레톤 표시
KangShinGyu98 Oct 25, 2026
11f92a1
fix(places): 100곳이 넘을 때 뒤 묶음이 실패해도 앞 묶음에서 받은 장소는 쓰도록 수정
KangShinGyu98 Oct 26, 2026
69ae201
fix(schedule): 기다리던 일정 불러오기가 취소되면 새 불러오기를 기다리도록 수정
KangShinGyu98 Oct 26, 2026
6556831
fix(places): 사진 batch 응답 id 형식이 달라도 같은 장소로 맞추도록 수정
KangShinGyu98 Oct 26, 2026
14068b9
fix(places): status 없는 미리보기 batch 항목도 id·이름이 있으면 사용
KangShinGyu98 Oct 26, 2026
867e4ac
fix(bookmark): 미리보기를 받지 못하면 내부 에러 문구 대신 안내 문구 표시
KangShinGyu98 Oct 26, 2026
d84a070
fix(plan): 사진을 다시 받아도 불러오지 못하면 일정 카드에 깨진 이미지 대신 회색 칸 표시
KangShinGyu98 Oct 26, 2026
5cc1d03
fix(places): 북마크·채팅·장소 상세 사진도 불러오지 못하면 사진 없음 모양 표시
KangShinGyu98 Oct 26, 2026
a6a503c
refactor(places): 사진 조회 옵션을 함수로 빼고 테스트가 훅 대신 쓰게 함
KangShinGyu98 Oct 26, 2026
fcc4799
test(places): 사진 없음 처리 훅 테스트가 렌더 중 바깥 변수를 바꾸지 않게 수정
KangShinGyu98 Oct 26, 2026
3e97a2d
fix(route): 경로를 다시 받다가 실패해도 마지막 답이 경로 없음이면 오류 대신 그대로 표시
KangShinGyu98 Oct 27, 2026
c12c5fb
fix(route): 지도가 방금 실패한 경로 구간을 다시 요청하지 않도록 수정
KangShinGyu98 Oct 27, 2026
035b6b6
fix(schedule): 미리보기 실패 상태에서 다시 들어와도 지운 장소가 되살아나지 않도록 수정
KangShinGyu98 Oct 27, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -4,28 +4,49 @@ import { createRoot } from "react-dom/client";
import { expect, it, vi } from "vitest";
import { BookmarkFolderDetailView } from "./BookmarkFolderDetailView";

const placeQueries = vi.hoisted(() => ({ current: [] as unknown[] }));

vi.mock("@/hooks/useOpenPlaceOnMap", () => ({ useOpenPlaceOnMap: () => vi.fn() }));
vi.mock("@/stores/session-store", () => ({ useSessionStore: () => "room" }));
vi.mock("@/hooks/useRooms", () => ({
useAllRoomBookmarks: () => undefined,
useRoomBookmarks: () => ({ data: [{ bookmarkId: 1, googlePlaceId: "place" }], isPending: false }),
}));
vi.mock("@/lib/places/place-queries", () => ({ placePreviewQueryOptions: () => ({ queryKey: ["place"] }) }));
vi.mock("@tanstack/react-query", () => ({
useQueries: () => [{ isPending: false, isFetching: true, data: { name: "저장한 장소", googlePlaceId: "place", formattedAddress: "서울" } }],
}));
vi.mock("@tanstack/react-query", () => ({ useQueries: () => placeQueries.current }));
vi.mock("./BookmarkFolderDetailHeader", () => ({ BookmarkFolderDetailHeader: () => null }));
vi.mock("./BookmarkPlaceRow", () => ({ BookmarkPlaceRow: ({ place }: { place: { name: string } }) => <div>{place.name}</div> }));
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });

it("keeps cached places visible while their previews refetch", async () => {
async function renderFolder() {
const container = document.createElement("div");
const root = createRoot(container);
await act(async () => root.render(<BookmarkFolderDetailView folder={{ id: "1", title: "여행", color: "red" }} />));
return { container, unmount: () => act(async () => root.unmount()) };
}

it("keeps cached places visible while their previews refetch", async () => {
placeQueries.current = [
{ isPending: false, isFetching: true, data: { name: "저장한 장소", googlePlaceId: "place", formattedAddress: "서울" } },
];
const { container, unmount } = await renderFolder();
try {
await act(async () => root.render(<BookmarkFolderDetailView folder={{ id: "1", title: "여행", color: "red" }} />));
expect(container.textContent).toContain("저장한 장소");
expect(container.querySelector('[role="status"]')).toBeNull();
} finally {
await act(async () => root.unmount());
await unmount();
}
});

it("미리보기를 모두 받지 못하면 내부 에러 문구 대신 안내 문구를 보여 준다", async () => {
placeQueries.current = [
{ isPending: false, isFetching: false, isError: true, error: new Error("batch 응답에 place가 없음") },
];
const { container, unmount } = await renderFolder();
try {
expect(container.textContent).toContain("장소 정보를 불러오지 못했습니다.");
expect(container.textContent).not.toContain("batch 응답에");
} finally {
await unmount();
}
});
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,6 @@ export function BookmarkFolderDetailView({ folder }: { folder: BookmarkFolder })
placeQueries.length > 0 &&
placeQueries.every((q) => q.isError);

const firstPlaceError = placeQueries.find((q) => q.isError)?.error;

const retryPlacePreviews = () => {
for (const q of placeQueries) {
void q.refetch();
Expand Down Expand Up @@ -165,10 +163,9 @@ export function BookmarkFolderDetailView({ folder }: { folder: BookmarkFolder })
<BookmarkListSkeleton />
) : cardsError ? (
<div className="space-y-3 py-10">
{/* 에러 문구는 내부 메시지(요청 id·API 오류 코드)라 화면에 그대로 보여 주지 않는다 */}
<p className="text-center text-body-m-regular mobile:text-body-s-regular text-primary">
{firstPlaceError instanceof Error
? firstPlaceError.message
: "장소 정보를 불러오지 못했습니다."}
장소 정보를 불러오지 못했습니다.
</p>
<div className="text-center">
<button
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { MapPin } from "lucide-react";

import { useInViewport } from "@/hooks/useInViewport";
import { usePlacePhotoUrlQuery } from "@/hooks/usePlacePhotoUrl";
import { useVisiblePlacePhotoUrl } from "@/hooks/useVisiblePlacePhotoUrl";
import { MAIN_CARD_INNER_PADDING_X_CLASS } from "@/lib/layout-tokens";
import { handlePlacePhotoImageError } from "@/lib/places/place-photo-refresh";
import { cn } from "@/lib/utils";
Expand Down Expand Up @@ -33,13 +34,14 @@ export function BookmarkPlacePreviewCard({
const { data: imageUrl } = usePlacePhotoUrlQuery(googlePlaceId, {
enabled: thumbnailInViewport,
});
const { visibleUrl, markUnavailable } = useVisiblePlacePhotoUrl(imageUrl);

const rawGid = typeof googlePlaceId === "string" ? googlePlaceId.trim() : "";

const thumbnailMedia = imageUrl ? (
const thumbnailMedia = visibleUrl ? (
// eslint-disable-next-line @next/next/no-img-element -- remote place photo URL
<img
src={imageUrl}
src={visibleUrl}
alt={name}
className="h-full w-full object-cover"
loading="lazy"
Expand All @@ -49,6 +51,7 @@ export function BookmarkPlacePreviewCard({
googlePlaceId,
placeName: name,
image: event.currentTarget,
onPhotoUnavailable: markUnavailable,
})
}
/>
Expand Down
8 changes: 6 additions & 2 deletions src/app/(main)/plan/_components/itinerary/PlanPlaceCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
useUpdateScheduleItem,
} from "@/hooks/useRooms";
import { usePlanPlaceCardPhoto } from "@/hooks/usePlanPlaceCardPhoto";
import { useVisiblePlacePhotoUrl } from "@/hooks/useVisiblePlacePhotoUrl";
import { bucketItemCount } from "@/lib/analytics/context";
import { AnalyticsEvents, trackAnalyticsEvent } from "@/lib/analytics/track";
import { PLAN_PLACE_CARD_TW } from "@/lib/layout-tokens";
Expand Down Expand Up @@ -106,6 +107,8 @@
const { resolvedPhotoUrl, photoLoading } = usePlanPlaceCardPhoto(place, {
enabled: thumbnailInViewport,
});
const { visibleUrl: visiblePhotoUrl, markUnavailable: markPhotoUnavailable } =
useVisiblePlacePhotoUrl(resolvedPhotoUrl);

const [memoOpen, setMemoOpen] = useState(false);
const [timeOpen, setTimeOpen] = useState(false);
Expand Down Expand Up @@ -320,24 +323,25 @@
className="absolute inset-0 m-auto h-5 w-5 animate-spin text-primary-strong"
aria-hidden
/>
) : resolvedPhotoUrl ? (
) : visiblePhotoUrl ? (
<Image
src={resolvedPhotoUrl}
src={visiblePhotoUrl}
alt={place.title}
fill
className="object-cover"
sizes="96px"
draggable={false}
onError={(event) =>
handlePlacePhotoImageError({
source: "plan-place-card",
googlePlaceId: place.googlePlaceId,
placeName: place.title,
image: event.currentTarget,
onPhotoUnavailable: markPhotoUnavailable,
})
}
/>
) : null;

Check warning on line 344 in src/app/(main)/plan/_components/itinerary/PlanPlaceCard.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=uttae_frontend-server&issues=AaF3C0PARFaOQeZRRGDQ&open=AaF3C0PARFaOQeZRRGDQ&pullRequest=248

if (!hasGooglePlace) {
return (
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { beforeEach, describe, expect, it, vi } from "vitest";

import { PlanTravelTime } from "./PlanTravelTime";

const selectedRoute = vi.hoisted(() => ({
current: { data: undefined, isPending: false, isError: false, isFetching: false } as Record<string, unknown>,
}));

vi.mock("@/hooks/useRooms", () => ({
useScheduleItemRoute: () => selectedRoute.current,
useUpdateScheduleItemTravelMode: () => ({ mutate: vi.fn() }),
}));
vi.mock("@tanstack/react-query", () => ({
useQueries: ({ queries }: { queries: unknown[] }) => queries.map(() => ({ data: undefined })),
}));
vi.mock("@/contexts/MobileViewContext", () => ({ useMobileView: () => ({ isMobileDevice: false }) }));

function renderSummary() {
return renderToStaticMarkup(
createElement(PlanTravelTime, {
roomId: "room-1",
scheduleId: 1,
segmentSourceItemId: 100,
scheduleFingerprint: "100:a:|101:b:",
travelMode: "DRIVING",
contentOnly: true,
}),
);
}

describe("PlanTravelTime 요약 줄", () => {
beforeEach(() => {
selectedRoute.current = { data: undefined, isPending: false, isError: false, isFetching: false };
});

it("다시 받다가 실패해도 마지막 답이 '경로 없음'이면 오류 대신 그 답을 보여 준다", () => {
selectedRoute.current = { data: null, isPending: false, isError: true, isFetching: false };

const html = renderSummary();

expect(html).toContain("이동 안내 없음");
expect(html).not.toContain("경로 조회 오류");
});

it("받은 답 없이 실패하면 오류를 보여 준다", () => {
selectedRoute.current = { data: undefined, isPending: false, isError: true, isFetching: false };

expect(renderSummary()).toContain("경로 조회 오류");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -222,8 +222,9 @@ export function PlanTravelTime({
const summaryPending =
!displayRoute && !selectedFetching && selectedPending;

// 마지막 답이 "경로 없음"(null)이면 다시 받다가 실패해도 그 답을 보여 준다 — 재조회 실패로 이미 받은 답을 오류로 바꾸지 않는다
const summaryIsError =
!displayRoute && primarySettled && selectedError;
!displayRoute && primarySettled && selectedError && selectedRoute !== null;

const summaryUnavailable =
!displayRoute &&
Expand Down
7 changes: 5 additions & 2 deletions src/components/chat/messages/OgPlacePreviewCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { MapPin, Star } from "lucide-react";

import { useInViewport } from "@/hooks/useInViewport";
import { usePlacePhotoUrlQuery } from "@/hooks/usePlacePhotoUrl";
import { useVisiblePlacePhotoUrl } from "@/hooks/useVisiblePlacePhotoUrl";
import { resolveChatMessageTypography } from "@/components/chat/chat-typography";
import { handlePlacePhotoImageError } from "@/lib/places/place-photo-refresh";
import { cn } from "@/lib/utils";
Expand Down Expand Up @@ -36,6 +37,7 @@ export function OgPlacePreviewCard({
const { data: imageUrl } = usePlacePhotoUrlQuery(googlePlaceId, {
enabled: thumbnailInViewport,
});
const { visibleUrl, markUnavailable } = useVisiblePlacePhotoUrl(imageUrl);

return (
<button
Expand All @@ -54,10 +56,10 @@ export function OgPlacePreviewCard({
isMinimized ? "h-[72px] w-[72px]" : "h-[88px] w-[88px]",
)}
>
{imageUrl ? (
{visibleUrl ? (
// eslint-disable-next-line @next/next/no-img-element -- 동적 장소 사진 URL (원격 패턴 비고정)
<img
src={imageUrl}
src={visibleUrl}
alt={name}
className="h-full w-full object-cover"
loading="lazy"
Expand All @@ -67,6 +69,7 @@ export function OgPlacePreviewCard({
googlePlaceId,
placeName: name,
image: event.currentTarget,
onPhotoUnavailable: markUnavailable,
})
}
/>
Expand Down
39 changes: 6 additions & 33 deletions src/components/map/MapBookmarkPins.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,16 @@
"use client";

import { Bookmark } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useQueries, useQueryClient } from "@tanstack/react-query";
import { useMemo } from "react";
import { useQueries } from "@tanstack/react-query";
import { AdvancedMarker } from "@vis.gl/react-google-maps";

import { useSelectedPlace } from "@/contexts/SelectedPlaceContext";
import { useAllRoomBookmarks, useBookmarkCategories } from "@/hooks/useRooms";
import { normalizeGooglePlaceResourceId } from "@/lib/maps";
import type { PlacePreview } from "@/lib/api/places";

import {
buildMapBookmarkPreviewPlan,
settleMapBookmarkPreviewBatch,
} from "./map-bookmark-preview-query";
import { buildMapBookmarkPreviewPlan } from "./map-bookmark-preview-query";
import {
MAP_PIN_BORDER_STROKE,
MAP_PIN_BORDER_STROKE_WIDTH,
Expand Down Expand Up @@ -50,7 +47,6 @@ export function MapBookmarkPins({
enabled,
hiddenNormalizedPlaceIds,
}: MapBookmarkPinsProps) {
const queryClient = useQueryClient();
const { selectedPlace, setSelectedPlace } = useSelectedPlace();

const { data: categories, isSuccess: categoriesReady } =
Expand Down Expand Up @@ -92,34 +88,11 @@ export function MapBookmarkPins({
return out;
}, [allBookmarks, categoryColorById, listsSettled]);

const [settledPreviewIdsKey, setSettledPreviewIdsKey] = useState<string | null>(
null,
);
const previewPlan = useMemo(
() =>
buildMapBookmarkPreviewPlan(
flattenedBookmarks,
hiddenNormalizedPlaceIds,
settledPreviewIdsKey,
),
[flattenedBookmarks, hiddenNormalizedPlaceIds, settledPreviewIdsKey],
() => buildMapBookmarkPreviewPlan(flattenedBookmarks, hiddenNormalizedPlaceIds),
[flattenedBookmarks, hiddenNormalizedPlaceIds],
);
const { visibleBookmarks, previewIdsKey } = previewPlan;

useEffect(() => {
if (!previewIdsKey.length) return;

let cancelled = false;
void settleMapBookmarkPreviewBatch(previewIdsKey, queryClient).then(
(settledKey) => {
if (!cancelled) setSettledPreviewIdsKey(settledKey);
},
);

return () => {
cancelled = true;
};
}, [previewIdsKey, queryClient]);
const { visibleBookmarks } = previewPlan;

const placeQueryDefs = useMemo(() => {
if (!roomId || !listsSettled || visibleBookmarks.length === 0) {
Expand Down
10 changes: 7 additions & 3 deletions src/components/map/PlanItineraryMapRoutes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ import {
decodeOrientedPlanItinerarySegmentPath,
normalizeGooglePlaceResourceId,
} from "@/lib/maps";
import { persistedScheduleItemRouteQueryOptions } from "@/lib/plan/scheduleItemRoutePersistedQuery";
import {
loadScheduleItemRouteForMap,
persistedScheduleItemRouteQueryOptions,
} from "@/lib/plan/scheduleItemRoutePersistedQuery";
import { schedulePlacesFingerprint } from "@/lib/plan/planTravelLocalStorage";
import { readSchedulePlanPlacesFromCache } from "@/lib/plan/scheduleItemPlaces";
import {
Expand Down Expand Up @@ -196,11 +199,12 @@ export function PlanItineraryMapRoutes({
segmentEpoch,
),
/**
* 구간 경로는 일정 리스트와 같은 쿼리를 통해 가져옵니다. `fetchQuery`에 맡겨야
* 구간 경로는 일정 리스트와 같은 쿼리를 통해 가져옵니다. 그 쿼리에 맡겨야
* in-flight 합치기와 재정렬 중 뒤늦게 온 응답 폐기를 React Query가 처리합니다.
*/
queryFn: async () => {
const route = await queryClient.fetchQuery(
const route = await loadScheduleItemRouteForMap(
queryClient,
persistedScheduleItemRouteQueryOptions(
rid,
seg.scheduleId,
Expand Down
Loading