ba-cafe

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

commit 1e006a284f1dcde4062d1b1b50a3a47893ac8eec
parent 45411bdb0d4619ee2d79acc62bd36573ac52467c
Author: Sunny <122193933+Sunny-JP@users.noreply.github.com>
Date:   Thu, 29 Jan 2026 02:26:44 +0900

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

change to timestamp from unix
Diffstat:
Msrc/app/api/tap/route.ts | 81++++++++++++++++++++++++++++++++++---------------------------------------------
Msrc/app/page.tsx | 96++++++++++++++++++++++++++++++++++++-------------------------------------------
Msrc/components/Settings.tsx | 49+++++++++++++------------------------------------
Msrc/components/TimerDashboard.tsx | 5++++-
Msrc/lib/timeUtils.ts | 57+++++++++++++++++++++++++++++----------------------------
5 files changed, 125 insertions(+), 163 deletions(-)

diff --git a/src/app/api/tap/route.ts b/src/app/api/tap/route.ts @@ -8,81 +8,70 @@ export const runtime = 'edge'; export async function POST(request: Request) { try { const body = await request.json(); - const { tapTime, onesignalId, ticket1Time, ticket2Time } = body; + const { tapTime, isPushEnabled, ticket1Time, ticket2Time } = body; const authHeader = request.headers.get('Authorization'); + const supabase = createClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, - { - global: { - headers: { - Authorization: authHeader ?? '', - }, - }, - } + { global: { headers: { Authorization: authHeader ?? '' } } } ); if (!authHeader) return NextResponse.json({ error: 'No token' }, { status: 401 }); const { data: { user }, error: authError } = await supabase.auth.getUser(); - if (authError || !user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } + if (authError || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); -// --- DB更新処理 --- - const { data: profile } = await supabase.from('profiles').select('tap_history').eq('id', user.id).single(); - let newHistory = [...(profile?.tap_history || [])]; - if (tapTime) newHistory.push(tapTime); + // --- DB更新処理 --- + const upsertData: any = { + id: user.id, + updated_at: new Date().toISOString() + }; - const upsertData: any = { id: user.id, updated_at: new Date().toISOString() }; - if (tapTime) upsertData.tap_history = newHistory; - // onesignalIdが空で送られてきた場合に既存のIDを消さないように制御 - if (onesignalId) upsertData.onesignal_id = onesignalId; + if (isPushEnabled !== undefined) upsertData.is_push_enabled = isPushEnabled; - if (ticket1Time !== undefined) upsertData.ticket1_time = ticket1Time ? new Date(ticket1Time).toISOString() : null; - if (ticket2Time !== undefined) upsertData.ticket2_time = ticket2Time ? new Date(ticket2Time).toISOString() : null; + if (ticket1Time) upsertData.ticket1_time = new Date(ticket1Time).toISOString(); + if (ticket2Time) upsertData.ticket2_time = new Date(ticket2Time).toISOString(); + + if (tapTime) { + const { data: profile } = await supabase.from('profiles').select('tap_history').eq('id', user.id).single(); + const newHistory = [...(profile?.tap_history || [])]; + + const tapDate = new Date(tapTime); + tapDate.setMilliseconds(0); + newHistory.push(tapDate.toISOString()); + + upsertData.tap_history = newHistory; + } await supabase.from('profiles').upsert(upsertData); // --- 通知予約処理 --- if (tapTime && shouldScheduleNotification(new Date(tapTime))) { const sendAfter = new Date(tapTime); + sendAfter.setSeconds(0, 0); sendAfter.setHours(sendAfter.getHours() + 3); - const randomMsg = messages - ? messages[Math.floor(Math.random() * messages.length)] - : { title: "Cafe Timer", body: "カフェ業務の時間です" }; + const randomMsg = messages[Math.floor(Math.random() * messages.length)]; - const notificationPayload = { - app_id: process.env.NEXT_PUBLIC_ONESIGNAL_APP_ID, - // Subscription IDではなく External ID (user.id) で送る - // これにより、OneSignal側で複数のデバイスが紐付いていても全てに届く - include_aliases: { - external_id: [user.id] - }, - target_channel: "push", - contents: { en: randomMsg.body, ja: randomMsg.body }, - headings: { en: randomMsg.title, ja: randomMsg.title }, - send_after: sendAfter.toISOString(), - }; - - const osRes = await fetch("https://onesignal.com/api/v1/notifications", { + await fetch("https://onesignal.com/api/v1/notifications", { method: "POST", headers: { "Content-Type": "application/json", "Authorization": `Basic ${process.env.ONESIGNAL_REST_API_KEY}` }, - body: JSON.stringify(notificationPayload) + body: JSON.stringify({ + app_id: process.env.NEXT_PUBLIC_ONESIGNAL_APP_ID, + include_aliases: { external_id: [user.id] }, + target_channel: "push", + contents: { en: randomMsg.body, ja: randomMsg.body }, + headings: { en: randomMsg.title, ja: randomMsg.title }, + send_after: sendAfter.toISOString(), + }) }); - - if (!osRes.ok) { - const errorDetail = await osRes.text(); - console.error("OneSignal API Error:", errorDetail); - } } - return NextResponse.json({ success: true, history: newHistory }); - + return NextResponse.json({ success: true }); } catch (error: any) { console.error("API Error:", error); return NextResponse.json({ error: error.message }, { status: 500 }); diff --git a/src/app/page.tsx b/src/app/page.tsx @@ -4,7 +4,6 @@ import { useState, useEffect, useCallback } from "react"; import { useAuth, supabase } from "@/hooks/useAuth"; import OneSignal from 'react-onesignal'; import OneSignalInit from "@/components/OneSignalInit"; - import Header from "@/components/Header"; import TimerDashboard from "@/components/TimerDashboard"; import BottomNavBar from "@/components/BottomNavBar"; @@ -57,12 +56,10 @@ function LoginScreen() { export default function Home() { const { isLoggedIn, isLoading } = useAuth(); - const [activeTab, setActiveTab] = useState<Tab>('timer'); const [tapHistory, setTapHistory] = 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); @@ -72,28 +69,34 @@ export default function Home() { if (!user) return; try { - if (OneSignal.User) { + if (typeof window !== 'undefined' && OneSignal.Notifications) { await OneSignal.login(user.id); - } else { - console.log("OneSignal not ready yet"); } } catch (e) { - console.error("OneSignal login error", e); + console.warn("OneSignal login skipped:", e); } - + try { - const { data } = await supabase + const { data, error } = await supabase .from('profiles') .select('*') .eq('id', user.id) .single(); + + if (error) throw error; if (data) { - if (data.tap_history) setTapHistory(data.tap_history as number[]); + 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)); - } else { - console.log("No profile found, waiting for trigger..."); } } catch (e) { console.error("Load error", e); @@ -102,17 +105,13 @@ export default function Home() { } }, []); - const syncData = async (newTapTime?: number, t1?: number | null, t2?: number | null) => { + const syncData = async (tapISO?: string, t1ISO?: string | null, t2ISO?: string | null) => { if (!isLoggedIn) return; setIsSyncing(true); try { const { data: { session } } = await supabase.auth.getSession(); - const onesignalId = (OneSignal as any).User?.PushSubscription?.id; - - if (!onesignalId) { - console.log("Notification ID not ready yet."); - } + const isPushEnabled = OneSignal.Notifications.permission; await fetch('/api/tap', { method: 'POST', @@ -121,13 +120,12 @@ export default function Home() { 'Authorization': `Bearer ${session?.access_token}` }, body: JSON.stringify({ - tapTime: newTapTime, - onesignalId: onesignalId, - ticket1Time: t1, - ticket2Time: t2 + tapTime: tapISO, + isPushEnabled: isPushEnabled, + ticket1Time: t1ISO, + ticket2Time: t2ISO }) }); - console.log("Sync success"); } catch (error) { console.error("Sync failed", error); } finally { @@ -138,56 +136,50 @@ export default function Home() { const handleTap = async () => { if (!isLoggedIn || isSyncing) return; - const tapTime = new Date().getTime(); - setTapHistory([...tapHistory, tapTime]); - await syncData(tapTime, - ticket1Time?.getTime() || null, - ticket2Time?.getTime() || null + const now = new Date(); + now.setMilliseconds(0); + const newTapMs = now.getTime(); + + setTapHistory(prev => [...prev, newTapMs]); + + await syncData( + now.toISOString(), + ticket1Time?.toISOString() || null, + ticket2Time?.toISOString() || null ); }; const handleInvite = async (ticketNumber: 1 | 2) => { const now = new Date(); - let t1 = ticket1Time?.getTime() || null; - let t2 = ticket2Time?.getTime() || null; + now.setMilliseconds(0); + + let t1ISO = ticket1Time?.toISOString() || null; + let t2ISO = ticket2Time?.toISOString() || null; if (ticketNumber === 1) { - setTicket1Time(now); - t1 = now.getTime(); + setTicket1Time(now); + t1ISO = now.toISOString(); } else { - setTicket2Time(now); - t2 = now.getTime(); + setTicket2Time(now); + t2ISO = now.toISOString(); } - await syncData(undefined, t1, t2); + await syncData(undefined, t1ISO, t2ISO); }; useEffect(() => { - if (isLoggedIn && !isLoading) { - loadData(); - } else if (!isLoggedIn && !isLoading) { - setTapHistory([]); - setIsDataLoaded(true); - } + if (isLoggedIn && !isLoading) loadData(); }, [isLoggedIn, isLoading, loadData]); - const lastTap = tapHistory.length > 0 ? tapHistory[tapHistory.length - 1] : null; - const lastTapTime = lastTap ? new Date(lastTap) : null; - - if (isLoading) { - return <div className="flex justify-center items-center h-screen text-muted-foreground font-bold">Loading...</div>; - } + const lastTapTime = tapHistory.length > 0 ? new Date(tapHistory[tapHistory.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 /> <Header isLoggedIn={isLoggedIn} onMenuClick={() => setIsSidePanelOpen(true)} /> - <main className="pt-16 pb-16 min-[1000px]:pb-0 flex-1 flex flex-col"> - {!isLoggedIn ? ( - <LoginScreen /> - ) : ( + {!isLoggedIn ? <LoginScreen /> : ( <> - {/* Mobile View */} <div className="min-[1000px]:hidden flex-1"> {activeTab === 'timer' && ( <TimerDashboard diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx @@ -40,49 +40,26 @@ const Settings = () => { ]; const handleNotificationClick = async () => { - if (isPushLoading) return; setIsPushLoading(true); - - try { - if (!OneSignal.User) { - throw new Error("通知システムが未ロードです。広告ブロックを確認してください。"); - } - - const isAllowed = await OneSignal.Notifications.requestPermission(); - if (!isAllowed) { - alert("通知がブロックされています。ブラウザの設定で許可してください。"); - return; - } - - const { data: { session } } = await supabase.auth.getSession(); - if (!session?.user) throw new Error("セッションが見つかりません。"); - - // External IDの紐付け - await OneSignal.login(session.user.id); - - const isOptedIn = OneSignal.User.PushSubscription.optedIn; - if (isOptedIn) { - await OneSignal.User.PushSubscription.optOut(); - alert("通知をOFFにしました。"); - } else { - await OneSignal.User.PushSubscription.optIn(); - alert("通知をONにしました!"); - } - - const currentSubscriptionId = OneSignal.User.PushSubscription.id; - + try { + await OneSignal.Notifications.requestPermission(); + const isEnabled = OneSignal.Notifications.permission; + + const { data: { session } } = await supabase.auth.getSession(); + if (session) { await fetch('/api/tap', { method: 'POST', - headers: { + headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${session.access_token}` }, - body: JSON.stringify({ onesignalId: currentSubscriptionId }) + body: JSON.stringify({ isPushEnabled: isEnabled }), }); - - } catch (e: any) { - console.error("Notification Setup Error:", e); - alert(e.message); + } + alert(isEnabled ? "通知をオンにしました" : "通知はオフのままです"); + } catch (error) { + console.error(error); + alert("設定に失敗しました"); } finally { setIsPushLoading(false); } diff --git a/src/components/TimerDashboard.tsx b/src/components/TimerDashboard.tsx @@ -95,9 +95,12 @@ export default function TimerDashboard({ mainLabel = timeLocal; subLabel = `${timeJst} JST`; } - const tapEntry = (tapHistory || []).find((t) => { + const tapEntryStr = (tapHistory || []).find((tStr) => { + const t = new Date(tStr).getTime(); return t >= start.getTime() && t < end.getTime(); }); + + const tapEntry = tapEntryStr ? new Date(tapEntryStr).getTime() : null; const tapTimeLocal = tapEntry ? formatInTimeZone(tapEntry, userTimeZone, 'HH:mm') : null; const tapTimeJst = tapEntry ? formatInTimeZone(tapEntry, JST_TZ, 'HH:mm') : null; return { diff --git a/src/lib/timeUtils.ts b/src/lib/timeUtils.ts @@ -1,48 +1,49 @@ -import { addHours, isAfter } from 'date-fns'; +import { addHours, isAfter, startOfHour, setHours, addDays } from 'date-fns'; import { toZonedTime, fromZonedTime } from 'date-fns-tz'; const JST_TZ = 'Asia/Tokyo'; -export const getNextBoundary = (now: Date): Date => { - const jstNow = toZonedTime(now, JST_TZ); - const h = jstNow.getHours(); +// 境界線(4時/16時)を求める +export const getNextBoundary = (date: Date): Date => { + const jst = toZonedTime(date, JST_TZ); + const hour = jst.getHours(); - let boundaryJst = new Date(jstNow); - boundaryJst.setMinutes(0, 0, 0); - - if (h < 4) { - boundaryJst.setHours(4); - } else if (h < 16) { - boundaryJst.setHours(16); + let boundary = startOfHour(jst); + if (hour < 4) { + boundary = setHours(boundary, 4); + } else if (hour < 16) { + boundary = setHours(boundary, 16); } else { - boundaryJst.setDate(boundaryJst.getDate() + 1); - boundaryJst.setHours(4); + boundary = setHours(addDays(boundary, 1), 4); } - - return fromZonedTime(boundaryJst, JST_TZ); + return fromZonedTime(boundary, JST_TZ); }; +// UI表示用の終了時刻計算 (3時間後 or 境界線の早い方) export const getSessionEndTime = (lastTapTime: Date | null): Date | null => { if (!lastTapTime) return null; - const standardEnd = addHours(lastTapTime, 3); - const boundary = getNextBoundary(lastTapTime); + const baseTime = new Date(lastTapTime); + baseTime.setMilliseconds(0); - if (isAfter(standardEnd, boundary)) { - return boundary; - } - return standardEnd; + const standardEnd = addHours(baseTime, 3); + const boundary = getNextBoundary(baseTime); + + return isAfter(standardEnd, boundary) ? boundary : standardEnd; }; +// 通知を予約すべきか判定 export const shouldScheduleNotification = (tapTime: Date): boolean => { - const jstNow = toZonedTime(tapTime, JST_TZ); - const h = jstNow.getHours(); + const jst = toZonedTime(tapTime, JST_TZ); + const h = jst.getHours(); + + if ((h >= 1 && h < 4) || (h >= 13 && h < 16)) return false; - if ((h >= 1 && h < 4) || (h >= 13 && h < 16)) { - return false; - } + const endTime = getSessionEndTime(tapTime); + if (!endTime) return false; const standardEnd = addHours(tapTime, 3); - const boundary = getNextBoundary(tapTime); - return !isAfter(standardEnd, boundary); + standardEnd.setMilliseconds(0); + + return endTime.getTime() === standardEnd.getTime(); }; \ No newline at end of file