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
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down Expand Up @@ -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")),
Expand Down
18 changes: 15 additions & 3 deletions apps/mobile/src/features/map-home/model/mission-query-args.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand All @@ -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,
});
});
});

Expand Down
30 changes: 17 additions & 13 deletions apps/mobile/src/features/map-home/model/mission-query-args.ts
Original file line number Diff line number Diff line change
@@ -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) — 순수 함수.
Expand All @@ -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]
Expand Down
2 changes: 2 additions & 0 deletions docs/STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 존치(후속 티켓).
Expand Down Expand Up @@ -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장
1 change: 1 addition & 0 deletions docs/decisions/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`가 공유해 무수정. 확대 안내 상태 신설은 마커와 목록이 함께 보이면 모순 문구가 사라져 기각 |
Loading
Loading