diff --git a/.env.example b/.env.example index 6c5e8ea..914db5f 100644 --- a/.env.example +++ b/.env.example @@ -36,6 +36,11 @@ NEW_MEMBER_JOINED_AFTER="" # 空文字の場合は通知をスキップ ADMIN_NOTIFICATION_CHANNEL_WEBHOOK="" +# Cron ジョブ認証シークレット(/api/cron/* エンドポイントの保護) +# `openssl rand -hex 32` で生成。Cloud Scheduler の Authorization ヘッダーに設定する。 +# 空文字の場合は認証をスキップ(ローカル開発用) +CRON_SECRET="" + # --- GitHub OAuth (SNS連携) --- # GitHub Developer Settings > OAuth Apps で取得 # Callback URL: http://localhost:3000/api/auth/link/github/callback diff --git a/app/api/cron/birthday/route.ts b/app/api/cron/birthday/route.ts new file mode 100644 index 0000000..5309081 --- /dev/null +++ b/app/api/cron/birthday/route.ts @@ -0,0 +1,66 @@ +import { NextResponse } from "next/server"; +import { getDb } from "@/lib/firebase"; +import { profileToMemberInternal } from "@/lib/members"; +import type { MemberDocument } from "@/lib/members"; +import { + notifyAdminChannel, + buildBirthdayNotification, +} from "@/lib/discord-dm"; + +// Cloud Scheduler から呼び出される誕生日通知エンドポイント。 +// 認証: Authorization: Bearer {CRON_SECRET} ヘッダーで保護。 +// +// Cloud Scheduler 設定例: +// URL: https:///api/cron/birthday +// Schedule: 0 9 * * * (毎朝 9:00 JST = 0:00 UTC) +// HTTP method: GET +// Headers: Authorization: Bearer + +export async function GET(request: Request) { + const secret = process.env.CRON_SECRET; + if (secret) { + const auth = request.headers.get("authorization"); + if (auth !== `Bearer ${secret}`) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + } + + const db = getDb(); + const snap = await db + .collection("members") + .where("onboardingCompleted", "==", true) + .get(); + + const now = new Date(); + const mm = String(now.getMonth() + 1).padStart(2, "0"); + const dd = String(now.getDate()).padStart(2, "0"); + const today = `${mm}-${dd}`; + + const todayMembers = snap.docs.flatMap((doc) => { + const data = doc.data() as MemberDocument; + if (data.optedOut) return []; + const member = profileToMemberInternal(doc.id, data); + if (!member.birthDate) return []; + if (member.birthDate.slice(5) !== today) return []; + return [member]; + }); + + if (todayMembers.length === 0) { + return NextResponse.json({ notified: false, count: 0 }); + } + + const names = todayMembers.map((m) => m.nickname || m.name); + + try { + await notifyAdminChannel(buildBirthdayNotification(names)); + } catch (e) { + console.error("[cron/birthday] Failed to notify:", e); + return NextResponse.json({ error: String(e) }, { status: 500 }); + } + + return NextResponse.json({ + notified: true, + count: todayMembers.length, + names, + }); +} diff --git a/app/internal/(protected)/birthdays/page.tsx b/app/internal/(protected)/birthdays/page.tsx new file mode 100644 index 0000000..766827b --- /dev/null +++ b/app/internal/(protected)/birthdays/page.tsx @@ -0,0 +1,36 @@ +import { auth } from "@/lib/auth"; +import { getMembersInternal } from "@/lib/members"; +import { BirthdayList } from "@/components/birthday-list"; +import { Cake } from "lucide-react"; + +export const dynamic = "force-dynamic"; + +export default async function BirthdaysPage() { + const [session, members] = await Promise.all([auth(), getMembersInternal()]); + + const myBirthDate = + members.find((m) => m.id === session?.user?.id)?.birthDate ?? null; + + const entries = members + .filter((m) => m.birthDate) + .map((m) => ({ + id: m.id, + name: m.name, + nickname: m.nickname, + birthDate: m.birthDate!, + avatarUrl: m.snsAvatar || m.publicImage || undefined, + })); + + return ( +
+
+ +

誕生日カレンダー

+ + {entries.length} 件 + +
+ +
+ ); +} diff --git a/app/internal/(protected)/page.tsx b/app/internal/(protected)/page.tsx index 93c5c5e..5a6eaa9 100644 --- a/app/internal/(protected)/page.tsx +++ b/app/internal/(protected)/page.tsx @@ -1,6 +1,7 @@ import Link from "next/link"; import { auth } from "@/lib/auth"; -import { getMember } from "@/lib/members"; +import { getMember, getMembersInternal } from "@/lib/members"; +import { TodayBirthdayBanner } from "@/components/today-birthday-banner"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Progress } from "@/components/ui/progress"; import { @@ -72,10 +73,25 @@ function getProfileCompletionItems( ]; } +function getTodayBirthdayNames( + members: { name: string; nickname?: string; birthDate?: string }[], +): string[] { + const now = new Date(); + const mm = String(now.getMonth() + 1).padStart(2, "0"); + const dd = String(now.getDate()).padStart(2, "0"); + return members + .filter((m) => m.birthDate?.slice(5) === `${mm}-${dd}`) + .map((m) => m.nickname || m.name); +} + export default async function InternalPage() { const session = await auth(); - const member = await getMember(session!.user!.id); + const [member, allMembers] = await Promise.all([ + getMember(session!.user!.id), + getMembersInternal(), + ]); const displayName = member?.nickname || session?.user?.name || "メンバー"; + const todayBirthdayNames = getTodayBirthdayNames(allMembers); const items = getProfileCompletionItems( member as Record | null, ); @@ -92,6 +108,9 @@ export default async function InternalPage() { return (
+ {todayBirthdayNames.length > 0 && ( + + )} {/* Welcome Banner */}
diff --git a/components/app-sidebar.tsx b/components/app-sidebar.tsx index fb50a66..3ceb5f2 100644 --- a/components/app-sidebar.tsx +++ b/components/app-sidebar.tsx @@ -6,6 +6,7 @@ import { LayoutDashboard, Users, CalendarDays, + Cake, UserCircle, Settings, ExternalLink, @@ -35,6 +36,7 @@ const NAV_ITEMS = [ { href: "/internal", icon: LayoutDashboard, label: "ホーム", exact: true }, { href: "/internal/members", icon: Users, label: "メンバー" }, { href: "/internal/events", icon: CalendarDays, label: "イベント" }, + { href: "/internal/birthdays", icon: Cake, label: "誕生日" }, { href: "/internal/profile", icon: UserCircle, label: "プロフィール" }, { href: "/internal/settings", icon: Settings, label: "設定" }, ]; diff --git a/components/birthday-list.tsx b/components/birthday-list.tsx new file mode 100644 index 0000000..6eb8944 --- /dev/null +++ b/components/birthday-list.tsx @@ -0,0 +1,188 @@ +"use client"; + +import { useMemo } from "react"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Badge } from "@/components/ui/badge"; +import { formatBirthDate } from "@/lib/date"; +import { BirthdayYearCalendar } from "@/components/birthday-year-calendar"; + +type BirthdayEntry = { + id: string; + name: string; + nickname?: string; + birthDate: string; + avatarUrl?: string; +}; + +const MONTH_NAMES = [ + "1月", + "2月", + "3月", + "4月", + "5月", + "6月", + "7月", + "8月", + "9月", + "10月", + "11月", + "12月", +]; + +function daysUntilNextBirthday(birthDate: string): number { + const now = new Date(); + const [, m, d] = birthDate.split("-").map(Number); + const next = new Date(now.getFullYear(), m - 1, d); + if ( + next.getMonth() < now.getMonth() || + (next.getMonth() === now.getMonth() && next.getDate() < now.getDate()) + ) { + next.setFullYear(now.getFullYear() + 1); + } + const diff = + next.getTime() - + new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); + return Math.round(diff / (1000 * 60 * 60 * 24)); +} + +function MyBirthdayCountdown({ birthDate }: { birthDate: string }) { + const days = daysUntilNextBirthday(birthDate); + if (days === 0) return null; + + return ( +
+

あなたの誕生日まで

+

あと {days} 日

+

+ {formatBirthDate(birthDate)} +

+
+ ); +} + +export function BirthdayList({ + entries, + myBirthDate, +}: { + entries: BirthdayEntry[]; + myBirthDate?: string | null; +}) { + const now = new Date(); + const todayMonth = now.getMonth() + 1; + const todayDay = now.getDate(); + + const grouped = useMemo(() => { + const map = new Map(); + for (const entry of entries) { + const month = parseInt(entry.birthDate.split("-")[1]); + if (!map.has(month)) map.set(month, []); + map.get(month)!.push(entry); + } + for (const list of map.values()) { + list.sort( + (a, b) => + parseInt(a.birthDate.split("-")[2]) - + parseInt(b.birthDate.split("-")[2]), + ); + } + const months: number[] = []; + for (let i = 0; i < 12; i++) { + const m = ((todayMonth - 1 + i) % 12) + 1; + if (map.has(m)) months.push(m); + } + return months.map((m) => ({ month: m, entries: map.get(m)! })); + }, [entries, todayMonth]); + + if (entries.length === 0) { + return ( +

+ 誕生日を公開しているメンバーがいません。 +

+ ); + } + + return ( +
+ {myBirthDate && } + +
+ {grouped.map(({ month, entries: monthEntries }) => { + const isCurrentMonth = month === todayMonth; + return ( +
+

+ {MONTH_NAMES[month - 1]} + {isCurrentMonth && ( + + 今月 + + )} +

+
    + {monthEntries.map((entry) => { + const displayName = + entry.nickname && entry.nickname !== entry.name + ? entry.nickname + : entry.name; + const isToday = + month === todayMonth && + parseInt(entry.birthDate.split("-")[2]) === todayDay; + return ( +
  • +
    + + + + {displayName.charAt(0)} + + +
    +

    + {displayName} +

    + {entry.nickname && entry.nickname !== entry.name && ( +

    + {entry.name} +

    + )} +
    +
    + {isToday && ( + + 今日 + + )} + + {formatBirthDate(entry.birthDate)} + +
    +
    +
  • + ); + })} +
+
+ ); + })} +
+ +
+ +
+
+ ); +} diff --git a/components/birthday-year-calendar.tsx b/components/birthday-year-calendar.tsx new file mode 100644 index 0000000..159c0cb --- /dev/null +++ b/components/birthday-year-calendar.tsx @@ -0,0 +1,254 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { ChevronLeft, ChevronRight } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { formatBirthDate } from "@/lib/date"; + +type BirthdayEntry = { + id: string; + name: string; + nickname?: string; + birthDate: string; + avatarUrl?: string; +}; + +const MONTH_NAMES = [ + "1月", + "2月", + "3月", + "4月", + "5月", + "6月", + "7月", + "8月", + "9月", + "10月", + "11月", + "12月", +]; +const WEEK_DAYS = ["月", "火", "水", "木", "金", "土", "日"]; + +export function BirthdayYearCalendar({ + entries, +}: { + entries: BirthdayEntry[]; +}) { + const now = new Date(); + const currentYear = now.getFullYear(); + const todayMonth = now.getMonth() + 1; + const todayDay = now.getDate(); + + const [month, setMonth] = useState(todayMonth); // 1-12 + const [selectedDay, setSelectedDay] = useState(null); + + const prev = () => setMonth((m) => (m === 1 ? 12 : m - 1)); + const next = () => setMonth((m) => (m === 12 ? 1 : m + 1)); + + // 月 → 日 → エントリ のマップ + const byDay = useMemo(() => { + const map = new Map(); + for (const entry of entries) { + const [, mm, dd] = entry.birthDate.split("-").map(Number); + if (mm !== month) continue; + if (!map.has(dd)) map.set(dd, []); + map.get(dd)!.push(entry); + } + return map; + }, [entries, month]); + + // 月曜始まり: 1日の曜日オフセット (Mon=0) + const firstDayOfWeek = (new Date(currentYear, month - 1, 1).getDay() + 6) % 7; + const daysInMonth = new Date(currentYear, month, 0).getDate(); + + const cells: (number | null)[] = [ + ...Array(firstDayOfWeek).fill(null), + ...Array.from({ length: daysInMonth }, (_, i) => i + 1), + ]; + + const selectedMembers = selectedDay ? (byDay.get(selectedDay) ?? []) : []; + + return ( +
+

年間カレンダー

+ +
+ {/* ヘッダー: 前月 / 月名 / 次月 */} +
+ + + {MONTH_NAMES[month - 1]} + + +
+ + {/* 曜日ヘッダー */} +
+ {WEEK_DAYS.map((d, i) => ( +
+ {d} +
+ ))} +
+ + {/* 日付グリッド */} +
+ {cells.map((day, i) => { + if (!day) return
; + const hasBirthday = byDay.has(day); + const isToday = month === todayMonth && day === todayDay; + return ( + + ); + })} +
+ + {/* 今月の誕生日サマリー */} + {byDay.size > 0 && ( +
+ {[...byDay.entries()] + .sort((a, b) => a[0] - b[0]) + .map(([day, members]) => ( + + ))} +
+ )} +
+ + {/* 誕生日ポップアップ */} + !o && setSelectedDay(null)} + > + + + + {MONTH_NAMES[month - 1]} + {selectedDay}日の誕生日 + + +
    + {selectedMembers.map((m) => { + const display = + m.nickname && m.nickname !== m.name ? m.nickname : m.name; + return ( +
  • + + + {display.charAt(0)} + +
    +

    {display}

    + {m.nickname && m.nickname !== m.name && ( +

    {m.name}

    + )} +

    + {formatBirthDate(m.birthDate)} +

    +
    +
  • + ); + })} +
+
+
+
+ ); +} diff --git a/components/today-birthday-banner.tsx b/components/today-birthday-banner.tsx new file mode 100644 index 0000000..123633f --- /dev/null +++ b/components/today-birthday-banner.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import confetti from "canvas-confetti"; +import { Cake } from "lucide-react"; +import Link from "next/link"; + +interface TodayBirthdayBannerProps { + names: string[]; +} + +export function TodayBirthdayBanner({ names }: TodayBirthdayBannerProps) { + const firedRef = useRef(false); + + useEffect(() => { + if (firedRef.current) return; + firedRef.current = true; + + // 左右から紙吹雪を打ち上げる + const end = Date.now() + 3000; + const colors = ["#9333ea", "#3b82f6", "#f59e0b", "#10b981", "#ec4899"]; + + const frame = () => { + confetti({ + particleCount: 3, + angle: 60, + spread: 55, + origin: { x: 0 }, + colors, + }); + confetti({ + particleCount: 3, + angle: 120, + spread: 55, + origin: { x: 1 }, + colors, + }); + if (Date.now() < end) requestAnimationFrame(frame); + }; + frame(); + }, []); + + const label = + names.length === 1 + ? `${names[0]}さん` + : `${names.slice(0, -1).join("さん・")}さん・${names[names.length - 1]}さん`; + + return ( + +
+
+
+ +

+ 今日は {label} の誕生日です!おめでとうございます +

+
+
+ + ); +} diff --git a/lib/discord-dm.ts b/lib/discord-dm.ts index 887ff0b..5998d77 100644 --- a/lib/discord-dm.ts +++ b/lib/discord-dm.ts @@ -845,3 +845,27 @@ export async function editDiscordDm( throw new Error(`Failed to edit DM message: ${response.status} ${error}`); } } + +// --- Birthday notification --- + +const BIRTHDAY_COLOR = 0xf59e0b; // Amber + +export function buildBirthdayNotification( + names: string[], +): DiscordMessagePayload { + const label = + names.length === 1 + ? `**${names[0]}** さん` + : names.map((n) => `**${n}**`).join(" さん・") + " さん"; + + return { + embeds: [ + { + title: "誕生日おめでとうございます!", + description: `本日は ${label} の誕生日です!\nLumos メンバーで一緒にお祝いしましょう。`, + color: BIRTHDAY_COLOR, + fields: [], + }, + ], + }; +} diff --git a/package.json b/package.json index 70b3181..2f75640 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "@radix-ui/react-tooltip": "1.2.8", "@uiw/react-md-editor": "^4.1.0", "autoprefixer": "^10.5.0", + "canvas-confetti": "^1.9.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "1.0.4", @@ -78,6 +79,7 @@ }, "devDependencies": { "@tailwindcss/typography": "^0.5.19", + "@types/canvas-confetti": "^1.9.0", "@types/node": "^22.19.18", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 466de25..829e388 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -113,6 +113,9 @@ importers: autoprefixer: specifier: ^10.5.0 version: 10.5.0(postcss@8.5.14) + canvas-confetti: + specifier: ^1.9.4 + version: 1.9.4 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -198,6 +201,9 @@ importers: '@tailwindcss/typography': specifier: ^0.5.19 version: 0.5.19(tailwindcss@3.4.19(yaml@2.8.4)) + '@types/canvas-confetti': + specifier: ^1.9.0 + version: 1.9.0 '@types/node': specifier: ^22.19.18 version: 22.19.18 @@ -1881,6 +1887,9 @@ packages: '@tybys/wasm-util@0.10.2': resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + '@types/canvas-confetti@1.9.0': + resolution: {integrity: sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==} + '@types/caseless@0.12.5': resolution: {integrity: sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==} @@ -2566,6 +2575,9 @@ packages: caniuse-lite@1.0.30001792: resolution: {integrity: sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==} + canvas-confetti@1.9.4: + resolution: {integrity: sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==} + ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -8075,6 +8087,8 @@ snapshots: tslib: 2.8.1 optional: true + '@types/canvas-confetti@1.9.0': {} + '@types/caseless@0.12.5': {} '@types/chai@5.2.3': @@ -8809,6 +8823,8 @@ snapshots: caniuse-lite@1.0.30001792: {} + canvas-confetti@1.9.4: {} + ccount@2.0.1: {} chai@6.2.2: {}