commit 45411bdb0d4619ee2d79acc62bd36573ac52467c
parent e40f3a31a2051305ff5f6f201f865d2dbf75b0e1
Author: Sunny <122193933+Sunny-JP@users.noreply.github.com>
Date: Thu, 29 Jan 2026 00:39:03 +0900
Merge pull request #33 from Sunny-JP/v4-sunny-dev
update api
Diffstat:
7 files changed, 426 insertions(+), 417 deletions(-)
diff --git a/src/app/api/tap/route.ts b/src/app/api/tap/route.ts
@@ -29,67 +29,55 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
- const { data: profile } = await supabase
- .from('profiles')
- .select('tap_history')
- .eq('id', user.id)
- .single();
-
+// --- 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);
- 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 (ticket1Time !== undefined) upsertData.ticket1_time = ticket1Time ? new Date(ticket1Time).toISOString() : null;
if (ticket2Time !== undefined) upsertData.ticket2_time = ticket2Time ? new Date(ticket2Time).toISOString() : null;
- const { error: upsertError } = await supabase
- .from('profiles')
- .upsert(upsertData);
+ await supabase.from('profiles').upsert(upsertData);
- if (upsertError) {
- console.error("DB Upsert Error:", upsertError);
- throw new Error(upsertError.message);
- }
+ // --- 通知予約処理 ---
+ if (tapTime && shouldScheduleNotification(new Date(tapTime))) {
+ const sendAfter = new Date(tapTime);
+ sendAfter.setHours(sendAfter.getHours() + 3);
- if (tapTime) {
- if (shouldScheduleNotification(new Date(tapTime))) {
- const sendAfter = new Date(tapTime);
- sendAfter.setHours(sendAfter.getHours() + 3); // Production
- // sendAfter.setSeconds(sendAfter.getSeconds() + 180); // Testing
-
- const randomMsg = messages
- ? messages[Math.floor(Math.random() * messages.length)]
- : { title: "Cafe Timer", body: "カフェ業務の時間です" };
-
- const notificationPayload = {
- 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(),
- };
+ const randomMsg = messages
+ ? messages[Math.floor(Math.random() * messages.length)]
+ : { title: "Cafe Timer", body: "カフェ業務の時間です" };
+
+ 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", {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- "Authorization": `Basic ${process.env.ONESIGNAL_REST_API_KEY}`
- },
- body: JSON.stringify(notificationPayload)
- });
-
- if (!osRes.ok) {
- console.error("OneSignal Error:", await osRes.text());
- }
+ const osRes = 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)
+ });
+
+ if (!osRes.ok) {
+ const errorDetail = await osRes.text();
+ console.error("OneSignal API Error:", errorDetail);
}
}
diff --git a/src/components/CountdownDisplay.tsx b/src/components/CountdownDisplay.tsx
@@ -7,23 +7,23 @@ import { Lekton } from "next/font/google";
const LektonFont = Lekton({ weight: "700", subsets: ["latin"] });
interface CountdownDisplayProps {
- milliseconds: number;
+ milliseconds: number;
}
export default function CountdownDisplay({ milliseconds }: CountdownDisplayProps) {
- const { hours, minutes, seconds } = useMemo(() => {
- const totalSeconds = Math.max(0, Math.floor(milliseconds / 1000));
- const hours = String(Math.floor(totalSeconds / 3600)).padStart(2, '0');
- const minutes = String(Math.floor((totalSeconds % 3600) / 60)).padStart(2, '0');
- const seconds = String(totalSeconds % 60).padStart(2, '0');
- return { hours, minutes, seconds };
- }, [milliseconds]);
+ const { hours, minutes, seconds } = useMemo(() => {
+ const totalSeconds = Math.max(0, Math.floor(milliseconds / 1000));
+ const hours = String(Math.floor(totalSeconds / 3600)).padStart(2, '0');
+ const minutes = String(Math.floor((totalSeconds % 3600) / 60)).padStart(2, '0');
+ const seconds = String(totalSeconds % 60).padStart(2, '0');
+ return { hours, minutes, seconds };
+ }, [milliseconds]);
- return (
- <div className={`countdown-text ${LektonFont.className}`}>
- <span>{hours}</span>:
- <span>{minutes}</span>:
- <span>{seconds}</span>
- </div>
- );
+ return (
+ <div className={`countdown-text ${LektonFont.className}`}>
+ <span>{hours}</span>:
+ <span>{minutes}</span>:
+ <span>{seconds}</span>
+ </div>
+ );
}
\ No newline at end of file
diff --git a/src/components/Header.tsx b/src/components/Header.tsx
@@ -3,28 +3,28 @@
import ThemeToggleButton from './ThemeToggleButton';
interface HeaderProps {
- onMenuClick?: () => void;
- isLoggedIn?: boolean;
+ onMenuClick?: () => void;
+ isLoggedIn?: boolean;
}
export default function Header({ onMenuClick, isLoggedIn = false }: HeaderProps) {
- return (
- <header className="fixed top-0 left-0 right-0 z-20 flex items-center h-16 justify-between px-6 shadow-sm border-b">
- <h1>Cafe Timer</h1>
- <div className="flex items-center gap-4">
- <ThemeToggleButton />
- {isLoggedIn && (
- <button
- onClick={onMenuClick}
- className="p-2 rounded-full"
- aria-label="メニューを開く"
- >
- <svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
- </svg>
- </button>
- )}
- </div>
- </header>
- );
+ return (
+ <header className="fixed top-0 left-0 right-0 z-20 flex items-center h-16 justify-between px-6 shadow-sm border-b">
+ <h1>Cafe Timer</h1>
+ <div className="flex items-center gap-4">
+ <ThemeToggleButton />
+ {isLoggedIn && (
+ <button
+ onClick={onMenuClick}
+ className="p-2 rounded-full"
+ aria-label="メニューを開く"
+ >
+ <svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
+ </svg>
+ </button>
+ )}
+ </div>
+ </header>
+ );
}
\ No newline at end of file
diff --git a/src/components/OneSignalInit.tsx b/src/components/OneSignalInit.tsx
@@ -1,28 +1,30 @@
"use client";
import { useEffect } from 'react';
import OneSignal from 'react-onesignal';
-
-let isInitialized = false;
+import { supabase } from "@/hooks/useAuth";
export default function OneSignalInit() {
useEffect(() => {
- if (isInitialized) return;
-
- isInitialized = true;
-
const initOneSignal = async () => {
try {
await OneSignal.init({
appId: process.env.NEXT_PUBLIC_ONESIGNAL_APP_ID!,
allowLocalhostAsSecureOrigin: true,
+ // サービスワーカーのパスをルートに固定して認識を安定させる
serviceWorkerPath: 'OneSignalSDKWorker.js',
-
welcomeNotification: {
title: "Cafe Timer",
- message: "先生、通知設定が完了しました!これでお仕事の時間をお知らせします。",
+ message: "先生、通知設定が完了しました!",
},
});
- console.log("OneSignal Initialized");
+
+ // 初期化直後にログイン状態を確認
+ const { data: { user } } = await supabase.auth.getUser();
+ if (user) {
+ // OneSignalのExternal IDとしてSupabaseのUser IDをセット
+ // これによりDBのIDと通知先が強固に紐付く
+ await OneSignal.login(user.id);
+ }
} catch (error) {
console.error("OneSignal init error", error);
}
diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx
@@ -1,160 +1,179 @@
"use client";
-import { useState, useEffect } from 'react';
+import { useState } from 'react';
import Link from 'next/link';
import { useAuth, supabase } from "@/hooks/useAuth";
import OneSignal from 'react-onesignal';
const LogoutIcon = ({ className = 'h-5 w-5 mr-2' }: { className?: string }) => (
- <svg xmlns="http://www.w3.org/2000/svg" className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
- </svg>
+ <svg xmlns="http://www.w3.org/2000/svg" className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+ <path strokeLinecap="round" strokeLinejoin="round" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
+ </svg>
);
const MenuItemIcon = () => (
- <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 mr-3 text-gray-500" fill="none" viewBox="0 1 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
- </svg>
+ <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 mr-3 text-gray-500" fill="none" viewBox="0 1 24 24" stroke="currentColor" strokeWidth={2}>
+ <path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
+ </svg>
);
const TrashIcon = ({ className = 'h-5 w-5 mr-2' }: { className?: string }) => (
- <svg xmlns="http://www.w3.org/2000/svg" className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6M9 7V5a2 2 0 012-2h2a2 2 0 012 2v2M7 7h10" />
- </svg>
+ <svg xmlns="http://www.w3.org/2000/svg" className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+ <path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6M9 7V5a2 2 0 012-2h2a2 2 0 012 2v2M7 7h10" />
+ </svg>
);
const BellIcon = ({ className = 'h-5 w-5 mr-2' }: { className?: string }) => (
- <svg xmlns="http://www.w3.org/2000/svg" className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
- </svg>
+ <svg xmlns="http://www.w3.org/2000/svg" className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+ <path strokeLinecap="round" strokeLinejoin="round" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
+ </svg>
);
const Settings = () => {
- const { isLoggedIn, logout, avatarUrl, displayName } = useAuth();
- const [isDeleting, setIsDeleting] = useState(false);
-
- const menuItems = [
- { label: 'About', path: '/about' },
- { label: '使い方ガイド', path: '/guide' },
- { label: '利用規約', path: '/terms' },
- { label: 'プライバシーポリシー', path: '/privacy' },
- { label: '運営者情報', path: '/operator' },
- ];
-
- const handleNotificationClick = async () => {
- try {
- if (!OneSignal.User) {
- alert("通知システムが読み込まれていません。\n広告ブロッカーをOFFにしてリロードしてください。");
- return;
- }
-
- if (Notification.permission === 'denied') {
- alert("通知がブロックされています。ブラウザの設定で許可してください。");
- return;
- }
-
- const isOptedIn = OneSignal.User.PushSubscription.optedIn;
-
- if (isOptedIn) {
- await OneSignal.User.PushSubscription.optOut();
- alert("通知をOFFにしました。");
- } else {
- await OneSignal.Notifications.requestPermission();
- await OneSignal.User.PushSubscription.optIn();
- alert("通知をONにしました!");
- }
-
- const currentSubscriptionId = OneSignal.User.PushSubscription.id;
-
- await fetch('/api/tap', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token}`
- },
- body: JSON.stringify({
- onesignalId: currentSubscriptionId
- })
- });
-
- } catch (e: any) {
- console.error("Notification Setup Error:", e);
- alert(`設定エラーが発生しました:\n${e.message || e}`);
+ const { isLoggedIn, logout, avatarUrl, displayName } = useAuth();
+ const [isDeleting, setIsDeleting] = useState(false);
+ const [isPushLoading, setIsPushLoading] = useState(false);
+
+ const menuItems = [
+ { label: 'About', path: '/about' },
+ { label: '使い方ガイド', path: '/guide' },
+ { label: '利用規約', path: '/terms' },
+ { label: 'プライバシーポリシー', path: '/privacy' },
+ { label: '運営者情報', path: '/operator' },
+ ];
+
+ const handleNotificationClick = async () => {
+ if (isPushLoading) return;
+ setIsPushLoading(true);
+
+ try {
+ if (!OneSignal.User) {
+ throw new Error("通知システムが未ロードです。広告ブロックを確認してください。");
}
- };
- const handleLogoutClick = async () => {
- if (window.confirm("ログアウトしますか?")) {
- await logout();
- window.location.href = '/';
+ const isAllowed = await OneSignal.Notifications.requestPermission();
+ if (!isAllowed) {
+ alert("通知がブロックされています。ブラウザの設定で許可してください。");
+ return;
}
- };
-
- const handleDeleteData = async () => {
- if (!window.confirm("本当に全データを削除しますか?")) return;
- setIsDeleting(true);
- try {
- const { data: { user } } = await supabase.auth.getUser();
- if (user) {
- await supabase.from('profiles').delete().eq('id', user.id);
- await logout();
- alert("データを削除しました。");
- window.location.href = "/";
- }
- } catch (err: any) {
- console.error(err);
- alert("削除に失敗しました: " + err.message);
- } finally {
- setIsDeleting(false);
+
+ 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にしました!");
}
- };
-
- return (
- <div className="p-4">
- <div className="space-y-4">
- <ul className="space-y-1">
- {menuItems.map((item) => (
- <li key={item.label}>
- <Link href={item.path} className="btn-setting flex items-center p-2 rounded transition-colors">
- <MenuItemIcon />
- <span>{item.label}</span>
- </Link>
- </li>
- ))}
- </ul>
-
- <div className="mt-8 border-t pt-4">
- {isLoggedIn && (
- <>
- <div className="flex items-center gap-4 mb-4 p-2 rounded-lg">
- {avatarUrl ? (
- <img src={avatarUrl} alt="avatar" className="w-10 h-10 rounded-full"/>
- ) : (
- <div className="w-10 h-10 rounded-full flex items-center justify-center">
- <span className="text-xl">?</span>
- </div>
- )}
- <div className="flex flex-col">
- <span className="font-semibold">{displayName}</span>
- </div>
- </div>
-
- <div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
- <button onClick={handleNotificationClick} className="btn-setting flex items-center justify-center p-2 rounded transition-opacity active:opacity-70">
- <BellIcon />
- <span>通知設定</span>
- </button>
- <button onClick={handleDeleteData} disabled={isDeleting} className="btn-setting flex items-center justify-center p-2 rounded transition-opacity active:opacity-70">
- {isDeleting ? (<><TrashIcon className="mr-2 opacity-50" /><span>削除中…</span></>) : (<><TrashIcon /><span>データ削除</span></>)}
- </button>
- <button onClick={handleLogoutClick} className="btn-setting flex items-center justify-center p-2 rounded transition-opacity active:opacity-70">
- <LogoutIcon />
- <span>ログアウト</span>
- </button>
- </div>
- </>
- )}
+
+ const currentSubscriptionId = OneSignal.User.PushSubscription.id;
+
+ await fetch('/api/tap', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${session.access_token}`
+ },
+ body: JSON.stringify({ onesignalId: currentSubscriptionId })
+ });
+
+ } catch (e: any) {
+ console.error("Notification Setup Error:", e);
+ alert(e.message);
+ } finally {
+ setIsPushLoading(false);
+ }
+ };
+
+ const handleLogoutClick = async () => {
+ if (window.confirm("ログアウトしますか?")) {
+ await logout();
+ window.location.href = '/';
+ }
+ };
+
+ const handleDeleteData = async () => {
+ if (!window.confirm("本当に全データを削除しますか?")) return;
+ setIsDeleting(true);
+ try {
+ const { data: { user } } = await supabase.auth.getUser();
+ if (user) {
+ await supabase.from('profiles').delete().eq('id', user.id);
+ await logout();
+ alert("データを削除しました。");
+ window.location.href = "/";
+ }
+ } catch (err: any) {
+ console.error(err);
+ alert("削除に失敗しました: " + err.message);
+ } finally {
+ setIsDeleting(false);
+ }
+ };
+
+ return (
+ <div className="p-4">
+ <div className="space-y-4">
+ <ul className="space-y-1">
+ {menuItems.map((item) => (
+ <li key={item.label}>
+ <Link href={item.path} className="btn-setting flex items-center p-2 rounded transition-colors">
+ <MenuItemIcon />
+ <span>{item.label}</span>
+ </Link>
+ </li>
+ ))}
+ </ul>
+
+ <div className="mt-8 border-t pt-4">
+ {isLoggedIn && (
+ <>
+ <div className="flex items-center gap-4 mb-4 p-2 rounded-lg">
+ {avatarUrl ? (
+ <img src={avatarUrl} alt="avatar" className="w-10 h-10 rounded-full"/>
+ ) : (
+ <div className="w-10 h-10 rounded-full flex items-center justify-center bg-gray-200">
+ <span className="text-xl">?</span>
+ </div>
+ )}
+ <div className="flex flex-col">
+ <span className="font-semibold">{displayName}</span>
</div>
- </div>
+ </div>
+
+ <div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
+ <button
+ onClick={handleNotificationClick}
+ disabled={isPushLoading}
+ className="btn-setting flex items-center justify-center p-2 rounded transition-opacity active:opacity-70 disabled:opacity-50"
+ >
+ <BellIcon />
+ <span>{isPushLoading ? '設定中...' : '通知設定'}</span>
+ </button>
+ <button
+ onClick={handleDeleteData}
+ disabled={isDeleting}
+ className="btn-setting flex items-center justify-center p-2 rounded transition-opacity active:opacity-70"
+ >
+ {isDeleting ? (<><TrashIcon className="mr-2 opacity-50" /><span>削除中…</span></>) : (<><TrashIcon /><span>データ削除</span></>)}
+ </button>
+ <button
+ onClick={handleLogoutClick}
+ className="btn-setting flex items-center justify-center p-2 rounded transition-opacity active:opacity-70"
+ >
+ <LogoutIcon />
+ <span>ログアウト</span>
+ </button>
+ </div>
+ </>
+ )}
</div>
- );
+ </div>
+ </div>
+ );
};
export default Settings;
\ No newline at end of file
diff --git a/src/components/ThemeToggleButton.tsx b/src/components/ThemeToggleButton.tsx
@@ -5,28 +5,28 @@ export const runtime = "edge";
import { useTheme } from '@/hooks/useTheme';
const SunIcon = () => (
- <svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
- </svg>
+ <svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+ <path strokeLinecap="round" strokeLinejoin="round" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
+ </svg>
);
const MoonIcon = () => (
- <svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
- </svg>
+ <svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+ <path strokeLinecap="round" strokeLinejoin="round" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
+ </svg>
);
export default function ThemeToggleButton() {
- const { theme, setTheme } = useTheme();
+ const { theme, setTheme } = useTheme();
- const toggleTheme = () => {
- setTheme(theme === 'light' ? 'dark' : 'light');
- };
+ const toggleTheme = () => {
+ setTheme(theme === 'light' ? 'dark' : 'light');
+ };
- return (
- <button onClick={toggleTheme} className="p-2 rounded-md hover:bg-secondary">
- {theme === 'light' ? <MoonIcon /> : <SunIcon />}
- </button>
- );
+ return (
+ <button onClick={toggleTheme} className="p-2 rounded-md hover:bg-secondary">
+ {theme === 'light' ? <MoonIcon /> : <SunIcon />}
+ </button>
+ );
}
\ No newline at end of file
diff --git a/src/components/TimerDashboard.tsx b/src/components/TimerDashboard.tsx
@@ -9,188 +9,188 @@ import { getSessionEndTime } from '@/lib/timeUtils';
const JST_TZ = 'Asia/Tokyo';
interface TimerDashboardProps {
- tapHistory: number[];
- lastTapTime: Date | null;
- ticket1Time: Date | null;
- ticket2Time: Date | null;
- onTap: () => void;
- onInvite: (ticketNumber: 1 | 2) => void;
- isSyncing: boolean;
- isDataLoaded: boolean;
+ tapHistory: number[];
+ lastTapTime: Date | null;
+ ticket1Time: Date | null;
+ ticket2Time: Date | null;
+ onTap: () => void;
+ onInvite: (ticketNumber: 1 | 2) => void;
+ isSyncing: boolean;
+ isDataLoaded: boolean;
}
export default function TimerDashboard({
- tapHistory,
- lastTapTime,
- ticket1Time,
- ticket2Time,
- onTap,
- onInvite,
- isSyncing,
- isDataLoaded,
+ tapHistory,
+ lastTapTime,
+ ticket1Time,
+ ticket2Time,
+ onTap,
+ onInvite,
+ isSyncing,
+ isDataLoaded,
}: TimerDashboardProps) {
- const [now, setNow] = useState(new Date());
-
- const userTimeZone = useMemo(() => {
- if (typeof window !== 'undefined') {
- try {
- return Intl.DateTimeFormat().resolvedOptions().timeZone;
- } catch (e) {
- return JST_TZ;
- }
- }
+ const [now, setNow] = useState(new Date());
+
+ const userTimeZone = useMemo(() => {
+ if (typeof window !== 'undefined') {
+ try {
+ return Intl.DateTimeFormat().resolvedOptions().timeZone;
+ } catch (e) {
return JST_TZ;
- }, []);
-
- const isJst = userTimeZone === JST_TZ;
-
- useEffect(() => {
- setNow(new Date());
- const timer = setInterval(() => setNow(new Date()), 1000);
- return () => clearInterval(timer);
- }, []);
-
- const cafeTapRemaining = useMemo(() => {
- if (!lastTapTime) return 0;
- const endTime = getSessionEndTime(lastTapTime);
- if (!endTime) return 0;
- return Math.max(0, differenceInMilliseconds(endTime, now));
- }, [now, lastTapTime]);
-
- const canTapCafe = useMemo(() => {
- if (!isDataLoaded) return false;
- if (!lastTapTime) return true;
- return cafeTapRemaining <= 0;
- }, [isDataLoaded, lastTapTime, cafeTapRemaining]);
-
- const ticket1Remaining = useMemo(() => {
- if (!ticket1Time) return 0;
- return differenceInMilliseconds(addHours(ticket1Time, 20), now);
- }, [now, ticket1Time]);
- const ticket2Remaining = useMemo(() => {
- if (!ticket2Time) return 0;
- return differenceInMilliseconds(addHours(ticket2Time, 20), now);
- }, [now, ticket2Time]);
-
- const dailySlots = useMemo(() => {
- let cycleStart = new Date(now);
- const currentHourJst = parseInt(formatInTimeZone(now, JST_TZ, 'H'), 10);
- if (currentHourJst < 4) {
- cycleStart = addHours(cycleStart, -24);
- }
- const cycleStartStr = formatInTimeZone(cycleStart, JST_TZ, "yyyy-MM-dd'T'04:00:00");
- cycleStart = new Date(cycleStartStr);
-
- return Array.from({ length: 8 }, (_, i) => {
- const start = addHours(cycleStart, i * 3);
- const end = addHours(start, 3);
-
- const timeJst = formatInTimeZone(start, JST_TZ, 'HH:mm');
- const timeLocal = formatInTimeZone(start, userTimeZone, 'HH:mm');
-
- let mainLabel = timeJst;
- let subLabel = null;
-
- if (!isJst) {
- mainLabel = timeLocal;
- subLabel = `${timeJst} JST`;
- }
- const tapEntry = (tapHistory || []).find((t) => {
- return t >= start.getTime() && t < end.getTime();
- });
- const tapTimeLocal = tapEntry ? formatInTimeZone(tapEntry, userTimeZone, 'HH:mm') : null;
- const tapTimeJst = tapEntry ? formatInTimeZone(tapEntry, JST_TZ, 'HH:mm') : null;
- return {
- mainLabel,
- subLabel,
- tapTimeLocal,
- tapTimeJst,
- isCurrent: now >= start && now < end,
- hasTapped: !!tapEntry
- };
- });
- }, [tapHistory, now, userTimeZone, isJst]);
-
- return (
- <div className="dashboard-container">
- <div className="timer-card">
- <div className="flex items-center justify-between mb-4">
- <h2 className="timer-card-title">Next Tap</h2>
- </div>
+ }
+ }
+ return JST_TZ;
+ }, []);
+
+ const isJst = userTimeZone === JST_TZ;
+
+ useEffect(() => {
+ setNow(new Date());
+ const timer = setInterval(() => setNow(new Date()), 1000);
+ return () => clearInterval(timer);
+ }, []);
+
+ const cafeTapRemaining = useMemo(() => {
+ if (!lastTapTime) return 0;
+ const endTime = getSessionEndTime(lastTapTime);
+ if (!endTime) return 0;
+ return Math.max(0, differenceInMilliseconds(endTime, now));
+ }, [now, lastTapTime]);
+
+ const canTapCafe = useMemo(() => {
+ if (!isDataLoaded) return false;
+ if (!lastTapTime) return true;
+ return cafeTapRemaining <= 0;
+ }, [isDataLoaded, lastTapTime, cafeTapRemaining]);
+
+ const ticket1Remaining = useMemo(() => {
+ if (!ticket1Time) return 0;
+ return differenceInMilliseconds(addHours(ticket1Time, 20), now);
+ }, [now, ticket1Time]);
+ const ticket2Remaining = useMemo(() => {
+ if (!ticket2Time) return 0;
+ return differenceInMilliseconds(addHours(ticket2Time, 20), now);
+ }, [now, ticket2Time]);
+
+ const dailySlots = useMemo(() => {
+ let cycleStart = new Date(now);
+ const currentHourJst = parseInt(formatInTimeZone(now, JST_TZ, 'H'), 10);
+ if (currentHourJst < 4) {
+ cycleStart = addHours(cycleStart, -24);
+ }
+ const cycleStartStr = formatInTimeZone(cycleStart, JST_TZ, "yyyy-MM-dd'T'04:00:00");
+ cycleStart = new Date(cycleStartStr);
+
+ return Array.from({ length: 8 }, (_, i) => {
+ const start = addHours(cycleStart, i * 3);
+ const end = addHours(start, 3);
+
+ const timeJst = formatInTimeZone(start, JST_TZ, 'HH:mm');
+ const timeLocal = formatInTimeZone(start, userTimeZone, 'HH:mm');
+
+ let mainLabel = timeJst;
+ let subLabel = null;
+
+ if (!isJst) {
+ mainLabel = timeLocal;
+ subLabel = `${timeJst} JST`;
+ }
+ const tapEntry = (tapHistory || []).find((t) => {
+ return t >= start.getTime() && t < end.getTime();
+ });
+ const tapTimeLocal = tapEntry ? formatInTimeZone(tapEntry, userTimeZone, 'HH:mm') : null;
+ const tapTimeJst = tapEntry ? formatInTimeZone(tapEntry, JST_TZ, 'HH:mm') : null;
+ return {
+ mainLabel,
+ subLabel,
+ tapTimeLocal,
+ tapTimeJst,
+ isCurrent: now >= start && now < end,
+ hasTapped: !!tapEntry
+ };
+ });
+ }, [tapHistory, now, userTimeZone, isJst]);
+
+ return (
+ <div className="dashboard-container">
+ <div className="timer-card">
+ <div className="flex items-center justify-between mb-4">
+ <h2 className="timer-card-title">Next Tap</h2>
+ </div>
- <div className="countdown-text-l mb-6">
- <CountdownDisplay milliseconds={cafeTapRemaining} />
- </div>
+ <div className="countdown-text-l mb-6">
+ <CountdownDisplay milliseconds={cafeTapRemaining} />
+ </div>
- <div className="slot-grid">
- {dailySlots.map((slot, i) => (
- <div
- key={i}
- className={`slot-item ${
- slot.hasTapped
- ? 'slot-tapped'
- : slot.isCurrent
- ? 'slot-current'
- : 'slot-default'
- }`}
- >
- {slot.hasTapped ? (
- <div className="flex flex-col items-center leading-tight">
- <span className="slot-text-main">{slot.tapTimeLocal}</span>
- {!isJst && (
- <span className="slot-text-sub whitespace-nowrap">
- ({slot.tapTimeJst} JST)
- </span>
- )}
- </div>
- ) : (
- <div className="flex flex-col items-center leading-tight">
- <span className="slot-text-main">{slot.mainLabel}</span>
- {slot.subLabel && (
- <span className="slot-text-sub whitespace-nowrap">
- ({slot.subLabel})
- </span>
- )}
- </div>
- )}
- </div>
- ))}
+ <div className="slot-grid">
+ {dailySlots.map((slot, i) => (
+ <div
+ key={i}
+ className={`slot-item ${
+ slot.hasTapped
+ ? 'slot-tapped'
+ : slot.isCurrent
+ ? 'slot-current'
+ : 'slot-default'
+ }`}
+ >
+ {slot.hasTapped ? (
+ <div className="flex flex-col items-center leading-tight">
+ <span className="slot-text-main">{slot.tapTimeLocal}</span>
+ {!isJst && (
+ <span className="slot-text-sub whitespace-nowrap">
+ ({slot.tapTimeJst} JST)
+ </span>
+ )}
</div>
-
- <button
- onClick={() => onTap()}
- disabled={!canTapCafe || isSyncing}
- className="btn-timer btn-timer-tap"
- >
- { !isDataLoaded ? 'Wait...' : isSyncing ? 'Wait...' : 'Tap' }
- </button>
+ ) : (
+ <div className="flex flex-col items-center leading-tight">
+ <span className="slot-text-main">{slot.mainLabel}</span>
+ {slot.subLabel && (
+ <span className="slot-text-sub whitespace-nowrap">
+ ({slot.subLabel})
+ </span>
+ )}
+ </div>
+ )}
</div>
+ ))}
+ </div>
- <div className="timer-card">
- <h2 className="timer-card-title mb-4">Next Call</h2>
- <div className="grid grid-cols-2 gap-4">
- <div className="flex flex-col gap-2">
- <div className="countdown-text-s bg-background/50 p-2 rounded">
- <CountdownDisplay milliseconds={ticket1Remaining} />
- </div>
- <button
- onClick={() => onInvite(1)}
- disabled={!isDataLoaded || ticket1Remaining > 0 || isSyncing}
- className="btn-timer btn-timer-tap"
- >{ isSyncing ? 'Wait...' : 'Ticket 1' }</button>
- </div>
- <div className="flex flex-col gap-2">
- <div className="countdown-text-s bg-background/50 p-2 rounded">
- <CountdownDisplay milliseconds={ticket2Remaining} />
- </div>
- <button
- onClick={() => onInvite(2)}
- disabled={!isDataLoaded || ticket2Remaining > 0 || isSyncing}
- className="btn-timer btn-timer-tap"
- >{ isSyncing ? 'Wait...' : 'Ticket 2' }</button>
- </div>
- </div>
+ <button
+ onClick={() => onTap()}
+ disabled={!canTapCafe || isSyncing}
+ className="btn-timer btn-timer-tap"
+ >
+ { !isDataLoaded ? 'Wait...' : isSyncing ? 'Wait...' : 'Tap' }
+ </button>
+ </div>
+
+ <div className="timer-card">
+ <h2 className="timer-card-title mb-4">Next Call</h2>
+ <div className="grid grid-cols-2 gap-4">
+ <div className="flex flex-col gap-2">
+ <div className="countdown-text-s bg-background/50 p-2 rounded">
+ <CountdownDisplay milliseconds={ticket1Remaining} />
+ </div>
+ <button
+ onClick={() => onInvite(1)}
+ disabled={!isDataLoaded || ticket1Remaining > 0 || isSyncing}
+ className="btn-timer btn-timer-tap"
+ >{ isSyncing ? 'Wait...' : 'Ticket 1' }</button>
+ </div>
+ <div className="flex flex-col gap-2">
+ <div className="countdown-text-s bg-background/50 p-2 rounded">
+ <CountdownDisplay milliseconds={ticket2Remaining} />
</div>
+ <button
+ onClick={() => onInvite(2)}
+ disabled={!isDataLoaded || ticket2Remaining > 0 || isSyncing}
+ className="btn-timer btn-timer-tap"
+ >{ isSyncing ? 'Wait...' : 'Ticket 2' }</button>
+ </div>
</div>
- );
+ </div>
+ </div>
+ );
}
\ No newline at end of file