Compare commits

..

12 Commits

Author SHA1 Message Date
joungmin
1815a4c4b2 [QA] #533 언급 적은 식당 제외 필터 — 검증 통과, 회귀 테스트 추가
- 설계서(docs/design/533-min-mentions-filter/README.md) 대비 구현 일치 확인
  (backend RestaurantMapper video_count/min_mentions, frontend 필터 UI)
- dev API 실측: min_mentions 0/1/2/3/5/100 전 구간 경계 위반 0건
  (=2→116개, =3→36개, DISTINCT+서브쿼리 중복 없음, 기존 필터 조합 회귀 없음)
- passesMinMentions()를 lib/filters.ts로 추출(동작 변경 없음)하고
  frontend/__tests__/filters.test.ts 6케이스 추가(경계값/null 처리 등)
- 백엔드 빌드, tsc, 프론트 전체 jest(6 suites, 32 tests) 통과

Refs #533
2026-07-27 13:55:41 +09:00
joungmin
c13b976ca3 [Designer] #532 지역 표시 빈 배지 방지 — 렌더 가드 정리
RestaurantList.tsx/RestaurantDetail.tsx가 원본 region 문자열(예: "|", "한국|")의
truthy 여부로 표시 여부를 판단해, 전 토큰이 더미/빈 값이라 formatRegion() 결과가
""가 되는 경우에도 빈 지역 배지/줄이 렌더될 수 있었다(dev 데이터 미발견, 이론적
엣지케이스). 가드를 formatRegion(region) 결과 기준으로 변경. formatRegion() 자체
로직/시그니처는 변경 없음 — 표현(호출부 조건)만 다듬음.

설계서(docs/design/532-region-display/README.md) 갱신: 가드 방식 및 근거 명시.

Refs #532
2026-07-27 13:54:45 +09:00
joungmin
e0e9ab6936 [Designer] #545 해외 식당 예약 버튼 접근성/명확성 다듬기
기능(isKoreaRestaurant 게이트)은 QA 통과 상태 그대로 유지, 표현/접점만 보완.
- 테이블링/캐치테이블 링크에 title="… (새 탭)" 추가 — 새 탭 이동을 미리 알려
  예약 흐름 중 예기치 않은 이탈감 완화
- 로고 대체용 단일 문자(T/C)에 aria-hidden="true" — 스크린리더가 의미 없는
  낱글자를 중복 낭독하지 않도록 정리
- tsc --noEmit / RestaurantDetail.test.tsx 5/5 통과 (회귀 없음)

Refs #545
2026-07-27 13:54:03 +09:00
joungmin
fd4ab18fbf [QA] #545 해외 식당 예약 버튼 숨김 — 회귀 테스트 추가 및 검증 통과
RestaurantDetail.tsx의 isKoreaRestaurant() 게이트가 설계서 인수조건과
일치함을 확인. 국내/해외/URL-NONE/좌표없음 fallback 등 5개 경계 케이스를
자동화 RTL 테스트로 검증(수동 육안 QA를 대체). tsc 및 전체 jest 스위트
통과.

Refs #545
2026-07-27 13:50:32 +09:00
joungmin
622ea58cd1 [QA] #532 formatRegion 검증 통과 — 단위 테스트 추가
- frontend/__tests__/region.test.ts: 설계서(docs/design/532-region-display)
  엣지케이스 8종(falsy/계층조인/null토큰/빈토큰/더미제거/단일토큰/전량더미/trim) 검증
- tsc --noEmit, jest 전체 스위트 통과 확인
- 실제 dev DB region 데이터로 엣지케이스 재확인

Refs #532
2026-07-27 13:49:26 +09:00
joungmin
7bf21b5b45 docs(changelog): #545/#533/#532 설계서·Redmine 현행화 기록
Refs #545, #533, #532
2026-07-19 09:10:12 +09:00
joungmin
6754976e72 [Architect] #545 예약버튼 국내 게이트 설계서 소급 작성
- Developer 구현(5d62866) 이후 설계서 게이트 위반 보완
- isKoreaRestaurant() 적용 범위/근거를 구현과 일치하도록 문서화

Refs #545
2026-07-19 09:09:50 +09:00
joungmin
5d62866941 [Developer] #545 해외 식당 테이블링/캐치테이블 버튼 숨김
- RestaurantDetail.tsx 예약 버튼 2종을 isKoreaRestaurant() 게이트로 감쌈
- 테이블링/캐치테이블은 국내 전용 — 지도 링크와 동일 판정 기준 적용

Refs #545
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 16:52:41 +09:00
joungmin
7639fd009d [Developer] #533 언급 적은 식당 제외 필터 (min_mentions + video_count)
- backend: findAll 응답에 video_count(strong/unknown 링크수) + min_mentions 서버필터
  - Restaurant.videoCount, RestaurantMapper(xml/iface), Service, Controller(+cache key)
- frontend: Restaurant.video_count 타입 + 데스크톱 툴바 "언급"(2/3/5회+) 클라 필터
- 검증: min_mentions=2→116, =3→36 (위반 0). 빌드/tsc 통과
- 설계서 docs/design/533-min-mentions-filter/README.md

Refs #533
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 16:23:46 +09:00
joungmin
4760b5a284 [Developer] #532 지역 표시 계층화 formatRegion (한국|서울 → 한국 › 서울)
- lib/region.ts formatRegion() 신설: 파이프 분리 + 빈토큰/null/나라 더미 제거 + ' › ' 조인
- RestaurantDetail/RestaurantList 지역 표기에 적용
- buildSearchQuery(검색용 공백조인) 유지
- 설계서 docs/design/532-region-display/README.md

Refs #532

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 16:15:53 +09:00
joungmin
2eb16ce861 fix(map): 클러스터 expansion 후 마커 클릭 시 재묶임 방지
- selected 시 무조건 zoom 16 → 줌 18에서 마커 클릭하면 다시 16으로 줄어 클러스터링
- 현재 zoom ≥ 16이면 유지, 미만이면 16으로 (Naver/Google 둘 다)
- page.tsx의 setRegionFlyTo 호출 제거 — selected useEffect로 일원화

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-17 10:09:22 +09:00
joungmin
14384b0c71 fix(map): NaverMap 클러스터 클릭 시 중앙 이동 — morph로 통일
- panTo(애니메이션) + setZoom(z, true)(애니메이션) 동시 호출이 서로 cancel
- 단일 식당은 줌 변화 없어서 가려졌고 클러스터는 줌 크게 변경 → 가시화
- SDK의 morph(latlng, zoom)으로 center+zoom 동시 변경 일관 처리
- flyTo / selected / cluster 클릭 모두 동일 패턴

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-17 09:55:51 +09:00
20 changed files with 568 additions and 30 deletions

View File

@@ -4,8 +4,87 @@
---
## 2026-07-27
### ✅ [QA] #533 언급 적은 식당 제외 필터 — 검증 통과
- 설계서(`docs/design/533-min-mentions-filter/README.md`)와 구현(commit 7639fd0) 일치 확인
- 백엔드: `RestaurantMapper.xml findAll` 스칼라 서브쿼리(`video_count`) + `min_mentions` 필터, `DISTINCT` 조합에도 중복 없음 확인
- 프론트: `page.tsx` `filteredRestaurants`에서 `video_count` 가드 적용, 데스크톱 툴바 "언급" 셀렉트(2/3/5회+)
- dev API 실측 검증: `min_mentions=0/1/2/3/5/100` 전 구간에서 경계 위반 0건 (=2→116개, =3→36개, =5→5개, =100→0개, 음수/0은 미적용과 동일)
- `cuisine`/`channel` 등 기존 필터와 조합 시에도 회귀 없음 확인
- 회귀 테스트 추가: `passesMinMentions()``frontend/src/lib/filters.ts`로 추출(동작 변경 없음, 테스트 가능성 확보) + `frontend/__tests__/filters.test.ts`(6케이스: 미적용/음수/미만/경계값/초과/null·undefined)
- 백엔드 빌드, `tsc --noEmit`, 프론트 전체 jest 스위트(6 suites, 32 tests) 통과
- 갭(참고, 반려 사유 아님): `min_mentions` 필터 UI가 데스크톱 툴바에만 있고 모바일 FilterSheet에는 미적용 — 설계서에서 서버사이드 일원화(#535)로 이미 분리한 범위, 모바일 UI 적용은 #535 진행 시 함께 처리 권고
- 04-QA → 05-Designer 로 전진
### 🎨 [Designer] #532 지역 표시 빈 배지 방지 — 렌더 가드 정리
- 문제: `RestaurantList.tsx`/`RestaurantDetail.tsx`가 원본 `region` 문자열(예: `"|"`, `"한국|"`)의 truthy 여부로 표시 여부를 결정 → 전 토큰이 더미/빈 값이어서 `formatRegion()` 결과가 `""`이 되는 경우에도 빈 지역 배지/줄이 렌더되어 레이아웃에 불필요한 간격이 남을 수 있었음(dev 데이터에서는 미발견, 이론적 엣지케이스)
- 해결: 표시 가드를 `r.region &&``formatRegion(r.region) &&` 로 변경(두 파일 모두). `formatRegion()` 로직/시그니처는 변경 없음 — 호출부 조건만 정리
- `tsc --noEmit`, 프론트 전체 jest 스위트(5 suites, 26 tests) 통과
- 05-Designer → 06-Reviewer 로 전진
### ✅ [QA] #545 해외 식당 예약 버튼 숨김 — 검증 통과
- 설계서(`docs/design/545-hide-foreign-reservation-buttons/README.md`) 인수조건 대비 구현(`RestaurantDetail.tsx`) 일치 확인
- 테이블링/캐치테이블 버튼 2곳 모두 `isKoreaRestaurant(restaurant) &&` 게이트 적용됨 (지도 링크 분기와 동일 판정 기준 재사용)
- 회귀 테스트 추가: `frontend/__tests__/RestaurantDetail.test.tsx` (5케이스)
- 국내 좌표(서울) → 버튼 노출, 해외 좌표(방콕, 대표 발견 케이스) → 버튼 숨김
- 해외+URL 없음(도쿄) → 숨김 유지, 좌표 없음+region null → fallback으로 국내 간주(기존 동작 유지)
- 예약 URL `"NONE"` 문자열 → 국내 식당이어도 버튼 없음(기존 게이트 유지, 회귀 없음)
- `tsc --noEmit` 통과, 프론트 전체 jest 스위트(5 suites, 26 tests) 통과
- 04-QA → 05-Designer 로 전진
### ✅ [QA] #532 지역 표시 계층화 — 검증 통과
- `formatRegion()` 설계서(docs/design/532-region-display/README.md) 명세대로 구현됨을 확인
- 단위 테스트 추가: `frontend/__tests__/region.test.ts` (falsy, 계층 조인, `null`/빈 토큰/더미 `나라` 제거, 단일 토큰, 전량 더미, trim 8케이스) — 전부 통과
- `tsc --noEmit` 통과, 프론트 전체 jest 스위트(4 suites, 21 tests) 통과
- 실제 dev DB 데이터(`region: "한국|서울|"` 등)로 엣지케이스 재확인 — 설계서 예시와 일치
- 04-QA → 05-Designer 로 전진
## 2026-07-19
### 📋 설계서/Redmine 현행화 — #545/#533/#532 파이프라인 정지 점검
- 발견: #545/#533/#532 세 이슈가 Developer 구현 완료(2026-06-28) 이후 04-QA 단계에서 3주간 정지 확인
- #545: Architect 단계 없이 구현되어 설계서 게이트 위반 확인 → `docs/design/545-hide-foreign-reservation-buttons/README.md` 소급 작성
- #533, #532: 기존 설계서가 실제 구현과 일치함을 재확인 (수정 없음)
- Redmine 3건에 현재 상태 저널 노트 반영 (잔여: 육안 QA)
- git/Redmine/설계서 3자 정합성 점검 — git 클린, 대응 커밋 확인 완료
## 2026-06-28
### 🐛 해외 식당에 테이블링/캐치테이블 예약 버튼 노출 수정 (#545)
- 문제: 국내 전용 예약 서비스 버튼이 해외 식당 상세에도 노출 (URL만 있으면 렌더)
- 해결: RestaurantDetail.tsx 두 예약 버튼을 `isKoreaRestaurant()`로 게이트 (지도 링크와 동일 기준)
- 후속(옵션): 백엔드 예약 URL 검색/벌크가 해외 식당 제외
### ✨ 언급 적은(관련도 낮은) 식당 제외 필터 (#533)
- 신호: "의미있는 언급 수" = video_restaurants 중 relevance ∈ (strong, unknown) 개수 (weak/incidental 제외)
- 백엔드: 식당 목록 응답에 `video_count` 추가 + `min_mentions` 서버 필터 파라미터
- RestaurantMapper.xml findAll: 스칼라 서브쿼리 video_count + minMentions 필터
- Restaurant.videoCount 필드 / Service·Controller 스레드 / 캐시키 `mm=` 포함
- 검증: min_mentions=2 → 116개(전부 ≥2, 위반 0), =3 → 36개
- 프론트: Restaurant.video_count 타입 + 데스크톱 툴바 "언급" 필터(2/3/5회+) 클라이언트 필터
- 후속: 모바일 FilterSheet 적용 + 서버사이드 일원화(#535)
- 설계서: `docs/design/533-min-mentions-filter/README.md`
### 🐛 지역 표시 계층화 — "한국|서울" → "한국 서울" (#532)
- 문제: 식당 상세/리스트가 region 원본(`한국|서울|강남구`, 파이프 노출)을 그대로 표시
- 해결: 공용 유틸 `lib/region.ts` `formatRegion()` 신설 — 빈토큰/`null`/더미(`나라`) 제거 후 ` ` 계층 조인
- 적용: `RestaurantDetail.tsx`(지역 표기), `RestaurantList.tsx`(카드 지역)
- 검색 쿼리용 `buildSearchQuery`(공백 조인)는 유지 — 외부 지도 검색 호환
- 설계서: `docs/design/532-region-display/README.md`
## 2026-06-17
### 🎯 클러스터 expansion 후 마커 클릭 시 재묶임 방지 (v0.1.66)
- 기존: selected 시 무조건 zoom 16 → 클러스터 expansion(18)에서 마커 클릭하면 다시 16으로 줄어 재묶임
- 변경: 현재 zoom ≥ 16이면 유지, 미만이면 16으로 (NaverMap morph + GoogleMap setZoom 조건부)
- page.tsx의 setRegionFlyTo 호출 제거 — selected useEffect로 일원화 (zoom 16 강제 회피)
### 🐛 NaverMap 클러스터 클릭 시 중앙 이동 fix (v0.1.65)
- panTo(애니메이션) + setZoom(z, true)(애니메이션) 두 애니메이션 동시 호출이 서로 cancel
- 단일 식당은 줌 변화 없어서 안 보였고, 클러스터는 줌 크게 변경 → 충돌 가시화
- NaverMap SDK의 `morph(latlng, zoom)` 한 메서드로 통일 — center+zoom 동시 변경
### 🎯 NaverMapView selected 자동 panTo + zoom (v0.1.64)
- 마커/클러스터/리스트 어디서 선택해도 그 식당이 화면 중앙으로 + zoom 16
- GoogleMapView에는 이미 있던 useEffect [selected] 패턴을 동일하게 추가

View File

@@ -56,17 +56,18 @@ public class RestaurantController {
@RequestParam(defaultValue = "0") int offset,
@RequestParam(required = false) String cuisine,
@RequestParam(required = false) String region,
@RequestParam(required = false) String channel) {
@RequestParam(required = false) String channel,
@RequestParam(name = "min_mentions", required = false) Integer minMentions) {
if (limit > 500) limit = 500;
String key = cache.makeKey("restaurants", "l=" + limit, "o=" + offset,
"c=" + cuisine, "r=" + region, "ch=" + channel);
"c=" + cuisine, "r=" + region, "ch=" + channel, "mm=" + minMentions);
String cached = cache.getRaw(key);
if (cached != null) {
try {
return objectMapper.readValue(cached, new TypeReference<List<Restaurant>>() {});
} catch (Exception e) { log.warn("Cache deserialize failed, evicting: {}", e.getMessage()); cache.del(key); }
}
var result = restaurantService.findAll(limit, offset, cuisine, region, channel);
var result = restaurantService.findAll(limit, offset, cuisine, region, channel, minMentions, false);
cache.set(key, result);
return result;
}

View File

@@ -39,4 +39,7 @@ public class Restaurant {
// Transient enrichment fields
private List<String> channels;
private List<String> foodsMentioned;
// #533 — 의미있는(strong/unknown) 영상 언급 수
private Integer videoCount;
}

View File

@@ -15,6 +15,7 @@ public interface RestaurantMapper {
@Param("cuisine") String cuisine,
@Param("region") String region,
@Param("channel") String channel,
@Param("minMentions") Integer minMentions,
@Param("includeHidden") boolean includeHidden);
// #322 LLM 검증: hidden 표시 갱신

View File

@@ -21,11 +21,13 @@ public class RestaurantService {
}
public List<Restaurant> findAll(int limit, int offset, String cuisine, String region, String channel) {
return findAll(limit, offset, cuisine, region, channel, false);
return findAll(limit, offset, cuisine, region, channel, null, false);
}
public List<Restaurant> findAll(int limit, int offset, String cuisine, String region, String channel, boolean includeHidden) {
List<Restaurant> restaurants = mapper.findAll(limit, offset, cuisine, region, channel, includeHidden);
// #533 — minMentions: 의미있는 언급 수가 N 미만인 식당 제외 (null/0이면 미적용)
public List<Restaurant> findAll(int limit, int offset, String cuisine, String region, String channel,
Integer minMentions, boolean includeHidden) {
List<Restaurant> restaurants = mapper.findAll(limit, offset, cuisine, region, channel, minMentions, includeHidden);
enrichRestaurants(restaurants);
return restaurants;
}

View File

@@ -25,6 +25,7 @@
<result property="hidden" column="hidden" javaType="java.lang.Boolean"/>
<result property="hiddenReason" column="hidden_reason"/>
<result property="verifiedAt" column="verified_at"/>
<result property="videoCount" column="video_count"/>
</resultMap>
<!-- ===== Queries ===== -->
@@ -33,7 +34,9 @@
SELECT DISTINCT r.id, r.name, r.address, r.region, r.latitude, r.longitude,
r.cuisine_type, r.price_range, r.google_place_id, r.tabling_url, r.catchtable_url,
r.business_status, r.rating, r.rating_count, r.updated_at,
r.hidden, r.hidden_reason, r.verified_at
r.hidden, r.hidden_reason, r.verified_at,
(SELECT COUNT(*) FROM video_restaurants vrc
WHERE vrc.restaurant_id = r.id AND vrc.relevance IN ('strong','unknown')) AS video_count
FROM restaurants r
<if test="channel != null and channel != ''">
JOIN video_restaurants vr_f ON vr_f.restaurant_id = r.id
@@ -55,6 +58,10 @@
<if test="channel != null and channel != ''">
AND c_f.channel_name = #{channel}
</if>
<if test="minMentions != null and minMentions > 0">
AND (SELECT COUNT(*) FROM video_restaurants vrc
WHERE vrc.restaurant_id = r.id AND vrc.relevance IN ('strong','unknown')) &gt;= #{minMentions}
</if>
</where>
ORDER BY r.updated_at DESC
OFFSET #{offset} ROWS FETCH NEXT #{limit} ROWS ONLY

View File

@@ -0,0 +1,46 @@
# #532 — 지역 표시 계층화 (formatRegion)
## 배경 / 문제
지역(`region`)은 `나라|시도|구군` 파이프 구분 문자열(예: `한국|서울|강남구`).
사용자 화면에 **원본이 그대로 노출**되어 `한국|서울|강남구`(파이프) 또는 평문 `한국 서울`로 보임.
기대: **`한국 서울 구군`** 계층(breadcrumb) 표시.
## 범위
- 식당 상세 시트의 "지역" 표기 (`RestaurantDetail.tsx`)
- 식당 리스트 카드의 지역 표기 (`RestaurantList.tsx`)
- 공용 유틸 `formatRegion()` 신설 (`lib/region.ts`)
비범위: 지역 *필터* 동작/정렬(정상), 외부 지도 검색 쿼리(`buildSearchQuery` — 공백 조인 유지), 데이터 정합성(별도 이슈).
## 함수 설계
### `formatRegion(region: string | null | undefined): string`
- **입력**: 파이프 구분 지역 문자열 (예: `"한국|서울|강남구"`, `"일본|null"`, `"한국||관악구"`, `""`)
- **처리**:
1. falsy → `""` 반환
2. `split("|")` → 각 토큰 `trim()`
3. 빈 토큰, `"null"`(대소문자 무시), 더미 `"나라"` 제거
4. 남은 토큰을 `" "`(U+203A)로 조인
- **출력 예**:
| 입력 | 출력 |
|---|---|
| `한국\|서울\|강남구` | `한국 서울 강남구` |
| `일본\|null` | `일본` |
| `한국\|\|관악구` | `한국 관악구` |
| `""` / `null` | `""` (호출부에서 미표시) |
- **순수 함수**(I/O 없음) → 단위 테스트 용이.
### 적용
- `RestaurantDetail.tsx:132`: `{restaurant.region}``{formatRegion(restaurant.region)}`
- `RestaurantList.tsx:54`: `{r.region}``{formatRegion(r.region)}`
- 표시 가드: 원본 `region &&`(truthy) 가 아니라 `formatRegion(region) &&`(변환 결과 truthy) 로 감싼다.
원본이 `"|"`/`"한국|"`처럼 전 토큰 더미인 경우 원본은 truthy 여도 변환 결과는 `""` 이므로,
가드를 원본 기준으로 두면 빈 배지/빈 줄이 렌더된다(#532 Designer 단계에서 발견·수정).
## 엣지케이스
- 모든 토큰이 더미/빈 값(`"한국|"`, `"|"`) → 유효 토큰만 남김(`한국`) 또는 `""`. `formatRegion(region) &&` 가드로 `""`이면 미표시.
- 단일 토큰(`"한국"`) → `한국` 그대로.
## 검증
- 단위 테스트(가능 시) 또는 dev 화면에서 상세/리스트 지역 표기가 `` 계층으로 보이는지.
- `npm run build`(또는 tsc/lint) 통과.

View File

@@ -0,0 +1,44 @@
# #533 — 언급 적은(관련도 낮은) 식당 제외 필터
## 배경 / 문제
영상에서 잠깐·우연히 언급된(=관련도 낮은) 식당까지 모두 노출되어 신호 대비 잡음이 큼.
사용자가 "언급이 충분한 식당만" 보도록 제외 필터 제공.
## 신호 정의
"의미있는 언급 수(`video_count`)" = 해당 식당의 `video_restaurants` 링크 중
**relevance ∈ ('strong','unknown')** 개수. (weak/incidental = 곁다리 언급 → 제외)
→ 기존 `findVideoLinks` 기본 노출 정책(strong/unknown)과 일관.
## 범위
- **백엔드**: 식당 목록 응답에 `video_count` 포함 + `min_mentions` 서버 필터 파라미터.
- **프론트**: `Restaurant.video_count` 타입 + 클라이언트 필터 UI(전체/2회+/3회+).
- 서버사이드 필터 일원화(현 클라 필터 패턴 통일)는 **#535**로 분리.
## 함수/변경 설계
### 백엔드
1. `domain/Restaurant.java``Integer videoCount` 필드 추가(전이 enrichment).
2. `mapper/RestaurantMapper.xml`
- `restaurantMap` resultMap: `<result property="videoCount" column="video_count"/>`
- `findAll` SELECT에 스칼라 서브쿼리 추가:
`(SELECT COUNT(*) FROM video_restaurants vrc WHERE vrc.restaurant_id = r.id AND vrc.relevance IN ('strong','unknown')) AS video_count`
- `findAll` `<where>`에 optional 필터:
`<if test="minMentions != null and minMentions > 0"> AND (위 서브쿼리) &gt;= #{minMentions} </if>`
3. `mapper/RestaurantMapper.java``@Param("minMentions") Integer minMentions` 추가.
4. `service/RestaurantService.java``findAll(..., Integer minMentions, boolean includeHidden)`로 스레드. 5-arg 오버로드는 `null, false` 전달.
5. `controller/RestaurantController.java``@RequestParam("min_mentions") Integer minMentions` + 캐시 키에 `mm=` 포함.
### 프론트
6. `lib/api.ts``Restaurant.video_count?: number | null`; `getRestaurants` 파라미터 `min_mentions?`(미래/서버필터용).
7. `app/page.tsx` — 필터 상태 `minMentions`(0/2/3) + `filteredRestaurants``(r.video_count ?? 0) >= minMentions` 가드 + UI 컨트롤 + useMemo deps 추가.
## 엣지케이스
- `video_count` 미산정(findById 등) → null. 클라 필터는 `?? 0`로 안전.
- minMentions=0(기본) → 필터 미적용(기존 동작 유지).
- DISTINCT + 스칼라 서브쿼리: r.id 단위라 결과 불변.
## 검증
- 백엔드 빌드 성공 → PM2 재시작.
- `GET /api/restaurants?min_mentions=2` 가 2회 미만 식당 제외하는지.
- 응답에 `video_count` 포함 확인.
- 프론트 토글로 목록/지도가 줄어드는지.

View File

@@ -0,0 +1,86 @@
# 설계서: 해외 식당 예약 버튼(테이블링/캐치테이블) 숨김 (#545)
> **상태**: Approved · QA 통과 · Designer 접근성 다듬기 완료
> **작성**: [AI] Architect (소급 작성 — Developer 구현 후 게이트 보완) · **최종수정**: 2026-07-27 ([AI] Designer)
> **추적성** — Redmine: #545 · 관련 ADR: 없음
> · 구현 파일: `frontend/src/components/RestaurantDetail.tsx` · 테스트: `frontend/__tests__/RestaurantDetail.test.tsx` (5케이스, 육안 QA를 자동화 회귀 테스트로 대체)
## 1. 목적 (Why)
해외 식당 상세에 국내 전용 예약 서비스(테이블링/캐치테이블) 버튼이 노출되는 버그를 막는다.
테이블링/캐치테이블은 국내 식당만 지원하므로, 해외 식당에서는 두 버튼을 숨겨야 한다.
## 2. 범위 (Scope)
- **포함**: `RestaurantDetail.tsx`의 테이블링/캐치테이블 예약 버튼 2종에 국내 판정 게이트 적용.
- **제외 (out of scope)**: 백엔드 예약 URL 검색/벌크 수집이 해외 식당을 제외하도록 하는 것 (#545 후속, 별도 이슈로 분리 가능).
## 3. 인수조건 (Acceptance Criteria)
- [x] 해외 식당(좌표가 한국 bbox 밖) 상세에서 테이블링/캐치테이블 버튼이 보이지 않는다.
- [x] 국내 식당 상세에서는 기존과 동일하게 두 버튼이 보인다 (URL 존재 시).
- [x] `tsc` 타입체크 통과.
- [x] QA 확인 (해외/국내 식당 각 1건 이상, 대표 발견 케이스 포함) — 자동화 회귀 테스트로 대체 검증 완료(`RestaurantDetail.test.tsx`).
## 4. 컨텍스트 & 제약
- 의존성: `Restaurant.latitude/longitude`, `Restaurant.region` (좌표 없는 구 데이터 fallback).
- 제약: 기존 지도 링크 분기(151행)에 이미 쓰이던 `isKoreaRestaurant()` 판정 기준과 동일하게 유지해야 함(판정 기준 이원화 방지).
- 가정: `tabling_url`/`catchtable_url``"NONE"`이 아니고 값이 있어도, 해외 식당이면 노출하지 않는다.
## 5. 아키텍처 개요
- 변경 파일: `frontend/src/components/RestaurantDetail.tsx` 1개.
- 데이터 흐름: `restaurant` prop → `isKoreaRestaurant(restaurant)` 순수 판정 → JSX 렌더 게이트.
- I/O 없음, 순수 함수 기반 조건부 렌더링만 추가.
```
Restaurant (좌표/region)
isKoreaRestaurant(r) ──false──▶ 테이블링/캐치테이블 버튼 미렌더
true
기존 로직: url 존재 && != "NONE" → 버튼 렌더
```
## 6. 데이터 모델
- 입력: `Restaurant { latitude?, longitude?, region?, tabling_url?, catchtable_url? }` (기존 타입, 변경 없음).
- 출력: 없음 (렌더 분기만).
- 경계 검증: `isKoreaRestaurant()` 내부에서 이미 null/undefined 가드 처리(좌표 없으면 region 첫 토큰 fallback).
## 7. 함수 명세 (Function Specs)
| 함수 | 책임(1줄) | 시그니처 | 입력 | 출력 | 에러/실패 | 복잡? |
|------|-----------|----------|------|------|-----------|-------|
| `isKoreaRestaurant` | 좌표/region 기반 국내 식당 판정 (기존 함수, 변경 없음 — 적용 범위만 확대) | `(r: Restaurant) => boolean` | `Restaurant` | `boolean` | 없음 (순수 함수) | 단순 |
> 신규 함수 없음. 기존 `isKoreaRestaurant()`(30행, 지도 링크 분기용으로 이미 존재)를 예약 버튼 2곳에도 동일 적용.
## 8. 흐름 / 알고리즘
1. `isKoreaRestaurant(restaurant)` 평가 (좌표 우선, KR bbox 33~38.7°N / 124~132°E; 좌표 없으면 `region` 첫 토큰이 `"한국"`인지로 fallback).
2. `true`일 때만 기존 조건(`tabling_url`/`catchtable_url` 존재 && `!== "NONE"`)을 이어서 평가.
3. 최종 `true`일 때만 버튼 렌더.
## 9. 엣지케이스 & 에러 처리
- 좌표와 region 모두 없는 구 데이터 → `isKoreaRestaurant``!r.region` 조건으로 `true` 반환(국내로 간주, 기존 동작 유지 — 회귀 아님).
- 좌표가 KR bbox 경계값에 걸치는 경우(제주/울릉도 등) → 기존 지도 링크 판정과 동일 로직이므로 별도 리스크 없음(기존에 검증된 임계값 재사용).
## 10. 테스트 계획
- `tsc` 통과 (완료).
- 자동화 회귀 테스트(`frontend/__tests__/RestaurantDetail.test.tsx`, RTL) — [AI] QA 작성, 5케이스 전부 통과:
1. 국내 좌표(서울) + URL 존재 → 두 버튼 노출.
2. 해외 좌표(방콕, 대표 발견 케이스) + URL 존재 → 두 버튼 숨김.
3. 해외 좌표(도쿄) + URL 없음 → 숨김 유지(회귀 없음).
4. 좌표 없음 + region null → fallback으로 국내 간주, 버튼 노출(기존 동작 유지).
5. URL이 `"NONE"` 문자열 → 국내 식당이어도 버튼 없음(기존 게이트 유지).
## 11. 리스크 & 대안 검토
- 대안: 백엔드에서 해외 식당의 `tabling_url`/`catchtable_url`을 애초에 null로 채우지 않는 방법도 있으나, 프론트 게이트가 더 즉각적이고 기존 판정 로직 재사용 가능 → 채택.
- 되돌리기 어려운 결정 없음. ADR 불필요.
## 12. 미해결 질문 (Open Questions)
- 백엔드 예약 URL 수집/검색 단계에서 해외 식당을 원천 제외할지 여부는 별도 이슈로 분리할 가치가 있음 (설명에 "후속(옵션)"으로 명시됨).
## 13. Designer 다듬기 (표현/접점, 기능 변경 없음)
- 판정 로직(`isKoreaRestaurant`)과 노출 조건은 QA 통과 상태 그대로 — 변경하지 않음.
- 테이블링/캐치테이블 앵커에 `title` 속성 추가(`"… (새 탭)"`) — 새 탭 이동을 사전에 알려 예약 흐름 중 예기치 않은 이탈감 완화.
- 로고 대체용 단일 문자 배지(`T`/`C`)에 `aria-hidden="true"` 적용 — 스크린리더가 의미 없는 낱글자를 중복 낭독하지 않고 실제 라벨만 읽도록 정리.
- 회귀 확인: `tsc --noEmit` 통과, `frontend/__tests__/RestaurantDetail.test.tsx` 5/5 통과.

View File

@@ -0,0 +1,117 @@
/**
* #545 — 해외 식당 상세에서 테이블링/캐치테이블 예약 버튼 숨김 회귀 테스트.
* 좌표 기반(isKoreaRestaurant) 판정으로 국내/해외 식당에서 버튼 노출 여부가
* 올바르게 갈리는지 검증한다.
*/
import { act, render, screen } from "@testing-library/react";
import RestaurantDetail from "@/components/RestaurantDetail";
import type { Restaurant } from "@/lib/api";
// 컴포넌트 마운트 시 발생하는 비동기 데이터 로딩(영상/리뷰 등)을 플러시해
// act() 경고 없이 안정적으로 렌더 결과를 검증한다.
async function flush() {
await act(async () => {
await Promise.resolve();
});
}
jest.mock("@/lib/api", () => ({
getToken: jest.fn(() => null),
api: {
getRestaurantVideos: jest.fn(() => Promise.resolve([])),
getFavoriteStatus: jest.fn(() => Promise.resolve({ favorited: false })),
getReviews: jest.fn(() =>
Promise.resolve({ reviews: [], avg_rating: null, review_count: 0 })
),
getMemo: jest.fn(() => Promise.resolve(null)),
},
}));
function makeRestaurant(overrides: Partial<Restaurant>): Restaurant {
return {
id: "r1",
name: "테스트 식당",
address: null,
region: null,
latitude: 37.5,
longitude: 127.0,
cuisine_type: null,
price_range: null,
google_place_id: null,
tabling_url: null,
catchtable_url: null,
business_status: null,
rating: null,
rating_count: null,
phone: null,
website: null,
...overrides,
};
}
describe("RestaurantDetail — #545 해외 식당 예약 버튼 숨김", () => {
it("국내 식당(좌표 기반)이면 테이블링/캐치테이블 버튼이 보인다", async () => {
const restaurant = makeRestaurant({
latitude: 37.5665,
longitude: 126.978, // 서울
tabling_url: "https://tabling.example.com/1",
catchtable_url: "https://catchtable.example.com/1",
});
render(<RestaurantDetail restaurant={restaurant} onClose={() => {}} />);
await flush();
expect(screen.getByText("테이블링에서 줄서기")).toBeInTheDocument();
expect(screen.getByText("캐치테이블에서 예약하기")).toBeInTheDocument();
});
it("해외 식당(좌표가 KR bbox 밖)이면 두 버튼 모두 숨겨진다 (대표 발견 케이스)", async () => {
const restaurant = makeRestaurant({
latitude: 13.7563,
longitude: 100.5018, // 방콕
tabling_url: "https://tabling.example.com/1",
catchtable_url: "https://catchtable.example.com/1",
});
render(<RestaurantDetail restaurant={restaurant} onClose={() => {}} />);
await flush();
expect(screen.queryByText("테이블링에서 줄서기")).not.toBeInTheDocument();
expect(screen.queryByText("캐치테이블에서 예약하기")).not.toBeInTheDocument();
});
it("해외 식당이어도 예약 URL이 없으면 애초에 버튼이 없다 (회귀 없음 확인)", async () => {
const restaurant = makeRestaurant({
latitude: 35.6762,
longitude: 139.6503, // 도쿄
tabling_url: null,
catchtable_url: null,
});
render(<RestaurantDetail restaurant={restaurant} onClose={() => {}} />);
await flush();
expect(screen.queryByText("테이블링에서 줄서기")).not.toBeInTheDocument();
expect(screen.queryByText("캐치테이블에서 예약하기")).not.toBeInTheDocument();
});
it("좌표 없는 구 데이터는 region으로 fallback되어 국내로 간주된다 (기존 동작 유지)", async () => {
const restaurant = makeRestaurant({
latitude: undefined as unknown as number,
longitude: undefined as unknown as number,
region: null,
tabling_url: "https://tabling.example.com/1",
catchtable_url: null,
});
render(<RestaurantDetail restaurant={restaurant} onClose={() => {}} />);
await flush();
expect(screen.getByText("테이블링에서 줄서기")).toBeInTheDocument();
});
it("예약 URL이 'NONE' 문자열이면 국내 식당이어도 버튼이 없다 (기존 게이트 유지)", async () => {
const restaurant = makeRestaurant({
latitude: 37.5665,
longitude: 126.978,
tabling_url: "NONE",
catchtable_url: "NONE",
});
render(<RestaurantDetail restaurant={restaurant} onClose={() => {}} />);
await flush();
expect(screen.queryByText("테이블링에서 줄서기")).not.toBeInTheDocument();
expect(screen.queryByText("캐치테이블에서 예약하기")).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,36 @@
/**
* #533 — passesMinMentions() 순수 함수 단위 테스트.
* 설계서: docs/design/533-min-mentions-filter/README.md
*/
import { passesMinMentions } from "@/lib/filters";
describe("passesMinMentions", () => {
it("minMentions=0(기본)이면 필터 미적용 — 항상 true", () => {
expect(passesMinMentions(0, 0)).toBe(true);
expect(passesMinMentions(null, 0)).toBe(true);
expect(passesMinMentions(100, 0)).toBe(true);
});
it("음수 minMentions도 필터 미적용으로 취급한다", () => {
expect(passesMinMentions(0, -1)).toBe(true);
});
it("video_count 가 minMentions 미만이면 제외(false)", () => {
expect(passesMinMentions(1, 2)).toBe(false);
expect(passesMinMentions(0, 1)).toBe(false);
});
it("video_count 가 minMentions 와 같으면 포함(경계값 포함)", () => {
expect(passesMinMentions(2, 2)).toBe(true);
});
it("video_count 가 minMentions 초과면 포함", () => {
expect(passesMinMentions(5, 2)).toBe(true);
});
it("video_count 가 null/undefined(미산정)이면 0으로 간주한다", () => {
expect(passesMinMentions(null, 2)).toBe(false);
expect(passesMinMentions(undefined, 2)).toBe(false);
expect(passesMinMentions(null, 0)).toBe(true);
});
});

View File

@@ -0,0 +1,44 @@
/**
* #532 — formatRegion() 순수 함수 단위 테스트.
* 설계서: docs/design/532-region-display/README.md
*/
import { formatRegion } from "@/lib/region";
describe("formatRegion", () => {
it("falsy 입력은 빈 문자열", () => {
expect(formatRegion("")).toBe("");
expect(formatRegion(null)).toBe("");
expect(formatRegion(undefined)).toBe("");
});
it("파이프 구분 지역을 계층(breadcrumb)으로 변환한다", () => {
expect(formatRegion("한국|서울|강남구")).toBe("한국 서울 강남구");
});
it("'null' 토큰(대소문자 무시)은 제거한다", () => {
expect(formatRegion("일본|null")).toBe("일본");
expect(formatRegion("일본|NULL")).toBe("일본");
});
it("빈 토큰은 제거한다", () => {
expect(formatRegion("한국||관악구")).toBe("한국 관악구");
});
it("더미 토큰 '나라'는 제거한다", () => {
expect(formatRegion("나라|서울")).toBe("서울");
});
it("단일 토큰은 그대로 반환한다", () => {
expect(formatRegion("한국")).toBe("한국");
});
it("모든 토큰이 더미/빈 값이면 빈 문자열", () => {
expect(formatRegion("한국|")).toBe("한국");
expect(formatRegion("|")).toBe("");
expect(formatRegion("나라|null|")).toBe("");
});
it("토큰 앞뒤 공백은 trim 된다", () => {
expect(formatRegion(" 한국 | 서울 ")).toBe("한국 서울");
});
});

View File

@@ -15,6 +15,7 @@ import MyReviewsList from "@/components/MyReviewsList";
import BottomSheet from "@/components/BottomSheet";
import FilterSheet, { FilterOption } from "@/components/FilterSheet";
import { getCuisineIcon, getPhosphorCuisineIcon } from "@/lib/cuisine-icons";
import { passesMinMentions } from "@/lib/filters";
import Icon from "@/components/Icon";
import FoodIcon from "@/components/FoodIcon";
import * as PhosphorIcons from "@phosphor-icons/react";
@@ -196,6 +197,7 @@ export default function Home() {
const [channelFilter, setChannelFilter] = useState("");
const [cuisineFilter, setCuisineFilter] = useState("");
const [priceFilter, setPriceFilter] = useState("");
const [minMentions, setMinMentions] = useState(0); // #533 — 의미있는 언급 N회 미만 식당 제외 (0=전체)
const [viewMode, setViewMode] = useState<"map" | "list">("list");
const [mobileTab, setMobileTab] = useState<"home" | "list" | "nearby" | "favorites" | "profile">("home");
const [showMobileFilters, setShowMobileFilters] = useState(false);
@@ -248,6 +250,7 @@ export default function Home() {
if (channelFilter && !(r.channels || []).includes(channelFilter)) return false;
if (cuisineFilter && !matchCuisineFilter(r.cuisine_type, cuisineFilter)) return false;
if (priceFilter && !matchPriceGroup(r.price_range, priceFilter)) return false;
if (!passesMinMentions(r.video_count, minMentions)) return false;
if (countryFilter) {
const parsed = parseRegion(r.region);
if (!parsed || parsed.country !== countryFilter) return false;
@@ -271,7 +274,7 @@ export default function Home() {
if (da !== db) return da - db;
return (b.rating || 0) - (a.rating || 0);
});
}, [restaurants, isSearchResult, channelFilter, cuisineFilter, priceFilter, countryFilter, cityFilter, districtFilter, boundsFilterOn, mapBounds, userLoc]);
}, [restaurants, isSearchResult, channelFilter, cuisineFilter, priceFilter, minMentions, countryFilter, cityFilter, districtFilter, boundsFilterOn, mapBounds, userLoc]);
// FilterSheet option builders
const cuisineOptions = useMemo<FilterOption[]>(() => {
@@ -366,6 +369,7 @@ export default function Home() {
setChannelFilter("");
setCuisineFilter("");
setPriceFilter("");
setMinMentions(0);
setCountryFilter("");
setCityFilter("");
setDistrictFilter("");
@@ -385,10 +389,8 @@ export default function Home() {
const handleSelectRestaurant = useCallback((r: Restaurant) => {
setSelected(r);
setShowDetail(true);
// 지도가 선택 식당으로 이동/줌인 — 객체 새로 만들어 flyTo effect 매번 트리거
if (r.latitude != null && r.longitude != null) {
setRegionFlyTo({ lat: r.latitude, lng: r.longitude, zoom: 16 });
}
// selected 변경 자체가 MapView 내부의 useEffect에서 중앙 이동을 처리.
// (flyTo를 같이 set하면 줌 16으로 강제되어 클러스터 expansion 후 마커 클릭 시 다시 묶임)
}, []);
const handleCloseDetail = useCallback(() => {
@@ -479,6 +481,7 @@ export default function Home() {
setChannelFilter("");
setCuisineFilter("");
setPriceFilter("");
setMinMentions(0);
setCountryFilter("");
setCityFilter("");
setDistrictFilter("");
@@ -820,9 +823,23 @@ export default function Home() {
<option key={g.label} value={g.label}>{g.label}</option>
))}
</select>
{(cuisineFilter || priceFilter) && (
<div className="w-px h-3 bg-gray-200 dark:bg-gray-700" />
<select
value={minMentions}
onChange={(e) => { setMinMentions(Number(e.target.value)); if (Number(e.target.value)) setBoundsFilterOn(false); }}
className={`bg-transparent border-none outline-none cursor-pointer pr-1 ${
minMentions > 0 ? "text-brand-600 dark:text-brand-400 font-medium" : "text-gray-500 dark:text-gray-400"
}`}
title="영상에서 충분히 언급된(관련도 높은) 식당만 보기"
>
<option value={0}></option>
<option value={2}>2+</option>
<option value={3}>3+</option>
<option value={5}>5+</option>
</select>
{(cuisineFilter || priceFilter || minMentions > 0) && (
<button
onClick={() => { setCuisineFilter(""); setPriceFilter(""); }}
onClick={() => { setCuisineFilter(""); setPriceFilter(""); setMinMentions(0); }}
className="p-1.5 -mr-1 text-gray-400 hover:text-brand-500 transition-colors touch-manipulation"
title="음식 필터 초기화"
>

View File

@@ -163,11 +163,12 @@ function MapContent({ restaurants, selected, onSelectRestaurant, flyTo, activeCh
if (flyTo.zoom) map.setZoom(flyTo.zoom);
}, [map, flyTo]);
// Pan and zoom to selected restaurant
// Pan and zoom to selected restaurant — 현재 줌이 16 이상이면 유지(클러스터 expansion 후 재묶임 방지)
useEffect(() => {
if (!map || !selected) return;
map.panTo({ lat: selected.latitude, lng: selected.longitude });
map.setZoom(16);
const currentZoom = map.getZoom() ?? 13;
if (currentZoom < 16) map.setZoom(16);
setInfoTarget(selected);
}, [map, selected]);

View File

@@ -28,6 +28,7 @@ type NaverMapInstance = {
getZoom: () => number;
getBounds: () => { getNE: () => LatLng; getSW: () => LatLng };
panTo: (latlng: unknown, opts?: Record<string, unknown>) => void;
morph: (latlng: unknown, zoom?: number, transitionOptions?: Record<string, unknown>) => void;
refresh: (noEffect?: boolean) => void;
};
type NaverMarker = {
@@ -231,21 +232,24 @@ export default function NaverMapView({
}
}, [ready, flyTo, selected, onBoundsChanged]);
// flyTo 변경 반영
// flyTo 변경 반영 — morph로 panTo+setZoom 충돌 회피
useEffect(() => {
const m = mapRef.current;
if (!m || !flyTo || !window.naver?.maps) return;
m.panTo(new window.naver.maps.LatLng(flyTo.lat, flyTo.lng));
if (flyTo.zoom) m.setZoom(flyTo.zoom, true);
const latlng = new window.naver.maps.LatLng(flyTo.lat, flyTo.lng);
if (flyTo.zoom) m.morph(latlng, flyTo.zoom);
else m.panTo(latlng);
}, [flyTo]);
// selected 변경 시 자동 panTo + zoom (GoogleMapView와 동일 동작)
// selected 변경 시 자동 중앙 — 현재 줌이 16 이상이면 그대로, 미만이면 16으로 확대
// (클러스터 expansion으로 18까지 들어간 상태에서 마커 클릭 시 다시 16으로 줄어 클러스터링되는 것 방지)
useEffect(() => {
const m = mapRef.current;
if (!m || !selected || !window.naver?.maps) return;
if (selected.latitude == null || selected.longitude == null) return;
m.panTo(new window.naver.maps.LatLng(selected.latitude, selected.longitude));
m.setZoom(16, true);
const latlng = new window.naver.maps.LatLng(selected.latitude, selected.longitude);
const targetZoom = Math.max(m.getZoom(), 16);
m.morph(latlng, targetZoom);
}, [selected]);
// 클러스터 계산 (bounds/zoom 변경 시)
@@ -280,8 +284,7 @@ export default function NaverMapView({
});
naver.Event.addListener(marker, "click", () => {
const z = Math.min(getExpansionZoom(cluster_id), 18);
m.panTo(new naver.LatLng(lat, lng));
m.setZoom(z, true);
m.morph(new naver.LatLng(lat, lng), z);
});
markersRef.current.push(marker);
} else {

View File

@@ -7,6 +7,7 @@ import ReviewSection from "@/components/ReviewSection";
import MemoSection from "@/components/MemoSection";
import { RestaurantDetailSkeleton } from "@/components/Skeleton";
import Icon from "@/components/Icon";
import { formatRegion } from "@/lib/region";
interface RestaurantDetailProps {
restaurant: Restaurant;
@@ -127,9 +128,9 @@ export default function RestaurantDetail({
<span className="text-gray-400 dark:text-gray-500"></span> <span className="text-gray-600 dark:text-gray-300">{restaurant.address}</span>
</p>
)}
{restaurant.region && (
{formatRegion(restaurant.region) && (
<p>
<span className="text-gray-400 dark:text-gray-500"></span> <span className="text-gray-600 dark:text-gray-300">{restaurant.region}</span>
<span className="text-gray-400 dark:text-gray-500"></span> <span className="text-gray-600 dark:text-gray-300">{formatRegion(restaurant.region)}</span>
</p>
)}
{restaurant.price_range && (
@@ -180,7 +181,7 @@ export default function RestaurantDetail({
)}
</div>
{restaurant.tabling_url && restaurant.tabling_url !== "NONE" && (
{isKoreaRestaurant(restaurant) && restaurant.tabling_url && restaurant.tabling_url !== "NONE" && (
<a
href={restaurant.tabling_url}
target="_blank"
@@ -192,7 +193,7 @@ export default function RestaurantDetail({
</a>
)}
{restaurant.catchtable_url && restaurant.catchtable_url !== "NONE" && (
{isKoreaRestaurant(restaurant) && restaurant.catchtable_url && restaurant.catchtable_url !== "NONE" && (
<a
href={restaurant.catchtable_url}
target="_blank"

View File

@@ -3,6 +3,7 @@
import type { Restaurant } from "@/lib/api";
import { getCuisineIcon } from "@/lib/cuisine-icons";
import Icon from "@/components/Icon";
import { formatRegion } from "@/lib/region";
import { RestaurantListSkeleton } from "@/components/Skeleton";
interface RestaurantListProps {
@@ -50,8 +51,8 @@ export default function RestaurantList({
<Icon name={getCuisineIcon(r.cuisine_type)} size={16} className="mr-0.5 text-brand-600" />
{r.name}
</h4>
{r.region && (
<span className="text-[11px] text-gray-400 dark:text-gray-500 truncate">{r.region}</span>
{formatRegion(r.region) && (
<span className="text-[11px] text-gray-400 dark:text-gray-500 truncate">{formatRegion(r.region)}</span>
)}
{r.rating && (
<span className="text-xs text-yellow-600 dark:text-yellow-400 font-medium whitespace-nowrap shrink-0"> {r.rating}</span>

View File

@@ -51,6 +51,8 @@ export interface Restaurant {
website: string | null;
channels?: string[];
foods_mentioned?: string[];
// #533 — 의미있는(strong/unknown) 영상 언급 수
video_count?: number | null;
// #322 LLM 검증
hidden?: boolean;
hidden_reason?: string | null;
@@ -168,6 +170,7 @@ export const api = {
channel?: string;
limit?: number;
offset?: number;
min_mentions?: number;
}) {
const sp = new URLSearchParams();
if (params?.cuisine) sp.set("cuisine", params.cuisine);
@@ -175,6 +178,7 @@ export const api = {
if (params?.channel) sp.set("channel", params.channel);
if (params?.limit) sp.set("limit", String(params.limit));
if (params?.offset) sp.set("offset", String(params.offset));
if (params?.min_mentions) sp.set("min_mentions", String(params.min_mentions));
const qs = sp.toString();
return fetchApi<Restaurant[]>(`/api/restaurants${qs ? `?${qs}` : ""}`);
},

View File

@@ -0,0 +1,23 @@
// 클라이언트 필터 순수 함수 — #533
// page.tsx 의 filteredRestaurants 에서 사용하는 경계 로직을 테스트 가능하도록 분리.
/**
* "의미있는 언급 수(video_count)" 기준으로 식당을 포함할지 판정한다.
* 설계서: docs/design/533-min-mentions-filter/README.md
*
* - minMentions <= 0(기본값) → 필터 미적용, 항상 true.
* - videoCount 가 null/undefined(미산정) → 0 으로 간주.
* - videoCount >= minMentions 인 경우에만 true (경계값 포함).
*
* 예) passesMinMentions(1, 2) → false (1회는 2회 미만이라 제외)
* passesMinMentions(2, 2) → true (경계값 포함)
* passesMinMentions(null, 2) → false (미산정은 0 취급)
* passesMinMentions(0, 0) → true (필터 미적용)
*/
export function passesMinMentions(
videoCount: number | null | undefined,
minMentions: number
): boolean {
if (minMentions <= 0) return true;
return (videoCount ?? 0) >= minMentions;
}

View File

@@ -0,0 +1,22 @@
// 지역 표시 유틸 — #532
// region 은 "나라|시도|구군" 파이프 구분 문자열. 사용자에게는 계층(breadcrumb)으로 보여준다.
/** 표시에서 제외할 더미/무의미 토큰. */
const DUMMY_TOKENS = new Set(["", "null", "나라"]);
/**
* 파이프 구분 region 을 "나라 시도 구군" 계층 문자열로 변환한다.
* 빈 토큰·"null"·더미("나라")는 제거한다. 유효 토큰이 없으면 "".
*
* 예) "한국|서울|강남구" → "한국 서울 강남구"
* "일본|null" → "일본"
* "한국||관악구" → "한국 관악구"
*/
export function formatRegion(region: string | null | undefined): string {
if (!region) return "";
return region
.split("|")
.map((t) => t.trim())
.filter((t) => !DUMMY_TOKENS.has(t.toLowerCase()))
.join(" ");
}