This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, number>();
|
||||
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<string, Set<string>>();
|
||||
for (const r of recent) {
|
||||
const set = map.get(r.userId) ?? new Set<string>();
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -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 ? "오늘 출석 완료" : "오늘 출석하기"}
|
||||
</button>
|
||||
</div>
|
||||
{/* 내 출석 통계 */}
|
||||
{effectiveLoggedIn && <MyAttendanceStats />}
|
||||
{!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>
|
||||
)}
|
||||
</div>
|
||||
{/* 내 출석 통계 */}
|
||||
{effectiveLoggedIn && <MyAttendanceStats />}
|
||||
<Rankings />
|
||||
</div>
|
||||
);
|
||||
@@ -192,7 +193,7 @@ function MyAttendanceStats() {
|
||||
<div className="text-lg font-semibold text-neutral-900">{total}</div>
|
||||
</div>
|
||||
<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>
|
||||
<div className="rounded-lg border border-neutral-200 px-3 py-2 text-center">
|
||||
@@ -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 (
|
||||
<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="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">
|
||||
{streak.length === 0 && <li className="text-xs text-neutral-500">데이터가 없습니다.</li>}
|
||||
{streak.map((u, idx) => (
|
||||
@@ -226,14 +227,14 @@ function Rankings() {
|
||||
</ol>
|
||||
</div>
|
||||
<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">
|
||||
{overall.length === 0 && <li className="text-xs text-neutral-500">데이터가 없습니다.</li>}
|
||||
{overall.map((u, idx) => (
|
||||
<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="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>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
Reference in New Issue
Block a user