Skip to content

마이히스토리 스타일 수정#92

Merged
Jieunsse merged 1 commit into
mainfrom
feature/history
Feb 26, 2026
Merged

마이히스토리 스타일 수정#92
Jieunsse merged 1 commit into
mainfrom
feature/history

Conversation

@HWAN0218

@HWAN0218 HWAN0218 commented Feb 25, 2026

Copy link
Copy Markdown
Contributor

Summary

  • 브레이크 포인트 마진 수정 및 폴더명 소문자로 변경

Issue

Scope

  • 포함

    • 변경한 내용
  • 특이사항

@HWAN0218 HWAN0218 added this to the 페이지 milestone Feb 25, 2026
@HWAN0218 HWAN0218 requested review from a team, Jieunsse, jungwon123 and yooolleee February 25, 2026 19:19
@HWAN0218 HWAN0218 self-assigned this Feb 25, 2026
@HWAN0218 HWAN0218 added the 리팩토링 리팩토링 및 리뷰내용 반영시 작성해주세요 label Feb 25, 2026
@HWAN0218 HWAN0218 added this to workers Feb 25, 2026
@github-project-automation github-project-automation Bot moved this to 개발 대기중 in workers Feb 25, 2026
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Summary of Changes

Hello @HWAN0218, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

이 PR은 사용자가 자신의 완료된 작업을 효율적으로 추적하고 관리할 수 있도록 '내 활동' 페이지를 도입합니다. 이 페이지는 월별 및 카테고리별로 작업을 필터링하고, 작업 세부 정보를 확인하며, 팀 관련 작업을 수행할 수 있는 기능을 제공합니다. 전반적인 사용자 경험을 향상시키기 위해 반응형 디자인과 데이터 처리 로직이 개선되었습니다.

Highlights

  • 새로운 '내 활동' 페이지 추가: 사용자가 완료한 작업을 월별 및 작업 목록별로 볼 수 있는 새로운 '내 활동' 페이지가 추가되었습니다.
  • 반응형 디자인 개선: 다양한 화면 크기에 맞춰 페이지 레이아웃과 컴포넌트의 브레이크포인트 마진이 조정되었습니다.
  • API 통합 및 데이터 관리: 사용자 정보, 그룹 상세 정보, 완료된 작업, 작업 댓글을 가져오고 작업을 완료 처리하거나 삭제하는 API 쿼리 및 뮤테이션이 구현되었습니다.

🧠 New Feature in Public Preview: You can now enable Memory to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Changelog
  • src/app/(root)/myhistory/page.module.css
    • 새로운 '내 활동' 페이지의 레이아웃, 컴포넌트 및 반응형 디자인을 위한 CSS 스타일이 추가되었습니다.
  • src/app/(root)/myhistory/page.tsx
    • 작업 표시, 필터링, 세부 정보 보기 및 UI 상태 관리를 구현하는 '내 활동' 페이지의 주요 React 컴포넌트가 추가되었습니다.
  • src/app/(root)/myhistory/queries.ts
    • 사용자 데이터, 그룹 세부 정보, 완료된 작업 및 작업 댓글을 가져오기 위한 API 쿼리 함수와 타입이 추가되었으며, 작업 및 팀 관리를 위한 뮤테이션도 포함되었습니다.
Activity
  • HWAN0218님이 '마이히스토리 스타일 수정'이라는 제목으로 PR을 생성했습니다.
  • PR 본문에 브레이크포인트 마진 수정 및 폴더명 소문자 변경에 대한 요약이 포함되었습니다.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@HWAN0218 HWAN0218 moved this from 개발 대기중 to PR 리뷰중 in workers Feb 25, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

마이히스토리 페이지의 스타일과 로직이 추가되었네요. 전반적으로 기능 구현은 잘 된 것 같지만, 코드의 유지보수성과 재사용성을 높이기 위해 몇 가지 개선할 점이 보입니다. 특히 CSS에서는 다른 모듈의 스타일을 직접 참조하는 부분이 있어 깨지기 쉬우며, React 컴포넌트는 너무 많은 역할을 하고 있어 분리가 필요해 보입니다. 자세한 내용은 각 파일에 남긴 코멘트를 참고해주세요.

Comment on lines +65 to +72
@media (min-width: 1200px) {
:global(.TeamHeader-module__H3kcRq__container) {
width: 100%;
max-width: 1200px;
margin-left: auto !important;
margin-right: auto !important;
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

CSS 모듈의 해시된 클래스 이름(.TeamHeader-module__H3kcRq__container)을 :global과 함께 사용하여 스타일을 오버라이드하는 방식은 매우 취약합니다. TeamHeader 컴포넌트의 스타일이 변경되면 이 코드는 깨질 수 있습니다. 더 나은 방법은 부모 컴포넌트에서 자식 컴포넌트로 className prop을 전달하여 스타일을 적용하거나, CSS 변수를 사용하여 커스터마이징하는 것입니다. 이 파일의 다른 부분(예: 375, 569, 585 라인)에서도 동일한 패턴이 발견되는데, 함께 수정하는 것을 권장합니다. 또한 !important의 사용이 많은데, 이는 유지보수를 어렵게 만들 수 있으므로 꼭 필요한 경우에만 제한적으로 사용하는 것이 좋습니다.

Comment on lines +91 to +116
/** 체크박스/케밥/버튼 클릭이면 디테일 오픈 금지 */
function isOpenDetailBlockedTarget(target: HTMLElement | null) {
if (!target) return false;

if (target.closest('button, a, input, textarea, select, [role="button"]')) return true;
if (target.closest('[role="checkbox"]')) return true;
if (target.closest('[aria-checked]')) return true;

const labeled = target.closest('[aria-label]') as HTMLElement | null;
if (labeled) {
const v = (labeled.getAttribute('aria-label') ?? '').toLowerCase();
if (v.includes('체크') || v.includes('완료') || v.includes('더보기') || v.includes('kebab')) {
return true;
}
}

const cls = (target.className ?? '').toString().toLowerCase();
if (cls.includes('checkbox') || cls.includes('kebab') || cls.includes('more')) return true;

if (target.tagName.toLowerCase() === 'svg' || target.tagName.toLowerCase() === 'path') {
const p = target.parentElement;
if (p && isOpenDetailBlockedTarget(p)) return true;
}

return false;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

isOpenDetailBlockedTarget 함수는 클릭된 대상에 따라 상세 정보창 열기를 막는 로직을 포함하고 있습니다. 클래스 이름이나 aria-label의 텍스트 내용에 의존하는 방식은 깨지기 쉽고 유지보수가 어렵습니다. 더 나은 접근 방식은 상세 정보창을 열지 않아야 하는 내부의 인터랙티브 요소(버튼, 체크박스 등)들의 onClick 핸들러에서 e.stopPropagation()을 호출하여 이벤트 버블링을 막는 것입니다. 이렇게 하면 taskRowonClick 핸들러는 항상 실행되지만, 원치 않는 내부 요소의 클릭 이벤트는 상위로 전파되지 않아 로직이 훨씬 단순하고 안정적으로 됩니다.

Comment on lines +118 to +723
export default function MyHistory() {
const qc = useQueryClient();
const router = useRouter();
const params = useParams<{ teamid?: string }>();
const teamId = params?.teamid ?? '';

// ===== API =====
const { data: me } = useMe();
useEffect(() => {
if (me) {
qc.setQueryData(['currentUser'], me);
}
}, [me, qc]);

const groups = useMemo(() => {
const arr = (me?.memberships ?? []).map((m) => m.group);
return arr.filter((g, idx) => arr.findIndex((x) => x.id === g.id) === idx);
}, [me?.memberships]);

const activeGroupId = groups[0]?.id ?? 0;
const activeGroup = useMemo(
() => groups.find((g) => g.id === activeGroupId) ?? null,
[groups, activeGroupId],
);

const { data: groupDetail } = useGroupDetail(activeGroupId);
const taskLists = useMemo(() => {
return (groupDetail?.taskLists ?? []).slice().sort((a, b) => a.displayIndex - b.displayIndex);
}, [groupDetail?.taskLists]);

// ===== month =====
const [userSelectedMonth, setUserSelectedMonth] = useState<string | null>(null);
const defaultMonth = useMemo(() => ymFromDate(new Date()), []);
const selectedMonth = userSelectedMonth ?? defaultMonth;

const { fromIso: selectedFromIso, toIso: selectedToIso } = useMemo(
() => monthRangeIso(selectedMonth),
[selectedMonth],
);

// 선택월 다음달 시작까지(=선택월 포함) 받아오기
const earliestFromIso = '2000-01-01T00:00:00.000Z';
const nextMonth = useMemo(() => addMonths(selectedMonth, 1), [selectedMonth]);
const { fromIso: toIso } = useMemo(() => monthRangeIso(nextMonth), [nextMonth]);

// ===== done tasks =====
const taskListIds = useMemo(() => taskLists.map((t) => t.id), [taskLists]);

const { tasksDoneAll, isLoading: isDoneLoading } = useDoneTasksForTaskLists({
groupId: activeGroupId,
taskListIds,
fromIso: earliestFromIso,
toIso,
});

// ===== 선택 월만 추리기 =====
const selectedFromT = useMemo(() => new Date(selectedFromIso).getTime(), [selectedFromIso]);
const selectedToT = useMemo(() => new Date(selectedToIso).getTime(), [selectedToIso]);

const tasksDoneSelectedMonth = useMemo(() => {
return tasksDoneAll.filter((t: DoneTask) => {
const iso = t.doneAt ?? t.date ?? '';
if (!iso) return false;
const time = new Date(iso).getTime();
return time >= selectedFromT && time < selectedToT;
});
}, [tasksDoneAll, selectedFromT, selectedToT]);

// ===== category selection =====
const [selectedTaskListId, setSelectedTaskListId] = useState<number | null>(null);

const categoriesInSelectedMonth = useMemo(() => {
const map = new Map<number, number>();
tasksDoneSelectedMonth.forEach((t: DoneTask) => {
const id = (t.taskListId ?? 0) as number;
if (!id) return;
map.set(id, (map.get(id) ?? 0) + 1);
});

return taskLists.map((tl) => ({
id: tl.id,
label: tl.name,
count: map.get(tl.id) ?? 0,
}));
}, [taskLists, tasksDoneSelectedMonth]);

const firstNonZeroTaskListId = useMemo(() => {
return categoriesInSelectedMonth.find((c) => c.count > 0)?.id ?? null;
}, [categoriesInSelectedMonth]);

const fallbackFirstTaskListId = useMemo(() => taskLists[0]?.id ?? null, [taskLists]);

const effectiveTaskListId = useMemo(() => {
if (selectedTaskListId != null && taskLists.some((t) => t.id === selectedTaskListId)) {
return selectedTaskListId;
}
return firstNonZeroTaskListId ?? fallbackFirstTaskListId;
}, [selectedTaskListId, taskLists, firstNonZeroTaskListId, fallbackFirstTaskListId]);

const filteredTasks = useMemo<DoneTask[]>(() => {
if (!effectiveTaskListId) return [];
return tasksDoneSelectedMonth.filter(
(t: DoneTask) => (t.taskListId ?? 0) === effectiveTaskListId,
);
}, [tasksDoneSelectedMonth, effectiveTaskListId]);

const tasksByDate = useMemo(() => {
const map = new Map<string, DoneTask[]>();

filteredTasks.forEach((t: DoneTask) => {
const iso = t.doneAt ?? t.date ?? '';
const dayKey = iso ? dayKeyFromIso(iso) : '1970-01-01';
const arr = map.get(dayKey) ?? [];
arr.push(t);
map.set(dayKey, arr);
});

const keys = Array.from(map.keys()).sort((a, b) => (a > b ? -1 : 1));

return keys.map((k) => {
const list = (map.get(k) ?? []).slice().sort((a, b) => {
const ai = a.doneAt ?? a.date ?? '';
const bi = b.doneAt ?? b.date ?? '';
return ai > bi ? -1 : 1;
});
return { dayKey: k, tasks: list };
});
}, [filteredTasks]);

// ✅ PC 왼쪽: “데이터 있는 달만” 표시 (선택월 이하만)
const leftMonthBlocks = useMemo(() => {
const monthMap = new Map<string, Map<number, number>>();

tasksDoneAll.forEach((t) => {
const iso = t.doneAt ?? t.date ?? '';
const tlId = (t.taskListId ?? 0) as number;
if (!iso || !tlId) return;

const mk = monthKeyFromIso(iso);
if (mk > selectedMonth) return;

const inner = monthMap.get(mk) ?? new Map<number, number>();
inner.set(tlId, (inner.get(tlId) ?? 0) + 1);
monthMap.set(mk, inner);
});

const months = Array.from(monthMap.keys()).sort((a, b) => (a > b ? -1 : 1));

return months
.map((monthKey) => {
const inner = monthMap.get(monthKey) ?? new Map<number, number>();
const categories = taskLists.map((tl) => ({
id: tl.id,
label: tl.name,
count: inner.get(tl.id) ?? 0,
}));
const total = categories.reduce((acc, c) => acc + c.count, 0);

return {
monthKey,
monthLabel: formatYearMonthFromKey(monthKey),
categories,
total,
};
})
.filter((b) => b.total > 0);
}, [tasksDoneAll, taskLists, selectedMonth]);

// ===== UI states =====
const [openedTaskMenuId, setOpenedTaskMenuId] = useState<number | null>(null);
const [calendarOpen, setCalendarOpen] = useState(false);
const [teamMenuOpen, setTeamMenuOpen] = useState(false);

// detail overlay
const [detailMounted, setDetailMounted] = useState(false);
const [detailOpen, setDetailOpen] = useState(false);
const [selectedTaskId, setSelectedTaskId] = useState<number>(0);

const effectiveSelectedTaskId = useMemo(() => {
if (filteredTasks.length === 0) return 0;
if (filteredTasks.some((t) => t.id === selectedTaskId)) return selectedTaskId;
return filteredTasks[0].id;
}, [filteredTasks, selectedTaskId]);

const selectedTask = useMemo(() => {
if (!effectiveSelectedTaskId) return null;
return filteredTasks.find((t) => t.id === effectiveSelectedTaskId) ?? null;
}, [filteredTasks, effectiveSelectedTaskId]);

const { data: apiComments = [] } = useTaskComments(effectiveSelectedTaskId);
const createComment = useCreateTaskComment(effectiveSelectedTaskId);

const meWriter: Writer = useMemo(
() => ({
id: me?.id ?? 0,
nickname: me?.nickname ?? '',
image: me?.image ?? null,
}),
[me],
);

const detailComments: UiComment[] = useMemo(() => {
return apiComments.map((c) => ({
id: c.id,
content: c.content,
createdAt: c.createdAt,
updatedAt: c.updatedAt,
taskId: c.taskId ?? effectiveSelectedTaskId,
userId: c.userId ?? c.user?.id ?? 0,
user: {
id: c.user?.id ?? 0,
nickname: c.user?.nickname ?? '',
image: c.user?.image ?? null,
},
}));
}, [apiComments, effectiveSelectedTaskId]);

function closeDetailImmediate() {
setDetailOpen(false);
setDetailMounted(false);
}
function openDetail() {
setDetailMounted(true);
requestAnimationFrame(() => setDetailOpen(true));
}
function closeDetail() {
setDetailOpen(false);
window.setTimeout(() => setDetailMounted(false), 260);
}

const handleOpenDetail = (taskId: number) => {
setOpenedTaskMenuId(null);
if (detailMounted && detailOpen && taskId === effectiveSelectedTaskId) {
closeDetail();
return;
}
setSelectedTaskId(taskId);
openDetail();
};

const invalidateCurrentRange = async () => {
await qc.invalidateQueries({ queryKey: ['doneTasks'] });
};

async function apiToggleDone(task: DoneTask, done: boolean) {
if (!activeGroupId || !task.taskListId) return;
await patchTaskDone({
groupId: activeGroupId,
taskListId: task.taskListId,
taskId: task.id,
done,
});
await invalidateCurrentRange();
}

async function apiDelete(task: DoneTask) {
if (!activeGroupId || !task.taskListId) return;
await deleteTask({ groupId: activeGroupId, taskListId: task.taskListId, taskId: task.id });
await invalidateCurrentRange();
}

const onPrevMonth = () => {
const prev = addMonths(selectedMonth, -1);
setUserSelectedMonth(prev);
setOpenedTaskMenuId(null);
setCalendarOpen(false);
setTeamMenuOpen(false);
closeDetailImmediate();
};

const onNextMonth = () => {
const next = addMonths(selectedMonth, 1);
setUserSelectedMonth(next);
setOpenedTaskMenuId(null);
setCalendarOpen(false);
setTeamMenuOpen(false);
closeDetailImmediate();
};

const monthLabel = formatYearMonthFromKey(selectedMonth);

// TeamHeader settingsLink 클릭 / outside close
const lastTeamMenuToggleAt = useRef<number>(0);

useEffect(() => {
const onDoc = (ev: globalThis.MouseEvent) => {
const t = ev.target as HTMLElement | null;
if (!t) return;

const settingsLink = t.closest(
'.TeamHeader-module__H3kcRq__settingsLink',
) as HTMLElement | null;
if (settingsLink) {
ev.preventDefault?.();
ev.stopPropagation?.();
lastTeamMenuToggleAt.current = Date.now();
setTeamMenuOpen((p) => !p);
return;
}

const aria = (t.closest('[aria-label]')?.getAttribute('aria-label') ?? '').toLowerCase();
const isKebabClick = aria.includes('더보기') || aria.includes('kebab');
if (!isKebabClick && openedTaskMenuId !== null && !t.closest(`.${styles.taskMenu}`)) {
setOpenedTaskMenuId(null);
}

if (
calendarOpen &&
!t.closest(`.${styles.calendarPopover}`) &&
!t.closest(`.${styles.calendarBtn}`)
) {
setCalendarOpen(false);
}

if (teamMenuOpen) {
const justToggled = Date.now() - lastTeamMenuToggleAt.current < 120;
if (!justToggled && !t.closest(`.${styles.teamMenu}`)) {
setTeamMenuOpen(false);
}
}
};

document.addEventListener('click', onDoc);
return () => document.removeEventListener('click', onDoc);
}, [openedTaskMenuId, calendarOpen, teamMenuOpen]);

const goTeamEditPage = () => {
setTeamMenuOpen(false);
if (teamId) router.push(`/${teamId}/team`);
else router.push(`/team`);
};

const doDeleteTeam = async () => {
setTeamMenuOpen(false);
const ok = window.confirm('팀을 삭제할까요? 삭제하면 되돌릴 수 없어요.');
if (!ok) return;
try {
await deleteTeam();
if (teamId) router.push(`/${teamId}`);
else router.push(`/`);
} catch {
alert('삭제에 실패했어요. (권한/로그인 상태를 확인해주세요)');
}
};

const preventAll = (e: ReactMouseEvent | React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
};

return (
<main className={styles.page}>
<section className={styles.main}>
<div className={styles.teamHeaderWrap}>
<div className={styles.teamHeaderRow}>
<TeamHeader variant="list" teamName={activeGroup?.name ?? ''} settingsHref="" />

{teamMenuOpen ? (
<div className={styles.teamMenu} role="menu" aria-label="팀 메뉴">
<button type="button" className={styles.teamMenuItem} onClick={goTeamEditPage}>
수정하기
</button>
<button type="button" className={styles.teamMenuItemDanger} onClick={doDeleteTeam}>
삭제하기
</button>
</div>
) : null}
</div>
</div>

<div className={styles.body}>
{/* LEFT (PC) */}
<aside className={styles.leftCol} aria-label="내가 한 일 목록">
<h2 className={styles.leftTitle}>내가 한 일</h2>

<div className={styles.leftScroll}>
{leftMonthBlocks.length === 0 ? (
<div className={styles.leftEmpty}>완료한 작업이 없어요.</div>
) : (
leftMonthBlocks.map((block) => (
<div key={block.monthKey} className={styles.monthBlock}>
<div className={styles.monthBlockTitle}>{block.monthLabel}</div>

<div className={styles.cardStack}>
{block.categories
.filter((c) => c.count > 0)
.map((c) => (
<TaskCard
key={`${block.monthKey}-${c.id}`}
label={c.label}
count={c.count}
onClick={() => {
setUserSelectedMonth(block.monthKey);
setSelectedTaskListId(c.id);
setOpenedTaskMenuId(null);
closeDetailImmediate();
}}
/>
))}
</div>
</div>
))
)}
</div>
</aside>

{/* RIGHT */}
<section className={styles.rightCol} aria-label="히스토리">
<div className={styles.whiteBox}>
<div className={styles.rightPanel}>
<div className={styles.boxHeader}>
<div className={styles.boxHeaderLeft}>
<ArrowButton size="small" direction="left" onClick={onPrevMonth} />
<span className={styles.monthLabel}>{monthLabel}</span>
<ArrowButton size="small" direction="right" onClick={onNextMonth} />
</div>

<button
type="button"
className={styles.calendarBtn}
aria-label="달력"
onClick={(e) => {
e.stopPropagation();
setCalendarOpen((p) => !p);
}}
>
<Image src={calendarIcon} alt="" width={16} height={16} />
</button>

{calendarOpen ? (
<div
className={styles.calendarPopover}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-label="캘린더"
>
<Calendar
value={new Date(`${selectedMonth}-01T00:00:00`)}
onChange={(d) => {
if (!d) return;
setUserSelectedMonth(ymFromDate(d));
setCalendarOpen(false);
setOpenedTaskMenuId(null);
closeDetailImmediate();
}}
/>
</div>
) : null}
</div>

{/* 모바일/태블릿 Chip row: 항상 렌더 (count=0도 표시) */}
<div className={styles.chipRow} aria-label="카테고리 선택">
{categoriesInSelectedMonth.map((c) => (
<Chip
key={c.id}
label={c.label}
count={c.count}
size="small"
selected={effectiveTaskListId === c.id}
onClick={() => {
setSelectedTaskListId(c.id);
setOpenedTaskMenuId(null);
closeDetailImmediate();
}}
/>
))}
</div>

<div className={styles.boxBody}>
{isDoneLoading ? <div className={styles.emptyState}>불러오는 중…</div> : null}

{!isDoneLoading && filteredTasks.length === 0 ? (
<div className={styles.emptyState}>이 달에 완료된 작업이 없어요.</div>
) : null}

{!isDoneLoading && filteredTasks.length > 0 ? (
<div className={styles.taskGroupWrap}>
{tasksByDate.map((group) => (
<section key={group.dayKey} className={styles.daySection}>
<div className={styles.dateDivider}>
<span className={styles.dateDividerLine} />
<span className={styles.dateDividerText}>
{formatKoreanDateFromIso(`${group.dayKey}T00:00:00`)}
</span>
<span className={styles.dateDividerLine} />
</div>

<div className={styles.taskList}>
{group.tasks.map((task) => (
<div
key={task.id}
className={styles.taskRow}
onClick={(e: ReactMouseEvent) => {
const t = e.target as HTMLElement | null;
if (isOpenDetailBlockedTarget(t)) return;
handleOpenDetail(task.id);
}}
>
<div style={{ position: 'relative' }}>
<TaskListItem
title={task.name}
date={formatKoreanDateFromIso(`${group.dayKey}T00:00:00`)}
checked={!!task.doneAt}
isSelected={false}
commentCount={task.commentCount ?? 0}
frequency={frequencyLabel(task.frequency ?? null)}
onCheckedChange={undefined}
onKebabClick={() =>
setOpenedTaskMenuId((prev) =>
prev === task.id ? null : task.id,
)
}
/>

{openedTaskMenuId === task.id ? (
<ul
className={styles.taskMenu}
role="menu"
aria-label="할 일 메뉴"
onClick={(e) => e.stopPropagation()}
>
<li>
<button
type="button"
className={styles.taskMenuItem}
onClick={(e) => {
e.stopPropagation();
setOpenedTaskMenuId(null);
handleOpenDetail(task.id);
}}
>
상세보기
</button>
</li>

<li>
<button
type="button"
className={`${styles.taskMenuItem} ${styles.taskMenuItemDisabled}`}
disabled
onClick={preventAll}
>
삭제하기
</button>
</li>
</ul>
) : null}
</div>
</div>
))}
</div>
</section>
))}
</div>
) : null}
</div>
</div>
</div>
</section>
</div>

{/* Detail Overlay */}
{detailMounted && selectedTask ? (
<div
className={`${styles.detailOverlay} ${detailOpen ? styles.detailOpen : styles.detailClose}`}
role="dialog"
aria-modal="true"
onClick={closeDetail}
>
<div className={styles.detailInner} onClick={(e) => e.stopPropagation()}>
<TaskDetailCard
id={Number(selectedTask.id)}
name={selectedTask.name}
description={selectedTask.description ?? ''}
date={
(selectedTask.doneAt ?? selectedTask.date ?? new Date().toISOString()) as string
}
frequency={(selectedTask.frequency ?? 'ONCE') as ApiFrequency}
writer={{
id: selectedTask.writer?.id ?? meWriter.id,
nickname: selectedTask.writer?.nickname ?? meWriter.nickname,
image: selectedTask.writer?.image ?? meWriter.image,
}}
doneAt={selectedTask.doneAt ?? null}
comments={detailComments}
onComplete={async () => {
await apiToggleDone(selectedTask, !selectedTask.doneAt);
}}
onEdit={() => {}}
onDelete={async () => {
await apiDelete(selectedTask);
closeDetail();
}}
onClose={closeDetail}
onCommentSubmit={(content) => {
if (!effectiveSelectedTaskId) return;
createComment.mutate({ content });
}}
/>
</div>
</div>
) : null}
</section>
</main>
);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

MyHistory 컴포넌트가 600줄이 넘어가며 너무 많은 역할을 수행하고 있습니다 (데이터 페칭, 여러 UI 상태 관리, 전체 페이지 렌더링 등). 이는 단일 책임 원칙(Single Responsibility Principle)에 위배되며 유지보수를 어렵게 만듭니다. 예를 들어, 좌측의 '내가 한 일' 목록(LeftColumn), 우측의 히스토리 패널(RightColumn), 그리고 상세 정보 오버레이(DetailOverlay) 등을 별도의 컴포넌트로 분리하는 것을 강력히 권장합니다.

}
function closeDetail() {
setDetailOpen(false);
window.setTimeout(() => setDetailMounted(false), 260);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

260이라는 숫자는 CSS 트랜지션 시간과 관련이 있는 것으로 보입니다. 이렇게 하드코딩된 '매직 넘버'는 CSS와 JS 코드 간의 동기화를 어렵게 만듭니다. 이 값을 상수로 정의하거나 CSS 변수로 관리하여 양쪽에서 참조하도록 하는 것이 유지보수에 더 좋습니다.

Comment on lines +450 to +461
const doDeleteTeam = async () => {
setTeamMenuOpen(false);
const ok = window.confirm('팀을 삭제할까요? 삭제하면 되돌릴 수 없어요.');
if (!ok) return;
try {
await deleteTeam();
if (teamId) router.push(`/${teamId}`);
else router.push(`/`);
} catch {
alert('삭제에 실패했어요. (권한/로그인 상태를 확인해주세요)');
}
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

window.confirmalert를 사용하는 대신, 일관된 사용자 경험을 제공하기 위해 애플리케이션의 디자인 시스템에 맞는 커스텀 모달(dialog) 컴포넌트를 사용하는 것을 권장합니다.

@Jieunsse Jieunsse merged commit 884b366 into main Feb 26, 2026
1 check failed
@github-project-automation github-project-automation Bot moved this from PR 리뷰중 to 개발 완료 in workers Feb 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

리팩토링 리팩토링 및 리뷰내용 반영시 작성해주세요

Projects

Status: 개발 완료

Development

Successfully merging this pull request may close these issues.

2 participants