Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions VOCA_PROGRESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.



31 changes: 20 additions & 11 deletions app/dashboard/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="py-16">
<h2 className="text-2xl font-semibold">Dashboard</h2>
<p className="mt-4 text-sm text-gray-600">This is a protected dashboard page.</p>
<div className="mt-8">
<h3 className="text-lg font-medium">Select a repository</h3>
<RepoSelector />
<p className="mt-2 text-sm text-gray-500">
Select a repository and generate a LinkedIn post from your latest activity.
</p>

<div className="mt-10">
<DashboardClient />
</div>
</div>
)
);
}
221 changes: 221 additions & 0 deletions app/drafts/page.tsx
Original file line number Diff line number Diff line change
@@ -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<GenerationResult | null>(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 (
<div className="py-12">
<LoadingView />
</div>
);
}

// ── Render: Error ──────────────────────────────────────────────────────────
if (error) {
return (
<div className="py-12">
<ErrorView
errorType={error.type}
message={error.message}
onRetry={handleRegenerate}
onBack={handleBackFromError}
/>
</div>
);
}

if (!result) return null;

const repoName = result.metadata?.repoFullName || 'Selected Repository';

return (
<div className="py-12 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
{/* Back Link */}
<div className="mb-6">
<Link
href="/dashboard"
className="inline-flex items-center gap-2 text-sm font-medium text-gray-500 hover:text-gray-700 transition-colors"
>
<svg
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Back to dashboard
</Link>
</div>

{/* Header */}
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900 sm:text-3xl">
Here are 3 posts based on your latest work
</h1>
<p className="text-sm text-gray-500 mt-2 flex items-center gap-1.5">
<span>From repository:</span>
<span className="font-semibold text-gray-700">{repoName}</span>
</p>
</div>

{/* Draft Cards Grid */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-12">
{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 (
<div key={draft.id || index} className="h-full">
<DraftCard
style={style}
content={draft.text}
onContentChange={(newContent) => handleContentChange(index, newContent)}
/>
</div>
);
})}
</div>

{/* Regeneration Button */}
<div className="flex flex-col items-center justify-center border-t border-gray-100 pt-8 gap-3">
<button
type="button"
onClick={handleRegenerate}
className="px-6 py-3 rounded-lg border border-gray-300 bg-white text-sm font-medium text-gray-700 hover:bg-gray-50 shadow-sm transition-colors duration-150 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
Generate again from this repository
</button>
<p className="text-xs text-gray-400">
Will pull your latest work activity again and generate fresh drafts.
</p>
</div>
</div>
);
}
37 changes: 37 additions & 0 deletions components/DashboardClient.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(null);

return (
<div className="space-y-8">
{/* Repository selection */}
<section aria-labelledby="repo-selector-heading">
<h3 id="repo-selector-heading" className="text-lg font-medium mb-4">
Select a repository
</h3>
<RepoSelector onRepoSelect={setSelectedRepo} selectedRepo={selectedRepo} />
</section>

{/* Generate trigger β€” disabled until a repo is chosen */}
<section aria-label="Generate post">
<GenerateButton selectedRepo={selectedRepo} onResetRepo={() => setSelectedRepo(null)} />
</section>
</div>
);
}
Loading