(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();
})();
