From cb1c8f22b7d4f6258e522ba33261a6a7404c6aa5 Mon Sep 17 00:00:00 2001 From: ClarusIubar Date: Sat, 13 Jun 2026 14:51:47 +0900 Subject: [PATCH] fix: improve tourism info data consumption --- ...SK-013-01-tourism-info-data-consumption.md | 42 ++++++++++++++++ src/api/tourismClient.ts | 4 +- src/components/TourismInfoSheet.tsx | 18 ++++++- src/config/runtimeLimitConfig.ts | 4 ++ .../useAppCoordinatorEffects.ts | 20 ++++++-- src/index.css | 14 ++++++ test/unit/tourism-client.test.ts | 12 +++++ test/unit/tourism-info-sheet.test.tsx | 48 +++++++++++++++++++ 8 files changed, 155 insertions(+), 7 deletions(-) create mode 100644 reports/completion/TSK-013-01-tourism-info-data-consumption.md create mode 100644 test/unit/tourism-info-sheet.test.tsx diff --git a/reports/completion/TSK-013-01-tourism-info-data-consumption.md b/reports/completion/TSK-013-01-tourism-info-data-consumption.md new file mode 100644 index 0000000..f521df1 --- /dev/null +++ b/reports/completion/TSK-013-01-tourism-info-data-consumption.md @@ -0,0 +1,42 @@ +# TSK-013-01 KTO tourism info sheet data consumption and timeout guard + +## Metadata + +- Scope-ID: `TSK-013-01` +- Issue: `#421` +- Parent Issue: `#420` +- Branch: `tourism-info-data-consumption-fix` +- Status: ready for PR + +## Summary + +KTO tourism place data is now consumed by the information sheet beyond title/address. The sheet renders image, summary, description, category, district, address, source, and homepage link when those fields are present. The tourism places request also receives an abort signal so a stalled `/api/tourism/places` response cannot leave the UI in an endless loading state. + +## Architecture Boundary Gate + +- Responsibility map: `tourismClient` owns the Worker consumer request; `useAppCoordinatorEffects` owns UI request lifecycle and timeout; `TourismInfoSheet` owns presentation of already-normalized `TourismPlaceItem` data. +- Dependency direction: UI effect -> API client -> `fetchJson`; presentation component -> `TourismPlaceItem`; no browser code calls KTO/OpenAPI, Supabase, or admin import APIs directly. +- Test seam: unit tests assert rendered KTO fields at component boundary and `RequestInit.signal` forwarding at API client boundary. +- Scope map: frontend data consumption and timeout guard only. API path, response shape, DB schema, OAuth flow, and curated place review/stamp flow are unchanged. +- Architecture risk: adding UI fields can accidentally turn KTO information pins into curated place actions. The component continues to render information-only content without stamp/review/feed actions. + +## Validation + +- `npm.cmd run check:numeric-literals` passed. +- `npm.cmd run lint` passed. +- `npm.cmd run typecheck` passed. +- `npm.cmd run test:unit -- tourism-info-sheet tourism-client` passed. +- `npm.cmd run test:unit` passed. +- `npm.cmd run test:integration` passed. +- `npm.cmd run test:regression` passed. +- `npm.cmd run test:e2e` passed. +- `npm.cmd run build` passed. +- `git diff --check` passed. +- UTF-8 strict read passed for all changed source/test files. + +## Remote Evidence + +- PR: TBD +- Main merge SHA: TBD +- CI: TBD +- CodeQL / code scanning: TBD diff --git a/src/api/tourismClient.ts b/src/api/tourismClient.ts index d72a82e..6fd683b 100644 --- a/src/api/tourismClient.ts +++ b/src/api/tourismClient.ts @@ -29,6 +29,6 @@ export function buildTourismPlacesPath(query: TourismPlacesQuery = {}) { return queryString ? `/api/tourism/places?${queryString}` : '/api/tourism/places'; } -export function getTourismPlaces(query: TourismPlacesQuery = {}) { - return fetchJson(buildTourismPlacesPath(query)); +export function getTourismPlaces(query: TourismPlacesQuery = {}, init?: RequestInit) { + return fetchJson(buildTourismPlacesPath(query), init); } diff --git a/src/components/TourismInfoSheet.tsx b/src/components/TourismInfoSheet.tsx index 6389256..d598caf 100644 --- a/src/components/TourismInfoSheet.tsx +++ b/src/components/TourismInfoSheet.tsx @@ -37,6 +37,8 @@ export function TourismInfoSheet({ const drawerState: DrawerState = sheetState === 'full' ? 'full' : 'partial'; const mapSheetState: MapSheetState = sheetState === 'full' ? 'full' : 'peek'; const sheetClassName = buildMapSheetClassName('place-drawer', mapSheetState, drawerState); + const primaryDescription = place.description && place.description !== place.summary ? place.description : null; + const summary = place.summary || primaryDescription || '관광지 기본 정보를 확인할 수 있어요.'; return (
@@ -50,11 +52,17 @@ export function TourismInfoSheet({
+ {place.imageUrl ? ( +
+ {`${place.title} +
+ ) : null} +

KTO INFO

{place.title}

-

{place.summary || place.description || '관광지 기본 정보를 확인할 수 있어요.'}

+

{summary}

+ {primaryDescription ? ( +
+ 소개 +

{primaryDescription}

+
+ ) : null}
- 주소 + 위치

{place.address || '주소 정보가 아직 제공되지 않았어요.'}

diff --git a/src/config/runtimeLimitConfig.ts b/src/config/runtimeLimitConfig.ts index 3d1befd..6f1ae25 100644 --- a/src/config/runtimeLimitConfig.ts +++ b/src/config/runtimeLimitConfig.ts @@ -54,3 +54,7 @@ export class FeedbackRuntimeConfig { export class PaginationRuntimeConfig { static readonly pageSize = 10; } + +export class TourismRuntimeConfig { + static readonly placesRequestTimeoutMs = 12000; +} diff --git a/src/hooks/app-coordinator/useAppCoordinatorEffects.ts b/src/hooks/app-coordinator/useAppCoordinatorEffects.ts index 44d1ed1..4431bdc 100644 --- a/src/hooks/app-coordinator/useAppCoordinatorEffects.ts +++ b/src/hooks/app-coordinator/useAppCoordinatorEffects.ts @@ -1,6 +1,6 @@ import { useEffect } from 'react'; import { getTourismPlaces } from '../../api/tourismClient'; -import { FeedbackRuntimeConfig } from '../../config/runtimeLimitConfig'; +import { FeedbackRuntimeConfig, TourismRuntimeConfig } from '../../config/runtimeLimitConfig'; import { getInitialNotice } from '../app-route/useAppRouteState'; import { useAppFeedbackEffects } from '../useAppFeedbackEffects'; import { useAppBootstrapLifecycle } from '../app-bootstrap/useAppBootstrapLifecycle'; @@ -96,10 +96,14 @@ export function useAppCoordinatorEffects({ } let isActive = true; + const controller = new AbortController(); + const timeoutId = window.setTimeout(() => { + controller.abort(); + }, TourismRuntimeConfig.placesRequestTimeoutMs); setTourismLoading(true); setTourismError(null); - getTourismPlaces() + getTourismPlaces({}, { signal: controller.signal }) .then((response) => { if (!isActive) { return; @@ -111,9 +115,10 @@ export function useAppCoordinatorEffects({ if (!isActive) { return; } - setTourismError(formatErrorMessage(error)); + setTourismError(formatTourismErrorMessage(error)); }) .finally(() => { + window.clearTimeout(timeoutId); if (isActive) { setTourismLoading(false); } @@ -121,6 +126,8 @@ export function useAppCoordinatorEffects({ return () => { isActive = false; + window.clearTimeout(timeoutId); + controller.abort(); }; }, [ setSelectedTourismPlaceId, @@ -160,6 +167,13 @@ export function useAppCoordinatorEffects({ }); } +function formatTourismErrorMessage(error: unknown) { + if (error instanceof DOMException && error.name === 'AbortError') { + return '관광정보 응답이 지연되고 있어요. 잠시 후 다시 켜 주세요.'; + } + return formatErrorMessage(error); +} + function formatErrorMessage(error: unknown) { if (error instanceof Error) { return error.message; diff --git a/src/index.css b/src/index.css index 27b252a..6d64d67 100644 --- a/src/index.css +++ b/src/index.css @@ -336,6 +336,20 @@ textarea { white-space: nowrap; } +.tourism-info-sheet__media { + overflow: hidden; + margin: 0 0 14px; + border-radius: 22px; + border: 1px solid rgba(255, 197, 217, 0.42); + background: rgba(255, 240, 246, 0.72); +} + +.tourism-info-sheet__media img { + width: 100%; + aspect-ratio: 16 / 9; + object-fit: cover; +} + .app-shell__content-slot { position: absolute; inset: 0; diff --git a/test/unit/tourism-client.test.ts b/test/unit/tourism-client.test.ts index e336fec..dbc4535 100644 --- a/test/unit/tourism-client.test.ts +++ b/test/unit/tourism-client.test.ts @@ -1,9 +1,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { invalidateApiCache } from '../../src/api/core'; import { getTourismPlaces } from '../../src/api/tourismClient'; const fetchMock = vi.fn(); beforeEach(() => { + invalidateApiCache(); fetchMock.mockReset(); vi.stubGlobal('fetch', fetchMock); fetchMock.mockResolvedValue({ @@ -37,4 +39,14 @@ describe('tourismClient', () => { expect(String(fetchMock.mock.calls[0][0])).toMatch(/\/api\/tourism\/places$/); }); + + it('passes request init to the Worker tourism consumer request', async () => { + const controller = new AbortController(); + + await getTourismPlaces({}, { signal: controller.signal }); + + expect(fetchMock.mock.calls[0][1]).toMatchObject({ + signal: controller.signal, + }); + }); }); diff --git a/test/unit/tourism-info-sheet.test.tsx b/test/unit/tourism-info-sheet.test.tsx new file mode 100644 index 0000000..6893178 --- /dev/null +++ b/test/unit/tourism-info-sheet.test.tsx @@ -0,0 +1,48 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { TourismInfoSheet } from '../../src/components/TourismInfoSheet'; +import type { TourismPlaceItem } from '../../src/tourismTypes'; + +const tourismPlace: TourismPlaceItem = { + id: 'tourism-1', + title: '대전근현대사전시관', + category: '문화관광', + district: '중구', + address: '대전 중구 중앙로 101', + summary: '옛 충남도청 건물을 활용한 전시 공간입니다.', + description: '근현대 대전의 전시 변화를 전시와 사진 자료로 볼 수 있습니다.', + latitude: 36.327, + longitude: 127.421, + imageUrl: 'https://example.com/tourism.jpg', + homepageUrl: 'https://example.com/tourism', + sourceName: 'KTO', + isCurated: false, + curatedPlace: null, +}; + +describe('TourismInfoSheet', () => { + it('renders the available KTO tourism data instead of only title and address', () => { + render( + , + ); + + expect(screen.getByRole('img', { name: '대전근현대사전시관 관광정보 이미지' })).toHaveAttribute( + 'src', + tourismPlace.imageUrl, + ); + expect(screen.getByText('옛 충남도청 건물을 활용한 전시 공간입니다.')).toBeInTheDocument(); + expect(screen.getByText('근현대 대전의 전시 변화를 전시와 사진 자료로 볼 수 있습니다.')).toBeInTheDocument(); + expect(screen.getByText('문화관광')).toBeInTheDocument(); + expect(screen.getByText('중구')).toBeInTheDocument(); + expect(screen.getByText('대전 중구 중앙로 101')).toBeInTheDocument(); + expect(screen.getByText('KTO')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: '자세히 보기' })).toHaveAttribute('href', tourismPlace.homepageUrl); + }); +});