+ {/* Header with Language, Auth, and Settings */}
+
+ {/* Controls Row */}
+
+ {/* Language Toggle - Left Side */}
+
+ {/* Auth + Settings - Right Side */}
+
+ {session ? (
+ <>
+
+
+
+
+ >
+ ) : (
+
+ {tAuth('signIn')}
+
+ )}
+
+
- {/* Settings Button - Right Side */}
-
+ {/* Title */}
+
+
+ {t('title')}
+
+
{t('description')}
+
{/* Timer Display */}
diff --git a/app/[locale]/sign-in/page.tsx b/app/[locale]/sign-in/page.tsx
new file mode 100644
index 0000000..d8c79f9
--- /dev/null
+++ b/app/[locale]/sign-in/page.tsx
@@ -0,0 +1,14 @@
+'use client'
+
+import { memo } from 'react'
+import { SignInForm } from '@/components/auth/SignInForm'
+
+const SignInPage = memo(function SignInPage() {
+ return (
+
+
+
+ )
+})
+
+export default SignInPage
diff --git a/app/[locale]/sign-up/page.tsx b/app/[locale]/sign-up/page.tsx
new file mode 100644
index 0000000..438f16d
--- /dev/null
+++ b/app/[locale]/sign-up/page.tsx
@@ -0,0 +1,14 @@
+'use client'
+
+import { memo } from 'react'
+import { SignUpForm } from '@/components/auth/SignUpForm'
+
+const SignUpPage = memo(function SignUpPage() {
+ return (
+
+
+
+ )
+})
+
+export default SignUpPage
diff --git a/app/api/auth/[...all]/route.ts b/app/api/auth/[...all]/route.ts
new file mode 100644
index 0000000..5d94414
--- /dev/null
+++ b/app/api/auth/[...all]/route.ts
@@ -0,0 +1,4 @@
+import { auth } from '@/lib/auth'
+import { toNextJsHandler } from 'better-auth/next-js'
+
+export const { GET, POST } = toNextJsHandler(auth)
diff --git a/app/api/timer-sessions/[id]/route.ts b/app/api/timer-sessions/[id]/route.ts
new file mode 100644
index 0000000..053c283
--- /dev/null
+++ b/app/api/timer-sessions/[id]/route.ts
@@ -0,0 +1,87 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { headers } from 'next/headers'
+import { eq, and } from 'drizzle-orm'
+import { auth } from '@/lib/auth'
+import { db } from '@/db'
+import { timerSession } from '@/db/schema'
+
+/**
+ * PATCH /api/timer-sessions/[id] - Update a timer session.
+ * Only allows updating note and durationSeconds.
+ *
+ * @param request - JSON body: { note?: string, durationSeconds?: number }
+ * @returns Updated timer session
+ */
+export async function PATCH(
+ request: NextRequest,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ const session = await auth.api.getSession({
+ headers: await headers(),
+ })
+
+ if (!session) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const { id } = await params
+ let body: { note?: string; durationSeconds?: number }
+ try {
+ body = await request.json()
+ } catch {
+ return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
+ }
+
+ const updated = await db
+ .update(timerSession)
+ .set({
+ ...(body.note !== undefined && { note: body.note }),
+ ...(body.durationSeconds !== undefined && {
+ durationSeconds: body.durationSeconds,
+ }),
+ updatedAt: new Date(),
+ })
+ .where(
+ and(eq(timerSession.id, id), eq(timerSession.userId, session.user.id)),
+ )
+ .returning()
+
+ if (updated.length === 0) {
+ return NextResponse.json({ error: 'Not found' }, { status: 404 })
+ }
+
+ return NextResponse.json(updated[0])
+}
+
+/**
+ * DELETE /api/timer-sessions/[id] - Delete a timer session.
+ *
+ * @returns 204 No Content on success
+ */
+export async function DELETE(
+ _request: NextRequest,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ const session = await auth.api.getSession({
+ headers: await headers(),
+ })
+
+ if (!session) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const { id } = await params
+
+ const deleted = await db
+ .delete(timerSession)
+ .where(
+ and(eq(timerSession.id, id), eq(timerSession.userId, session.user.id)),
+ )
+ .returning()
+
+ if (deleted.length === 0) {
+ return NextResponse.json({ error: 'Not found' }, { status: 404 })
+ }
+
+ return new NextResponse(null, { status: 204 })
+}
diff --git a/app/api/timer-sessions/route.ts b/app/api/timer-sessions/route.ts
new file mode 100644
index 0000000..f872e27
--- /dev/null
+++ b/app/api/timer-sessions/route.ts
@@ -0,0 +1,96 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { headers } from 'next/headers'
+import { eq, desc } from 'drizzle-orm'
+import { nanoid } from 'nanoid'
+import { auth } from '@/lib/auth'
+import { db } from '@/db'
+import { timerSession } from '@/db/schema'
+
+/**
+ * GET /api/timer-sessions - List timer sessions for the authenticated user.
+ *
+ * @returns JSON array of timer sessions, ordered by completedAt descending
+ *
+ * @example
+ * // Response: [{ id: 'abc', durationSeconds: 300, completedAt: '2026-02-16T09:30:00Z', ... }]
+ */
+export async function GET() {
+ const session = await auth.api.getSession({
+ headers: await headers(),
+ })
+
+ if (!session) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const sessions = await db
+ .select()
+ .from(timerSession)
+ .where(eq(timerSession.userId, session.user.id))
+ .orderBy(desc(timerSession.completedAt))
+
+ return NextResponse.json(sessions)
+}
+
+/**
+ * POST /api/timer-sessions - Create a new timer session.
+ *
+ * @param request - JSON body: { durationSeconds: number, completedAt: string, soundPreset: string }
+ * @returns Created timer session
+ *
+ * @example
+ * // Request body:
+ * { "durationSeconds": 300, "completedAt": "2026-02-16T09:30:00Z", "soundPreset": "ascending-chime" }
+ */
+export async function POST(request: NextRequest) {
+ const session = await auth.api.getSession({
+ headers: await headers(),
+ })
+
+ if (!session) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ let body: {
+ durationSeconds: number
+ completedAt: string
+ soundPreset: string
+ }
+ try {
+ body = await request.json()
+ } catch {
+ return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
+ }
+
+ if (
+ typeof body.durationSeconds !== 'number' ||
+ !Number.isFinite(body.durationSeconds) ||
+ body.durationSeconds <= 0
+ ) {
+ return NextResponse.json(
+ { error: 'Invalid durationSeconds' },
+ { status: 400 },
+ )
+ }
+
+ if (!body.completedAt || isNaN(Date.parse(body.completedAt))) {
+ return NextResponse.json({ error: 'Invalid completedAt' }, { status: 400 })
+ }
+
+ if (typeof body.soundPreset !== 'string' || body.soundPreset.length === 0) {
+ return NextResponse.json({ error: 'Invalid soundPreset' }, { status: 400 })
+ }
+
+ const newSession = await db
+ .insert(timerSession)
+ .values({
+ id: nanoid(),
+ userId: session.user.id,
+ durationSeconds: body.durationSeconds,
+ completedAt: new Date(body.completedAt),
+ soundPreset: body.soundPreset,
+ })
+ .returning()
+
+ return NextResponse.json(newSession[0], { status: 201 })
+}
diff --git a/app/globals.css b/app/globals.css
index 094c262..52e089f 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -41,6 +41,14 @@
--glass-tint-red: transparent;
--glass-tint-blue: transparent;
--ambient-gradient: none;
+
+ /* Heatmap Colors */
+ --heatmap-empty: #ebedf0;
+ --heatmap-level-1: rgba(4, 120, 87, 0.2);
+ --heatmap-level-2: rgba(4, 120, 87, 0.4);
+ --heatmap-level-3: rgba(4, 120, 87, 0.65);
+ --heatmap-level-4: #047857;
+ --heatmap-selected: #047857;
}
/* Dark Theme (Solid Styling) */
@@ -78,6 +86,14 @@
--glass-tint-red: transparent;
--glass-tint-blue: transparent;
--ambient-gradient: none;
+
+ /* Heatmap Colors */
+ --heatmap-empty: #161b22;
+ --heatmap-level-1: rgba(16, 185, 129, 0.25);
+ --heatmap-level-2: rgba(16, 185, 129, 0.45);
+ --heatmap-level-3: rgba(16, 185, 129, 0.7);
+ --heatmap-level-4: #10b981;
+ --heatmap-selected: #10b981;
}
/* Coffee Theme (Solid Styling) */
@@ -115,6 +131,14 @@
--glass-tint-red: transparent;
--glass-tint-blue: transparent;
--ambient-gradient: none;
+
+ /* Heatmap Colors */
+ --heatmap-empty: #d7ccc8;
+ --heatmap-level-1: rgba(93, 64, 55, 0.2);
+ --heatmap-level-2: rgba(93, 64, 55, 0.4);
+ --heatmap-level-3: rgba(93, 64, 55, 0.65);
+ --heatmap-level-4: #5d4037;
+ --heatmap-selected: #5d4037;
}
/* ============================================
@@ -184,6 +208,14 @@
rgba(96, 165, 250, 0.05) 0%,
transparent 70%
);
+
+ /* Heatmap Colors */
+ --heatmap-empty: #ebedf0;
+ --heatmap-level-1: rgba(4, 120, 87, 0.2);
+ --heatmap-level-2: rgba(4, 120, 87, 0.4);
+ --heatmap-level-3: rgba(4, 120, 87, 0.65);
+ --heatmap-level-4: #047857;
+ --heatmap-selected: #047857;
}
/* Liquid Glass Dark Theme */
@@ -248,6 +280,14 @@
rgba(96, 165, 250, 0.04) 0%,
transparent 70%
);
+
+ /* Heatmap Colors */
+ --heatmap-empty: #161b22;
+ --heatmap-level-1: rgba(16, 185, 129, 0.25);
+ --heatmap-level-2: rgba(16, 185, 129, 0.45);
+ --heatmap-level-3: rgba(16, 185, 129, 0.7);
+ --heatmap-level-4: #10b981;
+ --heatmap-selected: #10b981;
}
/* Liquid Glass Coffee Theme */
@@ -312,6 +352,14 @@
rgba(141, 110, 99, 0.05) 0%,
transparent 70%
);
+
+ /* Heatmap Colors */
+ --heatmap-empty: #d7ccc8;
+ --heatmap-level-1: rgba(93, 64, 55, 0.2);
+ --heatmap-level-2: rgba(93, 64, 55, 0.4);
+ --heatmap-level-3: rgba(93, 64, 55, 0.65);
+ --heatmap-level-4: #5d4037;
+ --heatmap-selected: #5d4037;
}
/* ============================================
diff --git a/components/auth/SignInForm.tsx b/components/auth/SignInForm.tsx
new file mode 100644
index 0000000..08b6931
--- /dev/null
+++ b/components/auth/SignInForm.tsx
@@ -0,0 +1,137 @@
+'use client'
+
+import { memo, useState } from 'react'
+import { useTranslations } from 'next-intl'
+import { useTheme } from 'next-themes'
+import { useRouter } from '@/i18n/navigation'
+import { authClient } from '@/lib/auth-client'
+import { useMounted } from '@/lib/hooks/useMounted'
+import { GlassPanel } from '@/components/ui/GlassPanel'
+import { Link } from '@/i18n/navigation'
+
+/**
+ * SignInForm - Email/password sign-in form with theme-aware styling.
+ * Supports both original and Liquid Glass theme variants.
+ *
+ * @example
+ *
+ */
+export const SignInForm = memo(function SignInForm() {
+ const t = useTranslations('Auth')
+ const { resolvedTheme } = useTheme()
+ const router = useRouter()
+ const mounted = useMounted()
+ const [email, setEmail] = useState('')
+ const [password, setPassword] = useState('')
+ const [error, setError] = useState('')
+ const [isPending, setIsPending] = useState(false)
+
+ const isLiquidGlass =
+ mounted && (resolvedTheme?.startsWith('liquid-glass') ?? false)
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault()
+ setError('')
+ setIsPending(true)
+
+ await authClient.signIn.email(
+ { email, password },
+ {
+ onSuccess: () => {
+ router.push('/')
+ router.refresh()
+ setIsPending(false)
+ },
+ onError: (ctx) => {
+ setError(ctx.error?.message ?? t('signInError'))
+ setIsPending(false)
+ },
+ },
+ )
+ }
+
+ const formContent = (
+
+ )
+
+ if (isLiquidGlass) {
+ return (
+
+ {formContent}
+
+ )
+ }
+
+ return (
+
+ {formContent}
+
+ )
+})
diff --git a/components/auth/SignUpForm.tsx b/components/auth/SignUpForm.tsx
new file mode 100644
index 0000000..87efc50
--- /dev/null
+++ b/components/auth/SignUpForm.tsx
@@ -0,0 +1,155 @@
+'use client'
+
+import { memo, useState } from 'react'
+import { useTranslations } from 'next-intl'
+import { useTheme } from 'next-themes'
+import { useRouter } from '@/i18n/navigation'
+import { authClient } from '@/lib/auth-client'
+import { useMounted } from '@/lib/hooks/useMounted'
+import { GlassPanel } from '@/components/ui/GlassPanel'
+import { Link } from '@/i18n/navigation'
+
+/**
+ * SignUpForm - Email/password registration form with theme-aware styling.
+ * Supports both original and Liquid Glass theme variants.
+ *
+ * @example
+ *
+ */
+export const SignUpForm = memo(function SignUpForm() {
+ const t = useTranslations('Auth')
+ const { resolvedTheme } = useTheme()
+ const router = useRouter()
+ const mounted = useMounted()
+ const [name, setName] = useState('')
+ const [email, setEmail] = useState('')
+ const [password, setPassword] = useState('')
+ const [error, setError] = useState('')
+ const [isPending, setIsPending] = useState(false)
+
+ const isLiquidGlass =
+ mounted && (resolvedTheme?.startsWith('liquid-glass') ?? false)
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault()
+ setError('')
+ setIsPending(true)
+
+ await authClient.signUp.email(
+ { name, email, password },
+ {
+ onSuccess: () => {
+ router.push('/')
+ router.refresh()
+ },
+ onError: (ctx) => {
+ setError(ctx.error?.message ?? t('signUpError'))
+ setIsPending(false)
+ },
+ },
+ )
+ }
+
+ const formContent = (
+
+ )
+
+ if (isLiquidGlass) {
+ return (
+
+ {formContent}
+
+ )
+ }
+
+ return (
+
+ {formContent}
+
+ )
+})
diff --git a/components/mypage/CalendarHeatmap.tsx b/components/mypage/CalendarHeatmap.tsx
new file mode 100644
index 0000000..be9f156
--- /dev/null
+++ b/components/mypage/CalendarHeatmap.tsx
@@ -0,0 +1,189 @@
+'use client'
+
+import { memo, useMemo } from 'react'
+import { ChevronLeft, ChevronRight } from 'lucide-react'
+import { useTranslations } from 'next-intl'
+import { useLocale } from 'next-intl'
+import { useTheme } from 'next-themes'
+import { useMounted } from '@/lib/hooks/useMounted'
+import { GlassPanel } from '@/components/ui/GlassPanel'
+import { DayCell } from './DayCell'
+import { HeatmapLegend } from './HeatmapLegend'
+
+interface CalendarHeatmapProps {
+ currentMonth: Date
+ onMonthChange: (date: Date) => void
+ selectedDate: string
+ onDateSelect: (dateStr: string) => void
+ sessionCountByDate: Map
+}
+
+/**
+ * CalendarHeatmap - Monthly calendar with GitHub-style heatmap coloring.
+ * Each day cell shows intensity based on coffee break count.
+ * Week start: Sunday for EN, Monday for JA.
+ *
+ * @example
+ *
+ */
+export const CalendarHeatmap = memo(function CalendarHeatmap({
+ currentMonth,
+ onMonthChange,
+ selectedDate,
+ onDateSelect,
+ sessionCountByDate,
+}: CalendarHeatmapProps) {
+ const t = useTranslations('MyPage')
+ const locale = useLocale()
+ const { resolvedTheme } = useTheme()
+ const mounted = useMounted()
+ const isLiquidGlass =
+ mounted && (resolvedTheme?.startsWith('liquid-glass') ?? false)
+
+ // Week starts on Monday for JA, Sunday for EN
+ const weekStartsOnMonday = locale === 'ja'
+
+ const monthLabel = useMemo(() => {
+ return new Intl.DateTimeFormat(locale === 'ja' ? 'ja-JP' : 'en-US', {
+ year: 'numeric',
+ month: 'long',
+ }).format(currentMonth)
+ }, [currentMonth, locale])
+
+ const dayHeaders = useMemo(() => {
+ const formatter = new Intl.DateTimeFormat(
+ locale === 'ja' ? 'ja-JP' : 'en-US',
+ { weekday: 'narrow' },
+ )
+ // Generate headers for Sun(0) through Sat(6)
+ const days = Array.from({ length: 7 }, (_, i) => {
+ const d = new Date(2026, 0, 4 + i) // 2026-01-04 is a Sunday
+ return formatter.format(d)
+ })
+ if (weekStartsOnMonday) {
+ // Rotate: [Sun, Mon, ..., Sat] → [Mon, ..., Sat, Sun]
+ const [sun, ...rest] = days
+ return [...rest, sun]
+ }
+ return days
+ }, [locale, weekStartsOnMonday])
+
+ const calendarDays = useMemo(() => {
+ const year = currentMonth.getFullYear()
+ const month = currentMonth.getMonth()
+ const firstDay = new Date(year, month, 1)
+ const lastDay = new Date(year, month + 1, 0)
+ const daysInMonth = lastDay.getDate()
+
+ // Day of week for first day (0=Sun, 6=Sat)
+ let startDow = firstDay.getDay()
+ if (weekStartsOnMonday) {
+ startDow = startDow === 0 ? 6 : startDow - 1
+ }
+
+ const cells: Array<{ day: number; dateStr: string } | null> = []
+ // Leading empty cells
+ for (let i = 0; i < startDow; i++) {
+ cells.push(null)
+ }
+ // Day cells
+ for (let d = 1; d <= daysInMonth; d++) {
+ const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`
+ cells.push({ day: d, dateStr })
+ }
+ return cells
+ }, [currentMonth, weekStartsOnMonday])
+
+ const todayStr = useMemo(() => {
+ const now = new Date()
+ return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
+ }, [])
+
+ const calendarContent = (
+
+ {/* Month Navigation */}
+
+
+
+ {monthLabel}
+
+
+
+
+ {/* Day Headers */}
+
+ {dayHeaders.map((day, i) => (
+
+ {day}
+
+ ))}
+
+
+ {/* Calendar Grid */}
+
+ {calendarDays.map((cell, i) => {
+ if (!cell) {
+ return
+ }
+ return (
+
+ )
+ })}
+
+
+ {/* Legend */}
+
+
+ )
+
+ if (isLiquidGlass) {
+ return (
+
+ {calendarContent}
+
+ )
+ }
+
+ return (
+
+ {calendarContent}
+
+ )
+})
diff --git a/components/mypage/DayCell.tsx b/components/mypage/DayCell.tsx
new file mode 100644
index 0000000..c5de018
--- /dev/null
+++ b/components/mypage/DayCell.tsx
@@ -0,0 +1,76 @@
+'use client'
+
+import { memo } from 'react'
+
+/**
+ * Maps session count to a heatmap intensity level (0-4).
+ *
+ * @param count - Number of sessions on a given day
+ * @returns CSS variable name for the background color
+ *
+ * @example
+ * getHeatmapColor(0) // => 'var(--heatmap-empty)'
+ * getHeatmapColor(3) // => 'var(--heatmap-level-2)'
+ * getHeatmapColor(6) // => 'var(--heatmap-level-4)'
+ */
+function getHeatmapColor(count: number): string {
+ if (count === 0) return 'var(--heatmap-empty)'
+ if (count === 1) return 'var(--heatmap-level-1)'
+ if (count <= 3) return 'var(--heatmap-level-2)'
+ if (count <= 5) return 'var(--heatmap-level-3)'
+ return 'var(--heatmap-level-4)'
+}
+
+interface DayCellProps {
+ day: number
+ dateStr: string
+ sessionCount: number
+ isSelected: boolean
+ isToday: boolean
+ onSelect: (dateStr: string) => void
+}
+
+/**
+ * DayCell - Individual calendar day with heatmap coloring.
+ * Shows session intensity and handles click selection.
+ *
+ * @example
+ *
+ */
+export const DayCell = memo(function DayCell({
+ day,
+ dateStr,
+ sessionCount,
+ isSelected,
+ isToday,
+ onSelect,
+}: DayCellProps) {
+ return (
+
+ )
+})
diff --git a/components/mypage/HeatmapLegend.tsx b/components/mypage/HeatmapLegend.tsx
new file mode 100644
index 0000000..abef07d
--- /dev/null
+++ b/components/mypage/HeatmapLegend.tsx
@@ -0,0 +1,41 @@
+'use client'
+
+import { memo } from 'react'
+import { useTranslations } from 'next-intl'
+
+/**
+ * HeatmapLegend - Shows the color scale from "Less" to "More".
+ *
+ * @example
+ *
+ */
+export const HeatmapLegend = memo(function HeatmapLegend() {
+ const t = useTranslations('MyPage')
+
+ return (
+
+
{t('heatmapLess')}
+
+
+
+
+
+
{t('heatmapMore')}
+
+ )
+})
diff --git a/components/mypage/MyPageContent.tsx b/components/mypage/MyPageContent.tsx
new file mode 100644
index 0000000..10095ae
--- /dev/null
+++ b/components/mypage/MyPageContent.tsx
@@ -0,0 +1,157 @@
+'use client'
+
+import { memo, useState, useCallback, useMemo } from 'react'
+import { ArrowLeft, Settings } from 'lucide-react'
+import { useTranslations } from 'next-intl'
+import { Link } from '@/i18n/navigation'
+import { LanguageToggle } from '@/components/LanguageToggle'
+import { SettingsPanel } from '@/components/settings/SettingsPanel'
+import { CalendarHeatmap } from './CalendarHeatmap'
+import { TimelinePanel } from './TimelinePanel'
+import { SummaryStats } from './SummaryStats'
+import { useTimerSessions } from '@/lib/hooks/useTimerSessions'
+
+/**
+ * Convert a date value to local YYYY-MM-DD string.
+ * Ensures consistent local-day bucketing regardless of timezone.
+ *
+ * @param value - Date string (ISO) or Date object
+ * @returns Local date string like "2026-02-16"
+ *
+ * @example
+ * toLocalDateStr('2026-02-16T23:59:00Z') // => "2026-02-17" (in UTC+9)
+ * toLocalDateStr(new Date()) // => "2026-02-16"
+ */
+function toLocalDateStr(value: string | Date): string {
+ const date = new Date(value)
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
+}
+
+/**
+ * Get today's date as YYYY-MM-DD string.
+ *
+ * @returns Local date string for today
+ * @example
+ * getTodayStr() // => "2026-02-16"
+ */
+function getTodayStr(): string {
+ return toLocalDateStr(new Date())
+}
+
+/**
+ * MyPageContent - Main client component for the My Page dashboard.
+ * Orchestrates calendar heatmap, timeline, and summary stats.
+ * Fetches session data from the API via useTimerSessions hook.
+ *
+ * @example
+ *
+ */
+export const MyPageContent = memo(function MyPageContent() {
+ const t = useTranslations('MyPage')
+ const tSettings = useTranslations('Settings')
+ const { sessions, isLoading, editSession, deleteSession } = useTimerSessions()
+ const [selectedDate, setSelectedDate] = useState(getTodayStr)
+ const [currentMonth, setCurrentMonth] = useState(
+ () => new Date(new Date().getFullYear(), new Date().getMonth()),
+ )
+ const [isSettingsOpen, setIsSettingsOpen] = useState(false)
+
+ // Semantic handlers for calendar navigation
+ const handleMonthChange = useCallback((date: Date) => {
+ setCurrentMonth(date)
+ }, [])
+
+ const handleDateSelect = useCallback((dateStr: string) => {
+ setSelectedDate(dateStr)
+ }, [])
+
+ const handleCloseSettings = useCallback(() => {
+ setIsSettingsOpen(false)
+ }, [])
+
+ // Pre-compute session count by date for heatmap
+ const sessionCountByDate = useMemo(() => {
+ const map = new Map()
+ for (const s of sessions) {
+ const dateStr = toLocalDateStr(s.completedAt)
+ map.set(dateStr, (map.get(dateStr) ?? 0) + 1)
+ }
+ return map
+ }, [sessions])
+
+ // Filter sessions for the selected date
+ const selectedDateSessions = useMemo(() => {
+ return sessions.filter(
+ (s) => toLocalDateStr(s.completedAt) === selectedDate,
+ )
+ }, [sessions, selectedDate])
+
+ return (
+
+
+ {/* Header */}
+
+ {/* Controls Row */}
+
+ {/* Back + Language Toggle - Left Side */}
+
+
+ {/* Settings - Right Side */}
+
+
+
+ {/* Title */}
+
+ {t('title')}
+
+
+
+ {isLoading ? (
+
+ ) : (
+ <>
+ {/* Calendar Heatmap */}
+
+
+ {/* Timeline */}
+
+
+ {/* Summary Stats */}
+
+ >
+ )}
+
+
+ {/* Settings Panel */}
+
+
+ )
+})
diff --git a/components/mypage/StatCard.tsx b/components/mypage/StatCard.tsx
new file mode 100644
index 0000000..ae2c301
--- /dev/null
+++ b/components/mypage/StatCard.tsx
@@ -0,0 +1,52 @@
+'use client'
+
+import { memo } from 'react'
+import { useTheme } from 'next-themes'
+import { useMounted } from '@/lib/hooks/useMounted'
+import { GlassPanel } from '@/components/ui/GlassPanel'
+
+interface StatCardProps {
+ label: string
+ time: string
+ count: string
+}
+
+/**
+ * StatCard - Displays a summary statistic (total time + count) in a themed card.
+ * Supports both original and Liquid Glass theme variants.
+ *
+ * @example
+ *
+ */
+export const StatCard = memo(function StatCard({
+ label,
+ time,
+ count,
+}: StatCardProps) {
+ const { resolvedTheme } = useTheme()
+ const mounted = useMounted()
+ const isLiquidGlass =
+ mounted && (resolvedTheme?.startsWith('liquid-glass') ?? false)
+
+ const content = (
+
+
{label}
+
{time}
+
{count}
+
+ )
+
+ if (isLiquidGlass) {
+ return (
+
+ {content}
+
+ )
+ }
+
+ return (
+
+ {content}
+
+ )
+})
diff --git a/components/mypage/SummaryStats.tsx b/components/mypage/SummaryStats.tsx
new file mode 100644
index 0000000..f315e48
--- /dev/null
+++ b/components/mypage/SummaryStats.tsx
@@ -0,0 +1,140 @@
+'use client'
+
+import { memo, useMemo } from 'react'
+import { useTranslations } from 'next-intl'
+import { useLocale } from 'next-intl'
+import { StatCard } from './StatCard'
+import type { TimerSessionRecord } from '@/lib/types/timerSession'
+
+interface SummaryStatsProps {
+ sessions: TimerSessionRecord[]
+}
+
+/**
+ * Formats total seconds into a readable time string.
+ *
+ * @param totalSeconds - Total duration in seconds
+ * @param locale - 'en' or 'ja'
+ * @returns Formatted string like "18 min" or "1h 45min"
+ *
+ * @example
+ * formatTime(1080, 'en') // => "18 min"
+ * formatTime(6300, 'en') // => "1h 45min"
+ * formatTime(6300, 'ja') // => "1時間45分"
+ */
+function formatTime(totalSeconds: number, locale: string): string {
+ if (totalSeconds === 0) return locale === 'ja' ? '0分' : '0 min'
+ const hours = Math.floor(totalSeconds / 3600)
+ const minutes = Math.floor((totalSeconds % 3600) / 60)
+
+ if (locale === 'ja') {
+ if (hours > 0) return `${hours}時間${minutes}分`
+ return `${minutes}分`
+ }
+ if (hours > 0) return `${hours}h ${minutes}min`
+ return `${minutes} min`
+}
+
+/**
+ * Calculate stats for sessions in a date range.
+ *
+ * @param sessions - All sessions
+ * @param startDate - Range start (inclusive)
+ * @param endDate - Range end (inclusive)
+ * @returns Object with totalSeconds and count
+ *
+ * @example
+ * calcRange(sessions, '2026-02-16', '2026-02-16') // => { totalSeconds: 900, count: 3 }
+ */
+function calcRange(
+ sessions: TimerSessionRecord[],
+ startDate: Date,
+ endDate: Date,
+): { totalSeconds: number; count: number } {
+ let totalSeconds = 0
+ let count = 0
+ for (const s of sessions) {
+ const d = new Date(s.completedAt)
+ if (d >= startDate && d <= endDate) {
+ totalSeconds += s.durationSeconds
+ count++
+ }
+ }
+ return { totalSeconds, count }
+}
+
+/**
+ * SummaryStats - Shows today/this week/this month break statistics.
+ *
+ * @example
+ *
+ */
+export const SummaryStats = memo(function SummaryStats({
+ sessions,
+}: SummaryStatsProps) {
+ const t = useTranslations('MyPage')
+ const locale = useLocale()
+
+ const stats = useMemo(() => {
+ const now = new Date()
+ const todayStart = new Date(
+ now.getFullYear(),
+ now.getMonth(),
+ now.getDate(),
+ )
+ const todayEnd = new Date(
+ now.getFullYear(),
+ now.getMonth(),
+ now.getDate(),
+ 23,
+ 59,
+ 59,
+ 999,
+ )
+
+ // Week start: Monday for JA, Sunday for EN
+ const weekStart = new Date(todayStart)
+ const dow = weekStart.getDay()
+ if (locale === 'ja') {
+ weekStart.setDate(weekStart.getDate() - (dow === 0 ? 6 : dow - 1))
+ } else {
+ weekStart.setDate(weekStart.getDate() - dow)
+ }
+
+ const monthStart = new Date(now.getFullYear(), now.getMonth(), 1)
+
+ return {
+ today: calcRange(sessions, todayStart, todayEnd),
+ week: calcRange(sessions, weekStart, todayEnd),
+ month: calcRange(sessions, monthStart, todayEnd),
+ }
+ }, [sessions, locale])
+
+ const formatCount = (count: number): string => {
+ if (count === 0) return t('noBreaksYet')
+ if (count === 1) return t('break')
+ return t('breaks', { count })
+ }
+
+ return (
+
+ )
+})
diff --git a/components/mypage/TimelineEntry.tsx b/components/mypage/TimelineEntry.tsx
new file mode 100644
index 0000000..5af99e9
--- /dev/null
+++ b/components/mypage/TimelineEntry.tsx
@@ -0,0 +1,184 @@
+'use client'
+
+import { memo, useState } from 'react'
+import { Pencil, Trash2, MoreHorizontal } from 'lucide-react'
+import { useTranslations } from 'next-intl'
+import { useLocale } from 'next-intl'
+import type { TimerSessionRecord } from '@/lib/types/timerSession'
+
+interface TimelineEntryProps {
+ session: TimerSessionRecord
+ onEdit: (
+ id: string,
+ updates: { note?: string; durationSeconds?: number },
+ ) => void
+ onDelete: (id: string) => void
+}
+
+/**
+ * Formats seconds into a human-readable duration string.
+ *
+ * @param seconds - Duration in seconds
+ * @returns Formatted string like "5:00" or "10:30"
+ *
+ * @example
+ * formatDuration(300) // => "5:00"
+ * formatDuration(630) // => "10:30"
+ */
+function formatDuration(seconds: number): string {
+ const m = Math.floor(seconds / 60)
+ const s = seconds % 60
+ return `${m}:${String(s).padStart(2, '0')}`
+}
+
+/**
+ * TimelineEntry - Single timer session row in the timeline.
+ * Shows time, duration, and edit/delete actions.
+ *
+ * @example
+ *
+ */
+export const TimelineEntry = memo(function TimelineEntry({
+ session,
+ onEdit,
+ onDelete,
+}: TimelineEntryProps) {
+ const t = useTranslations('MyPage')
+ const locale = useLocale()
+ const [showMenu, setShowMenu] = useState(false)
+ const [isEditing, setIsEditing] = useState(false)
+ const [noteValue, setNoteValue] = useState(session.note ?? '')
+ const [showConfirmDelete, setShowConfirmDelete] = useState(false)
+
+ const timeStr = new Intl.DateTimeFormat(locale === 'ja' ? 'ja-JP' : 'en-US', {
+ hour: '2-digit',
+ minute: '2-digit',
+ hour12: false,
+ }).format(new Date(session.completedAt))
+
+ const saveNote = () => {
+ onEdit(session.id, { note: noteValue || undefined })
+ setIsEditing(false)
+ }
+
+ return (
+
+ {/* Time */}
+
+ {timeStr}
+
+
+ {/* Duration */}
+
+ {formatDuration(session.durationSeconds)}
+
+
+ {/* Note or Sound Preset */}
+
+ {isEditing ? (
+
+ setNoteValue(e.target.value)}
+ placeholder={t('notePlaceholder')}
+ className="flex-1 rounded-md border border-bg-secondary bg-bg-primary px-2 py-1 text-sm text-text-primary focus:border-primary-green focus:outline-none"
+ autoFocus
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') saveNote()
+ if (e.key === 'Escape') setIsEditing(false)
+ }}
+ />
+
+
+
+ ) : (
+
+ {session.note ?? session.soundPreset}
+
+ )}
+
+
+ {/* Actions - Desktop */}
+
+
+ {showConfirmDelete ? (
+
+
+
+
+ ) : (
+
+ )}
+
+
+ {/* Actions - Mobile (overflow menu) */}
+
+
+ {showMenu && (
+
+
+
+
+ )}
+
+
+ )
+})
diff --git a/components/mypage/TimelinePanel.tsx b/components/mypage/TimelinePanel.tsx
new file mode 100644
index 0000000..12b0427
--- /dev/null
+++ b/components/mypage/TimelinePanel.tsx
@@ -0,0 +1,110 @@
+'use client'
+
+import { memo, useMemo } from 'react'
+import { Coffee } from 'lucide-react'
+import { useTranslations } from 'next-intl'
+import { useLocale } from 'next-intl'
+import { useTheme } from 'next-themes'
+import { useMounted } from '@/lib/hooks/useMounted'
+import { GlassPanel } from '@/components/ui/GlassPanel'
+import { TimelineEntry } from './TimelineEntry'
+import type { TimerSessionRecord } from '@/lib/types/timerSession'
+import { AnimatePresence, motion } from 'framer-motion'
+
+interface TimelinePanelProps {
+ selectedDate: string
+ sessions: TimerSessionRecord[]
+ onEdit: (
+ id: string,
+ updates: { note?: string; durationSeconds?: number },
+ ) => void
+ onDelete: (id: string) => void
+}
+
+/**
+ * TimelinePanel - Shows timer sessions for a selected date in chronological order.
+ * Animates when the selected date changes.
+ *
+ * @example
+ *
+ */
+export const TimelinePanel = memo(function TimelinePanel({
+ selectedDate,
+ sessions,
+ onEdit,
+ onDelete,
+}: TimelinePanelProps) {
+ const t = useTranslations('MyPage')
+ const locale = useLocale()
+ const { resolvedTheme } = useTheme()
+ const mounted = useMounted()
+ const isLiquidGlass =
+ mounted && (resolvedTheme?.startsWith('liquid-glass') ?? false)
+
+ const dateLabel = useMemo(() => {
+ const [y, m, d] = selectedDate.split('-').map(Number)
+ const date = new Date(y, m - 1, d)
+ return new Intl.DateTimeFormat(locale === 'ja' ? 'ja-JP' : 'en-US', {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ }).format(date)
+ }, [selectedDate, locale])
+
+ const sortedSessions = useMemo(() => {
+ return [...sessions].sort(
+ (a, b) =>
+ new Date(a.completedAt).getTime() - new Date(b.completedAt).getTime(),
+ )
+ }, [sessions])
+
+ const timelineContent = (
+
+
+ {t('timeline')}: {dateLabel}
+
+
+
+
+ {sortedSessions.length === 0 ? (
+
+ ) : (
+
+ {sortedSessions.map((s) => (
+
+ ))}
+
+ )}
+
+
+
+ )
+
+ if (isLiquidGlass) {
+ return (
+
+ {timelineContent}
+
+ )
+ }
+
+ return (
+
+ {timelineContent}
+
+ )
+})
diff --git a/db/index.ts b/db/index.ts
new file mode 100644
index 0000000..207a33a
--- /dev/null
+++ b/db/index.ts
@@ -0,0 +1,16 @@
+import { drizzle } from 'drizzle-orm/node-postgres'
+import { Pool } from 'pg'
+import * as schema from './schema'
+
+const pool = new Pool({
+ connectionString: process.env.DATABASE_URL,
+})
+
+/**
+ * Drizzle ORM client instance with typed schema.
+ * Uses node-postgres Pool for connection management.
+ *
+ * @example
+ * const users = await db.select().from(schema.user)
+ */
+export const db = drizzle(pool, { schema })
diff --git a/db/schema.ts b/db/schema.ts
new file mode 100644
index 0000000..41794a3
--- /dev/null
+++ b/db/schema.ts
@@ -0,0 +1,76 @@
+import { pgTable, text, timestamp, boolean, integer } from 'drizzle-orm/pg-core'
+
+// ─── Better Auth tables ───────────────────────────────────────────
+
+export const user = pgTable('user', {
+ id: text('id').primaryKey(),
+ name: text('name').notNull(),
+ email: text('email').notNull().unique(),
+ emailVerified: boolean('email_verified').notNull(),
+ image: text('image'),
+ createdAt: timestamp('created_at').notNull().defaultNow(),
+ updatedAt: timestamp('updated_at').notNull().defaultNow(),
+})
+
+export const session = pgTable('session', {
+ id: text('id').primaryKey(),
+ expiresAt: timestamp('expires_at').notNull(),
+ token: text('token').notNull().unique(),
+ createdAt: timestamp('created_at').notNull(),
+ updatedAt: timestamp('updated_at').notNull(),
+ ipAddress: text('ip_address'),
+ userAgent: text('user_agent'),
+ userId: text('user_id')
+ .notNull()
+ .references(() => user.id, { onDelete: 'cascade' }),
+})
+
+export const account = pgTable('account', {
+ id: text('id').primaryKey(),
+ accountId: text('account_id').notNull(),
+ providerId: text('provider_id').notNull(),
+ userId: text('user_id')
+ .notNull()
+ .references(() => user.id, { onDelete: 'cascade' }),
+ accessToken: text('access_token'),
+ refreshToken: text('refresh_token'),
+ idToken: text('id_token'),
+ accessTokenExpiresAt: timestamp('access_token_expires_at'),
+ refreshTokenExpiresAt: timestamp('refresh_token_expires_at'),
+ scope: text('scope'),
+ password: text('password'),
+ createdAt: timestamp('created_at').notNull(),
+ updatedAt: timestamp('updated_at').notNull(),
+})
+
+export const verification = pgTable('verification', {
+ id: text('id').primaryKey(),
+ identifier: text('identifier').notNull(),
+ value: text('value').notNull(),
+ expiresAt: timestamp('expires_at').notNull(),
+ createdAt: timestamp('created_at'),
+ updatedAt: timestamp('updated_at'),
+})
+
+// ─── Application tables ───────────────────────────────────────────
+
+/**
+ * Stores completed timer sessions for authenticated users.
+ * Each row represents one coffee break that was timed to completion.
+ *
+ * @example
+ * // A 5-minute break completed at 9:30 AM
+ * { id: 'abc123', userId: 'usr_1', durationSeconds: 300, completedAt: '2026-02-16T09:30:00Z' }
+ */
+export const timerSession = pgTable('timer_session', {
+ id: text('id').primaryKey(),
+ userId: text('user_id')
+ .notNull()
+ .references(() => user.id, { onDelete: 'cascade' }),
+ durationSeconds: integer('duration_seconds').notNull(),
+ completedAt: timestamp('completed_at').notNull(),
+ soundPreset: text('sound_preset').notNull(),
+ note: text('note'),
+ createdAt: timestamp('created_at').notNull().defaultNow(),
+ updatedAt: timestamp('updated_at').notNull().defaultNow(),
+})
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..9e76911
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,14 @@
+services:
+ postgres:
+ image: postgres:17-alpine
+ ports:
+ - '5433:5432'
+ environment:
+ POSTGRES_USER: coffee_timer
+ POSTGRES_PASSWORD: coffee_timer_dev
+ POSTGRES_DB: coffee_timer
+ volumes:
+ - pgdata:/var/lib/postgresql/data
+
+volumes:
+ pgdata:
diff --git a/drizzle.config.ts b/drizzle.config.ts
new file mode 100644
index 0000000..a997c5a
--- /dev/null
+++ b/drizzle.config.ts
@@ -0,0 +1,10 @@
+import { defineConfig } from 'drizzle-kit'
+
+export default defineConfig({
+ schema: './db/schema.ts',
+ out: './db/migrations',
+ dialect: 'postgresql',
+ dbCredentials: {
+ url: process.env.DATABASE_URL!,
+ },
+})
diff --git a/lib/auth-client.ts b/lib/auth-client.ts
new file mode 100644
index 0000000..9dd1671
--- /dev/null
+++ b/lib/auth-client.ts
@@ -0,0 +1,15 @@
+import { createAuthClient } from 'better-auth/react'
+
+/**
+ * Better Auth client instance for React components.
+ * Provides hooks like `useSession` and methods like `signIn`, `signUp`, `signOut`.
+ *
+ * @example
+ * const { data: session, isPending } = authClient.useSession()
+ *
+ * @example
+ * await authClient.signIn.email({ email, password })
+ */
+export const authClient = createAuthClient({
+ baseURL: process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3009',
+})
diff --git a/lib/auth.ts b/lib/auth.ts
new file mode 100644
index 0000000..abb316e
--- /dev/null
+++ b/lib/auth.ts
@@ -0,0 +1,24 @@
+import { betterAuth } from 'better-auth'
+import { drizzleAdapter } from 'better-auth/adapters/drizzle'
+import { db } from '@/db'
+
+/**
+ * Better Auth server instance.
+ * Configured with Drizzle adapter for PostgreSQL and email/password authentication.
+ *
+ * @example
+ * // Server-side session check
+ * const session = await auth.api.getSession({ headers: await headers() })
+ *
+ * @example
+ * // API route handler
+ * export const { GET, POST } = toNextJsHandler(auth)
+ */
+export const auth = betterAuth({
+ database: drizzleAdapter(db, {
+ provider: 'pg',
+ }),
+ emailAndPassword: {
+ enabled: true,
+ },
+})
diff --git a/lib/hooks/useTimerSessionSave.ts b/lib/hooks/useTimerSessionSave.ts
new file mode 100644
index 0000000..87982ba
--- /dev/null
+++ b/lib/hooks/useTimerSessionSave.ts
@@ -0,0 +1,56 @@
+import { useEffect, useRef, type MutableRefObject } from 'react'
+import type { SoundPreset } from '@/lib/stores/settingsStore'
+import { authClient } from '@/lib/auth-client'
+
+/**
+ * Auto-save completed timer sessions to the server for authenticated users.
+ * Uses the same completion detection pattern as useTimerCompletion.
+ * Non-blocking fire-and-forget fetch — never delays sound or notification.
+ *
+ * @param timeRemaining - Current time remaining in seconds
+ * @param initialTime - Original timer duration in seconds
+ * @param soundPreset - Sound preset used for this session
+ * @param userSetTimeRef - Ref tracking if user manually set time (avoid false triggers)
+ *
+ * @example
+ * useTimerSessionSave(timeRemaining, initialTime, soundPreset, userSetTimeRef)
+ */
+export function useTimerSessionSave(
+ timeRemaining: number,
+ initialTime: number,
+ soundPreset: SoundPreset,
+ userSetTimeRef: MutableRefObject,
+): void {
+ const { data: session } = authClient.useSession()
+ const previousTimeRef = useRef(timeRemaining)
+
+ useEffect(() => {
+ // Same completion detection as useTimerCompletion:
+ // timer just hit 0, but not because user manually set it to 0
+ if (
+ previousTimeRef.current > 0 &&
+ timeRemaining === 0 &&
+ !userSetTimeRef.current &&
+ session?.user
+ ) {
+ // Fire-and-forget: don't block sound or notification
+ fetch('/api/timer-sessions', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ durationSeconds: initialTime,
+ completedAt: new Date().toISOString(),
+ soundPreset,
+ }),
+ })
+ .then((res) => {
+ if (!res.ok)
+ console.error('[TimerSessionSave] Server error:', res.status)
+ })
+ .catch((error) => {
+ console.error('[TimerSessionSave] Failed to save session:', error)
+ })
+ }
+ previousTimeRef.current = timeRemaining
+ }, [timeRemaining, initialTime, soundPreset, userSetTimeRef, session])
+}
diff --git a/lib/hooks/useTimerSessions.ts b/lib/hooks/useTimerSessions.ts
new file mode 100644
index 0000000..32c28c7
--- /dev/null
+++ b/lib/hooks/useTimerSessions.ts
@@ -0,0 +1,75 @@
+import { useState, useEffect, useCallback } from 'react'
+import type { TimerSessionRecord } from '@/lib/types/timerSession'
+
+/**
+ * Custom hook to fetch, edit, and delete timer sessions from the API.
+ * Manages loading state and provides CRUD operations.
+ *
+ * @returns Object with sessions array, loading state, and mutation handlers
+ *
+ * @example
+ * const { sessions, isLoading, editSession, deleteSession } = useTimerSessions()
+ */
+export function useTimerSessions() {
+ const [sessions, setSessions] = useState([])
+ const [isLoading, setIsLoading] = useState(true)
+
+ useEffect(() => {
+ let cancelled = false
+
+ fetch('/api/timer-sessions')
+ .then((res) => {
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
+ return res.json()
+ })
+ .then((data: TimerSessionRecord[]) => {
+ if (!cancelled) {
+ setSessions(data)
+ setIsLoading(false)
+ }
+ })
+ .catch((error) => {
+ console.error('[useTimerSessions] Failed to fetch sessions:', error)
+ if (!cancelled) setIsLoading(false)
+ })
+
+ return () => {
+ cancelled = true
+ }
+ }, [])
+
+ const editSession = useCallback(
+ async (
+ id: string,
+ updates: { note?: string; durationSeconds?: number },
+ ) => {
+ try {
+ const res = await fetch(`/api/timer-sessions/${id}`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(updates),
+ })
+ if (!res.ok) return
+ const updated: TimerSessionRecord = await res.json()
+ setSessions((prev) => prev.map((s) => (s.id === id ? updated : s)))
+ } catch (error) {
+ console.error('[useTimerSessions] Failed to edit session:', error)
+ }
+ },
+ [],
+ )
+
+ const deleteSession = useCallback(async (id: string) => {
+ try {
+ const res = await fetch(`/api/timer-sessions/${id}`, {
+ method: 'DELETE',
+ })
+ if (!res.ok) return
+ setSessions((prev) => prev.filter((s) => s.id !== id))
+ } catch (error) {
+ console.error('[useTimerSessions] Failed to delete session:', error)
+ }
+ }, [])
+
+ return { sessions, isLoading, editSession, deleteSession }
+}
diff --git a/lib/types/timerSession.ts b/lib/types/timerSession.ts
new file mode 100644
index 0000000..00e5113
--- /dev/null
+++ b/lib/types/timerSession.ts
@@ -0,0 +1,16 @@
+/**
+ * Timer session as returned from the API.
+ *
+ * @example
+ * { id: 'abc123', userId: 'usr_1', durationSeconds: 300, completedAt: '2026-02-16T09:30:00Z', soundPreset: 'ascending-chime', note: null }
+ */
+export interface TimerSessionRecord {
+ id: string
+ userId: string
+ durationSeconds: number
+ completedAt: string
+ soundPreset: string
+ note: string | null
+ createdAt: string
+ updatedAt: string
+}
diff --git a/messages/en.json b/messages/en.json
index 12a6231..c9cce95 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -55,6 +55,47 @@
"en": "English",
"ja": "日本語"
},
+ "Auth": {
+ "signIn": "Sign In",
+ "signUp": "Sign Up",
+ "signOut": "Sign Out",
+ "email": "Email",
+ "password": "Password",
+ "name": "Name",
+ "noAccount": "Don't have an account?",
+ "hasAccount": "Already have an account?",
+ "signingIn": "Signing in...",
+ "signingUp": "Signing up...",
+ "myPage": "My Page",
+ "signInError": "Invalid email or password",
+ "signUpError": "Sign up failed. Please try again."
+ },
+ "MyPage": {
+ "title": "My Page",
+ "back": "Back",
+ "previousMonth": "Previous month",
+ "nextMonth": "Next month",
+ "heatmapLess": "Less",
+ "heatmapMore": "More",
+ "noSessions": "No coffee breaks recorded on this day.",
+ "timeline": "Timeline",
+ "edit": "Edit",
+ "delete": "Delete",
+ "confirmDelete": "Are you sure you want to delete this session?",
+ "cancel": "Cancel",
+ "save": "Save",
+ "duration": "Duration",
+ "note": "Note",
+ "notePlaceholder": "Add a note...",
+ "today": "Today",
+ "thisWeek": "This Week",
+ "thisMonth": "This Month",
+ "totalTime": "Total time",
+ "breaks": "{count} breaks",
+ "break": "1 break",
+ "noBreaksYet": "No breaks yet",
+ "actions": "Actions"
+ },
"Notifications": {
"title": "Notifications",
"enabled": "Enable notifications",
diff --git a/messages/ja.json b/messages/ja.json
index fe61440..38dc953 100644
--- a/messages/ja.json
+++ b/messages/ja.json
@@ -55,6 +55,47 @@
"en": "English",
"ja": "日本語"
},
+ "Auth": {
+ "signIn": "サインイン",
+ "signUp": "サインアップ",
+ "signOut": "サインアウト",
+ "email": "メールアドレス",
+ "password": "パスワード",
+ "name": "名前",
+ "noAccount": "アカウントをお持ちでないですか?",
+ "hasAccount": "すでにアカウントをお持ちですか?",
+ "signingIn": "サインイン中...",
+ "signingUp": "サインアップ中...",
+ "myPage": "マイページ",
+ "signInError": "メールアドレスまたはパスワードが無効です",
+ "signUpError": "サインアップに失敗しました。もう一度お試しください。"
+ },
+ "MyPage": {
+ "title": "マイページ",
+ "back": "戻る",
+ "previousMonth": "前月",
+ "nextMonth": "翌月",
+ "heatmapLess": "少ない",
+ "heatmapMore": "多い",
+ "noSessions": "この日のコーヒーブレイク記録はありません。",
+ "timeline": "タイムライン",
+ "edit": "編集",
+ "delete": "削除",
+ "confirmDelete": "このセッションを削除してもよろしいですか?",
+ "cancel": "キャンセル",
+ "save": "保存",
+ "duration": "時間",
+ "note": "メモ",
+ "notePlaceholder": "メモを追加...",
+ "today": "今日",
+ "thisWeek": "今週",
+ "thisMonth": "今月",
+ "totalTime": "合計時間",
+ "breaks": "{count}回",
+ "break": "1回",
+ "noBreaksYet": "まだ記録はありません",
+ "actions": "アクション"
+ },
"Notifications": {
"title": "通知",
"enabled": "通知を有効にする",
diff --git a/package.json b/package.json
index c8b5106..9fee86d 100644
--- a/package.json
+++ b/package.json
@@ -22,18 +22,28 @@
"e2e:mobile": "playwright test --project=\"Mobile Chrome\"",
"prepare": "husky",
"prettier": "prettier --ignore-unknown --write .",
- "clean": "rm -rf .next node_modules .playwright-mcp test-results playwright-report .pnpm-store"
+ "clean": "rm -rf .next node_modules .playwright-mcp test-results playwright-report .pnpm-store",
+ "db:start": "docker compose up -d",
+ "db:stop": "docker compose down",
+ "db:push": "drizzle-kit push",
+ "db:generate": "drizzle-kit generate",
+ "db:migrate": "drizzle-kit migrate",
+ "db:studio": "drizzle-kit studio"
},
"dependencies": {
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slider": "^1.3.6",
"@vercel/analytics": "^1.6.1",
+ "better-auth": "^1.4.18",
+ "drizzle-orm": "^0.45.1",
"framer-motion": "^12.29.2",
"lucide-react": "^0.563.0",
+ "nanoid": "^5.1.6",
"next": "^16.1.6",
"next-intl": "^4.8.1",
"next-themes": "^0.4.6",
+ "pg": "^8.18.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"zustand": "^5.0.10"
@@ -45,9 +55,11 @@
"@playwright/test": "^1.58.1",
"@tailwindcss/postcss": "^4.1.18",
"@types/node": "^25.1.0",
+ "@types/pg": "^8.16.0",
"@types/react": "^19.2.10",
"@types/react-dom": "^19.2.3",
"axe-core": "^4.11.1",
+ "drizzle-kit": "^0.31.9",
"eslint": "^9.39.2",
"eslint-config-next": "^16.1.6",
"husky": "^9.1.7",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 0ce82ec..23ee637 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -20,12 +20,21 @@ importers:
'@vercel/analytics':
specifier: ^1.6.1
version: 1.6.1(next@16.1.6(@babel/core@7.29.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)
+ better-auth:
+ specifier: ^1.4.18
+ version: 1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.45.1(@types/pg@8.16.0)(kysely@0.28.11)(pg@8.18.0))(next@16.1.6(@babel/core@7.29.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(pg@8.18.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ drizzle-orm:
+ specifier: ^0.45.1
+ version: 0.45.1(@types/pg@8.16.0)(kysely@0.28.11)(pg@8.18.0)
framer-motion:
specifier: ^12.29.2
version: 12.29.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
lucide-react:
specifier: ^0.563.0
version: 0.563.0(react@19.2.4)
+ nanoid:
+ specifier: ^5.1.6
+ version: 5.1.6
next:
specifier: ^16.1.6
version: 16.1.6(@babel/core@7.29.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -35,6 +44,9 @@ importers:
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ pg:
+ specifier: ^8.18.0
+ version: 8.18.0
react:
specifier: ^19.2.4
version: 19.2.4
@@ -63,6 +75,9 @@ importers:
'@types/node':
specifier: ^25.1.0
version: 25.1.0
+ '@types/pg':
+ specifier: ^8.16.0
+ version: 8.16.0
'@types/react':
specifier: ^19.2.10
version: 19.2.10
@@ -72,6 +87,9 @@ importers:
axe-core:
specifier: ^4.11.1
version: 4.11.1
+ drizzle-kit:
+ specifier: ^0.31.9
+ version: 0.31.9
eslint:
specifier: ^9.39.2
version: 9.39.2(jiti@2.6.1)
@@ -175,6 +193,30 @@ packages:
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
engines: {node: '>=6.9.0'}
+ '@better-auth/core@1.4.18':
+ resolution: {integrity: sha512-q+awYgC7nkLEBdx2sW0iJjkzgSHlIxGnOpsN1r/O1+a4m7osJNHtfK2mKJSL1I+GfNyIlxJF8WvD/NLuYMpmcg==}
+ peerDependencies:
+ '@better-auth/utils': 0.3.0
+ '@better-fetch/fetch': 1.1.21
+ better-call: 1.1.8
+ jose: ^6.1.0
+ kysely: ^0.28.5
+ nanostores: ^1.0.1
+
+ '@better-auth/telemetry@1.4.18':
+ resolution: {integrity: sha512-e5rDF8S4j3Um/0LIVATL2in9dL4lfO2fr2v1Wio4qTMRbfxqnUDTa+6SZtwdeJrbc4O+a3c+IyIpjG9Q/6GpfQ==}
+ peerDependencies:
+ '@better-auth/core': 1.4.18
+
+ '@better-auth/utils@0.3.0':
+ resolution: {integrity: sha512-W+Adw6ZA6mgvnSnhOki270rwJ42t4XzSK6YWGF//BbVXL6SwCLWfyzBc1lN2m/4RM28KubdBKQ4X5VMoLRNPQw==}
+
+ '@better-fetch/fetch@1.1.21':
+ resolution: {integrity: sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==}
+
+ '@drizzle-team/brocli@0.10.2':
+ resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==}
+
'@emnapi/core@1.8.1':
resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==}
@@ -184,6 +226,302 @@ packages:
'@emnapi/wasi-threads@1.1.0':
resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==}
+ '@esbuild-kit/core-utils@3.3.2':
+ resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==}
+ deprecated: 'Merged into tsx: https://tsx.is'
+
+ '@esbuild-kit/esm-loader@2.6.5':
+ resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==}
+ deprecated: 'Merged into tsx: https://tsx.is'
+
+ '@esbuild/aix-ppc64@0.25.12':
+ resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [aix]
+
+ '@esbuild/android-arm64@0.18.20':
+ resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [android]
+
+ '@esbuild/android-arm64@0.25.12':
+ resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [android]
+
+ '@esbuild/android-arm@0.18.20':
+ resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==}
+ engines: {node: '>=12'}
+ cpu: [arm]
+ os: [android]
+
+ '@esbuild/android-arm@0.25.12':
+ resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [android]
+
+ '@esbuild/android-x64@0.18.20':
+ resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [android]
+
+ '@esbuild/android-x64@0.25.12':
+ resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [android]
+
+ '@esbuild/darwin-arm64@0.18.20':
+ resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@esbuild/darwin-arm64@0.25.12':
+ resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@esbuild/darwin-x64@0.18.20':
+ resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@esbuild/darwin-x64@0.25.12':
+ resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@esbuild/freebsd-arm64@0.18.20':
+ resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@esbuild/freebsd-arm64@0.25.12':
+ resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@esbuild/freebsd-x64@0.18.20':
+ resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@esbuild/freebsd-x64@0.25.12':
+ resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@esbuild/linux-arm64@0.18.20':
+ resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@esbuild/linux-arm64@0.25.12':
+ resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@esbuild/linux-arm@0.18.20':
+ resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==}
+ engines: {node: '>=12'}
+ cpu: [arm]
+ os: [linux]
+
+ '@esbuild/linux-arm@0.25.12':
+ resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [linux]
+
+ '@esbuild/linux-ia32@0.18.20':
+ resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==}
+ engines: {node: '>=12'}
+ cpu: [ia32]
+ os: [linux]
+
+ '@esbuild/linux-ia32@0.25.12':
+ resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [linux]
+
+ '@esbuild/linux-loong64@0.18.20':
+ resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==}
+ engines: {node: '>=12'}
+ cpu: [loong64]
+ os: [linux]
+
+ '@esbuild/linux-loong64@0.25.12':
+ resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==}
+ engines: {node: '>=18'}
+ cpu: [loong64]
+ os: [linux]
+
+ '@esbuild/linux-mips64el@0.18.20':
+ resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==}
+ engines: {node: '>=12'}
+ cpu: [mips64el]
+ os: [linux]
+
+ '@esbuild/linux-mips64el@0.25.12':
+ resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==}
+ engines: {node: '>=18'}
+ cpu: [mips64el]
+ os: [linux]
+
+ '@esbuild/linux-ppc64@0.18.20':
+ resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==}
+ engines: {node: '>=12'}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@esbuild/linux-ppc64@0.25.12':
+ resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@esbuild/linux-riscv64@0.18.20':
+ resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==}
+ engines: {node: '>=12'}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@esbuild/linux-riscv64@0.25.12':
+ resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==}
+ engines: {node: '>=18'}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@esbuild/linux-s390x@0.18.20':
+ resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==}
+ engines: {node: '>=12'}
+ cpu: [s390x]
+ os: [linux]
+
+ '@esbuild/linux-s390x@0.25.12':
+ resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==}
+ engines: {node: '>=18'}
+ cpu: [s390x]
+ os: [linux]
+
+ '@esbuild/linux-x64@0.18.20':
+ resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [linux]
+
+ '@esbuild/linux-x64@0.25.12':
+ resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [linux]
+
+ '@esbuild/netbsd-arm64@0.25.12':
+ resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [netbsd]
+
+ '@esbuild/netbsd-x64@0.18.20':
+ resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [netbsd]
+
+ '@esbuild/netbsd-x64@0.25.12':
+ resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [netbsd]
+
+ '@esbuild/openbsd-arm64@0.25.12':
+ resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [openbsd]
+
+ '@esbuild/openbsd-x64@0.18.20':
+ resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [openbsd]
+
+ '@esbuild/openbsd-x64@0.25.12':
+ resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [openbsd]
+
+ '@esbuild/openharmony-arm64@0.25.12':
+ resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@esbuild/sunos-x64@0.18.20':
+ resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [sunos]
+
+ '@esbuild/sunos-x64@0.25.12':
+ resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [sunos]
+
+ '@esbuild/win32-arm64@0.18.20':
+ resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@esbuild/win32-arm64@0.25.12':
+ resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@esbuild/win32-ia32@0.18.20':
+ resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==}
+ engines: {node: '>=12'}
+ cpu: [ia32]
+ os: [win32]
+
+ '@esbuild/win32-ia32@0.25.12':
+ resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [win32]
+
+ '@esbuild/win32-x64@0.18.20':
+ resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [win32]
+
+ '@esbuild/win32-x64@0.25.12':
+ resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [win32]
+
'@eslint-community/eslint-utils@4.9.1':
resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
@@ -487,6 +825,14 @@ packages:
cpu: [x64]
os: [win32]
+ '@noble/ciphers@2.1.1':
+ resolution: {integrity: sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==}
+ engines: {node: '>= 20.19.0'}
+
+ '@noble/hashes@2.0.1':
+ resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==}
+ engines: {node: '>= 20.19.0'}
+
'@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'}
@@ -887,6 +1233,9 @@ packages:
'@schummar/icu-type-parser@1.21.5':
resolution: {integrity: sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==}
+ '@standard-schema/spec@1.1.0':
+ resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
+
'@swc/core-darwin-arm64@1.15.11':
resolution: {integrity: sha512-QoIupRWVH8AF1TgxYyeA5nS18dtqMuxNwchjBIwJo3RdwLEFiJq6onOx9JAxHtuPwUkIVuU2Xbp+jCJ7Vzmgtg==}
engines: {node: '>=10'}
@@ -1068,6 +1417,9 @@ packages:
'@types/node@25.1.0':
resolution: {integrity: sha512-t7frlewr6+cbx+9Ohpl0NOTKXZNV9xHRmNOvql47BFJKcEG1CxtxlPEEe+gR9uhVWM4DwhnvTF110mIL4yP9RA==}
+ '@types/pg@8.16.0':
+ resolution: {integrity: sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==}
+
'@types/react-dom@19.2.3':
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
peerDependencies:
@@ -1354,6 +1706,76 @@ packages:
resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==}
hasBin: true
+ better-auth@1.4.18:
+ resolution: {integrity: sha512-bnyifLWBPcYVltH3RhS7CM62MoelEqC6Q+GnZwfiDWNfepXoQZBjEvn4urcERC7NTKgKq5zNBM8rvPvRBa6xcg==}
+ peerDependencies:
+ '@lynx-js/react': '*'
+ '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0
+ '@sveltejs/kit': ^2.0.0
+ '@tanstack/react-start': ^1.0.0
+ '@tanstack/solid-start': ^1.0.0
+ better-sqlite3: ^12.0.0
+ drizzle-kit: '>=0.31.4'
+ drizzle-orm: '>=0.41.0'
+ mongodb: ^6.0.0 || ^7.0.0
+ mysql2: ^3.0.0
+ next: ^14.0.0 || ^15.0.0 || ^16.0.0
+ pg: ^8.0.0
+ prisma: ^5.0.0 || ^6.0.0 || ^7.0.0
+ react: ^18.0.0 || ^19.0.0
+ react-dom: ^18.0.0 || ^19.0.0
+ solid-js: ^1.0.0
+ svelte: ^4.0.0 || ^5.0.0
+ vitest: ^2.0.0 || ^3.0.0 || ^4.0.0
+ vue: ^3.0.0
+ peerDependenciesMeta:
+ '@lynx-js/react':
+ optional: true
+ '@prisma/client':
+ optional: true
+ '@sveltejs/kit':
+ optional: true
+ '@tanstack/react-start':
+ optional: true
+ '@tanstack/solid-start':
+ optional: true
+ better-sqlite3:
+ optional: true
+ drizzle-kit:
+ optional: true
+ drizzle-orm:
+ optional: true
+ mongodb:
+ optional: true
+ mysql2:
+ optional: true
+ next:
+ optional: true
+ pg:
+ optional: true
+ prisma:
+ optional: true
+ react:
+ optional: true
+ react-dom:
+ optional: true
+ solid-js:
+ optional: true
+ svelte:
+ optional: true
+ vitest:
+ optional: true
+ vue:
+ optional: true
+
+ better-call@1.1.8:
+ resolution: {integrity: sha512-XMQ2rs6FNXasGNfMjzbyroSwKwYbZ/T3IxruSS6U2MJRsSYh3wYtG3o6H00ZlKZ/C/UPOAD97tqgQJNsxyeTXw==}
+ peerDependencies:
+ zod: ^4.0.0
+ peerDependenciesMeta:
+ zod:
+ optional: true
+
brace-expansion@1.1.12:
resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
@@ -1369,6 +1791,9 @@ packages:
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
+ buffer-from@1.1.2:
+ resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
+
call-bind-apply-helpers@1.0.2:
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
engines: {node: '>= 0.4'}
@@ -1476,6 +1901,9 @@ packages:
resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
engines: {node: '>= 0.4'}
+ defu@6.1.4:
+ resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==}
+
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
@@ -1487,6 +1915,102 @@ packages:
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
engines: {node: '>=0.10.0'}
+ drizzle-kit@0.31.9:
+ resolution: {integrity: sha512-GViD3IgsXn7trFyBUUHyTFBpH/FsHTxYJ66qdbVggxef4UBPHRYxQaRzYLTuekYnk9i5FIEL9pbBIwMqX/Uwrg==}
+ hasBin: true
+
+ drizzle-orm@0.45.1:
+ resolution: {integrity: sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA==}
+ peerDependencies:
+ '@aws-sdk/client-rds-data': '>=3'
+ '@cloudflare/workers-types': '>=4'
+ '@electric-sql/pglite': '>=0.2.0'
+ '@libsql/client': '>=0.10.0'
+ '@libsql/client-wasm': '>=0.10.0'
+ '@neondatabase/serverless': '>=0.10.0'
+ '@op-engineering/op-sqlite': '>=2'
+ '@opentelemetry/api': ^1.4.1
+ '@planetscale/database': '>=1.13'
+ '@prisma/client': '*'
+ '@tidbcloud/serverless': '*'
+ '@types/better-sqlite3': '*'
+ '@types/pg': '*'
+ '@types/sql.js': '*'
+ '@upstash/redis': '>=1.34.7'
+ '@vercel/postgres': '>=0.8.0'
+ '@xata.io/client': '*'
+ better-sqlite3: '>=7'
+ bun-types: '*'
+ expo-sqlite: '>=14.0.0'
+ gel: '>=2'
+ knex: '*'
+ kysely: '*'
+ mysql2: '>=2'
+ pg: '>=8'
+ postgres: '>=3'
+ prisma: '*'
+ sql.js: '>=1'
+ sqlite3: '>=5'
+ peerDependenciesMeta:
+ '@aws-sdk/client-rds-data':
+ optional: true
+ '@cloudflare/workers-types':
+ optional: true
+ '@electric-sql/pglite':
+ optional: true
+ '@libsql/client':
+ optional: true
+ '@libsql/client-wasm':
+ optional: true
+ '@neondatabase/serverless':
+ optional: true
+ '@op-engineering/op-sqlite':
+ optional: true
+ '@opentelemetry/api':
+ optional: true
+ '@planetscale/database':
+ optional: true
+ '@prisma/client':
+ optional: true
+ '@tidbcloud/serverless':
+ optional: true
+ '@types/better-sqlite3':
+ optional: true
+ '@types/pg':
+ optional: true
+ '@types/sql.js':
+ optional: true
+ '@upstash/redis':
+ optional: true
+ '@vercel/postgres':
+ optional: true
+ '@xata.io/client':
+ optional: true
+ better-sqlite3:
+ optional: true
+ bun-types:
+ optional: true
+ expo-sqlite:
+ optional: true
+ gel:
+ optional: true
+ knex:
+ optional: true
+ kysely:
+ optional: true
+ mysql2:
+ optional: true
+ pg:
+ optional: true
+ postgres:
+ optional: true
+ prisma:
+ optional: true
+ sql.js:
+ optional: true
+ sqlite3:
+ optional: true
+
dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
@@ -1540,6 +2064,21 @@ packages:
resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==}
engines: {node: '>= 0.4'}
+ esbuild-register@3.6.0:
+ resolution: {integrity: sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==}
+ peerDependencies:
+ esbuild: '>=0.12 <1'
+
+ esbuild@0.18.20:
+ resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==}
+ engines: {node: '>=12'}
+ hasBin: true
+
+ esbuild@0.25.12:
+ resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==}
+ engines: {node: '>=18'}
+ hasBin: true
+
escalade@3.2.0:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'}
@@ -1987,6 +2526,9 @@ packages:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true
+ jose@6.1.3:
+ resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==}
+
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -2024,6 +2566,10 @@ packages:
keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
+ kysely@0.28.11:
+ resolution: {integrity: sha512-zpGIFg0HuoC893rIjYX1BETkVWdDnzTzF5e0kWXJFg5lE0k1/LfNWBejrcnOFu8Q2Rfq/hTDTU7XLUM8QOrpzg==}
+ engines: {node: '>=20.0.0'}
+
language-subtag-registry@0.3.23:
resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==}
@@ -2184,6 +2730,15 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
+ nanoid@5.1.6:
+ resolution: {integrity: sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==}
+ engines: {node: ^18 || >=20}
+ hasBin: true
+
+ nanostores@1.1.0:
+ resolution: {integrity: sha512-yJBmDJr18xy47dbNVlHcgdPrulSn1nhSE6Ns9vTG+Nx9VPT6iV1MD6aQFp/t52zpf82FhLLTXAXr30NuCnxvwA==}
+ engines: {node: ^20.0.0 || >=22.0.0}
+
napi-postinstall@0.3.4:
resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==}
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
@@ -2309,6 +2864,40 @@ packages:
path-parse@1.0.7:
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
+ pg-cloudflare@1.3.0:
+ resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==}
+
+ pg-connection-string@2.11.0:
+ resolution: {integrity: sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==}
+
+ pg-int8@1.0.1:
+ resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==}
+ engines: {node: '>=4.0.0'}
+
+ pg-pool@3.11.0:
+ resolution: {integrity: sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==}
+ peerDependencies:
+ pg: '>=8.0'
+
+ pg-protocol@1.11.0:
+ resolution: {integrity: sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==}
+
+ pg-types@2.2.0:
+ resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==}
+ engines: {node: '>=4'}
+
+ pg@8.18.0:
+ resolution: {integrity: sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==}
+ engines: {node: '>= 16.0.0'}
+ peerDependencies:
+ pg-native: '>=3.0.1'
+ peerDependenciesMeta:
+ pg-native:
+ optional: true
+
+ pgpass@1.0.5:
+ resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==}
+
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -2350,6 +2939,22 @@ packages:
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
engines: {node: ^10 || ^12 || >=14}
+ postgres-array@2.0.0:
+ resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==}
+ engines: {node: '>=4'}
+
+ postgres-bytea@1.0.1:
+ resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==}
+ engines: {node: '>=0.10.0'}
+
+ postgres-date@1.0.7:
+ resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==}
+ engines: {node: '>=0.10.0'}
+
+ postgres-interval@1.2.0:
+ resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==}
+ engines: {node: '>=0.10.0'}
+
prelude-ls@1.2.1:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
@@ -2446,6 +3051,9 @@ packages:
rfdc@1.4.1:
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
+ rou3@0.7.12:
+ resolution: {integrity: sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==}
+
run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
@@ -2473,6 +3081,9 @@ packages:
engines: {node: '>=10'}
hasBin: true
+ set-cookie-parser@2.7.2:
+ resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
+
set-function-length@1.2.2:
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
engines: {node: '>= 0.4'}
@@ -2525,6 +3136,17 @@ packages:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
+ source-map-support@0.5.21:
+ resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==}
+
+ source-map@0.6.1:
+ resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
+ engines: {node: '>=0.10.0'}
+
+ split2@4.2.0:
+ resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
+ engines: {node: '>= 10.x'}
+
stable-hash@0.0.5:
resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
@@ -2732,6 +3354,10 @@ packages:
resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==}
engines: {node: '>=18'}
+ xtend@4.0.2:
+ resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
+ engines: {node: '>=0.4'}
+
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
@@ -2880,6 +3506,29 @@ snapshots:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
+ '@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)':
+ dependencies:
+ '@better-auth/utils': 0.3.0
+ '@better-fetch/fetch': 1.1.21
+ '@standard-schema/spec': 1.1.0
+ better-call: 1.1.8(zod@4.3.6)
+ jose: 6.1.3
+ kysely: 0.28.11
+ nanostores: 1.1.0
+ zod: 4.3.6
+
+ '@better-auth/telemetry@1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0))':
+ dependencies:
+ '@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)
+ '@better-auth/utils': 0.3.0
+ '@better-fetch/fetch': 1.1.21
+
+ '@better-auth/utils@0.3.0': {}
+
+ '@better-fetch/fetch@1.1.21': {}
+
+ '@drizzle-team/brocli@0.10.2': {}
+
'@emnapi/core@1.8.1':
dependencies:
'@emnapi/wasi-threads': 1.1.0
@@ -2896,6 +3545,160 @@ snapshots:
tslib: 2.8.1
optional: true
+ '@esbuild-kit/core-utils@3.3.2':
+ dependencies:
+ esbuild: 0.18.20
+ source-map-support: 0.5.21
+
+ '@esbuild-kit/esm-loader@2.6.5':
+ dependencies:
+ '@esbuild-kit/core-utils': 3.3.2
+ get-tsconfig: 4.13.1
+
+ '@esbuild/aix-ppc64@0.25.12':
+ optional: true
+
+ '@esbuild/android-arm64@0.18.20':
+ optional: true
+
+ '@esbuild/android-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/android-arm@0.18.20':
+ optional: true
+
+ '@esbuild/android-arm@0.25.12':
+ optional: true
+
+ '@esbuild/android-x64@0.18.20':
+ optional: true
+
+ '@esbuild/android-x64@0.25.12':
+ optional: true
+
+ '@esbuild/darwin-arm64@0.18.20':
+ optional: true
+
+ '@esbuild/darwin-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/darwin-x64@0.18.20':
+ optional: true
+
+ '@esbuild/darwin-x64@0.25.12':
+ optional: true
+
+ '@esbuild/freebsd-arm64@0.18.20':
+ optional: true
+
+ '@esbuild/freebsd-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/freebsd-x64@0.18.20':
+ optional: true
+
+ '@esbuild/freebsd-x64@0.25.12':
+ optional: true
+
+ '@esbuild/linux-arm64@0.18.20':
+ optional: true
+
+ '@esbuild/linux-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/linux-arm@0.18.20':
+ optional: true
+
+ '@esbuild/linux-arm@0.25.12':
+ optional: true
+
+ '@esbuild/linux-ia32@0.18.20':
+ optional: true
+
+ '@esbuild/linux-ia32@0.25.12':
+ optional: true
+
+ '@esbuild/linux-loong64@0.18.20':
+ optional: true
+
+ '@esbuild/linux-loong64@0.25.12':
+ optional: true
+
+ '@esbuild/linux-mips64el@0.18.20':
+ optional: true
+
+ '@esbuild/linux-mips64el@0.25.12':
+ optional: true
+
+ '@esbuild/linux-ppc64@0.18.20':
+ optional: true
+
+ '@esbuild/linux-ppc64@0.25.12':
+ optional: true
+
+ '@esbuild/linux-riscv64@0.18.20':
+ optional: true
+
+ '@esbuild/linux-riscv64@0.25.12':
+ optional: true
+
+ '@esbuild/linux-s390x@0.18.20':
+ optional: true
+
+ '@esbuild/linux-s390x@0.25.12':
+ optional: true
+
+ '@esbuild/linux-x64@0.18.20':
+ optional: true
+
+ '@esbuild/linux-x64@0.25.12':
+ optional: true
+
+ '@esbuild/netbsd-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/netbsd-x64@0.18.20':
+ optional: true
+
+ '@esbuild/netbsd-x64@0.25.12':
+ optional: true
+
+ '@esbuild/openbsd-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/openbsd-x64@0.18.20':
+ optional: true
+
+ '@esbuild/openbsd-x64@0.25.12':
+ optional: true
+
+ '@esbuild/openharmony-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/sunos-x64@0.18.20':
+ optional: true
+
+ '@esbuild/sunos-x64@0.25.12':
+ optional: true
+
+ '@esbuild/win32-arm64@0.18.20':
+ optional: true
+
+ '@esbuild/win32-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/win32-ia32@0.18.20':
+ optional: true
+
+ '@esbuild/win32-ia32@0.25.12':
+ optional: true
+
+ '@esbuild/win32-x64@0.18.20':
+ optional: true
+
+ '@esbuild/win32-x64@0.25.12':
+ optional: true
+
'@eslint-community/eslint-utils@4.9.1(eslint@9.39.2(jiti@2.6.1))':
dependencies:
eslint: 9.39.2(jiti@2.6.1)
@@ -3158,6 +3961,10 @@ snapshots:
'@next/swc-win32-x64-msvc@16.1.6':
optional: true
+ '@noble/ciphers@2.1.1': {}
+
+ '@noble/hashes@2.0.1': {}
+
'@nodelib/fs.scandir@2.1.5':
dependencies:
'@nodelib/fs.stat': 2.0.5
@@ -3509,6 +4316,8 @@ snapshots:
'@schummar/icu-type-parser@1.21.5': {}
+ '@standard-schema/spec@1.1.0': {}
+
'@swc/core-darwin-arm64@1.15.11':
optional: true
@@ -3649,6 +4458,12 @@ snapshots:
dependencies:
undici-types: 7.16.0
+ '@types/pg@8.16.0':
+ dependencies:
+ '@types/node': 25.1.0
+ pg-protocol: 1.11.0
+ pg-types: 2.2.0
+
'@types/react-dom@19.2.3(@types/react@19.2.10)':
dependencies:
'@types/react': 19.2.10
@@ -3928,6 +4743,37 @@ snapshots:
baseline-browser-mapping@2.9.19: {}
+ better-auth@1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.45.1(@types/pg@8.16.0)(kysely@0.28.11)(pg@8.18.0))(next@16.1.6(@babel/core@7.29.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(pg@8.18.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
+ dependencies:
+ '@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)
+ '@better-auth/telemetry': 1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0))
+ '@better-auth/utils': 0.3.0
+ '@better-fetch/fetch': 1.1.21
+ '@noble/ciphers': 2.1.1
+ '@noble/hashes': 2.0.1
+ better-call: 1.1.8(zod@4.3.6)
+ defu: 6.1.4
+ jose: 6.1.3
+ kysely: 0.28.11
+ nanostores: 1.1.0
+ zod: 4.3.6
+ optionalDependencies:
+ drizzle-kit: 0.31.9
+ drizzle-orm: 0.45.1(@types/pg@8.16.0)(kysely@0.28.11)(pg@8.18.0)
+ next: 16.1.6(@babel/core@7.29.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ pg: 8.18.0
+ react: 19.2.4
+ react-dom: 19.2.4(react@19.2.4)
+
+ better-call@1.1.8(zod@4.3.6):
+ dependencies:
+ '@better-auth/utils': 0.3.0
+ '@better-fetch/fetch': 1.1.21
+ rou3: 0.7.12
+ set-cookie-parser: 2.7.2
+ optionalDependencies:
+ zod: 4.3.6
+
brace-expansion@1.1.12:
dependencies:
balanced-match: 1.0.2
@@ -3949,6 +4795,8 @@ snapshots:
node-releases: 2.0.27
update-browserslist-db: 1.2.3(browserslist@4.28.1)
+ buffer-from@1.1.2: {}
+
call-bind-apply-helpers@1.0.2:
dependencies:
es-errors: 1.3.0
@@ -4052,6 +4900,8 @@ snapshots:
has-property-descriptors: 1.0.2
object-keys: 1.1.1
+ defu@6.1.4: {}
+
detect-libc@2.1.2: {}
detect-node-es@1.1.0: {}
@@ -4060,6 +4910,21 @@ snapshots:
dependencies:
esutils: 2.0.3
+ drizzle-kit@0.31.9:
+ dependencies:
+ '@drizzle-team/brocli': 0.10.2
+ '@esbuild-kit/esm-loader': 2.6.5
+ esbuild: 0.25.12
+ esbuild-register: 3.6.0(esbuild@0.25.12)
+ transitivePeerDependencies:
+ - supports-color
+
+ drizzle-orm@0.45.1(@types/pg@8.16.0)(kysely@0.28.11)(pg@8.18.0):
+ optionalDependencies:
+ '@types/pg': 8.16.0
+ kysely: 0.28.11
+ pg: 8.18.0
+
dunder-proto@1.0.1:
dependencies:
call-bind-apply-helpers: 1.0.2
@@ -4180,6 +5045,67 @@ snapshots:
is-date-object: 1.1.0
is-symbol: 1.1.1
+ esbuild-register@3.6.0(esbuild@0.25.12):
+ dependencies:
+ debug: 4.4.3
+ esbuild: 0.25.12
+ transitivePeerDependencies:
+ - supports-color
+
+ esbuild@0.18.20:
+ optionalDependencies:
+ '@esbuild/android-arm': 0.18.20
+ '@esbuild/android-arm64': 0.18.20
+ '@esbuild/android-x64': 0.18.20
+ '@esbuild/darwin-arm64': 0.18.20
+ '@esbuild/darwin-x64': 0.18.20
+ '@esbuild/freebsd-arm64': 0.18.20
+ '@esbuild/freebsd-x64': 0.18.20
+ '@esbuild/linux-arm': 0.18.20
+ '@esbuild/linux-arm64': 0.18.20
+ '@esbuild/linux-ia32': 0.18.20
+ '@esbuild/linux-loong64': 0.18.20
+ '@esbuild/linux-mips64el': 0.18.20
+ '@esbuild/linux-ppc64': 0.18.20
+ '@esbuild/linux-riscv64': 0.18.20
+ '@esbuild/linux-s390x': 0.18.20
+ '@esbuild/linux-x64': 0.18.20
+ '@esbuild/netbsd-x64': 0.18.20
+ '@esbuild/openbsd-x64': 0.18.20
+ '@esbuild/sunos-x64': 0.18.20
+ '@esbuild/win32-arm64': 0.18.20
+ '@esbuild/win32-ia32': 0.18.20
+ '@esbuild/win32-x64': 0.18.20
+
+ esbuild@0.25.12:
+ optionalDependencies:
+ '@esbuild/aix-ppc64': 0.25.12
+ '@esbuild/android-arm': 0.25.12
+ '@esbuild/android-arm64': 0.25.12
+ '@esbuild/android-x64': 0.25.12
+ '@esbuild/darwin-arm64': 0.25.12
+ '@esbuild/darwin-x64': 0.25.12
+ '@esbuild/freebsd-arm64': 0.25.12
+ '@esbuild/freebsd-x64': 0.25.12
+ '@esbuild/linux-arm': 0.25.12
+ '@esbuild/linux-arm64': 0.25.12
+ '@esbuild/linux-ia32': 0.25.12
+ '@esbuild/linux-loong64': 0.25.12
+ '@esbuild/linux-mips64el': 0.25.12
+ '@esbuild/linux-ppc64': 0.25.12
+ '@esbuild/linux-riscv64': 0.25.12
+ '@esbuild/linux-s390x': 0.25.12
+ '@esbuild/linux-x64': 0.25.12
+ '@esbuild/netbsd-arm64': 0.25.12
+ '@esbuild/netbsd-x64': 0.25.12
+ '@esbuild/openbsd-arm64': 0.25.12
+ '@esbuild/openbsd-x64': 0.25.12
+ '@esbuild/openharmony-arm64': 0.25.12
+ '@esbuild/sunos-x64': 0.25.12
+ '@esbuild/win32-arm64': 0.25.12
+ '@esbuild/win32-ia32': 0.25.12
+ '@esbuild/win32-x64': 0.25.12
+
escalade@3.2.0: {}
escape-string-regexp@4.0.0: {}
@@ -4706,6 +5632,8 @@ snapshots:
jiti@2.6.1: {}
+ jose@6.1.3: {}
+
js-tokens@4.0.0: {}
js-yaml@4.1.1:
@@ -4737,6 +5665,8 @@ snapshots:
dependencies:
json-buffer: 3.0.1
+ kysely@0.28.11: {}
+
language-subtag-registry@0.3.23: {}
language-tags@1.0.9:
@@ -4879,6 +5809,10 @@ snapshots:
nanoid@3.3.11: {}
+ nanoid@5.1.6: {}
+
+ nanostores@1.1.0: {}
+
napi-postinstall@0.3.4: {}
natural-compare@1.4.0: {}
@@ -5017,6 +5951,41 @@ snapshots:
path-parse@1.0.7: {}
+ pg-cloudflare@1.3.0:
+ optional: true
+
+ pg-connection-string@2.11.0: {}
+
+ pg-int8@1.0.1: {}
+
+ pg-pool@3.11.0(pg@8.18.0):
+ dependencies:
+ pg: 8.18.0
+
+ pg-protocol@1.11.0: {}
+
+ pg-types@2.2.0:
+ dependencies:
+ pg-int8: 1.0.1
+ postgres-array: 2.0.0
+ postgres-bytea: 1.0.1
+ postgres-date: 1.0.7
+ postgres-interval: 1.2.0
+
+ pg@8.18.0:
+ dependencies:
+ pg-connection-string: 2.11.0
+ pg-pool: 3.11.0(pg@8.18.0)
+ pg-protocol: 1.11.0
+ pg-types: 2.2.0
+ pgpass: 1.0.5
+ optionalDependencies:
+ pg-cloudflare: 1.3.0
+
+ pgpass@1.0.5:
+ dependencies:
+ split2: 4.2.0
+
picocolors@1.1.1: {}
picomatch@2.3.1: {}
@@ -5049,6 +6018,16 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
+ postgres-array@2.0.0: {}
+
+ postgres-bytea@1.0.1: {}
+
+ postgres-date@1.0.7: {}
+
+ postgres-interval@1.2.0:
+ dependencies:
+ xtend: 4.0.2
+
prelude-ls@1.2.1: {}
prettier@3.8.1: {}
@@ -5144,6 +6123,8 @@ snapshots:
rfdc@1.4.1: {}
+ rou3@0.7.12: {}
+
run-parallel@1.2.0:
dependencies:
queue-microtask: 1.2.3
@@ -5173,6 +6154,8 @@ snapshots:
semver@7.7.3: {}
+ set-cookie-parser@2.7.2: {}
+
set-function-length@1.2.2:
dependencies:
define-data-property: 1.1.4
@@ -5270,6 +6253,15 @@ snapshots:
source-map-js@1.2.1: {}
+ source-map-support@0.5.21:
+ dependencies:
+ buffer-from: 1.1.2
+ source-map: 0.6.1
+
+ source-map@0.6.1: {}
+
+ split2@4.2.0: {}
+
stable-hash@0.0.5: {}
stop-iteration-iterator@1.1.0:
@@ -5556,6 +6548,8 @@ snapshots:
string-width: 7.2.0
strip-ansi: 7.1.2
+ xtend@4.0.2: {}
+
yallist@3.1.1: {}
yaml@2.8.2: {}
diff --git a/proxy.ts b/proxy.ts
index 1239afc..ce12626 100644
--- a/proxy.ts
+++ b/proxy.ts
@@ -1,7 +1,48 @@
+import { type NextRequest, NextResponse } from 'next/server'
import createMiddleware from 'next-intl/middleware'
import { routing } from './i18n/routing'
-export default createMiddleware(routing)
+const intlMiddleware = createMiddleware(routing)
+
+/** Route paths (without locale prefix) that require authentication. */
+const protectedPaths = ['/mypage']
+
+/**
+ * Check if a pathname (after stripping locale prefix) matches a protected route.
+ *
+ * @param pathname - Full request pathname e.g. "/en/mypage"
+ * @returns true if the route requires auth
+ *
+ * @example
+ * isProtectedRoute('/en/mypage') // => true
+ * isProtectedRoute('/ja/') // => false
+ */
+function isProtectedRoute(pathname: string): boolean {
+ const withoutLocale = pathname.replace(/^\/(en|ja)/, '') || '/'
+ return protectedPaths.some((p) => withoutLocale.startsWith(p))
+}
+
+/**
+ * Next.js 16 proxy (middleware) combining auth protection with i18n routing.
+ * - Protected routes redirect to sign-in if no session cookie present.
+ * - All non-API routes pass through next-intl middleware.
+ */
+export default function proxy(request: NextRequest) {
+ const { pathname } = request.nextUrl
+
+ // Lightweight cookie check for protected routes (no DB call)
+ if (isProtectedRoute(pathname)) {
+ const sessionCookie =
+ request.cookies.get('better-auth.session_token') ??
+ request.cookies.get('__Secure-better-auth.session_token')
+ if (!sessionCookie) {
+ const locale = pathname.match(/^\/(en|ja)/)?.[1] ?? 'en'
+ return NextResponse.redirect(new URL(`/${locale}/sign-in`, request.url))
+ }
+ }
+
+ return intlMiddleware(request)
+}
export const config = {
// Match all pathnames except for
diff --git a/tailwind.config.ts b/tailwind.config.ts
index d51a3e5..03451d9 100644
--- a/tailwind.config.ts
+++ b/tailwind.config.ts
@@ -24,6 +24,14 @@ const config: Config = {
blue: 'var(--color-accent-blue)',
amber: 'var(--color-accent-amber)',
},
+ heatmap: {
+ empty: 'var(--heatmap-empty)',
+ 'level-1': 'var(--heatmap-level-1)',
+ 'level-2': 'var(--heatmap-level-2)',
+ 'level-3': 'var(--heatmap-level-3)',
+ 'level-4': 'var(--heatmap-level-4)',
+ selected: 'var(--heatmap-selected)',
+ },
},
boxShadow: {
soft: 'var(--shadow-soft)',