Skip to content
Merged
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
50 changes: 21 additions & 29 deletions app/src/components/announcement-banner.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,19 @@
import { useState } from 'react'
import type React from 'react'
import { useTranslation } from 'react-i18next'
import { ArrowRight, Sparkles, X } from 'lucide-react'
import { ArrowRight, Info, User, X } from 'lucide-react'
import { CUTOFF_AT } from '@mandatoto/shared/types'
import { storage } from '@/lib/storage'

const BANNER_ID = 'telex-tip-v1'
const BANNER_ID = 'election-started-v1'
const CUTOFF_MS = new Date(CUTOFF_AT).getTime()

function navigateToTelex(e: React.MouseEvent) {
e.preventDefault()
window.location.hash = 'jatek'
// Give the router one tick to mount the ballot page before scrolling
setTimeout(() => {
document.getElementById('telex-section')?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}, 80)
}

export function AnnouncementBanner() {
const { t } = useTranslation()

const shareToken = storage.getDraft<{ shareToken?: string }>()?.shareToken ?? null

const [dismissed, setDismissed] = useState(
() => storage.getDismissedBanners().includes(BANNER_ID) || Date.now() >= CUTOFF_MS,
() => storage.getDismissedBanners().includes(BANNER_ID) || Date.now() < CUTOFF_MS,
)

if (dismissed) return null
Expand All @@ -33,22 +26,21 @@ export function AnnouncementBanner() {
return (
<div className="relative mx-auto w-full max-w-6xl px-5 md:px-8">
<div className="flex items-center gap-2 rounded-b-xl border-x border-b border-amber-200 bg-amber-50 px-4 py-2.5 text-sm text-amber-900">
{/* On mobile the whole strip (minus X) is tappable */}
<a href="#jatek" onClick={navigateToTelex} className="flex min-w-0 flex-1 items-center gap-2 sm:contents">
<Sparkles className="size-4 shrink-0 text-amber-500" aria-hidden />
<p className="min-w-0 flex-1 text-xs leading-snug sm:text-sm">
{t('banner.text')}
</p>
</a>

<a
href="#jatek"
onClick={navigateToTelex}
className="hidden shrink-0 items-center gap-1 rounded-md bg-amber-500 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-amber-600 sm:inline-flex"
>
{t('banner.cta')}
<ArrowRight className="size-3" aria-hidden />
</a>
<Info className="size-4 shrink-0 text-amber-500" aria-hidden />
<p className="min-w-0 flex-1 text-xs leading-snug sm:text-sm">
{t('banner.text')}
</p>

{shareToken && (
<a
href={`#tipp/${shareToken}`}
className="inline-flex shrink-0 items-center gap-1 rounded-md bg-amber-500 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-amber-600"
>
<User className="size-3" aria-hidden />
{t('banner.cta')}
<ArrowRight className="size-3" aria-hidden />
</a>
)}

<button
type="button"
Expand Down
19 changes: 16 additions & 3 deletions app/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,7 @@
"descriptionPreResult": "Tip submission is only possible until <b>April 12, 2026 06:00 AM CEST</b> — after this, all modifications and submissions are disabled to ensure fair play. After the election, results will be updated in milestones: first update at <b>80% vote processing</b>, then at every <b>5% increment</b> (85%, 90%, 95%, 100%). Definitive results and final scoring can only be considered at <b>100% processing</b>.",
"resultsTitle": "Results",
"resultsListWinner": "Party list winner",
"resultsMandates": "Mandates",
"resultsPm": "Prime minister",
"resultsPercentages": "Party list results",
"rank": "#",
Expand All @@ -437,6 +438,7 @@
"individualSubtitle": "Finalized tips ranked by score.",
"individualSubtitlePreResult": "The most recently finalized tips. Rankings will appear after the election based on results.",
"resultsProcessing": "Vote Processing",
"resultsParticipation": "Turnout",
"processingAwaiting": "Awaiting results",
"processingComplete": "Processing complete",
"bestGroupsAvgScore": "Avg. score",
Expand All @@ -453,7 +455,18 @@
"tooltipTotal": "Total",
"profileRank": "#{{rank}} on leaderboard",
"topThreeTitle": "Top 3 predictions",
"topThreeSubtitle": "The most accurate predictions based on election results."
"topThreeSubtitle": "The most accurate predictions based on election results.",
"tabOverall": "Overall",
"tabNationalities": "Nationalities",
"tabParticipation": "Attendance",
"categoryPredicted": "Tip",
"categoryActual": "Actual",
"categoryError": "Error",
"categoryEmpty": "Not enough data for this category.",
"categoryTabTitle": "{{party}} best tips",
"categoryTabTitleNationalities": "Nationalities best tips",
"categoryTabTitleParticipation": "Attendance best tips",
"categorySubtitle": "Tip accuracy for this category, sorted by closest prediction to the actual result."
},
"groups": {
"title": "Groups",
Expand Down Expand Up @@ -531,8 +544,8 @@
}
},
"banner": {
"text": "New feature: attach your Telex mandate tip to your profile before the submission deadline!",
"cta": "Add it now",
"text": "Voting has begun and submissions are closed. Results will first update after the 19:00 polls close, once at least 60% of votes have been processed.",
"cta": "Your tip",
"dismiss": "Dismiss notification"
},
"telex": {
Expand Down
23 changes: 18 additions & 5 deletions app/src/locales/hu.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"logoAria": "Mandatoto főoldal",
"menuBallot": "Tippjáték",
"menuDistricts": "Választókerületek",
"menuLeaderboard": "Ranglista",
"menuLeaderboard": "Eredmények",
"menuStats": "Statisztikák",
"menuInfo": "Információ",
"comingSoon": "Hamarosan...",
Expand Down Expand Up @@ -410,11 +410,12 @@
"turnstile": "Emberi ellenőrzés sikertelen. Kérjük, próbáld újra."
},
"leaderboard": {
"title": "Ranglista",
"title": "Eredmények",
"description": "A tippek rangsorolása pontszám alapján. Az eredmények a választás után kerülnek kiértékelésre.",
"descriptionPreResult": "Tippek leadása kizárólag <b>2026. április 12. 06:00 CEST</b>-ig lehetséges — ezt követően minden módosítás és beküldés le lesz tiltva a fair play érdekében. A választás után az eredmények mérföldkövek szerint frissülnek: az első frissítés <b>80%-os szavazatfeldolgozásnál</b>, majd minden további <b>5%-os lépcső</b> elérésekor (85%, 90%, 95%, 100%). A végleges eredmények és a pontozás csak <b>100%-os feldolgozottságnál</b> tekinthetők véglegesnek.",
"resultsTitle": "Eredmények",
"resultsListWinner": "Pártlista győztes",
"resultsMandates": "Mandátumok",
"resultsPm": "Miniszterelnök",
"resultsPercentages": "Pártlista eredmények",
"rank": "#",
Expand All @@ -437,6 +438,7 @@
"individualSubtitle": "Véglegesített tippek pontszám szerinti rangsorolása.",
"individualSubtitlePreResult": "A legutóbb véglegesített tippek. A rangsorolás a választás után, az eredmények alapján történik.",
"resultsProcessing": "Feldolgozottság",
"resultsParticipation": "Részvétel",
"processingAwaiting": "Eredményekre várunk",
"processingComplete": "Feldolgozás kész",
"bestGroupsAvgScore": "Átlag pontszám",
Expand All @@ -453,7 +455,18 @@
"tooltipTotal": "Összesen",
"profileRank": "#{{rank}} a ranglistán",
"topThreeTitle": "Top 3 legjobb tipp",
"topThreeSubtitle": "A legpontosabb előrejelzések a választási eredmények alapján."
"topThreeSubtitle": "A legpontosabb előrejelzések a választási eredmények alapján.",
"tabOverall": "Összesített",
"tabNationalities": "Nemzetiségek",
"tabParticipation": "Részvétel",
"categoryPredicted": "Tipp",
"categoryActual": "Tényleges",
"categoryError": "Eltérés",
"categoryEmpty": "Nincs elég adat ehhez a kategóriához.",
"categoryTabTitle": "{{party}} legjobb tippek",
"categoryTabTitleNationalities": "Nemzetiségek legjobb tippek",
"categoryTabTitleParticipation": "Részvétel legjobb tippek",
"categorySubtitle": "A tippek pontossága az adott kategóriában, a tényleges eredménytől való eltérés alapján rendezve."
},
"groups": {
"title": "Csoportok",
Expand Down Expand Up @@ -531,8 +544,8 @@
}
},
"banner": {
"text": "Új funkció: add hozzá a Telex mandátum tipped is a profilodhoz a leadási határidő előtt!",
"cta": "Kitöltöm",
"text": "A szavazás elkezdődött, a tippjáték lezárult. Az első eredmények a 19:00 órai urnazárás után min. 60%-os feldolgozottságnál frissülnek először az oldalon.",
"cta": "Saját tipped",
"dismiss": "Értesítés bezárása"
},
"telex": {
Expand Down
103 changes: 103 additions & 0 deletions app/src/pages/leaderboard/category-table.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { RankBadge } from '@/components/rank-badge'
import type { LeaderboardEntry } from '@mandatoto/shared/types'

type CategoryTableProps = {
entries: LeaderboardEntry[]
getValue: (entry: LeaderboardEntry) => number | null
referenceValue: number
/** Optional name filter — entries are filtered for display but ranks come from the full sorted list. */
nameFilter?: string
unit?: string
}

export function CategoryTable({ entries, getValue, referenceValue, nameFilter, unit = 'pp' }: CategoryTableProps) {
const { t } = useTranslation()

const ranked = useMemo(() => {
return entries
.filter((e) => getValue(e) != null)
.map((e) => ({
entry: e,
predicted: getValue(e) as number,
error: Math.abs((getValue(e) as number) - referenceValue),
}))
.sort((a, b) => a.error - b.error)
.map((row, idx) => ({ ...row, rank: idx + 1 }))
}, [entries, getValue, referenceValue])

const displayed = useMemo(() => {
if (!nameFilter || nameFilter.length < 2) return ranked
const q = nameFilter.toLowerCase()
return ranked.filter((r) => r.entry.displayName.toLowerCase().includes(q))
}, [ranked, nameFilter])

const isFilterActive = !!nameFilter && nameFilter.length >= 2

if (ranked.length === 0) {
return (
<p className="py-12 text-center text-sm text-zinc-400">
{t('leaderboard.categoryEmpty')}
</p>
)
}

if (isFilterActive && displayed.length === 0) {
return (
<p className="py-8 text-center text-sm text-zinc-400">
{t('leaderboard.noSearchResults')}
</p>
)
}

return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-200 text-left text-[11px] font-semibold uppercase tracking-wider text-zinc-500">
<th className="px-3 py-2.5 text-right w-10">{t('leaderboard.rank')}</th>
<th className="px-3 py-2.5">{t('leaderboard.name')}</th>
<th className="px-3 py-2.5 text-right">{t('leaderboard.categoryPredicted')}</th>
<th className="px-3 py-2.5 text-right">{t('leaderboard.categoryActual')}</th>
<th className="px-3 py-2.5 text-right">{t('leaderboard.categoryError')}</th>
</tr>
</thead>
<tbody>
{displayed.map(({ entry, predicted, error, rank }) => (
<tr
key={entry.shareToken}
className={`border-b border-zinc-100 transition-colors ${rank <= 3 ? 'bg-zinc-50/60' : ''}`}
>
<td className="px-3 py-2.5 text-right tabular-nums font-medium text-zinc-500">
{rank <= 3 ? <RankBadge rank={rank} /> : rank}
</td>
<td className="px-3 py-2.5 font-medium text-zinc-900">
<a
href={`#tipp/${entry.shareToken}`}
className="underline underline-offset-2 decoration-zinc-300 hover:decoration-zinc-900 transition-colors"
>
{entry.displayName}
</a>
</td>
<td className="px-3 py-2.5 text-right tabular-nums text-zinc-800">
{predicted.toFixed(2)}%
</td>
<td className="px-3 py-2.5 text-right tabular-nums text-zinc-500">
{referenceValue.toFixed(2)}%
</td>
<td className={`px-3 py-2.5 text-right tabular-nums font-semibold ${
error === 0 ? 'text-emerald-600'
: error < 1 ? 'text-emerald-500'
: error < 3 ? 'text-amber-600'
: 'text-red-500'
}`}>
±{error.toFixed(2)}{unit}
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
4 changes: 2 additions & 2 deletions app/src/pages/leaderboard/leaderboard-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { RESULTS_AVAILABLE } from '@mandatoto/shared/types'

import { ResultsSummary } from './results-summary'
import { BestGroupsSection } from './best-groups-section'
import { LeaderboardTable } from './leaderboard-table'
import { LeaderboardTabs } from './leaderboard-tabs'

type LeaderboardPageProps = {
currentToken: string | null
Expand Down Expand Up @@ -52,7 +52,7 @@ export function LeaderboardPage({ currentToken }: LeaderboardPageProps) {

<BestGroupsSection bestGroups={bestGroups} />

<LeaderboardTable entries={entries} loading={loading} currentToken={currentToken} />
<LeaderboardTabs entries={entries} loading={loading} currentToken={currentToken} />
</CardContent>
</Card>
</section>
Expand Down
Loading
Loading