feat(release): prepare 1.1.0 candidate
This commit is contained in:
parent
5a34f66981
commit
5205dcdfa9
736 changed files with 115667 additions and 12203 deletions
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue