diff --git a/apps/mobile/src/features/map-home/api/active-missions-query.test.ts b/apps/mobile/src/features/map-home/api/active-missions-query.test.ts index c38d6e3..2ed4d3b 100644 --- a/apps/mobile/src/features/map-home/api/active-missions-query.test.ts +++ b/apps/mobile/src/features/map-home/api/active-missions-query.test.ts @@ -27,7 +27,7 @@ const SEOMYEON: Bounds = { sw: { lat: 35.15, lng: 129.05 }, ne: { lat: 35.17, lng: 129.07 }, }; -/** 부산 전역 — 한 변 0.5° 초과 (조회 비활성, 서버 400/12401 구간) */ +/** 부산 전역 — 한 변 0.5° 초과 (중심 0.5°로 잘라 조회 — MSG-581) */ const WIDE: Bounds = { sw: { lat: 35.0, lng: 128.8 }, ne: { lat: 35.4, lng: 129.4 }, @@ -55,9 +55,18 @@ describe("activeMissionsQueryOptions — 직전 데이터 유지", () => { expect(options.placeholderData(previous, undefined)).toBeUndefined(); }); - it("bbox가 0.5°를 넘어 조회가 비활성이면 같은 칩이라도 비운다 — 비활성 쿼리는 응답이 없어 placeholder가 영원히 남는다", () => { + it("bbox가 0.5°를 넘어도 같은 칩이면 유지한다 — 광역 줌은 잘라서 조회하므로 비활성이 아니다 (MSG-581)", () => { const options = activeMissionsQueryOptions("route", WIDE, true); + expect(options.enabled).toBe(true); + expect( + options.placeholderData(previous, previousQueryWithType("COURSE")), + ).toBe(previous); + }); + + it("뷰포트가 미확정(null)이면 같은 칩이라도 비운다 — 비활성 쿼리는 응답이 없어 placeholder가 영원히 남는다", () => { + const options = activeMissionsQueryOptions("route", null, true); + expect(options.enabled).toBe(false); expect( options.placeholderData(previous, previousQueryWithType("COURSE")), diff --git a/apps/mobile/src/features/map-home/model/mission-query-args.test.ts b/apps/mobile/src/features/map-home/model/mission-query-args.test.ts index 35ac03a..7ee8d49 100644 --- a/apps/mobile/src/features/map-home/model/mission-query-args.test.ts +++ b/apps/mobile/src/features/map-home/model/mission-query-args.test.ts @@ -14,7 +14,7 @@ const VIEWPORT: Bounds = { sw: { lat: 35.15, lng: 129.05 }, ne: { lat: 35.165, lng: 129.07 }, }; -/** 명세 상한(한 변 0.5도)을 넘긴 뷰포트 — 조회하지 않는다 */ +/** 명세 상한(한 변 0.5도)을 넘긴 뷰포트 — 중심 기준 0.5°로 잘라 조회한다 (MSG-581) */ const TOO_WIDE: Bounds = { sw: { lat: 35, lng: 128 }, ne: { lat: 36, lng: 129 }, @@ -38,10 +38,22 @@ describe("활성 미션 조회 인자 (D1)", () => { ); }); - it("칩이 없거나 뷰포트가 미확정·상한 초과면 조회하지 않는다", () => { + it("칩이 없거나 뷰포트가 미확정이면 조회하지 않는다", () => { expect(activeMissionsQueryArgs(null, VIEWPORT).enabled).toBe(false); expect(activeMissionsQueryArgs("festival", null).enabled).toBe(false); - expect(activeMissionsQueryArgs("festival", TOO_WIDE).enabled).toBe(false); + }); + + it("상한 초과 뷰포트는 중심 기준 0.5°×0.5°로 잘라 조회한다 — 광역 줌에서도 목록이 비지 않는다 (MSG-581)", () => { + expect(activeMissionsQueryArgs("festival", TOO_WIDE)).toEqual({ + query: { + type: "EVENT", + swLat: 35.25, + swLng: 128.25, + neLat: 35.75, + neLng: 128.75, + }, + enabled: true, + }); }); }); diff --git a/apps/mobile/src/features/map-home/model/mission-query-args.ts b/apps/mobile/src/features/map-home/model/mission-query-args.ts index 17e17b3..2000aa5 100644 --- a/apps/mobile/src/features/map-home/model/mission-query-args.ts +++ b/apps/mobile/src/features/map-home/model/mission-query-args.ts @@ -1,6 +1,7 @@ import type { Bounds } from "../../../entities/cell/model/grid"; +import { clampBoundsToSpan } from "./aggregation-unit"; import { missionTypeParam, type MissionChip } from "./mission"; -import { viewportQueryArgs } from "./viewport-query"; +import { MAX_VIEWPORT_SPAN_DEG, toGridsRequest } from "./viewport-query"; /** * 미션 조회 요청 인자·게이트 (MSG-427 D1·D2) — 순수 함수. @@ -13,22 +14,25 @@ import { viewportQueryArgs } from "./viewport-query"; /** * 활성 미션 조회 인자 — `GET /api/missions/active`. [D1] * `type`·bbox 4개가 **전부 필수**라 칩이 없거나 뷰포트가 미확정이면 발사하지 않는다. - * 비활성 쿼리는 나가지 않지만 생성 옵션 타입이 값을 요구해 EVENT로 채운다 - * (`viewportQueryArgs`가 bbox를 0으로 채우는 것과 같은 관례). + * 비활성 쿼리는 나가지 않지만 생성 옵션 타입이 값을 요구해 EVENT·0으로 채운다. + * + * bbox 한 변 상한(0.5°)을 넘는 광역 줌은 `viewportQueryArgs`처럼 조회를 닫지 않고 + * 집계 조회(`grid-aggregation-query`)와 같이 **중심 기준 0.5°로 잘라** 보낸다 (MSG-581) — + * 닫으면 목록·경로선이 사라져 집계 마커 수와 모순된다. 목록은 중앙 0.5° 창이라 마커 + * 총계와 수가 다를 수 있다(허용). */ export const activeMissionsQueryArgs = ( chip: MissionChip | null, bounds: Bounds | null, -) => { - const { query, enabled } = viewportQueryArgs(bounds); - return { - query: { - type: chip === null ? "EVENT" : missionTypeParam(chip), - ...query, - }, - enabled: enabled && chip !== null, - }; -}; +) => ({ + query: { + type: chip === null ? "EVENT" : missionTypeParam(chip), + ...(bounds + ? toGridsRequest(clampBoundsToSpan(bounds, MAX_VIEWPORT_SPAN_DEG)) + : { swLat: 0, swLng: 0, neLat: 0, neLng: 0 }), + }, + enabled: bounds !== null && chip !== null, +}); /** * 진행도 일괄 조회 인자 — `GET /api/missions/progress?missionIds=`. [D2] diff --git a/docs/STATUS.md b/docs/STATUS.md index f4532c3..1bcf64a 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -127,6 +127,7 @@ - MSG-574: 지도 줌 하한 `MAP_MIN_ZOOM`을 8km 단(10) → **16km 단(9, `map-scale.MAP_SCALE_16KM_ZOOM` 신설)** 으로 내려 시(SIDO) 집계 마커 구간의 가장 좁은 끝을 연다. 웹처럼 하한을 없애지 않는 이유는 서버 제약 — 집계(`getOccupiedAggregatesWithCurrentRegion`)·역지오코딩이 지도 중심을 한국 범위(위도 33~39)로 검사해 400을 내고, 앱은 시트 뒤 카메라 중심이 보이는 중심보다 약 500px 남쪽(zoom 9 ≈ 1.1°, zoom 8 ≈ 2.2°)이라 32km 단부터 부산 남해안에서 범위를 벗어난다. `GridMap.minZoom` prop·배선 무수정, parity L6 교체. - MSG-579: 활성 미션 조회를 `api/active-missions-query.ts`(옵션 팩토리 — 게이트·요청 인자·**같은 type·활성일 때만 placeholder**) + `use-active-missions-query.ts`(얇은 훅) 2파일로 분리. 비활성(bbox > 0.5°)·칩 전환에는 직전 목록을 비운다 - MSG-580: 경로추천 목록 상태에서 **모든 코스**의 경로선·번호 마커 — `model/route-overlay.ts` `courseRoutesOf`(웹 `buildCourseRoutes` 대응, 클리핑 없음) · `use-home-overlays.ts` `routes: CourseRoute[]`(상세면 그 코스만) · GridMap `routes?` prop(단일 `route`는 AI 추천용으로 존치, 내부에서 합류 렌더) +- MSG-581: 활성 미션 조회 bbox를 `model/mission-query-args.ts`에서 **`clampBoundsToSpan(bounds, 0.5°)`로 잘라 요청**(집계 조회와 같은 패턴) — span 초과 게이트 제거. 광역 줌에서도 목록·경로선이 살아 있고, 목록은 중앙 0.5° 창이라 집계 마커 총계와 수가 다를 수 있음(허용) - MSG-427: 테마 칩 상세 흐름 — 구 MSG-298 테마 피드를 **미션 구조로 전면 교체**하고 `mock-theme-data`를 제거(참조 0건). 칩 4파일(`theme-chip`·`theme-chips-bar`·`theme-chip-view`·`theme-selection`)은 MSG-423 산출물 그대로 존치, 시트 콘텐츠만 갈아끼움. 화면 9종(핫구역 동 요약 / 격자 상세 / 축제·팝업 목록·상세 / 코스 목록·상세·스팟 격자 상세). `entities`에 웹 포팅 3종(`encodeGridId`·`viewportGridRange`·`pointInPolygon`), `model`에 웹 포팅 13종 + 모바일 고유 7종, `api/` 조회 11 + 조립 훅 3, ui 22종. **parity 16종 동반**. 미션 API는 생성 SDK 4종(`missions/active`·`missions/progress`·`missions/{id}`·`missions/{id}/videos`) — 티켓이 적은 플레인 `/missions/`는 생성물에 없음. `map-home-screen.tsx`의 확장점 ②는 `home-sheet-switch.tsx` 한 파일로, ③은 `use-home-overlays.ts`로 분리해 MSG-428이 스위치 파일만 만지면 되게 남김. 삭제 10파일(`theme-sheet`·`themes`·`theme-cells`·`theme-format`·`mock-theme-data` + 테스트, `theme-sheet-content`·`theme-video-card`). `ProgressBar.fillClassName` 비파괴 확장(미지정 시 렌더 불변, `index.ts` diff 0줄 유지). - MSG-430: 도감 개편 2 — MSG-425 셸의 렌더 스위치 **2줄**을 실 본문으로 교체(`BadgeShelf`→`BadgeTabBody`, `DexTabPlaceholder`→`RecordTabBody`)하고 `dex-tab-placeholder`·`badge-shelf`·`mock-badges`·`badge-grid`를 삭제. `entities/badge/` 신설(`badge-art`: `resolveBadgeArt`·`isSvgUri` + `assets/*.ts` 24종 = 웹 SVG를 아트보드 잔재 제거 후 문자열 모듈로 변환, 368→307KB). `features/dex/model/` 포팅 3종+parity(`badge-showcase`·`featured-selection`·`upload-grass`), `features/dex/api/use-badge-mutations.ts`(dex 최초 `api/`), ui 7종(`badge-tab-body`·`badge-medal`·`featured-profile-preview`·`record-tab-body`·`upload-grass-grid`·`upload-history-screen`·`dex-tab-loading`). **`/dex/history` 라우트 신설** — `app/dex.tsx`와 공존(expo-router `getRoutes` 확증). `tailwind-preset/native.ts`에 `2.25`(9px 잔디 셀) 보충. `ui-native/src/index.ts`·`metro.config.js`·`vitest.config.ts` diff 0줄. - MSG-431: 내 영상 관리 — **`features/video-actions` 신설**(모바일 최초). 도감 갤러리 카드와 격자 상세 영상 행이 **`VideoActionsMenu` 하나를 재사용**하고 `mine`으로만 갈라진다(내 영상=공개 범위 ✓ 2행+삭제 / 타인=신고). model: `video-menu`·`report`(둘 다 웹 포팅+parity)·`report-form`(닫힘=리셋 리듀서 — 회귀 재발 방지로 컴포넌트 밖에 뺌)·`use-auto-dismiss-toast` / api: `video-mutations`(옵션 팩토리+얇은 훅, MSG-426 구조 승계)·`invalidate-video-queries`(+parity)·`use-video-visibility-query`(시트 오픈 시 `getPlayback` 게이트 쿼리) / ui: `video-more-button`(36dp+hitSlop=44dp)·`video-more-sheet`·`video-delete-confirm-dialog`·`action-toast`·`video-actions-menu`. **공개 범위 현재값은 목록 응답에 `visibility`가 없어 `getPlayback`으로 보강**(웹과 동일, 조회수 +1 부작용은 환류) — 전환 성공은 응답값으로 `setQueryData` seed(무효화 재조회 금지, 조회수 재증가 방지). 전환은 비낙관(웹 동일), 카드 메타에 공개 배지 없음(Figma 정본). **신고 실배선**: 격자 상세 "이 격자의 영상"을 `getGridGlobalVideos`+`getGridVideos` 2개 조회로 교체하고 **videoId 교집합으로 소유 판정**(닉네임 비교 아님), 중복 신고는 409/developCode 11409 안내. `entities/cell/model/`에 `encodeGridId`(웹 복제+parity) + `serverGridIdFromCellId` — **라우트 셀 id(구 위경도 체계) → 서버 gridId(EPSG:5179)는 인덱스 문자열 재조합이 성립하지 않고**(스펙 원안 `"{row}_{col}"`은 북태평양 격자를 가리켰다) 셀 중심 좌표를 5179로 인코딩하는 경로만 옳다. 삭제: `more-menu-sheet`(상단 격자 단위 ⋯ — 삭제=화면 로컬 감춤·신고=videoId 없음·수정=무관한 `/upload` 이동이라 전부 대상 불명 mock 잔재)·`report-types`(mock 사유 5종 → 웹 정본 3종 통합, 서버 enum 불일치 해소). `hasCellVideos`는 이름·시그니처 유지로 `map-home-screen.tsx` diff 0(MSG-427·428 리베이스 보호). `packages/ui-native`·`apps/web` diff 0줄. **실기 미검증**(S1~S10) — 병렬 워크트리 Metro 점유·공유 dev client APK 충돌로 웨이브 3 종료 후 일괄 확인 필요. 격자 상세 통계·미리보기·지도는 mock 존치(후속 티켓). @@ -231,3 +232,4 @@ - MSG-574: [모바일] 지도 홈 줌아웃 하한 완화 — 시(SIDO) 집계 마커 구간까지. 사용자 QA("줌아웃 상한이 불편, 시 단위로 크게")로 하네스가 티켓 생성(JQL 중복 없음). 경량 실행(상수 1개 + 테스트). **1차 구현은 티켓대로 하한 삭제(웹 동일 zoom 6)했다가 실기에서 집계 토스트 + 시트 실패** — BE 실측으로 원인이 지도 중심 한국 범위 검사(`KoreaCoordinates` 33~39)와 시트 뒤 중심 오프셋임을 확정하고 **16km 단(zoom 9)** 으로 재작업(D1). 실기 14분·7장: 부산 전역 한 화면 + "부산 1" 시 마커, 탭 → 구 구간, 구 → 500m 단, 격자 복귀, logcat 0. 부수 발견: 핀치 스크립트 연타 시 제스처 유실·슬롯 미해제 시 지도만 입력 무시(런북 함정 10 보강 후보), 현재 위치 점이 시·구 마커 중심과 겹치면 개수 가림, 드릴인 목적지가 시트 뒤 — 전부 후속 후보. 웹 0줄. - MSG-579: [모바일] 광역 줌(bbox > 0.5°)에서 테마 칩 전환 시 직전 칩 미션 목록이 새 칩 카드로 표시 — 사용자 QA("경로추천에 지역축제가 뜬다")로 하네스가 티켓 생성. 칩→type·서버는 정상이고 원인은 TanStack `placeholderData(keepPreviousData)`가 **비활성 쿼리에도 `#lastQueryWithDefinedData`(키·type 무관)** 를 넘기는 의미론. `grid-aggregation-query`의 unit 선례대로 `api/active-missions-query.ts` 옵션 팩토리 신설 + 같은 type·활성일 때만 placeholder. 경량 실행, 실기 2방향 확인 - MSG-580: [모바일] 경로추천 칩 목록 상태에서 지도에 코스 경로선·번호 마커가 안 그려짐 — 사용자 QA("지도에 경로가 표시가 안 된다")로 하네스가 티켓 생성. 앱은 MSG-427 AC 10대로 선택 코스만 라인을 그렸고 웹은 목록 전체(`buildCourseRoutes`)로 갔던 파리티 갭. `courseRoutesOf` 신설 + GridMap `routes` prop(단일 `route`와 합류 렌더). 코스별 이름표는 미이식(Q4 유지). 경량 실행, 실기 확인 +- MSG-581: [모바일] 광역 줌(bbox > 0.5°)에서 테마 칩 미션 목록 0개 — 경로선 미표시·클러스터 16/시트 0 불일치(세 칩 공통) — 사용자 QA 3건으로 하네스가 티켓 생성. 원인은 미션 조회의 0.5° 초과 `enabled=false` + MSG-579가 비활성 placeholder를 비운 결과(예고된 후속). `viewportQueryArgs` 대신 `clampBoundsToSpan`으로 요청 bbox 클램프. 경량 실행, 두 번째 AVD + Metro 8083 실기 4장 diff --git a/docs/decisions/DECISIONS.md b/docs/decisions/DECISIONS.md index 401f116..c139da2 100644 --- a/docs/decisions/DECISIONS.md +++ b/docs/decisions/DECISIONS.md @@ -649,3 +649,4 @@ | 2026-09-07 | MSG-579 | 결정(D2): 활성 미션 조회의 `keepPreviousData`를 **같은 칩(type)·활성 상태**로 좁힌다 — `map-query-policy.ts`는 건드리지 않고 `active-missions-query.ts` 팩토리에서 placeholder만 덮어쓴다(grid-aggregation의 unit 선례) | 사용자 QA "경로추천에 지역축제가 뜬다". 매핑·서버 정상. TanStack query-core 5.101.2는 비활성 쿼리에도 placeholder 함수를 부르고 인자로 키·type 무관 `#lastQueryWithDefinedData`를 넘겨, bbox 0.5° 초과 구간에서 칩을 바꾸면 직전 칩 목록이 새 칩 모양으로 남는다. DTO `type` 방어 필터는 원인 지점이 아니라 기각. 후속: 0.5° 초과 구간의 "없어요" 빈 문구가 집계 마커와 모순 — 서버 명세가 권고하는 "확대 안내" 상태 부재 | | 2026-09-07 | MSG-580 | 결정(D3·D4): GridMap에 `routes?`를 추가하되 단일 `route`를 지우지 않고 내부에서 한 목록으로 합쳐 렌더 경로를 하나로 둔다. 웹의 코스별 이름표(AC 21)는 미이식 — 이름표는 선택 코스에만(MSG-427 Q4 유지) | 사용자 QA "경로추천에서 지도에 경로가 표시가 안 된다". AI 추천(MSG-556 D8)이 단일 `route`+`onWaypointTap`을 쓰므로 계약을 바꾸면 화면 2곳 수정. 이름표는 모바일 화면 폭에서 글자가 지도를 덮는 위험이 Q4 결정의 근거였고 그 사정이 그대로다 | | 2026-09-07 | MSG-580 | 결정: pre-commit react-doctor `no-giant-component` 2건(`grid-map.tsx` 618줄·`map-home-screen.tsx` 540줄)은 무수정 기각·`--no-verify` 커밋 — 이 티켓 이전부터 300줄 초과인 기존 파일이고 변경은 GridMap 렌더 블록 치환 + 화면 1줄 | MSG-560 선례. GridMap 분할은 오버레이 8층(격자·점령·테마·빗금·강조·경로·이름표·클러스터)을 함께 옮기는 별도 리팩터 티켓 규모라 이 티켓 범위 밖 | +| 2026-09-07 | MSG-581 | 결정(D1·D3): 활성 미션 조회는 bbox 0.5° 초과에서 조회를 닫지 않고 **중심 기준 0.5°로 잘라 요청**한다(`clampBoundsToSpan`, 집계 조회 선례). "확대 안내" UI는 넣지 않는다 — 목록(중앙 0.5° 창)과 집계 마커(행정 단위 총계)의 수 불일치는 허용 | 사용자 QA "경로추천 최대 줌아웃 안 보임"·"팝업 클러스터 16개·하단 0개"·"지역축제 동일". MSG-579가 비활성 placeholder를 비우면서 잠복 결함이 드러났다(MSG-579 후속 후보 그대로). `viewportQueryArgs`·`canQueryGrids`는 `/api/grids`·`/api/hotzones`가 공유해 무수정. 확대 안내 상태 신설은 마커와 목록이 함께 보이면 모순 문구가 사라져 기각 | diff --git a/docs/spec/MSG-581.md b/docs/spec/MSG-581.md new file mode 100644 index 0000000..d41e9e5 --- /dev/null +++ b/docs/spec/MSG-581.md @@ -0,0 +1,65 @@ +# MSG-581: [모바일] 광역 줌에서 테마 칩 미션 목록 0개 — 경로선 미표시·클러스터/시트 수 불일치 + +> 원문: https://soma17-msg.atlassian.net/browse/MSG-581 (하네스 생성, 2026-09-07 — 사용자 QA "경로추천 최대 줌아웃 안 보임" / "팝업스토어 클러스터 16개·하단 0개" / "지역축제도 같은 증상") +> **수정 범위: `apps/mobile/src/features/map-home/model/mission-query-args.ts`(+test) 1쌍 + `api/active-missions-query.test.ts` 케이스 1건 갱신.** `apps/web`·`packages/**`·`viewport-query.ts` 0줄. + +## 기획 요약 +최대 줌아웃에서 경로추천 경로선이 사라지고, 팝업스토어·지역축제 시트가 0개("…가 없어요")인데 집계 마커는 16을 보인다. 세 칩 모두 같은 `useActiveMissionsQuery`라 원인·수정이 하나다. + +## 0. 착수 실측 (develop `b65ecc4`) + +| 사실 | 실측 | 함의 | +|---|---|---| +| 미션 조회 게이트 | `mission-query-args.ts` → `viewportQueryArgs(bounds)` → `canQueryGrids`: 한 변 0.5° 초과면 `enabled=false` | 최대 줌아웃은 항상 초과 | +| 빈 목록 | MSG-579가 비활성 쿼리의 placeholder를 `undefined`로 → `missions=[]` (이전엔 줌인 때 목록이 잔존해 증상이 가려짐) | 목록 0개 | +| 경로선 | `use-home-overlays.ts` `routes = courseRoutesOf(courseViews)` — 목록 파생 | 목록 비면 라인·마커 소멸 | +| 집계 마커 | `grid-aggregation-query.ts`는 `clampBoundsToSpan(bounds, AGGREGATION_SPAN_CAP_DEG[unit])`로 클램프해 어떤 줌에서도 발사 | 마커 16 / 시트 0개 | +| 선례 | MSG-579 작업 로그 "후속 후보: 0.5° 초과 구간 빈 문구가 집계 마커와 모순" | 예고된 결함 | +| 웹 | 칩 활성 시 1km 고정 줌(`chip-zoom.ts`) + 확정 영역 bbox — 경계에 닿지 않음 | 모바일 전용 | + +## 1. 결정 + +| # | 결정 | 근거 | +|---|---|---| +| D1 | `activeMissionsQueryArgs`에서 bounds를 **`clampBoundsToSpan(bounds, MAX_VIEWPORT_SPAN_DEG)`로 잘라** 요청하고, `enabled = bounds !== null && chip !== null`(span 게이트 제거) | 집계 조회와 같은 패턴. 상한 초과 400 경로 없음. 한 줄 diff | +| D2 | `viewportQueryArgs`·`canQueryGrids` 무수정 | `/api/grids`·`/api/hotzones`가 공유 — 저줌은 집계 조회가 담당(MSG-410 설계) | +| D3 | 확대 안내 UI·헤더 문구 없음 | 마커와 목록이 함께 보이면 모순 문구가 사라진다. 목록=중앙 0.5° 창, 마커=행정 단위 총계라 수가 다를 수 있음 — 허용(티켓 명시) | +| D4 | MSG-579 테스트 L3("0.5° 초과면 undefined")는 **"bounds null이면 undefined"** 로 치환 | 초과 bbox가 더는 비활성이 아님. 칩 전환·null 게이트·인증 게이트는 그대로 | +| D5 | 웹식 1km 고정 줌·확정 영역 미도입 | 티켓 제외 범위 | + +## 2. 수용 기준 + +| # | 기준 | 유형 | 검증 | +|---|---|---|---| +| L1 | 한 변 1° 뷰포트 → 요청 bbox가 중심 기준 0.5°×0.5°이고 `enabled=true` | 로직 | `mission-query-args.test.ts` | +| L2 | 상한 안 뷰포트는 bbox 그대로(회귀 없음), 칩 null·bounds null은 `enabled=false` | 로직 | 〃 | +| L3 | `activeMissionsQueryOptions`: 넓은 bbox + 같은 type이면 placeholder가 직전 데이터를 돌려주고, bounds null이면 undefined | 로직 | `active-missions-query.test.ts` | +| L4 | 게이트: 모바일 vitest·typecheck·lint·format:check·nose 통과, `apps/web`·`packages` diff 0 | 게이트 | 명령 | +| S1 | 에뮬레이터 최대 줌아웃 + 경로추천 칩: 경로선·번호 마커가 보이고 시트에 코스 카드가 있다 | 화면 | 실기 + 스크린샷 ① | +| S2 | 최대 줌아웃 + 팝업스토어 칩: 시트 목록이 0개가 아니다(집계 마커와 수는 달라도 됨). 지역축제 동일 | 화면 | 실기 + 스크린샷 ② | +| S3 | 광안리 zoom 16 경로추천=해파랑길, 칩 전환 시 직전 칩 카드 없음(MSG-579 회귀 없음) | 화면 | 실기 | +| S4 | logcat `ReactNativeJS` 에러 0, 네트워크 400 없음 | 화면 | 실기 | + +**검증 프로파일**: 경미(로직 한 줄 + 테스트) — 경량 실행(메인 스레드). 실기 3-B ≤4장·15분, 워크트리 Metro 8082 + 런북 1-D. + +## 추정 및 질문 +- A1 시트 수와 마커 총계 불일치 허용(D3) — 대안 "확대 안내" 상태 신설은 별도 티켓(MSG-579 후속 후보 그대로) +- Q: 없음 + +## 리스크 +- 최대 줌아웃에서 중앙 0.5° 창 밖의 미션은 목록에 없다 — 사용자가 마커 16 vs 목록 N을 결함으로 볼 수 있음. 안내 UI가 필요해지면 후속 +- 경로추천 최대 줌아웃 시 코스 수십 개의 라인이 한 화면에 — `courseRoutesOf`는 클리핑 없음(ponytail 주석). 현재 서버 코스 수 한 자리라 성능 영향 없음 + +## 구현 계획 +- 브랜치 `fix/MSG-581-mission-bbox-clamp` (신규 — 기존 0건), 워크트리 `../FE-MSG-581` +- `model/mission-query-args.ts`: import `clampBoundsToSpan`(aggregation-unit)·`MAX_VIEWPORT_SPAN_DEG`·`toGridsRequest`(viewport-query); `viewportQueryArgs` 호출 제거 +- 테스트 갱신 2파일(test-first: L1 RED 확인 후 구현) +- nose: 접촉 패밀리만 교체(MSG-572 레시피) + +## 작업 로그 (2026-09-07) + +- 변경 3파일: `model/mission-query-args.ts`(`viewportQueryArgs` → `toGridsRequest(clampBoundsToSpan(bounds, MAX_VIEWPORT_SPAN_DEG))`, `enabled = bounds !== null && chip !== null`) · `model/mission-query-args.test.ts`(L1 클램프 케이스 신설, L2 null 게이트 분리 — test-first, RED 확인) · `api/active-missions-query.test.ts`(MSG-579 L3 "0.5° 초과 → undefined"를 "넓은 bbox 같은 type → 유지" + "bounds null → undefined" 2건으로 치환). +- 게이트: 모바일 vitest 1327/1327(203 파일) · typecheck · oxlint · oxfmt · nose 0 families(exit 0). `apps/web`·`packages` diff 0. +- 실기(런북 1-D — `emulator-5554`·8081·8082가 전부 타 세션 것이라 `FillMap_Pixel8_verify`(5556) + 워크트리 Metro **8083**, APK는 5554에서 pull, dev 메뉴 Bundle Location `10.0.2.2:8083`, 번들 grep으로 내 코드 확증): 최대 줌아웃(16km 단) 경로추천 4개 + 번호 마커(S1) · 팝업스토어 16개 = 집계 마커 "부산 16", 지역축제 1개 = "부산 1"(S2) · 수영구/광안리 줌인 경로추천=해파랑길 1코스 + 경로선, 칩 전환 시 직전 카드 없음(S3) · logcat `ReactNativeJS` 에러 0·4xx 0(S4). 스크린샷 4장, 실기 약 9분. iOS 미실행(환경). +- 검토한 대안: (a) `canQueryGrids` 상한 완화 — `/api/grids`·`/api/hotzones` 공유라 기각. (b) "확대 안내" 시트 상태 신설 — 마커·목록이 함께 보이면 모순이 사라지므로 기각, 필요해지면 후속(리스크 참조). +- 실기 부작용 관찰: 검증 종료 시점에 타 세션 Metro 8082(FE-MSG-578)가 내려가 있었다. 내 정리 명령은 8083·5556 한정(`pkill -f "--port 8083"`, `emu kill` on 5556)이라 인과는 확인 안 됨 — 사용자 보고에 기재.