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
42 changes: 42 additions & 0 deletions reports/completion/TSK-013-01-tourism-info-data-consumption.md
Original file line number Diff line number Diff line change
@@ -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
4 changes: 2 additions & 2 deletions src/api/tourismClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TourismPlacesResponse>(buildTourismPlacesPath(query));
export function getTourismPlaces(query: TourismPlacesQuery = {}, init?: RequestInit) {
return fetchJson<TourismPlacesResponse>(buildTourismPlacesPath(query), init);
}
18 changes: 16 additions & 2 deletions src/components/TourismInfoSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<section className={sheetClassName} data-map-sheet-state={mapSheetState} aria-label="관광정보 시트">
Expand All @@ -50,11 +52,17 @@ export function TourismInfoSheet({
</button>

<div className="place-drawer__content">
{place.imageUrl ? (
<figure className="tourism-info-sheet__media">
<img src={place.imageUrl} alt={`${place.title} 관광정보 이미지`} loading="lazy" />
</figure>
) : null}

<div className="place-drawer__header">
<div>
<p className="eyebrow">KTO INFO</p>
<h2>{place.title}</h2>
<p className="place-drawer__summary">{place.summary || place.description || '관광지 기본 정보를 확인할 수 있어요.'}</p>
<p className="place-drawer__summary">{summary}</p>
</div>
<button type="button" className="text-button" onClick={onClose}>
닫기
Expand All @@ -67,8 +75,14 @@ export function TourismInfoSheet({
</div>

<div className="sheet-card stack-gap">
{primaryDescription ? (
<div>
<strong>소개</strong>
<p>{primaryDescription}</p>
</div>
) : null}
<div>
<strong>주소</strong>
<strong>위치</strong>
<p>{place.address || '주소 정보가 아직 제공되지 않았어요.'}</p>
</div>
<div>
Expand Down
4 changes: 4 additions & 0 deletions src/config/runtimeLimitConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,7 @@ export class FeedbackRuntimeConfig {
export class PaginationRuntimeConfig {
static readonly pageSize = 10;
}

export class TourismRuntimeConfig {
static readonly placesRequestTimeoutMs = 12000;
}
20 changes: 17 additions & 3 deletions src/hooks/app-coordinator/useAppCoordinatorEffects.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;
Expand All @@ -111,16 +115,19 @@ export function useAppCoordinatorEffects({
if (!isActive) {
return;
}
setTourismError(formatErrorMessage(error));
setTourismError(formatTourismErrorMessage(error));
})
.finally(() => {
window.clearTimeout(timeoutId);
if (isActive) {
setTourismLoading(false);
}
});

return () => {
isActive = false;
window.clearTimeout(timeoutId);
controller.abort();
};
}, [
setSelectedTourismPlaceId,
Expand Down Expand Up @@ -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;
Expand Down
14 changes: 14 additions & 0 deletions src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
12 changes: 12 additions & 0 deletions test/unit/tourism-client.test.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand Down Expand Up @@ -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,
});
});
});
48 changes: 48 additions & 0 deletions test/unit/tourism-info-sheet.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<TourismInfoSheet
place={tourismPlace}
isOpen
sheetState="partial"
onClose={vi.fn()}
onExpand={vi.fn()}
onCollapse={vi.fn()}
/>,
);

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);
});
});
Loading