This commit is contained in:
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user