commit 8beb48d2583e6d657aab8b9092efe51ba5342d4b
parent 4a2cfdb3bba70bef5b7c112cd43840e7c7f26eeb
Author: Sunny <122193933+Sunny-JP@users.noreply.github.com>
Date: Thu, 5 Feb 2026 13:14:40 +0900
Merge pull request #49 from Sunny-JP/v4-sunny-dev
V4 sunny dev
Diffstat:
14 files changed, 164 insertions(+), 100 deletions(-)
diff --git a/src/app/about/page.tsx b/src/app/about/page.tsx
@@ -27,7 +27,7 @@ export default function AboutPage() {
</div>
<div className="mt-8 pt-4">
- <Link href="/" className="btn-setting inline-block text-center w-full sm:w-auto">
+ <Link href="/" className="btn-setting inline-block text-center w-full">
ホームに戻る
</Link>
</div>
diff --git a/src/app/api/tap/route.ts b/src/app/api/tap/route.ts
@@ -19,10 +19,8 @@ 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');
-
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
@@ -34,7 +32,12 @@ 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. 重複タップ防止ロジック (tapsテーブルから最新レコードを確認) ---
+ // ミリ秒を切り捨てた基準時刻を作成
+ const now = new Date();
+ now.setMilliseconds(0);
+ const nowIso = now.toISOString();
+
+ // 1. カフェタップの処理
if (tapTime) {
const { data: lastTap } = await supabase
.from('taps')
@@ -44,36 +47,42 @@ export async function POST(request: Request) {
.limit(1)
.maybeSingle();
+ const currentTapDate = new Date(tapTime);
+ currentTapDate.setMilliseconds(0);
+
if (lastTap) {
const lastTapDate = new Date(lastTap.tap_time);
- const currentTapDate = new Date(tapTime);
const diffMs = currentTapDate.getTime() - lastTapDate.getTime();
- // 1時間以内の同一時間枠タップをチェック
if (diffMs < 3600000) {
if (shouldScheduleNotification(lastTapDate) === shouldScheduleNotification(currentTapDate)) {
- return NextResponse.json({ error: 'Duplicate tap within 1 hour' }, { status: 429 });
+ return NextResponse.json({ error: 'Duplicate tap' }, { status: 429 });
}
}
}
-
- // 重複でなければ新テーブルにインサート
- await supabase.from('taps').insert([{ user_id: user.id, tap_time: new Date(tapTime).toISOString() }]);
+ await supabase.from('taps').insert([{ user_id: user.id, tap_time: currentTapDate.toISOString() }]);
}
- // --- 2. profilesテーブルの更新 (チケット時間のみ) ---
+ // 2. プロフィールの更新
const upsertData: any = {
id: user.id,
- updated_at: new Date().toISOString()
+ updated_at: nowIso
};
- 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 !== undefined) {
+ const d1 = ticket1Time ? new Date(ticket1Time) : null;
+ if (d1) d1.setMilliseconds(0);
+ upsertData.ticket1_time = d1 ? d1.toISOString() : null;
+ }
+ if (ticket2Time !== undefined) {
+ const d2 = ticket2Time ? new Date(ticket2Time) : null;
+ if (d2) d2.setMilliseconds(0);
+ upsertData.ticket2_time = d2 ? d2.toISOString() : null;
+ }
- // tap_historyカラムへの書き込みは行わない(profilesを軽量に保つ)
await supabase.from('profiles').upsert(upsertData);
- // --- 3. 通知予約処理 ---
+ // 3. 通知予約処理
if (tapTime && shouldScheduleNotification(new Date(tapTime))) {
const sendAfter = new Date(tapTime);
sendAfter.setSeconds(0, 0);
diff --git a/src/app/faq/page.tsx b/src/app/faq/page.tsx
@@ -31,6 +31,13 @@ export default function FaqPage() {
A: テキストデータなどによるエクスポートは、今のところは対応していません。SNSなどへの共有は、月ごとの画像エクスポート機能をご使用ください。</p>
</div>
+ <div className="flex flex-col gap-1">
+ <h3 className="font-bold text-foreground">
+ Q: The history calendar doesn't match the dates I actually tapped.</h3>
+ <p className="font-normal text-sm">
+ A: The historical calendar considers the period from 4:00 AM to 3:59 AM the following day (Japan Standard Time) as one day. If you reside in a country using a time zone different from UTC+9, please understand this may not match the actual date you use.</p>
+ </div>
+
<h2 className="text-xl font-bold mb-3 text-foreground border-b border-muted pb-2">
🟨 システム
</h2>
@@ -92,7 +99,7 @@ export default function FaqPage() {
</div>
<div className="mt-8 pt-4">
- <Link href="/" className="btn-setting inline-block text-center w-full sm:w-auto">
+ <Link href="/" className="btn-setting inline-block text-center w-full">
ホームに戻る
</Link>
</div>
diff --git a/src/app/globals.css b/src/app/globals.css
@@ -103,6 +103,7 @@ body {
color: var(--secondary-foreground);
background-color: transparent;
transition: all 0.2s ease-in-out;
+ cursor: pointer;
}
.btn-setting:hover {
color: var(--secondary);
@@ -115,7 +116,7 @@ body {
align-items: center;
justify-content: center;
padding-top: 14px;
- padding-bottom: 16px;
+ padding-bottom: 18px;
color: var(--nav-foreground);
background-color: var(--background);
transition: all 0.2s ease-in-out;
@@ -145,6 +146,25 @@ body {
.dark .btn-nav:hover.active {
color: var(--foreground);
}
+.btn-nav-bar {
+ height: 2.5px;
+ width: 0px;
+ opacity: 0;
+ transition:
+ width 0.2s ease-out,
+ opacity 0.2s ease-out;
+ bottom: -8px;
+}
+.btn-nav-bar.active {
+ background-color: var(--primary);
+ width: 50px;
+ opacity: 1;
+}
+.dark .btn-nav-bar.active {
+ background-color: var(--foreground);
+ width: 50px;
+ opacity: 1;
+}
.dashboard-container {
container-type: inline-size;
display: flex;
@@ -247,6 +267,7 @@ body {
}
.slot-text-sub {
font-size: 2cqw;
+ margin-top: -0.7cqw;
}
.btn-timer {
@@ -313,12 +334,14 @@ body {
color: var(--secondary-foreground);
background-color: transparent;
transition: all 0.2s ease-in-out;
+ cursor: pointer;
}
.cal-save-nav {
font-size: 2.5cqw;
color: var(--secondary-foreground);
background-color: transparent;
transition: all 0.2s ease-in-out;
+ cursor: pointer;
}
.cal-nav:hover,
.cal-save-nav:hover {
diff --git a/src/app/guide/page.tsx b/src/app/guide/page.tsx
@@ -69,7 +69,7 @@ export default function GuidePage() {
</div>
<div className="mt-8 pt-4">
- <Link href="/" className="btn-setting inline-block text-center w-full sm:w-auto">
+ <Link href="/" className="btn-setting inline-block text-center w-full">
ホームに戻る
</Link>
</div>
diff --git a/src/app/operator/page.tsx b/src/app/operator/page.tsx
@@ -1,38 +1,62 @@
import Link from 'next/link';
export default function OperatorPage() {
+ const updateLogs = [
+ { date: '2026-02-05', content: 'Ver.1.0 リリース' },
+ ];
+
return (
<div className="p-6">
<div className="max-w-2xl mx-auto bg-card rounded-lg shadow-lg p-8 border border-muted">
- <h1 className="text-3xl font-bold mb-6 text-foreground">運営者情報</h1>
+ <h1 className="text-3xl font-bold mb-6 text-foreground">運営者情報・変更履歴</h1>
- <div className="space-y-4 text-muted-foreground">
- <div className="flex flex-col gap-2">
- <h3 className="font-bold text-foreground">運営代表・開発</h3>
- <p className="font-normal">
- ・さにー <a href="https://x.com/156miyako" target="_blank" rel="noopener noreferrer" className="hover:underline text-blue-400">@156miyako </a>
- <a href="https://rabbit1.cc" target="_blank" rel="noopener noreferrer" className="hover:underline text-blue-400">https://rabbit1.cc</a></p>
+ <div className="space-y-6 text-muted-foreground">
+ {/* 運営・開発情報 */}
+ <div className="grid grid-cols-1 gap-6">
+ <div className="flex flex-col gap-2">
+ <h3 className="font-bold text-foreground border-b border-muted pb-1">運営代表・開発</h3>
+ <p className="font-normal">
+ さにー X: <a href="https://x.com/156miyako" target="_blank" rel="noopener noreferrer" className="hover:underline text-blue-400">@156miyako</a>
+ HP: <a href="https://rabbit1.cc" target="_blank" rel="noopener noreferrer" className="hover:underline text-blue-400">https://rabbit1.cc</a>
+ </p>
+ </div>
+ <div className="flex flex-col gap-2">
+ <h3 className="font-bold text-foreground border-b border-muted pb-1">開発協力</h3>
+ <p className="font-normal">
+ ハチか X: <a href="https://x.com/bite_sour_sweet" target="_blank" rel="noopener noreferrer" className="hover:underline text-blue-400">@bite_sour_sweet</a>
+ </p>
+ </div>
</div>
- <div className="flex flex-col gap-2">
- <h3 className="font-bold text-foreground">開発協力</h3>
- <p className="font-normal">
- ・ハチか <a href="https://x.com/bite_sour_sweet" target="_blank" rel="noopener noreferrer" className="hover:underline text-blue-400">@bite_sour_sweet</a></p>
+ {/* 変更履歴セクション */}
+ <div className="flex flex-col gap-1 pb-2">
+ <h3 className="font-bold text-foreground border-b border-muted pb-1">変更履歴</h3>
+ <div className="bg-background/50 rounded-md p-1.5 max-h-48 overflow-y-auto space-y-2 text-sm">
+ {updateLogs.map((log, index) => (
+ <div key={index} className="flex gap-3 border-b border-muted/30 pb-1.5 last:border-0">
+ <span className="font-mono shrink-0">{log.date}</span>
+ <p className="text-foreground/80">{log.content}</p>
+ </div>
+ ))}
+ </div>
</div>
+ {/* 連絡先 */}
<div className="flex flex-col gap-2">
- <h3 className="font-bold text-foreground">連絡先</h3>
- <p className="font-normal">不具合報告やご要望は、
- <a href="https://github.com/Sunny-JP/hw-ba-cafe" target="_blank" rel="noopener noreferrer" className="hover:underline text-blue-400">
- GitHubリポジトリ
- </a> のissue または X (Twitter) までお願いいたします。<br />
- なお、個人開発のため全ての不具合やご要望に対応できない場合があります。ご了承ください。<br />
- 共同開発も受け付けています。GitHubリポジトリでPRしていただければ、採用されるかもしれません。バグ修正や機能改善など、積極的にご参加いただけるとありがたいです。</p>
+ <h3 className="font-bold text-foreground border-b border-muted pb-1">連絡先・フィードバック</h3>
+ <p className="text-sm leading-relaxed">
+ 不具合報告やご要望は、
+ <a href="https://github.com/Sunny-JP/hw-ba-cafe" target="_blank" rel="noopener noreferrer" className="mx-1 hover:underline text-blue-400 font-bold">
+ GitHub
+ </a>
+ または X (Twitter) までお願いいたします。<br />
+ 個人開発のため全ての対応は難しい場合がありますが、Pull Requestなど積極的に受け付けています。
+ </p>
</div>
</div>
<div className="mt-8 pt-4">
- <Link href="/" className="btn-setting inline-block text-center w-full sm:w-auto">
+ <Link href="/" className="btn-setting inline-block text-center w-full">
ホームに戻る
</Link>
</div>
diff --git a/src/app/privacy/page.tsx b/src/app/privacy/page.tsx
@@ -31,7 +31,7 @@ export default function PrivacyPage() {
</div>
<div className="mt-8 pt-4">
- <Link href="/" className="btn-setting inline-block text-center w-full sm:w-auto">
+ <Link href="/" className="btn-setting inline-block text-center w-full">
ホームに戻る
</Link>
</div>
diff --git a/src/app/terms/page.tsx b/src/app/terms/page.tsx
@@ -40,7 +40,7 @@ export default function TermsPage() {
</div>
<div className="mt-8 pt-4">
- <Link href="/" className="btn-setting inline-block text-center w-full sm:w-auto">
+ <Link href="/" className="btn-setting inline-block text-center w-full">
ホームに戻る
</Link>
</div>
diff --git a/src/components/BottomNavBar.tsx b/src/components/BottomNavBar.tsx
@@ -15,10 +15,7 @@ const BottomNavBar: React.FC<BottomNavBarProps> = ({ activeTab, setActiveTab })
];
return (
- <div
- className="fixed bottom-0 left-0 right-0 desktop-hidden"
- style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}
- >
+ <div className="fixed bottom-0 left-0 right-0 desktop-hidden">
{tabs.map((tab) => {
const Icon = tab.icon;
const isActive = activeTab === tab.id;
@@ -27,9 +24,12 @@ const BottomNavBar: React.FC<BottomNavBarProps> = ({ activeTab, setActiveTab })
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
- className={`btn-nav ${isActive ? 'active' : ''}`}
+ className={`btn-nav relative flex flex-col items-center justify-center ${isActive ? 'active' : ''}`}
>
- <Icon size={30} />
+ <div className="relative flex flex-col items-center">
+ <Icon size={30} />
+ <div className={`btn-nav-bar absolute rounded-full ${isActive ? 'active' : ''}`}/>
+ </div>
</button>
);
})}
diff --git a/src/components/Header.tsx b/src/components/Header.tsx
@@ -16,7 +16,7 @@ export default function Header({ onMenuClick, isLoggedIn = false }: HeaderProps)
{isLoggedIn && (
<button
onClick={onMenuClick}
- className="p-2 rounded-full"
+ className="p-2 rounded-full cursor-pointer"
aria-label="メニューを開く"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx
@@ -37,7 +37,7 @@ const Settings = () => {
{ label: 'FAQ', path: '/faq' },
{ label: '利用規約', path: '/terms' },
{ label: 'プライバシーポリシー', path: '/privacy' },
- { label: '運営者情報', path: '/operator' },
+ { label: '運営者情報・変更履歴', path: '/operator' },
];
const handleNotificationClick = async () => {
@@ -135,7 +135,7 @@ const handleDeleteData = async () => {
</div>
</div>
- <div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
+ <div className="grid grid-cols-1 gap-4">
<button
onClick={handleNotificationClick}
disabled={isPushLoading}
diff --git a/src/components/SidePanel.tsx b/src/components/SidePanel.tsx
@@ -19,7 +19,7 @@ const SidePanel = ({ isOpen, onClose, children }: SidePanelProps) => {
onClick={onClose}
/>
<div
- className={`fixed top-0 right-0 h-full w-[75vw] sm:w-100 bg-(--background) shadow-2xl z-50 transform transition-transform duration-300 ease-in-out border-l border-muted ${
+ className={`fixed top-0 right-0 h-full w-[75vw] sm:w-90 bg-(--background) shadow-2xl z-50 transform transition-transform duration-300 ease-in-out border-l border-muted ${
isOpen ? 'translate-x-0' : 'translate-x-full'
}`}
>
diff --git a/src/components/ThemeToggleButton.tsx b/src/components/ThemeToggleButton.tsx
@@ -25,7 +25,7 @@ export default function ThemeToggleButton() {
};
return (
- <button onClick={toggleTheme} className="p-2 rounded-md hover:bg-secondary">
+ <button onClick={toggleTheme} className="p-2 rounded-md hover:bg-secondary cursor-pointer">
{theme === 'light' ? <MoonIcon /> : <SunIcon />}
</button>
);
diff --git a/src/components/TimerDashboard.tsx b/src/components/TimerDashboard.tsx
@@ -2,7 +2,7 @@
import { useState, useEffect, useMemo } from 'react';
import { addHours, differenceInMilliseconds } from 'date-fns';
-import { formatInTimeZone } from 'date-fns-tz';
+import { formatInTimeZone, fromZonedTime } from 'date-fns-tz';
import CountdownDisplay from './CountdownDisplay';
import { getSessionEndTime } from '@/lib/timeUtils';
@@ -31,6 +31,7 @@ export default function TimerDashboard({
}: TimerDashboardProps) {
const [now, setNow] = useState(new Date());
+ // デバイスのタイムゾーンを取得
const userTimeZone = useMemo(() => {
if (typeof window !== 'undefined') {
try {
@@ -42,6 +43,7 @@ export default function TimerDashboard({
return JST_TZ;
}, []);
+ // 日本時間(JST)設定かどうかを判定
const isJst = userTimeZone === JST_TZ;
useEffect(() => {
@@ -50,6 +52,7 @@ export default function TimerDashboard({
return () => clearInterval(timer);
}, []);
+ // 次回タップまでの残り時間計算
const cafeTapRemaining = useMemo(() => {
if (!lastTapTime) return 0;
const endTime = getSessionEndTime(lastTapTime);
@@ -63,56 +66,60 @@ export default function TimerDashboard({
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]);
+ // 8スロットの生成ロジック
const dailySlots = useMemo(() => {
- let cycleStart = new Date(now);
- const currentHourJst = parseInt(formatInTimeZone(now, JST_TZ, 'H'), 10);
- if (currentHourJst < 4) {
- cycleStart = addHours(cycleStart, -24);
+ // 1. 日本時間での現在時刻をベースにサイクルの起点(04:00)を算出
+ const nowJstStr = formatInTimeZone(now, JST_TZ, "yyyy-MM-dd'T'HH:mm:ss");
+ const jstNow = new Date(nowJstStr);
+
+ let cycleStartDate = new Date(jstNow);
+ if (jstNow.getHours() < 4) {
+ cycleStartDate.setDate(cycleStartDate.getDate() - 1);
}
- const cycleStartStr = formatInTimeZone(cycleStart, JST_TZ, "yyyy-MM-dd'T'04:00:00");
- cycleStart = new Date(cycleStartStr);
+
+ const cycleStartJstStr = `${formatInTimeZone(cycleStartDate, JST_TZ, "yyyy-MM-dd")}T04:00:00`;
+ const cycleStartUtc = fromZonedTime(cycleStartJstStr, JST_TZ);
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');
+ const startUtc = addHours(cycleStartUtc, i * 3);
+ const endUtc = addHours(startUtc, 3);
- let mainLabel = timeJst;
- let subLabel = null;
-
- if (!isJst) {
- mainLabel = timeLocal;
- subLabel = `${timeJst} JST`;
- }
- const tapEntryStr = (tapHistory || []).find((tStr) => {
- const t = new Date(tStr).getTime();
- return t >= start.getTime() && t < end.getTime();
+ // 履歴からこの枠に収まるタップを探す
+ const tapEntry = (tapHistory || []).find((t) => {
+ const tapMs = new Date(t).getTime();
+ return tapMs >= startUtc.getTime() && tapMs < endUtc.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;
+ const tapMs = tapEntry ? new Date(tapEntry).getTime() : null;
+
+ // 表示用ラベルの決定 タップ済みの場合は実測時間、未タップの場合は枠の開始時間を採用
+ const mainLabel = tapMs
+ ? formatInTimeZone(tapMs, userTimeZone, 'HH:mm')
+ : formatInTimeZone(startUtc, userTimeZone, 'HH:mm');
+
+ const subLabelJst = tapMs
+ ? formatInTimeZone(tapMs, JST_TZ, 'HH:mm')
+ : formatInTimeZone(startUtc, JST_TZ, 'HH:mm');
+
return {
mainLabel,
- subLabel,
- tapTimeLocal,
- tapTimeJst,
- isCurrent: now >= start && now < end,
+ subLabelJst,
+ isCurrent: now >= startUtc && now < endUtc,
hasTapped: !!tapEntry
};
});
- }, [tapHistory, now, userTimeZone, isJst]);
+ }, [tapHistory, now, userTimeZone]);
return (
<div className="dashboard-container">
@@ -137,25 +144,19 @@ export default function TimerDashboard({
: '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 className="flex flex-col items-center leading-tight">
+ <span
+ className="slot-text-main"
+ style={!isJst ? { marginTop: '-0.5cqw' } : {}}
+ >
+ {slot.mainLabel}
+ </span>
+ {!isJst && (
+ <span className="slot-text-sub whitespace-nowrap">
+ {slot.subLabelJst} JST
+ </span>
+ )}
+ </div>
</div>
))}
</div>