ba-cafe

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

commit 4a2cfdb3bba70bef5b7c112cd43840e7c7f26eeb
parent 2d44e97e8dd14cfc233c80e6556e851964284bce
Author: Sunny <122193933+Sunny-JP@users.noreply.github.com>
Date:   Thu,  5 Feb 2026 01:53:46 +0900

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

adjust DB structure
Diffstat:
Msrc/app/api/tap/route.ts | 55++++++++++++++++++++-----------------------------------
Msrc/app/page.tsx | 142+++++++++++++++++++++++++++++++++++++++++++++++++------------------------------
Msrc/components/HistoryCalendar.tsx | 82+++++++++++++++++++++----------------------------------------------------------
3 files changed, 131 insertions(+), 148 deletions(-)

diff --git a/src/app/api/tap/route.ts b/src/app/api/tap/route.ts @@ -19,6 +19,7 @@ export async function OPTIONS() { export async function POST(request: Request) { try { const body = await request.json(); + // tapTime(通知・履歴用)とチケット時間を取得 const { tapTime, ticket1Time, ticket2Time } = body; const authHeader = request.headers.get('Authorization'); @@ -33,34 +34,34 @@ export async function POST(request: Request) { const { data: { user }, error: authError } = await supabase.auth.getUser(); if (authError || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - // --- 1. 重複タップ防止ロジック --- + // --- 1. 重複タップ防止ロジック (tapsテーブルから最新レコードを確認) --- if (tapTime) { - const { data: profile } = await supabase - .from('profiles') - .select('tap_history') - .eq('id', user.id) - .single(); + const { data: lastTap } = await supabase + .from('taps') + .select('tap_time') + .eq('user_id', user.id) + .order('tap_time', { ascending: false }) + .limit(1) + .maybeSingle(); - const history = profile?.tap_history || []; - if (history.length > 0) { - const lastTapDate = new Date(history[history.length - 1]); + if (lastTap) { + const lastTapDate = new Date(lastTap.tap_time); const currentTapDate = new Date(tapTime); const diffMs = currentTapDate.getTime() - lastTapDate.getTime(); - // 1時間以内の連続タップをチェック + // 1時間以内の同一時間枠タップをチェック if (diffMs < 3600000) { - const lastShouldNotify = shouldScheduleNotification(lastTapDate); - const currentShouldNotify = shouldScheduleNotification(currentTapDate); - - // 通知予約の判定(送る/送らない)が同じ時間枠なら、重複として拒否 - if (lastShouldNotify === currentShouldNotify) { + if (shouldScheduleNotification(lastTapDate) === shouldScheduleNotification(currentTapDate)) { return NextResponse.json({ error: 'Duplicate tap within 1 hour' }, { status: 429 }); } } } + + // 重複でなければ新テーブルにインサート + await supabase.from('taps').insert([{ user_id: user.id, tap_time: new Date(tapTime).toISOString() }]); } - // --- 2. DB更新データの作成 --- + // --- 2. profilesテーブルの更新 (チケット時間のみ) --- const upsertData: any = { id: user.id, updated_at: new Date().toISOString() @@ -69,18 +70,7 @@ export async function POST(request: Request) { if (ticket1Time !== undefined) upsertData.ticket1_time = ticket1Time ? new Date(ticket1Time).toISOString() : null; if (ticket2Time !== undefined) upsertData.ticket2_time = ticket2Time ? new Date(ticket2Time).toISOString() : null; - let newHistory: string[] = []; - if (tapTime) { - const { data: profile } = await supabase.from('profiles').select('tap_history').eq('id', user.id).single(); - newHistory = [...(profile?.tap_history || [])]; - - const tapDate = new Date(tapTime); - tapDate.setMilliseconds(0); - newHistory.push(tapDate.toISOString()); - - upsertData.tap_history = newHistory; - } - + // tap_historyカラムへの書き込みは行わない(profilesを軽量に保つ) await supabase.from('profiles').upsert(upsertData); // --- 3. 通知予約処理 --- @@ -91,7 +81,7 @@ export async function POST(request: Request) { const randomMsg = messages[Math.floor(Math.random() * messages.length)]; - const osResponse = await fetch("https://onesignal.com/api/v1/notifications", { + await fetch("https://onesignal.com/api/v1/notifications", { method: "POST", headers: { "Content-Type": "application/json", @@ -106,14 +96,9 @@ export async function POST(request: Request) { send_after: sendAfter.toISOString(), }) }); - - if (!osResponse.ok) { - const errorMsg = await osResponse.text(); - console.error("OneSignal API Error:", errorMsg); - } } - return NextResponse.json({ success: true, history: newHistory }); + return NextResponse.json({ success: true }); } catch (error: any) { console.error("API Error:", error); diff --git a/src/app/page.tsx b/src/app/page.tsx @@ -35,7 +35,6 @@ function LoginScreen() { <p className="mb-8 text-muted-foreground text-sm"> 利用するにはログインしてください </p> - <button onClick={handleLogin} disabled={isLoginLoading} @@ -57,14 +56,37 @@ function LoginScreen() { export default function Home() { const { isLoggedIn, isLoading } = useAuth(); const [activeTab, setActiveTab] = useState<Tab>('timer'); - const [tapHistory, setTapHistory] = useState<number[]>([]); + + // ステートの分離 + const [timerHistory, setTimerHistory] = useState<number[]>([]); + const [calendarHistory, setCalendarHistory] = useState<number[]>([]); + 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 [isSidePanelOpen, setIsSidePanelOpen] = useState(false); - const loadData = useCallback(async () => { + // 月次データ取得の共通関数 + const fetchMonthlyData = useCallback(async (year: number, month: number) => { + const { data: { user } } = await supabase.auth.getUser(); + if (!user) return []; + + const { data, error } = await supabase.rpc('get_taps_by_logical_month', { + target_user_id: user.id, + target_year: year, + target_month: month + }); + + if (error) { + console.error("Fetch monthly data error:", error); + return []; + } + return (data || []).map((t: any) => new Date(t.tap_time).getTime()); + }, []); + + // 初期ロード + const loadInitialData = useCallback(async () => { const { data: { user } } = await supabase.auth.getUser(); if (!user) return; @@ -75,63 +97,71 @@ export default function Home() { } catch (e) { console.warn("OneSignal login skipped:", e); } - + try { - const { data, error } = await supabase + // 1. チケット情報の取得 + const { data: profile } = await supabase .from('profiles') - .select('*') + .select('ticket1_time, ticket2_time') .eq('id', user.id) .single(); - if (error) throw error; + if (profile) { + if (profile.ticket1_time) setTicket1Time(new Date(profile.ticket1_time)); + if (profile.ticket2_time) setTicket2Time(new Date(profile.ticket2_time)); + } + + // 2. 現在の論理的な月を判定してフル取得 + const now = new Date(); + const jstNow = new Date(now.getTime() + 9 * 60 * 60 * 1000); + let year = jstNow.getUTCFullYear(); + let month = jstNow.getUTCMonth() + 1; - if (data) { - if (data.tap_history && Array.isArray(data.tap_history)) { - const numericHistory = data.tap_history - .map((t: string) => new Date(t).getTime()) - .filter((t: number) => !isNaN(t)); - - setTapHistory(numericHistory); - console.log("Success: Loaded tap history", numericHistory.length, "items"); - } - - if (data.ticket1_time) setTicket1Time(new Date(data.ticket1_time)); - if (data.ticket2_time) setTicket2Time(new Date(data.ticket2_time)); + // 4時境界の補正 (1日の朝4時前なら前月を取得) + if (jstNow.getUTCDate() === 1 && jstNow.getUTCHours() < 4) { + const prev = new Date(jstNow); + prev.setUTCDate(0); + year = prev.getUTCFullYear(); + month = prev.getUTCMonth() + 1; } + + const fullMonthlyData = await fetchMonthlyData(year, month); + + // 両方のステートを更新(カレンダーは全件、タイマーは計算用に全件持たせる) + setCalendarHistory(fullMonthlyData); + setTimerHistory(fullMonthlyData); + } catch (e) { - console.error("Load error", e); + console.error("Initial load error", e); } finally { setIsDataLoaded(true); } - }, []); + }, [fetchMonthlyData]); - const syncData = async (tapISO?: string, t1ISO?: string | null, t2ISO?: string | null) => { - if (!isLoggedIn) return; + // カレンダーの月切り替え用 + const loadMonthlyData = useCallback(async (year: number, month: number) => { setIsSyncing(true); - + const data = await fetchMonthlyData(year, month); + setCalendarHistory(data); + setIsSyncing(false); + }, [fetchMonthlyData]); + + const syncTickets = async (t1ISO?: string | null, t2ISO?: string | null) => { + if (!isLoggedIn) return; try { const { data: { session } } = await supabase.auth.getSession(); if (!session) return; - const res = await fetch('/api/tap', { + await fetch('/api/tap', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${session.access_token}` }, - body: JSON.stringify({ - tapTime: tapISO, - ticket1Time: t1ISO, - ticket2Time: t2ISO - }) + body: JSON.stringify({ ticket1Time: t1ISO, ticket2Time: t2ISO }) }); - - if (!res.ok) throw new Error("Server sync failed"); - } catch (error) { - console.error("Sync failed", error); - } finally { - setIsSyncing(false); + console.error("Ticket sync failed", error); } }; @@ -139,22 +169,27 @@ export default function Home() { if (!isLoggedIn || isSyncing) return; const now = new Date(); - now.setMilliseconds(0); const newTapMs = now.getTime(); - setTapHistory(prev => [...prev, newTapMs]); + setTimerHistory(prev => [...prev, newTapMs]); + setCalendarHistory(prev => [...prev, newTapMs]); - await syncData( - now.toISOString(), - ticket1Time?.toISOString() || null, - ticket2Time?.toISOString() || null - ); + 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() }) + }); + } }; const handleInvite = async (ticketNumber: 1 | 2) => { const now = new Date(); - now.setMilliseconds(0); - let t1ISO = ticket1Time?.toISOString() || null; let t2ISO = ticket2Time?.toISOString() || null; @@ -165,16 +200,17 @@ export default function Home() { setTicket2Time(now); t2ISO = now.toISOString(); } - await syncData(undefined, t1ISO, t2ISO); + await syncTickets(t1ISO, t2ISO); }; useEffect(() => { - if (isLoggedIn && !isLoading) loadData(); - }, [isLoggedIn, isLoading, loadData]); + if (isLoggedIn && !isLoading) loadInitialData(); + }, [isLoggedIn, isLoading, loadInitialData]); - const lastTapTime = tapHistory.length > 0 ? new Date(tapHistory[tapHistory.length - 1]) : null; + 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>; + return ( <div className="bg-background h-screen flex flex-col"> <OneSignalInit /> @@ -185,7 +221,7 @@ export default function Home() { <div className="min-[1000px]:hidden flex-1"> {activeTab === 'timer' && ( <TimerDashboard - tapHistory={tapHistory} + tapHistory={timerHistory} lastTapTime={lastTapTime} ticket1Time={ticket1Time} ticket2Time={ticket2Time} @@ -197,7 +233,7 @@ export default function Home() { )} {activeTab === 'history' && ( <div className="p-4"> - <HistoryCalendar tapHistory={tapHistory} /> + <HistoryCalendar tapHistory={calendarHistory} onMonthChange={loadMonthlyData} /> </div> )} </div> @@ -206,7 +242,7 @@ export default function Home() { <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={tapHistory} + tapHistory={timerHistory} lastTapTime={lastTapTime} ticket1Time={ticket1Time} ticket2Time={ticket2Time} @@ -217,7 +253,7 @@ export default function Home() { /> </div> <div className="flex flex-col justify-center"> - <HistoryCalendar tapHistory={tapHistory} /> + <HistoryCalendar tapHistory={calendarHistory} onMonthChange={loadMonthlyData} /> </div> </div> </div> diff --git a/src/components/HistoryCalendar.tsx b/src/components/HistoryCalendar.tsx @@ -1,4 +1,4 @@ -import React, { useState, useRef } from 'react'; +import React, { useState, useRef, useEffect } from 'react'; import { formatInTimeZone, toZonedTime } from 'date-fns-tz'; import { toPng } from 'html-to-image'; @@ -8,13 +8,18 @@ const SITE_URL = "https://cafetimer.rabbit1.cc"; interface HistoryCalendarProps { tapHistory: number[]; + onMonthChange: (year: number, month: number) => void; } -const HistoryCalendar: React.FC<HistoryCalendarProps> = ({ tapHistory }) => { +const HistoryCalendar: React.FC<HistoryCalendarProps> = ({ tapHistory, onMonthChange }) => { const [currentDate, setCurrentDate] = useState(() => toZonedTime(new Date(), JST_TZ)); const [isExporting, setIsExporting] = useState(false); const exportRef = useRef<HTMLDivElement>(null); + useEffect(() => { + onMonthChange(currentDate.getFullYear(), currentDate.getMonth() + 1); + }, [currentDate, onMonthChange]); + const getLogicalDateString = (timestamp: number): string => { const adjustedTime = timestamp - 4 * 60 * 60 * 1000; return formatInTimeZone(adjustedTime, JST_TZ, 'yyyy-MM-dd'); @@ -59,6 +64,12 @@ const HistoryCalendar: React.FC<HistoryCalendarProps> = ({ tapHistory }) => { } }; + const changeMonth = (offset: number) => { + const newDate = new Date(currentDate); + newDate.setMonth(newDate.getMonth() + offset); + setCurrentDate(newDate); + }; + const calendarDays = []; for (let i = 0; i < startDay; i++) { calendarDays.push(<div key={`empty-${i}`} className="p-2 aspect-square"></div>); @@ -76,12 +87,6 @@ const HistoryCalendar: React.FC<HistoryCalendarProps> = ({ tapHistory }) => { ); } - const changeMonth = (offset: number) => { - const newDate = new Date(currentDate); - newDate.setMonth(newDate.getMonth() + offset); - setCurrentDate(newDate); - }; - return ( <div className="cal-container"> <div className="flex justify-between items-center mb-6"> @@ -95,21 +100,14 @@ const HistoryCalendar: React.FC<HistoryCalendarProps> = ({ tapHistory }) => { <button onClick={() => changeMonth(1)} className="cal-nav">Next</button> </div> - <div - ref={exportRef} - className={`bg-(--background) ${isExporting ? 'export-container' : 'w-full rounded-2xl'}`} - > - {isExporting && ( - <div className="export-header">{year} / {String(month + 1).padStart(2, '0')}</div> - )} - + <div ref={exportRef} className={`bg-(--background) ${isExporting ? 'export-container' : 'w-full rounded-2xl'}`}> + {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 => ( <div key={d} className={`text-center font-bold cal-week ${isExporting ? 'export-week' : 'text-xl pb-4'}`}>{d}</div> ))} {calendarDays} </div> - {isExporting && ( <div className="export-footer"> <div className="export-site-name">{SITE_NAME}</div> @@ -119,49 +117,13 @@ const HistoryCalendar: React.FC<HistoryCalendarProps> = ({ tapHistory }) => { </div> <style jsx>{` - .export-container { - position: fixed; - top: 0; - left: 0; - width: 1440px; - height: 1440px; - display: flex; - flex-direction: column; - justify-content: space-between; - padding: 80px 100px; - z-index: -100; - } - .export-header { - font-size: 100px !important; - font-weight: 800; - text-align: center; - margin-bottom: 20px; - } - .export-grid { - gap: 20px !important; - } - .export-week { - font-size: 32px !important; - opacity: 0.6; - } - :global(.export-text-days) { - font-size: 50px !important; - line-height: 1 !important; - left: 15px !important; - top: 15px !important; - } - :global(.export-text-taps) { - font-size: 70px !important; - line-height: 1 !important; - font-weight: 700 !important; - right: 20px !important; - bottom: 20px !important; - } - .export-footer { - text-align: center; - opacity: 0.5; - margin-top: 30px; - } + .export-container { position: fixed; top: 0; left: 0; width: 1440px; height: 1440px; display: flex; flex-direction: column; justify-content: space-between; padding: 80px 100px; z-index: -100; } + .export-header { font-size: 100px !important; font-weight: 800; text-align: center; margin-bottom: 20px; } + .export-grid { gap: 20px !important; } + .export-week { font-size: 32px !important; opacity: 0.6; } + :global(.export-text-days) { font-size: 50px !important; line-height: 1 !important; left: 15px !important; top: 15px !important; } + :global(.export-text-taps) { font-size: 70px !important; line-height: 1 !important; font-weight: 700 !important; right: 20px !important; bottom: 20px !important; } + .export-footer { text-align: center; opacity: 0.5; margin-top: 30px; } .export-site-name { font-size: 38px; font-weight: 700; } .export-site-url { font-size: 24px; } `}</style>