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:
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);