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 => (
+ setStatusFilter(chip.value)}
+ aria-pressed={statusFilter === chip.value}
+ className={`text-xs font-mono px-3 py-1 rounded-full border transition-colors ${
+ statusFilter === chip.value
+ ? 'bg-blue-500/15 border-blue-500/50 text-blue-400'
+ : 'bg-dark-surface border-dark-border text-dark-muted hover:text-dark-heading'
+ }`}
+ >
+ {chip.label}
+
+ ))}
+
+
+
+
+ {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."
}
}