feat(release): prepare 1.1.0 candidate

This commit is contained in:
Yun Chan 2026-08-29 18:33:45 +09:00
parent 5a34f66981
commit 5205dcdfa9
736 changed files with 115667 additions and 12203 deletions

View file

@ -1,182 +1,281 @@
// apps/web/src/app/dashboard/page.tsx
// 대시보드 — 요약 카드 4개 + 최근 회의
'use client'
import Link from 'next/link'
import { Box, Grid, Stack } from '@mui/material'
import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds'
import { d3roPalette, typoSx } from "@d3ro/ui/theme"
import { getSupabaseServerClient } from '@/lib/supabase-server'
import { useCallback, useEffect, useState } from 'react'
import { Alert, Box, Button, CircularProgress, Typography } from '@mui/material'
import {
MeetingsTrendChart,
type TrendPoint
} from '@/components/dashboard/meetings-trend-chart'
ArrowUpRight,
BookOpen,
CalendarDays,
Clock3,
Database,
Flame,
Mic,
RefreshCw,
ShieldCheck,
Star
} from 'lucide-react'
import type { D3roSupabaseClient } from '@d3ro/api-client'
import {
DoubleBezelCard,
MetalCard,
PhosphorText,
PhysicalButton,
StatRing,
TactileBadge,
TiltCard
} from '@d3ro/ui/components/ds'
import { d3roFontMono, d3roFontSans, d3roPalette } from '@d3ro/ui/theme'
import { useAuth } from '@/components/providers/auth-provider'
import { getSupabaseBrowserClient } from '@/lib/supabase-browser'
import {
DashboardClientError,
loadDashboardSnapshot,
type DashboardSnapshot
} from '@/lib/dashboard-client'
interface Stat {
label: string
value: string
hint?: string
}
const TREND_DAYS = 14
function buildEmptyTrend(days: number): TrendPoint[] {
const out: TrendPoint[] = []
const now = new Date()
for (let i = days - 1; i >= 0; i -= 1) {
const d = new Date(now)
d.setDate(now.getDate() - i)
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
out.push({ date: `${m}/${day}`, meetings: 0, documents: 0 })
function dashboardMessage(error: unknown): string {
if (error instanceof DashboardClientError) {
if (error.code === 'auth') return '세션이 만료되었습니다. 다시 로그인해 주세요.'
if (error.code === 'network') return '네트워크 연결을 확인한 뒤 다시 시도해 주세요.'
if (error.code === 'not-initialized') return '계정 구독 정보가 아직 초기화되지 않았습니다.'
if (error.code === 'invalid-response') return '서버 데이터 형식이 올바르지 않습니다.'
}
return out
return '대시보드 데이터를 불러오지 못했습니다. 잠시 후 다시 시도해 주세요.'
}
function bumpTrend(points: TrendPoint[], isoTimestamp: string, key: 'meetings' | 'documents'): void {
const created = new Date(isoTimestamp)
const m = String(created.getMonth() + 1).padStart(2, '0')
const d = String(created.getDate()).padStart(2, '0')
const label = `${m}/${d}`
const point = points.find((p) => p.date === label)
if (point) point[key] += 1
function tierLabel(tier: DashboardSnapshot['subscription']['tier']): string {
if (tier === 'pro_plus') return 'PRO+'
return tier.toUpperCase()
}
async function loadDashboardData(): Promise<{
stats: Stat[]
recentMeetings: Array<{ id: string; title: string; started_at: string }>
trend: TrendPoint[]
}> {
const trend = buildEmptyTrend(TREND_DAYS)
try {
const supabase = await getSupabaseServerClient()
const since = new Date()
since.setDate(since.getDate() - (TREND_DAYS - 1))
since.setHours(0, 0, 0, 0)
const sinceIso = since.toISOString()
const [
{ count: meetingCount },
{ data: recent },
{ data: meetingsTrendRows },
{ data: documentsTrendRows }
] = await Promise.all([
supabase.from('meetings').select('*', { count: 'exact', head: true }),
supabase
.from('meetings')
.select('id, title, started_at')
.order('started_at', { ascending: false })
.limit(5),
supabase.from('meetings').select('started_at').gte('started_at', sinceIso),
supabase.from('meeting_documents').select('created_at').gte('created_at', sinceIso)
])
;(meetingsTrendRows ?? []).forEach((row) => {
if (row.started_at) bumpTrend(trend, row.started_at, 'meetings')
})
;(documentsTrendRows ?? []).forEach((row) => {
if (row.created_at) bumpTrend(trend, row.created_at, 'documents')
})
const thisWeek = trend.slice(-7).reduce((acc, p) => acc + p.meetings, 0)
const stats: Stat[] = [
{ label: '총 회의', value: String(meetingCount ?? 0), hint: '전체 기간' },
{ label: '이번 주', value: String(thisWeek), hint: '최근 7일' },
{ label: '구독 티어', value: 'Free', hint: '업그레이드 가능' },
{ label: '쿼터 사용', value: '0 / 50', hint: '오늘' }
]
return {
stats,
recentMeetings: (recent ?? []).map((m) => ({
id: m.id,
title: m.title ?? '(제목 없음)',
started_at: m.started_at
})),
trend
}
} catch {
return {
stats: [
{ label: '총 회의', value: '—' },
{ label: '이번 주', value: '—' },
{ label: '구독 티어', value: '—' },
{ label: '쿼터 사용', value: '—' }
],
recentMeetings: [],
trend
}
function providerLabel(provider: DashboardSnapshot['subscription']['provider']): string {
const labels: Record<DashboardSnapshot['subscription']['provider'], string> = {
none: '미연결',
stripe: 'Stripe',
payple: '페이플',
google_play: 'Google Play',
app_store: 'App Store',
admin: '관리자 부여'
}
return labels[provider]
}
export default async function DashboardPage(): Promise<React.ReactElement> {
const { stats, recentMeetings, trend } = await loadDashboardData()
function formatDuration(seconds: number): string {
const total = Math.max(0, Math.floor(seconds))
const hours = Math.floor(total / 3600)
const minutes = Math.floor((total % 3600) / 60)
const remaining = total % 60
if (hours > 0) return `${hours}시간 ${minutes}분`
if (minutes > 0) return `${minutes}분 ${remaining}초`
return `${remaining}초`
}
export default function DashboardPage(): React.ReactElement {
const { user, loading: authLoading } = useAuth()
const [snapshot, setSnapshot] = useState<DashboardSnapshot | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const load = useCallback(async (): Promise<void> => {
if (!user) {
if (!authLoading) {
setSnapshot(null)
setError('세션이 만료되었습니다. 다시 로그인해 주세요.')
setLoading(false)
}
return
}
setLoading(true)
setError(null)
try {
const client = getSupabaseBrowserClient() as unknown as D3roSupabaseClient
setSnapshot(await loadDashboardSnapshot(client, user.id))
} catch (requestError) {
setSnapshot(null)
setError(dashboardMessage(requestError))
} finally {
setLoading(false)
}
}, [authLoading, user])
useEffect(() => {
void load()
}, [load])
if (authLoading || loading) {
return (
<Box role="status" sx={{ minHeight: 360, display: 'grid', placeItems: 'center' }}>
<CircularProgress aria-label="대시보드 불러오는 중" />
</Box>
)
}
if (error || !snapshot) {
return (
<Box sx={{ maxWidth: 1080, mx: 'auto', p: { xs: 2.5, md: 4 }, pt: 4 }}>
<Alert
severity="error"
action={(
<Button color="inherit" size="small" startIcon={<RefreshCw size={14} />} onClick={() => void load()}>
다시 시도
</Button>
)}
>
{error ?? '대시보드 데이터를 불러오지 못했습니다.'}
</Alert>
</Box>
)
}
const { stats, subscription } = snapshot
const generatedAt = new Date(stats.generatedAt)
const tiles = [
{ label: '오늘 녹음 시간', value: formatDuration(stats.todayRecordingSeconds), icon: <Mic size={20} />, ring: 'blue' as const },
{ label: '전체 단어 수', value: stats.totalWordCount.toLocaleString(), icon: <BookOpen size={20} />, ring: 'accent' as const },
{ label: '오늘 세션', value: stats.todaySessions.toLocaleString(), icon: <CalendarDays size={20} />, ring: 'green' as const },
{ label: '연속 사용', value: `${stats.streakDays}일`, icon: <Flame size={20} />, ring: 'orange' as const }
]
return (
<Box sx={{ p: 4 }}>
<PhosphorText variant="title" sx={{ mb: 4 }}>
DASHBOARD
</PhosphorText>
<Box sx={{ maxWidth: 1080, mx: 'auto', p: { xs: 2.5, md: 4 }, pt: 4, pb: 10 }}>
<DoubleBezelCard innerPadding={3.5}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3, gap: 2, flexWrap: 'wrap' }}>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
<TactileBadge ledColor="green" tone="success">SUPABASE SSOT</TactileBadge>
<TactileBadge mono tone="mono">RLS ACCOUNT DATA</TactileBadge>
</Box>
<PhosphorText variant="small" sx={{ color: d3roPalette.text.secondary }} data-testid="dashboard-generated-at">
갱신 {generatedAt.toLocaleString('ko-KR')}
</PhosphorText>
</Box>
<Grid container spacing={3} sx={{ mb: 4 }}>
{stats.map((stat) => (
<Grid size={{ xs: 12, sm: 6, md: 3 }} key={stat.label}>
<MetalCard sx={{ p: 3, height: '100%' }}>
<Box sx={{ color: d3roPalette.text.label, ...typoSx("label"), mb: 1 }}>
{stat.label}
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: '1fr auto' }, gap: 3, alignItems: 'center' }}>
<Box>
<PhosphorText variant="hero" sx={{ mb: 1, letterSpacing: '-0.02em', lineHeight: 1.15 }}>
내 음성 작업 현황
</PhosphorText>
<PhosphorText variant="body" sx={{ color: d3roPalette.text.secondary, display: 'block', mb: 2.5 }}>
계정에 동기화된 완료 기록을 기준으로 집계합니다.
</PhosphorText>
<Link href="/record" style={{ textDecoration: 'none' }}>
<PhysicalButton tone="accent" trailingIcon={<ArrowUpRight size={13} />}>
<Mic size={15} /> 웹 녹음 시작
</PhysicalButton>
</Link>
</Box>
<Box sx={{ display: 'flex', gap: 4 }}>
{[
{ label: '전체 세션', value: stats.totalSessions.toLocaleString() },
{ label: '전체 녹음', value: formatDuration(stats.totalRecordingSeconds) }
].map((item) => (
<Box key={item.label}>
<PhosphorText variant="small" sx={{ color: d3roPalette.text.secondary, display: 'block', mb: 0.75 }}>
{item.label}
</PhosphorText>
<Typography sx={{ fontFamily: d3roFontSans, fontSize: { xs: 24, md: 30 }, fontWeight: 500, color: d3roPalette.text.primary }}>
{item.value}
</Typography>
</Box>
<PhosphorText variant="title">
{stat.value}
</PhosphorText>
{stat.hint && (
<Box sx={{ mt: 1, color: d3roPalette.text.muted, fontSize: 11 }}>{stat.hint}</Box>
)}
</MetalCard>
</Grid>
))}
</Box>
</Box>
</DoubleBezelCard>
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr', lg: 'repeat(4, 1fr)' }, gap: 2, mt: 3 }}>
{tiles.map((tile) => (
<TiltCard key={tile.label} maxTilt={6} sx={{ p: 2.5 }} data-testid={`dashboard-stat-${tile.label}`}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<StatRing color={tile.ring} size={54} thickness={2.5}>{tile.icon}</StatRing>
<Box sx={{ minWidth: 0 }}>
<PhosphorText variant="small" sx={{ color: d3roPalette.text.secondary, display: 'block', mb: 0.5 }}>
{tile.label}
</PhosphorText>
<Typography sx={{ fontFamily: d3roFontSans, fontSize: 24, fontWeight: 500, color: d3roPalette.text.primary }}>
{tile.value}
</Typography>
</Box>
</Box>
</TiltCard>
))}
</Grid>
</Box>
<MetalCard sx={{ p: 3, mb: 4 }}>
<PhosphorText variant="heading" sx={{ mb: 2 }}>
ACTIVITY TREND ({TREND_DAYS}D)
</PhosphorText>
<MeetingsTrendChart points={trend} />
</MetalCard>
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' }, gap: 2, mt: 3 }}>
<MetalCard sx={{ p: 2.75 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<Database size={16} color={d3roPalette.accent.light} />
<PhosphorText variant="heading">동기화 기준</PhosphorText>
</Box>
<Box sx={{ display: 'grid', gap: 1.25 }}>
<DataLine label="기록 범위" value="완료된 내 전사 기록" />
<DataLine label="일자 기준" value="UTC" />
<DataLine label="오늘 단어" value={stats.todayWordCount.toLocaleString()} />
</Box>
</MetalCard>
<PhosphorText variant="heading" sx={{ mb: 2 }}>
RECENT MEETINGS
</PhosphorText>
<Stack spacing={2}>
{recentMeetings.length === 0 ? (
<MetalCard sx={{ p: 4, textAlign: 'center', color: d3roPalette.text.muted }}>
아직 회의가 없습니다.
<MetalCard sx={{ p: 2.75 }} data-testid="dashboard-subscription">
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Star size={16} color={d3roPalette.accent.light} />
<PhosphorText variant="heading">현재 구독</PhosphorText>
</Box>
<TactileBadge tone={subscription.tier === 'free' ? 'mono' : 'accent'}>
{tierLabel(subscription.tier)}
</TactileBadge>
</Box>
<Box sx={{ display: 'grid', gap: 1.25 }}>
<DataLine label="상태" value={subscription.status ?? '미설정'} />
<DataLine label="결제 제공자" value={providerLabel(subscription.provider)} />
<DataLine label="추가 크레딧" value={subscription.overageCredits.toLocaleString()} />
{subscription.currentPeriodEnd && <DataLine label="현재 기간 종료" value={new Date(subscription.currentPeriodEnd).toLocaleDateString('ko-KR')} />}
{subscription.cancelAt && <DataLine label="해지 예정" value={new Date(subscription.cancelAt).toLocaleDateString('ko-KR')} />}
</Box>
<Button component={Link} href="/billing" size="small" endIcon={<ArrowUpRight size={13} />} sx={{ mt: 2 }}>
구독 관리
</Button>
</MetalCard>
</Box>
<Box sx={{ mt: 4 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', pb: 1.5, mb: 2, borderBottom: `1px solid ${d3roPalette.glass.hairline}` }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<ShieldCheck size={16} color={d3roPalette.accent.light} />
<PhosphorText variant="heading">최근 전사 기록</PhosphorText>
</Box>
<Button component={Link} href="/history" size="small">전체 보기</Button>
</Box>
{stats.recentHistory.length === 0 ? (
<MetalCard sx={{ p: 5, textAlign: 'center', color: d3roPalette.text.muted }} data-testid="dashboard-empty-history">
아직 완료된 전사 기록이 없습니다.
</MetalCard>
) : (
recentMeetings.map((meeting) => (
<Link
key={meeting.id}
href={`/meetings/${meeting.id}`}
style={{ textDecoration: 'none' }}
>
<MetalCard
sx={{
p: 3,
cursor: 'pointer',
transition: 'transform 120ms ease, box-shadow 120ms ease',
'&:hover': { transform: 'translateY(-1px)' }
}}
>
<Box sx={{ color: d3roPalette.text.primary, ...typoSx("body") }}>{meeting.title}</Box>
<Box sx={{ color: d3roPalette.text.muted, fontSize: 12, mt: 0.5 }}>
{new Date(meeting.started_at).toLocaleString('ko-KR')}
</Box>
</MetalCard>
</Link>
))
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', sm: 'repeat(2, 1fr)' }, gap: 1.5 }}>
{stats.recentHistory.map((entry) => (
<Link key={entry.id} href={`/history/${entry.id}`} style={{ color: 'inherit', textDecoration: 'none' }}>
<MetalCard sx={{ p: 2.5, height: '100%' }} data-testid={`dashboard-history-${entry.id}`}>
{entry.title && <Typography sx={{ color: d3roPalette.text.primary, fontWeight: 500, mb: 0.75 }}>{entry.title}</Typography>}
<Typography sx={{ color: d3roPalette.text.secondary, fontSize: 14, lineHeight: 1.6, overflowWrap: 'anywhere' }}>
{entry.text}
</Typography>
<Box sx={{ mt: 1.5, display: 'flex', gap: 1.5, color: d3roPalette.text.muted, fontFamily: d3roFontMono, fontSize: 11 }}>
<span>{new Date(entry.createdAt).toLocaleString('ko-KR')}</span>
<span><Clock3 size={11} style={{ verticalAlign: 'middle' }} /> {formatDuration(entry.durationSeconds)}</span>
</Box>
</MetalCard>
</Link>
))}
</Box>
)}
</Stack>
</Box>
</Box>
)
}
function DataLine({ label, value }: { label: string; value: string }): React.ReactElement {
return (
<Box sx={{ display: 'flex', justifyContent: 'space-between', gap: 2 }}>
<PhosphorText variant="small" sx={{ color: d3roPalette.text.secondary }}>{label}</PhosphorText>
<PhosphorText variant="compact" sx={{ color: d3roPalette.text.primary, textAlign: 'right' }}>{value}</PhosphorText>
</Box>
)
}