(function initTradeFairsSubpage() { const tableBody = document.getElementById('tradeFairTableBody'); const searchInput = document.getElementById('tradeFairSearchInput'); const meta = document.getElementById('tradeFairMeta'); const sortButtons = Array.from(document.querySelectorAll('.bookmark-subpage__sort[data-trade-sort]')); const table = tableBody ? tableBody.closest('table') : null; const headerRow = table ? table.querySelector('thead tr') : null; const SORT_STATE_KEY = 'fb_trade_fairs_sort_v1'; const COLUMN_ORDER_STATE_KEY = 'fb_trade_fairs_columns_v1'; const LAST_OPEN_STATE_KEY = 'fb_trade_fairs_last_open_v1'; const DEFAULT_COLUMN_ORDER = [ 'tage_bis_start', 'rang', 'messe', 'zuletzt_gesucht_am', 'thema', 'stadt', 'bundesland', 'termin_start', 'termin_ende', 'besucher', 'besucher_jahr', 'besucher_status', 'ausstellungsflaeche_m2', 'ticketpreis_we_eur', 'ticketpreis_unterderwoche_eur', 'notiz', 'quelle_homepage' ]; if (!tableBody || !searchInput || !meta || !sortButtons.length || !table || !headerRow) { return; } const headerCellsByKey = new Map(); sortButtons.forEach((button) => { const key = button.dataset.tradeSort; const th = button.closest('th'); if (!key || !th) { return; } th.dataset.columnKey = key; th.draggable = true; headerCellsByKey.set(key, th); }); if (headerCellsByKey.size !== DEFAULT_COLUMN_ORDER.length) { return; } const TRADE_FAIRS = [ { rang: 1, messe: 'bauma', thema: 'Baumaschinen, Baustoffmaschinen, Bergbau, Baufahrzeuge', stadt: 'München', bundesland: 'Bayern', termin_start: '2028-04-03', termin_ende: '2028-04-09', besucher: 605974, besucher_jahr: 2025, besucher_status: 'AUMA 2025', ausstellungsflaeche_m2: 420107, ticketpreis_we_eur: 38, ticketpreis_unterderwoche_eur: 38, notiz: 'Besucher laut AUMA. Tagesticket aus zuletzt verfuegbarem Preisstand (TradeFairDates, 2025).', quelle_homepage: 'https://bauma.de' }, { rang: 2, messe: 'IAA MOBILITY', thema: 'Mobilitaet, Auto, Tech, Open Space', stadt: 'München', bundesland: 'Bayern', termin_start: '2027-09-08', termin_ende: '2027-09-12', besucher: 500000, besucher_jahr: 2025, besucher_status: 'Veranstalterangabe 2025 (>500.000)', ausstellungsflaeche_m2: null, ticketpreis_we_eur: 39, ticketpreis_unterderwoche_eur: 29, notiz: 'Open Space teils frei; Ticketpreise laut IAA-Ticketseite (ab EUR 29, Family Pass ab EUR 39).', quelle_homepage: 'https://www.iaa-mobility.com' }, { rang: 3, messe: 'AGRITECHNICA', thema: 'Landtechnik und Agrartechnologie', stadt: 'Hannover', bundesland: 'Niedersachsen', termin_start: '2027-11-14', termin_ende: '2027-11-20', besucher: 477055, besucher_jahr: 2025, besucher_status: 'AUMA 2025', ausstellungsflaeche_m2: 229886, ticketpreis_we_eur: 29, ticketpreis_unterderwoche_eur: 29, notiz: 'Besucher laut AUMA. Ticketpreis laut Agritechnica-Ticketseite (Tagesticket EUR 29).', quelle_homepage: 'https://www.agritechnica.com' }, { rang: 4, messe: 'IdeenExpo', thema: 'Technik, Naturwissenschaften, Erlebnis, Ausbildung', stadt: 'Hannover', bundesland: 'Niedersachsen', termin_start: '2026-06-20', termin_ende: '2026-06-28', besucher: 430000, besucher_jahr: 2024, besucher_status: 'Veranstalter/Pressestand 2024', ausstellungsflaeche_m2: null, ticketpreis_we_eur: 0, ticketpreis_unterderwoche_eur: 0, notiz: 'Eintritt frei (IdeenExpo). Besucherzahl aus dem letzten veroeffentlichten Durchgang.', quelle_homepage: 'https://www.ideenexpo.de' }, { rang: 5, messe: 'gamescom', thema: 'Games, digitale Unterhaltung, Hardware/Software', stadt: 'Köln', bundesland: 'Nordrhein-Westfalen', termin_start: '2026-08-26', termin_ende: '2026-08-30', besucher: 357000, besucher_jahr: 2025, besucher_status: 'Veranstalter/Branchenpresse 2025', ausstellungsflaeche_m2: 73515, ticketpreis_we_eur: 40, ticketpreis_unterderwoche_eur: 30.5, notiz: 'Wochenende hoeher als Werktag (Preisstand 2025 aus Gaming-/Ticketberichten).', quelle_homepage: 'https://www.gamescom.global/en' }, { rang: 6, messe: 'Grüne Woche', thema: 'Ernaehrung, Landwirtschaft, Garten, Genuss', stadt: 'Berlin', bundesland: 'Berlin', termin_start: '2027-01-15', termin_ende: '2027-01-24', besucher: 307000, besucher_jahr: 2025, besucher_status: 'Veranstalterangabe 2025', ausstellungsflaeche_m2: 42500, ticketpreis_we_eur: 16, ticketpreis_unterderwoche_eur: 16, notiz: 'Tagesticket Erwachsene laut Gruene-Woche-Ticketseite (Preisstand 2025).', quelle_homepage: 'https://www.gruenewoche.de' }, { rang: 7, messe: 'CARAVAN SALON DUESSELDORF', thema: 'Reisemobile, Caravans, Camping, Touristik', stadt: 'Düsseldorf', bundesland: 'Nordrhein-Westfalen', termin_start: '2026-08-28', termin_ende: '2026-09-06', besucher: 269000, besucher_jahr: 2025, besucher_status: 'Veranstalter/Branchenpresse 2025', ausstellungsflaeche_m2: 121439, ticketpreis_we_eur: 20, ticketpreis_unterderwoche_eur: 18, notiz: 'Preisstand zuletzt verfuegbarer Ticketinfos (Weekend hoeher als Weekday).', quelle_homepage: 'https://www.caravan-salon.de' }, { rang: 8, messe: 'Maimarkt Mannheim', thema: 'Publikums-Mehrbranchenmesse', stadt: 'Mannheim', bundesland: 'Baden-Württemberg', termin_start: '2026-04-25', termin_ende: '2026-05-05', besucher: 268613, besucher_jahr: 2025, besucher_status: 'AUMA 2025', ausstellungsflaeche_m2: 52769, ticketpreis_we_eur: 10, ticketpreis_unterderwoche_eur: 10, notiz: 'Tagesticket Erwachsene an der Kasse EUR 10 (VVK guenstiger).', quelle_homepage: 'https://www.maimarkt.de' }, { rang: 9, messe: 'CMT - Die Urlaubsmesse', thema: 'Tourismus, Caravaning, Freizeit', stadt: 'Stuttgart', bundesland: 'Baden-Württemberg', termin_start: '2027-01-16', termin_ende: '2027-01-24', besucher: 261004, besucher_jahr: 2025, besucher_status: 'AUMA 2025', ausstellungsflaeche_m2: 75587, ticketpreis_we_eur: 18, ticketpreis_unterderwoche_eur: 15, notiz: 'Preisstaffel laut CMT-Partner-/Infoseiten (Wochenende teurer).', quelle_homepage: 'https://www.messe-stuttgart.de/cmt' }, { rang: 10, messe: 'Frankfurter Buchmesse', thema: 'Buecher, Kultur, Medien, Publishing', stadt: 'Frankfurt am Main', bundesland: 'Hessen', termin_start: '2026-10-07', termin_ende: '2026-10-11', besucher: 238000, besucher_jahr: 2025, besucher_status: 'Veranstalter/Branchenpresse 2025', ausstellungsflaeche_m2: null, ticketpreis_we_eur: 35, ticketpreis_unterderwoche_eur: 30, notiz: 'Preisniveau aus zuletzt veroeffentlichtem Ticketstand (Publikumstage teils teurer).', quelle_homepage: 'https://www.buchmesse.de' }, { rang: 11, messe: 'Leipziger Buchmesse / Manga Comic Con', thema: 'Buchmarkt, Medien, Manga/Comic', stadt: 'Leipzig', bundesland: 'Sachsen', termin_start: '2026-03-19', termin_ende: '2026-03-22', besucher: 222819, besucher_jahr: 2025, besucher_status: 'Veranstalter/Pressestand 2025', ausstellungsflaeche_m2: 20788, ticketpreis_we_eur: 35, ticketpreis_unterderwoche_eur: 28, notiz: 'Ticketpreise laut offizieller Leipzig-Messe-Preisliste 2026 (Sa am hoechsten).', quelle_homepage: 'https://www.leipziger-buchmesse.de' }, { rang: 12, messe: 'SPIEL Essen', thema: 'Brettspiele, Tabletop, Family Entertainment', stadt: 'Essen', bundesland: 'Nordrhein-Westfalen', termin_start: '2026-10-22', termin_ende: '2026-10-25', besucher: 220000, besucher_jahr: 2025, besucher_status: 'Veranstalterangabe 2025', ausstellungsflaeche_m2: null, ticketpreis_we_eur: 23.5, ticketpreis_unterderwoche_eur: 23.5, notiz: 'Tagesticket Erwachsene laut Ticketberichten 2025 (einheitlicher Preis).', quelle_homepage: 'https://www.spiel-essen.de' }, { rang: 13, messe: 'ESSEN MOTOR SHOW', thema: 'Performance, Tuning, Motorsport, Classic Cars', stadt: 'Essen', bundesland: 'Nordrhein-Westfalen', termin_start: '2026-11-28', termin_ende: '2026-12-06', besucher: 202827, besucher_jahr: 2024, besucher_status: 'AUMA 2024 (letzter verfuegbarer Wert)', ausstellungsflaeche_m2: 33781, ticketpreis_we_eur: 20, ticketpreis_unterderwoche_eur: 20, notiz: 'Tagesticket aus zuletzt publiziertem Ticketstand.', quelle_homepage: 'https://www.essen-motorshow.de' }, { rang: 14, messe: 'boot Duesseldorf', thema: 'Boote, Wassersport, Tauchen, Yachting', stadt: 'Düsseldorf', bundesland: 'Nordrhein-Westfalen', termin_start: '2027-01-23', termin_ende: '2027-01-31', besucher: 198339, besucher_jahr: 2025, besucher_status: 'AUMA 2025', ausstellungsflaeche_m2: 94867, ticketpreis_we_eur: 19, ticketpreis_unterderwoche_eur: 19, notiz: 'Preis aus offiziellem Ticket-/Club-Stand (2-fuer-1 Aktion auf Basis Tagesticket).', quelle_homepage: 'https://www.boot.de' }, { rang: 15, messe: 'IFA', thema: 'Consumer Electronics, Home Appliances, Tech', stadt: 'Berlin', bundesland: 'Berlin', termin_start: '2026-09-04', termin_ende: '2026-09-08', besucher: 191997, besucher_jahr: 2024, besucher_status: 'AUMA 2024 (letzter verfuegbarer Wert)', ausstellungsflaeche_m2: 107546, ticketpreis_we_eur: 19.5, ticketpreis_unterderwoche_eur: 19.5, notiz: 'Preisniveau aus Ticketberichten zum letzten verfuegbaren IFA-Durchgang.', quelle_homepage: 'https://www.ifa-berlin.com' }, { rang: 16, messe: 'FIBO', thema: 'Fitness, Wellness, Gesundheit', stadt: 'Köln', bundesland: 'Nordrhein-Westfalen', termin_start: '2026-04-16', termin_ende: '2026-04-19', besucher: 154890, besucher_jahr: 2025, besucher_status: 'AUMA 2025', ausstellungsflaeche_m2: 56904, ticketpreis_we_eur: 44, ticketpreis_unterderwoche_eur: 44, notiz: 'Tagesticket laut FIBO-Ticketseite (ab EUR 44).', quelle_homepage: 'https://www.fibo.com' }, { rang: 17, messe: 'IAA TRANSPORTATION', thema: 'Nutzfahrzeuge, Logistik, Transporttechnologie', stadt: 'Hannover', bundesland: 'Niedersachsen', termin_start: '2026-09-15', termin_ende: '2026-09-20', besucher: 145000, besucher_jahr: 2024, besucher_status: 'Veranstalterangabe 2024', ausstellungsflaeche_m2: null, ticketpreis_we_eur: 20, ticketpreis_unterderwoche_eur: 20, notiz: 'Ticketpreis laut offizieller IAA-Transportation-Ticketseite (ab EUR 20).', quelle_homepage: 'https://www.iaa-transportation.com' }, { rang: 18, messe: 'Anuga', thema: 'Lebensmittel und Getraenke', stadt: 'Köln', bundesland: 'Nordrhein-Westfalen', termin_start: '2027-10-09', termin_ende: '2027-10-13', besucher: 143432, besucher_jahr: 2025, besucher_status: 'AUMA 2025', ausstellungsflaeche_m2: 157545, ticketpreis_we_eur: 80, ticketpreis_unterderwoche_eur: 80, notiz: 'Tagesticket laut TradeFairDates (Stand zuletzt verfuegbarer Preis).', quelle_homepage: 'https://www.anuga.com' }, { rang: 19, messe: 'HANNOVER MESSE', thema: 'Industrie, Automation, Energie, Digital', stadt: 'Hannover', bundesland: 'Niedersachsen', termin_start: '2026-04-20', termin_ende: '2026-04-24', besucher: 123035, besucher_jahr: 2025, besucher_status: 'AUMA 2025', ausstellungsflaeche_m2: 101699, ticketpreis_we_eur: null, ticketpreis_unterderwoche_eur: 39, notiz: 'Messe liegt auf Werktagen; Tagesticket laut TradeFairDates EUR 39.', quelle_homepage: 'https://www.hannovermesse.de' }, { rang: 20, messe: 'CONSUMENTA', thema: 'Publikumsmesse: Haushalt, Freizeit, Bauen, Genuss', stadt: 'Nürnberg', bundesland: 'Bayern', termin_start: '2026-10-31', termin_ende: '2026-11-08', besucher: 123000, besucher_jahr: 2025, besucher_status: 'Veroeffentlichter Wert 2025', ausstellungsflaeche_m2: 30448, ticketpreis_we_eur: 16, ticketpreis_unterderwoche_eur: 16, notiz: 'Aktionstags-Preisstand vorhanden; regulaere Ticketpreise fuer 2026 noch im Rollout.', quelle_homepage: 'https://www.consumenta.de' }, { rang: 21, messe: 'f.re.e München', thema: 'Reisen, Freizeit, Caravaning, Outdoor', stadt: 'München', bundesland: 'Bayern', termin_start: '2026-02-18', termin_ende: '2026-02-22', besucher: 120000, besucher_jahr: 2025, besucher_status: 'Veroeffentlichter Wert 2025', ausstellungsflaeche_m2: null, ticketpreis_we_eur: 17, ticketpreis_unterderwoche_eur: 14, notiz: 'Preisniveau aus zuletzt veroeffentlichtem Ticketstand (Wochenende hoeher).', quelle_homepage: 'https://free-muenchen.de' }, { rang: 22, messe: 'infa', thema: 'Publikumsmesse: Lifestyle, Haushalt, Genuss', stadt: 'Hannover', bundesland: 'Niedersachsen', termin_start: '2026-10-10', termin_ende: '2026-10-18', besucher: 112228, besucher_jahr: 2025, besucher_status: 'Veroeffentlichter Wert 2025', ausstellungsflaeche_m2: 16423, ticketpreis_we_eur: 15, ticketpreis_unterderwoche_eur: 15, notiz: 'Tagesticket laut zuletzt verfuegbarem Preisstand.', quelle_homepage: 'https://www.infa.de' }, { rang: 23, messe: 'ITB Berlin', thema: 'Reiseindustrie (B2B/Fachbesucher)', stadt: 'Berlin', bundesland: 'Berlin', termin_start: '2026-03-03', termin_ende: '2026-03-05', besucher: 100000, besucher_jahr: 2025, besucher_status: 'Veroeffentlichter Wert 2025', ausstellungsflaeche_m2: 78892, ticketpreis_we_eur: null, ticketpreis_unterderwoche_eur: 65, notiz: 'B2B-Fachmesse ohne Wochenend-Tag. Ticketpreis laut offizieller ITB-Ticketseite.', quelle_homepage: 'https://www.itb.com' }, { rang: 24, messe: 'ILA Berlin', thema: 'Luft- und Raumfahrt', stadt: 'Berlin', bundesland: 'Berlin', termin_start: '2026-06-10', termin_ende: '2026-06-14', besucher: 95000, besucher_jahr: 2024, besucher_status: 'AUMA 2024 (letzter verfuegbarer Wert)', ausstellungsflaeche_m2: 32008, ticketpreis_we_eur: 22, ticketpreis_unterderwoche_eur: 22, notiz: 'Tagesticket laut ILA-Besucherinfo (EUR 22, Kinder frei).', quelle_homepage: 'https://www.ila-berlin.de' }, { rang: 25, messe: 'Heim+Handwerk', thema: 'Wohnen, Handwerk, Lifestyle', stadt: 'München', bundesland: 'Bayern', termin_start: '2026-11-25', termin_ende: '2026-11-29', besucher: 90000, besucher_jahr: 2025, besucher_status: 'Veroeffentlichter Wert 2025', ausstellungsflaeche_m2: null, ticketpreis_we_eur: 15, ticketpreis_unterderwoche_eur: 15, notiz: 'Ticketpreis als zuletzt verfuegbares Preisniveau; finale 2026-Staffel folgt i.d.R. spaeter.', quelle_homepage: 'https://www.heim-handwerk.de' }, { rang: 26, messe: 'offerta', thema: 'Einkaufs- und Erlebnismesse fuer Verbraucher', stadt: 'Karlsruhe', bundesland: 'Baden-Württemberg', termin_start: '2026-10-24', termin_ende: '2026-11-01', besucher: 88738, besucher_jahr: 2025, besucher_status: 'AUMA 2025', ausstellungsflaeche_m2: 16242, ticketpreis_we_eur: 10, ticketpreis_unterderwoche_eur: 10, notiz: 'Preisniveau aus regional veroeffentlichtem Ticketstand (offerta Karlsruhe).', quelle_homepage: 'https://www.offerta.info' }, { rang: 27, messe: 'Reise + Camping Essen', thema: 'Reisen, Camping, Fahrrad, Outdoor', stadt: 'Essen', bundesland: 'Nordrhein-Westfalen', termin_start: '2026-02-25', termin_ende: '2026-03-01', besucher: 80000, besucher_jahr: 2025, besucher_status: 'Veroeffentlichter Wert 2025', ausstellungsflaeche_m2: null, ticketpreis_we_eur: 12, ticketpreis_unterderwoche_eur: 12, notiz: 'Online-Tagesticket laut Messe-Essen-Presse (EUR 12).', quelle_homepage: 'https://www.reise-camping.de' }, { rang: 28, messe: 'RETRO CLASSICS', thema: 'Klassische Fahrzeuge und Fahrkultur', stadt: 'Stuttgart', bundesland: 'Baden-Württemberg', termin_start: '2026-02-19', termin_ende: '2026-02-22', besucher: 72386, besucher_jahr: 2025, besucher_status: 'AUMA 2025', ausstellungsflaeche_m2: 46496, ticketpreis_we_eur: 25, ticketpreis_unterderwoche_eur: 25, notiz: 'Tagesticket laut offizieller Retro-Classics-Preisliste.', quelle_homepage: 'https://www.retro-classics.de' }, { rang: 29, messe: 'Freizeit Messe Nürnberg', thema: 'Freizeit, Touristik, Garten', stadt: 'Nürnberg', bundesland: 'Bayern', termin_start: '2026-03-04', termin_ende: '2026-03-08', besucher: 71000, besucher_jahr: 2025, besucher_status: 'Veroeffentlichter Wert 2025', ausstellungsflaeche_m2: null, ticketpreis_we_eur: 16, ticketpreis_unterderwoche_eur: 15, notiz: 'Ticketpreise laut offizieller Freizeit-Messe-Preistabelle 2026.', quelle_homepage: 'https://www.freizeitmesse.de' }, { rang: 30, messe: 'Thueringen Ausstellung', thema: 'Haus, Bau, Modernisieren, Lifestyle', stadt: 'Erfurt', bundesland: 'Thüringen', termin_start: '2026-02-28', termin_ende: '2026-03-08', besucher: 69988, besucher_jahr: 2025, besucher_status: 'Veroeffentlichter Wert 2025', ausstellungsflaeche_m2: null, ticketpreis_we_eur: 14, ticketpreis_unterderwoche_eur: 14, notiz: 'Tagesticket Erwachsene laut offizieller Thueringen-Ausstellung-Preisliste.', quelle_homepage: 'https://thueringen-ausstellung.de' } ]; function isFreeTradeFair(row) { const prices = [ toNumber(row.ticketpreis_we_eur), toNumber(row.ticketpreis_unterderwoche_eur) ].filter((value) => value !== null); if (!prices.length) { return false; } return prices.every((price) => price === 0); } const EFFECTIVE_TRADE_FAIRS = TRADE_FAIRS .filter((row) => !isFreeTradeFair(row)) .sort((a, b) => (a.rang || 0) - (b.rang || 0)) .map((row, index) => ({ ...row, rang: index + 1 })); const HIDDEN_FREE_FAIRS_COUNT = TRADE_FAIRS.length - EFFECTIVE_TRADE_FAIRS.length; const SORT_TYPES = { tage_bis_start: 'number', rang: 'number', messe: 'text', zuletzt_gesucht_am: 'date', thema: 'text', stadt: 'text', bundesland: 'text', termin_start: 'date', termin_ende: 'date', besucher: 'number', besucher_jahr: 'number', besucher_status: 'text', ausstellungsflaeche_m2: 'number', ticketpreis_we_eur: 'number', ticketpreis_unterderwoche_eur: 'number', notiz: 'text', quelle_homepage: 'text' }; let sortKey = 'rang'; let sortDirection = 'asc'; let searchTerm = ''; let draggedColumnKey = null; let lastOpenedByTradeFair = {}; function toNumber(value) { if (typeof value === 'number' && Number.isFinite(value)) { return value; } if (typeof value === 'string' && value.trim()) { const normalized = value.replace(/\./g, '').replace(',', '.').replace(/[^\d.-]/g, ''); const parsed = Number(normalized); return Number.isFinite(parsed) ? parsed : null; } return null; } function toDate(value) { if (!value) { return null; } const parsed = new Date(value); if (Number.isNaN(parsed.getTime())) { return null; } return parsed; } function getDaysUntil(startIso) { const start = toDate(startIso); if (!start) { return null; } const now = new Date(); now.setHours(0, 0, 0, 0); start.setHours(0, 0, 0, 0); return Math.round((start.getTime() - now.getTime()) / 86400000); } function normalizeRow(row) { return { ...row, tage_bis_start: getDaysUntil(row.termin_start), zuletzt_gesucht_am: lastOpenedByTradeFair[getTradeFairOpenKey(row)] || null }; } function getSortValue(row, key) { const type = SORT_TYPES[key] || 'text'; const value = row[key]; if (type === 'number') { const numeric = toNumber(value); return numeric === null ? Number.NEGATIVE_INFINITY : numeric; } if (type === 'date') { const date = toDate(value); return date ? date.getTime() : Number.NEGATIVE_INFINITY; } return String(value || '').toLocaleLowerCase('de-DE'); } function formatNumber(value) { if (!Number.isFinite(value)) { return 'k.A.'; } return value.toLocaleString('de-DE'); } function formatPrice(value) { if (!Number.isFinite(value)) { return 'k.A.'; } return value.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); } function formatDate(iso) { const date = toDate(iso); if (!date) { return 'k.A.'; } return date.toLocaleDateString('de-DE'); } function matchesSearch(row, term) { if (!term) { return true; } const haystack = [ row.messe, row.thema, row.stadt, row.bundesland, row.notiz ].join(' ').toLocaleLowerCase('de-DE'); return haystack.includes(term); } function sortRows(rows) { return [...rows].sort((a, b) => { const aValue = getSortValue(a, sortKey); const bValue = getSortValue(b, sortKey); if (aValue === bValue) { const fallback = (a.rang || 0) - (b.rang || 0); return sortDirection === 'asc' ? fallback : -fallback; } if (aValue > bValue) { return sortDirection === 'asc' ? 1 : -1; } return sortDirection === 'asc' ? -1 : 1; }); } function normalizeSortState(rawState) { if (!rawState || typeof rawState !== 'object') { return null; } const key = rawState.sortKey; const direction = rawState.sortDirection; if (!Object.prototype.hasOwnProperty.call(SORT_TYPES, key)) { return null; } if (direction !== 'asc' && direction !== 'desc') { return null; } return { sortKey: key, sortDirection: direction }; } function normalizeColumnOrder(rawOrder) { if (!Array.isArray(rawOrder) || rawOrder.length !== DEFAULT_COLUMN_ORDER.length) { return null; } const filtered = rawOrder.filter((key) => DEFAULT_COLUMN_ORDER.includes(key)); if (filtered.length !== DEFAULT_COLUMN_ORDER.length) { return null; } if (new Set(filtered).size !== DEFAULT_COLUMN_ORDER.length) { return null; } return filtered; } function getColumnOrderFromHeader() { const keys = Array.from(headerRow.children) .map((th) => th.dataset.columnKey) .filter((key) => DEFAULT_COLUMN_ORDER.includes(key)); if (keys.length !== DEFAULT_COLUMN_ORDER.length) { return [...DEFAULT_COLUMN_ORDER]; } return keys; } function applyColumnOrder(order) { order.forEach((key) => { const th = headerCellsByKey.get(key); if (th) { headerRow.appendChild(th); } }); } function loadColumnOrder() { try { const raw = localStorage.getItem(COLUMN_ORDER_STATE_KEY); if (!raw) { return; } const parsed = JSON.parse(raw); const normalized = normalizeColumnOrder(parsed); if (!normalized) { return; } applyColumnOrder(normalized); } catch (_error) { // ignore } } function persistColumnOrder() { try { localStorage.setItem(COLUMN_ORDER_STATE_KEY, JSON.stringify(getColumnOrderFromHeader())); } catch (_error) { // ignore } } function normalizeLastOpenedState(rawState) { if (!rawState || typeof rawState !== 'object' || Array.isArray(rawState)) { return {}; } const normalized = {}; Object.entries(rawState).forEach(([key, value]) => { if (typeof key !== 'string' || typeof value !== 'string') { return; } const parsed = new Date(value); if (Number.isNaN(parsed.getTime())) { return; } normalized[key] = parsed.toISOString(); }); return normalized; } function loadLastOpenedState() { try { const raw = localStorage.getItem(LAST_OPEN_STATE_KEY); if (!raw) { return {}; } return normalizeLastOpenedState(JSON.parse(raw)); } catch (_error) { return {}; } } function persistLastOpenedState() { try { localStorage.setItem(LAST_OPEN_STATE_KEY, JSON.stringify(lastOpenedByTradeFair)); } catch (_error) { // ignore } } function getTradeFairOpenKey(row) { const messe = String(row.messe || '').trim().toLocaleLowerCase('de-DE'); const city = String(row.stadt || '').trim().toLocaleLowerCase('de-DE'); return `${messe}|${city}`; } function setTradeFairLastOpened(row) { const key = getTradeFairOpenKey(row); if (!key) { return null; } const nowIso = new Date().toISOString(); lastOpenedByTradeFair[key] = nowIso; persistLastOpenedState(); return nowIso; } function formatDateTime(iso) { const parsed = new Date(iso); if (Number.isNaN(parsed.getTime())) { return 'k.A.'; } return parsed.toLocaleString('de-DE'); } function formatLastSearched(iso) { if (!iso) { return 'noch nie'; } return formatDateTime(iso); } function getTradeFairHoverTitle(row) { const key = getTradeFairOpenKey(row); const lastOpened = key ? lastOpenedByTradeFair[key] : ''; const suffix = lastOpened ? formatDateTime(lastOpened) : 'noch nie'; return `Als Bookmark-Suche oeffnen (3 Tabs)\\nZuletzt geoeffnet: ${suffix}`; } function loadSortState() { try { const raw = localStorage.getItem(SORT_STATE_KEY); if (!raw) { return; } const parsed = JSON.parse(raw); const normalized = normalizeSortState(parsed); if (!normalized) { return; } sortKey = normalized.sortKey; sortDirection = normalized.sortDirection; } catch (_error) { // ignore } } function persistSortState() { try { localStorage.setItem(SORT_STATE_KEY, JSON.stringify({ sortKey, sortDirection })); } catch (_error) { // ignore } } function updateSortButtonState() { sortButtons.forEach((button) => { const key = button.dataset.tradeSort; const isActive = key === sortKey; button.classList.toggle('is-active', isActive); const indicator = isActive ? (sortDirection === 'asc' ? '▲' : '▼') : '↕'; const baseLabel = button.dataset.baseLabel || button.textContent.trim(); button.textContent = `${baseLabel} ${indicator}`; }); } function clearDropMarkers() { Array.from(headerRow.children).forEach((th) => { if (th.dataset.columnKey !== draggedColumnKey) { th.classList.remove('is-dragging'); } th.classList.remove('is-drop-before', 'is-drop-after'); }); } function setupColumnDragAndDrop() { Array.from(headerCellsByKey.values()).forEach((th) => { th.addEventListener('dragstart', (event) => { const key = th.dataset.columnKey; if (!key) { event.preventDefault(); return; } draggedColumnKey = key; clearDropMarkers(); th.classList.add('is-dragging'); if (event.dataTransfer) { event.dataTransfer.effectAllowed = 'move'; try { event.dataTransfer.setData('text/plain', key); } catch (_error) { // ignore } } }); th.addEventListener('dragover', (event) => { if (!draggedColumnKey || th.dataset.columnKey === draggedColumnKey) { return; } event.preventDefault(); clearDropMarkers(); const rect = th.getBoundingClientRect(); const insertAfter = event.clientX >= rect.left + (rect.width / 2); th.classList.toggle('is-drop-before', !insertAfter); th.classList.toggle('is-drop-after', insertAfter); if (event.dataTransfer) { event.dataTransfer.dropEffect = 'move'; } }); th.addEventListener('dragleave', () => { th.classList.remove('is-drop-before', 'is-drop-after'); }); th.addEventListener('drop', (event) => { event.preventDefault(); const targetKey = th.dataset.columnKey; const sourceKey = draggedColumnKey || (event.dataTransfer ? event.dataTransfer.getData('text/plain') : ''); if (!sourceKey || !targetKey || sourceKey === targetKey) { draggedColumnKey = null; clearDropMarkers(); return; } const sourceTh = headerCellsByKey.get(sourceKey); const targetTh = headerCellsByKey.get(targetKey); if (!sourceTh || !targetTh) { draggedColumnKey = null; clearDropMarkers(); return; } const rect = targetTh.getBoundingClientRect(); const insertAfter = event.clientX >= rect.left + (rect.width / 2); if (insertAfter) { headerRow.insertBefore(sourceTh, targetTh.nextSibling); } else { headerRow.insertBefore(sourceTh, targetTh); } draggedColumnKey = null; clearDropMarkers(); persistColumnOrder(); render(); }); th.addEventListener('dragend', () => { draggedColumnKey = null; clearDropMarkers(); }); }); } function createCell(content, columnKey) { const td = document.createElement('td'); if (columnKey) { td.dataset.column = columnKey; } td.textContent = content; return td; } function buildTradeFairQueries(row) { const base = String(row.messe || '').trim(); const city = String(row.stadt || '').trim(); const topic = String(row.thema || '').split(',')[0].trim(); return [...new Set([ base, [base, city].filter(Boolean).join(' '), [base, topic].filter(Boolean).join(' ') ].map((entry) => entry.trim()).filter(Boolean))]; } function openQueryFallback(query) { const trimmed = String(query || '').trim(); if (!trimmed) { return 0; } if (typeof window.buildBookmarkSearchUrl === 'function') { const built = window.buildBookmarkSearchUrl(trimmed); if (built) { window.open(built, '_blank', 'noopener'); return 1; } } const url = new URL('https://www.facebook.com/search/posts'); url.searchParams.set('q', trimmed); window.open(url.toString(), '_blank', 'noopener'); return 1; } function openTradeFairSearch(row) { const queries = buildTradeFairQueries(row); if (!queries.length) { return 0; } let opened = 0; if (typeof window.openBookmarkQueries === 'function') { opened = window.openBookmarkQueries(queries); } if (opened === 0) { queries.forEach((query) => { opened += openQueryFallback(query); }); } if (opened > 0) { setTradeFairLastOpened(row); } return opened; } function createMesseCell(row) { const td = document.createElement('td'); td.dataset.column = 'messe'; const button = document.createElement('button'); button.type = 'button'; button.className = 'bookmark-subpage__messe-link'; button.textContent = row.messe || 'k.A.'; button.title = getTradeFairHoverTitle(row); button.addEventListener('click', () => { const opened = openTradeFairSearch(row); if (opened > 0) { button.title = getTradeFairHoverTitle(row); render(); } }); td.appendChild(button); return td; } function createSourceCell(row) { const sourceCell = document.createElement('td'); sourceCell.dataset.column = 'quelle_homepage'; if (row.quelle_homepage) { const link = document.createElement('a'); link.href = row.quelle_homepage; link.target = '_blank'; link.rel = 'noopener'; link.textContent = row.quelle_homepage; sourceCell.appendChild(link); } else { sourceCell.textContent = 'k.A.'; } return sourceCell; } function createRowCells(row) { return { tage_bis_start: createCell(Number.isFinite(row.tage_bis_start) ? String(row.tage_bis_start) : 'k.A.', 'tage_bis_start'), rang: createCell(String(row.rang), 'rang'), messe: createMesseCell(row), zuletzt_gesucht_am: createCell(formatLastSearched(row.zuletzt_gesucht_am), 'zuletzt_gesucht_am'), thema: createCell(row.thema, 'thema'), stadt: createCell(row.stadt, 'stadt'), bundesland: createCell(row.bundesland, 'bundesland'), termin_start: createCell(formatDate(row.termin_start), 'termin_start'), termin_ende: createCell(formatDate(row.termin_ende), 'termin_ende'), besucher: createCell(formatNumber(row.besucher), 'besucher'), besucher_jahr: createCell(formatNumber(row.besucher_jahr), 'besucher_jahr'), besucher_status: createCell(row.besucher_status, 'besucher_status'), ausstellungsflaeche_m2: createCell(formatNumber(row.ausstellungsflaeche_m2), 'ausstellungsflaeche_m2'), ticketpreis_we_eur: createCell(formatPrice(row.ticketpreis_we_eur), 'ticketpreis_we_eur'), ticketpreis_unterderwoche_eur: createCell(formatPrice(row.ticketpreis_unterderwoche_eur), 'ticketpreis_unterderwoche_eur'), notiz: createCell(row.notiz, 'notiz'), quelle_homepage: createSourceCell(row) }; } function render() { const normalizedRows = EFFECTIVE_TRADE_FAIRS.map(normalizeRow); const filtered = normalizedRows.filter((row) => matchesSearch(row, searchTerm)); const sorted = sortRows(filtered); const columnOrder = getColumnOrderFromHeader(); tableBody.innerHTML = ''; if (!sorted.length) { const emptyRow = document.createElement('tr'); const emptyCell = document.createElement('td'); emptyCell.colSpan = columnOrder.length; emptyCell.textContent = 'Keine Messe zum Suchbegriff gefunden.'; emptyRow.appendChild(emptyCell); tableBody.appendChild(emptyRow); } else { sorted.forEach((row) => { const tr = document.createElement('tr'); const rowCells = createRowCells(row); columnOrder.forEach((columnKey) => { const cell = rowCells[columnKey] || createCell('k.A.', columnKey); tr.appendChild(cell); }); tableBody.appendChild(tr); }); } const activeSortButton = sortButtons.find((button) => button.dataset.tradeSort === sortKey); const sortLabel = activeSortButton?.dataset.baseLabel || sortKey; const hiddenInfo = HIDDEN_FREE_FAIRS_COUNT > 0 ? ` | ${HIDDEN_FREE_FAIRS_COUNT} kostenlose Messe${HIDDEN_FREE_FAIRS_COUNT === 1 ? '' : 'n'} ausgeblendet` : ''; meta.textContent = `${sorted.length} von ${EFFECTIVE_TRADE_FAIRS.length} Messen${hiddenInfo} | Sortierung: ${sortLabel} (${sortDirection === 'asc' ? 'aufsteigend' : 'absteigend'})`; } sortButtons.forEach((button) => { const label = button.textContent.trim(); button.dataset.baseLabel = label; button.addEventListener('click', () => { const key = button.dataset.tradeSort; if (!key) { return; } if (sortKey === key) { sortDirection = sortDirection === 'asc' ? 'desc' : 'asc'; } else { sortKey = key; sortDirection = key === 'rang' ? 'asc' : 'desc'; } persistSortState(); sortButtons.forEach((btn) => { const baseLabel = btn.dataset.baseLabel || btn.textContent; btn.textContent = baseLabel; }); updateSortButtonState(); render(); }); }); searchInput.addEventListener('input', () => { searchTerm = searchInput.value.trim().toLocaleLowerCase('de-DE'); render(); }); lastOpenedByTradeFair = loadLastOpenedState(); loadColumnOrder(); setupColumnDragAndDrop(); loadSortState(); updateSortButtonState(); render(); })();