출석관련
Some checks failed
deploy-on-main / deploy (push) Failing after 22s

This commit is contained in:
koreacomp5
2025-11-10 01:56:44 +09:00
parent b579b32138
commit 5485da4029
4 changed files with 145 additions and 68 deletions

View File

@@ -119,6 +119,7 @@ async function createRandomUsers(count = 100) {
} }
} }
const createdUsers = [];
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
// 닉네임을 결정론적으로 생성해 재실행 시 중복 생성 방지 // 닉네임을 결정론적으로 생성해 재실행 시 중복 생성 방지
const nickname = `user${String(i + 1).padStart(3, "0")}`; 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 }, create: { userId: user.userId, roleId: roleUser.roleId },
}); });
} }
if (user) createdUsers.push(user);
} }
return createdUsers;
} }
async function upsertCategories() { 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) { async function upsertBoards(admin, categoryMap) {
const boards = [ const boards = [
// 일반 // 일반
@@ -600,7 +633,8 @@ async function main() {
const admin = await upsertAdmin(); const admin = await upsertAdmin();
const categoryMap = await upsertCategories(); const categoryMap = await upsertCategories();
await upsertViewTypes(); await upsertViewTypes();
await createRandomUsers(100); const randomUsers = await createRandomUsers(3);
await seedRandomAttendanceForUsers(randomUsers, 10, 20);
await removeNonPrimaryBoards(); await removeNonPrimaryBoards();
const boards = await upsertBoards(admin, categoryMap); const boards = await upsertBoards(admin, categoryMap);
await seedAdminAttendance(admin); await seedAdminAttendance(admin);

View File

@@ -30,7 +30,16 @@ export async function GET(req: Request) {
if (days.length > 0) { if (days.length > 0) {
const set = new Set(days); const set = new Set(days);
const now = new Date(); const now = new Date();
let cursor = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), 0, 0, 0, 0)); // 로컬 날짜(사용자 체감 날짜)를 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);
tried += 1;
continue;
}
while (true) { while (true) {
const ymd = toYmdUTC(cursor); const ymd = toYmdUTC(cursor);
if (set.has(ymd)) { if (set.has(ymd)) {
@@ -41,6 +50,7 @@ export async function GET(req: Request) {
} }
} }
} }
}
// 최대 연속 출석 // 최대 연속 출석
let maxStreak = 0; let maxStreak = 0;

View File

@@ -9,61 +9,63 @@ function toYmdUTC(d: Date): string {
return `${yy}-${mm}-${dd}`; return `${yy}-${mm}-${dd}`;
} }
export async function GET() { export async function GET(req: Request) {
// 전체 출석 누적 상위 (Top 10) const url = new URL(req.url);
const overallGroups = await prisma.attendance.groupBy({ const only = url.searchParams.get("only");
by: ["userId"],
_count: { userId: true },
orderBy: { _count: { userId: "desc" } },
take: 20,
});
const overallUserIds = overallGroups.map((g) => g.userId);
const overallUsers = await prisma.user.findMany({
where: { userId: { in: overallUserIds } },
select: { userId: true, nickname: true, profileImage: true, grade: true },
});
const userMeta = new Map(overallUsers.map((u) => [u.userId, u]));
const overall = overallGroups.map((g) => ({
userId: g.userId,
nickname: userMeta.get(g.userId)?.nickname ?? "회원",
count: g._count.userId ?? 0,
profileImage: userMeta.get(g.userId)?.profileImage ?? null,
grade: userMeta.get(g.userId)?.grade ?? 0,
}));
// 연속 출석 상위 (Top 10, 현재 연속 기준) // 최대 연속 출석 상위 (Top 20, 전체 이력 기준)
const now = new Date(); async function computeStreakTop() {
const todayUTC = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), 0, 0, 0, 0)); // 전체 이력 기준으로 사용자별 최대 연속 출석을 계산
const past = new Date(todayUTC); const rows = await prisma.attendance.findMany({
past.setUTCDate(past.getUTCDate() - 60); // 최근 60일 데이터만으로 streak 계산
const recent = await prisma.attendance.findMany({
where: { date: { gte: past } },
select: { userId: true, date: true }, select: { userId: true, date: true },
orderBy: { userId: "asc" }, orderBy: [{ userId: "asc" }, { date: "asc" }],
}); });
const map = new Map<string, Set<string>>(); const maxByUser = new Map<string, number>();
for (const r of recent) { let currentUserId: string | null = null;
const set = map.get(r.userId) ?? new Set<string>(); let lastDayMs: number | null = null;
set.add(toYmdUTC(new Date(r.date))); let current = 0;
map.set(r.userId, set); let maxStreak = 0;
const commit = () => {
if (currentUserId) {
maxByUser.set(currentUserId, Math.max(maxByUser.get(currentUserId) ?? 0, maxStreak));
} }
const streakArr: { userId: string; streak: number }[] = []; };
for (const [userId, set] of map.entries()) { for (const r of rows) {
let streak = 0; if (r.userId !== currentUserId) {
let cursor = new Date(todayUTC); // flush previous
while (streak < 60) { if (currentUserId !== null) commit();
const ymd = toYmdUTC(cursor); // reset for new user
if (set.has(ymd)) { currentUserId = r.userId;
streak += 1; lastDayMs = null;
cursor.setUTCDate(cursor.getUTCDate() - 1); 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 { } else {
break; 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 (streak > 0) streakArr.push({ userId, streak }); if (current > maxStreak) maxStreak = current;
lastDayMs = ms;
} }
streakArr.sort((a, b) => b.streak - a.streak); // flush last
const topStreak = streakArr.slice(0, 20); 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 streakUserIds = topStreak.map((s) => s.userId);
const streakUsers = await prisma.user.findMany({ const streakUsers = await prisma.user.findMany({
where: { userId: { in: streakUserIds } }, where: { userId: { in: streakUserIds } },
@@ -77,6 +79,36 @@ export async function GET() {
profileImage: streakMeta.get(s.userId)?.profileImage ?? null, profileImage: streakMeta.get(s.userId)?.profileImage ?? null,
grade: streakMeta.get(s.userId)?.grade ?? 0, 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: { date: true },
orderBy: { _count: { date: "desc" } },
take: 20,
});
const overallUserIds = overallGroups.map((g) => g.userId);
const overallUsers = await prisma.user.findMany({
where: { userId: { in: overallUserIds } },
select: { userId: true, nickname: true, profileImage: true, grade: true },
});
const userMeta = new Map(overallUsers.map((u) => [u.userId, u]));
const overall = overallGroups.map((g) => ({
userId: g.userId,
nickname: userMeta.get(g.userId)?.nickname ?? "회원",
count: g._count.date ?? 0,
profileImage: userMeta.get(g.userId)?.profileImage ?? null,
grade: userMeta.get(g.userId)?.grade ?? 0,
}));
const streak = await computeStreakTop();
return NextResponse.json({ overall, streak }); return NextResponse.json({ overall, streak });
} }

View File

@@ -34,6 +34,7 @@ export default function AttendanceCalendar({ isLoggedIn = true }: { isLoggedIn?:
if (!isLoggedIn) { if (!isLoggedIn) {
setDays([]); setDays([]);
setTodayChecked(null); setTodayChecked(null);
setEffectiveLoggedIn(false);
return; return;
} }
setLoading(true); setLoading(true);
@@ -164,14 +165,14 @@ export default function AttendanceCalendar({ isLoggedIn = true }: { isLoggedIn?:
{todayChecked ? "오늘 출석 완료" : "오늘 출석하기"} {todayChecked ? "오늘 출석 완료" : "오늘 출석하기"}
</button> </button>
</div> </div>
{/* 내 출석 통계 */}
{effectiveLoggedIn && <MyAttendanceStats />}
{!effectiveLoggedIn && ( {!effectiveLoggedIn && (
<div className="absolute inset-0 bg-white/70 backdrop-blur-[1px] rounded-lg flex items-center justify-center"> <div className="absolute inset-0 z-20 bg-white/70 backdrop-blur-[1px] rounded-lg flex items-center justify-center">
<div className="text-sm font-semibold text-neutral-700"> </div> <div className="text-sm font-semibold text-neutral-700"> </div>
</div> </div>
)} )}
</div> </div>
{/* 내 출석 통계 */}
{effectiveLoggedIn && <MyAttendanceStats />}
<Rankings /> <Rankings />
</div> </div>
); );
@@ -192,7 +193,7 @@ function MyAttendanceStats() {
<div className="text-lg font-semibold text-neutral-900">{total}</div> <div className="text-lg font-semibold text-neutral-900">{total}</div>
</div> </div>
<div className="rounded-lg border border-neutral-200 px-3 py-2 text-center"> <div className="rounded-lg border border-neutral-200 px-3 py-2 text-center">
<div className="text-[11px] text-neutral-600"></div> <div className="text-[11px] text-neutral-600"> </div>
<div className="text-lg font-semibold text-neutral-900">{current}</div> <div className="text-lg font-semibold text-neutral-900">{current}</div>
</div> </div>
<div className="rounded-lg border border-neutral-200 px-3 py-2 text-center"> <div className="rounded-lg border border-neutral-200 px-3 py-2 text-center">
@@ -204,16 +205,16 @@ function MyAttendanceStats() {
} }
function Rankings() { function Rankings() {
const { data } = useSWR<{ overall: any[]; streak: any[] }>( const { data } = useSWR<{ overall?: any[]; streak: any[] }>(
"/api/attendance/rankings", "/api/attendance/rankings",
(u) => fetch(u, { cache: "no-store" }).then((r) => r.json()) (u) => fetch(u, { cache: "no-store" }).then((r) => r.json())
); );
const overall = data?.overall ?? [];
const streak = data?.streak ?? []; const streak = data?.streak ?? [];
const overall = data?.overall ?? [];
return ( return (
<div className="mt-6 grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="mt-6 grid grid-cols-2 gap-4">
<div className="rounded-lg border border-neutral-200"> <div className="rounded-lg border border-neutral-200">
<div className="px-3 py-2 border-b border-neutral-200 text-sm font-semibold"> </div> <div className="px-3 py-2 border-b border-neutral-200 text-sm font-semibold"> </div>
<ol className="p-3 space-y-2"> <ol className="p-3 space-y-2">
{streak.length === 0 && <li className="text-xs text-neutral-500"> .</li>} {streak.length === 0 && <li className="text-xs text-neutral-500"> .</li>}
{streak.map((u, idx) => ( {streak.map((u, idx) => (
@@ -226,14 +227,14 @@ function Rankings() {
</ol> </ol>
</div> </div>
<div className="rounded-lg border border-neutral-200"> <div className="rounded-lg border border-neutral-200">
<div className="px-3 py-2 border-b border-neutral-200 text-sm font-semibold"> </div> <div className="px-3 py-2 border-b border-neutral-200 text-sm font-semibold"> </div>
<ol className="p-3 space-y-2"> <ol className="p-3 space-y-2">
{overall.length === 0 && <li className="text-xs text-neutral-500"> .</li>} {overall.length === 0 && <li className="text-xs text-neutral-500"> .</li>}
{overall.map((u, idx) => ( {overall.map((u, idx) => (
<li key={u.userId} className="flex items-center gap-3 text-sm"> <li key={u.userId} className="flex items-center gap-3 text-sm">
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-neutral-900 text-white text-xs">{idx + 1}</span> <span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-neutral-900 text-white text-xs">{idx + 1}</span>
<span className="truncate">{u.nickname}</span> <span className="truncate">{u.nickname}</span>
<span className="ml-auto text-xs text-neutral-600">{u.count}</span> <span className="ml-auto text-xs text-neutral-600">{u.count}</span>
</li> </li>
))} ))}
</ol> </ol>