From 5485da40292889ea36307d247dc740531e71e92e Mon Sep 17 00:00:00 2001 From: koreacomp5 Date: Mon, 10 Nov 2025 01:56:44 +0900 Subject: [PATCH] =?UTF-8?q?=EC=B6=9C=EC=84=9D=EA=B4=80=EB=A0=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- prisma/seed.js | 36 +++++- src/app/api/attendance/me-stats/route.ts | 24 ++-- src/app/api/attendance/rankings/route.ts | 132 ++++++++++++++-------- src/app/components/AttendanceCalendar.tsx | 21 ++-- 4 files changed, 145 insertions(+), 68 deletions(-) diff --git a/prisma/seed.js b/prisma/seed.js index 3beb110..9d79ca6 100644 --- a/prisma/seed.js +++ b/prisma/seed.js @@ -119,6 +119,7 @@ async function createRandomUsers(count = 100) { } } + const createdUsers = []; for (let i = 0; i < count; i++) { // 닉네임을 결정론적으로 생성해 재실행 시 중복 생성 방지 const nickname = `user${String(i + 1).padStart(3, "0")}`; @@ -155,7 +156,9 @@ async function createRandomUsers(count = 100) { create: { userId: user.userId, roleId: roleUser.roleId }, }); } + if (user) createdUsers.push(user); } + return createdUsers; } async function upsertCategories() { @@ -289,6 +292,36 @@ async function seedAdminAttendance(admin) { } } +async function seedRandomAttendanceForUsers(users, minDays = 10, maxDays = 20) { + try { + const today = new Date(); + const todayUtcMidnight = new Date(Date.UTC( + today.getUTCFullYear(), + today.getUTCMonth(), + today.getUTCDate(), + 0, 0, 0, 0 + )); + for (const user of users) { + const count = randomInt(minDays, maxDays); + const used = new Set(); + while (used.size < count) { + const offsetDays = randomInt(0, 120); // 최근 120일 범위에서 랜덤 + const date = new Date(todayUtcMidnight.getTime() - offsetDays * 24 * 60 * 60 * 1000); + const key = date.toISOString().slice(0, 10); // YYYY-MM-DD + if (used.has(key)) continue; + used.add(key); + await prisma.attendance.upsert({ + where: { userId_date: { userId: user.userId, date } }, + update: {}, + create: { userId: user.userId, date }, + }); + } + } + } catch (e) { + console.warn("seedRandomAttendanceForUsers failed:", e); + } +} + async function upsertBoards(admin, categoryMap) { const boards = [ // 일반 @@ -600,7 +633,8 @@ async function main() { const admin = await upsertAdmin(); const categoryMap = await upsertCategories(); await upsertViewTypes(); - await createRandomUsers(100); + const randomUsers = await createRandomUsers(3); + await seedRandomAttendanceForUsers(randomUsers, 10, 20); await removeNonPrimaryBoards(); const boards = await upsertBoards(admin, categoryMap); await seedAdminAttendance(admin); diff --git a/src/app/api/attendance/me-stats/route.ts b/src/app/api/attendance/me-stats/route.ts index d9a34bd..392cd0b 100644 --- a/src/app/api/attendance/me-stats/route.ts +++ b/src/app/api/attendance/me-stats/route.ts @@ -30,14 +30,24 @@ export async function GET(req: Request) { if (days.length > 0) { const set = new Set(days); const now = new Date(); - let cursor = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), 0, 0, 0, 0)); - while (true) { - const ymd = toYmdUTC(cursor); - if (set.has(ymd)) { - currentStreak += 1; + // 로컬 날짜(사용자 체감 날짜)를 UTC 자정으로 정규화하여 비교 + let cursor = new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0)); + let tried = 0; + while (tried < 2 && currentStreak === 0) { + const startYmd = toYmdUTC(cursor); + if (!set.has(startYmd)) { cursor.setUTCDate(cursor.getUTCDate() - 1); - } else { - break; + tried += 1; + continue; + } + while (true) { + const ymd = toYmdUTC(cursor); + if (set.has(ymd)) { + currentStreak += 1; + cursor.setUTCDate(cursor.getUTCDate() - 1); + } else { + break; + } } } } diff --git a/src/app/api/attendance/rankings/route.ts b/src/app/api/attendance/rankings/route.ts index 03e6584..453ef0d 100644 --- a/src/app/api/attendance/rankings/route.ts +++ b/src/app/api/attendance/rankings/route.ts @@ -9,12 +9,89 @@ function toYmdUTC(d: Date): string { return `${yy}-${mm}-${dd}`; } -export async function GET() { +export async function GET(req: Request) { + const url = new URL(req.url); + const only = url.searchParams.get("only"); + + // 최대 연속 출석 상위 (Top 20, 전체 이력 기준) + async function computeStreakTop() { + // 전체 이력 기준으로 사용자별 최대 연속 출석을 계산 + const rows = await prisma.attendance.findMany({ + select: { userId: true, date: true }, + orderBy: [{ userId: "asc" }, { date: "asc" }], + }); + const maxByUser = new Map(); + let currentUserId: string | null = null; + let lastDayMs: number | null = null; + let current = 0; + let maxStreak = 0; + const commit = () => { + if (currentUserId) { + maxByUser.set(currentUserId, Math.max(maxByUser.get(currentUserId) ?? 0, maxStreak)); + } + }; + for (const r of rows) { + if (r.userId !== currentUserId) { + // flush previous + if (currentUserId !== null) commit(); + // reset for new user + currentUserId = r.userId; + lastDayMs = null; + current = 0; + maxStreak = 0; + } + // UTC 일 단위로 비교 + const ymd = toYmdUTC(new Date(r.date)); + const ms = Date.parse(`${ymd}T00:00:00.000Z`); + if (lastDayMs === null) { + current = 1; + } else { + const diffDays = Math.round((ms - lastDayMs) / (24 * 60 * 60 * 1000)); + if (diffDays === 1) { + current += 1; + } else if (diffDays > 0) { + current = 1; + } else { + // 동일/역순은 이례적이지만 안전하게 스킵 + current = Math.max(current, 1); + } + } + if (current > maxStreak) maxStreak = current; + lastDayMs = ms; + } + // flush last + if (currentUserId !== null) commit(); + + const topStreak = Array.from(maxByUser.entries()) + .map(([userId, streak]) => ({ userId, streak })) + .sort((a, b) => b.streak - a.streak) + .slice(0, 20); + const streakUserIds = topStreak.map((s) => s.userId); + const streakUsers = await prisma.user.findMany({ + where: { userId: { in: streakUserIds } }, + select: { userId: true, nickname: true, profileImage: true, grade: true }, + }); + const streakMeta = new Map(streakUsers.map((u) => [u.userId, u])); + const streak = topStreak.map((s) => ({ + userId: s.userId, + nickname: streakMeta.get(s.userId)?.nickname ?? "회원", + streak: s.streak, + profileImage: streakMeta.get(s.userId)?.profileImage ?? null, + grade: streakMeta.get(s.userId)?.grade ?? 0, + })); + return streak; + } + + if (only === "streak") { + const streak = await computeStreakTop(); + return NextResponse.json({ streak }); + } + // 전체 출석 누적 상위 (Top 10) const overallGroups = await prisma.attendance.groupBy({ by: ["userId"], - _count: { userId: true }, - orderBy: { _count: { userId: "desc" } }, + _count: { date: true }, + orderBy: { _count: { date: "desc" } }, take: 20, }); const overallUserIds = overallGroups.map((g) => g.userId); @@ -26,57 +103,12 @@ export async function GET() { const overall = overallGroups.map((g) => ({ userId: g.userId, nickname: userMeta.get(g.userId)?.nickname ?? "회원", - count: g._count.userId ?? 0, + count: g._count.date ?? 0, profileImage: userMeta.get(g.userId)?.profileImage ?? null, grade: userMeta.get(g.userId)?.grade ?? 0, })); - // 연속 출석 상위 (Top 10, 현재 연속 기준) - const now = new Date(); - const todayUTC = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), 0, 0, 0, 0)); - const past = new Date(todayUTC); - past.setUTCDate(past.getUTCDate() - 60); // 최근 60일 데이터만으로 streak 계산 - const recent = await prisma.attendance.findMany({ - where: { date: { gte: past } }, - select: { userId: true, date: true }, - orderBy: { userId: "asc" }, - }); - const map = new Map>(); - for (const r of recent) { - const set = map.get(r.userId) ?? new Set(); - set.add(toYmdUTC(new Date(r.date))); - map.set(r.userId, set); - } - const streakArr: { userId: string; streak: number }[] = []; - for (const [userId, set] of map.entries()) { - let streak = 0; - let cursor = new Date(todayUTC); - while (streak < 60) { - const ymd = toYmdUTC(cursor); - if (set.has(ymd)) { - streak += 1; - cursor.setUTCDate(cursor.getUTCDate() - 1); - } else { - break; - } - } - if (streak > 0) streakArr.push({ userId, streak }); - } - streakArr.sort((a, b) => b.streak - a.streak); - const topStreak = streakArr.slice(0, 20); - const streakUserIds = topStreak.map((s) => s.userId); - const streakUsers = await prisma.user.findMany({ - where: { userId: { in: streakUserIds } }, - select: { userId: true, nickname: true, profileImage: true, grade: true }, - }); - const streakMeta = new Map(streakUsers.map((u) => [u.userId, u])); - const streak = topStreak.map((s) => ({ - userId: s.userId, - nickname: streakMeta.get(s.userId)?.nickname ?? "회원", - streak: s.streak, - profileImage: streakMeta.get(s.userId)?.profileImage ?? null, - grade: streakMeta.get(s.userId)?.grade ?? 0, - })); + const streak = await computeStreakTop(); return NextResponse.json({ overall, streak }); } diff --git a/src/app/components/AttendanceCalendar.tsx b/src/app/components/AttendanceCalendar.tsx index cf206d5..ca233d3 100644 --- a/src/app/components/AttendanceCalendar.tsx +++ b/src/app/components/AttendanceCalendar.tsx @@ -34,6 +34,7 @@ export default function AttendanceCalendar({ isLoggedIn = true }: { isLoggedIn?: if (!isLoggedIn) { setDays([]); setTodayChecked(null); + setEffectiveLoggedIn(false); return; } setLoading(true); @@ -164,14 +165,14 @@ export default function AttendanceCalendar({ isLoggedIn = true }: { isLoggedIn?: {todayChecked ? "오늘 출석 완료" : "오늘 출석하기"} + {/* 내 출석 통계 */} + {effectiveLoggedIn && } {!effectiveLoggedIn && ( -
+
로그인이 필요합니다
)}
- {/* 내 출석 통계 */} - {effectiveLoggedIn && } ); @@ -192,7 +193,7 @@ function MyAttendanceStats() {
{total}
-
연속출석
+
현재 연속출석
{current}일
@@ -204,16 +205,16 @@ function MyAttendanceStats() { } function Rankings() { - const { data } = useSWR<{ overall: any[]; streak: any[] }>( + const { data } = useSWR<{ overall?: any[]; streak: any[] }>( "/api/attendance/rankings", (u) => fetch(u, { cache: "no-store" }).then((r) => r.json()) ); - const overall = data?.overall ?? []; const streak = data?.streak ?? []; + const overall = data?.overall ?? []; return ( -
+
-
연속출석 순위
+
최대 연속출석 순위
    {streak.length === 0 &&
  1. 데이터가 없습니다.
  2. } {streak.map((u, idx) => ( @@ -226,14 +227,14 @@ function Rankings() {
-
전체 출석 순위
+
출석일 순위
    {overall.length === 0 &&
  1. 데이터가 없습니다.
  2. } {overall.map((u, idx) => (
  3. {idx + 1} {u.nickname} - {u.count}회 + {u.count}일
  4. ))}