diff --git a/VOCA_PROGRESS.md b/VOCA_PROGRESS.md index 58488db..6fcc4d1 100644 --- a/VOCA_PROGRESS.md +++ b/VOCA_PROGRESS.md @@ -190,4 +190,27 @@ Session Detection: * Consider multiple sessions for trend analysis * Implement activity timeline visualization +## EPIC 5 — Manual Trigger (VOC-131, VOC-132, VOC-133) + +### Completed: +* Added **GenerateButton** component with disabled/idle, loading, error, and success states (VOC-131) +* Added **LoadingView** with auto-advancing progress steps and layout-shift-free UI (VOC-132) +* Added **ErrorView** mapped to 7 distinct failure states with retryable/non-retryable handling (VOC-133) +* Created `/api/generate` endpoint with server-side error logging (not logging access tokens) and test hooks (VOC-133) +* Integrated controlled repo selection state between `RepoSelector`, `GenerateButton`, and `DashboardClient` wrapper. + +### Test Protocol & Manual Verification: +All 7 error types were successfully tested and verified: +1. **no_activity** (No recent activity in 7 days): Mapped to a friendly message prompting user to commit & push. Verification: Selected `[TEST] Trigger No Activity`, clicked Generate. Renders No Activity card with Retry and Go Back actions. +2. **ai_failure** (Claude API failure): Message explains AI hit a snag. Verification: Selected `[TEST] Trigger Claude/AI Failure`, clicked Generate. Renders AI Failure card with Try Again and Go Back. +3. **network** (Connection failure): Verification: Disabled network connection, clicked Generate. Client-side fetch threw network exception and immediately rendered the Network Connection card with Try Again. +4. **auth_expired** (Auth token expired/401): Verification: Selected `[TEST] Trigger Auth Expired (401)`, clicked Generate. API returned 401. UI displayed Session Expired card with "Sign In Again" primary action (no retry button). +5. **repo_not_found** (Repo access denied/404): Verification: Selected `[TEST] Trigger Repo Access Denied (404)`, clicked Generate. API returned 404. UI displayed Repo Access card with "Choose Different Repository" and "Go back" secondary link. Clicking either cleared the selection state in the parent and returned to repository selection. +6. **server_error** (Server error/500): Verification: Selected `[TEST] Trigger Server Error (500)`, clicked Generate. API returned 500. UI displayed Server Error card with Try Again. +7. **timeout** (Request exceeds 45s): Verification: Selected `[TEST] Trigger Timeout (50s)`, clicked Generate. Client aborted request at 45 seconds, throwing AbortError and rendering Timeout card with Try Again. + +### Status: +✅ Epic 5 Complete and ready for review. + + diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index 05a26b7..46aa075 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -1,20 +1,29 @@ -import React from 'react' -import { redirect } from 'next/navigation' -import { getServerAuthSession } from '@/lib/auth' -import RepoSelector from '@/components/RepoSelector' +import React from 'react'; +import { redirect } from 'next/navigation'; +import { getServerAuthSession } from '@/lib/auth'; +import DashboardClient from '@/components/DashboardClient'; +/** + * Dashboard page — Server Component. + * + * Auth guard runs server-side (zero client cost). All interactive UI (repo + * selection + generate trigger) is delegated to DashboardClient which owns + * the shared selectedRepo state. + */ export default async function DashboardPage() { - const session = await getServerAuthSession() - if (!session) return redirect('/') + const session = await getServerAuthSession(); + if (!session) return redirect('/'); return (

Dashboard

-

This is a protected dashboard page.

-
-

Select a repository

- +

+ Select a repository and generate a LinkedIn post from your latest activity. +

+ +
+
- ) + ); } diff --git a/app/drafts/page.tsx b/app/drafts/page.tsx new file mode 100644 index 0000000..0c97b94 --- /dev/null +++ b/app/drafts/page.tsx @@ -0,0 +1,221 @@ +'use client'; + +import React, { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import Link from 'next/link'; +import { GenerationResult, Draft } from '@/types/generation'; +import { DraftCard } from '@/components/DraftCard'; +import { LoadingView } from '@/components/LoadingView'; +import { ErrorView, ErrorType } from '@/components/ErrorView'; + +const DEFAULT_STYLES: Array<'raw' | 'polished' | 'short'> = ['raw', 'polished', 'short']; + +export default function DraftsPage() { + const [result, setResult] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState<{ type: ErrorType; message?: string } | null>(null); + const router = useRouter(); + + // Load from sessionStorage on mount + useEffect(() => { + const raw = sessionStorage.getItem('voca_drafts'); + if (!raw) { + router.replace('/dashboard'); + return; + } + try { + const parsed = JSON.parse(raw); + if (!parsed || !Array.isArray(parsed.drafts)) { + throw new Error('Invalid format'); + } + setResult(parsed); + } catch { + router.replace('/dashboard'); + } + }, [router]); + + // Update content of a draft in state & sessionStorage + const handleContentChange = (index: number, newContent: string) => { + if (!result) return; + const updatedDrafts = [...result.drafts]; + updatedDrafts[index] = { ...updatedDrafts[index], text: newContent }; + const updatedResult = { ...result, drafts: updatedDrafts }; + + setResult(updatedResult); + sessionStorage.setItem('voca_drafts', JSON.stringify(updatedResult)); + }; + + // Re-run generation for the same repository + const handleRegenerate = async () => { + const repoFullName = result?.metadata?.repoFullName; + if (!repoFullName) return; + + setIsLoading(true); + setError(null); + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 45000); // 45s client timeout + + try { + const res = await fetch('/api/generate', { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ repoFullName }), + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + const data: any = await res.json().catch(() => ({})); + + if (!res.ok) { + let type: ErrorType = 'server_error'; + if (res.status === 401) { + type = 'auth_expired'; + } else if (res.status === 404) { + type = 'repo_not_found'; + } else if (res.status === 504 || res.status === 408) { + type = 'timeout'; + } else if ( + res.status === 502 || + res.status === 503 || + (data.error && (data.error.toLowerCase().includes('claude') || data.error.toLowerCase().includes('ai'))) + ) { + type = 'ai_failure'; + } else if ( + data.error && + (data.error.toLowerCase().includes('activity') || data.error.toLowerCase().includes('commits')) + ) { + type = 'no_activity'; + } + + setError({ type, message: data.error }); + setIsLoading(false); + return; + } + + if (data.noActivity) { + setError({ type: 'no_activity' }); + setIsLoading(false); + return; + } + + // Update local state and sessionStorage + setResult(data); + sessionStorage.setItem('voca_drafts', JSON.stringify(data)); + setIsLoading(false); + } catch (err: unknown) { + clearTimeout(timeoutId); + setIsLoading(false); + + if (err instanceof DOMException && err.name === 'AbortError') { + setError({ type: 'timeout' }); + } else if (err instanceof TypeError || (err instanceof Error && err.message.toLowerCase().includes('fetch'))) { + setError({ type: 'network' }); + } else { + setError({ + type: 'server_error', + message: err instanceof Error ? err.message : 'Something went wrong', + }); + } + } + }; + + const handleBackFromError = () => { + setError(null); + }; + + // ── Render: Loading ──────────────────────────────────────────────────────── + if (isLoading) { + return ( +
+ +
+ ); + } + + // ── Render: Error ────────────────────────────────────────────────────────── + if (error) { + return ( +
+ +
+ ); + } + + if (!result) return null; + + const repoName = result.metadata?.repoFullName || 'Selected Repository'; + + return ( +
+ {/* Back Link */} +
+ + + + + Back to dashboard + +
+ + {/* Header */} +
+

+ Here are 3 posts based on your latest work +

+

+ From repository: + {repoName} +

+
+ + {/* Draft Cards Grid */} +
+ {result.drafts.map((draft, index) => { + // Fallback style if none provided by API: raw, polished, short in sequence + const style = draft.style || DEFAULT_STYLES[index % DEFAULT_STYLES.length]; + + return ( +
+ handleContentChange(index, newContent)} + /> +
+ ); + })} +
+ + {/* Regeneration Button */} +
+ +

+ Will pull your latest work activity again and generate fresh drafts. +

+
+
+ ); +} diff --git a/components/DashboardClient.tsx b/components/DashboardClient.tsx new file mode 100644 index 0000000..5e68b3c --- /dev/null +++ b/components/DashboardClient.tsx @@ -0,0 +1,37 @@ +'use client'; + +import React, { useState } from 'react'; +import RepoSelector from '@/components/RepoSelector'; +import { GenerateButton } from '@/components/GenerateButton'; + +/** + * DashboardClient — Client Component boundary for the dashboard page. + * + * The dashboard page (Server Component) cannot hold local state, so this wrapper + * acts as the single source of truth for `selectedRepo`. Both RepoSelector and + * GenerateButton consume it, eliminating any prop-drilling issues across the tree. + * + * State flow: + * RepoSelector → onRepoSelect(repoFullName) → selectedRepo state + * GenerateButton reads selectedRepo → triggers POST /api/generate + */ +export default function DashboardClient() { + const [selectedRepo, setSelectedRepo] = useState(null); + + return ( +
+ {/* Repository selection */} +
+

+ Select a repository +

+ +
+ + {/* Generate trigger — disabled until a repo is chosen */} +
+ setSelectedRepo(null)} /> +
+
+ ); +} diff --git a/components/DraftCard.tsx b/components/DraftCard.tsx new file mode 100644 index 0000000..5c7ed1d --- /dev/null +++ b/components/DraftCard.tsx @@ -0,0 +1,196 @@ +'use client'; + +import React, { useState, useEffect, useRef } from 'react'; + +export interface DraftCardProps { + style: 'raw' | 'polished' | 'short'; + content: string; + onContentChange: (newContent: string) => void; +} + +const STYLE_LABELS: Record< + DraftCardProps['style'], + { label: string; description: string; badgeClass: string } +> = { + raw: { + label: 'Raw', + description: 'Unfiltered, first-thought voice', + badgeClass: 'bg-amber-100 text-amber-800 border-amber-200', + }, + polished: { + label: 'Polished', + description: 'Cleaned up, still human', + badgeClass: 'bg-emerald-100 text-emerald-800 border-emerald-200', + }, + short: { + label: 'Short', + description: 'Under 150 words, punchy', + badgeClass: 'bg-purple-100 text-purple-800 border-purple-200', + }, +}; + +function getCharCountColor(count: number): string { + if (count <= 1300) return 'text-green-600'; // Optimal LinkedIn length + if (count <= 3000) return 'text-yellow-600'; // Fine but getting long + return 'text-red-600 font-semibold'; // LinkedIn truncates at ~3000 +} + +export function DraftCard({ style, content, onContentChange }: DraftCardProps) { + const [copied, setCopied] = useState(false); + const [localContent, setLocalContent] = useState(content); + const textareaRef = useRef(null); + const meta = STYLE_LABELS[style]; + + // Sync local content only if external content changes and is different to prevent cursor jumps + useEffect(() => { + if (content !== localContent) { + setLocalContent(content); + } + }, [content]); + + // Handle auto-resizing of textarea to prevent any internal scrollbar inside the card + useEffect(() => { + const textarea = textareaRef.current; + if (textarea) { + textarea.style.height = 'auto'; + textarea.style.height = `${textarea.scrollHeight}px`; + } + }, [localContent]); + + const charCount = localContent.length; + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(localContent); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error('Failed to copy to clipboard:', err); + } + }; + + const handleLinkedInShare = async () => { + try { + await navigator.clipboard.writeText(localContent); + } catch (err) { + console.error('Clipboard copy failed before sharing:', err); + } + + const url = `https://www.linkedin.com/feed/?shareActive=true&text=${encodeURIComponent( + localContent + )}`; + window.open(url, '_blank', 'noopener,noreferrer'); + }; + + return ( +
+ {/* Badge & Description Header */} +
+
+ + {meta.label} + +
+

{meta.description}

+
+ + {/* Editable Area */} +
+