ba-cafe

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README | LICENSE

commit b4666e45467dbe63cd5edf28d87f659b9ded7b41
parent 8beb48d2583e6d657aab8b9092efe51ba5342d4b
Author: Sunny <122193933+Sunny-JP@users.noreply.github.com>
Date:   Sat,  7 Feb 2026 18:19:42 +0900

Merge pull request #50 from Sunny-JP/v4-sunny-dev

reduce traffic
Diffstat:
Msrc/app/page.tsx | 357+++++++++++++++++++++++++++++++++++--------------------------------------------
Msrc/components/HistoryCalendar.tsx | 61+++++++++++++++++++++++++++++++++++--------------------------
Msrc/lib/timeUtils.ts | 6++++++
3 files changed, 198 insertions(+), 226 deletions(-)

diff --git a/src/app/page.tsx b/src/app/page.tsx @@ -1,8 +1,7 @@ "use client"; -import { useState, useEffect, useCallback } from "react"; -import { useAuth, supabase } from "@/hooks/useAuth"; -import OneSignal from 'react-onesignal'; +import { useState, useEffect, useCallback, useRef } from "react"; +import { supabase } from "@/hooks/useAuth"; import OneSignalInit from "@/components/OneSignalInit"; import Header from "@/components/Header"; import TimerDashboard from "@/components/TimerDashboard"; @@ -10,114 +9,71 @@ import BottomNavBar from "@/components/BottomNavBar"; import HistoryCalendar from "@/components/HistoryCalendar"; import Settings from "@/components/Settings"; import SidePanel from "@/components/SidePanel"; +import { CALENDAR_LIMITS } from "@/lib/timeUtils"; type Tab = 'timer' | 'history'; -function LoginScreen() { - const { loginWithDiscord } = useAuth(); - const [isLoginLoading, setIsLoginLoading] = useState(false); - - const handleLogin = async () => { - setIsLoginLoading(true); - try { - await loginWithDiscord(); - } catch (error) { - console.error("Login failed:", error); - alert("ログインに失敗しました"); - setIsLoginLoading(false); - } - }; - - return ( - <div className="flex flex-col items-center justify-center flex-1 p-8"> - <div className="timer-card text-center bg-card border border-muted p-8 rounded-2xl shadow-lg max-w-sm w-full"> - <h2 className="text-2xl font-bold mb-2">Welcome!</h2> - <p className="mb-8 text-muted-foreground text-sm"> - 利用するにはログインしてください - </p> - <button - onClick={handleLogin} - disabled={isLoginLoading} - className={` - w-full py-4 rounded-xl text-lg font-bold transition-all shadow-md - ${isLoginLoading - ? 'bg-muted text-muted-foreground cursor-wait' - : 'bg-[#5865F2] text-white hover:brightness-110 hover:shadow-lg' - } - `} - > - {isLoginLoading ? 'Connecting...' : 'Discord Login'} - </button> - </div> - </div> - ); -} - export default function Home() { - const { isLoggedIn, isLoading } = useAuth(); const [activeTab, setActiveTab] = useState<Tab>('timer'); - - // ステートの分離 + const [session, setSession] = useState<any>(null); const [timerHistory, setTimerHistory] = useState<number[]>([]); const [calendarHistory, setCalendarHistory] = useState<number[]>([]); - + const [calendarDate, setCalendarDate] = useState(() => new Date()); const [ticket1Time, setTicket1Time] = useState<Date | null>(null); const [ticket2Time, setTicket2Time] = useState<Date | null>(null); const [isSyncing, setIsSyncing] = useState(false); const [isDataLoaded, setIsDataLoaded] = useState(false); + const [isAuthChecking, setIsAuthChecking] = useState(true); const [isSidePanelOpen, setIsSidePanelOpen] = useState(false); - // 月次データ取得の共通関数 - const fetchMonthlyData = useCallback(async (year: number, month: number) => { - const { data: { user } } = await supabase.auth.getUser(); - if (!user) return []; + const isInitialFetched = useRef(false); - const { data, error } = await supabase.rpc('get_taps_by_logical_month', { - target_user_id: user.id, - target_year: year, - target_month: month - }); + const fetchMonthlyData = useCallback(async (year: number, month: number) => { + const { data: { session: s } } = await supabase.auth.getSession(); + if (!s?.user?.id) return []; - if (error) { - console.error("Fetch monthly data error:", error); + try { + const { data, error } = await supabase.rpc('get_taps_by_logical_month', { + target_user_id: s.user.id, + target_year: year, + target_month: month + }); + if (error) throw error; + return (data || []).map((t: any) => new Date(t.tap_time).getTime()); + } catch (e) { + console.error("RPC Error:", e); return []; } - return (data || []).map((t: any) => new Date(t.tap_time).getTime()); }, []); - // 初期ロード + const handleMonthChange = useCallback(async (year: number, month: number) => { + const targetDate = new Date(year, month - 1, 1); + const minLimit = new Date(CALENDAR_LIMITS.MIN.getFullYear(), CALENDAR_LIMITS.MIN.getMonth(), 1); + const maxLimit = new Date(CALENDAR_LIMITS.MAX.getFullYear(), CALENDAR_LIMITS.MAX.getMonth(), 1); + + if (targetDate < minLimit || targetDate > maxLimit) return; + + setCalendarDate(targetDate); + const data = await fetchMonthlyData(year, month); + setCalendarHistory(data); + }, [fetchMonthlyData]); + const loadInitialData = useCallback(async () => { - const { data: { user } } = await supabase.auth.getUser(); - if (!user) return; - - try { - if (typeof window !== 'undefined' && OneSignal.User) { - await OneSignal.login(user.id); - } - } catch (e) { - console.warn("OneSignal login skipped:", e); + if (isInitialFetched.current) return; + const { data: { session: s } } = await supabase.auth.getSession(); + if (!s?.user?.id) { + setIsAuthChecking(false); + return; } - try { - // 1. チケット情報の取得 - const { data: profile } = await supabase - .from('profiles') - .select('ticket1_time, ticket2_time') - .eq('id', user.id) - .single(); - - if (profile) { - if (profile.ticket1_time) setTicket1Time(new Date(profile.ticket1_time)); - if (profile.ticket2_time) setTicket2Time(new Date(profile.ticket2_time)); - } + isInitialFetched.current = true; - // 2. 現在の論理的な月を判定してフル取得 + try { const now = new Date(); const jstNow = new Date(now.getTime() + 9 * 60 * 60 * 1000); let year = jstNow.getUTCFullYear(); let month = jstNow.getUTCMonth() + 1; - // 4時境界の補正 (1日の朝4時前なら前月を取得) if (jstNow.getUTCDate() === 1 && jstNow.getUTCHours() < 4) { const prev = new Date(jstNow); prev.setUTCDate(0); @@ -125,146 +81,147 @@ export default function Home() { month = prev.getUTCMonth() + 1; } - const fullMonthlyData = await fetchMonthlyData(year, month); - - // 両方のステートを更新(カレンダーは全件、タイマーは計算用に全件持たせる) - setCalendarHistory(fullMonthlyData); - setTimerHistory(fullMonthlyData); + setCalendarDate(new Date(year, month - 1, 1)); + const [profileRes, monthlyData] = await Promise.all([ + supabase.from('profiles').select('ticket1_time, ticket2_time').eq('id', s.user.id).single(), + fetchMonthlyData(year, month) + ]); + + if (profileRes.data) { + if (profileRes.data.ticket1_time) setTicket1Time(new Date(profileRes.data.ticket1_time)); + if (profileRes.data.ticket2_time) setTicket2Time(new Date(profileRes.data.ticket2_time)); + } + + setCalendarHistory(monthlyData); + setTimerHistory(monthlyData); + setIsDataLoaded(true); } catch (e) { - console.error("Initial load error", e); + console.error("Load failed:", e); + isInitialFetched.current = false; } finally { - setIsDataLoaded(true); + setIsAuthChecking(false); } }, [fetchMonthlyData]); - // カレンダーの月切り替え用 - const loadMonthlyData = useCallback(async (year: number, month: number) => { - setIsSyncing(true); - const data = await fetchMonthlyData(year, month); - setCalendarHistory(data); - setIsSyncing(false); - }, [fetchMonthlyData]); + useEffect(() => { + supabase.auth.getSession().then(({ data: { session: s } }) => { + setSession(s); + if (s) { + loadInitialData(); + } else { + setIsAuthChecking(false); + } + }); - const syncTickets = async (t1ISO?: string | null, t2ISO?: string | null) => { - if (!isLoggedIn) return; - try { - const { data: { session } } = await supabase.auth.getSession(); - if (!session) return; + const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, s) => { + setSession(s); + if (s) { + loadInitialData(); + } else { + setIsAuthChecking(false); + setIsDataLoaded(false); + isInitialFetched.current = false; + } + }); - await fetch('/api/tap', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${session.access_token}` - }, - body: JSON.stringify({ ticket1Time: t1ISO, ticket2Time: t2ISO }) - }); - } catch (error) { - console.error("Ticket sync failed", error); - } - }; + return () => subscription.unsubscribe(); + }, [loadInitialData]); const handleTap = async () => { - if (!isLoggedIn || isSyncing) return; - + if (!session || isSyncing) return; + setIsSyncing(true); const now = new Date(); - const newTapMs = now.getTime(); - - setTimerHistory(prev => [...prev, newTapMs]); - setCalendarHistory(prev => [...prev, newTapMs]); + now.setMilliseconds(0); + const ms = now.getTime(); - const { data: { session } } = await supabase.auth.getSession(); - if (session) { - // APIを叩くことで通知予約も同時に行う - await fetch('/api/tap', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${session.access_token}` - }, - body: JSON.stringify({ tapTime: now.toISOString() }) - }); + setTimerHistory(prev => [...prev, ms]); + const tapJST = new Date(now.getTime() + 9 * 60 * 60 * 1000); + if (tapJST.getUTCFullYear() === calendarDate.getFullYear() && (tapJST.getUTCMonth() + 1) === (calendarDate.getMonth() + 1)) { + setCalendarHistory(prev => [...prev, ms]); } + + try { + const { data: { session: curS } } = await supabase.auth.getSession(); + if (curS) { + await fetch('/api/tap', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${curS.access_token}` }, + body: JSON.stringify({ tapTime: now.toISOString() }) + }); + } + } finally { setIsSyncing(false); } }; - const handleInvite = async (ticketNumber: 1 | 2) => { + const handleInvite = async (num: 1 | 2) => { + if (!session || isSyncing) return; + setIsSyncing(true); const now = new Date(); - let t1ISO = ticket1Time?.toISOString() || null; - let t2ISO = ticket2Time?.toISOString() || null; - - if (ticketNumber === 1) { - setTicket1Time(now); - t1ISO = now.toISOString(); - } else { - setTicket2Time(now); - t2ISO = now.toISOString(); - } - await syncTickets(t1ISO, t2ISO); + now.setMilliseconds(0); + if (num === 1) setTicket1Time(now); else setTicket2Time(now); + try { + const { data: { session: curS } } = await supabase.auth.getSession(); + if (curS) { + await fetch('/api/tap', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${curS.access_token}` }, + body: JSON.stringify({ [num === 1 ? 'ticket1Time' : 'ticket2Time']: now.toISOString() }) + }); + } + } finally { setIsSyncing(false); } }; - useEffect(() => { - if (isLoggedIn && !isLoading) loadInitialData(); - }, [isLoggedIn, isLoading, loadInitialData]); - - const lastTapTime = timerHistory.length > 0 ? new Date(timerHistory[timerHistory.length - 1]) : null; - - if (isLoading) return <div className="flex justify-center items-center h-screen font-bold">Loading...</div>; + // 1. セッション確認中またはデータロード中のLoading + if (isAuthChecking || (session && !isDataLoaded)) { + return ( + <div className="flex justify-center items-center h-screen bg-background font-bold text-foreground"> + Loading... + </div> + ); + } + + // 2. 未ログイン時のログイン画面 + if (!session) { + return ( + <div className="flex flex-col items-center justify-center min-h-screen bg-background p-8"> + <div className="timer-card text-center bg-card border border-muted p-8 rounded-2xl shadow-lg max-w-sm w-full"> + <h2 className="text-2xl font-bold mb-2">Welcome!</h2> + <p className="mb-8 text-muted-foreground text-sm">利用するにはログインしてください</p> + <button + onClick={() => supabase.auth.signInWithOAuth({ provider: 'discord', options: { redirectTo: window.location.origin } })} + className="w-full py-4 rounded-xl text-lg font-bold bg-[#5865F2] text-white hover:brightness-110 shadow-md transition-all" + > + Discord Login + </button> + </div> + </div> + ); + } + // 3. メインコンテンツ return ( <div className="bg-background h-screen flex flex-col"> <OneSignalInit /> - <Header isLoggedIn={isLoggedIn} onMenuClick={() => setIsSidePanelOpen(true)} /> - <main className="pt-16 pb-16 min-[1000px]:pb-0 flex-1 flex flex-col"> - {!isLoggedIn ? <LoginScreen /> : ( - <> - <div className="min-[1000px]:hidden flex-1"> - {activeTab === 'timer' && ( - <TimerDashboard - tapHistory={timerHistory} - lastTapTime={lastTapTime} - ticket1Time={ticket1Time} - ticket2Time={ticket2Time} - onTap={handleTap} - onInvite={handleInvite} - isSyncing={isSyncing} - isDataLoaded={isDataLoaded} - /> - )} - {activeTab === 'history' && ( - <div className="p-4"> - <HistoryCalendar tapHistory={calendarHistory} onMonthChange={loadMonthlyData} /> - </div> - )} - </div> - {/* Desktop View */} - <div className="hidden min-[1000px]:flex flex-1 items-center justify-center p-6 h-[calc(100vh-64px)] overflow-hidden"> - <div className="grid grid-cols-2 gap-6 w-full max-w-[160svh] mx-auto items-stretch"> - <div className="flex flex-col justify-center"> - <TimerDashboard - tapHistory={timerHistory} - lastTapTime={lastTapTime} - ticket1Time={ticket1Time} - ticket2Time={ticket2Time} - onTap={handleTap} - onInvite={handleInvite} - isSyncing={isSyncing} - isDataLoaded={isDataLoaded} - /> - </div> - <div className="flex flex-col justify-center"> - <HistoryCalendar tapHistory={calendarHistory} onMonthChange={loadMonthlyData} /> - </div> - </div> + <Header isLoggedIn={!!session} onMenuClick={() => setIsSidePanelOpen(true)} /> + <main className="flex-1 flex flex-col pt-16 pb-16 min-[1000px]:pb-0"> + <div className="min-[1000px]:hidden flex-1"> + {activeTab === 'timer' && ( + <TimerDashboard tapHistory={timerHistory} lastTapTime={timerHistory.length ? new Date(timerHistory[timerHistory.length-1]) : null} ticket1Time={ticket1Time} ticket2Time={ticket2Time} onTap={handleTap} onInvite={handleInvite} isSyncing={isSyncing} isDataLoaded={isDataLoaded} /> + )} + {activeTab === 'history' && ( + <div className="p-4"> + <HistoryCalendar tapHistory={calendarHistory} currentDate={calendarDate} onMonthChange={handleMonthChange} /> </div> - - <BottomNavBar activeTab={activeTab} setActiveTab={setActiveTab} /> - - <SidePanel isOpen={isSidePanelOpen} onClose={() => setIsSidePanelOpen(false)} title="Settings"> - <Settings /> - </SidePanel> - </> - )} + )} + </div> + <div className="hidden min-[1000px]:flex flex-1 items-center justify-center p-6 h-[calc(100vh-64px)] overflow-hidden"> + <div className="grid grid-cols-2 gap-6 w-full max-w-[160svh] items-stretch mx-auto"> + <TimerDashboard tapHistory={timerHistory} lastTapTime={timerHistory.length ? new Date(timerHistory[timerHistory.length-1]) : null} ticket1Time={ticket1Time} ticket2Time={ticket2Time} onTap={handleTap} onInvite={handleInvite} isSyncing={isSyncing} isDataLoaded={isDataLoaded} /> + <HistoryCalendar tapHistory={calendarHistory} currentDate={calendarDate} onMonthChange={handleMonthChange} /> + </div> + </div> + <BottomNavBar activeTab={activeTab} setActiveTab={setActiveTab} /> + <SidePanel isOpen={isSidePanelOpen} onClose={() => setIsSidePanelOpen(false)} title="Settings"><Settings /></SidePanel> </main> </div> ); diff --git a/src/components/HistoryCalendar.tsx b/src/components/HistoryCalendar.tsx @@ -1,6 +1,7 @@ -import React, { useState, useRef, useEffect } from 'react'; +import React, { useRef } from 'react'; import { formatInTimeZone, toZonedTime } from 'date-fns-tz'; import { toPng } from 'html-to-image'; +import { CALENDAR_LIMITS } from '@/lib/timeUtils'; const JST_TZ = 'Asia/Tokyo'; const SITE_NAME = "My Tap History by Cafe Timer"; @@ -8,17 +9,20 @@ const SITE_URL = "https://cafetimer.rabbit1.cc"; interface HistoryCalendarProps { tapHistory: number[]; + currentDate: Date; onMonthChange: (year: number, month: number) => void; } -const HistoryCalendar: React.FC<HistoryCalendarProps> = ({ tapHistory, onMonthChange }) => { - const [currentDate, setCurrentDate] = useState(() => toZonedTime(new Date(), JST_TZ)); - const [isExporting, setIsExporting] = useState(false); +const HistoryCalendar: React.FC<HistoryCalendarProps> = ({ tapHistory, currentDate, onMonthChange }) => { const exportRef = useRef<HTMLDivElement>(null); + const [isExporting, setIsExporting] = React.useState(false); - useEffect(() => { - onMonthChange(currentDate.getFullYear(), currentDate.getMonth() + 1); - }, [currentDate, onMonthChange]); + const jstDate = toZonedTime(currentDate, JST_TZ); + const year = jstDate.getFullYear(); + const month = jstDate.getMonth(); + + const canPrev = year > CALENDAR_LIMITS.MIN.getFullYear() || month > CALENDAR_LIMITS.MIN.getMonth(); + const canNext = year < CALENDAR_LIMITS.MAX.getFullYear() || month < CALENDAR_LIMITS.MAX.getMonth(); const getLogicalDateString = (timestamp: number): string => { const adjustedTime = timestamp - 4 * 60 * 60 * 1000; @@ -32,8 +36,6 @@ const HistoryCalendar: React.FC<HistoryCalendarProps> = ({ tapHistory, onMonthCh return acc; }, {} as Record<string, number[]>); - const year = currentDate.getFullYear(); - const month = currentDate.getMonth(); const firstDayIndex = new Date(year, month, 1).getDay(); const startDay = (firstDayIndex + 6) % 7; const daysInMonth = new Date(year, month + 1, 0).getDate(); @@ -42,17 +44,11 @@ const HistoryCalendar: React.FC<HistoryCalendarProps> = ({ tapHistory, onMonthCh if (!exportRef.current) return; setIsExporting(true); await new Promise((resolve) => setTimeout(resolve, 200)); - try { const dataUrl = await toPng(exportRef.current, { - canvasWidth: 1440, - canvasHeight: 1440, - width: 1440, - height: 1440, - style: { transform: 'none' }, - cacheBust: true, + canvasWidth: 1440, canvasHeight: 1440, width: 1440, height: 1440, + style: { transform: 'none' }, cacheBust: true, }); - const link = document.createElement('a'); link.download = `History-${year}-${month + 1}.png`; link.href = dataUrl; @@ -65,9 +61,12 @@ const HistoryCalendar: React.FC<HistoryCalendarProps> = ({ tapHistory, onMonthCh }; const changeMonth = (offset: number) => { - const newDate = new Date(currentDate); - newDate.setMonth(newDate.getMonth() + offset); - setCurrentDate(newDate); + if (offset < 0 && !canPrev) return; + if (offset > 0 && !canNext) return; + + const nextDate = new Date(currentDate); + nextDate.setMonth(nextDate.getMonth() + offset); + onMonthChange(nextDate.getFullYear(), nextDate.getMonth() + 1); }; const calendarDays = []; @@ -90,17 +89,27 @@ const HistoryCalendar: React.FC<HistoryCalendarProps> = ({ tapHistory, onMonthCh return ( <div className="cal-container"> <div className="flex justify-between items-center mb-6"> - <button onClick={() => changeMonth(-1)} className="cal-nav">Prev</button> + <button + onClick={() => changeMonth(-1)} + disabled={!canPrev} + className={`cal-nav ${!canPrev ? 'opacity-30 cursor-not-allowed' : ''}`} + > + Prev + </button> <div className="flex flex-col items-center"> <div className="cal-info">{year}. {month + 1}</div> - <button onClick={handleExportImage} className="cal-save-nav uppercase tracking-widest"> - Save Image - </button> + <button onClick={handleExportImage} className="cal-save-nav uppercase tracking-widest">Save Image</button> </div> - <button onClick={() => changeMonth(1)} className="cal-nav">Next</button> + <button + onClick={() => changeMonth(1)} + disabled={!canNext} + className={`cal-nav ${!canNext ? 'opacity-30 cursor-not-allowed' : ''}`} + > + Next + </button> </div> - <div ref={exportRef} className={`bg-(--background) ${isExporting ? 'export-container' : 'w-full rounded-2xl'}`}> + <div ref={exportRef} className={`bg-(--background) ${isExporting ? 'export-container' : 'w-full rounded-2xl overflow-hidden'}`}> {isExporting && <div className="export-header">{year} / {String(month + 1).padStart(2, '0')}</div>} <div className={`grid grid-cols-7 ${isExporting ? 'export-grid' : 'gap-2'}`}> {['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].map(d => ( diff --git a/src/lib/timeUtils.ts b/src/lib/timeUtils.ts @@ -3,6 +3,12 @@ import { toZonedTime, fromZonedTime } from 'date-fns-tz'; const JST_TZ = 'Asia/Tokyo'; +// カレンダーの移動制限範囲(運用開始月~現在の月) +export const CALENDAR_LIMITS = { + MIN: new Date(2025, 12, 1), + MAX: new Date(), +} as const; + // 境界線(4時/16時)を求める export const getNextBoundary = (date: Date): Date => { const jst = toZonedTime(date, JST_TZ);