(function initTradeFairsSubpage() { const tableBody = document.getElementById('tradeFairTableBody'); const searchInput = document.getElementById('tradeFairSearchInput'); const daysFilterContainer = document.getElementById('tradeFairDaysFilterContainer'); const daysFilterToggle = document.getElementById('tradeFairDaysFilterToggle'); const daysFilterInput = document.getElementById('tradeFairDaysFilterInput'); const meta = document.getElementById('tradeFairMeta'); const columnSettingsBtn = document.getElementById('tradeFairColumnSettingsBtn'); const columnsModal = document.getElementById('tradeFairColumnsModal'); const columnsModalBackdrop = document.getElementById('tradeFairColumnsModalBackdrop'); const columnsModalClose = document.getElementById('tradeFairColumnsModalClose'); const columnsModalDone = document.getElementById('tradeFairColumnsDoneBtn'); const columnsModalReset = document.getElementById('tradeFairColumnsResetBtn'); const columnsList = document.getElementById('tradeFairColumnsList'); 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 COLUMN_VISIBILITY_STATE_KEY = 'fb_trade_fairs_column_visibility_v1'; const LAST_OPEN_STATE_KEY = 'fb_trade_fairs_last_open_v1'; const DAYS_FILTER_STATE_KEY = 'fb_trade_fairs_days_filter_v1'; const DEFAULT_COLUMN_ORDER = [ 'tage_bis_start', 'rang', 'messe', 'zuletzt_gesucht_am', 'thema', 'stadt', 'bundesland', 'termin', '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(); const columnLabelByKey = new Map(); sortButtons.forEach((button) => { const key = button.dataset.tradeSort; const th = button.closest('th'); if (!key || !th) { return; } columnLabelByKey.set(key, button.textContent.trim()); 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 DÜSSELDORF', 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', bookmark_suchbegriffe: ['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: 'Ein Ticket gilt fuer Leipziger Buchmesse und Manga Comic Con (parallel, inkl. Antiquariatsmesse).', 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 Düsseldorf', 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 / IMOT', bookmark_suchbegriffe: ['f.re.e München', 'IMOT'], 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: 'Ein Ticket gilt fuer f.re.e und IMOT (sowie meist auch Muenchner Auto Tage) laut offiziellem Ticketshop.', 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 / FOOD & LIFE', bookmark_suchbegriffe: ['Heim+Handwerk', 'FOOD & LIFE'], 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: 'Ein Ticket gilt fuer Heim+Handwerk und FOOD & LIFE (parallel in Muenchen).', 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 / Fahrrad Essen', bookmark_suchbegriffe: ['Reise + Camping Essen', 'Fahrrad 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: 'Ein Ticket gilt fuer Reise + Camping und Fahrrad Essen (parallel in den Messehallen).', 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: 'Thüringen 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' }, { rang: 31, messe: 'CREATIVA Dortmund', thema: 'Kreativmesse, DIY, Basteln, Handarbeit', stadt: 'Dortmund', bundesland: 'Nordrhein-Westfalen', termin_start: '2026-03-25', termin_ende: '2026-03-29', besucher: 65000, besucher_jahr: 2025, besucher_status: 'Veranstalterangabe 2025 (rund)', ausstellungsflaeche_m2: null, ticketpreis_we_eur: 17, ticketpreis_unterderwoche_eur: 17, notiz: 'Tagesticket Erwachsene EUR 17 (erm. EUR 10) laut Veranstaltungsseite Dortmund.', quelle_homepage: 'https://www.messe-creativa.de' }, { rang: 32, messe: 'didacta Köln', thema: 'Bildungsmesse, Schule, Kita, Weiterbildung', stadt: 'Köln', bundesland: 'Nordrhein-Westfalen', termin_start: '2027-02-23', termin_ende: '2027-02-27', besucher: 63000, besucher_jahr: 2025, besucher_status: 'Veroeffentlichter Wert 2025 (rund)', ausstellungsflaeche_m2: null, ticketpreis_we_eur: 20, ticketpreis_unterderwoche_eur: 20, notiz: 'Tagesticket Erwachsene laut zuletzt verfuegbarem didacta-Preisstand.', quelle_homepage: 'https://www.didacta-koeln.de' }, { rang: 33, messe: 'INTERNORGA', thema: 'Hotellerie, Gastronomie, Baeckerei, Konditorei', stadt: 'Hamburg', bundesland: 'Hamburg', termin_start: '2026-03-13', termin_ende: '2026-03-17', besucher: 59000, besucher_jahr: 2025, besucher_status: 'Veroeffentlichter Wert 2025 (rund)', ausstellungsflaeche_m2: null, ticketpreis_we_eur: 45, ticketpreis_unterderwoche_eur: 45, notiz: 'Fachmesse; Tagesticket laut zuletzt verfuegbarem INTERNORGA-Preisstand.', quelle_homepage: 'https://www.internorga.com' }, { rang: 34, messe: 'PFERD & JAGD', thema: 'Pferdesport, Jagd, Outdoor, Hund', stadt: 'Hannover', bundesland: 'Niedersachsen', termin_start: '2026-12-03', termin_ende: '2026-12-06', besucher: 55000, besucher_jahr: 2024, besucher_status: 'Veroeffentlichter Wert 2024 (rund)', ausstellungsflaeche_m2: null, ticketpreis_we_eur: 19, ticketpreis_unterderwoche_eur: 19, notiz: 'Tagesticket Erwachsene laut zuletzt veroeffentlichter Preisliste.', quelle_homepage: 'https://www.pferd-und-jagd-messe.de' }, { rang: 35, messe: 'HAUS-GARTEN-FREIZEIT', thema: 'Bauen, Wohnen, Garten, Freizeit', stadt: 'Leipzig', bundesland: 'Sachsen', termin_start: '2026-02-14', termin_ende: '2026-02-22', besucher: 52000, besucher_jahr: 2025, besucher_status: 'Veroeffentlichter Wert 2025 (rund)', ausstellungsflaeche_m2: null, ticketpreis_we_eur: 15, ticketpreis_unterderwoche_eur: 14, notiz: 'Tagesticket laut zuletzt verfuegbarem Preisstand (Wochenende leicht hoeher).', quelle_homepage: 'https://www.haus-garten-freizeit.de' }, { rang: 36, messe: 'REISEN & CARAVANING Hamburg', thema: 'Reisen, Caravaning, Kreuzfahrt, Outdoor', stadt: 'Hamburg', bundesland: 'Hamburg', termin_start: '2026-02-04', termin_ende: '2026-02-08', besucher: 50000, besucher_jahr: 2025, besucher_status: 'Veroeffentlichter Wert 2025 (rund)', ausstellungsflaeche_m2: null, ticketpreis_we_eur: 12, ticketpreis_unterderwoche_eur: 12, notiz: 'Tagesticket Erwachsene laut zuletzt veroeffentlichtem Preisstand.', quelle_homepage: 'https://www.reisenhamburg.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 SORT_TYPES = { tage_bis_start: 'number', rang: 'number', messe: 'text', zuletzt_gesucht_am: 'date', thema: 'text', stadt: 'text', bundesland: 'text', termin: '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 daysFilterTerm = ''; let isDaysFilterOpen = false; let draggedColumnKey = null; let lastOpenedByTradeFair = {}; let columnVisibility = Object.fromEntries(DEFAULT_COLUMN_ORDER.map((key) => [key, true])); 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, termin: row.termin_start || 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 formatTermRange(startIso, endIso) { const start = toDate(startIso); const end = toDate(endIso); if (!start && !end) { return 'k.A.'; } if (start && !end) { return formatDate(startIso); } if (!start && end) { return formatDate(endIso); } const startDay = start.getDate(); const startMonth = start.getMonth() + 1; const startYear = start.getFullYear(); const endDay = end.getDate(); const endMonth = end.getMonth() + 1; const endYear = end.getFullYear(); if (startYear === endYear) { if (startMonth === endMonth) { if (startDay === endDay) { return `${startDay}.${startMonth}.${startYear}`; } return `${startDay}.-${endDay}.${startMonth}.${startYear}`; } return `${startDay}.${startMonth}.-${endDay}.${endMonth}.${startYear}`; } return `${startDay}.${startMonth}.${startYear}-${endDay}.${endMonth}.${endYear}`; } 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 parseDaysFilter(term) { const normalized = String(term || '').trim(); if (!normalized) { return { type: 'all' }; } const comparisonMatch = normalized.match(/^(<=|>=|!=|=|<|>)\s*(-?\d+)$/); if (comparisonMatch) { return { type: 'comparison', operator: comparisonMatch[1], value: Number(comparisonMatch[2]) }; } const rangeMatch = normalized.match(/^(-?\d+)\s*-\s*(-?\d+)$/); if (rangeMatch) { const first = Number(rangeMatch[1]); const second = Number(rangeMatch[2]); return { type: 'range', min: Math.min(first, second), max: Math.max(first, second) }; } const exactMatch = normalized.match(/^-?\d+$/); if (exactMatch) { return { type: 'comparison', operator: '=', value: Number(normalized) }; } return { type: 'invalid', raw: normalized }; } function matchesDaysFilter(value, parsedFilter) { if (!parsedFilter || parsedFilter.type === 'all' || parsedFilter.type === 'invalid') { return true; } const numeric = toNumber(value); if (numeric === null) { return false; } if (parsedFilter.type === 'range') { return numeric >= parsedFilter.min && numeric <= parsedFilter.max; } if (parsedFilter.type === 'comparison') { switch (parsedFilter.operator) { case '>': return numeric > parsedFilter.value; case '>=': return numeric >= parsedFilter.value; case '<': return numeric < parsedFilter.value; case '<=': return numeric <= parsedFilter.value; case '!=': return numeric !== parsedFilter.value; case '=': default: return numeric === parsedFilter.value; } } return true; } function getDaysFilterMetaLabel(parsedFilter) { if (!parsedFilter || parsedFilter.type === 'all') { return ''; } if (parsedFilter.type === 'invalid') { return `Tage-bis-Start-Filter ungültig (${parsedFilter.raw})`; } if (parsedFilter.type === 'range') { return `Tage-bis-Start-Filter ${parsedFilter.min}-${parsedFilter.max}`; } return `Tage-bis-Start-Filter ${parsedFilter.operator}${parsedFilter.value}`; } 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 normalizeColumnVisibility(rawState) { if (!rawState || typeof rawState !== 'object' || Array.isArray(rawState)) { return null; } const normalized = {}; DEFAULT_COLUMN_ORDER.forEach((key) => { const value = rawState[key]; normalized[key] = value !== false; }); return normalized; } function loadColumnVisibility() { try { const raw = localStorage.getItem(COLUMN_VISIBILITY_STATE_KEY); if (!raw) { return; } const parsed = JSON.parse(raw); const normalized = normalizeColumnVisibility(parsed); if (!normalized) { return; } columnVisibility = normalized; } catch (_error) { // ignore } } function persistColumnVisibility() { try { localStorage.setItem(COLUMN_VISIBILITY_STATE_KEY, JSON.stringify(columnVisibility)); } catch (_error) { // ignore } } function isColumnVisible(columnKey) { return columnVisibility[columnKey] !== false; } function getVisibleColumnOrder() { const order = getColumnOrderFromHeader().filter((columnKey) => isColumnVisible(columnKey)); if (order.length) { return order; } return ['messe']; } function ensureValidSortColumn() { if (isColumnVisible(sortKey)) { return; } const visibleColumns = getVisibleColumnOrder(); if (visibleColumns.includes('rang')) { sortKey = 'rang'; sortDirection = 'asc'; persistSortState(); return; } sortKey = visibleColumns[0] || 'messe'; sortDirection = 'asc'; persistSortState(); } function applyColumnVisibilityToHeader() { headerCellsByKey.forEach((th, key) => { const visible = isColumnVisible(key); th.hidden = !visible; th.style.display = visible ? '' : 'none'; }); } function getColumnLabel(columnKey) { const fromBase = sortButtons.find((button) => button.dataset.tradeSort === columnKey)?.dataset.baseLabel; if (fromBase) { return fromBase; } return columnLabelByKey.get(columnKey) || columnKey; } function moveColumnInOrder(columnKey, direction) { const order = getColumnOrderFromHeader(); const index = order.indexOf(columnKey); if (index === -1) { return; } const targetIndex = direction < 0 ? index - 1 : index + 1; if (targetIndex < 0 || targetIndex >= order.length) { return; } const nextOrder = [...order]; const [item] = nextOrder.splice(index, 1); nextOrder.splice(targetIndex, 0, item); applyColumnOrder(nextOrder); persistColumnOrder(); applyColumnVisibilityToHeader(); render(); renderColumnSettingsList(); } function setColumnVisibility(columnKey, visible) { const nextState = { ...columnVisibility, [columnKey]: visible }; const visibleCount = DEFAULT_COLUMN_ORDER.filter((key) => nextState[key] !== false).length; if (visibleCount === 0) { return false; } columnVisibility = nextState; persistColumnVisibility(); applyColumnVisibilityToHeader(); ensureValidSortColumn(); updateSortButtonState(); render(); return true; } function renderColumnSettingsList() { if (!columnsList) { return; } const order = getColumnOrderFromHeader(); columnsList.innerHTML = ''; order.forEach((columnKey, index) => { const item = document.createElement('div'); item.className = 'bookmark-columns-modal__item'; const label = document.createElement('label'); label.className = 'bookmark-columns-modal__toggle'; const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; checkbox.checked = isColumnVisible(columnKey); checkbox.addEventListener('change', () => { const ok = setColumnVisibility(columnKey, checkbox.checked); if (!ok) { checkbox.checked = true; return; } renderColumnSettingsList(); }); const text = document.createElement('span'); text.textContent = getColumnLabel(columnKey); label.append(checkbox, text); const actions = document.createElement('div'); actions.className = 'bookmark-columns-modal__item-actions'; const upButton = document.createElement('button'); upButton.type = 'button'; upButton.className = 'bookmark-columns-modal__move'; upButton.textContent = '↑'; upButton.title = 'Nach oben'; upButton.disabled = index === 0; upButton.addEventListener('click', () => { moveColumnInOrder(columnKey, -1); }); const downButton = document.createElement('button'); downButton.type = 'button'; downButton.className = 'bookmark-columns-modal__move'; downButton.textContent = '↓'; downButton.title = 'Nach unten'; downButton.disabled = index === order.length - 1; downButton.addEventListener('click', () => { moveColumnInOrder(columnKey, 1); }); actions.append(upButton, downButton); item.append(label, actions); columnsList.appendChild(item); }); } function closeColumnSettingsModal() { if (!columnsModal) { return; } columnsModal.hidden = true; } function openColumnSettingsModal() { if (!columnsModal) { return; } renderColumnSettingsList(); columnsModal.hidden = false; } function resetColumnSettings() { applyColumnOrder(DEFAULT_COLUMN_ORDER); columnVisibility = Object.fromEntries(DEFAULT_COLUMN_ORDER.map((key) => [key, true])); persistColumnOrder(); persistColumnVisibility(); applyColumnVisibilityToHeader(); ensureValidSortColumn(); updateSortButtonState(); render(); renderColumnSettingsList(); } function setupColumnSettingsModal() { if (!columnSettingsBtn || !columnsModal) { return; } columnSettingsBtn.addEventListener('click', () => { openColumnSettingsModal(); }); if (columnsModalBackdrop) { columnsModalBackdrop.addEventListener('click', () => { closeColumnSettingsModal(); }); } if (columnsModalClose) { columnsModalClose.addEventListener('click', () => { closeColumnSettingsModal(); }); } if (columnsModalDone) { columnsModalDone.addEventListener('click', () => { closeColumnSettingsModal(); }); } if (columnsModalReset) { columnsModalReset.addEventListener('click', () => { resetColumnSettings(); }); } document.addEventListener('keydown', (event) => { if (event.key === 'Escape' && columnsModal && !columnsModal.hidden) { closeColumnSettingsModal(); } }); } 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 loadDaysFilterState() { try { const raw = localStorage.getItem(DAYS_FILTER_STATE_KEY); if (typeof raw !== 'string') { return ''; } return raw.trim(); } catch (_error) { return ''; } } function persistDaysFilterState() { try { localStorage.setItem(DAYS_FILTER_STATE_KEY, daysFilterTerm); } catch (_error) { // ignore } } function updateDaysFilterToggleState() { if (!daysFilterToggle) { return; } daysFilterToggle.classList.toggle('is-active', Boolean(daysFilterTerm)); } function setDaysFilterOpen(nextOpen, options = {}) { isDaysFilterOpen = Boolean(nextOpen); if (daysFilterContainer) { daysFilterContainer.classList.toggle('is-filter-open', isDaysFilterOpen); } if (daysFilterToggle) { daysFilterToggle.classList.toggle('is-open', isDaysFilterOpen); daysFilterToggle.setAttribute('aria-expanded', isDaysFilterOpen ? 'true' : 'false'); } if (options.focus && isDaysFilterOpen && daysFilterInput) { daysFilterInput.focus(); daysFilterInput.select(); } } 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(); renderColumnSettingsList(); }); 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 getTradeFairBookmarkQueries(row) { if (Array.isArray(row.bookmark_suchbegriffe) && row.bookmark_suchbegriffe.length) { return [...new Set( row.bookmark_suchbegriffe .map((entry) => String(entry || '').trim()) .filter(Boolean) )]; } const messe = String(row.messe || '').trim(); if (!messe) { return []; } if (messe.includes(' / ')) { const split = messe.split(' / ').map((part) => part.trim()).filter(Boolean); if (split.length > 1) { return [...new Set(split)]; } } return [messe]; } function openBookmarkLikeFallback(baseQuery) { const trimmed = String(baseQuery || '').trim(); if (!trimmed) { return 0; } const suffixes = ['Gewinnspiel', 'gewinnen', 'verlosen']; let opened = 0; suffixes.forEach((suffix) => { const query = `${trimmed} ${suffix}`.trim(); if (typeof window.buildBookmarkSearchUrl === 'function') { const built = window.buildBookmarkSearchUrl(query); if (built) { window.open(built, '_blank', 'noopener'); opened += 1; return; } } const url = new URL('https://www.facebook.com/search/posts'); url.searchParams.set('q', query); window.open(url.toString(), '_blank', 'noopener'); opened += 1; }); return opened; } function openTradeFairSearch(row, explicitBaseQuery) { const baseQuery = explicitBaseQuery ? String(explicitBaseQuery).trim() : getTradeFairBookmarkQueries(row)[0]; if (!baseQuery) { return 0; } let opened = 0; if (typeof window.openBookmarkQueries === 'function') { opened = window.openBookmarkQueries(baseQuery); } if (opened === 0) { opened = openBookmarkLikeFallback(baseQuery); } if (opened > 0) { setTradeFairLastOpened(row); } return opened; } function createMesseCell(row) { const td = document.createElement('td'); td.dataset.column = 'messe'; const bookmarkQueries = getTradeFairBookmarkQueries(row); if (!bookmarkQueries.length) { td.textContent = row.messe || 'k.A.'; return td; } const linksWrap = document.createElement('div'); linksWrap.className = 'bookmark-subpage__messe-links'; bookmarkQueries.forEach((query, index) => { const button = document.createElement('button'); button.type = 'button'; button.className = 'bookmark-subpage__messe-link'; button.textContent = query; button.title = getTradeFairHoverTitle(row); button.addEventListener('click', () => { const opened = openTradeFairSearch(row, query); if (opened > 0) { button.title = getTradeFairHoverTitle(row); render(); } }); linksWrap.appendChild(button); if (index < bookmarkQueries.length - 1) { const separator = document.createElement('span'); separator.className = 'bookmark-subpage__messe-separator'; separator.textContent = ' / '; linksWrap.appendChild(separator); } }); td.appendChild(linksWrap); 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: createCell(formatTermRange(row.termin_start, row.termin_ende), 'termin'), 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 parsedDaysFilter = parseDaysFilter(daysFilterTerm); const filtered = normalizedRows.filter( (row) => matchesSearch(row, searchTerm) && matchesDaysFilter(row.tage_bis_start, parsedDaysFilter) ); const sorted = sortRows(filtered); const visibleColumnOrder = getVisibleColumnOrder(); tableBody.innerHTML = ''; if (!sorted.length) { const emptyRow = document.createElement('tr'); const emptyCell = document.createElement('td'); emptyCell.colSpan = visibleColumnOrder.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); visibleColumnOrder.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 daysFilterMeta = getDaysFilterMetaLabel(parsedDaysFilter); meta.textContent = `${sorted.length} von ${EFFECTIVE_TRADE_FAIRS.length} Messen | Sortierung: ${sortLabel} (${sortDirection === 'asc' ? 'aufsteigend' : 'absteigend'})${daysFilterMeta ? ` | ${daysFilterMeta}` : ''}`; } 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(); }); if (daysFilterToggle) { daysFilterToggle.addEventListener('click', (event) => { event.preventDefault(); event.stopPropagation(); setDaysFilterOpen(!isDaysFilterOpen, { focus: true }); }); } if (daysFilterInput) { daysFilterInput.addEventListener('input', () => { daysFilterTerm = daysFilterInput.value.trim(); persistDaysFilterState(); updateDaysFilterToggleState(); render(); }); daysFilterInput.addEventListener('keydown', (event) => { if (event.key === 'Escape') { setDaysFilterOpen(false); if (daysFilterToggle) { daysFilterToggle.focus(); } } }); } if (daysFilterContainer) { document.addEventListener('click', (event) => { if (!isDaysFilterOpen) { return; } const target = event.target; if (!(target instanceof Node)) { return; } if (!daysFilterContainer.contains(target)) { setDaysFilterOpen(false); } }); } daysFilterTerm = loadDaysFilterState(); if (daysFilterInput) { daysFilterInput.value = daysFilterTerm; } updateDaysFilterToggleState(); setDaysFilterOpen(false); lastOpenedByTradeFair = loadLastOpenedState(); loadColumnOrder(); loadColumnVisibility(); applyColumnVisibilityToHeader(); setupColumnDragAndDrop(); setupColumnSettingsModal(); loadSortState(); ensureValidSortColumn(); updateSortButtonState(); render(); })();