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
10 changes: 10 additions & 0 deletions apps/client/src/shared/apis/axios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,13 @@ export const deleteRemindArticle = async (id: number) => {
const response = await apiRequest.delete(`/api/v1/articles/${id}`);
return response;
};

export const getGoogleProfile = async () => {
const { data } = await apiRequest.get('/api/v2/users/me/google-profile');
return data.data;
};

export const getMyProfile = async () => {
const { data } = await apiRequest.get('/api/v2/users/me');
return data.data;
};
17 changes: 17 additions & 0 deletions apps/client/src/shared/apis/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import {
getArticleDetail,
getAcorns,
deleteRemindArticle,
getGoogleProfile,
getMyProfile,
} from '@shared/apis/axios';
import { AxiosError } from 'axios';
import {
Expand Down Expand Up @@ -136,3 +138,18 @@ export const useGetPageMeta = (url: string) => {
retry: false,
});
};

export const useGetGoogleProfile = () => {
return useQuery({
queryKey: ['googleProfile'],
queryFn: getGoogleProfile,
staleTime: Infinity,
});
};
Comment on lines +142 to +148
Copy link
Member

Choose a reason for hiding this comment

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

이 부분은 따로 바뀌는 경우가 없어서 staleTimeInfinity로 지정하신거죠??


export function useGetMyProfile() {
return useQuery({
queryKey: ['myProfile'],
queryFn: getMyProfile,
});
}
81 changes: 81 additions & 0 deletions apps/client/src/shared/components/profilePopup/ProfilePopup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { Icon } from '@pinback/design-system/icons';
import { Button } from '@pinback/design-system/ui';
import formatRemindTime from '@shared/utils/formatRemindTime';
import { useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';

interface ProfilePopupProps {
open: boolean;
onClose: () => void;
profileImage: string | null;
email: string;
Comment on lines +9 to +11
Copy link
Collaborator

Choose a reason for hiding this comment

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

프로필 이미지가 null일 경우에는 이미지가 비어있게 되나요?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

기본 동그라미로 대체됩니다-!

name: string;
remindTime?: string;
}

export default function ProfilePopup({
open,
onClose,
profileImage,
email,
name,
remindTime,
}: ProfilePopupProps) {
const navigate = useNavigate();
const popupRef = useRef<HTMLDivElement>(null);

useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (popupRef.current && !popupRef.current.contains(e.target as Node)) {
onClose();
}
}

if (open) document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [open, onClose]);

if (!open) return null;

const handleLogout = () => {
localStorage.removeItem('token');
localStorage.removeItem('email');
navigate('/onboarding');
};

return (
<div className="fixed inset-0 z-[2000] flex items-start justify-start pl-[19rem] pt-[7rem]">
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

하드코딩된 위치 값으로 인한 유지보수성 저하.

pl-[19rem] pt-[7rem]은 사이드바 레이아웃에 강하게 결합되어 있습니다. 사이드바 너비나 헤더 높이가 변경되면 팝업 위치가 어긋나게 됩니다.

🔎 개선 방안

다음 중 하나를 선택하여 개선하세요:

방안 1: CSS 변수 사용

-<div className="fixed inset-0 z-[2000] flex items-start justify-start pl-[19rem] pt-[7rem]">
+<div className="fixed inset-0 z-[2000] flex items-start justify-start pl-[var(--sidebar-width)] pt-[var(--header-height)]">

방안 2: 동적 위치 계산
버튼 요소의 위치를 기준으로 동적으로 계산하는 앵커 포지셔닝 사용 (기존 useAnchoredMenu 훅 참고)

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In apps/client/src/shared/components/profilePopup/ProfilePopup.tsx around line
38, the popup is positioned using hardcoded padding classes `pl-[19rem]
pt-[7rem]`, which couples it to the sidebar/header layout; replace this with a
maintainable approach by either (a) using CSS custom properties (e.g.,
--sidebar-width and --header-height) and applying padding via utility classes or
inline styles that reference those variables so layout changes update position
automatically, or (b) switching to dynamic anchor positioning: compute the popup
location from the trigger/button element (reuse the existing useAnchoredMenu
hook pattern) to place the popup relative to the anchor instead of fixed rem
offsets; update classnames/styles accordingly and remove the hardcoded padding
values.

<div
ref={popupRef}
className="common-shadow flex w-[26rem] flex-col items-center rounded-[1.2rem] bg-white pb-[2.4rem] pt-[3.2rem]"
>
<div className="mb-[0.8rem] flex h-[13.2rem] w-[13.2rem] items-center justify-center overflow-hidden rounded-full bg-gray-100">
{profileImage ? (
<img
src={profileImage}
alt="프로필"
className="h-full w-full object-cover"
/>
) : (
<div className="h-full w-full bg-gray-200" />
)}
</div>

<p className="sub1-sb">{name}</p>
<p className="body4-r text-font-gray-3 mt-[0.8rem]">{email}</p>

<div className="text-font-gray-3 mb-[1.6rem] flex items-center gap-[0.2rem]">
<Icon name="ic_clock_active" width={18} height={18} />
<span className="">리마인드 알람&nbsp;</span>
<span className="caption2-m">{formatRemindTime(remindTime)}</span>
</div>

<div className="w-full px-[7.6rem]">
<Button variant="secondary" size="small" onClick={handleLogout}>
로그아웃
</Button>
</div>
Comment on lines 73 to 77
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

로그아웃 버튼 기능이 구현되지 않았습니다.

버튼이 렌더링되지만 onClick 핸들러가 없어 클릭해도 아무 동작도 하지 않습니다. 사용자가 로그아웃을 시도할 수 없습니다.

로그아웃 기능 구현을 도와드릴까요? Firebase 인증 로그아웃 로직과 라우팅 처리가 필요합니다.

🔎 임시 해결책

기능 구현 전까지 버튼을 비활성화하거나 제거하는 것을 권장합니다:

-        <div className="w-full px-[7.6rem]">
-          <Button variant="secondary" size="small">
-            로그아웃
-          </Button>
-        </div>
+        {/* TODO: 로그아웃 기능 구현 필요 */}
📝 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.

Suggested change
<div className="w-full px-[7.6rem]">
<Button variant="secondary" size="small">
로그아웃
</Button>
</div>
<div className="w-full px-[7.6rem]">
<Button
variant="secondary"
size="small"
onClick={handleLogout}
>
로그아웃
</Button>
</div>
🤖 Prompt for AI Agents
In apps/client/src/shared/components/profilePopup/ProfilePopup.tsx around lines
64 to 68, the Logout button is rendered without an onClick handler so it
performs no action; implement an onClick that calls Firebase Auth signOut,
awaits completion, handles errors (log/show toast), and then navigates the user
to the login (or landing) route; while signing out, disable the button (or show
loading) to prevent duplicate clicks and clear any client-side user state
(context/store) before/after navigation as appropriate; ensure necessary imports
for Firebase auth and the router (or use existing auth/logout utilities) are
added and any promise rejections are caught and reported.

</div>
</div>
);
}
60 changes: 48 additions & 12 deletions apps/client/src/shared/components/sidebar/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,18 @@ import {
useGetArcons,
usePutCategory,
useDeleteCategory,
useGetGoogleProfile,
useGetMyProfile,
} from '@shared/apis/queries';
import { useEffect, useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import ProfilePopup from '../profilePopup/ProfilePopup';

export function Sidebar() {
const [newCategoryName, setNewCategoryName] = useState('');
const [toastIsOpen, setToastIsOpen] = useState(false);
const [profileOpen, setProfileOpen] = useState(false);

const queryClient = useQueryClient();
const navigate = useNavigate();
Expand All @@ -33,6 +37,15 @@ export function Sidebar() {
const { mutate: createCategory } = usePostCategory();
const { data, isPending } = useGetArcons();
const { mutate: deleteCategory } = useDeleteCategory();
const { data: googleProfileData } = useGetGoogleProfile();
Copy link

@coderabbitai coderabbitai bot Dec 17, 2025

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

프로필 로딩 및 에러 상태 처리 부재.

useGetGoogleProfile에서 isLoadingisError 상태를 추출하지 않아, 프로필 로딩 중이거나 요청이 실패해도 사용자에게 피드백이 없습니다. 특히 하단의 MyLevelItem은 로딩 스켈레톤을 보여주는 반면(226-228줄), 프로필 영역은 일관성 있는 로딩 처리가 없습니다.

다음과 같이 수정하세요:

-  const { data: googleProfileData } = useGetGoogleProfile();
+  const { data: googleProfileData, isLoading: isProfileLoading } = useGetGoogleProfile();

그리고 UI에서 로딩 상태를 처리하세요:

   <button
     className="h-[3.6rem] w-[3.6rem] flex-shrink-0 overflow-hidden rounded-full border border-gray-200"
     onClick={() => console.log('프로필 클릭', profileImageUrl)}
   >
-    {profileImageUrl ? (
+    {isProfileLoading ? (
+      <div className="h-full w-full animate-pulse bg-gray-200" />
+    ) : profileImageUrl ? (
       <img
         src={profileImageUrl}
         alt="프로필 이미지"
         className="h-full w-full object-cover"
       />
     ) : (
       <div className="h-full w-full bg-gray-200" />
     )}
   </button>
📝 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.

Suggested change
const { data: googleProfileData } = useGetGoogleProfile();
const { data: googleProfileData, isLoading: isProfileLoading } = useGetGoogleProfile();
🤖 Prompt for AI Agents
apps/client/src/shared/components/sidebar/Sidebar.tsx around line 37:
useGetGoogleProfile call only destructures data and omits isLoading/isError, so
the profile section lacks loading/error handling; update the hook call to
extract { data: googleProfileData, isLoading: isGoogleProfileLoading, isError:
isGoogleProfileError } (or similar names), then in the JSX conditionally render
a loading skeleton when isGoogleProfileLoading, an error fallback/message when
isGoogleProfileError, and the normal profile UI when data is present; also
ensure MyLevelItem’s existing skeleton behavior remains consistent with the new
profile-loading state so the sidebar shows a unified loading state.

Copy link
Member

Choose a reason for hiding this comment

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

해당 API pending관련 처리는 이후에 추가될까요??

Copy link

Choose a reason for hiding this comment

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

@constantly-dev, 좋은 질문입니다!

현재 PR에서 로딩 상태 처리를 추가하실 계획인가요, 아니면 추후 별도 PR에서 진행하실 예정인가요?

만약 이후에 추가하실 계획이시라면, 추적을 위해 별도 이슈를 생성해드릴까요? 이렇게 하면 향후 UX 개선 작업 시 놓치지 않고 처리하실 수 있을 것 같습니다. 😊


🧠 Learnings used
Learnt from: constantly-dev
Repo: Pinback-Team/pinback-client PR: 2
File: pnpm-workspace.yaml:3-3
Timestamp: 2025-08-18T13:48:59.065Z
Learning: constantly-dev는 docs 디렉터리를 컨벤션 문서 추가용으로 사용할 예정이라고 명시했습니다.

Learnt from: constantly-dev
Repo: Pinback-Team/pinback-client PR: 5
File: apps/extension/src/index.css:1-1
Timestamp: 2025-08-19T17:18:57.678Z
Learning: constantly-dev는 디자인 시스템 설정 PR 머지 후 `import 'pinback/tailwind-config/shared-styles.css';`를 `app.css`나 `index.css`에서 사용하여 공유 스타일을 관리할 계획입니다.

const { data: myProfile } = useGetMyProfile();

const profileImageUrl = googleProfileData?.googleProfile || null;
Comment on lines +41 to +43
Copy link
Collaborator

Choose a reason for hiding this comment

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

사이드바 프로필 클릭할때, 그때 유저정보 불러오는게 아니라,
사이드바 전체 fetch할때 한번에 팝업 내용도 받아오는 구조인거죠? 좋네용


const chippiImageUrl = myProfile?.profileImage ?? null;
const profileEmail = myProfile?.email ?? '';
const profileName = myProfile?.name ?? '';
const remindAt = myProfile?.remindAt ?? 'AM 09:00';
Copy link
Collaborator

Choose a reason for hiding this comment

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

제꺼 알람시간 설정 변동이 어려워서, 다른 시간대 (PM 09:00? 등의 경우)는 UI 확인 못하였는데ㅠㅠ

서버에서 remindAt을
"remindAt": "9:00" 그냥 이렇게 주는 걸루 아는데, 오전/오후 포맷팅까지 적용된걸까용!
(아마 시간포맷팅 로직: 패키지/ds에 공통함수로 쓸 수 있도록 빼두긴했슴!)

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

시간 포맷팅은 확인하지 못했네요.. 수정하겠습니다


const {
activeTab,
Expand Down Expand Up @@ -76,9 +89,7 @@ export function Sidebar() {
queryClient.invalidateQueries({ queryKey: ['dashboardCategories'] });
close();
},
onError: () => {
setToastIsOpen(true);
},
onError: () => setToastIsOpen(true),
});
};

Expand All @@ -92,9 +103,7 @@ export function Sidebar() {
close();
moveNewCategory(id);
},
onError: () => {
setToastIsOpen(true);
},
onError: () => setToastIsOpen(true),
}
);
};
Expand All @@ -105,9 +114,7 @@ export function Sidebar() {
queryClient.invalidateQueries({ queryKey: ['dashboardCategories'] });
close();
},
onError: () => {
setToastIsOpen(true);
},
onError: () => setToastIsOpen(true),
});
};

Expand All @@ -128,16 +135,34 @@ export function Sidebar() {
return (
<aside className="bg-white-bg sticky top-0 h-screen w-[24rem] border-r border-gray-300">
<div className="flex h-full flex-col px-[0.8rem]">
<header className="px-[0.8rem] py-[2.8rem]">
{/* 헤더 */}
<header className="flex items-center justify-between px-[0.8rem] py-[2.8rem]">
<Icon
name="logo"
aria-label="Pinback 로고"
className="h-[2.4rem] w-[8.7rem] cursor-pointer"
/>

<button
type="button"
className="h-[3.6rem] w-[3.6rem] flex-shrink-0 overflow-hidden rounded-full border border-gray-200"
onClick={() => setProfileOpen(true)}
>
Comment on lines 146 to 150
Copy link
Member

Choose a reason for hiding this comment

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

button 태그는 항상 명시적으로 type을 지정해주세요!

{profileImageUrl ? (
<img
src={profileImageUrl}
alt="프로필"
className="h-full w-full object-cover"
/>
) : (
<div className="h-full w-full bg-gray-200" />
)}
</button>
</header>

<hr className="my-[0.8rem] border-gray-100" />

{/* 메뉴 영역 */}
<div className="flex-1 overflow-y-auto">
<SideItem
icon="clock"
Expand Down Expand Up @@ -211,19 +236,30 @@ export function Sidebar() {
acorns={acornCount}
isActive={activeTab === 'level'}
onClick={() => {
setSelectedCategoryId(null);
closeMenu();
setSelectedCategoryId(null);
goLevel();
}}
/>
)}
</footer>
</div>

{/* 팝업 영역 */}

<ProfilePopup
open={profileOpen}
onClose={() => setProfileOpen(false)}
profileImage={chippiImageUrl}
name={profileName}
email={profileEmail}
remindTime={remindAt}
/>

<PopupPortal
popup={popup}
onClose={handlePopupClose}
onChange={handleCategoryChange}
onChange={setNewCategoryName}
onCreateConfirm={handleCreateCategory}
onEditConfirm={(id) => handlePatchCategory(id)}
onDeleteConfirm={(id) => handleDeleteCategory(id)}
Expand Down
28 changes: 26 additions & 2 deletions apps/client/src/shared/hooks/useSidebarNav.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,39 @@
import { useNavigate } from 'react-router-dom';
import { useCallback, useState } from 'react';
import { useNavigate, useLocation, useSearchParams } from 'react-router-dom';
import { useCallback, useEffect, useState } from 'react';

export type SidebarTab = 'mybookmark' | 'remind' | 'level';

export function useSidebarNav() {
const navigate = useNavigate();
const location = useLocation();
const [searchParams] = useSearchParams();

const [activeTab, setActiveTab] = useState<SidebarTab>('remind');
const [selectedCategoryId, setSelectedCategoryId] = useState<number | null>(
null
);

useEffect(() => {
const path = location.pathname;

if (path.startsWith('/my-bookmarks')) {
setActiveTab('mybookmark');

const id = searchParams.get('id');
if (id) {
setSelectedCategoryId(Number(id));
} else {
setSelectedCategoryId(null);
}
} else if (path === '/' || path.startsWith('/remind')) {
setActiveTab('remind');
setSelectedCategoryId(null);
} else if (path.startsWith('/level')) {
setActiveTab('level');
setSelectedCategoryId(null);
}
}, [location.pathname, searchParams]);
Comment on lines +16 to +35
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

URL 쿼리 파라미터 파싱 시 유효성 검증이 필요합니다.

Line 24에서 Number(id)id가 유효한 숫자 문자열이 아닐 경우 NaN을 반환합니다. 이는 selectedCategoryId의 타입이 number | null임에도 불구하고 NaN이 설정될 수 있어 하위 컴포넌트에서 예상치 못한 동작을 유발할 수 있습니다.

다음 diff를 적용하여 안전하게 파싱하세요:

       const id = searchParams.get('id');
       if (id) {
-        setSelectedCategoryId(Number(id));
+        const numId = parseInt(id, 10);
+        setSelectedCategoryId(isNaN(numId) ? null : numId);
       } else {
         setSelectedCategoryId(null);
       }
🤖 Prompt for AI Agents
In apps/client/src/shared/hooks/useSidebarNav.ts around lines 16 to 35, the code
directly uses Number(id) (line 24) which can produce NaN if the query param is
not a valid numeric string; instead, validate and parse the id safely: check
that id is not null and matches a numeric pattern (or use parseInt and verify
Number.isFinite/!Number.isNaN and optionally Number.isInteger) before calling
setSelectedCategoryId; if validation fails, setSelectedCategoryId(null). Ensure
the branch logic only calls setSelectedCategoryId with a valid number or null.


const goRemind = useCallback(() => {
setActiveTab('remind');
setSelectedCategoryId(null);
Expand Down
22 changes: 22 additions & 0 deletions apps/client/src/shared/utils/formatRemindTime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
function formatRemindTime(time: string | undefined): string {
if (!time) return '';

const [period, timePart] = time.split(' ');
const [hourStr, minute] = timePart.split(':');

let hour = Number(hourStr);

if (isNaN(hour)) return '';

Comment on lines +1 to +10
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

입력 형식 검증 및 에러 처리가 누락되었습니다.

다음과 같은 문제가 있습니다:

  1. 런타임 에러 위험: Line 4에서 split(' ')의 결과가 2개 미만의 요소를 가질 경우, timePartundefined가 되어 Line 5에서 런타임 에러가 발생합니다.
  2. 검증 부족: period가 "AM" 또는 "PM"인지 검증하지 않습니다.
  3. 불완전한 검증: minuteundefined인 경우를 처리하지 않아, Line 19에서 "PM 1:undefined" 같은 잘못된 출력이 발생할 수 있습니다.
🔎 제안하는 수정 사항
 function formatRemindTime(time: string | undefined): string {
   if (!time) return '';
 
   const [period, timePart] = time.split(' ');
+  
+  // 입력 형식 검증
+  if (!period || !timePart || (period !== 'AM' && period !== 'PM')) {
+    return '';
+  }
+  
   const [hourStr, minute] = timePart.split(':');
+  
+  if (!hourStr || !minute) {
+    return '';
+  }
 
   let hour = Number(hourStr);
 
   if (isNaN(hour)) return '';
📝 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.

Suggested change
function formatRemindTime(time: string | undefined): string {
if (!time) return '';
const [period, timePart] = time.split(' ');
const [hourStr, minute] = timePart.split(':');
let hour = Number(hourStr);
if (isNaN(hour)) return '';
function formatRemindTime(time: string | undefined): string {
if (!time) return '';
const [period, timePart] = time.split(' ');
// 입력 형식 검증
if (!period || !timePart || (period !== 'AM' && period !== 'PM')) {
return '';
}
const [hourStr, minute] = timePart.split(':');
if (!hourStr || !minute) {
return '';
}
let hour = Number(hourStr);
if (isNaN(hour)) return '';
🤖 Prompt for AI Agents
In apps/client/src/shared/utils/formatRemindTime.ts around lines 1 to 10, the
function assumes time.split(' ') returns two parts and that timePart.split(':')
returns hour and minute and that period is "AM"/"PM"; add input validation and
early returns: verify time is a non-empty string, split by whitespace and ensure
you have exactly two parts (period and timePart), trim and uppercase period and
validate it is "AM" or "PM", ensure timePart contains ':' and that both hour and
minute exist and are numeric (return '' on any validation failure), parse hour
safely and adjust for AM/PM rules, and finally format the minute with
zero-padding before returning.

if (period === 'PM') {
hour = hour === 12 ? 12 : hour - 12;
}
Comment on lines +11 to +13
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

PM 시간 변환 로직에 치명적인 버그가 있습니다.

현재 로직 hour = hour === 12 ? 12 : hour - 12은 다음과 같은 문제를 발생시킵니다:

  • 입력이 "PM 1:00" (12시간 형식)인 경우: hour = 1 - 12 = -11 → 출력 "PM -11:00" ❌
  • 입력이 "PM 2:00" (12시간 형식)인 경우: hour = 2 - 12 = -10 → 출력 "PM -10:00" ❌

이 로직은 입력이 "PM 13:00", "PM 14:00" 같은 혼합 형식(AM/PM 접두사 + 24시간 형식 시)일 때만 올바르게 작동합니다. 그러나 이는 매우 비표준적인 형식입니다.

올바른 변환 로직:

  • 24시간 → 12시간 변환: hour = hour > 12 ? hour - 12 : hour
  • 12시간 형식이 이미 입력이라면 변환이 필요 없음

실제 입력 데이터의 형식을 확인하기 위해 다음 스크립트를 실행해주세요:

#!/bin/bash
# Description: ProfilePopup에서 remindTime이 어떤 형식으로 전달되는지 확인

# remindTime 사용처 검색
rg -n -C5 'remindTime' --type=tsx --type=ts
🤖 Prompt for AI Agents
In apps/client/src/shared/utils/formatRemindTime.ts around lines 11-13, the PM
conversion uses hour = hour === 12 ? 12 : hour - 12 which produces negative
hours for standard 12-hour inputs; change the logic to only convert when the
input is in 24-hour form (i.e., when hour > 12) by using hour = hour > 12 ? hour
- 12 : hour for PM handling, and ensure that if input appears to already be
12-hour (1–12) you leave it unchanged; also add a quick runtime check or comment
to document expected input format and run the provided ripgrep command to verify
remindTime formats across the codebase before committing.


if (period === 'AM' && hour === 0) {
hour = 12;
}
Comment on lines +15 to +17
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

AM 시간 처리 로직이 혼란스러운 입력 형식을 가정하고 있습니다.

Line 15-17의 로직은 hour === 0을 체크하여 12로 변환하는데, 이는 "AM 00:30" 같은 입력(자정을 24시간 형식 + AM 접두사로 표현)을 가정합니다.

문제점:

  • 표준 12시간 형식에서는 시간이 0이 될 수 없습니다 (1-12 범위)
  • 표준 24시간 형식에서는 AM/PM 접두사가 없습니다
  • 현재 함수는 비표준 혼합 형식을 가정하고 있어 유지보수와 이해가 어렵습니다

권장사항:

입력 형식을 명확히 정의하고, 다음 중 하나를 선택하세요:

  1. 표준 12시간 형식 ("1:00 PM", "12:00 AM") → 파싱만 하고 변환 불필요
  2. 24시간 형식 ("13:00", "00:00") → 12시간 형식으로 변환하고 AM/PM 추가
🔎 24시간 → 12시간 변환을 위한 올바른 구현 예시
function formatRemindTime(time: string | undefined): string {
  if (!time) return '';
  
  // 24시간 형식 "HH:MM" 파싱
  const [hourStr, minute] = time.split(':');
  
  if (!hourStr || !minute) return '';
  
  let hour = Number(hourStr);
  
  if (isNaN(hour) || hour < 0 || hour > 23) return '';
  
  // 24시간 → 12시간 변환
  const period = hour >= 12 ? 'PM' : 'AM';
  const hour12 = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour;
  
  return `${period} ${hour12}:${minute}`;
}
🤖 Prompt for AI Agents
In apps/client/src/shared/utils/formatRemindTime.ts around lines 15-17, the
current check converting hour 0 to 12 assumes a nonstandard mixed 24h+AM format;
instead choose and enforce one input format and implement proper parsing: if you
accept 24-hour inputs, parse "HH:MM", validate hour 0-23 and minute 00-59,
compute period = hour >= 12 ? 'PM' : 'AM', compute hour12 = hour === 0 ? 12 :
hour > 12 ? hour - 12 : hour, and return `${period} ${hour12}:${minute}`; if you
accept 12-hour inputs, validate the hour is 1-12 with an AM/PM suffix and simply
normalize/return without converting; also add input validation, clear typings,
and update tests or callers to match the chosen format.


return `${period} ${hour}:${minute}`;
}

export default formatRemindTime;
Loading