diff --git a/src/app/[locale]/questions/page.tsx b/src/app/[locale]/questions/page.tsx index 4c0f380..00227b6 100644 --- a/src/app/[locale]/questions/page.tsx +++ b/src/app/[locale]/questions/page.tsx @@ -1,7 +1,7 @@ import { getTranslations, getLocale } from 'next-intl/server' import { CATEGORIES } from '@/lib/categories' import { getAllQuestionMeta } from '@/lib/content' -import { CategoryCard } from '@/components/questions/CategoryCard' +import { CategoryListClient } from '@/components/questions/CategoryListClient' import { QuizSetupPanel } from '@/components/quiz/QuizSetupPanel' export async function generateStaticParams() { @@ -16,14 +16,14 @@ export default async function QuestionsPage() { CATEGORIES.map(cat => [cat.slug, getAllQuestionMeta(locale, cat.slug).length]) ) - const categoriesWithCount = CATEGORIES.map(cat => ({ + const categories = CATEGORIES.map(cat => ({ cat, label: t(`categories.${cat.slug}`), count: categoryCounts[cat.slug], })) return ( -
+

{t('categories.title')} @@ -33,14 +33,10 @@ export default async function QuestionsPage() { categoryCounts={categoryCounts} />

-

+

{t('categories.subtitle')}

-
- {categoriesWithCount.map(({ cat, label, count }) => ( - - ))} -
+
) } diff --git a/src/app/[locale]/updates/page.tsx b/src/app/[locale]/updates/page.tsx index a38a990..d42e5b8 100644 --- a/src/app/[locale]/updates/page.tsx +++ b/src/app/[locale]/updates/page.tsx @@ -10,6 +10,24 @@ type ChangelogEntry = { } const CHANGELOG: ChangelogEntry[] = [ + { + date: '2026-06-05', + version: '1.8.0', + title: 'Category page redesign — filterable list with progress', + titlePt: 'Redesign da página de categorias — lista filtrável com progresso', + points: [ + 'Replaced the card grid with a full-width vertical list — cleaner and more readable', + 'Each category now shows a progress bar and a "X/total completed" label based on your local study history', + 'Added a filter bar with text search and quick chips: all / with progress / not started', + 'Renamed "Create Test" to "Quiz" throughout the app for consistency', + ], + pointsPt: [ + 'Grid de cards substituído por lista vertical full-width — mais limpa e legível', + 'Cada categoria agora exibe uma barra de progresso e o contador "X/total concluídas" baseado no histórico de estudo local', + 'Adicionada barra de filtro com busca por texto e chips rápidos: todas / com progresso / não iniciadas', + 'Renomeado "Criar Teste" para "Quiz" em todo o app para maior consistência', + ], + }, { date: '2026-06-05', version: '1.7.0', diff --git a/src/components/questions/CategoryCard.tsx b/src/components/questions/CategoryCard.tsx index 85f2767..22b428c 100644 --- a/src/components/questions/CategoryCard.tsx +++ b/src/components/questions/CategoryCard.tsx @@ -1,32 +1,49 @@ 'use client' import Link from 'next/link' -import { useLocale } from 'next-intl' -import { ProgressBadge } from './ProgressBadge' +import { useLocale, useTranslations } from 'next-intl' import type { Category } from '@/lib/categories' type Props = { category: Category label: string count: number + known: number } -export function CategoryCard({ category, label, count }: Props) { +export function CategoryCard({ category, label, count, known }: Props) { const locale = useLocale() + const t = useTranslations('categories') + const tProgress = useTranslations('progress') + const pct = count > 0 ? Math.round((known / count) * 100) : 0 return ( -
- {category.icon} - +
+
+ {category.icon} + {label} +
+ {count} {t('questionsTotal')}
-
- {label} +
+
-
{count} questions
+ {known > 0 ? ( +

+ {known}/{count} {tProgress('studied')} +

+ ) : ( +

{t('notStarted')}

+ )} ) } diff --git a/src/components/questions/CategoryListClient.tsx b/src/components/questions/CategoryListClient.tsx new file mode 100644 index 0000000..8beafa1 --- /dev/null +++ b/src/components/questions/CategoryListClient.tsx @@ -0,0 +1,105 @@ +'use client' + +import { useState, useEffect, useMemo } from 'react' +import { useTranslations } from 'next-intl' +import { CategoryCard } from './CategoryCard' +import { countProgress } from '@/lib/progress' +import type { Category } from '@/lib/categories' + +type CategoryItem = { + cat: Category + label: string + count: number +} + +type StatusFilter = 'all' | 'in-progress' | 'not-started' + +type Props = { + categories: CategoryItem[] +} + +export function CategoryListClient({ categories }: Props) { + const t = useTranslations('categories') + const [query, setQuery] = useState('') + const [statusFilter, setStatusFilter] = useState('all') + const [progressMap, setProgressMap] = useState>({}) + const [isHydrated, setIsHydrated] = useState(false) + + useEffect(() => { + const map: Record = {} + for (const { cat } of categories) { + const { known } = countProgress(cat.slug) + map[cat.slug] = known + } + setProgressMap(map) + setIsHydrated(true) + }, [categories]) + + const chips: { value: StatusFilter; label: string }[] = [ + { value: 'all', label: t('filterAll') }, + { value: 'in-progress', label: t('filterInProgress') }, + { value: 'not-started', label: t('filterNotStarted') }, + ] + + const filtered = useMemo(() => + categories.filter(({ cat, label }) => { + const q = query.toLowerCase() + const matchesQuery = label.toLowerCase().includes(q) || cat.slug.toLowerCase().includes(q) + const known = progressMap[cat.slug] ?? 0 + if (statusFilter === 'in-progress') return matchesQuery && known > 0 + if (statusFilter === 'not-started') return matchesQuery && known === 0 + return matchesQuery + }), + [categories, query, statusFilter, progressMap] + ) + + return ( +
+
+
+ 🔍 + setQuery(e.target.value)} + placeholder={t('filterPlaceholder')} + aria-label={t('filterPlaceholder')} + className="flex-1 bg-transparent text-sm text-dark-text placeholder-dark-muted outline-none font-mono" + /> +
+
+ {chips.map(chip => ( + + ))} +
+
+ +
+ {filtered.length === 0 ? ( +

{t('noCategoriesFound')}

+ ) : ( + filtered.map(({ cat, label, count }) => ( + + )) + )} +
+
+ ) +} diff --git a/src/components/questions/ProgressBadge.tsx b/src/components/questions/ProgressBadge.tsx deleted file mode 100644 index a17cabb..0000000 --- a/src/components/questions/ProgressBadge.tsx +++ /dev/null @@ -1,26 +0,0 @@ -'use client' - -import { useEffect, useState } from 'react' -import { countProgress } from '@/lib/progress' - -type Props = { - category: string - total: number -} - -export function ProgressBadge({ category, total }: Props) { - const [known, setKnown] = useState(0) - - useEffect(() => { - const { known: k } = countProgress(category) - setKnown(k) - }, [category]) - - if (known === 0) return null - - return ( - - {known}/{total} - - ) -} diff --git a/src/components/questions/__tests__/CategoryCard.test.tsx b/src/components/questions/__tests__/CategoryCard.test.tsx new file mode 100644 index 0000000..c8ced65 --- /dev/null +++ b/src/components/questions/__tests__/CategoryCard.test.tsx @@ -0,0 +1,68 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import { CategoryCard } from '../CategoryCard' +import type { Category } from '@/lib/categories' + +vi.mock('next/link', () => ({ + default: ({ href, children, className, style }: { href: string; children: React.ReactNode; className?: string; style?: React.CSSProperties }) => + {children}, +})) + +vi.mock('next-intl', () => ({ + useLocale: () => 'pt', + useTranslations: (ns: string) => (key: string) => { + const map: Record> = { + categories: { + questionsTotal: 'perguntas', + notStarted: 'não iniciado', + }, + progress: { + studied: 'concluídas', + }, + } + return map[ns]?.[key] ?? key + }, +})) + +const mockCategory: Category = { + slug: 'frontend', + icon: '⚛️', + color: 'text-blue-400', + borderColor: 'border-blue-500/30', + accentColor: '#58a6ff', +} + +describe('CategoryCard', () => { + it('renders category label and count', () => { + render() + expect(screen.getByText('Frontend')).toBeInTheDocument() + expect(screen.getByText('48 perguntas')).toBeInTheDocument() + }) + + it('renders "não iniciado" when known is 0', () => { + render() + expect(screen.getByText('não iniciado')).toBeInTheDocument() + }) + + it('renders progress label when known > 0', () => { + render() + expect(screen.getByText('17/48 concluídas')).toBeInTheDocument() + }) + + it('links to the correct category URL', () => { + render() + expect(screen.getByRole('link')).toHaveAttribute('href', '/pt/questions/frontend') + }) + + it('renders the category icon', () => { + render() + expect(screen.getByText('⚛️')).toBeInTheDocument() + }) + + it('renders a progress bar with correct width', () => { + const { container } = render() + const fill = container.querySelector('[data-testid="progress-fill"]') + expect(fill).toBeInTheDocument() + expect(fill).toHaveStyle({ width: '35%' }) + }) +}) diff --git a/src/components/questions/__tests__/CategoryListClient.test.tsx b/src/components/questions/__tests__/CategoryListClient.test.tsx new file mode 100644 index 0000000..ed92157 --- /dev/null +++ b/src/components/questions/__tests__/CategoryListClient.test.tsx @@ -0,0 +1,118 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { CategoryListClient } from '../CategoryListClient' +import type { Category } from '@/lib/categories' + +vi.mock('next/link', () => ({ + default: ({ href, children, className, style }: { href: string; children: React.ReactNode; className?: string; style?: React.CSSProperties }) => + {children}, +})) + +vi.mock('next-intl', () => ({ + useLocale: () => 'pt', + useTranslations: (ns: string) => (key: string) => { + const map: Record> = { + categories: { + filterAll: 'todas', + filterInProgress: 'com progresso', + filterNotStarted: 'não iniciadas', + filterPlaceholder: 'Filtrar categorias...', + noCategoriesFound: 'Nenhuma categoria encontrada.', + questionsTotal: 'perguntas', + notStarted: 'não iniciado', + }, + progress: { + studied: 'concluídas', + }, + } + return map[ns]?.[key] ?? key + }, +})) + +vi.mock('@/lib/progress', () => ({ + countProgress: vi.fn((slug: string) => { + if (slug === 'frontend') return { known: 5, review: 0 } + return { known: 0, review: 0 } + }), +})) + +const makeCategory = (slug: string): Category => ({ + slug, + icon: '📦', + color: 'text-blue-400', + borderColor: 'border-blue-500/30', + accentColor: '#58a6ff', +}) + +const categories = [ + { cat: makeCategory('frontend'), label: 'Frontend', count: 48 }, + { cat: makeCategory('backend'), label: 'Backend', count: 36 }, + { cat: makeCategory('devops'), label: 'DevOps', count: 24 }, +] + +describe('CategoryListClient', () => { + beforeEach(() => vi.clearAllMocks()) + + it('renders all categories by default', () => { + render() + expect(screen.getByText('Frontend')).toBeInTheDocument() + expect(screen.getByText('Backend')).toBeInTheDocument() + expect(screen.getByText('DevOps')).toBeInTheDocument() + }) + + it('filters categories by text query', () => { + render() + fireEvent.change(screen.getByPlaceholderText('Filtrar categorias...'), { + target: { value: 'front' }, + }) + expect(screen.getByText('Frontend')).toBeInTheDocument() + expect(screen.queryByText('Backend')).not.toBeInTheDocument() + expect(screen.queryByText('DevOps')).not.toBeInTheDocument() + }) + + it('chip "com progresso" shows only categories with known > 0', () => { + render() + fireEvent.click(screen.getByText('com progresso')) + expect(screen.getByText('Frontend')).toBeInTheDocument() + expect(screen.queryByText('Backend')).not.toBeInTheDocument() + expect(screen.queryByText('DevOps')).not.toBeInTheDocument() + }) + + it('chip "não iniciadas" shows only categories with known === 0', () => { + render() + fireEvent.click(screen.getByText('não iniciadas')) + expect(screen.queryByText('Frontend')).not.toBeInTheDocument() + expect(screen.getByText('Backend')).toBeInTheDocument() + expect(screen.getByText('DevOps')).toBeInTheDocument() + }) + + it('chip "todas" resets to show all categories', () => { + render() + fireEvent.click(screen.getByText('não iniciadas')) + fireEvent.click(screen.getByText('todas')) + expect(screen.getByText('Frontend')).toBeInTheDocument() + expect(screen.getByText('Backend')).toBeInTheDocument() + expect(screen.getByText('DevOps')).toBeInTheDocument() + }) + + it('shows empty message when no categories match', () => { + render() + fireEvent.change(screen.getByPlaceholderText('Filtrar categorias...'), { + target: { value: 'xyznotfound' }, + }) + expect(screen.getByText('Nenhuma categoria encontrada.')).toBeInTheDocument() + }) + + it('combines text and status filter', () => { + render() + fireEvent.click(screen.getByText('não iniciadas')) + fireEvent.change(screen.getByPlaceholderText('Filtrar categorias...'), { + target: { value: 'front' }, + }) + // frontend has known=5, so it's excluded from 'not-started' + expect(screen.queryByText('Frontend')).not.toBeInTheDocument() + expect(screen.queryByText('Backend')).not.toBeInTheDocument() + expect(screen.queryByText('DevOps')).not.toBeInTheDocument() + expect(screen.getByText('Nenhuma categoria encontrada.')).toBeInTheDocument() + }) +}) diff --git a/src/lib/categories.ts b/src/lib/categories.ts index 6010154..8300994 100644 --- a/src/lib/categories.ts +++ b/src/lib/categories.ts @@ -3,16 +3,17 @@ export type Category = { icon: string color: string borderColor: string + accentColor: string // hex — usado no border-left e na barra de progresso } export const CATEGORIES: Category[] = [ - { slug: 'frontend', icon: '⚛️', color: 'text-blue-400', borderColor: 'border-blue-500/30' }, - { slug: 'backend', icon: '🖥️', color: 'text-green-400', borderColor: 'border-green-500/30' }, - { slug: 'devops', icon: '🐳', color: 'text-orange-400', borderColor: 'border-orange-500/30' }, - { slug: 'data', icon: '📊', color: 'text-purple-400', borderColor: 'border-purple-500/30' }, - { slug: 'soft-skills', icon: '🗣️', color: 'text-pink-400', borderColor: 'border-pink-500/30' }, - { slug: 'architecture', icon: '🏗️', color: 'text-yellow-400', borderColor: 'border-yellow-500/30' }, - { slug: 'security', icon: '🔒', color: 'text-red-400', borderColor: 'border-red-500/30' }, + { slug: 'frontend', icon: '⚛️', color: 'text-blue-400', borderColor: 'border-blue-500/30', accentColor: '#58a6ff' }, + { slug: 'backend', icon: '🖥️', color: 'text-green-400', borderColor: 'border-green-500/30', accentColor: '#3fb950' }, + { slug: 'devops', icon: '🐳', color: 'text-orange-400', borderColor: 'border-orange-500/30', accentColor: '#f0883e' }, + { slug: 'data', icon: '📊', color: 'text-purple-400', borderColor: 'border-purple-500/30', accentColor: '#bc8cff' }, + { slug: 'soft-skills', icon: '🗣️', color: 'text-pink-400', borderColor: 'border-pink-500/30', accentColor: '#ff7b72' }, + { slug: 'architecture', icon: '🏗️', color: 'text-yellow-400', borderColor: 'border-yellow-500/30', accentColor: '#e3b341' }, + { slug: 'security', icon: '🔒', color: 'text-red-400', borderColor: 'border-red-500/30', accentColor: '#f85149' }, ] export function getCategoryBySlug(slug: string): Category | undefined { diff --git a/src/messages/en.json b/src/messages/en.json index c1876d0..7b572ce 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -53,6 +53,12 @@ "focusModeNext": "Next", "focusModePrev": "Previous", "focusModeClose": "Close", + "notStarted": "not started", + "filterPlaceholder": "Filter categories...", + "filterAll": "all", + "filterInProgress": "with progress", + "filterNotStarted": "not started", + "noCategoriesFound": "No categories found.", "frontend": "Frontend", "backend": "Backend", "devops": "DevOps", @@ -94,7 +100,7 @@ "madeBy": "Made by" }, "quiz": { - "createTest": "Create Test", + "createTest": "Quiz", "categories": "Categories", "questionCount": "Questions", "seniority": "Seniority", @@ -109,18 +115,18 @@ "known": "Got it", "partial": "Sort of", "unknown": "Wrong", - "endTest": "End test", + "endTest": "End Quiz", "endConfirm": "End now? Remaining questions will be marked as unknown.", "result": "Result", "byCategory": "By Category", "score": "Score", "duration": "Duration", - "newTest": "New Test", + "newTest": "New Quiz", "viewHistory": "View History", - "history": "Test History", - "noHistory": "No tests completed yet.", + "history": "Quiz History", + "noHistory": "No quizzes completed yet.", "clearHistory": "Clear history", "clearHistoryConfirm": "Delete all history?", - "notFound": "Test not found." + "notFound": "Quiz not found." } } diff --git a/src/messages/pt.json b/src/messages/pt.json index 0e4d109..356a7b4 100644 --- a/src/messages/pt.json +++ b/src/messages/pt.json @@ -53,6 +53,12 @@ "focusModeNext": "Próxima", "focusModePrev": "Anterior", "focusModeClose": "Fechar", + "notStarted": "não iniciado", + "filterPlaceholder": "Filtrar categorias...", + "filterAll": "todas", + "filterInProgress": "com progresso", + "filterNotStarted": "não iniciadas", + "noCategoriesFound": "Nenhuma categoria encontrada.", "frontend": "Frontend", "backend": "Backend", "devops": "DevOps", @@ -94,7 +100,7 @@ "madeBy": "Feito por" }, "quiz": { - "createTest": "Criar Teste", + "createTest": "Quiz", "categories": "Categorias", "questionCount": "Perguntas", "seniority": "Senioridade", @@ -109,18 +115,18 @@ "known": "Acertei", "partial": "Mais ou menos", "unknown": "Errei", - "endTest": "Encerrar teste", + "endTest": "Encerrar Quiz", "endConfirm": "Encerrar agora? As perguntas restantes serão marcadas como não sabia.", "result": "Resultado", "byCategory": "Por Categoria", "score": "Aproveitamento", "duration": "Duração", - "newTest": "Novo Teste", + "newTest": "Novo Quiz", "viewHistory": "Ver Histórico", - "history": "Histórico de Testes", - "noHistory": "Nenhum teste realizado ainda.", + "history": "Histórico de Quiz", + "noHistory": "Nenhum quiz realizado ainda.", "clearHistory": "Limpar histórico", "clearHistoryConfirm": "Apagar todo o histórico?", - "notFound": "Teste não encontrado." + "notFound": "Quiz não encontrado." } }