diff --git a/.github/workflows/automated-code-review.yml b/.github/workflows/automated-code-review.yml index 00ea5d1..b63d6a6 100644 --- a/.github/workflows/automated-code-review.yml +++ b/.github/workflows/automated-code-review.yml @@ -4,13 +4,15 @@ on: pull_request: types: [opened, synchronize, reopened, ready_for_review] +# 기본은 읽기 전용. secrets가 필요한 publish 권한은 ai-review job에서만 올린다. permissions: contents: read - issues: write - pull-requests: write jobs: - review: + # 1) 검증 단계: PR 브랜치 코드를 실제로 실행(lint/typecheck/test/build)한다. + # untrusted PR 코드가 도는 곳이므로 어떤 secret도 주입하지 않는다. + # 브랜치 보호에서 필수로 걸 체크는 이 job(checks)이다. + checks: if: ${{ !github.event.pull_request.draft }} runs-on: ubuntu-latest steps: @@ -25,7 +27,56 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile - - name: Run automated review + - name: Run checks (lint/typecheck/test/build) + env: + REVIEW_REPORT_TITLE: "자동 PR 코드 리뷰" + REVIEW_CADENCE: "PR 리뷰" + REVIEW_BASE: ${{ github.event.pull_request.base.sha }} + REVIEW_HEAD: ${{ github.event.pull_request.head.sha }} + # typecheck(tsc)는 next build가 만드는 next-env.d.ts와 .next/types에 의존한다. + # fresh 체크아웃에서 typecheck가 먼저 돌면 Logo.png 타입 등을 못 찾으므로 build를 먼저 돌린다. + REVIEW_CHECKS: "lint,build,typecheck,test" + # NEXT_PUBLIC_* 는 원래 브라우저에 노출되는 공개 값이다. + # 빌드(프리렌더)에서 supabase 클라이언트 생성이 throw하지 않도록 플레이스홀더를 준다. + NEXT_PUBLIC_SUPABASE_URL: ${{ vars.NEXT_PUBLIC_SUPABASE_URL || 'https://placeholder.supabase.co' }} + NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ vars.NEXT_PUBLIC_SUPABASE_ANON_KEY || 'placeholder-anon-key' }} + run: bun run review -- --skip-agents --fail-on P1 + + - name: Upload check report + if: always() + uses: actions/upload-artifact@v4 + with: + name: automated-code-review-checks + path: tools/code-review/output/ + + # 2) LLM 리뷰 + Notion/GitHub publish: secrets가 필요하다. + # checks(untrusted 코드 실행)와 분리하고, environment 승인 게이트 뒤에 둔다. + # --skip-checks 로 코드 실행 없이 diff 수집·리뷰·publish만 수행한다. + ai-review: + if: ${{ !github.event.pull_request.draft }} + needs: checks + runs-on: ubuntu-latest + # Settings > Environments 에서 이 환경에 required reviewers 를 추가하면 + # secrets가 실린 이 job이 수동 승인 후에만 실행된다. + environment: ai-review + permissions: + contents: read + issues: write + pull-requests: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + + # 설치 단계에는 secrets를 노출하지 않는다(postinstall 스크립트 방어). + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Run AI review & publish env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} @@ -40,11 +91,11 @@ jobs: NOTION_DATABASE_ID: ${{ secrets.NOTION_DATABASE_ID }} NOTION_TITLE_PROPERTY: ${{ vars.NOTION_TITLE_PROPERTY || 'Name' }} PR_NUMBER: ${{ github.event.pull_request.number }} - run: bun run review -- --fail-on P1 + run: bun run review -- --skip-checks - name: Upload review report if: always() uses: actions/upload-artifact@v4 with: - name: automated-code-review + name: automated-code-review-ai path: tools/code-review/output/ diff --git a/package.json b/package.json index dde8bf6..a4da24f 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "start": "bun ext start", "lint": "bun biome check --apply .", "review": "bun tools/code-review/review-harness.ts", - "test": "bun test ./src/**/*.test.ts", + "test": "TZ=Asia/Seoul bun test src", "format": "biome format --write", "prepare": "husky" }, diff --git a/src/app/(with-sidebar)/mypage/components/Heatmap/hook.test.ts b/src/app/(with-sidebar)/mypage/components/Heatmap/hook.test.ts new file mode 100644 index 0000000..0b91b14 --- /dev/null +++ b/src/app/(with-sidebar)/mypage/components/Heatmap/hook.test.ts @@ -0,0 +1,57 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import { createAndFillHeatmap, getColor } from "./hook"; + +// bun test는 타임존을 UTC로 강제한다. +// toISOString() 기반의 말일 누락 버그는 KST에서만 재현되므로 KST로 고정한다. +beforeAll(() => { + process.env.TZ = "Asia/Seoul"; +}); + +describe("테스트 환경", () => { + test("KST(UTC+9)로 실행된다", () => { + // getTimezoneOffset은 KST에서 -540분이다. TZ 고정이 적용됐는지 확인한다. + expect(new Date().getTimezoneOffset()).toBe(-540); + }); +}); + +describe("createAndFillHeatmap", () => { + const today = new Date(2026, 6, 15); // 2026-07-15 (KST) + + test("이번 달 말일(2026-07-31)이 포함된다", () => { + const result = createAndFillHeatmap({}, today); + expect("2026-07-31" in result).toBe(true); + expect(result["2026-07-31"]).toBe(0); + }); + + test("12개월치(2025-08-01 ~ 2026-07-31, 365일)를 생성한다", () => { + const result = createAndFillHeatmap({}, today); + expect(Object.keys(result).length).toBe(365); + expect("2025-08-01" in result).toBe(true); + }); + + test("풀이가 있는 날은 개수, 없는 날은 0으로 채운다", () => { + const result = createAndFillHeatmap({ "2026-07-10": 3 }, today); + expect(result["2026-07-10"]).toBe(3); + expect(result["2026-07-11"]).toBe(0); + }); +}); + +describe("getColor 경계값", () => { + test("0은 가장 옅은 단계", () => { + expect(getColor(0)).toBe("#F2F4F6"); + }); + + test("1과 2는 같은 단계", () => { + expect(getColor(1)).toBe("#D4E4FF"); + expect(getColor(2)).toBe("#D4E4FF"); + }); + + test("3은 그다음 단계", () => { + expect(getColor(3)).toBe("#689FFF"); + }); + + test("4 이상은 가장 진한 단계", () => { + expect(getColor(4)).toBe("#1B64FA"); + expect(getColor(10)).toBe("#1B64FA"); + }); +}); diff --git a/src/app/(with-sidebar)/mypage/components/Heatmap/hook.ts b/src/app/(with-sidebar)/mypage/components/Heatmap/hook.ts index 0242ac1..69fbd63 100644 --- a/src/app/(with-sidebar)/mypage/components/Heatmap/hook.ts +++ b/src/app/(with-sidebar)/mypage/components/Heatmap/hook.ts @@ -1,12 +1,18 @@ -export function createAndFillHeatmap(mySolves: Record) { +import { format } from "date-fns"; + +export function createAndFillHeatmap( + mySolves: Record, + today = new Date(), +) { const yearSolves: Record = {}; //Record 가 무엇인지 알아보기 - const today = new Date(); const start = new Date(today.getFullYear(), today.getMonth() - 11, 1); const end = new Date(today.getFullYear(), today.getMonth() + 1, 0); const cur = new Date(start); while (cur <= end) { - const key = cur.toISOString().slice(0, 10); + // toISOString()은 UTC로 변환돼 KST에서 하루가 밀리고 말일이 누락된다. + // DB의 date(YYYY-MM-DD)와 맞추려면 로컬 기준으로 포맷해야 한다. + const key = format(cur, "yyyy-MM-dd"); yearSolves[key] = mySolves[key] ?? 0; cur.setDate(cur.getDate() + 1); } diff --git a/src/app/(with-sidebar)/mypage/components/LineChart/hook.test.ts b/src/app/(with-sidebar)/mypage/components/LineChart/hook.test.ts new file mode 100644 index 0000000..2c36be8 --- /dev/null +++ b/src/app/(with-sidebar)/mypage/components/LineChart/hook.test.ts @@ -0,0 +1,40 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import { createSixMonthObject } from "./hook"; + +// bun test는 타임존을 UTC로 강제한다. +// toISOString()/new Date(key)의 월 밀림 버그는 KST에서만 재현되므로 KST로 고정한다. +beforeAll(() => { + process.env.TZ = "Asia/Seoul"; +}); + +describe("테스트 환경", () => { + test("KST(UTC+9)로 실행된다", () => { + expect(new Date().getTimezoneOffset()).toBe(-540); + }); +}); + +describe("createSixMonthObject", () => { + const today = new Date(2026, 6, 15); // 2026-07-15 (KST) + + test("정확히 6개월치를 만든다", () => { + const result = createSixMonthObject([], today); + expect(result.length).toBe(6); + }); + + test("첫 항목은 5개월 전(2월), 마지막 항목은 기준월(7월)이다", () => { + const result = createSixMonthObject([], today); + expect(result[0].date).toBe("2월"); + expect(result[5].date).toBe("7월"); + }); + + test("월 라벨과 값이 정확히 매핑되고 데이터 없는 달은 0이다", () => { + const lineData = [ + { date: "2026-07", value: 5 }, + { date: "2026-05", value: 2 }, + ]; + const result = createSixMonthObject(lineData, today); + expect(result.find((r) => r.date === "7월")?.value).toBe(5); + expect(result.find((r) => r.date === "5월")?.value).toBe(2); + expect(result.find((r) => r.date === "6월")?.value).toBe(0); + }); +}); diff --git a/src/app/(with-sidebar)/mypage/components/LineChart/hook.ts b/src/app/(with-sidebar)/mypage/components/LineChart/hook.ts index f97d6fd..a685275 100644 --- a/src/app/(with-sidebar)/mypage/components/LineChart/hook.ts +++ b/src/app/(with-sidebar)/mypage/components/LineChart/hook.ts @@ -1,26 +1,32 @@ +import { format } from "date-fns"; + interface lineDataType { date: string; value: number; } -export function createSixMonthObject(lineData: lineDataType[] | undefined) { +export function createSixMonthObject( + lineData: lineDataType[] | undefined, + today = new Date(), +) { const result: lineDataType[] = []; - const today = new Date(); - const startMonth = new Date(today.getFullYear(), today.getMonth() - 4); - const endMonth = new Date(today.getFullYear(), today.getMonth() + 1); - const curMonth = new Date(startMonth); - - while (curMonth <= endMonth) { - const key = curMonth.toISOString().slice(0, 7); - const lineValue = lineData?.find((item) => item.date === key)?.value ?? 0; - const newKey = new Date(key); - const changeKey = - newKey.getFullYear() === today.getFullYear() - ? `${newKey.getMonth() + 1}월` - : `${newKey.getFullYear()}년 ${newKey.getMonth() + 1}월`; - result.push({ date: changeKey, value: lineValue }); + // 기준월(0)에서 5개월 전까지, 마지막 항목이 기준월이 되도록 정확히 6개를 만든다. + const startMonth = new Date(today.getFullYear(), today.getMonth() - 5, 1); - curMonth.setMonth(curMonth.getMonth() + 1); + for (let i = 0; i < 6; i++) { + const cur = new Date( + startMonth.getFullYear(), + startMonth.getMonth() + i, + 1, + ); + // toISOString()/new Date(key)의 UTC 변환을 피하려고 cur을 그대로 쓰고 로컬 기준으로 포맷한다. + const key = format(cur, "yyyy-MM"); + const value = lineData?.find((item) => item.date === key)?.value ?? 0; + const label = + cur.getFullYear() === today.getFullYear() + ? `${cur.getMonth() + 1}월` + : `${cur.getFullYear()}년 ${cur.getMonth() + 1}월`; + result.push({ date: label, value }); } return result; diff --git a/src/app/(with-sidebar)/problems/components/problem-add/TagSection.tsx b/src/app/(with-sidebar)/problems/components/problem-add/TagSection.tsx index 3542526..525adcf 100644 --- a/src/app/(with-sidebar)/problems/components/problem-add/TagSection.tsx +++ b/src/app/(with-sidebar)/problems/components/problem-add/TagSection.tsx @@ -1,14 +1,14 @@ import { ChevronDown, ChevronUp } from "lucide-react"; import { useState } from "react"; -import { useForm, useFormContext, useWatch } from "react-hook-form"; +import { useFormContext, useWatch } from "react-hook-form"; import { ALGORITHM_TAGS } from "@/constants/problem"; function TagSection() { const [isOpen, setIsOpen] = useState(false); - const { setValue, control, watch } = useFormContext(); + const { setValue, control } = useFormContext(); // const { watch } = useForm({ defaultValues: { tags: [] } }); - const selectedTags = useWatch({ control, name: "tags" }); + const selectedTags: string[] = useWatch({ control, name: "tags" }) ?? []; // const selectedTags = watch("tags") ?? []; const toggleTag = (tag: string) => { diff --git a/src/app/(with-sidebar)/problems/page.tsx b/src/app/(with-sidebar)/problems/page.tsx index 1ec7a16..882dafc 100644 --- a/src/app/(with-sidebar)/problems/page.tsx +++ b/src/app/(with-sidebar)/problems/page.tsx @@ -1,24 +1,24 @@ "use client"; import { useQuery } from "@tanstack/react-query"; +import { FileCode2, Plus } from "lucide-react"; +import { useState } from "react"; import SelectDemo from "@/components/SelectCustom"; -import { getMyMemberInfo } from "@/lib/api/members"; -import { getProblems } from "@/lib/api/problems"; -import ProblemCard from "./components/ProblemCard"; -import type { Problem, UserProfile } from "@/lib/types/study"; +import Input from "@/components/ui/Input"; +import { Skeleton } from "@/components/ui/skeleton"; import { ALGORITHM_TAGS, DIFFICULT_TAGS, PLATFORM_TAGS, type PlatformType, } from "@/constants/problem"; -import { useState } from "react"; -import Input from "@/components/ui/Input"; -import { Skeleton } from "@/components/ui/skeleton"; -import { FileCode2, Plus } from "lucide-react"; +import { getMyMemberInfo } from "@/lib/api/members"; +import { getProblems } from "@/lib/api/problems"; import { getStudyMembers } from "@/lib/api/study"; -import ProblemModal from "./components/ProblemModal"; +import type { Problem } from "@/lib/types/study"; import ProblemAddModal from "./components/ProblemAddModal"; +import ProblemCard from "./components/ProblemCard"; +import ProblemModal from "./components/ProblemModal"; function page() { // const [platform, setPlatform] = useState("all"); @@ -87,7 +87,7 @@ function page() { placeholder: "난이도", }, { - array: members, + array: members ?? [], value: memberName, change: setMemberName, placeholder: "풀이자", @@ -163,9 +163,9 @@ function page() {
{isProblemLoading ? ( - new Array(3) - .fill(0) - .map((_, i) => ) + ["s1", "s2", "s3"].map((skeletonKey) => ( + + )) ) : problems?.length === 0 ? (
diff --git a/src/app/(without-sidebar)/find/component/MakeStudy.tsx b/src/app/(without-sidebar)/find/component/MakeStudy.tsx index edf5cfc..c52a45b 100644 --- a/src/app/(without-sidebar)/find/component/MakeStudy.tsx +++ b/src/app/(without-sidebar)/find/component/MakeStudy.tsx @@ -1,65 +1,77 @@ -// import { ArrowLeft, ArrowRight } from "lucide-react"; -// import type { phaseType } from "@/lib/types/step"; -// import Button from "@/components/ui/Button"; -// import Input from "@/components/ui/Input"; -// import { createStudy } from "@/lib/api/study"; +import { ArrowLeft, ArrowRight } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import Button from "@/components/ui/Button"; +import Input from "@/components/ui/Input"; +import { createStudy } from "@/lib/api/study"; +import type { phaseType } from "@/lib/types/step"; -// function MakeStudy({ moveStep }: phaseType) { -// const handleCreateStudy = async () => { -// try { -// await createStudy(); -// } catch (error) { -// console.log(error); -// } -// }; -// return ( -//
-// -//
-// {/* 1. 메인 텍스트 및 서브 텍스트 영역 */} -//
-//

-// 초대 코드를 -//
입력해주세요 -//

-//

-// 스터디장에게 받은 코드를 입력해주세요 -//

-//
-// {/* 2. 초대 코드 입력 영역 */} -//
-// setInviteCode(e.currentTarget.value)} -// placeholder="예) ABCD1234" -// maxLength={8} -// className="text-center text-2xl font-bold text-slate-900 tracking-[0.25em] h-16" -// /> -//
-// {/* 3. 입장하기 버튼 영역 (사진에 있는 것 추가) */} -//
-//
-//
-//

-// 💡 초대 코드는 스터디장의 마이페이지에서 -//
확인할 수 있어요 -//

-//
-//
-//
-// ); -// } +function MakeStudy({ moveStep }: phaseType) { + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const router = useRouter(); -// export default MakeStudy; + const handleCreateStudy = async () => { + try { + await createStudy(name, description); + alert("스터디가 생성되었습니다."); + router.push("/home"); + } catch (error) { + console.log(error); + alert("스터디 생성에 실패했습니다"); + } + }; + + return ( +
+ +
+ {/* 1. 메인 텍스트 및 서브 텍스트 영역 */} +
+

+ 스터디를 +
만들어볼까요 +

+

+ 스터디 이름과 소개를 입력해주세요 +

+
+ {/* 2. 스터디 이름·소개 입력 영역 */} +
+ setName(e.currentTarget.value)} + placeholder="스터디 이름" + maxLength={20} + className="text-lg font-bold text-slate-900 h-14" + /> + setDescription(e.currentTarget.value)} + placeholder="스터디 소개" + maxLength={40} + className="text-slate-900 h-14" + /> +
+ {/* 3. 만들기 버튼 영역 */} +
+ +
+
+
+ ); +} + +export default MakeStudy; diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 419b254..a92ded2 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -46,7 +46,7 @@ function Sidebar() { const { data: members } = useQuery({ queryKey: ["getMembers"], - queryFn: () => getStudyMembers(study.id), + queryFn: () => getStudyMembers(study?.id ?? 0), enabled: !!study, }); diff --git a/src/components/ui/calendar.tsx b/src/components/ui/calendar.tsx index be9cda6..cbd4552 100644 --- a/src/components/ui/calendar.tsx +++ b/src/components/ui/calendar.tsx @@ -1,20 +1,19 @@ "use client"; -import * as React from "react"; import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon, } from "lucide-react"; +import * as React from "react"; import { + type DayButton, DayPicker, getDefaultClassNames, - type DayButton, type Locale, } from "react-day-picker"; - -import { cn } from "./utils"; import { ButtonRadix, buttonVariants } from "@/components/ui/radix/buttonRadix"; +import { cn } from "./utils"; function Calendar({ className, @@ -91,7 +90,7 @@ function Calendar({ : "cn-calendar-caption-label flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground", defaultClassNames.caption_label, ), - table: "w-full border-collapse", + month_grid: "w-full border-collapse", weekdays: cn("flex", defaultClassNames.weekdays), weekday: cn( "flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal text-muted-foreground select-none", diff --git a/src/lib/store/studyStore.ts b/src/lib/store/studyStore.ts index 164883f..0417e2e 100644 --- a/src/lib/store/studyStore.ts +++ b/src/lib/store/studyStore.ts @@ -2,7 +2,17 @@ import { atom } from "jotai"; import { atomWithStorage } from "jotai/utils"; import type { Study } from "../types/study"; -const studyAtom = atomWithStorage("study_storage", { +// 스터디에 참여하기 전 초기 상태에서는 값이 아직 없어 모두 null이다. +interface StoredStudy { + created_at: string | null; + description: string | null; + emoji: string | null; + id: number | null; + invite_code: string | null; + name: string | null; +} + +const studyAtom = atomWithStorage("study_storage", { created_at: null, description: null, emoji: null, diff --git a/tsconfig.json b/tsconfig.json index cf9c65d..fe916a7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -30,5 +30,5 @@ ".next/dev/types/**/*.ts", "**/*.mts" ], - "exclude": ["node_modules"] + "exclude": ["node_modules", "**/*.test.ts"] }