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