From b1bbf4f6086eb0e6927205429b6bda21c8277092 Mon Sep 17 00:00:00 2001 From: midi Date: Sun, 12 Apr 2026 02:03:21 +0200 Subject: [PATCH 1/4] patch: Adjust score calculation script --- worker/package.json | 3 +++ worker/scripts/recalculate-scores.ts | 16 +++++++++++----- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/worker/package.json b/worker/package.json index 935c5e1..1860112 100644 --- a/worker/package.json +++ b/worker/package.json @@ -30,6 +30,9 @@ "db:score": "tsx scripts/recalculate-scores.ts local", "db:score:dev": "tsx scripts/recalculate-scores.ts dev", "db:score:prod": "tsx scripts/recalculate-scores.ts prod", + "db:score:null": "tsx scripts/recalculate-scores.ts local --null-only", + "db:score:null:dev": "tsx scripts/recalculate-scores.ts dev --null-only", + "db:score:null:prod": "tsx scripts/recalculate-scores.ts prod --null-only", "telex:screenshot": "tsx scripts/test-telex-screenshot.ts", "telex:screenshot:mobile": "tsx scripts/test-telex-screenshot.ts --mobile" }, diff --git a/worker/scripts/recalculate-scores.ts b/worker/scripts/recalculate-scores.ts index ab859f9..338fe65 100644 --- a/worker/scripts/recalculate-scores.ts +++ b/worker/scripts/recalculate-scores.ts @@ -9,6 +9,7 @@ * * Flags: * --dry-run Print counts and first rows; no SQL writes + * --null-only Only process rows where score IS NULL (skip already-scored rows) * --confirm-prod Required when target is prod */ @@ -37,15 +38,17 @@ type D1Row = Record function parseArgs(argv: string[]) { let target: Target = 'local' let dryRun = false + let nullOnly = false let confirmProd = false const unknown: string[] = [] for (const a of argv) { if (a === '--dry-run') dryRun = true + else if (a === '--null-only') nullOnly = true else if (a === '--confirm-prod') confirmProd = true else if (a === 'local' || a === 'dev' || a === 'prod') target = a else unknown.push(a) } - return { target, dryRun, confirmProd, unknown } + return { target, dryRun, nullOnly, confirmProd, unknown } } function d1ExecuteJson(dbName: string, extraArgs: string[], command: string): D1Row[] { @@ -106,10 +109,10 @@ function rowToInput(row: D1Row) { } } -const SELECT_SQL = `SELECT token, list_winner_id, pm_winner_id, pct_mkkp, pct_tisza, pct_mi_hazank, pct_dk, pct_fidesz_kdnp, pct_nationalities, participation_rate, score FROM predictions WHERE status = 'finalized'` +const SELECT_COLS = `token, list_winner_id, pm_winner_id, pct_mkkp, pct_tisza, pct_mi_hazank, pct_dk, pct_fidesz_kdnp, pct_nationalities, participation_rate, score` function main() { - const { target, dryRun, confirmProd, unknown } = parseArgs(process.argv.slice(2)) + const { target, dryRun, nullOnly, confirmProd, unknown } = parseArgs(process.argv.slice(2)) if (unknown.length > 0) { console.error('Unknown arguments:', unknown.join(' ')) process.exit(1) @@ -122,9 +125,12 @@ function main() { const dbName = TARGET_DB[target] const wranglerDbArgs = target === 'local' ? (['--local'] as const) : (['--remote'] as const) - console.error(`Target: ${target} (${dbName})${dryRun ? ' [dry-run]' : ''}`) + const selectSql = `SELECT ${SELECT_COLS} FROM predictions WHERE status = 'finalized'${nullOnly ? ' AND score IS NULL' : ''}` - const rows = d1ExecuteJson(dbName, [...wranglerDbArgs], SELECT_SQL) + const flags = [dryRun && '[dry-run]', nullOnly && '[null-only]'].filter(Boolean).join(' ') + console.error(`Target: ${target} (${dbName})${flags ? ' ' + flags : ''}`) + + const rows = d1ExecuteJson(dbName, [...wranglerDbArgs], selectSql) console.error(`Finalized predictions: ${rows.length}`) const updates: { token: string; score: number; prev: number | null }[] = [] From f3d884e2aab5237b5fa90e5cdf90297a0d85c97c Mon Sep 17 00:00:00 2001 From: midi Date: Sun, 12 Apr 2026 15:19:16 +0200 Subject: [PATCH 2/4] feat: Extend leaderboard with categories and mandates + adjust total voters --- app/src/locales/en.json | 15 +- app/src/locales/hu.json | 19 +- app/src/pages/leaderboard/category-table.tsx | 103 +++++++ .../pages/leaderboard/leaderboard-page.tsx | 4 +- .../pages/leaderboard/leaderboard-table.tsx | 168 +++-------- .../pages/leaderboard/leaderboard-tabs.tsx | 265 ++++++++++++++++++ app/src/pages/leaderboard/results-summary.tsx | 103 ++++--- shared/types.ts | 28 +- 8 files changed, 525 insertions(+), 180 deletions(-) create mode 100644 app/src/pages/leaderboard/category-table.tsx create mode 100644 app/src/pages/leaderboard/leaderboard-tabs.tsx diff --git a/app/src/locales/en.json b/app/src/locales/en.json index 6b9a54b..9cae4be 100644 --- a/app/src/locales/en.json +++ b/app/src/locales/en.json @@ -415,6 +415,7 @@ "descriptionPreResult": "Tip submission is only possible until April 12, 2026 06:00 AM CEST — after this, all modifications and submissions are disabled to ensure fair play. After the election, results will be updated in milestones: first update at 80% vote processing, then at every 5% increment (85%, 90%, 95%, 100%). Definitive results and final scoring can only be considered at 100% processing.", "resultsTitle": "Results", "resultsListWinner": "Party list winner", + "resultsMandates": "Mandates", "resultsPm": "Prime minister", "resultsPercentages": "Party list results", "rank": "#", @@ -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", @@ -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", diff --git a/app/src/locales/hu.json b/app/src/locales/hu.json index ff11706..7ba156d 100644 --- a/app/src/locales/hu.json +++ b/app/src/locales/hu.json @@ -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...", @@ -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 2026. április 12. 06:00 CEST-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 80%-os szavazatfeldolgozásnál, majd minden további 5%-os lépcső elérésekor (85%, 90%, 95%, 100%). A végleges eredmények és a pontozás csak 100%-os feldolgozottságnál tekinthetők véglegesnek.", "resultsTitle": "Eredmények", "resultsListWinner": "Pártlista győztes", + "resultsMandates": "Mandátumok", "resultsPm": "Miniszterelnök", "resultsPercentages": "Pártlista eredmények", "rank": "#", @@ -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", @@ -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", diff --git a/app/src/pages/leaderboard/category-table.tsx b/app/src/pages/leaderboard/category-table.tsx new file mode 100644 index 0000000..899a7b4 --- /dev/null +++ b/app/src/pages/leaderboard/category-table.tsx @@ -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 ( +

+ {t('leaderboard.categoryEmpty')} +

+ ) + } + + if (isFilterActive && displayed.length === 0) { + return ( +

+ {t('leaderboard.noSearchResults')} +

+ ) + } + + return ( +
+ + + + + + + + + + + + {displayed.map(({ entry, predicted, error, rank }) => ( + + + + + + + + ))} + +
{t('leaderboard.rank')}{t('leaderboard.name')}{t('leaderboard.categoryPredicted')}{t('leaderboard.categoryActual')}{t('leaderboard.categoryError')}
+ {rank <= 3 ? : rank} + + + {entry.displayName} + + + {predicted.toFixed(2)}% + + {referenceValue.toFixed(2)}% + + ±{error.toFixed(2)}{unit} +
+
+ ) +} diff --git a/app/src/pages/leaderboard/leaderboard-page.tsx b/app/src/pages/leaderboard/leaderboard-page.tsx index ec8721e..c32b037 100644 --- a/app/src/pages/leaderboard/leaderboard-page.tsx +++ b/app/src/pages/leaderboard/leaderboard-page.tsx @@ -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 @@ -52,7 +52,7 @@ export function LeaderboardPage({ currentToken }: LeaderboardPageProps) { - + diff --git a/app/src/pages/leaderboard/leaderboard-table.tsx b/app/src/pages/leaderboard/leaderboard-table.tsx index 0541966..3901739 100644 --- a/app/src/pages/leaderboard/leaderboard-table.tsx +++ b/app/src/pages/leaderboard/leaderboard-table.tsx @@ -1,10 +1,7 @@ -import { useCallback, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { Loader2, Search, Trophy, User, X } from 'lucide-react' -import * as api from '@/lib/api-client' +import { Loader2, Trophy } from 'lucide-react' import { PartyBadge } from '@/components/party-badge' import { RankBadge } from '@/components/rank-badge' -import { Button } from '@/components/ui/button' import { RESULTS_AVAILABLE } from '@mandatoto/shared/types' import type { LeaderboardEntry } from '@mandatoto/shared/types' import { formatDateTimeBudapest } from '@/lib/date-format' @@ -14,135 +11,47 @@ type LeaderboardTableProps = { entries: LeaderboardEntry[] loading: boolean currentToken: string | null + /** When true the title/subtitle header is omitted (used inside LeaderboardTabs). */ + hideHeader?: boolean + /** Pre-filtered entries from the parent search; falls back to entries when absent. */ + displayedEntries?: LeaderboardEntry[] + /** Share token of the current user, resolved by the parent search handler. */ + userShareToken?: string | null + /** Whether a search is currently active (≥2 chars typed). */ + isSearchActive?: boolean + /** Whether a server-side search is in progress. */ + searchLoading?: boolean } -export function LeaderboardTable({ entries, loading, currentToken }: LeaderboardTableProps) { +export function LeaderboardTable({ + entries, + loading, + currentToken: _currentToken, + hideHeader = false, + displayedEntries, + userShareToken, + isSearchActive = false, + searchLoading = false, +}: LeaderboardTableProps) { const { t } = useTranslation() - const [searchQuery, setSearchQuery] = useState('') - const [searchResults, setSearchResults] = useState(null) - const [searchLoading, setSearchLoading] = useState(false) - const debounceRef = useRef | null>(null) - - const [findMeLoading, setFindMeLoading] = useState(false) - const [userShareToken, setUserShareToken] = useState(null) - - const clientFiltered = useMemo(() => { - if (searchQuery.length < 2) return null - const q = searchQuery.toLowerCase() - const matches = entries.filter((e) => e.displayName.toLowerCase().includes(q)) - return matches.length > 0 ? matches : null - }, [searchQuery, entries]) - - const searchServerSide = useCallback((query: string) => { - if (debounceRef.current) clearTimeout(debounceRef.current) - - if (query.length < 2) { - setSearchResults(null) - setSearchLoading(false) - return - } - - setSearchLoading(true) - debounceRef.current = setTimeout(() => { - api.getLeaderboard(query) - .then((data) => setSearchResults(data)) - .catch(() => {}) - .finally(() => setSearchLoading(false)) - }, 350) - }, []) - - function handleSearchChange(value: string) { - setSearchQuery(value) - setSearchResults(null) - - if (value.length < 2) { - if (debounceRef.current) clearTimeout(debounceRef.current) - setSearchLoading(false) - return - } - - const q = value.toLowerCase() - const hasClientMatch = entries.some((e) => e.displayName.toLowerCase().includes(q)) - if (!hasClientMatch) { - searchServerSide(value) - } else { - if (debounceRef.current) clearTimeout(debounceRef.current) - setSearchLoading(false) - } - } - - function clearSearch() { - setSearchQuery('') - setSearchResults(null) - setSearchLoading(false) - if (debounceRef.current) clearTimeout(debounceRef.current) - } - - async function handleFindMe() { - if (!currentToken) return - setFindMeLoading(true) - try { - const data = await api.getPrediction(currentToken) - if (!data) return - setUserShareToken(data.shareToken ?? null) - const name = data.displayName as string - setSearchQuery(name) - setSearchResults(null) - const q = name.toLowerCase() - const hasClientMatch = entries.some((e) => e.displayName.toLowerCase().includes(q)) - if (!hasClientMatch) { - searchServerSide(name) - } - } catch { - // ignore - } finally { - setFindMeLoading(false) - } - } - - const allDisplayed = clientFiltered ?? searchResults ?? entries - const displayedEntries = !RESULTS_AVAILABLE ? allDisplayed.slice(0, 10) : allDisplayed - const isSearchActive = searchQuery.length >= 2 + const shown = displayedEntries ?? (!RESULTS_AVAILABLE ? entries.slice(0, 10) : entries) return (
-

- - {t(RESULTS_AVAILABLE ? 'leaderboard.individualLeaderboard' : 'leaderboard.individualLeaderboardPreResult')} -

-

- {t(RESULTS_AVAILABLE ? 'leaderboard.individualSubtitle' : 'leaderboard.individualSubtitlePreResult')} -

- - {RESULTS_AVAILABLE && ( -
-
- - handleSearchChange(e.target.value)} - placeholder={t('leaderboard.searchPlaceholder')} - className="w-full bg-transparent text-xs text-zinc-900 placeholder:text-zinc-400 outline-none" - /> - {searchQuery && ( - - )} - {searchLoading && } -
- {currentToken && ( - - )} -
+ {!hideHeader && ( + <> +

+ + {t(RESULTS_AVAILABLE ? 'leaderboard.individualLeaderboard' : 'leaderboard.individualLeaderboardPreResult')} +

+

+ {t(RESULTS_AVAILABLE ? 'leaderboard.individualSubtitle' : 'leaderboard.individualSubtitlePreResult')} +

+ )} - {isSearchActive && displayedEntries.length === 0 && !searchLoading && ( + {isSearchActive && shown.length === 0 && !searchLoading && (

{t('leaderboard.noSearchResults')}

@@ -156,7 +65,7 @@ export function LeaderboardTable({ entries, loading, currentToken }: Leaderboard

{t('leaderboard.empty')}

- ) : displayedEntries.length > 0 ? ( + ) : shown.length > 0 ? (
@@ -170,12 +79,11 @@ export function LeaderboardTable({ entries, loading, currentToken }: Leaderboard - {displayedEntries.map((entry, idx) => { + {shown.map((entry, idx) => { const num = entry.rank ?? (entries.length - idx) - const isCurrentUserRow = - userShareToken != null - ? entry.shareToken === userShareToken - : false + const isCurrentUserRow = userShareToken != null + ? entry.shareToken === userShareToken + : false return ( ('overall') + + const [searchQuery, setSearchQuery] = useState('') + const [searchResults, setSearchResults] = useState(null) + const [searchLoading, setSearchLoading] = useState(false) + const debounceRef = useRef | null>(null) + + const [findMeLoading, setFindMeLoading] = useState(false) + const [userShareToken, setUserShareToken] = useState(null) + + const isSearchActive = searchQuery.length >= 2 + + const clientFiltered = useMemo(() => { + if (searchQuery.length < 2) return null + const q = searchQuery.toLowerCase() + const matches = entries.filter((e) => e.displayName.toLowerCase().includes(q)) + return matches.length > 0 ? matches : null + }, [searchQuery, entries]) + + const searchServerSide = useCallback((query: string) => { + if (debounceRef.current) clearTimeout(debounceRef.current) + if (query.length < 2) { + setSearchResults(null) + setSearchLoading(false) + return + } + setSearchLoading(true) + debounceRef.current = setTimeout(() => { + api.getLeaderboard(query) + .then((data) => setSearchResults(data)) + .catch(() => {}) + .finally(() => setSearchLoading(false)) + }, 350) + }, []) + + function handleSearchChange(value: string) { + setSearchQuery(value) + setSearchResults(null) + if (value.length < 2) { + if (debounceRef.current) clearTimeout(debounceRef.current) + setSearchLoading(false) + return + } + const q = value.toLowerCase() + const hasClientMatch = entries.some((e) => e.displayName.toLowerCase().includes(q)) + if (!hasClientMatch) { + searchServerSide(value) + } else { + if (debounceRef.current) clearTimeout(debounceRef.current) + setSearchLoading(false) + } + } + + function clearSearch() { + setSearchQuery('') + setSearchResults(null) + setSearchLoading(false) + if (debounceRef.current) clearTimeout(debounceRef.current) + } + + async function handleFindMe() { + if (!currentToken) return + setFindMeLoading(true) + try { + const data = await api.getPrediction(currentToken) + if (!data) return + setUserShareToken(data.shareToken ?? null) + const name = data.displayName as string + setSearchQuery(name) + setSearchResults(null) + const q = name.toLowerCase() + const hasClientMatch = entries.some((e) => e.displayName.toLowerCase().includes(q)) + if (!hasClientMatch) { + searchServerSide(name) + } + } catch { + // ignore + } finally { + setFindMeLoading(false) + } + } + + // For the overall tab: honour server-search results (may include entries outside top 100). + const overallDisplayed = clientFiltered ?? searchResults ?? entries + + if (!RESULTS_AVAILABLE) { + return + } + + const partyTabIds = PARTY_OPTIONS.map((p) => p.id as TabId) + + const categoryTitleMap: Record = { + overall: t('leaderboard.individualLeaderboard'), + mkkp: t('leaderboard.categoryTabTitle', { party: 'MKKP' }), + tisza: t('leaderboard.categoryTabTitle', { party: 'TISZA' }), + mi_hazank: t('leaderboard.categoryTabTitle', { party: 'Mi Hazánk' }), + dk: t('leaderboard.categoryTabTitle', { party: 'DK' }), + fidesz_kdnp: t('leaderboard.categoryTabTitle', { party: 'FIDESZ-KDNP' }), + nationalities: t('leaderboard.categoryTabTitleNationalities'), + participation: t('leaderboard.categoryTabTitleParticipation'), + } + + const tabLabels: Record = { + overall: t('leaderboard.tabOverall'), + mkkp: 'MKKP', + tisza: 'TISZA', + mi_hazank: 'Mi Hazánk', + dk: 'DK', + fidesz_kdnp: 'FIDESZ-KDNP', + nationalities: t('leaderboard.tabNationalities'), + participation: t('leaderboard.tabParticipation'), + } + + const tabOrder: TabId[] = ['overall', ...partyTabIds, 'nationalities', 'participation'] + + function renderContent() { + if (activeTab === 'overall') { + return ( + + ) + } + + const nameFilter = searchQuery.length >= 2 ? searchQuery : undefined + + if (activeTab === 'nationalities') { + return ( + e.pctNationalities} + referenceValue={REFERENCE_RESULT.pctNationalities} + nameFilter={nameFilter} + /> + ) + } + + if (activeTab === 'participation') { + return ( + e.participationRate} + referenceValue={REFERENCE_RESULT.participationRate} + nameFilter={nameFilter} + /> + ) + } + + const party = PARTY_OPTIONS.find((p) => p.id === activeTab) + if (party) { + const fieldKey = `pct${party.id.charAt(0).toUpperCase()}${party.id.slice(1).replace(/_([a-z])/g, (_, c: string) => c.toUpperCase())}` as keyof LeaderboardEntry + return ( + e[fieldKey] as number | null} + referenceValue={REFERENCE_RESULT.percentages[party.id]} + nameFilter={nameFilter} + /> + ) + } + + return null + } + + return ( +
+

+ + {categoryTitleMap[activeTab]} +

+

+ {activeTab === 'overall' + ? t('leaderboard.individualSubtitle') + : t('leaderboard.categorySubtitle')} +

+ +
+
+ + handleSearchChange(e.target.value)} + placeholder={t('leaderboard.searchPlaceholder')} + className="w-full bg-transparent text-xs text-zinc-900 placeholder:text-zinc-400 outline-none" + /> + {searchQuery && ( + + )} + {searchLoading && } +
+ {currentToken && ( + + )} +
+ +
+
+ {tabOrder.map((id) => ( + + ))} +
+
+ + {renderContent()} +
+ ) +} diff --git a/app/src/pages/leaderboard/results-summary.tsx b/app/src/pages/leaderboard/results-summary.tsx index 829229a..99ef90a 100644 --- a/app/src/pages/leaderboard/results-summary.tsx +++ b/app/src/pages/leaderboard/results-summary.tsx @@ -1,5 +1,5 @@ import { useTranslation } from 'react-i18next' -import { BarChart3, CheckCircle2 } from 'lucide-react' +import { CheckCircle2 } from 'lucide-react' import { T } from '@/components/trans' import { PARTY_OPTIONS, PARTY_SHORT } from '@/data/election-options' import { REFERENCE_RESULT, RESULTS_AVAILABLE, VOTE_PROCESSING_PCT } from '@mandatoto/shared/types' @@ -10,14 +10,7 @@ export function ResultsSummary() { return (
-

- - {t(RESULTS_AVAILABLE ? 'leaderboard.resultsTitle' : 'leaderboard.resultsTitlePreResult')} -

-

- {t(RESULTS_AVAILABLE ? 'leaderboard.resultsSubtitle' : 'leaderboard.resultsSubtitlePreResult')} -

-
+

{t('leaderboard.resultsListWinner')} @@ -77,36 +70,76 @@ export function ResultsSummary() {

+
+

+ {t('leaderboard.resultsMandates')} +

+
+ {PARTY_OPTIONS.map((p) => { + const count = RESULTS_AVAILABLE ? REFERENCE_RESULT.mandates[p.id] : null + const isMajority = count != null && count >= 133 + return ( + + {p.shortName}{' '} + + {count != null ? count : '—'} + + + ) + })} + + {t('stats.nationalities')}{' '} + + {RESULTS_AVAILABLE ? REFERENCE_RESULT.nationalitiesMandate : '—'} + + +
+
-
-

- {t('leaderboard.resultsProcessing')} -

-
- {RESULTS_AVAILABLE ? ( - VOTE_PROCESSING_PCT >= 100 ? ( - <> - -

{t('leaderboard.processingComplete')}

- +
+
+

+ {t('leaderboard.resultsProcessing')} +

+
+ {RESULTS_AVAILABLE ? ( + VOTE_PROCESSING_PCT >= 100 ? ( + <> + +

100%

+
+
+
+ + ) : ( + <> +

+ {VOTE_PROCESSING_PCT}% +

+
+
+
+ + ) ) : ( <> -
-
-
-

{VOTE_PROCESSING_PCT}%

+

0%

+
+

{t('leaderboard.processingAwaiting')}

- ) - ) : ( - <> -
-

0%

-

{t('leaderboard.processingAwaiting')}

- - )} + )} +
+
+
+

+ {t('leaderboard.resultsParticipation')} +

+

+ {RESULTS_AVAILABLE ? `${REFERENCE_RESULT.participationRate}%` : '—'} +

diff --git a/shared/types.ts b/shared/types.ts index 98245ab..db0e370 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -2,7 +2,7 @@ export type PartyId = 'mkkp' | 'tisza' | 'mi_hazank' | 'dk' | 'fidesz_kdnp' export const PARTY_IDS: PartyId[] = ['mkkp', 'tisza', 'mi_hazank', 'dk', 'fidesz_kdnp'] -export const TOTAL_ELIGIBLE_VOTERS = 7_618_706 +export const TOTAL_ELIGIBLE_VOTERS = 8_114_688 export type PredictionVisibility = 'public' | 'private' export type PredictionStatus = 'draft' | 'finalized' @@ -188,7 +188,7 @@ export type BestGroupEntry = { } export const RESULTS_AVAILABLE = false -export const VOTE_PROCESSING_PCT = 80 +export const VOTE_PROCESSING_PCT = 100 export const CUTOFF_AT = '2026-04-12T06:00:00+02:00' export const PARTY_LIST_THRESHOLD = 5 @@ -198,18 +198,28 @@ export type ElectionResult = { percentages: Record pctNationalities: number participationRate: number + mandates: Record + nationalitiesMandate: number } export const REFERENCE_RESULT: ElectionResult = { listWinnerId: 'tisza' as PartyId, pmWinnerId: 'tisza' as PartyId, percentages: { - mkkp: 2.8, - tisza: 44.6, - mi_hazank: 6.9, - dk: 4.3, - fidesz_kdnp: 35.2, + mkkp: 0.61, + tisza: 56.94, + mi_hazank: 4.89, + dk: 0.75, + fidesz_kdnp: 36.20, }, - pctNationalities: 0.44, - participationRate: 68.0, + pctNationalities: 0.61, + participationRate: 74.23, + mandates: { + mkkp: 0, + tisza: 133, + mi_hazank: 0, + dk: 0, + fidesz_kdnp: 65, + }, + nationalitiesMandate: 1, } From 02c4a30465d428e9dc80e778fef08776e8243f79 Mon Sep 17 00:00:00 2001 From: midi Date: Sun, 12 Apr 2026 15:32:44 +0200 Subject: [PATCH 3/4] feat: Change notification after vote start --- app/src/components/announcement-banner.tsx | 50 +++++++++------------- app/src/locales/en.json | 4 +- app/src/locales/hu.json | 4 +- 3 files changed, 25 insertions(+), 33 deletions(-) diff --git a/app/src/components/announcement-banner.tsx b/app/src/components/announcement-banner.tsx index 04af586..a1086f5 100644 --- a/app/src/components/announcement-banner.tsx +++ b/app/src/components/announcement-banner.tsx @@ -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 @@ -33,22 +26,21 @@ export function AnnouncementBanner() { return (

- {/* On mobile the whole strip (minus X) is tappable */} - - -

- {t('banner.text')} -

-
- - - {t('banner.cta')} - - + +

+ {t('banner.text')} +

+ + {shareToken && ( + + + {t('banner.cta')} + + + )}