diff --git a/.claude/skills/verify-debatelab/features/README.md b/.claude/skills/verify-debatelab/features/README.md index 29130b00c..7a136faf8 100644 --- a/.claude/skills/verify-debatelab/features/README.md +++ b/.claude/skills/verify-debatelab/features/README.md @@ -54,4 +54,4 @@ Each feature file starts with an H1 title and one paragraph describing the user- - Billing: expired account blocked, promo code restores access (`recipes/billing.spec.ts`); signed, forged, and replayed Stripe webhooks (`e2e/full-stack/billing.spec.ts`). - Admin announcements and the non-admin block (`e2e/full-stack/admin.spec.ts`), public share links (`e2e/full-stack/public.spec.ts`), and Workspaces with live collaboration and invites (`e2e/workspace-collaboration.spec.ts`). -Not yet mapped in recipes: theme and UI language, the public landing and SEO pages, and share-link creation (the app has no client caller for the `share` function). The agentic suite covers UI language switching (`agentic/workspace.e2e.ts`) and renders the public pages in every language (`agentic/pages.e2e.ts`). +Not yet mapped in recipes: theme and UI language, the public landing and SEO pages, the free no-login tools under `/tools` and `/embed/debate-timer` (no backend; `e2e/free-tools.spec.ts` covers them), and share-link creation (the app has no client caller for the `share` function). The agentic suite covers UI language switching (`agentic/workspace.e2e.ts`) and renders the public pages in every language (`agentic/pages.e2e.ts`). diff --git a/docs/operations.md b/docs/operations.md index 58abd5e2e..9e33f997c 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -46,12 +46,16 @@ Vercel builds and serves the frontend from the Git repository. Merging to `main` - 301 redirects from the retired brand pages (`/debatelab`, `/debatelab-by-programcode`, and the rest) to `/`; - 301 redirects from retired guide slugs to the guide that replaced them, such as `/debate-weighing-guide` to `/how-to-weigh-in-debate`. The `trailingSlash` 308 runs first, so `/debate-weighing-guide/` reaches the same page in two permanent hops and needs no rule of its own; - no-cache headers for `/`, `index.html`, `app-shell.html`, `theme-init.js`, and `sw.js`, and immutable caching for hashed `/assets/`; -- `noindex` headers for `/app`, `/login`, and `/share` routes; -- Content Security Policy and the other response security headers, including the `Permissions-Policy` that allows the microphone for the app's own origin only. +- `noindex` headers for `/app`, `/login`, `/share` and `/embed/debate-timer`; +- Content Security Policy and the other response security headers, including the `Permissions-Policy` that allows the microphone for the app's own origin only. Every path except `/embed/debate-timer` refuses to be framed (`X-Frame-Options: DENY` and `frame-ancestors 'none'`). `/embed/debate-timer` allows any site to frame it and gets its own CSP, which permits only same-origin scripts and requests. The two rules use complementary sources (`/((?!embed/debate-timer$).*)` and `/embed/debate-timer`) so no path gets both, a new embed needs its path added to both, and `src/test/vercel-deployment-config.test.ts` fails if two matching rules ever set the same header. After `vite build`, [`scripts/prerender.mjs`](../scripts/prerender.mjs) renders the landing page, `/terms`, `/privacy`, and the 404 page in Node through [`src/prerender/entry.tsx`](../src/prerender/entry.tsx). It writes them to `dist/index.html`, `dist/terms/index.html`, `dist/privacy/index.html`, and `dist/404.html`, each with its own title, description, and canonical URL from [`src/lib/seo/pages.ts`](../src/lib/seo/pages.ts); the landing page also carries the Organization and SoftwareApplication JSON-LD. The built shell is kept as `dist/app-shell.html`. On load, the client replaces the prerendered HTML with the same page. A new public SPA page needs an entry in `PAGE_HEADS`, in the prerender entry, and a route kept out of the 404. -[`scripts/sitemap.mjs`](../scripts/sitemap.mjs) then writes `dist/sitemap.xml` from every page in `dist/` that names itself as canonical and is not `noindex`. Each `` is the `dateModified` the page declares in its JSON-LD: the guide pages' `CONTENT_DATE` in [`scripts/generate_seo_content.py`](../scripts/generate_seo_content.py), the legal pages' "last updated" dates, and the landing page's last source commit. A page without a date is listed without ``. +The free tools under `/tools` ([`src/pages/tools/`](../src/pages/tools/)) are prerendered the same way, listed in [`src/prerender/tool-pages.tsx`](../src/prerender/tool-pages.tsx) with heads in [`src/lib/seo/tool-pages.ts`](../src/lib/seo/tool-pages.ts). They render their own JSON-LD in the page body, so the dev server and the prerendered HTML carry the same structured data, and their `dateModified` is `TOOL_PAGES_MODIFIED` in that file: bump it with a substantive content change. Their speech times come from [`src/lib/free-tools/formats.ts`](../src/lib/free-tools/formats.ts), which cites a league source per format. + +`/embed/debate-timer` is a second Vite HTML entry ([`embed/debate-timer/index.html`](../embed/debate-timer/index.html)), not an app route: it loads only the timer, never the app shell, Jam, analytics or the service worker, so a site that frames it sees no third-party request. It is `noindex` with its canonical URL on `/tools/debate-timer`, and the service worker leaves `/embed/` alone. + +[`scripts/sitemap.mjs`](../scripts/sitemap.mjs) then writes `dist/sitemap.xml` from every page in `dist/` that names itself as canonical and is not `noindex`. Each `` is the `dateModified` the page declares in its JSON-LD: the guide pages' `CONTENT_DATE` in [`scripts/generate_seo_content.py`](../scripts/generate_seo_content.py), the legal pages' "last updated" dates, the tool pages' `TOOL_PAGES_MODIFIED`, and the landing page's last source commit. A page without a date is listed without ``. The guide pages in `public//index.html` are generated, not hand-edited: run `python3 scripts/generate_seo_content.py` and commit its output. Short pages live in its `PAGES` list; pages with long-form copy, their own highlight-card descriptions, and their own FAQs (which also feed the page's FAQPage JSON-LD) live in [`scripts/seo_guides.py`](../scripts/seo_guides.py). Keep `PAGES` in order, because each page's related-guide links are picked by position. A guide entry can set its own `modified` date, which overrides `CONTENT_DATE` for that page alone. diff --git a/docs/testing.md b/docs/testing.md index 0a808b87b..0627e816c 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -131,6 +131,8 @@ The server is never reused. CI uses retries and two workers. On failure, traces, `e2e/first-run.spec.ts` drives the same first run against stubbed Supabase routes in English and Hungarian, so it proves the client flow only. +`e2e/free-tools.spec.ts` (tag `@smoke`) opens every `/tools` page signed out and checks its heading, canonical URL and JSON-LD, and that it makes no Supabase request. On a paused Playwright clock it runs the timer past the BP protected minute, through a reload, and runs the two Public Forum prep banks; it also draws a motion set and reloads its permalink, times a pasted speech, lays out a BP flow sheet under print media, and checks that `/embed/debate-timer` is `noindex` and requests nothing from another origin. The format data, timer transitions, custom-format links, motion filters and speech-time arithmetic have unit tests in `src/test/free-tools/`. The dev server cannot prove the response headers: `src/test/vercel-deployment-config.test.ts` checks which paths may be framed. + `src/test/i18n-keys.test.ts` fails when a literal `t()` key in `src/` is missing from `en.json`, or when a call that passes `count` lacks its `_one` and `_other` forms. Dynamic keys such as ``t(`nav.${key}`)`` are out of its reach. `src/test/i18n.test.ts` requires the same keys in all three catalogs. Together they prove every literal key has a Spanish and Hungarian entry, not that the translation is natural. The speech unit test proves that Hungarian selects `recognition.lang = 'hu-HU'`. Credential-free browser automation has no real microphone or speech service, so it does not prove recognition accuracy or hardware support. diff --git a/e2e/free-tools.spec.ts b/e2e/free-tools.spec.ts new file mode 100644 index 000000000..193148139 --- /dev/null +++ b/e2e/free-tools.spec.ts @@ -0,0 +1,174 @@ +import { test, expect, type Page } from '@playwright/test'; + +const ORIGIN = 'https://debate.pi-stack.org'; +const TOOL_PAGES = [ + { path: '/tools', h1: 'Free debate tools', type: 'CollectionPage' }, + { path: '/tools/debate-timer', h1: 'Free debate timer for every format', type: 'WebApplication' }, + { path: '/tools/debate-motion-generator', h1: 'Debate motion generator', type: 'WebApplication' }, + { path: '/tools/debate-flow-sheet', h1: 'Printable debate flow sheet', type: 'WebApplication' }, + { path: '/tools/speech-time-calculator', h1: 'How long is my speech? Speech time calculator', type: 'WebApplication' }, +] as const; + +/** Every request the page makes to a Supabase host or API path. The free tools need none. */ +function watchSupabase(page: Page): string[] { + const calls: string[] = []; + page.on('request', (request) => { + const url = new URL(request.url()); + if (url.hostname.endsWith('supabase.co') || /^\/(auth|rest|functions|storage)\/v1\//.test(url.pathname)) calls.push(request.url()); + }); + return calls; +} + +const T0 = new Date('2026-10-22T10:00:00Z').getTime(); + +/** Opens a page on a fake clock that only moves when the test runs it forward. */ +async function openPaused(page: Page, path: string) { + await page.clock.install({ time: T0 }); + await page.goto(path); + await page.clock.pauseAt(T0 + 5_000); +} + +function watchErrors(page: Page): string[] { + const errors: string[] = []; + page.on('pageerror', (error) => errors.push(error.message)); + page.on('console', (message) => { + if (message.type() === 'error' && !/Failed to load resource|net::ERR_/.test(message.text())) errors.push(message.text()); + }); + return errors; +} + +test.describe('free debate tools', { tag: '@smoke' }, () => { + for (const tool of TOOL_PAGES) { + test(`${tool.path} renders signed out with its head and structured data`, async ({ page }) => { + const supabase = watchSupabase(page); + const errors = watchErrors(page); + const response = await page.goto(tool.path); + expect(response?.ok()).toBeTruthy(); + await expect(page.getByRole('heading', { level: 1, name: tool.h1 })).toBeVisible(); + await expect(page.locator('link[rel="canonical"]')).toHaveAttribute('href', `${ORIGIN}${tool.path}`); + await expect(page).toHaveTitle(/DebateLab/); + const graphs = await page.locator('script[type="application/ld+json"]').allTextContents(); + const types = graphs.flatMap((json) => (JSON.parse(json)['@graph'] as { '@type': string }[]).map((node) => node['@type'])); + expect(types).toContain(tool.type); + expect(types).toContain('BreadcrumbList'); + expect(JSON.stringify(graphs)).not.toMatch(/aggregateRating|"review"/); + expect(supabase, supabase.join('\n')).toEqual([]); + expect(errors, errors.join('\n')).toEqual([]); + }); + } + + test('the timer starts, pauses and opens points of information after the first minute', async ({ page }) => { + await openPaused(page, '/tools/debate-timer?format=bp'); + const clock = page.getByRole('timer', { name: 'Speech time remaining' }); + await expect(clock).toHaveText('7:00'); + await expect(page.getByText('Protected time', { exact: true })).toBeVisible(); + + await page.getByRole('button', { name: 'Start' }).click(); + await page.clock.runFor(61_000); + await expect(clock).toHaveText('5:59'); + await expect(page.getByText('POIs open', { exact: true })).toBeVisible(); + + await page.getByRole('button', { name: 'Pause' }).click(); + await page.clock.runFor(5_000); + await expect(clock).toHaveText('5:59'); + + // The round survives a reload. + await page.reload(); + await expect(page.getByRole('timer', { name: 'Speech time remaining' })).toHaveText('5:59'); + + // Keys act once the timer has focus. + await page.getByRole('timer', { name: 'Speech time remaining' }).click(); + await page.keyboard.press('ArrowRight'); + await expect(page.getByRole('heading', { level: 2, name: 'Leader of the Opposition (Opening Opposition)' })).toBeVisible(); + }); + + test('a custom format applies, lands in the share link, and survives a reload', async ({ page }) => { + await page.goto('/tools/debate-timer?format=wsdc'); + await page.getByRole('button', { name: 'Build a custom format' }).click(); + const editor = page.getByRole('form', { name: 'Custom format' }); + await editor.getByLabel('Format name').fill('Club round'); + // Whole and fractional minutes both submit; the browser's step check once blocked every value. + await editor.getByLabel('Minutes', { exact: true }).first().fill('5'); + await editor.getByLabel('Minutes', { exact: true }).nth(1).fill('2.5'); + await editor.getByRole('button', { name: 'Use this format' }).click(); + await expect(editor).toHaveCount(0); + await expect(page).toHaveURL(/format=custom&custom=[A-Za-z0-9_-]+/); + await expect(page.getByRole('timer', { name: 'Speech time remaining' })).toHaveText('5:00'); + await page.reload(); + await expect(page.getByRole('region', { name: /round timer/ }).getByLabel('Format')).toHaveValue('custom'); + await expect(page.getByRole('navigation', { name: 'Speeches' })).toContainText('2:30'); + }); + + test('prep banks run down one side at a time', async ({ page }) => { + await openPaused(page, '/tools/debate-timer?format=pf'); + const teamA = page.getByRole('button', { name: /Team A prep/ }); + const teamB = page.getByRole('button', { name: /Team B prep/ }); + await teamA.click(); + await page.clock.runFor(30_000); + await teamB.click(); + await page.clock.runFor(10_000); + await expect(teamA).toContainText('2:30'); + await expect(teamB).toContainText('2:50'); + await expect(teamB).toHaveAttribute('aria-pressed', 'true'); + await expect(teamA).toHaveAttribute('aria-pressed', 'false'); + }); + + test('the embed renders the format alone, makes no third-party request, and stays out of the index', async ({ page, baseURL }) => { + const external: string[] = []; + page.on('request', (request) => { + if (new URL(request.url()).origin !== new URL(baseURL!).origin) external.push(request.url()); + }); + await page.goto('/embed/debate-timer?format=wsdc'); + await expect(page.getByRole('heading', { level: 2, name: 'First Proposition' })).toBeVisible(); + await expect(page.locator('meta[name="robots"]')).toHaveAttribute('content', 'noindex'); + await expect(page.locator('link[rel="canonical"]')).toHaveAttribute('href', `${ORIGIN}/tools/debate-timer`); + const attribution = page.getByRole('link', { name: 'Timer by DebateLab' }); + await expect(attribution).toHaveAttribute('href', `${ORIGIN}/tools/debate-timer`); + await expect(attribution).not.toHaveAttribute('rel', /nofollow/); + await expect(page.getByLabel('Format')).toHaveCount(0); + await page.getByRole('button', { name: 'Start' }).click(); + await expect(page.getByRole('button', { name: 'Pause' })).toBeVisible(); + // Running for a few seconds gives any stray request time to show up. + await expect(page.getByRole('timer', { name: 'Speech time remaining' })).toHaveText('7:57', { timeout: 10_000 }); + expect(external, external.join('\n')).toEqual([]); + }); + + test('a drawn set of motions keeps its permalink', async ({ page }) => { + await page.goto('/tools/debate-motion-generator'); + await page.getByRole('button', { name: 'Education', exact: true }).click(); + await page.getByRole('button', { name: 'Draw three' }).click(); + const options = page.getByRole('region', { name: 'Motion generator' }).locator('ol > li'); + await expect(options).toHaveCount(3); + const drawn = await options.locator('p').first().textContent(); + await expect(page).toHaveURL(/[?&]m=edu-\d{3}%2Cedu-\d{3}%2Cedu-\d{3}/); + await page.reload(); + await expect(page.getByRole('region', { name: 'Motion generator' }).locator('ol > li').first().locator('p').first()).toHaveText(drawn!); + // The whole bank is on the page for readers and crawlers. + await expect(page.locator('#edu-001')).toBeAttached(); + }); + + test('the calculator times a pasted speech and fits it to speech slots', async ({ page }) => { + await page.goto('/tools/speech-time-calculator'); + await page.getByLabel('Your speech').fill(Array.from({ length: 260 }, (_, i) => `word${i}`).join(' ')); + await expect(page.getByTestId('word-count')).toHaveText('260'); + // 260 words at 130 wpm is two minutes: it fits a 2:00 final focus exactly. + await expect(page.getByTestId('slot-fits').locator('li', { hasText: 'PF 2:00' })).toContainText('0:00 spare'); + }); + + test('the flow sheet has a column per speech and prints', async ({ page }) => { + await page.goto('/tools/debate-flow-sheet'); + await page.getByLabel('Format').selectOption('bp'); + const sheet = page.getByTestId('flow-sheet'); + for (const speech of ['PM', 'LO', 'DPM', 'DLO', 'MG', 'MO', 'GW', 'OW']) await expect(sheet.getByText(speech, { exact: true })).toBeVisible(); + await page.emulateMedia({ media: 'print' }); + await expect(page.getByRole('button', { name: /Print/ })).toBeHidden(); + await expect(sheet).toBeVisible(); + }); + + test('the landing footer links to the tools hub', async ({ page }) => { + await page.goto('/'); + await page.getByRole('contentinfo').getByRole('link', { name: 'Free debate tools' }).click(); + await expect(page).toHaveURL(/\/tools$/); + await expect(page.getByRole('heading', { level: 1, name: 'Free debate tools' })).toBeVisible(); + }); +}); diff --git a/embed/debate-timer/index.html b/embed/debate-timer/index.html new file mode 100644 index 000000000..f83b79110 --- /dev/null +++ b/embed/debate-timer/index.html @@ -0,0 +1,21 @@ + + + + + + + Debate timer | DebateLab + + + + + + +
+ + + diff --git a/public/sw.js b/public/sw.js index 6f60af19c..9efb24cfe 100644 --- a/public/sw.js +++ b/public/sw.js @@ -113,6 +113,9 @@ self.addEventListener('fetch', (event) => { const url = new URL(request.url); if (url.origin !== self.location.origin) return; if (API_PATH_PREFIXES.some((prefix) => url.pathname.startsWith(prefix))) return; + // Embeds are framed on other sites: never store one as the app shell, and + // never answer one with the cached shell, which refuses to be framed. + if (url.pathname.startsWith('/embed/')) return; if (request.mode === 'navigate') { event.respondWith(navigationFallback(request)); diff --git a/src/App.tsx b/src/App.tsx index 50896a629..432da506b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -62,6 +62,11 @@ const FlowchartPage = lazy(routeLoaders["/app/flowchart"]); const LoginPage = lazy(routeLoaders["/login"]); const SourcesPage = lazy(routeLoaders["/expocit/sources"]); const NotFound = prerenderedPage("*"); +const ToolsHubPage = prerenderedPage("/tools"); +const DebateTimerToolPage = prerenderedPage("/tools/debate-timer"); +const MotionGeneratorToolPage = prerenderedPage("/tools/debate-motion-generator"); +const FlowSheetToolPage = prerenderedPage("/tools/debate-flow-sheet"); +const SpeechTimeCalculatorToolPage = prerenderedPage("/tools/speech-time-calculator"); const OnboardingTour = lazy(() => import("@/components/features/onboarding/OnboardingTour") .then(({ OnboardingTour: Tour }) => ({ default: Tour }))); @@ -276,6 +281,11 @@ function AppRoutes() { } /> } /> } /> + } /> + } /> + } /> + } /> + } /> {/* Protected App Routes */} void; className: string; label?: string; +}) { + const [text, setText] = useState(() => String(seconds / 60)); + const [editing, setEditing] = useState(false); + return ( + { setText(String(Math.round((seconds / 60) * 100) / 100)); setEditing(true); }} + onBlur={() => setEditing(false)} + onChange={(event) => { + setText(event.target.value); + const minutes = Number(event.target.value); + if (event.target.value.trim() !== '' && Number.isFinite(minutes)) onChange(clamp(minutes * 60, min, max)); + }} + /> + ); +} + +/** Builds a custom round from the current format: add, remove, rename and reorder speeches. */ +export function CustomFormatEditor({ choice, onApply }: { choice: FormatChoice; onApply: (choice: FormatChoice) => void }) { + const { t } = useTranslation('tools'); + const [open, setOpen] = useState(false); + const [tooLong, setTooLong] = useState(false); + const [draft, setDraft] = useState(() => (choice.id === 'custom' ? choice.custom : roundFormatToCustom(formatFor(choice)))); + + const begin = () => { + setDraft(choice.id === 'custom' ? choice.custom : roundFormatToCustom(formatFor(choice))); + setOpen(true); + }; + const setSegment = (index: number, change: Partial) => + setDraft((current) => ({ ...current, segments: current.segments.map((segment, i) => (i === index ? { ...segment, ...change } : segment)) })); + const move = (index: number, delta: number) => setDraft((current) => { + const segments = [...current.segments]; + const [moved] = segments.splice(index, 1); + segments.splice(index + delta, 0, moved); + return { ...current, segments }; + }); + const remove = (index: number) => setDraft((current) => ({ ...current, segments: current.segments.filter((_, i) => i !== index) })); + const add = () => setDraft((current) => ({ + ...current, + segments: [...current.segments, { label: t('custom.newSpeech', { n: current.segments.length + 1 }), seconds: 240, kind: 'speech', side: 'a', poi: false }], + })); + + const valid = draft.segments.length > 0 && draft.segments.every((segment) => segment.label.trim()); + + if (!open) { + return ( +
+ +
+ ); + } + + return ( +
{ + event.preventDefault(); + if (!valid) return; + const custom = { ...draft, name: draft.name.trim() || t('custom.defaultName'), segments: draft.segments.map((segment) => ({ ...segment, label: segment.label.trim() })) }; + // A format too long for its share link would also be lost on reload. + if (encodeCustomFormat(custom).length > CUSTOM_LIMITS.encoded) { + setTooLong(true); + return; + } + setTooLong(false); + onApply({ id: 'custom', custom }); + setOpen(false); + }} + > +

{t('custom.title')}

+
+ + +
+ +
    + {draft.segments.map((segment, index) => ( +
  1. + + + + + +
    + + + +
    +
  2. + ))} +
+

{t('custom.poiHint')}

+ {tooLong &&

{t('custom.tooLong')}

} +
+ + + +
+
+ ); +} diff --git a/src/components/free-tools/DebateTimer.tsx b/src/components/free-tools/DebateTimer.tsx new file mode 100644 index 000000000..217c120cb --- /dev/null +++ b/src/components/free-tools/DebateTimer.tsx @@ -0,0 +1,379 @@ +import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ChevronLeft, ChevronRight, Maximize, Minimize, Pause, Play, RotateCcw, Volume2, VolumeX } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { ClockAnnouncer, CLOCK_TONE_TEXT, clockTone } from '@/components/features/practice/RoundClock'; +import { useScreenWakeLock } from '@/hooks/use-screen-wake-lock'; +import { playSignal } from '@/lib/timer-signals'; +import { BUILT_IN_FORMATS, formatDuration, isBuiltInFormatId, type RoundSegment } from '@/lib/free-tools/formats'; +import { + clockFace, + cueBetween, + elapsedMs, + goToSegment, + initialRoundState, + isPrepRunning, + isSpeechRunning, + prepRemainingMs, + resetPrep, + resetSpeech, + segmentStatus, + settlePrep, + togglePrep, + toggleSpeech, + type PrepSide, +} from '@/lib/free-tools/round-timer'; +import { cn } from '@/lib/utils'; +import { formatFor, initialSession, searchForChoice, storeSession, type FormatChoice, type TimerSession } from './timer-session'; + +const SIDE_TONE: Record = { + a: 'bg-sky-600 dark:bg-sky-400', + b: 'bg-rose-600 dark:bg-rose-400', + both: 'bg-slate-500 dark:bg-slate-400', +}; + +function isTyping(target: EventTarget | null): boolean { + return target instanceof HTMLElement && (target.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)); +} + +function isActivatable(target: EventTarget | null): boolean { + return target instanceof HTMLElement && ['BUTTON', 'A', 'SUMMARY'].includes(target.tagName); +} + +/** The protected and open stretches of a speech, with a marker for where the speaker is. */ +function PoiStrip({ segment, elapsed }: { segment: RoundSegment; elapsed: number }) { + const { t } = useTranslation('tools'); + if (!segment.poi) return null; + const total = segment.seconds; + const opens = (segment.poi.opensAt / total) * 100; + const closes = (segment.poi.closesAt / total) * 100; + const position = Math.min(100, (elapsed / 1000 / total) * 100); + return ( +