-
Notifications
You must be signed in to change notification settings - Fork 1
Feat(client): tree 레벨 컴포넌트 구현 #54
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 9 commits
5029750
7c9a28d
2e03753
8c67c8a
6ba83f1
a97c1e7
b75597d
ceb5f80
878d41c
c8649e2
0802bb4
c643660
152c232
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,21 @@ | ||
| import TreeStatusCard from './components/TreeStatusCard'; | ||
| import LevelInfoCard from './components/LevelInfoCard'; | ||
|
|
||
| const Level = () => { | ||
| return <div>Level</div>; | ||
| return ( | ||
| <div className="bg-secondary flex flex-col gap-[2rem] p-[1rem]"> | ||
| <LevelInfoCard /> | ||
| <TreeStatusCard acorns={0} /> | ||
| <TreeStatusCard acorns={1} /> | ||
| <TreeStatusCard acorns={2} /> | ||
| <TreeStatusCard acorns={3} /> | ||
| <TreeStatusCard acorns={4} /> | ||
| <TreeStatusCard acorns={5} /> | ||
| <TreeStatusCard acorns={6} /> | ||
| <TreeStatusCard acorns={7} /> | ||
| <TreeStatusCard acorns={987987} /> | ||
| </div> | ||
| ); | ||
| }; | ||
|
|
||
| export default Level; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,68 @@ | ||
| import { cn } from '@pinback/design-system/utils'; | ||
| import { Level } from '@pinback/design-system/ui'; | ||
| import { Icon, type IconName } from '@pinback/design-system/icons'; | ||
| import { TREE_LEVEL_TABLE, type TreeLevel } from '../utils/treeLevel'; | ||
|
|
||
| const LEVEL_TOOLTIP_ICON = { | ||
| 1: 'tooltip_1', | ||
| 2: 'tooltip_2', | ||
| 3: 'tooltip_3', | ||
| 4: 'tooltip_4', | ||
| 5: 'tooltip_5', | ||
| } as const satisfies Record<TreeLevel, IconName>; | ||
|
|
||
| export default function LevelInfoCard() { | ||
| const rows = [...TREE_LEVEL_TABLE].reverse(); | ||
|
|
||
| return ( | ||
| <section | ||
| className={cn( | ||
| 'bg-white-bg common-shadow w-[24.6rem] rounded-[1.2rem] px-[1.6rem] py-[2.4rem]' | ||
| )} | ||
| aria-label="지식나무 숲 레벨 안내" | ||
| > | ||
| <h2 className="sub2-sb text-font-black-1 mb-[1.2rem] flex items-center justify-center"> | ||
| 치삐의 지식나무 숲 레벨 | ||
| </h2> | ||
|
|
||
| <ul> | ||
| {rows.map((row) => ( | ||
| <li | ||
| key={row.level} | ||
| className="flex w-full items-center justify-between py-[1.2rem]" | ||
| > | ||
| <div className="flex w-full items-center gap-[1.2rem]"> | ||
| <div className="bg-gray0 flex h-[4.6rem] w-[4.6rem] items-center justify-center rounded-[0.8rem]"> | ||
| <Icon | ||
| name={LEVEL_TOOLTIP_ICON[row.level]} | ||
| width={46} | ||
| height={46} | ||
| className="rounded-[0.8rem]" | ||
| aria-label={`${row.level} 썸네일 아이콘`} | ||
| /> | ||
| </div> | ||
|
|
||
| <div className="ml-[0.8rem] flex flex-1 flex-col gap-[0.4rem]"> | ||
| <div className="flex justify-between"> | ||
| <span className="sub5-sb text-font-black-1">{row.name}</span> | ||
| <Level level={row.level} aria-label={`레벨 ${row.level}`} /> | ||
| </div> | ||
|
|
||
| <span className="caption2-m text-font-gray-3"> | ||
| {row.rangeLabel} | ||
| </span> | ||
| </div> | ||
| </div> | ||
| </li> | ||
| ))} | ||
| </ul> | ||
|
|
||
| <div className="bg-gray0 mt-[0.8rem] rounded-[0.4rem] px-[0.8rem] py-[1.2rem]"> | ||
| <p className="caption2-m text-font-gray-3 flex items-center justify-center"> | ||
| 정보를 1분 동안 읽고 도토리를 모아보세요. 치삐를 행복하게 만들 수 | ||
| 있어요. | ||
| </p> | ||
| </div> | ||
| </section> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,38 @@ | ||||||||
| import { Level, Progress } from '@pinback/design-system/ui'; | ||||||||
| import { cn } from '@pinback/design-system/utils'; | ||||||||
| import { getTreeLevel } from '../utils/treeLevel'; | ||||||||
|
|
||||||||
| export interface TreeStatusCardProps { | ||||||||
| acorns: number; | ||||||||
| } | ||||||||
|
|
||||||||
| export default function TreeStatusCard({ acorns }: TreeStatusCardProps) { | ||||||||
| const info = getTreeLevel(acorns); | ||||||||
|
|
||||||||
| const barPercent = Math.min(100, info.level * 20); | ||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion 진행률 계산이 의도와 다르게 동작합니다 (레벨 1에서도 20%로 표기). 현재 - const barPercent = Math.min(100, info.level * 20);
+ // 레벨 내 진행률(0~100)
+ const barPercent = Math.round(info.progressToNext * 100);추가로 “전체 레벨 진행률(0~100)”이 필요하다면 utils에 계산 함수를 두고 여기서는 그 값을 사용하도록 분리하는 것을 권장합니다. 예시는 아래 코멘트에 첨부했습니다. 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 레벨 1 시작점도 20%로 보이도록 의도 되었습니다 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @jjangminii 네, 이해했습니다! 레벨 1부터 20%로 시작하는 것이 의도된 디자인이군요. 항상 어느 정도의 진행률을 시각적으로 보여주는 UX 관점에서 좋은 접근입니다. 제 이전 제안을 철회하고, 현재 구현이 올바르다는 점을 확인했습니다. 명확히 설명해주셔서 감사합니다. ✏️ Learnings added
|
||||||||
|
|
||||||||
| return ( | ||||||||
| <div | ||||||||
| className={cn('bg-white-bg w-[32.3rem] rounded-[1.2rem] p-[1.2rem]')} | ||||||||
| role="group" | ||||||||
| aria-label={`${info.name} 진행 카드`} | ||||||||
| > | ||||||||
| <div className="flex items-baseline"> | ||||||||
| <span className="head1 text-main500">{barPercent}%</span> | ||||||||
| </div> | ||||||||
|
|
||||||||
| <div className="mt-[0.8rem] flex items-center gap-[0.4rem]"> | ||||||||
| <span className="sub4-sb text-font-gray-2">{info.name}</span> | ||||||||
| <Level level={info.level} /> | ||||||||
| </div> | ||||||||
|
|
||||||||
| <div className="mt-[1.6rem]"> | ||||||||
| <Progress | ||||||||
| value={barPercent} | ||||||||
| variant="tree" | ||||||||
| aria-label={`${info.name} 레벨 진행률`} | ||||||||
| /> | ||||||||
| </div> | ||||||||
| </div> | ||||||||
| ); | ||||||||
| } | ||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,49 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export type TreeLevel = 1 | 2 | 3 | 4 | 5; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export interface TreeLevelRow { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| level: TreeLevel; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| name: string; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| min: number; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| max?: number; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| rangeLabel: string; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export const TREE_LEVEL_TABLE: readonly TreeLevelRow[] = [ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| { level: 1, name: '잊힌 기록의 숲', min: 0, max: 0, rangeLabel: '0개' }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| { level: 2, name: '햇살의 터전', min: 1, max: 2, rangeLabel: '1–2개' }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| { level: 3, name: '기록의 오솔길', min: 3, max: 4, rangeLabel: '3–4개' }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| { level: 4, name: '지식 나무 언덕', min: 5, max: 6, rangeLabel: '5–6개' }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| { level: 5, name: '도토리 만개 숲', min: 7, rangeLabel: '7개 이상' }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ] as const; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export type TreeLevel = 1 | 2 | 3 | 4 | 5; | |
| export interface TreeLevelRow { | |
| level: TreeLevel; | |
| name: string; | |
| min: number; | |
| max?: number; | |
| rangeLabel: string; | |
| } | |
| export const TREE_LEVEL_TABLE: readonly TreeLevelRow[] = [ | |
| { level: 1, name: '잊힌 기록의 숲', min: 0, max: 0, rangeLabel: '0개' }, | |
| { level: 2, name: '햇살의 터전', min: 1, max: 2, rangeLabel: '1–2개' }, | |
| { level: 3, name: '기록의 오솔길', min: 3, max: 4, rangeLabel: '3–4개' }, | |
| { level: 4, name: '지식 나무 언덕', min: 5, max: 6, rangeLabel: '5–6개' }, | |
| { level: 5, name: '도토리 만개 숲', min: 7, rangeLabel: '7개 이상' }, | |
| ] as const; | |
| // TREE_LEVEL_TABLE 먼저 as const로 정의 (유일한 원본) | |
| export const TREE_LEVEL_TABLE = [ | |
| { level: 1, name: '잊힌 기록의 숲', min: 0, max: 0, rangeLabel: '0개' }, | |
| { level: 2, name: '햇살의 터전', min: 1, max: 2, rangeLabel: '1–2개' }, | |
| { level: 3, name: '기록의 오솔길', min: 3, max: 4, rangeLabel: '3–4개' }, | |
| { level: 4, name: '지식 나무 언덕', min: 5, max: 6, rangeLabel: '5–6개' }, | |
| { level: 5, name: '도토리 만개 숲', min: 7, rangeLabel: '7개 이상' }, | |
| ] as const; | |
| // 이렇게 row 타입도 추출하고 | |
| export type TreeLevelRow = (typeof TREE_LEVEL_TABLE)[number]; | |
| // level 타입도 추출하고! | |
| export type TreeLevel = TreeLevelRow['level']; |
이렇게 되면 원본 TREE_LEVEL_TABLE만 추가해주면 알아서 타입도 적용이 되는 거죠!
export const TREE_LEVEL_TABLE: readonly TreeLevelRow[] = [그리고 as const가 애초에 readonly의 의미를 가지기도 하고요! 어떻게 생각하시나요??
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
확실히 지금 구조에서는 레벨 변동이나 추가에 있어서 불편할것같다는 생각이 드네요.. 지금 구조만 생각하고 추후 생각은 못했는데 이 부분은 진혁님 말씀처럼 수정하는게 좋을 것 같습니다-! 좋은 의견 감사합니다
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
범위 판정 로직을 max 대신 next.min 기준으로 단순화하세요
현재 (min ≤ count ≤ max)는 다음 레벨의 min과 정보 중복이며, 0과 1 사이 실수(예: 0.5)가 레벨1에 매칭되지 않는 가장자리 케이스도 있습니다. next?.min을 상한으로 쓰면 중복 제거와 함께 반열림 구간 [min, next.min)로 자연스럽게 해석됩니다.
아래처럼 변경 제안:
function findLevelRow(count: number, rows: readonly TreeLevelRow[]) {
- const idx = rows.findIndex(
- (r) => count >= r.min && (r.max === undefined || count <= r.max)
- );
+ const idx = rows.findIndex((r, i) => {
+ const next = rows[i + 1];
+ return count >= r.min && (next ? count < next.min : true);
+ });
const i = idx === -1 ? 0 : idx;
return { row: rows[i], next: rows[i + 1] as TreeLevelRow | undefined };
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| function findLevelRow(count: number, rows: readonly TreeLevelRow[]) { | |
| const idx = rows.findIndex( | |
| (r) => count >= r.min && (r.max === undefined || count <= r.max) | |
| ); | |
| const i = idx === -1 ? 0 : idx; | |
| return { row: rows[i], next: rows[i + 1] as TreeLevelRow | undefined }; | |
| } | |
| function findLevelRow(count: number, rows: readonly TreeLevelRow[]) { | |
| const idx = rows.findIndex((r, i) => { | |
| const next = rows[i + 1]; | |
| // use [min, next.min) for all but last row, and [min, ∞) for the final row | |
| return count >= r.min && (next ? count < next.min : true); | |
| }); | |
| const i = idx === -1 ? 0 : idx; | |
| return { row: rows[i], next: rows[i + 1] as TreeLevelRow | undefined }; | |
| } |
🤖 Prompt for AI Agents
In apps/client/src/pages/level/utils/treeLevel.ts around lines 26-32, the range
check currently uses r.max and allows edge cases and duplicate info with next
row; change the logic to locate the row by comparing count >= r.min and count <
next?.min (treating upper bound as exclusive) so ranges become [min, next.min)
and remove reliance on max; if no matching index found use index 0, and return {
row: rows[i], next: rows[i+1] } as before.
Outdated
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
단순 궁금증으로 acorns 개수를 floor와 max로 count를 계산하는 이유가 궁금해요!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
레벨 판정을 정수화나 이상값에 대해 안정적으로 하려고했는데 들어오는값이 안정적으로 들어온다면 굳이 필요없는 부분이긴합니다-! 이 부분은 서버와 상의해보고 다시 정리해봐도 좋을것같아요
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
서버랑 상의했습니다 이부분은 굳이 필요하지 않을거같아 수정하겠습니다
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
서버랑 상의했습니다 이부분은 굳이 필요하지 않을거같아 수정하겠습니다
너무 좋습니다~~ 굿굿 👍
Outdated
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
NaN/Infinity 입력 시 NaN 전파 가능 — 입력 정규화 보강 필요
acorns가 NaN/Infinity 등 비정상 값이면 progressToNext/remainingToNext가 NaN으로 전파될 수 있습니다. UI/ARIA에 그대로 반영되면 접근성/표시 문제가 납니다. 안전 가드 추가를 권장합니다.
-export function getTreeLevel(acorns: number): TreeLevelResult {
- const count = Math.max(0, Math.floor(acorns ?? 0));
+export function getTreeLevel(acorns: number): TreeLevelResult {
+ const n = Number(acorns);
+ const count = Number.isFinite(n) ? Math.max(0, Math.floor(n)) : 0;📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| export function getTreeLevel(acorns: number): TreeLevelResult { | |
| const count = Math.max(0, Math.floor(acorns ?? 0)); | |
| export function getTreeLevel(acorns: number): TreeLevelResult { | |
| const n = Number(acorns); | |
| const count = Number.isFinite(n) ? Math.max(0, Math.floor(n)) : 0; |
🤖 Prompt for AI Agents
In apps/client/src/pages/level/utils/treeLevel.ts around lines 25-27, the
function accepts acorns but doesn't guard against NaN/Infinity so downstream
calculations (progressToNext/remainingToNext) can become NaN; normalize the
input by validating Number.isFinite(acorns) and coercing to a safe numeric value
(e.g., 0) before Math.floor, clamp negatives to 0 and optionally cap to a
sensible max, then use that normalized finite integer for all subsequent
computations so no NaN/Infinity propagates to the UI/ARIA.
Outdated
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
저의 개인적인 생각일 뿐이지만, 저는 함수의 역할을 생각할 때 너무 길어지면 하나의 함수가 너무 많은 책임을 가지고 있다고 생각해요. 그래서 해당 함수도 어떻게 보면 여러가지 로직을 담고 있는 것 같은데 어떻게 생각하시는지 궁금해요!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
확실히 지금 함수는 행 선택, 다음 단계 탐색, 진행도 계산을 한꺼번에 하고 있어서 책임이 많아 보이고 가독성도 떨어지네요.. 이 부분은 함수 분리하겠습니다-!
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1,2 @@ | ||
| export { Icon } from './components/icon'; | ||
| export type { IconName } from './iconNames'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
확인했씁니당! 일정 수이상 커지면 걍 최대 100%되게 잘 해두신 것 같네용
머지 전에는 이 확인용으로 불러온 코드들 삭제해주세용!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
넵넵-!