Initial commit: Brachnha Insight project setup

- Next.js 14+ with App Router and TypeScript
- Tailwind CSS and ShadCN UI styling
- Zustand state management
- Dexie.js for IndexedDB (local-first data)
- Auth.js v5 for authentication
- BMAD framework integration

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Max
2026-01-26 12:28:43 +07:00
commit 3fbbb1a93b
812 changed files with 150531 additions and 0 deletions

View File

@@ -0,0 +1,75 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { DeleteConfirmDialog } from './DeleteConfirmDialog';
describe('DeleteConfirmDialog', () => {
const mockOnConfirm = vi.fn();
const mockOnOpenChange = vi.fn();
const defaultProps = {
open: true,
onOpenChange: mockOnOpenChange,
onConfirm: mockOnConfirm,
draftTitle: 'Test Draft Title',
};
beforeEach(() => {
mockOnConfirm.mockClear();
mockOnOpenChange.mockClear();
});
it('should render dialog when open', () => {
render(<DeleteConfirmDialog {...defaultProps} />);
expect(screen.getByText('Delete this post?')).toBeInTheDocument();
expect(screen.getByText(/Test Draft Title/)).toBeInTheDocument();
// "This action cannot be undone" is split across lines
expect(screen.getByText(/This action cannot be undone/)).toBeInTheDocument();
});
it('should not render when closed', () => {
render(<DeleteConfirmDialog {...defaultProps} open={false} />);
expect(screen.queryByText('Delete this post?')).not.toBeInTheDocument();
});
it('should call onConfirm when Delete button is clicked', async () => {
const user = userEvent.setup();
render(<DeleteConfirmDialog {...defaultProps} />);
const deleteButton = screen.getByRole('button', { name: 'Delete' });
await user.click(deleteButton);
expect(mockOnConfirm).toHaveBeenCalledTimes(1);
});
it('should call onOpenChange when Cancel button is clicked', async () => {
const user = userEvent.setup();
render(<DeleteConfirmDialog {...defaultProps} />);
const cancelButton = screen.getByRole('button', { name: 'Cancel' });
await user.click(cancelButton);
expect(mockOnOpenChange).toHaveBeenCalledWith(false);
});
it('should have accessible labels', () => {
render(<DeleteConfirmDialog {...defaultProps} />);
// Check for descriptive text
expect(screen.getByText(/Delete this post\?/)).toBeInTheDocument();
expect(screen.getByText(/You are about to delete/)).toBeInTheDocument();
expect(screen.getByText(/This action cannot be undone/)).toBeInTheDocument();
});
it('should have proper button styling for destructive action', () => {
render(<DeleteConfirmDialog {...defaultProps} />);
const deleteButton = screen.getByRole('button', { name: 'Delete' });
expect(deleteButton).toHaveClass('bg-destructive');
const cancelButton = screen.getByRole('button', { name: 'Cancel' });
expect(cancelButton).toHaveClass('border');
});
});

View File

@@ -0,0 +1,65 @@
'use client';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
/**
* DeleteConfirmDialog - Confirmation dialog for destructive actions
*
* Story 3.2: Delete confirmation dialog for drafts/posts
*
* Uses ShadCN AlertDialog for critical interruptions like deletion.
* - Clear warning text: "This cannot be undone"
* - Two buttons: Cancel (secondary), Delete (destructive/red)
* - Accessible: proper aria labels, focus management, keyboard navigation
*/
interface DeleteConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
draftTitle: string;
}
export function DeleteConfirmDialog({
open,
onOpenChange,
onConfirm,
draftTitle
}: DeleteConfirmDialogProps) {
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="text-destructive">
Delete this post?
</AlertDialogTitle>
<AlertDialogDescription>
You are about to delete <strong>"{draftTitle}"</strong>.
</AlertDialogDescription>
<AlertDialogDescription className="text-sm text-muted-foreground mt-2">
This action cannot be undone. The post will be permanently removed.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={onConfirm}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -0,0 +1,50 @@
'use client';
import { BookOpen, Sparkles } from 'lucide-react';
interface EmptyHistoryStateProps {
onStartVent?: () => void;
}
/**
* EmptyHistoryState Component
*
* Story 3.1: Encouraging empty state for new users
*
* Shown when user has no completed drafts yet.
* Displays:
* - Encouraging message
* - Calming illustration (using icons)
* - CTA to start first vent
*/
export function EmptyHistoryState({ onStartVent }: EmptyHistoryStateProps) {
return (
<div className="empty-history-state flex flex-col items-center justify-center py-16 px-6 text-center">
{/* Illustration */}
<div className="mb-6 relative">
<div className="w-32 h-32 bg-gradient-to-br from-slate-100 to-slate-200 rounded-full flex items-center justify-center">
<BookOpen className="w-16 h-16 text-slate-400" aria-hidden="true" />
</div>
<Sparkles className="w-8 h-8 text-amber-400 absolute -top-2 -right-2" aria-hidden="true" />
</div>
{/* Message */}
<h2 className="text-2xl font-bold text-slate-800 mb-2 font-serif">
Your journey starts here
</h2>
<p className="text-slate-600 mb-6 max-w-md font-sans">
Every venting session becomes a learning moment. Start your first session to see your growth unfold.
</p>
{/* CTA Button */}
<button
onClick={onStartVent}
type="button"
className="min-h-[44px] px-6 py-3 bg-slate-800 text-white rounded-lg hover:bg-slate-700 transition-colors flex items-center gap-2 font-sans"
>
<Sparkles className="w-5 h-5" aria-hidden="true" />
<span>Start My First Vent</span>
</button>
</div>
);
}

View File

@@ -0,0 +1,119 @@
/**
* HistoryCard Component Tests
* Story 3.1: Testing history card display
*/
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { HistoryCard } from './HistoryCard';
import type { Draft } from '@/lib/db/draft-service';
// Mock date utility
vi.mock('@/lib/utils/date', () => ({
formatRelativeDate: vi.fn((timestamp: number) => 'Today')
}));
const mockDraft: Draft = {
id: 1,
sessionId: 'test-session',
title: 'Test Draft Title',
content: 'This is a test draft content that is longer than 100 characters to test the preview functionality.',
tags: ['react', 'testing'],
createdAt: Date.now(),
completedAt: Date.now(),
status: 'completed'
};
describe('HistoryCard', () => {
it('renders draft title', () => {
const onClick = vi.fn();
render(<HistoryCard draft={mockDraft} onClick={onClick} />);
expect(screen.getByText('Test Draft Title')).toBeInTheDocument();
});
it('renders relative date', () => {
const onClick = vi.fn();
render(<HistoryCard draft={mockDraft} onClick={onClick} />);
expect(screen.getByText('Today')).toBeInTheDocument();
});
it('renders tags', () => {
const onClick = vi.fn();
render(<HistoryCard draft={mockDraft} onClick={onClick} />);
expect(screen.getByText('#react')).toBeInTheDocument();
expect(screen.getByText('#testing')).toBeInTheDocument();
});
it('renders content preview truncated to 100 chars', () => {
const onClick = vi.fn();
render(<HistoryCard draft={mockDraft} onClick={onClick} />);
const previewText = screen.getByText(/This is a test draft/);
expect(previewText).toBeInTheDocument();
// Note: If content is exactly 100 chars, no "..." is added
const contentLength = mockDraft.content.length;
if (contentLength > 100) {
expect(previewText.textContent).toContain('...');
}
});
it('calls onClick when clicked', () => {
const onClick = vi.fn();
render(<HistoryCard draft={mockDraft} onClick={onClick} />);
const button = screen.getByRole('button', { name: /View post: Test Draft Title/ });
button.click();
expect(onClick).toHaveBeenCalledWith(mockDraft);
});
it('handles draft without tags', () => {
const onClick = vi.fn();
const draftWithoutTags: Draft = {
...mockDraft,
tags: []
};
render(<HistoryCard draft={draftWithoutTags} onClick={onClick} />);
expect(screen.queryByText('#react')).not.toBeInTheDocument();
expect(screen.queryByText('#testing')).not.toBeInTheDocument();
});
it('handles short content (no truncation)', () => {
const onClick = vi.fn();
const shortDraft: Draft = {
...mockDraft,
content: 'Short content'
};
render(<HistoryCard draft={shortDraft} onClick={onClick} />);
const previewText = screen.getByText(/Short content/);
expect(previewText.textContent).not.toContain('...');
});
it('has correct accessibility attributes', () => {
const onClick = vi.fn();
render(<HistoryCard draft={mockDraft} onClick={onClick} />);
const button = screen.getByRole('button', { name: /View post: Test Draft Title/ });
expect(button).toHaveAttribute('type', 'button');
});
it('handles draft without completedAt (uses createdAt)', () => {
const onClick = vi.fn();
const draftWithoutCompletedAt: Draft = {
...mockDraft,
completedAt: undefined
};
render(<HistoryCard draft={draftWithoutCompletedAt} onClick={onClick} />);
// Should still render with createdAt
expect(screen.getByText('Today')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,72 @@
'use client';
import { type Draft } from '@/lib/db/draft-service';
import { formatRelativeDate } from '@/lib/utils/date';
interface HistoryCardProps {
draft: Draft;
onClick: (draft: Draft) => void;
}
/**
* HistoryCard Component
*
* Story 3.1: Individual history item for the feed
*
* Displays a completed draft with:
* - Title (Merriweather serif font)
* - Relative date ("Today", "Yesterday", etc.)
* - Tags (if present)
* - Content preview (first 100 chars)
* - Click to view full detail
*
* Accessibility:
* - Semantic button with aria-label
* - 44px minimum touch target
*/
export function HistoryCard({ draft, onClick }: HistoryCardProps) {
// Generate preview text (first 100 chars)
const preview = draft.content.slice(0, 100);
// Calculate the display date (use completedAt if available, otherwise createdAt)
const displayDate = draft.completedAt || draft.createdAt;
return (
<button
onClick={() => onClick(draft)}
type="button"
className="history-card group w-full text-left p-4 bg-white rounded-lg shadow-sm hover:shadow-md transition-shadow border border-slate-200"
aria-label={`View post: ${draft.title}`}
>
{/* Title - Merriweather serif font for "published" feel */}
<h3 className="history-title text-lg font-bold text-slate-800 mb-2 font-serif leading-tight line-clamp-2">
{draft.title}
</h3>
{/* Date - Inter font, subtle gray, relative format */}
<p className="history-date text-sm text-slate-500 mb-2 font-sans">
{formatRelativeDate(displayDate)}
</p>
{/* Tags - pill badges */}
{draft.tags && draft.tags.length > 0 && (
<div className="flex flex-wrap gap-2 mb-2">
{draft.tags.map((tag) => (
<span
key={tag}
className="tag-chip px-2 py-1 bg-slate-100 text-slate-600 rounded-full text-xs font-sans"
>
#{tag}
</span>
))}
</div>
)}
{/* Preview - light gray text */}
<p className="history-preview text-sm text-slate-400 font-sans line-clamp-2">
{preview}
{draft.content.length > 100 && '...'}
</p>
</button>
);
}

View File

@@ -0,0 +1,101 @@
'use client';
import { useState } from 'react';
import { Copy, Check, X } from 'lucide-react';
import { useHistoryStore } from '@/lib/store/history-store';
import { DraftContent } from '@/components/features/draft/DraftContent';
import { CopySuccessToast } from '@/components/features/feedback/CopySuccessToast';
import { useChatStore } from '@/lib/store/chat-store';
import { Sheet } from '@/components/features/draft/Sheet';
/**
* HistoryDetailSheet Component
*
* Story 3.1: Full draft view from history feed
*
* Reuses:
* - Sheet component from DraftViewSheet (Story 2.2)
* - DraftContent component (Story 2.2)
* - CopyButton functionality (Story 2.4)
*
* Features:
* - Displays full draft with Merriweather font
* - Copy button for clipboard export
* - Close button
* - Swipe-to-dismiss support (via Sheet)
*
* Architecture Compliance:
* - Uses atomic selectors from HistoryStore
* - Reuses ChatStore's copyDraftToClipboard action
*/
export function HistoryDetailSheet() {
const selectedDraft = useHistoryStore((s) => s.selectedDraft);
const closeDetail = useHistoryStore((s) => s.closeDetail);
// Reuse copy action from ChatStore
const copyDraftToClipboard = useChatStore((s) => s.copyDraftToClipboard);
// Toast state
const [toastShow, setToastShow] = useState(false);
const [toastMessage, setToastMessage] = useState('');
const showCopyToast = (message: string = 'Copied to clipboard!') => {
setToastMessage(message);
setToastShow(true);
};
const handleCopy = async () => {
if (selectedDraft) {
await copyDraftToClipboard(selectedDraft.id);
showCopyToast();
}
};
const handleClose = () => {
closeDetail();
};
if (!selectedDraft) {
return null;
}
return (
<>
<Sheet open={!!selectedDraft} onClose={handleClose}>
<DraftContent draft={selectedDraft} />
{/* Footer with copy and close buttons */}
<nav className="sticky bottom-0 flex gap-3 p-4 bg-white border-t border-slate-200">
{/* Copy button */}
<button
onClick={handleCopy}
type="button"
className="flex-1 min-h-[44px] px-4 py-3 border border-slate-300 rounded-md text-slate-700 hover:bg-slate-50 transition-colors flex items-center justify-center gap-2"
aria-label="Copy to clipboard"
>
<Copy className="w-5 h-5" aria-hidden="true" />
<span>Copy</span>
</button>
{/* Close button */}
<button
onClick={handleClose}
type="button"
className="min-h-[44px] px-4 py-3 bg-slate-800 text-white rounded-md hover:bg-slate-700 transition-colors flex items-center justify-center gap-2"
aria-label="Close"
>
<X className="w-5 h-5" aria-hidden="true" />
<span>Close</span>
</button>
</nav>
</Sheet>
{/* Toast for copy feedback */}
<CopySuccessToast
show={toastShow}
message={toastMessage}
onClose={() => setToastShow(false)}
/>
</>
);
}

View File

@@ -0,0 +1,316 @@
/**
* HistoryFeed Component Tests
* Story 3.1: Testing history feed with lazy loading
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { HistoryFeed } from './HistoryFeed';
import { useHistoryStore } from '@/lib/store/history-store';
import { DraftService } from '@/lib/db/draft-service';
// Mock HistoryStore
vi.mock('@/lib/store/history-store', () => ({
useHistoryStore: vi.fn()
}));
// Mock DraftService
vi.mock('@/lib/db/draft-service', () => ({
DraftService: {
getCompletedDrafts: vi.fn()
}
}));
const mockDraft = {
id: 1,
sessionId: 'test-session',
title: 'Test Draft',
content: 'Test content',
tags: ['test'],
createdAt: Date.now(),
completedAt: Date.now(),
status: 'completed' as const
};
describe('HistoryFeed', () => {
const mockLoadMore = vi.fn();
const mockClearError = vi.fn();
const mockRefreshHistory = vi.fn();
const mockSelectDraft = vi.fn();
const mockCloseDetail = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
// Default mock store state
vi.mocked(useHistoryStore).mockImplementation((selector) => {
const state = {
drafts: [],
loading: false,
hasMore: true,
error: null,
loadMore: mockLoadMore,
clearError: mockClearError,
refreshHistory: mockRefreshHistory,
selectDraft: mockSelectDraft,
closeDetail: mockCloseDetail,
selectedDraft: null
};
return selector ? selector(state) : state;
});
});
describe('initial loading', () => {
it('shows loading spinner on initial load', () => {
vi.mocked(useHistoryStore).mockImplementation((selector) => {
const state = {
drafts: [],
loading: true,
hasMore: true,
error: null,
loadMore: mockLoadMore,
clearError: mockClearError,
refreshHistory: mockRefreshHistory,
selectDraft: mockSelectDraft,
closeDetail: mockCloseDetail,
selectedDraft: null
};
return selector ? selector(state) : state;
});
render(<HistoryFeed />);
expect(screen.getByText(/Loading more entries/i)).toBeInTheDocument();
});
it('calls loadMore on mount', () => {
render(<HistoryFeed />);
expect(mockLoadMore).toHaveBeenCalled();
});
});
describe('empty state', () => {
it('shows empty state when no drafts', async () => {
vi.mocked(useHistoryStore).mockImplementation((selector) => {
const state = {
drafts: [],
loading: false,
hasMore: true,
error: null,
loadMore: mockLoadMore,
clearError: mockClearError,
refreshHistory: mockRefreshHistory,
selectDraft: mockSelectDraft,
closeDetail: mockCloseDetail,
selectedDraft: null
};
return selector ? selector(state) : state;
});
render(<HistoryFeed />);
await waitFor(() => {
expect(screen.getByText('Your journey starts here')).toBeInTheDocument();
});
});
it('shows CTA button in empty state', async () => {
vi.mocked(useHistoryStore).mockImplementation((selector) => {
const state = {
drafts: [],
loading: false,
hasMore: true,
error: null,
loadMore: mockLoadMore,
clearError: mockClearError,
refreshHistory: mockRefreshHistory,
selectDraft: mockSelectDraft,
closeDetail: mockCloseDetail,
selectedDraft: null
};
return selector ? selector(state) : state;
});
render(<HistoryFeed />);
await waitFor(() => {
expect(screen.getByText('Start My First Vent')).toBeInTheDocument();
});
});
});
describe('rendering drafts', () => {
it('renders draft cards', () => {
vi.mocked(useHistoryStore).mockImplementation((selector) => {
const state = {
drafts: [mockDraft],
loading: false,
hasMore: true,
error: null,
loadMore: mockLoadMore,
clearError: mockClearError,
refreshHistory: mockRefreshHistory,
selectDraft: mockSelectDraft,
closeDetail: mockCloseDetail,
selectedDraft: null
};
return selector ? selector(state) : state;
});
render(<HistoryFeed />);
expect(screen.getByText('Test Draft')).toBeInTheDocument();
});
it('shows loading indicator when loading more', () => {
vi.mocked(useHistoryStore).mockImplementation((selector) => {
const state = {
drafts: [mockDraft],
loading: true,
hasMore: true,
error: null,
loadMore: mockLoadMore,
clearError: mockClearError,
refreshHistory: mockRefreshHistory,
selectDraft: mockSelectDraft,
closeDetail: mockCloseDetail,
selectedDraft: null
};
return selector ? selector(state) : state;
});
render(<HistoryFeed />);
expect(screen.getByText(/Loading more entries/i)).toBeInTheDocument();
});
});
describe('error state', () => {
it('shows error message and retry button', () => {
vi.mocked(useHistoryStore).mockImplementation((selector) => {
const state = {
drafts: [],
loading: false,
hasMore: true,
error: 'Failed to load history',
loadMore: mockLoadMore,
clearError: mockClearError,
refreshHistory: mockRefreshHistory,
selectDraft: mockSelectDraft,
closeDetail: mockCloseDetail,
selectedDraft: null
};
return selector ? selector(state) : state;
});
render(<HistoryFeed />);
expect(screen.getByText('Failed to load history')).toBeInTheDocument();
expect(screen.getByText('Retry')).toBeInTheDocument();
});
it('clears error and retries when retry clicked', async () => {
vi.mocked(useHistoryStore).mockImplementation((selector) => {
const state = {
drafts: [],
loading: false,
hasMore: true,
error: 'Failed to load history',
loadMore: mockLoadMore,
clearError: mockClearError,
refreshHistory: mockRefreshHistory,
selectDraft: mockSelectDraft,
closeDetail: mockCloseDetail,
selectedDraft: null
};
return selector ? selector(state) : state;
});
const user = userEvent.setup();
render(<HistoryFeed />);
const retryButton = screen.getByText('Retry');
await user.click(retryButton);
expect(mockClearError).toHaveBeenCalled();
expect(mockLoadMore).toHaveBeenCalled();
});
});
describe('end of list', () => {
it('shows end message when no more drafts', () => {
vi.mocked(useHistoryStore).mockImplementation((selector) => {
const state = {
drafts: [mockDraft],
loading: false,
hasMore: false,
error: null,
loadMore: mockLoadMore,
clearError: mockClearError,
refreshHistory: mockRefreshHistory,
selectDraft: mockSelectDraft,
closeDetail: mockCloseDetail,
selectedDraft: null
};
return selector ? selector(state) : state;
});
render(<HistoryFeed />);
expect(screen.getByText("You've reached the beginning")).toBeInTheDocument();
});
});
describe('week-based grouping', () => {
it('groups drafts by week with headers', () => {
const now = new Date('2026-01-25T10:00:00');
const lastWeek = new Date('2026-01-18T10:00:00');
const draftsFromDifferentWeeks = [
{ ...mockDraft, id: 1, title: 'Draft 1', createdAt: now.getTime() },
{ ...mockDraft, id: 2, title: 'Draft 2', createdAt: now.getTime() },
{ ...mockDraft, id: 3, title: 'Draft 3', createdAt: lastWeek.getTime() },
];
vi.mocked(useHistoryStore).mockImplementation((selector) => {
const state = {
drafts: draftsFromDifferentWeeks,
loading: false,
hasMore: false,
error: null,
loadMore: mockLoadMore,
clearError: mockClearError,
refreshHistory: mockRefreshHistory,
selectDraft: mockSelectDraft,
closeDetail: mockCloseDetail,
selectedDraft: null
};
return selector ? selector(state) : state;
});
render(<HistoryFeed />);
// Should show week headers (W4 - 2026 and W3 - 2026)
expect(screen.getByText(/W4 - 2026/)).toBeInTheDocument();
expect(screen.getByText(/W3 - 2026/)).toBeInTheDocument();
// Should show all drafts
expect(screen.getByText('Draft 1')).toBeInTheDocument();
expect(screen.getByText('Draft 2')).toBeInTheDocument();
expect(screen.getByText('Draft 3')).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,171 @@
'use client';
import { useEffect, useRef, useState, useMemo } from 'react';
import { Loader2 } from 'lucide-react';
import { useHistoryStore } from '@/lib/store/history-store';
import { HistoryCard } from './HistoryCard';
import { EmptyHistoryState } from './EmptyHistoryState';
import type { Draft } from '@/lib/db/draft-service';
/**
* Get ISO week number and year for a given date
* Returns format: "W{week} - {year}"
*/
function getWeekLabel(date: Date): string {
const d = new Date(date);
// Set to nearest Thursday (current date + 4 - current day number)
// Make Sunday's day number 7
d.setDate(d.getDate() + 4 - (d.getDay() || 7));
// Get first day of year
const yearStart = new Date(d.getFullYear(), 0, 1);
// Calculate full weeks to nearest Thursday
const weekNo = Math.ceil((((d.getTime() - yearStart.getTime()) / 86400000) + 1) / 7);
return `W${weekNo} - ${d.getFullYear()}`;
}
/**
* Group drafts by week
*/
function groupDraftsByWeek(drafts: Draft[]): Map<string, Draft[]> {
const groups = new Map<string, Draft[]>();
drafts.forEach(draft => {
const weekLabel = getWeekLabel(new Date(draft.createdAt));
if (!groups.has(weekLabel)) {
groups.set(weekLabel, []);
}
groups.get(weekLabel)!.push(draft);
});
return groups;
}
/**
* HistoryFeed Component
*
* Story 3.1: List of completed drafts with lazy loading
*
* Features:
* - Week-based grouping with headers (W36 - 2026)
* - Lazy loading (infinite scroll)
* - Loading spinner at bottom
* - Empty state for new users
* - Error state with retry
* - Progressive fade-in for new items
*
* Architecture Compliance:
* - Uses atomic selectors from HistoryStore
* - Triggers loadMore when scrolling near bottom
*/
export function HistoryFeed() {
const drafts = useHistoryStore((s) => s.drafts);
const loading = useHistoryStore((s) => s.loading);
const hasMore = useHistoryStore((s) => s.hasMore);
const error = useHistoryStore((s) => s.error);
const loadMore = useHistoryStore((s) => s.loadMore);
const clearError = useHistoryStore((s) => s.clearError);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [initialLoad, setInitialLoad] = useState(true);
// Group drafts by week
const groupedDrafts = useMemo(() => groupDraftsByWeek(drafts), [drafts]);
// Initial load on mount
useEffect(() => {
loadMore().finally(() => {
setInitialLoad(false);
});
}, [loadMore]);
// Infinite scroll handler
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
const target = e.target as HTMLDivElement;
const scrollBottom = target.scrollHeight - target.scrollTop - target.clientHeight;
// Trigger loadMore when 200px from bottom
if (scrollBottom < 200 && hasMore && !loading && !error) {
loadMore();
}
};
const handleRetry = () => {
clearError();
loadMore();
};
const handleStartVent = () => {
// Navigate to chat page
window.location.href = '/chat';
};
// Empty state
if (!initialLoad && drafts.length === 0 && !loading) {
return <EmptyHistoryState onStartVent={handleStartVent} />;
}
return (
<div className="history-feed flex flex-col h-full">
{/* Scrollable feed container */}
<div
ref={scrollContainerRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto px-4 py-4"
>
{/* Render grouped drafts by week */}
{Array.from(groupedDrafts.entries()).map(([weekLabel, weekDrafts]) => (
<div key={weekLabel} className="mb-6">
{/* Week separator header */}
<div className="flex items-center justify-center gap-3 mt-6 mb-4">
<div className="h-px flex-1 max-w-[100px] bg-slate-200" />
<span className="text-xs font-medium text-slate-500 uppercase tracking-wide px-3 py-1 bg-slate-50 rounded-full border border-slate-200">
{weekLabel}
</span>
<div className="h-px flex-1 max-w-[100px] bg-slate-200" />
</div>
{/* Drafts for this week */}
<div className="space-y-3">
{weekDrafts.map((draft) => (
<HistoryCard
key={draft.id}
draft={draft}
onClick={(draft) => useHistoryStore.getState().selectDraft(draft)}
/>
))}
</div>
</div>
))}
{/* Loading indicator at bottom */}
{loading && (
<div className="flex justify-center py-4">
<Loader2 className="w-6 h-6 text-slate-400 animate-spin" aria-hidden="true" />
<span className="sr-only">Loading more entries...</span>
</div>
)}
{/* Error state with retry */}
{error && (
<div className="flex flex-col items-center py-4 px-6 bg-red-50 rounded-lg border border-red-200">
<p className="text-red-700 mb-2">{error}</p>
<button
onClick={handleRetry}
type="button"
className="min-h-[44px] px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
>
Retry
</button>
</div>
)}
{/* End of list message */}
{!hasMore && drafts.length > 0 && !loading && (
<p className="text-center text-slate-500 py-4 font-sans">
You've reached the beginning
</p>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,9 @@
/**
* Journal Feature Exports
* Story 3.1: History feed and related components
*/
export { HistoryCard } from './HistoryCard';
export { HistoryFeed } from './HistoryFeed';
export { HistoryDetailSheet } from './HistoryDetailSheet';
export { EmptyHistoryState } from './EmptyHistoryState';