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