Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
a049b93
feat : TJ 공식 차트 연동 기능 추가 (#295)
GulSam00 Aug 5, 2026
471a452
chore : 불일치 로그 업데이트
GulSam00 Aug 5, 2026
feeee00
feat : TJ 차트 월 이동 화살표 및 장르 뱃지 필터 적용 (#295)
GulSam00 Aug 10, 2026
a8c4fd1
fix : TJ 차트 API 요청 timeout 및 월 파라미터 처리 보완 (#295)
GulSam00 Aug 10, 2026
8a22dd8
doc : crawling CLAUDE.md TJ 차트 파이프라인 반영 (#295)
GulSam00 Aug 10, 2026
950e148
feat : 인기차트 제목 분리 및 장르 뱃지 이모지·휠 가로 스크롤 적용 (#295)
GulSam00 Aug 11, 2026
a29e060
feat : TJ 반주 버전 뱃지 수집 파이프라인 추가 (#295)
GulSam00 Aug 11, 2026
29574a5
chore : TJ 백필 구간 갱신 및 수집 로그 추가 (#295)
GulSam00 Aug 11, 2026
165e644
doc : crawling CLAUDE.md 뱃지 파이프라인 반영 (#295)
GulSam00 Aug 11, 2026
29ada6a
feat : 차트 미매칭 곡 자동 보충 및 API 타임아웃·재시도 보완 (#295)
GulSam00 Aug 11, 2026
f233e83
chore : 해소된 TJ 차트 미매칭 로그 정리 (#295)
GulSam00 Aug 11, 2026
5152aef
chore : TJ 차트 워크플로우에 미매칭 로그 아티팩트 업로드 추가 (#295)
GulSam00 Aug 11, 2026
328cb03
feat : 인기차트에 TJ 반주 버전 뱃지 노출 (#295)
GulSam00 Aug 11, 2026
623314f
feat : 검색 결과에도 반주 버전 뱃지 표시 및 UI 통일 (#295)
GulSam00 Aug 11, 2026
706021c
feat : TJ에서 사라진 번호의 곡 정리 스크립트 추가 (#295)
GulSam00 Aug 11, 2026
d5c09a3
fix : 60 뱃지 라벨에 TJ 명시 (#295)
GulSam00 Aug 11, 2026
285f61d
fix : 뱃지 묶음 앞에 TJ 표식을 붙여 출처 명시 (#295)
GulSam00 Aug 11, 2026
89a5563
fix : TJ 표식을 뱃지 테두리 안으로 이동 (#295)
GulSam00 Aug 11, 2026
0f65d84
fix : 신곡이 죽은 곡으로 오인돼 삭제되던 문제 (#295)
GulSam00 Aug 11, 2026
9555959
feat : 장르 필터 스크롤바 노출 및 이모지 교체 (#295)
GulSam00 Aug 11, 2026
66a46fc
feat : 가이드 예시 카드 가상화, 검색 추천 제거, 곡 카드 디자인 통일 (#295)
GulSam00 Aug 12, 2026
5ad9e3a
fix : 60 뱃지에만 TJ 표식 유지, MR·LIVE는 제거 (#295)
GulSam00 Aug 12, 2026
88678c5
fix : 번호 검색에서 언어 필터 숨김 및 값 초기화 (#295)
GulSam00 Aug 12, 2026
d95acce
fix : 국가 뱃지 줄바꿈 대신 가로 스크롤 (#295)
GulSam00 Aug 12, 2026
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
15 changes: 15 additions & 0 deletions .github/workflows/crawl_recent_tj.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,18 @@ jobs:
- name: run crawl script - crawlRecentTJ.ts
working-directory: packages/crawling
run: pnpm run recent-tj

# recent-tj가 넣는 신곡은 badges가 null이다. 그대로 두면 뱃지가 표시되지 않고,
# badges is null을 판정 기준으로 쓰는 정리 스크립트가 신곡을 죽은 곡으로 오인한다.
- name: run crawl script - crawlTjBadges.ts
working-directory: packages/crawling
run: pnpm run tj-badges

- name: Upload badge log
if: always()
uses: actions/upload-artifact@v4
with:
name: tj-badges-log-${{ github.run_id }}
path: packages/crawling/src/assets/tjBadgeErrors.txt
if-no-files-found: ignore
retention-days: 30
13 changes: 13 additions & 0 deletions .github/workflows/crawl_tj_all_number.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,16 @@ jobs:
START_NUMBER: ${{ matrix.range.start }}
END_NUMBER: ${{ matrix.range.end }}
run: pnpm run tj-all-number

# 변경 내역은 러너 파일시스템에만 쌓이므로 잡이 끝나면 사라진다.
# 실패해도(if: always()) 그때까지의 기록을 받을 수 있게 아티팩트로 올린다.
- name: Upload change log
if: always()
uses: actions/upload-artifact@v4
with:
name: tj-all-number-log-${{ matrix.range.start }}-${{ matrix.range.end }}
path: |
packages/crawling/src/assets/tjAllNumberLog.txt
packages/crawling/src/assets/tjAllNumberProgress.json
if-no-files-found: ignore
retention-days: 30
49 changes: 49 additions & 0 deletions .github/workflows/crawl_tj_chart.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
name: Crawl TJ Chart

on:
schedule:
- cron: "0 1 1 * *" # 매달 1일 KST 오전 10:00 실행 (UTC+9 → UTC 01:00)
workflow_dispatch:

jobs:
run-npm-task:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- name: Use Node.js 20
uses: actions/setup-node@v4
with:
node-version: "20"

- name: Install pnpm
uses: pnpm/action-setup@v2
with:
version: 9
run_install: false

- name: Install dependencies
working-directory: packages/crawling
run: pnpm install

- name: Create .env file
working-directory: packages/crawling
run: |
echo "SUPABASE_URL=${{ secrets.SUPABASE_URL }}" >> .env
echo "SUPABASE_KEY=${{ secrets.SUPABASE_KEY }}" >> .env

- name: run crawl script - crawlTjChart.ts
working-directory: packages/crawling
run: pnpm run tj-chart

# 미매칭·경고 로그는 러너 파일시스템에만 쌓여 잡이 끝나면 사라진다.
# 실패해도(if: always()) 그때까지의 기록을 받을 수 있게 아티팩트로 올린다.
- name: Upload unmatched log
if: always()
uses: actions/upload-artifact@v4
with:
name: tj-chart-unmatched-${{ github.run_id }}
path: packages/crawling/src/assets/tjChartUnmatched.txt
if-no-files-found: ignore
retention-days: 30
1 change: 1 addition & 0 deletions apps/web/src/app/api/search/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,7 @@ export async function GET(request: Request): Promise<NextResponse<ApiResponse<Se
artist_ko: song.artist_ko,
num_tj: song.num_tj,
num_ky: song.num_ky,
badges: song.badges,
isToSing: authenticated
? (song.tosings?.some(tosing => tosing.user_id === userId) ?? false)
: false,
Expand Down
79 changes: 79 additions & 0 deletions apps/web/src/app/api/tj-chart/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { NextRequest, NextResponse } from 'next/server';

import createClient from '@/lib/supabase/server';
import { ApiResponse } from '@/types/apiRoute';
import { Song } from '@/types/song';
import { StrType, TjChartResponse } from '@/types/tjChart';

interface ChartRow {
rank: number;
songs: Song | null;
}

const MONTH_PATTERN = /^\d{4}-\d{2}-\d{2}$/;

// availableMonths를 만들 때 chart_rankings 전체를 조회하면 PostgREST row 상한에 걸려 과거 월이 잘린다.
// 월당 최대 10 row만 반환되도록 종합 차트 상위권으로 좁혀서 조회한다.
const MONTH_LOOKUP_RANK_LIMIT = 10;

export async function GET(
request: NextRequest,
): Promise<NextResponse<ApiResponse<TjChartResponse>>> {
try {
const supabase = await createClient();
const searchParams = request.nextUrl.searchParams;

const genreParam = searchParams.get('genre') ?? StrType.All;
const genre = Object.values(StrType).includes(genreParam as StrType)
? (genreParam as StrType)
: StrType.All;

// 1) 데이터가 존재하는 월 목록 조회
const { data: monthRows, error: monthError } = await supabase
.from('chart_rankings')
.select('chart_month')
.eq('type', StrType.All)
.lte('rank', MONTH_LOOKUP_RANK_LIMIT)
.order('chart_month', { ascending: false });
Comment on lines +31 to +37

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

2. Incomplete month list 🐞 Bug ≡ Correctness

GET /api/tj-chart가 chart_rankings의 모든 row에서 chart_month를 가져와 availableMonths를 만들기 때문에, Supabase 응답
row 제한/트렁케이션으로 오래된 월이 누락될 수 있습니다. 그 결과 월 선택 UI가 실제 DB에 있는 과거 월 데이터를 숨길 수 있습니다.
Agent Prompt
### Issue description
`/api/tj-chart`에서 `availableMonths`를 만들기 위해 `chart_rankings` 전체에서 `chart_month`를 조회하고 `Set`으로 중복 제거하고 있습니다. `chart_rankings`는 월/장르/순위 단위로 row 수가 많아지기 때문에, Supabase가 한 번의 select에서 반환하는 row 수 제한에 걸려 과거 월이 응답에 포함되지 않을 수 있고, 그 상태로 `availableMonths`를 계산하면 월 목록이 잘못됩니다.

### Issue Context
- 크롤러는 `StrType` 전체에 대해 TOP 차트를 수집/저장하므로 월 단위로 다수의 row가 생성됩니다.
- 레포 내 Supabase 조회 코드에서도 row 제한 존재를 인지하고 `.limit(...)`를 사용하는 패턴이 이미 있습니다.

### Fix Focus Areas
- apps/web/src/app/api/tj-chart/route.ts[25-45]

### Suggested fix
- 월 목록 조회 쿼리를 **중복이 구조적으로 발생하지 않도록** 바꾸세요. 예를 들어 월당 1개 row만 나오도록 고정 조건을 추가하면 됩니다.
  - 예: `type = StrType.All` AND `rank = 1`만 조회
  - 또는 DB view/RPC로 `select distinct chart_month ...`를 제공

예시(개념):
```ts
const { data: monthRows, error: monthError } = await supabase
  .from('chart_rankings')
  .select('chart_month')
  .eq('type', StrType.All)
  .eq('rank', 1)
  .order('chart_month', { ascending: false });

const availableMonths = (monthRows ?? []).map(r => r.chart_month as string);
```

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


if (monthError) throw monthError;

const availableMonths = [...new Set((monthRows ?? []).map(row => row.chart_month as string))];

if (availableMonths.length === 0) {
return NextResponse.json({
success: true,
data: { month: '', genre, availableMonths: [], items: [] },
});
}

// 형식만 검증하고 요청한 월을 그대로 조회한다.
// (데이터가 없는 월이면 items가 빈 배열로 내려가고, 클라이언트가 빈 상태를 표시한다)
const monthParam = searchParams.get('month');
const targetMonth =
monthParam && MONTH_PATTERN.test(monthParam) ? monthParam : availableMonths[0];

// 2) 해당 월 + 장르의 차트 순위 조회 (songs 테이블과 조인)
const { data, error } = await supabase
.from('chart_rankings')
.select('rank, songs(id, title, artist, title_ko, artist_ko, num_tj, num_ky, badges)')
.eq('chart_month', targetMonth)
.eq('type', genre)
.order('rank', { ascending: true })
.returns<ChartRow[]>();

if (error) throw error;

const items = (data ?? [])
.filter((row): row is ChartRow & { songs: Song } => row.songs !== null)
.map(row => ({ ...row.songs, rank: row.rank }));

return NextResponse.json({
success: true,
data: { month: targetMonth, genre, availableMonths, items },
});
} catch (error) {
console.error('Error in tj-chart API:', error);
return NextResponse.json({ success: false, error: 'Failed to get tj chart' }, { status: 500 });
}
}
67 changes: 67 additions & 0 deletions apps/web/src/app/popular/ChartGenreFilter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
'use client';

import { useEffect, useRef } from 'react';

import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { STR_TYPE_EMOJI, STR_TYPE_LABEL, StrType } from '@/types/tjChart';

// 장르가 12종이라 줄바꿈 없이 한 줄로 두고 가로 스크롤로 훑게 한다.
// TabsTrigger 기본 스타일의 flex-1(균등 분할)은 flex-none으로 해제해야 칩 너비가 라벨에 맞는다.
const CHIP_CLASSES =
'flex-none rounded-full border border-border bg-transparent px-3 py-1 text-xs font-medium text-muted-foreground shadow-none transition-all data-[state=active]:border-foreground data-[state=active]:bg-foreground data-[state=active]:text-background dark:data-[state=active]:border-foreground dark:data-[state=active]:bg-foreground dark:data-[state=active]:text-background';

// 더 훑을 게 남았다는 걸 알 수 있도록 가로 스크롤바를 노출한다.
// 기본 스크롤바는 두꺼우므로 thin으로 얇게 하고, pb로 칩과 스크롤바 사이를 띄운다.
// thumb 색은 border 토큰이 다크 테마에서 배경과 거의 같아 muted-foreground를 쓴다.
// (Chrome 121+는 scrollbar-width가 ::-webkit-scrollbar보다 우선하므로 표준 속성을 함께 지정)
const LIST_CLASSES =
'h-auto w-full flex-nowrap justify-start gap-1 overflow-x-auto bg-transparent p-0 pb-1 ' +
'[scrollbar-width:thin] [scrollbar-color:var(--color-muted-foreground)_transparent] ' +
'[&::-webkit-scrollbar]:h-1.5 [&::-webkit-scrollbar-track]:bg-transparent ' +
'[&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-muted-foreground';

interface ChartGenreFilterProps {
value: StrType;
onChange: (value: StrType) => void;
}

export default function ChartGenreFilter({ value, onChange }: ChartGenreFilterProps) {
const listRef = useRef<HTMLDivElement>(null);

// 데스크톱 마우스 휠은 세로 델타만 발생시켜 가로 목록을 훑을 수 없다. 세로 델타를 가로 스크롤로 넘긴다.
// React의 onWheel은 passive 리스너로 붙어 preventDefault가 막히므로 네이티브로 직접 등록한다.
useEffect(() => {
const list = listRef.current;
if (!list) return;

const handleWheel = (event: WheelEvent) => {
// 트랙패드의 가로 제스처는 브라우저 기본 동작에 맡긴다.
if (Math.abs(event.deltaY) <= Math.abs(event.deltaX)) return;
if (list.scrollWidth <= list.clientWidth) return;

// 양 끝에 닿았으면 페이지 세로 스크롤을 가로채지 않는다.
const atStart = list.scrollLeft <= 0;
const atEnd = list.scrollLeft + list.clientWidth >= list.scrollWidth - 1;
if ((event.deltaY < 0 && atStart) || (event.deltaY > 0 && atEnd)) return;

event.preventDefault();
list.scrollLeft += event.deltaY;
};

list.addEventListener('wheel', handleWheel, { passive: false });
return () => list.removeEventListener('wheel', handleWheel);
}, []);

return (
<Tabs value={value} onValueChange={next => onChange(next as StrType)} className="w-full">
<TabsList ref={listRef} className={LIST_CLASSES}>
{Object.values(StrType).map(type => (
<TabsTrigger key={type} value={type} className={CHIP_CLASSES}>
<span aria-hidden="true">{STR_TYPE_EMOJI[type]}</span>
{STR_TYPE_LABEL[type]}
</TabsTrigger>
))}
</TabsList>
</Tabs>
);
}
122 changes: 122 additions & 0 deletions apps/web/src/app/popular/ChartRankingList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
'use client';

import { addMonths, format, parseISO, startOfMonth } from 'date-fns';
import { ChevronLeft, ChevronRight, Construction } from 'lucide-react';
import { useState } from 'react';

import SongSummary from '@/components/SongSummary';
import StaticLoading from '@/components/StaticLoading';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { ScrollArea } from '@/components/ui/scroll-area';
import { useTjChartQuery } from '@/queries/tjChartQuery';
import { StrType } from '@/types/tjChart';
import { cn } from '@/utils/cn';
import { getPrevMonthFirstDayKST } from '@/utils/kst';

import ChartGenreFilter from './ChartGenreFilter';

const MONTH_FORMAT = 'yyyy-MM-dd';

const getRankStyle = (rank: number) => {
switch (rank) {
case 1:
return 'bg-amber-500 text-white font-bold';
case 2:
return 'bg-gray-300 text-white font-bold';
case 3:
return 'bg-amber-700 text-white font-bold';
default:
return 'bg-muted text-muted-foreground';
}
};

const formatMonth = (month: string) => {
const [year, m] = month.split('-');
return `${year}년 ${Number(m)}월`;
};

const shiftMonth = (month: string, delta: number) =>
format(startOfMonth(addMonths(parseISO(month), delta)), MONTH_FORMAT);

export default function ChartRankingList() {
const [genre, setGenre] = useState<StrType>(StrType.All);
// 차트는 월 단위로 마감되므로 이번 달 데이터는 아직 없다. 전월을 기본으로 보여준다.
const [month, setMonth] = useState(getPrevMonthFirstDayKST);

const { data, isPending, isError, isPlaceholderData } = useTjChartQuery(month, genre);

if (isPending) {
return <StaticLoading />;
}

const availableMonths = data?.availableMonths ?? [];
const items = data?.items ?? [];

// availableMonths는 최신순이므로 마지막 원소가 가장 오래된 월이다.
const oldestMonth = availableMonths.at(-1);
const canGoPrev = !!oldestMonth && month > oldestMonth;
const canGoNext = month < getPrevMonthFirstDayKST();

return (
<Card className="relative flex min-h-0 flex-1 flex-col">
<CardHeader className="flex shrink-0 flex-col gap-3 pb-2">
{/* 차트 제목은 페이지 h1로 빠졌고, 헤더에는 조회 월만 중앙에 둔다. */}
<div className="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="icon"
className="size-8"
aria-label="이전 달"
disabled={!canGoPrev}
onClick={() => setMonth(shiftMonth(month, -1))}
>
<ChevronLeft />
</Button>

<span className="w-24 text-center text-sm font-medium">{formatMonth(month)}</span>

<Button
variant="ghost"
size="icon"
className="size-8"
aria-label="다음 달"
disabled={!canGoNext}
onClick={() => setMonth(shiftMonth(month, 1))}
>
<ChevronRight />
</Button>
</div>

<ChartGenreFilter value={genre} onChange={setGenre} />
</CardHeader>

<ScrollArea className="min-h-0 flex-1">
<CardContent className="pt-0">
<div className={cn('space-y-0 transition-opacity', isPlaceholderData && 'opacity-50')}>
{isError || items.length === 0 ? (
<div className="flex h-64 flex-col items-center justify-center gap-4">
<Construction className="text-muted-foreground h-16 w-16" />
<p className="text-muted-foreground text-xl">데이터를 준비중이에요</p>
</div>
) : (
items.map(item => (
<div key={item.id} className={cn('flex gap-4 border-b py-3 last:border-0')}>
<div
className={cn(
'flex h-8 w-8 shrink-0 items-center justify-center rounded-full',
getRankStyle(item.rank),
)}
>
{item.rank}
</div>
<SongSummary song={item} />
</div>
))
)}
</div>
</CardContent>
</ScrollArea>
</Card>
);
}
Loading