diff --git a/reports/completion/TSK-014-01-mobile-map-location-sheet-fix.md b/reports/completion/TSK-014-01-mobile-map-location-sheet-fix.md new file mode 100644 index 0000000..ec7655f --- /dev/null +++ b/reports/completion/TSK-014-01-mobile-map-location-sheet-fix.md @@ -0,0 +1,42 @@ +# TSK-014-01 Mobile map location and tourism sheet layout fix + +## Metadata + +- Scope-ID: `TSK-014-01` +- Issue: `#426` +- Parent Issue: `#425` +- Branch: `mobile-map-location-sheet-fix` +- Status: ready for PR + +## Summary + +모바일 지도 화면의 내 위치 찾기와 KTO 관광정보 바텀시트 UX 회귀를 복구했다. 위치 확인 메시지가 지도 상태 컴포넌트까지 전달되도록 고쳤고, `watchPosition` 콜백이 동기 실행될 때 `watchId` 초기화 전에 접근하던 결함을 제거했다. KTO 정보 시트는 공통 `MapBottomSheet` wrapper로 조립하고 기본 full 상태로 열어 이미지 이후 본문, 위치, 출처, 상세 링크까지 접근 가능하게 했다. + +## Architecture Boundary Gate + +- Responsibility map: `geolocation.ts`는 브라우저 위치 API를 promise 경계로 변환하고, `NaverMapStatus`는 지도 위치 버튼과 피드백 표시를 담당하며, `MapBottomSheet`는 지도 바텀시트 공통 shell을 담당한다. +- Dependency direction: map stage -> Naver map/status -> geolocation action 흐름을 유지한다. KTO 시트는 `TourismPlaceItem` presentation만 담당하고 KTO/OpenAPI 또는 Worker를 직접 호출하지 않는다. +- Test seam: geolocation unit test는 browser API mock으로 성공/권한 거부를 검증하고, NaverMapStatus unit test는 버튼/메시지 public UI를 검증하며, TourismInfoSheet unit test는 공통 bottom sheet shell과 full 상태를 검증한다. +- Scope map: frontend map UX 복구만 변경했다. API path, response shape, DB schema, OAuth flow, Worker/Admin KTO import flow는 변경하지 않았다. +- Architecture risk: full sheet와 bottom nav z-index가 겹치는 구조가 남아 있어 full sheet content padding으로 접근성을 보강했다. bottom tab hide 정책은 별도 앱 셸 상태 설계가 필요하면 후속 이슈로 분리한다. + +## Validation + +- `npm.cmd run typecheck` passed. +- `npm.cmd run test:unit -- geolocation naver-map-status tourism-info-sheet map-config` passed. +- `npm.cmd run check:numeric-literals` passed. +- `npm.cmd run lint` 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 changed source/test files. + +## Remote Evidence + +- PR: TBD +- Main merge SHA: TBD +- CI: TBD +- production-smoke: TBD +- CodeQL / Code Quality: TBD diff --git a/src/components/AppMapStageView.tsx b/src/components/AppMapStageView.tsx index ad0f947..109d804 100644 --- a/src/components/AppMapStageView.tsx +++ b/src/components/AppMapStageView.tsx @@ -1,10 +1,18 @@ +/* + * File: AppMapStageView.tsx + * Purpose: Compose the map tab stage from coordinator-provided map data and actions. + * Primary Responsibility: Adapt app-level map props into the MapTabStage contract. + * Design Intent: Keep the app shell coordinator separate from map-stage presentation wiring. + * Non-Goals: This component does not fetch map data, own Naver SDK state, or implement sheet internals. + * Dependencies: React memo, MapTabStage, map category strip, and app domain DTOs. + */ import { memo } from 'react'; -import { MapTabStage } from './MapTabStage'; -import { MapStageCategoryStrip } from './map-stage/MapStageCategoryStrip'; import type { TourismPlaceItem } from '../tourismTypes'; -import type { ApiStatus, Category, DrawerState, FestivalItem, Place, ReviewMood, RoutePreview } from '../types/core'; import type { SessionUser } from '../types/auth'; +import type { ApiStatus, Category, DrawerState, FestivalItem, Place, ReviewMood, RoutePreview } from '../types/core'; import type { BootstrapResponse } from '../types/review'; +import { MapTabStage } from './MapTabStage'; +import { MapStageCategoryStrip } from './map-stage/MapStageCategoryStrip'; interface AppMapStageViewProps { mapData: { @@ -16,6 +24,7 @@ interface AppMapStageViewProps { selectedTourismPlace: TourismPlaceItem | null; currentPosition: { latitude: number; longitude: number } | null; mapLocationStatus: ApiStatus; + mapLocationMessage: string | null; mapLocationFocusKey: number; drawerState: DrawerState; sessionUser: SessionUser | null; @@ -101,6 +110,7 @@ export const AppMapStageView = memo(function AppMapStageView({ tourismPlaces: mapData.showTourismInfo ? mapData.tourismPlaces : [], currentPosition: mapData.currentPosition, mapLocationStatus: mapData.mapLocationStatus, + mapLocationMessage: mapData.mapLocationMessage, mapLocationFocusKey: mapData.mapLocationFocusKey, routePreviewPlaces: mapData.routePreviewPlaces, }} diff --git a/src/components/TourismInfoSheet.tsx b/src/components/TourismInfoSheet.tsx index 3020c9b..d70a8f5 100644 --- a/src/components/TourismInfoSheet.tsx +++ b/src/components/TourismInfoSheet.tsx @@ -2,13 +2,13 @@ * File: TourismInfoSheet.tsx * Purpose: Present non-curated KTO tourism place information from the map layer. * Primary Responsibility: Render a read-only information sheet without stamp, review, or feed actions. - * Design Intent: Reuse the existing map sheet visual language while keeping tourism info separate from curated place interactions. + * Design Intent: Reuse the shared map bottom-sheet shell while keeping tourism info separate from curated place interactions. * Non-Goals: This component does not allow stamping, review creation, or direct KTO/OpenAPI calls. - * Dependencies: TourismPlaceItem DTO and map sheet state class helper. + * Dependencies: TourismPlaceItem DTO and MapBottomSheet. */ import type { TourismPlaceItem } from '../tourismTypes'; import type { DrawerState } from '../types/core'; -import { buildMapSheetClassName } from './map-stage/mapSheetState'; +import { MapBottomSheet } from './map-stage/MapBottomSheet'; import type { MapSheetState } from './map-stage/mapSheetState'; export type TourismInfoSheetState = 'partial' | 'full'; @@ -52,7 +52,6 @@ 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 title = getTourismPlaceTitle(place); const address = getTourismPlaceAddress(place); const categoryLabel = getTourismPlaceCategoryLabel(place); @@ -61,61 +60,56 @@ export function TourismInfoSheet({ const summary = place.summary || primaryDescription || '관광지 기본 정보를 확인할 수 있어요.'; return ( -
- + + {place.imageUrl ? ( +
+ {`${title} +
+ ) : null} -
- {place.imageUrl ? ( -
- {`${title} -
- ) : null} - -
-
-

KTO INFO

-

{title}

-

{summary}

-
- +
+
+

KTO INFO

+

{title}

+

{summary}

+ +
-
- {categoryLabel ? {categoryLabel} : null} - {place.district ? {place.district} : null} -
+
+ {categoryLabel ? {categoryLabel} : null} + {place.district ? {place.district} : null} +
-
- {primaryDescription ? ( -
- 소개 -

{primaryDescription}

-
- ) : null} -
- 위치 -

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

-
+
+ {primaryDescription ? (
- 출처 -

{place.sourceName || 'KTO 관광정보'}

+ 소개 +

{primaryDescription}

- {sourceUrl ? ( - - 자세히 보기 - - ) : null} + ) : null} +
+ 위치 +

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

+
+
+ 출처 +

{place.sourceName || 'KTO 관광정보'}

+ {sourceUrl ? ( + + 자세히 보기 + + ) : null}
-
+ ); } diff --git a/src/components/map-stage/MapBottomSheet.tsx b/src/components/map-stage/MapBottomSheet.tsx new file mode 100644 index 0000000..428334b --- /dev/null +++ b/src/components/map-stage/MapBottomSheet.tsx @@ -0,0 +1,47 @@ +/* + * File: MapBottomSheet.tsx + * Purpose: Provide the shared map bottom-sheet shell used by map detail surfaces. + * Primary Responsibility: Own the common section, drag handle, state classes, and scrollable content slot. + * Design Intent: Keep place, festival, and tourism sheets visually consistent while preserving domain-specific body content. + * Non-Goals: This component does not fetch data, own selection state, or render domain-specific copy/actions. + * Dependencies: React children, DrawerState, and map sheet state class helper. + */ +import type { ReactNode } from 'react'; +import type { DrawerState } from '../../types/core'; +import { buildMapSheetClassName, type MapSheetState } from './mapSheetState'; + +interface MapBottomSheetProps { + ariaLabel: string; + children: ReactNode; + drawerState: DrawerState; + sheetState: MapSheetState; + onCollapse: () => void; + onExpand: () => void; +} + +export function MapBottomSheet({ + ariaLabel, + children, + drawerState, + sheetState, + onCollapse, + onExpand, +}: MapBottomSheetProps) { + const sheetClassName = buildMapSheetClassName('place-drawer', sheetState, drawerState); + + return ( +
+ +
+ {children} +
+
+ ); +} diff --git a/src/components/map-stage/MapStageMapSurface.tsx b/src/components/map-stage/MapStageMapSurface.tsx index 265b25c..b36e858 100644 --- a/src/components/map-stage/MapStageMapSurface.tsx +++ b/src/components/map-stage/MapStageMapSurface.tsx @@ -38,7 +38,7 @@ export function MapStageMapSurface({ onSelectTourismPlace={tourismActions.onOpenTourismPlace} currentPosition={mapData.currentPosition} currentLocationStatus={mapData.mapLocationStatus} - currentLocationMessage={null} + currentLocationMessage={mapData.mapLocationMessage} focusCurrentLocationKey={mapData.mapLocationFocusKey} onLocateCurrentPosition={viewportData.onLocateCurrentPosition} initialCenter={viewportData.initialMapCenter} diff --git a/src/components/map-stage/mapTabStageTypes.ts b/src/components/map-stage/mapTabStageTypes.ts index d214fea..5e9a54e 100644 --- a/src/components/map-stage/mapTabStageTypes.ts +++ b/src/components/map-stage/mapTabStageTypes.ts @@ -10,6 +10,7 @@ export interface MapTabStageProps { tourismPlaces: TourismPlaceItem[]; currentPosition: { latitude: number; longitude: number } | null; mapLocationStatus: ApiStatus; + mapLocationMessage: string | null; mapLocationFocusKey: number; routePreviewPlaces: Place[]; }; diff --git a/src/components/naver-map/NaverMapStatus.tsx b/src/components/naver-map/NaverMapStatus.tsx index 52b4366..7cf3df3 100644 --- a/src/components/naver-map/NaverMapStatus.tsx +++ b/src/components/naver-map/NaverMapStatus.tsx @@ -1,3 +1,11 @@ +/* + * File: NaverMapStatus.tsx + * Purpose: Render map loading, SDK error, and current-location action feedback. + * Primary Responsibility: Own user-visible map status controls around the Naver map surface. + * Design Intent: Keep location feedback visible at the map UI boundary instead of hiding it in app-level banners only. + * Non-Goals: This component does not request geolocation or mutate map viewport state directly. + * Dependencies: ApiStatus and callbacks supplied by the map stage. + */ import type { ApiStatus } from '../../types/core'; type NaverMapStatusProps = { @@ -32,7 +40,7 @@ export function NaverMapStatus({ <> {status === 'loading' && (
- 대전 지도를 준비하고 있어요 + 대전 지도를 준비하고 있어요.

잠시만 기다리면 지도와 마커를 바로 보여드릴게요.

)} diff --git a/src/hooks/app-stage-props/useMapStageProps.ts b/src/hooks/app-stage-props/useMapStageProps.ts index ef9e58f..a9fe1ac 100644 --- a/src/hooks/app-stage-props/useMapStageProps.ts +++ b/src/hooks/app-stage-props/useMapStageProps.ts @@ -10,6 +10,7 @@ export function useMapStageProps(state: AppShellCoordinatorState) { festivals, initialMapViewport, mapLocationFocusKey, + mapLocationMessage, mapLocationStatus, mapStageActions, reviewActions, @@ -45,6 +46,7 @@ export function useMapStageProps(state: AppShellCoordinatorState) { selectedTourismPlace, currentPosition, mapLocationStatus, + mapLocationMessage, mapLocationFocusKey, drawerState: state.drawerState, sessionUser, @@ -79,7 +81,7 @@ export function useMapStageProps(state: AppShellCoordinatorState) { return; } setSelectedTourismPlaceId(tourismPlaceId); - setTourismSheetState('partial'); + setTourismSheetState('full'); }, onCloseTourismInfoSheet: () => setSelectedTourismPlaceId(null), onExpandTourismInfoSheet: () => setTourismSheetState('full'), diff --git a/src/index.css b/src/index.css index 6d64d67..6b861dd 100644 --- a/src/index.css +++ b/src/index.css @@ -1112,6 +1112,10 @@ textarea { scrollbar-width: none; } +.place-drawer--full .place-drawer__content { + padding-bottom: calc(var(--bottom-nav-offset) + 18px); +} + .place-drawer__content::-webkit-scrollbar { width: 0; height: 0; diff --git a/src/lib/geolocation.ts b/src/lib/geolocation.ts index 1e2ec0f..a6cbce3 100644 --- a/src/lib/geolocation.ts +++ b/src/lib/geolocation.ts @@ -1,3 +1,11 @@ +/* + * File: geolocation.ts + * Purpose: Resolve the browser's current device position for map and stamp flows. + * Primary Responsibility: Convert browser geolocation callbacks into validated Daejeon-area coordinates. + * Design Intent: Keep browser API quirks and user-readable failure messages behind one promise-based boundary. + * Non-Goals: This module does not move the map, claim stamps, or render UI feedback. + * Dependencies: GeolocationConfig and distance helpers. + */ import { GeolocationConfig } from '../config/mapConfig'; import { calculateDistanceMeters, formatDistanceMeters } from './visits'; @@ -42,72 +50,79 @@ export function getCurrentDevicePosition() { let bestPosition: GeolocationPosition | null = null; let finished = false; let timeoutId = 0; + let watchId: number | null = null; - const cleanup = (watchId: number) => { - navigator.geolocation.clearWatch(watchId); + const cleanup = () => { + if (watchId !== null) { + navigator.geolocation.clearWatch(watchId); + } window.clearTimeout(timeoutId); finished = true; }; - const finishWithError = (watchId: number, error: Error) => { + const finishWithError = (error: Error) => { if (finished) { return; } - cleanup(watchId); + cleanup(); reject(error); }; - const finishWithBestPosition = (watchId: number) => { + const finishWithBestPosition = () => { if (finished) { return; } if (!bestPosition) { - cleanup(watchId); + cleanup(); reject(new Error('현재 위치를 확인하지 못했어요.')); return; } try { const nextPosition = validateCurrentDevicePosition(bestPosition); - cleanup(watchId); + cleanup(); resolve(nextPosition); } catch (error) { - cleanup(watchId); + cleanup(); reject(error instanceof Error ? error : new Error('현재 위치를 확인하지 못했어요.')); } }; - const watchId = navigator.geolocation.watchPosition( + watchId = navigator.geolocation.watchPosition( (position) => { if (!bestPosition || position.coords.accuracy < bestPosition.coords.accuracy) { bestPosition = position; } if (position.coords.accuracy <= GeolocationConfig.earlySuccessAccuracyMeters) { - finishWithBestPosition(watchId); + finishWithBestPosition(); } }, (error) => { if (error.code === error.PERMISSION_DENIED) { - finishWithError(watchId, new Error('브라우저 위치 권한이 꺼져 있어요. 위치 권한을 허용해 주세요.')); + finishWithError(new Error('브라우저 위치 권한이 꺼져 있어요. 위치 권한을 허용해 주세요.')); return; } if (error.code === error.POSITION_UNAVAILABLE) { - finishWithError(watchId, new Error('현재 위치를 찾지 못했어요. GPS가 잘 잡히는 곳에서 다시 시도해 주세요.')); + finishWithError(new Error('현재 위치를 찾지 못했어요. GPS가 잘 잡히는 곳에서 다시 시도해 주세요.')); return; } if (error.code === error.TIMEOUT) { - finishWithError(watchId, new Error('위치 확인 시간이 초과됐어요. 다시 시도해 주세요.')); + finishWithError(new Error('위치 확인 시간이 초과됐어요. 다시 시도해 주세요.')); return; } - finishWithError(watchId, new Error('현재 위치를 확인하지 못했어요.')); + finishWithError(new Error('현재 위치를 확인하지 못했어요.')); }, GeolocationConfig.watchOptions, ); + if (finished && watchId !== null) { + navigator.geolocation.clearWatch(watchId); + return; + } timeoutId = window.setTimeout(() => { - finishWithBestPosition(watchId); + finishWithBestPosition(); }, GeolocationConfig.settleTimeoutMs); }); } diff --git a/test/unit/geolocation.test.ts b/test/unit/geolocation.test.ts new file mode 100644 index 0000000..6d46ff3 --- /dev/null +++ b/test/unit/geolocation.test.ts @@ -0,0 +1,70 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { getCurrentDevicePosition } from '../../src/lib/geolocation'; + +const originalGeolocation = navigator.geolocation; + +afterEach(() => { + vi.useRealTimers(); + Object.defineProperty(navigator, 'geolocation', { + configurable: true, + value: originalGeolocation, + }); +}); + +function installGeolocationMock(mock: Partial) { + Object.defineProperty(navigator, 'geolocation', { + configurable: true, + value: mock, + }); +} + +function createPosition(latitude: number, longitude: number, accuracy: number): GeolocationPosition { + return { + coords: { + latitude, + longitude, + accuracy, + altitude: null, + altitudeAccuracy: null, + heading: null, + speed: null, + }, + timestamp: Date.now(), + }; +} + +describe('getCurrentDevicePosition', () => { + it('resolves a readable Daejeon position when the browser reports accurate coordinates', async () => { + const clearWatch = vi.fn(); + const watchPosition = vi.fn((success) => { + success(createPosition(36.3504, 127.3845, 30)); + return 7; + }); + installGeolocationMock({ clearWatch, watchPosition }); + + await expect(getCurrentDevicePosition()).resolves.toEqual({ + latitude: 36.3504, + longitude: 127.3845, + accuracyMeters: 30, + }); + expect(clearWatch).toHaveBeenCalledWith(7); + }); + + it('rejects with readable guidance when location permission is denied', async () => { + const clearWatch = vi.fn(); + const watchPosition = vi.fn((_success, error) => { + error?.({ + code: 1, + message: 'denied', + PERMISSION_DENIED: 1, + POSITION_UNAVAILABLE: 2, + TIMEOUT: 3, + }); + return 9; + }); + installGeolocationMock({ clearWatch, watchPosition }); + + await expect(getCurrentDevicePosition()).rejects.toThrow('브라우저 위치 권한이 꺼져 있어요.'); + expect(clearWatch).toHaveBeenCalledWith(9); + }); +}); diff --git a/test/unit/naver-map-status.test.tsx b/test/unit/naver-map-status.test.tsx new file mode 100644 index 0000000..25c3e4e --- /dev/null +++ b/test/unit/naver-map-status.test.tsx @@ -0,0 +1,42 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { NaverMapStatus } from '../../src/components/naver-map/NaverMapStatus'; + +describe('NaverMapStatus', () => { + it('shows readable current-location feedback and calls the locate action', () => { + const onLocateCurrentPosition = vi.fn(); + + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '내 위치 찾기' })); + + expect(onLocateCurrentPosition).toHaveBeenCalledTimes(1); + expect(screen.getByText('현재 위치를 확인했어요.')).toBeInTheDocument(); + }); + + it('keeps the locate button disabled while a position request is pending', () => { + render( + , + ); + + expect(screen.getByRole('button', { name: '확인 중' })).toBeDisabled(); + }); +}); diff --git a/test/unit/tourism-info-sheet.test.tsx b/test/unit/tourism-info-sheet.test.tsx index d8047af..0cc77cb 100644 --- a/test/unit/tourism-info-sheet.test.tsx +++ b/test/unit/tourism-info-sheet.test.tsx @@ -51,4 +51,23 @@ describe('TourismInfoSheet', () => { expect(screen.getByText('KTO TourAPI Daejeon Tourism')).toBeInTheDocument(); expect(screen.getByRole('link', { name: '자세히 보기' })).toHaveAttribute('href', tourismPlace.sourcePageUrl); }); + + it('uses the shared map bottom sheet shell and supports full-height scrolling', () => { + render( + , + ); + + const sheet = screen.getByRole('region', { name: '관광정보 시트' }); + const content = sheet.querySelector('.map-bottom-sheet__content'); + + expect(sheet).toHaveClass('place-drawer', 'place-drawer--full', 'place-drawer--route-full'); + expect(content).not.toBeNull(); + }); });