feat(desktop+web): Phase 3.3 Auto push on write + transcript fallback + dashboard Link (빅뱅 Phase 5 Part 3)

Desktop — CloudSyncService:
- pushOne(table, id) 제네릭 단건 push 신규 (history/dictionary/meetings/meeting_memos/meeting_documents)
- 로그인 미상태 silent noop, 실패 시 warn만 (로컬 write 절대 차단 금지)
- 5개 row→payload 매퍼(_mapHistoryPayload 외 4개) private 메서드로 추출, pushAll과 공유 (DRY)

Desktop — write hook 8곳 fire-and-forget 연결:
- HistoryService.create / generateTitle
- MeetingSummaryService.generateSummary (summaryText 저장 후)
- DictionaryService.add / update
- MeetingModeService.startRecording (부모 meeting pre-push — 자식 memo/doc RLS 통과용)
- MeetingModeService.addMemo / generateDocument
- MeetingModeService._runPostProcessing (완료 상태 최종 저장 후)

Web — meeting detail page:
- transcripts 테이블이 비고 status!=recording인 경우 meeting.raw_transcript(또는 edited_transcript) 를 whiteSpace:pre-wrap으로 fallback 렌더
- 실시간 구독 경로(LiveTranscriptList)는 recording 상태 또는 transcripts 존재 시에만 마운트

Web — dashboard:
- RECENT MEETINGS의 MetalCard를 next/link Link로 감싸 /meetings/[id] 이동 가능
- cursor pointer + hover lift 효과

실증: 재기동 직후 pushAll 캐치업이 이전 세션 RLS 실패 메모 2건을 재시도 → pushed=4 성공.
새 녹음 → pushOne history/... ok 실시간 발화 → 웹에서 전사/메모 렌더 확인 (사용자 "잘 전사와 메모가 올라왔어")
This commit is contained in:
윤찬 2026-04-11 21:34:02 +09:00
parent 4e6280bc69
commit 046ac857cb
8 changed files with 344 additions and 85 deletions

View file

@ -1,6 +1,7 @@
// apps/web/src/app/dashboard/page.tsx
// 대시보드 — 요약 카드 4개 + 최근 회의
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"
@ -154,12 +155,25 @@ export default async function DashboardPage(): Promise<React.ReactElement> {
</MetalCard>
) : (
recentMeetings.map((meeting) => (
<MetalCard key={meeting.id} sx={{ p: 3 }}>
<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
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>
))
)}
</Stack>

View file

@ -68,15 +68,33 @@ export default async function MeetingDetailPage({ params }: PageProps): Promise<
<MeetingAudioPlayer storageKey={meeting.audio_storage_key ?? null} />
</MetalCard>
{/* Transcript — Realtime 구독 */}
{/* Transcript — Realtime 구독 or raw_transcript fallback */}
<MetalCard sx={{ p: 3 }}>
<PhosphorText variant="heading" sx={{ mb: 2 }}>
TRANSCRIPT (LIVE)
TRANSCRIPT {meeting.status === 'recording' ? '(LIVE)' : ''}
</PhosphorText>
<LiveTranscriptList
meetingId={id}
initial={(transcripts ?? []) as unknown as TranscriptRow[]}
/>
{(transcripts ?? []).length > 0 || meeting.status === 'recording' ? (
<LiveTranscriptList
meetingId={id}
initial={(transcripts ?? []) as unknown as TranscriptRow[]}
/>
) : meeting.edited_transcript || meeting.raw_transcript ? (
<Box
sx={{
whiteSpace: 'pre-wrap',
color: d3roPalette.text.primary,
fontSize: 13,
lineHeight: 1.6,
fontFamily: 'inherit'
}}
>
{meeting.edited_transcript ?? meeting.raw_transcript}
</Box>
) : (
<Box sx={{ color: d3roPalette.text.muted, fontSize: 13 }}>
.
</Box>
)}
</MetalCard>
{/* Memos */}