Fieldwork Monitor: Dating Conjoint (Germany & Austria)

Turnbull-Dugarte, Lopez Ortega & Wurthmann

Published

March 25, 2026

Last updated: Wednesday March 25, 2026 at 14:36 CET

0. Setup

Show code
library(tidyverse)
library(cregg)
library(ggdist)
library(gridExtra)
library(patchwork)
library(gt)
library(janitor)
library(lubridate)
library(scales)

set.seed(12345)

# --- Color palette ---
pal <- list(
  primary = "#2563EB",     # blue - single-group CIs
  accent = "#DC2626",      # red - far-right / highlights
  muted = "#6B7280",       # grey - reference lines, secondary
  left = "#3B82F6",        # blue - left party bloc
  right = "#EF4444",       # red - right party bloc
  germany = "#1D4ED8",     # dark blue
  austria = "#DC2626",     # red
  pass = "#10B981",        # green
  fail = "#EF4444",        # red
  female = "#8B5CF6",      # purple
  male = "#F59E0B",        # amber
  hetero = "#10B981",      # green
  lgbtq = "#8B5CF6"        # purple
)

# --- Global theme ---
theme_monitor <- function() {
  theme_minimal(base_size = 13, base_family = "sans") +
  theme(
    plot.title = element_text(face = "bold", size = 14, margin = margin(b = 6)),
    plot.subtitle = element_text(color = "grey40", size = 11, margin = margin(b = 10)),
    plot.caption = element_text(color = "grey50", size = 9, hjust = 0),
    panel.grid.major.y = element_blank(),
    panel.grid.minor = element_blank(),
    panel.grid.major.x = element_line(color = "grey90", linewidth = 0.3),
    strip.text = element_text(face = "bold", size = 10, hjust = 0),
    strip.background = element_rect(fill = "grey97", color = NA),
    axis.text = element_text(size = 10),
    legend.position = "bottom",
    legend.title = element_text(size = 10),
    plot.margin = margin(12, 12, 12, 12)
  )
}
theme_set(theme_monitor())

# Country configurations
countries <- list(
  germany = list(
    label = "Germany",
    file = "../data/germany_latest.csv",
    target_n = 1200, buffer_n = 1320,
    parties = c("AfD", "CDU/CSU", "SPD", "Grune", "FDP", "Die Linke"),
    far_right = "AfD",
    center_right = "CDU/CSU",
    rejection_hashtag = "#NiemalsAfD",
    col_party = c("AfD" = "#009EE0", "CDU/CSU" = "#000000", "SPD" = "#E3000F",
                  "Grune" = "#64A12D", "FDP" = "#FFED00", "Die Linke" = "#BE3075"),
    quotas = list(
      gender = tibble(level = c("Woman", "Man"), target_pct = c(50, 50)),
      education = tibble(level = c("Low", "Medium", "High"), target_pct = c(41, 31, 28)),
      age = tibble(level = c("18-29", "30-39", "40-49", "50-59", "60-74"),
                   target_pct = c(22.5, 17.5, 16.5, 20.0, 23.5))
    ),
    party_detect = list(
      "AfD" = "AfD", "CDU/CSU" = "CDU", "SPD" = "SPD",
      "Grune" = "GRUNE|GRÜNE|Grüne", "FDP" = "FDP", "Die Linke" = "Linke"
    ),
    resp_party_levels = c("CDU/CSU", "SPD", "Grune", "FDP", "AfD", "Die Linke", "BSW", "None"),
    education_map = list(
      "Low" = c("Kein Abschluss / Hauptschulabschluss / Volksschulabschluss"),
      "Medium" = c("Mittlere Reife / Realschulabschluss"),
      "High" = c("Fachhochschulreife", "Abitur / Allgemeine Hochschulreife",
                  "Hochschulabschluss (FH / Universität)")
    ),
    left_parties = c("SPD", "Grune", "Die Linke"),
    right_nofr_parties = c("CDU/CSU", "FDP"),
    age_qid = "qid501",
    sex_id_qid = "qid398"
  ),
  austria = list(
    label = "Austria",
    file = "../data/austria_latest.csv",
    target_n = 1200, buffer_n = 1320,
    parties = c("FPO", "OVP", "SPO", "Grune", "NEOS", "KPO"),
    far_right = "FPO",
    center_right = "OVP",
    rejection_hashtag = "#NiemalsFPO",
    col_party = c("FPO" = "#005DA5", "OVP" = "#63C3D0", "SPO" = "#E3000F",
                  "Grune" = "#88B626", "NEOS" = "#E84188", "KPO" = "#CC0000"),
    quotas = list(
      gender = tibble(level = c("Woman", "Man"), target_pct = c(50, 50)),
      education = tibble(level = c("Low", "Medium", "High"), target_pct = c(17, 48, 35)),
      age = tibble(level = c("18-29", "30-39", "40-49", "50-59", "60-74"),
                   target_pct = c(22.0, 17.5, 17.0, 20.0, 23.5))
    ),
    party_detect = list(
      "FPO" = "FPÖ|FPO", "OVP" = "ÖVP|OVP", "SPO" = "SPÖ|SPO",
      "Grune" = "GRUNE|GRÜNE|Grüne", "NEOS" = "NEOS", "KPO" = "KPÖ|KPO"
    ),
    resp_party_levels = c("OVP", "SPO", "Grune", "NEOS", "FPO", "KPO", "None"),
    education_map = list(
      "Low" = c("Pflichtschulabschluss (Volksschule, Hauptschule, Polytechnikum)"),
      "Medium" = c("Lehre / BMS (Fachschule, Handelsschule)"),
      "High" = c("AHS / BHS mit Matura (HTL, HAK, HBLA)",
                  "Hochschulabschluss (FH / Universität)")
    ),
    left_parties = c("SPO", "Grune", "KPO"),
    right_nofr_parties = c("OVP", "NEOS"),
    age_qid = "qid499",
    sex_id_qid = "qid396"
  )
)

# Ethnicity map (same for both countries - based on face IDs)
ethnicity_map <- c(
  "W1" = "Local", "W2" = "Local", "W3" = "Local", "W4" = "Local",
  "W5" = "Other", "W6" = "Local", "W7" = "Turkish",
  "W8" = "Other", "W9" = "Local", "W10" = "Eastern European",
  "M1" = "Local", "M2" = "Other", "M3" = "Turkish",
  "M4" = "Other", "M5" = "Other", "M6" = "Local",
  "M7" = "Local", "M8" = "Local", "M9" = "Local", "M10" = "Eastern European"
)

# Progressive cultural identity statements
progressive_statements <- c(
  "Ich schatze Vielfalt und setze mich fur Gleichstellung ein",
  "Gesellschaftlicher Wandel ist mir wichtig",
  "Ich setze mich fur eine gerechtere Welt ein",
  "Menschenrechte und Freiheit sind fur mich zentral",
  "Der Schutz von Umwelt und Klima ist mir ein Anliegen",
  "Ich engagiere mich fur Gleichberechtigung und gegen Diskriminierung",
  "Solidaritat ist ein wichtiger Teil meines Lebens",
  "Bildung und Kultur sollten allen offenstehen",
  "Ich befurworte eine offene und inklusive Gesellschaft",
  "Ich setze mich fur Frieden und Gewaltfreiheit ein",
  "Feminismus ist fur mich ein wichtiger Bezugspunkt",
  "Fur eine nachhaltige und okologische Zukunft",
  "Gegen soziale Ungleichheit, fur soziale Gerechtigkeit",
  "Liebe und gegenseitiger Respekt sind zentrale Werte",
  "Ich setze mich fur die Rechte von Arbeitnehmerinnen und Arbeitnehmern ein",
  "Ich befurworte mehr Beteiligung in der Demokratie",
  "Freiheit gilt nur, wenn sie fur alle gilt",
  "Meinungsfreiheit und kreative Entfaltung sind mir wichtig"
)

1. Data Loading & Processing

Show code
process_country <- function(cfg, country_key) {
  # Load
  if (!file.exists(cfg$file)) {
    cat(paste0("**", cfg$label, ":** File not found: ", cfg$file, "\n\n"))
    return(NULL)
  }
  raw <- qualtRics::read_survey(cfg$file) |> clean_names()

  # Rename key variables (handle different QID numbers per country)
  age_col <- cfg$age_qid
  if (age_col %in% names(raw)) raw <- raw |> rename(age = !!sym(age_col))
  if ("duration_in_seconds" %in% names(raw)) {
    raw <- raw |> rename(duration_seconds = duration_in_seconds)
  }

  # Create sex_id from multi-select columns (varies by survey version)
  # Germany new: q62_1 through q62_8
  # Austria new: sexuality_id_1 through sexuality_id_8
  # Old surveys: single qid column
  sex_prefix <- if ("q62_1" %in% names(raw)) "q62" else if ("sexuality_id_1" %in% names(raw)) "sexuality_id" else NULL
  if (!is.null(sex_prefix)) {
    col1 <- paste0(sex_prefix, "_1")
    col2 <- paste0(sex_prefix, "_2")
    col3 <- paste0(sex_prefix, "_3")
    col4 <- paste0(sex_prefix, "_4")
    raw <- raw |> mutate(
      sex_id = case_when(
        !is.na(.data[[col1]]) ~ "Heterosexuell",
        !is.na(.data[[col2]]) ~ "Schwul/lesbisch",
        !is.na(.data[[col3]]) ~ "Bisexuell",
        !is.na(.data[[col4]]) ~ "Pansexuell",
        TRUE ~ NA_character_
      )
    )
  } else {
    sex_id_col <- cfg$sex_id_qid
    if (sex_id_col %in% names(raw)) raw <- raw |> rename(sex_id = !!sym(sex_id_col))
    if (!"sex_id" %in% names(raw)) raw$sex_id <- NA_character_
  }

  # Handle relationship_type multi-select (new surveys have relationship_type_1 through _5)
  if ("relationship_type_1" %in% names(raw) && !"relationship_type" %in% names(raw)) {
    raw <- raw |> mutate(
      relationship_type = case_when(
        !is.na(relationship_type_1) ~ relationship_type_1,
        !is.na(relationship_type_2) ~ relationship_type_2,
        !is.na(relationship_type_3) ~ relationship_type_3,
        !is.na(relationship_type_4) ~ relationship_type_4,
        !is.na(relationship_type_5) ~ relationship_type_5,
        TRUE ~ NA_character_
      )
    )
  }

  # Map sex_pref: handle both text labels and numeric
  if (is.character(raw$sex_pref)) {
    raw <- raw |> mutate(
      sex_pref = case_when(
        str_detect(sex_pref, "Nur M.nner") ~ 1,
        str_detect(sex_pref, "berwiegend M.nner") ~ 2,
        str_detect(sex_pref, "berwiegend Frauen") ~ 3,
        str_detect(sex_pref, "Nur Frauen") ~ 4,
        str_detect(sex_pref, "gleicherma") ~ 5,
        TRUE ~ NA_real_
      )
    )
  }

  # Filter
  n_raw <- nrow(raw)
  # Handle both logical and numeric finished
  if (is.logical(raw$finished)) {
    raw <- raw |> filter(finished == TRUE)
  } else {
    raw <- raw |> filter(finished == 1)
  }
  n_finished <- nrow(raw)

  # Handle both text and numeric consent
  if (is.character(raw$particip_agree)) {
    raw <- raw |> filter(str_detect(particip_agree, "stimme zu|Ich stimme"))
  } else {
    raw <- raw |> filter(particip_agree == 1)
  }
  n_consented <- nrow(raw)

  # --- Reshape attributes ---
  attributes_long <- raw |>
    select(response_id, starts_with("rd_")) |>
    mutate(across(starts_with("rd_"), as.character)) |>
    pivot_longer(cols = -response_id, names_to = "key", values_to = "value") |>
    separate(key, into = c("prefix", "round", "attribute"), sep = "_", extra = "merge") |>
    select(-prefix) |>
    mutate(round = as.character(round)) |>
    pivot_wider(names_from = attribute, values_from = value)

  # --- Extract decisions ---
  # QID numbers differ between Germany and Austria surveys
  # Germany: men=350-368, women=310-328
  # Austria: men=348-366, women=308-326
  if ("qid350" %in% names(raw)) {
    men_qids <- paste0("qid", seq(350, 368, by = 2))
    women_qids <- paste0("qid", seq(310, 328, by = 2))
  } else {
    men_qids <- paste0("qid", seq(348, 366, by = 2))
    women_qids <- paste0("qid", seq(308, 326, by = 2))
  }
  decision_map <- tibble(
    qid = c(men_qids, women_qids),
    arm_round = c(1:10, 1:10),
    arm = c(rep("mw", 10), rep("ww", 10))
  )

  available_qids <- intersect(decision_map$qid, names(raw))
  decision_map_avail <- decision_map |> filter(qid %in% available_qids)

  decisions_raw <- raw |>
    select(response_id, sex_pref, all_of(decision_map_avail$qid)) |>
    pivot_longer(cols = -c(response_id, sex_pref), names_to = "qid", values_to = "choice_text") |>
    left_join(decision_map_avail, by = "qid") |>
    filter(!is.na(choice_text) & choice_text != "")

  # Map text or numeric choices
  decisions <- decisions_raw |>
    mutate(
      choice = case_when(
        choice_text %in% c("Cancel", "1") ~ 1,
        choice_text %in% c("star", "2") ~ 2,
        choice_text %in% c("Heart", "4") ~ 4,
        TRUE ~ suppressWarnings(as.numeric(choice_text))
      ),
      round = case_when(
        sex_pref != 5 ~ as.character(arm_round),
        sex_pref == 5 & arm == "mw" ~ as.character(2 * arm_round - 1),
        sex_pref == 5 & arm == "ww" ~ as.character(2 * arm_round)
      )
    ) |>
    filter(!is.na(choice)) |>
    mutate(
      selected = ifelse(choice != 1, 1, 0),
      swipe = case_when(choice == 1 ~ "Reject", choice == 2 ~ "Super Like", choice == 4 ~ "Like")
    )

  # --- Keep only completers (answered all mapped rounds) ---
  # Use max observed arm_round as the target (handles Austria QID offset: max=9 not 10)
  max_possible_round <- max(decisions$arm_round, na.rm = TRUE)
  completers <- decisions |>
    group_by(response_id) |>
    summarise(max_round = max(arm_round), .groups = "drop") |>
    filter(max_round == max_possible_round) |>
    pull(response_id)
  decisions <- decisions |> filter(response_id %in% completers)
  raw       <- raw       |> filter(response_id %in% completers)

  # --- Merge ---
  conjoint <- decisions |>
    select(response_id, round, choice, selected, swipe, arm) |>
    inner_join(attributes_long, by = c("response_id", "round"))

  respondent_cols <- c("response_id", "gender", "sex_pref", "education_level",
                       "occupation", "age", "religion", "religiosity",
                       "party_id", "party_id_squeeze", "party_id_strength", "ideology_lr",
                       "political_interest", "tinder", "tinder_2",
                       "relationship_type", "relationship_now", "sex_id",
                       "duration_seconds", "start_date", "end_date", "ip_address")
  available_resp <- intersect(respondent_cols, names(raw))
  # Add bundesland/region if present
  if ("bundesland" %in% names(raw)) available_resp <- c(available_resp, "bundesland")

  respondent_vars <- raw |> select(all_of(available_resp))
  # Rename to avoid collision
  if ("gender" %in% names(respondent_vars)) {
    respondent_vars <- respondent_vars |> rename(resp_gender_raw = gender)
  }
  if ("age" %in% names(respondent_vars)) {
    respondent_vars <- respondent_vars |> rename(resp_age_raw = age)
  }
  if ("relationship_type" %in% names(respondent_vars)) {
    respondent_vars <- respondent_vars |> rename(resp_relationship_type = relationship_type)
  }

  conjoint <- conjoint |> left_join(respondent_vars, by = "response_id")

  # Drop missing engine data
  conjoint <- conjoint |>
    filter(!is.na(party_label), party_label != "",
           !is.na(person), person != "")

  # --- Recode party ---
  conjoint$PARTY <- NA_character_
  for (party_name in names(cfg$party_detect)) {
    pattern <- cfg$party_detect[[party_name]]
    if (party_name == cfg$far_right) {
      # Far-right: match only if no "Niemals" present
      idx <- which(is.na(conjoint$PARTY) &
                     str_detect(conjoint$party_label, pattern) &
                     !str_detect(conjoint$party_label, "Niemals|niemals"))
    } else {
      idx <- which(is.na(conjoint$PARTY) &
                     str_detect(conjoint$party_label, pattern))
    }
    if (length(idx) > 0) conjoint$PARTY[idx] <- party_name
  }

  conjoint <- conjoint |> mutate(
    REJECTION = case_when(
      str_detect(party_label, "Niemals|niemals") ~ "Anti-far-right signal",
      PARTY == cfg$far_right ~ paste0(cfg$far_right, " (no signal possible)"),
      TRUE ~ "No signal"
    ),
    REJECTION_BINARY = ifelse(str_detect(party_label, "Niemals|niemals"), 1, 0),
    REJECTION_CLEAN = factor(
      ifelse(REJECTION_BINARY == 1, "With rejection signal", "Without rejection signal"),
      levels = c("Without rejection signal", "With rejection signal")
    )
  )

  # --- Recode other attributes ---
  conjoint <- conjoint |> mutate(
    base_person = str_extract(person, "^[WM]\\d+"),
    variant_num = as.numeric(str_extract(person, "\\d+$")),
    FLAG = case_when(
      nationality == "" | is.na(nationality) ~ "No flag",
      str_detect(nationality, "german-european|austrian-european") ~ "National + EU flag",
      str_detect(nationality, "^german$|^austrian$") ~ "National flag",
      str_detect(nationality, "^european$") ~ "EU flag",
      TRUE ~ nationality
    ),
    BI = factor(ifelse(bisexual == TRUE | bisexual == "true" | bisexual == "TRUE",
                       "Bisexual", "Heterosexual"),
                levels = c("Heterosexual", "Bisexual")),
    TRANS = factor(ifelse(trans == TRUE | trans == "true" | trans == "TRUE",
                          "Trans", "Cis"),
                   levels = c("Cis", "Trans")),
    VEGAN = factor(ifelse(vegan != "" & !is.na(vegan) & vegan != "FALSE" & vegan != FALSE,
                          "Vegan", "Not vegan"),
                   levels = c("Not vegan", "Vegan")),
    CONGRUENCE = factor(
      ifelse(gender_congruence == "yes", "Gender congruent", "Non-congruent"),
      levels = c("Non-congruent", "Gender congruent")
    ),
    EMOTION = factor(
      ifelse(!is.na(emotion) & emotion != "",
             ifelse(str_detect(emotion, "smiling|Smiling"), "Smiling", "Neutral"),
             ifelse(variant_num %% 2 == 1, "Smiling", "Neutral")),
      levels = c("Neutral", "Smiling")
    ),
    DISTANCE = factor(case_when(
      str_detect(distance, "<1|weniger") ~ "<1km",
      str_detect(distance, "1-5|1 bis 5") ~ "1-5km",
      str_detect(distance, "5-10|5 bis 10") ~ "5-10km",
      str_detect(distance, ">10|mehr als 10") ~ ">10km",
      TRUE ~ NA_character_
    ), levels = c("<1km", "1-5km", "5-10km", ">10km")),
    ETHNICITY = factor(
      ifelse(!is.na(ethnicity) & ethnicity != "", ethnicity, ethnicity_map[base_person]),
      levels = c("Local", "Turkish", "Eastern European", "Other")
    ),
    SOLIDARITY = case_when(
      international_solidarity == "" | is.na(international_solidarity) ~ "None",
      str_detect(international_solidarity, "FUCK TRUMP") ~ "Anti-Trump",
      str_detect(international_solidarity, "ProTRUMP") ~ "Pro-Trump",
      str_detect(international_solidarity, "ProPutin") ~ "Pro-Putin",
      str_detect(international_solidarity, "Ukraine") ~ "Pro-Ukraine",
      TRUE ~ international_solidarity
    ),
    RELATIONSHIP_SEEK = case_when(
      str_detect(relationship_type, "feste Partnerschaft") ~ "Committed",
      str_detect(relationship_type, "offene Beziehung") ~ "Open",
      str_detect(relationship_type, "unverbindliche") ~ "Casual",
      str_detect(relationship_type, "offen fur alles|offen für alles") ~ "Open to anything",
      str_detect(relationship_type, "Freundschaft") ~ "FWB",
      TRUE ~ "Unknown"
    ),
    CULTURAL_TYPE = factor(
      ifelse(cultural_identity %in% progressive_statements, "Progressive", "Conservative"),
      levels = c("Conservative", "Progressive")
    ),
    PROFILE_GENDER = factor(
      ifelse(str_detect(tolower(gender), "woman|frau"), "Woman", "Man"),
      levels = c("Woman", "Man")
    )
  )

  # Recode SES
  conjoint <- conjoint |> mutate(
    JOB_SLIM = case_when(
      job %in% c("Rechtsanwaltschaft/Jura", "Steuerberatung / Wirtschaftsprufung",
                 "Leitende Tatigkeit im Personalwesen", "Arztlich-medizinisches Personal",
                 "Bauingenieurwesen", "Webdesign", "Architekturburo",
                 "Wissenschaftliche Tatigkeit an einer Universitat",
                 "Softwareentwicklung", "Finanzanalyse",
                 "Hoherer offentlicher Dienst", "Leitung Unternehmensberatung",
                 "Steuerberatung / Wirtschaftsprüfung",
                 "Leitende Tätigkeit im Personalwesen", "Ärztlich-medizinisches Personal",
                 "Architekturbüro", "Wissenschaftliche Tätigkeit an einer Universität",
                 "Höherer öffentlicher Dienst") ~ "Upper professional",
      job %in% c("Pflegefachkraft", "Rettungsdienst", "IT-Fachkraft",
                 "Lehrkraft an einer weiterfuhrenden Schule",
                 "Lehrkraft an einer berufsbildenden Schule",
                 "Personliche Assistenz", "Lehrkraft an einer Grundschule",
                 "Lehrkraft an einer Volksschule",
                 "Laborfachkraft", "Polizei", "Grafikdesign",
                 "Offentlicher Dienst", "Finanzberatung",
                 "Filialleitung im Einzelhandel", "Fitnesscoach",
                 "Lehrkraft an einer weiterführenden Schule",
                 "Persönliche Assistenz", "Öffentlicher Dienst") ~ "Lower professional",
      job %in% c("Anstellung im Tatowierstudio", "Zahnmedizinische Assistenz",
                 "Fachkraft fur Elektrotechnik", "Buroassistenz",
                 "Pharmazeutische Assistenz", "Unterrichtsassistenz (padagogisch)",
                 "Maschinen- und Fahrzeugtechnik", "Abteilungsleitung im Einzelhandel",
                 "Assistenz im Personalwesen", "Immobilienvermittlung",
                 "Styling im Modebereich",
                 "Anstellung im Tätowierstudio", "Fachkraft für Elektrotechnik",
                 "Büroassistenz", "Unterrichtsassistenz (pädagogisch)") ~ "Skilled manual/service",
      job %in% c("Fachkraft im Einzelhandel", "Kassenmitarbeit im Einzelhandel",
                 "Anstellung im Bauwesen", "Lkw-Fahrdienst", "Kurierdienst",
                 "Reinigungspersonal", "Barservice", "Service im Restaurant",
                 "Callcenter-Tatigkeit", "Arbeit im Lagerbereich",
                 "Warenverraumung", "Taxifahrdienst",
                 "Callcenter-Tätigkeit", "Warenverräumung") ~ "Unskilled/routine",
      TRUE ~ "Unclassified"
    )
  )

  # --- Recode respondent variables ---
  conjoint <- conjoint |> mutate(
    resp_gender = factor(case_when(
      str_detect(resp_gender_raw, "Mann|mann") ~ "Man",
      str_detect(resp_gender_raw, "Frau|frau") ~ "Woman",
      str_detect(resp_gender_raw, "bin.r") ~ "Non-binary",
      resp_gender_raw == "1" ~ "Man",
      resp_gender_raw == "2" ~ "Woman",
      TRUE ~ NA_character_
    ), levels = c("Man", "Woman", "Non-binary")),
    resp_age = factor(case_when(
      str_detect(resp_age_raw, "18.29") ~ "18-29",
      str_detect(resp_age_raw, "30.39") ~ "30-39",
      str_detect(resp_age_raw, "40.49") ~ "40-49",
      str_detect(resp_age_raw, "50.59") ~ "50-59",
      str_detect(resp_age_raw, "60.74") ~ "60-74",
      resp_age_raw == "1" ~ "18-29",
      resp_age_raw == "2" ~ "30-39",
      resp_age_raw == "3" ~ "40-49",
      resp_age_raw == "4" ~ "50-59",
      resp_age_raw == "5" ~ "60-74",
      TRUE ~ NA_character_
    ), levels = c("18-29", "30-39", "40-49", "50-59", "60-74")),
    ideology_numeric = as.numeric(str_extract(as.character(ideology_lr), "^\\d+"))
  )

  # --- Clean respondent party ID using cfg$party_detect patterns (handles umlauts) ---
  conjoint$resp_party_clean <- NA_character_
  for (pn in names(cfg$party_detect)) {
    pat <- cfg$party_detect[[pn]]
    idx <- which(is.na(conjoint$resp_party_clean) &
      (str_detect(coalesce(as.character(conjoint$party_id), ""), pat) |
       str_detect(coalesce(as.character(conjoint$party_id_squeeze), ""), pat)))
    if (length(idx) > 0) conjoint$resp_party_clean[idx] <- pn
  }

  conjoint <- conjoint |> mutate(
    resp_farright = factor(ifelse(
      resp_party_clean == cfg$far_right,
      paste0(cfg$far_right, " supporter"),
      paste0("Not ", cfg$far_right, " supporter")
    )),
    # Both surveys are German-language, so these labels work for DE + AT
    resp_lgbtq = factor(case_when(
      is.na(sex_id) | sex_id == "" ~ NA_character_,
      sex_id %in% c("1", "Heterosexuell") ~ "Heterosexual",
      sex_id %in% c("Unsicher", "Keine Angabe") ~ NA_character_,
      TRUE ~ "LGBTQ+"
    ), levels = c("Heterosexual", "LGBTQ+")),
    party_bloc = factor(case_when(
      resp_party_clean %in% cfg$left_parties ~ "Left-party voters",
      resp_party_clean %in% cfg$right_nofr_parties ~ "Right-party voters (non-FR)",
      TRUE ~ NA_character_
    ), levels = c("Left-party voters", "Right-party voters (non-FR)"))
  )

  # Recode education for quota check
  conjoint <- conjoint |> mutate(
    resp_edu_level = case_when(
      as.character(education_level) %in% cfg$education_map[["Low"]] ~ "Low",
      as.character(education_level) %in% cfg$education_map[["Medium"]] ~ "Medium",
      as.character(education_level) %in% cfg$education_map[["High"]] ~ "High",
      # Numeric fallback
      education_level == "1" ~ "Low",
      education_level == "2" ~ "Medium",
      education_level %in% c("3", "4", "5") ~ "High",
      TRUE ~ NA_character_
    )
  )

  # Factor conversions
  conjoint <- conjoint |> mutate(
    PARTY = factor(PARTY, levels = c(cfg$center_right,
                                     setdiff(names(cfg$party_detect), cfg$center_right))),
    FLAG = factor(FLAG, levels = c("No flag", "National flag", "National + EU flag", "EU flag")),
    SOLIDARITY = factor(SOLIDARITY, levels = c("Anti-Trump", "Pro-Trump")),
    REJECTION = factor(REJECTION),
    RELATIONSHIP_SEEK = factor(RELATIONSHIP_SEEK),
    country = cfg$label
  )

  # Respondent-level data
  respondents <- conjoint |> distinct(response_id, .keep_all = TRUE)

  list(
    raw = raw,
    conjoint = conjoint,
    respondents = respondents,
    n_raw = n_raw,
    n_finished = n_finished,
    n_consented = n_consented,
    cfg = cfg
  )
}

# Process both countries
data <- list()
for (ck in names(countries)) {
  result <- tryCatch(
    process_country(countries[[ck]], ck),
    error = function(e) {
      cat(paste0("**Error processing ", countries[[ck]]$label, ":** ", e$message, "\n\n"))
      NULL
    }
  )
  if (!is.null(result)) data[[ck]] <- result
}

# Summary
for (ck in names(data)) {
  d <- data[[ck]]
  cat(paste0("**", d$cfg$label, ":** ",
             nrow(d$respondents), " respondents, ",
             nrow(d$conjoint), " profile evaluations\n\n"))
}

Germany: 1756 respondents, 17560 profile evaluations

Austria: 1365 respondents, 12285 profile evaluations

2. Collection Progress

Show code
progress_data <- map_dfr(names(data), function(ck) {
  d <- data[[ck]]
  d$respondents |>
    mutate(
      date = as.Date(start_date),
      country = d$cfg$label,
      target = d$cfg$target_n
    ) |>
    select(response_id, date, country, target)
})

if (nrow(progress_data) > 0) {
  # Daily cumulative curve
  daily <- progress_data |>
    count(country, date, name = "daily_n") |>
    group_by(country) |>
    arrange(date) |>
    mutate(cumulative = cumsum(daily_n)) |>
    ungroup()

  targets <- progress_data |> distinct(country, target)

  ggplot(daily, aes(x = date, y = cumulative, color = country)) +
    geom_step(linewidth = 1.2) +
    geom_point(size = 2) +
    geom_hline(data = targets, aes(yintercept = target, color = country),
               linetype = "dashed", alpha = 0.5) +
    scale_y_continuous(labels = comma) +
    scale_color_manual(values = c("Germany" = pal$germany, "Austria" = pal$austria)) +
    labs(title = "Data Collection Progress",
         subtitle = "Dashed lines = target N (1,200 per country)",
         x = NULL, y = "Cumulative Respondents", color = NULL)

  # Progress table
  progress_tbl <- map_dfr(names(data), function(ck) {
    d <- data[[ck]]
    n <- nrow(d$respondents)
    tibble(
      Country = d$cfg$label,
      N = n,
      Target = d$cfg$target_n,
      `%` = round(n / d$cfg$target_n * 100, 1),
      `First response` = as.character(min(as.Date(d$respondents$start_date), na.rm = TRUE)),
      `Last response` = as.character(max(as.Date(d$respondents$start_date), na.rm = TRUE))
    )
  })

  progress_tbl |>
    gt() |>
    tab_header(title = "Collection Status") |>
    tab_style(style = cell_fill(color = "#dcfce7"),
              locations = cells_body(rows = `%` >= 80)) |>
    tab_style(style = cell_fill(color = "#fef9c3"),
              locations = cells_body(rows = `%` >= 50 & `%` < 80)) |>
    tab_style(style = cell_fill(color = "#fee2e2"),
              locations = cells_body(rows = `%` < 50)) |>
    tab_options(table.font.size = 13, column_labels.font.weight = "bold")
}
Collection Status
Country N Target % First response Last response
Germany 1756 1200 146.3 2026-03-17 2026-03-25
Austria 1365 1200 113.8 2026-03-17 2026-03-25

3. Quota Tracking

Show code
check_quotas <- function(d) {
  cfg <- d$cfg
  resp <- d$respondents
  n <- nrow(resp)

  results <- list()

  buf <- cfg$buffer_n  # 1320 = target + 10% buffer

  # Gender
  gender_obs <- resp |>
    count(resp_gender) |>
    rename(observed_n = n, level = resp_gender) |>
    mutate(observed_pct = round(observed_n / sum(observed_n) * 100, 1),
           level = as.character(level))
  gender_check <- cfg$quotas$gender |>
    left_join(gender_obs, by = "level") |>
    mutate(limit = ceiling(buf * target_pct / 100),
           remaining = pmax(0L, limit - observed_n),
           fill_pct = round(observed_n / limit * 100, 1),
           deviation = observed_pct - target_pct,
           dimension = "Gender")
  results$gender <- gender_check

  # Age
  age_obs <- resp |>
    count(resp_age) |>
    rename(observed_n = n, level = resp_age) |>
    mutate(observed_pct = round(observed_n / sum(observed_n) * 100, 1),
           level = as.character(level))
  age_check <- cfg$quotas$age |>
    left_join(age_obs, by = "level") |>
    mutate(limit = ceiling(buf * target_pct / 100),
           remaining = pmax(0L, limit - observed_n),
           fill_pct = round(observed_n / limit * 100, 1),
           deviation = observed_pct - target_pct,
           dimension = "Age")
  results$age <- age_check

  # Education
  edu_obs <- resp |>
    count(resp_edu_level) |>
    rename(observed_n = n, level = resp_edu_level) |>
    mutate(observed_pct = round(observed_n / sum(observed_n) * 100, 1),
           level = as.character(level))
  edu_check <- cfg$quotas$education |>
    left_join(edu_obs, by = "level") |>
    mutate(limit = ceiling(buf * target_pct / 100),
           remaining = pmax(0L, limit - observed_n),
           fill_pct = round(observed_n / limit * 100, 1),
           deviation = observed_pct - target_pct,
           dimension = "Education")
  results$education <- edu_check

  bind_rows(results)
}
Show code
plot_quotas <- function(d) {
  quota_results <- check_quotas(d)
  n_resp <- nrow(d$respondents)

  # Tolerance: +/- 5pp for gender/education (soft), +/- 3pp for age (strict)
  quota_results <- quota_results |>
    mutate(
      tol = case_when(dimension == "Age" ~ 3, dimension == "Education" ~ 7, TRUE ~ 5),
      level = factor(level, levels = rev(unique(level))),
      status = case_when(
        abs(deviation) <= tol ~ "On target",
        deviation > tol ~ "Over-represented",
        deviation < -tol ~ "Under-represented"
      ),
      bar_label = paste0(round(observed_pct, 0), "% (target: ", round(target_pct, 0), "%)")
    )

  ggplot(quota_results, aes(y = level)) +
    # Target zone (subtle background)
    geom_segment(aes(x = target_pct - tol, xend = target_pct + tol, y = level, yend = level),
                 color = "grey90", linewidth = 12, alpha = 0.5) +
    # Observed bar
    geom_segment(aes(x = 0, xend = observed_pct, y = level, yend = level, color = status),
                 linewidth = 6) +
    # Target marker
    geom_point(aes(x = target_pct), shape = "|", size = 5, color = "grey30") +
    # Value labels
    geom_text(aes(x = observed_pct, label = bar_label),
              hjust = -0.1, size = 3.2, color = "grey30") +
    scale_color_manual(values = c(
      "On target" = pal$pass, "Over-represented" = pal$primary,
      "Under-represented" = pal$accent
    ), name = NULL) +
    scale_x_continuous(limits = c(0, max(quota_results$observed_pct, quota_results$target_pct) * 1.4),
                       labels = function(x) paste0(x, "%")) +
    facet_wrap(~dimension, scales = "free_y", ncol = 1) +
    labs(title = paste0(d$cfg$label, ": Quota Compliance (N = ", n_resp, ")"),
         subtitle = "Grey zone = tolerance (Age: +/-3pp | Gender: +/-5pp | Education: +/-7pp) | Vertical mark = target",
         x = NULL, y = NULL)
}
Show code
plot_quota_fill <- function(d) {
  quota_results <- check_quotas(d)

  quota_results <- quota_results |>
    mutate(
      level = factor(level, levels = rev(unique(level))),
      fill_status = case_when(
        fill_pct >= 100 ~ "Full",
        fill_pct >= 80  ~ "Nearly full",
        TRUE            ~ "Needs more"
      ),
      bar_label = case_when(
        fill_pct >= 100 ~ paste0(observed_n, " / ", limit, " (FULL)"),
        TRUE            ~ paste0(observed_n, " / ", limit, " (", fill_pct, "%)")
      )
    )

  ggplot(quota_results, aes(y = level)) +
    # Limit marker (grey background bar)
    geom_segment(aes(x = 0, xend = limit, y = level, yend = level),
                 color = "grey90", linewidth = 8) +
    # Observed fill bar
    geom_segment(aes(x = 0, xend = observed_n, y = level, yend = level, color = fill_status),
                 linewidth = 6) +
    # Limit tick
    geom_point(aes(x = limit), shape = "|", size = 5, color = "grey40") +
    # Labels
    geom_text(aes(x = observed_n, label = bar_label),
              hjust = -0.08, size = 3.2, color = "grey30") +
    scale_color_manual(values = c(
      "Full" = pal$pass,
      "Nearly full" = "#f59e0b",
      "Needs more" = pal$accent
    ), name = NULL) +
    scale_x_continuous(
      limits = c(0, max(quota_results$limit) * 1.45),
      labels = comma
    ) +
    facet_wrap(~dimension, scales = "free_y", ncol = 1) +
    labs(title = paste0(d$cfg$label, ": Quota Fill Progress"),
         subtitle = paste0("Absolute counts vs quota limit (", d$cfg$buffer_n, " total slots) | Grey bar = limit"),
         x = "N respondents", y = NULL)
}
Show code
if ("germany" %in% names(data)) {
  plot_quotas(data[["germany"]])
  plot_quota_fill(data[["germany"]])
  check_quotas(data[["germany"]]) |>
    select(dimension, level, observed_n, limit, remaining, fill_pct, observed_pct, target_pct, deviation) |>
    rename(
      Dimension = dimension, Group = level,
      N = observed_n, Limit = limit, Remaining = remaining,
      `Fill %` = fill_pct, `Obs %` = observed_pct,
      `Target %` = target_pct, `Dev (pp)` = deviation
    ) |>
    gt() |>
    tab_header(title = "Germany: Quota Fill Status") |>
    tab_style(style = cell_fill(color = "#dcfce7"),
              locations = cells_body(rows = `Fill %` >= 100)) |>
    tab_style(style = cell_fill(color = "#fef9c3"),
              locations = cells_body(rows = `Fill %` >= 80 & `Fill %` < 100)) |>
    tab_style(style = cell_fill(color = "#fee2e2"),
              locations = cells_body(rows = `Fill %` < 80)) |>
    tab_options(table.font.size = 13, column_labels.font.weight = "bold") |>
    opt_row_striping()
}
Germany: Quota Fill Status
Dimension Group N Limit Remaining Fill % Obs % Target % Dev (pp)
Gender Woman 902 660 0 136.7 51.4 50.0 1.4
Gender Man 851 660 0 128.9 48.5 50.0 -1.5
Age 18-29 306 297 0 103.0 17.4 22.5 -5.1
Age 30-39 288 231 0 124.7 16.4 17.5 -1.1
Age 40-49 253 218 0 116.1 14.4 16.5 -2.1
Age 50-59 366 264 0 138.6 20.8 20.0 0.8
Age 60-74 543 311 0 174.6 30.9 23.5 7.4
Education Low 514 542 28 94.8 29.3 41.0 -11.7
Education Medium 614 410 0 149.8 35.0 31.0 4.0
Education High 628 370 0 169.7 35.8 28.0 7.8
Show code
if ("austria" %in% names(data)) {
  plot_quotas(data[["austria"]])
  plot_quota_fill(data[["austria"]])
  check_quotas(data[["austria"]]) |>
    select(dimension, level, observed_n, limit, remaining, fill_pct, observed_pct, target_pct, deviation) |>
    rename(
      Dimension = dimension, Group = level,
      N = observed_n, Limit = limit, Remaining = remaining,
      `Fill %` = fill_pct, `Obs %` = observed_pct,
      `Target %` = target_pct, `Dev (pp)` = deviation
    ) |>
    gt() |>
    tab_header(title = "Austria: Quota Fill Status") |>
    tab_style(style = cell_fill(color = "#dcfce7"),
              locations = cells_body(rows = `Fill %` >= 100)) |>
    tab_style(style = cell_fill(color = "#fef9c3"),
              locations = cells_body(rows = `Fill %` >= 80 & `Fill %` < 100)) |>
    tab_style(style = cell_fill(color = "#fee2e2"),
              locations = cells_body(rows = `Fill %` < 80)) |>
    tab_options(table.font.size = 13, column_labels.font.weight = "bold") |>
    opt_row_striping()
}
Austria: Quota Fill Status
Dimension Group N Limit Remaining Fill % Obs % Target % Dev (pp)
Gender Woman 701 660 0 106.2 51.4 50.0 1.4
Gender Man 662 660 0 100.3 48.5 50.0 -1.5
Age 18-29 270 291 21 92.8 19.8 22.0 -2.2
Age 30-39 237 231 0 102.6 17.4 17.5 -0.1
Age 40-49 247 225 0 109.8 18.1 17.0 1.1
Age 50-59 288 264 0 109.1 21.1 20.0 1.1
Age 60-74 323 311 0 103.9 23.7 23.5 0.2
Education Low 180 225 45 80.0 13.2 17.0 -3.8
Education Medium 655 634 0 103.3 48.0 48.0 0.0
Education High 530 462 0 114.7 38.8 35.0 3.8

4. Randomization Diagnostics

Show code
calc_binomial_ci <- function(x, n, expected) {
  test <- binom.test(x, n, p = expected)
  tibble(
    observed_pct = round(x / n * 100, 1),
    expected_pct = expected * 100,
    deviation = round(observed_pct - expected_pct, 1),
    ci_lower = round(test$conf.int[1] * 100, 1),
    ci_upper = round(test$conf.int[2] * 100, 1),
    p_value = round(test$p.value, 3)
  )
}

safe_chisq <- function(var1, var2) {
  tbl <- table(var1, var2)
  tbl <- tbl[rowSums(tbl) > 0, colSums(tbl) > 0, drop = FALSE]
  if (min(dim(tbl)) < 2) return(NA_real_)
  tryCatch(
    chisq.test(tbl, simulate.p.value = TRUE, B = 2000)$p.value,
    error = function(e) NA_real_
  )
}

run_randomization_checks <- function(d) {
  conjoint <- d$conjoint
  cfg <- d$cfg
  n_profiles <- nrow(conjoint)
  cat(paste0("\n### ", cfg$label, " (N=", nrow(d$respondents),
             " respondents, ", n_profiles, " profiles)\n\n"))
  if (n_profiles == 0) { cat("No conjoint data available.\n\n"); return(invisible(NULL)) }

  # --- Party distribution ---
  cat("#### Party Distribution\n\n")
  n_parties <- length(cfg$parties)
  party_dist <- tibble(PARTY = cfg$parties) |>
    left_join(conjoint |> filter(!is.na(PARTY)) |> count(PARTY), by = "PARTY") |>
    mutate(n = replace_na(n, 0L),
           observed_pct = round(n / max(sum(n), 1) * 100, 1),
           expected_pct = round(100 / n_parties, 1),
           deviation = observed_pct - expected_pct)

  party_chisq <- tryCatch(
    chisq.test(party_dist$n, p = rep(1/n_parties, n_parties)),
    error = function(e) NULL
  )
  if (!is.null(party_chisq)) {
    cat(paste0("Chi-squared: X2=", round(party_chisq$statistic, 2),
               ", p=", round(party_chisq$p.value, 3), "\n\n"))
  } else {
    cat("Chi-squared: insufficient data\n\n")
  }

  print(knitr::kable(party_dist, caption = paste0("Expected: ~", round(100/n_parties, 1), "% each")))
  cat("\n\n")

  # --- Rejection signal ---
  cat("#### Rejection Signal Rate\n\n")
  non_fr <- conjoint |> filter(!is.na(PARTY) & PARTY != cfg$far_right)
  rej_rate <- mean(non_fr$REJECTION_BINARY, na.rm = TRUE)
  rej_test <- if (nrow(non_fr) > 0)
    tryCatch(binom.test(sum(non_fr$REJECTION_BINARY, na.rm = TRUE), nrow(non_fr), p = 0.3),
             error = function(e) NULL)
  else NULL
  cat(paste0("Observed rejection rate (non-far-right): **",
             round(rej_rate * 100, 1), "%** (expected: 30%)\n\n"))
  if (!is.null(rej_test)) {
    cat(paste0("Binomial test: p=", round(rej_test$p.value, 3),
               ", 95% CI: [", round(rej_test$conf.int[1]*100, 1),
               "%, ", round(rej_test$conf.int[2]*100, 1), "%]\n\n"))
  }

  fr_with_signal <- conjoint |> filter(PARTY == cfg$far_right & REJECTION_BINARY == 1)
  cat(paste0(cfg$far_right, " profiles with rejection signal (should be 0): ",
             nrow(fr_with_signal), "\n\n"))

  # --- Binary attributes ---
  cat("#### Binary Attributes\n\n")
  binary_checks <- bind_rows(
    calc_binomial_ci(sum(conjoint$BI == "Bisexual", na.rm = TRUE), n_profiles, 0.25) |>
      mutate(Attribute = "Bisexual (exp: 25%)"),
    calc_binomial_ci(sum(conjoint$TRANS == "Trans", na.rm = TRUE), n_profiles, 0.10) |>
      mutate(Attribute = "Trans (exp: 10%)"),
    calc_binomial_ci(sum(conjoint$VEGAN == "Vegan", na.rm = TRUE), n_profiles, 0.30) |>
      mutate(Attribute = "Vegan (exp: 30%)")
  ) |> select(Attribute, observed_pct, expected_pct, deviation, ci_lower, ci_upper, p_value)
  print(knitr::kable(binary_checks))
  cat("\n\n")

  # --- Categorical attributes ---
  cat("#### Categorical Attributes\n\n")

  cat("**Distance** (expected: 25% each)\n\n")
  print(knitr::kable(conjoint |> count(DISTANCE) |>
    mutate(pct = round(n/sum(n)*100, 1), expected = 25.0, dev = round(pct - expected, 1))))
  cat("\n")

  cat("**Relationship Type** (expected: 40/20/20/10/10)\n\n")
  print(knitr::kable(conjoint |> count(RELATIONSHIP_SEEK) |>
    mutate(pct = round(n/sum(n)*100, 1))))
  cat("\n")

  cat("**Nationality Flag** (expected: 25% each)\n\n")
  print(knitr::kable(conjoint |> count(FLAG) |>
    mutate(pct = round(n/sum(n)*100, 1))))
  cat("\n")

  cat("**Ethnicity**\n\n")
  print(knitr::kable(conjoint |> count(ETHNICITY) |>
    mutate(pct = round(n/sum(n)*100, 1))))
  cat("\n")

  cat("**Cultural Identity Type** (expected: ~53% conservative, ~47% progressive)\n\n")
  print(knitr::kable(conjoint |> count(CULTURAL_TYPE) |>
    mutate(pct = round(n/sum(n)*100, 1))))
  cat("\n\n")

  # --- Face deduplication ---
  cat("#### Face Deduplication\n\n")
  face_check <- conjoint |>
    group_by(response_id) |>
    summarise(n_profiles = n(), n_unique = n_distinct(person),
              has_repeat = n_profiles > n_unique, .groups = "drop")
  cat(paste0("Respondents with repeated faces: **", sum(face_check$has_repeat), "**\n\n"))

  # --- Age matching ---
  cat("#### Age Matching\n\n")
  age_check <- conjoint |>
    mutate(
      profile_age = suppressWarnings(as.numeric(age)),
      profile_bracket = case_when(
        profile_age >= 18 & profile_age <= 29 ~ "18-29",
        profile_age >= 30 & profile_age <= 49 ~ "30-49",
        profile_age >= 50 ~ "50-74",
        TRUE ~ NA_character_
      ),
      resp_bracket = case_when(
        resp_age %in% c("18-29") ~ "18-29",
        resp_age %in% c("30-39", "40-49") ~ "30-49",
        resp_age %in% c("50-59", "60-74") ~ "50-74"
      ),
      age_match = profile_bracket == resp_bracket
    )
  match_rate <- mean(age_check$age_match, na.rm = TRUE)
  cat(paste0("Age match rate: **", round(match_rate * 100, 1), "%** (expected: ~80-90%)\n\n"))

  # --- Cross-attribute independence ---
  cat("#### Cross-Attribute Independence\n\n")
  independence <- tibble(
    Pair = c("PARTY x BI", "PARTY x TRANS", "PARTY x VEGAN",
             "PARTY x DISTANCE", "PARTY x FLAG", "BI x TRANS"),
    p_value = c(
      safe_chisq(conjoint$PARTY, conjoint$BI),
      safe_chisq(conjoint$PARTY, conjoint$TRANS),
      safe_chisq(conjoint$PARTY, conjoint$VEGAN),
      safe_chisq(conjoint$PARTY, conjoint$DISTANCE),
      safe_chisq(conjoint$PARTY, conjoint$FLAG),
      safe_chisq(conjoint$BI, conjoint$TRANS)
    )
  ) |> mutate(
    p_value = round(p_value, 3),
    Status = ifelse(is.na(p_value), "N/A", ifelse(p_value > 0.05, "OK", "CONCERN"))
  )
  print(knitr::kable(independence))
  cat("\n\n")
}
Show code
if ("germany" %in% names(data)) {
  run_randomization_checks(data[["germany"]])
}

Germany (N=1756 respondents, 17560 profiles)

Party Distribution

Chi-squared: X2=2.09, p=0.837

Expected: ~16.7% each
PARTY n observed_pct expected_pct deviation
AfD 2932 16.7 16.7 0.0
CDU/CSU 2900 16.5 16.7 -0.2
SPD 2889 16.5 16.7 -0.2
Grune 2956 16.8 16.7 0.1
FDP 2975 17.0 16.7 0.3
Die Linke 2899 16.5 16.7 -0.2

Rejection Signal Rate

Observed rejection rate (non-far-right): 30.4% (expected: 30%)

Binomial test: p=0.263, 95% CI: [29.7%, 31.2%]

AfD profiles with rejection signal (should be 0): 0

Binary Attributes

Attribute observed_pct expected_pct deviation ci_lower ci_upper p_value
Bisexual (exp: 25%) 20 25 -5 19.4 20.6 0.000
Trans (exp: 10%) 10 10 0 9.6 10.4 0.990
Vegan (exp: 30%) 30 30 0 29.3 30.7 0.987

Categorical Attributes

Distance (expected: 25% each)

DISTANCE n pct expected dev
<1km 4455 25.4 25 0.4
1-5km 4364 24.9 25 -0.1
5-10km 4371 24.9 25 -0.1
>10km 4370 24.9 25 -0.1

Relationship Type (expected: 40/20/20/10/10)

RELATIONSHIP_SEEK n pct
Casual 3512 20
Committed 7022 40
FWB 1756 10
Open 3513 20
Open to anything 1757 10

Nationality Flag (expected: 25% each)

FLAG n pct
No flag 5267 30
National flag 3512 20
National + EU flag 5270 30
EU flag 3511 20

Ethnicity

ETHNICITY n pct
Local 9607 54.7
Turkish 1757 10.0
Eastern European 1756 10.0
Other 4440 25.3

Cultural Identity Type (expected: ~53% conservative, ~47% progressive)

CULTURAL_TYPE n pct
Conservative 15202 86.6
Progressive 2358 13.4

Face Deduplication

Respondents with repeated faces: 2

Age Matching

Age match rate: 89.9% (expected: ~80-90%)

Cross-Attribute Independence

Pair p_value Status
PARTY x BI 0.835 OK
PARTY x TRANS 0.308 OK
PARTY x VEGAN 0.510 OK
PARTY x DISTANCE 0.892 OK
PARTY x FLAG 0.668 OK
BI x TRANS 0.612 OK
Show code
if ("austria" %in% names(data)) {
  run_randomization_checks(data[["austria"]])
}

Austria (N=1365 respondents, 12285 profiles)

Party Distribution

Chi-squared: X2=5.58, p=0.349

Expected: ~16.7% each
PARTY n observed_pct expected_pct deviation
FPO 2009 16.4 16.7 -0.3
OVP 1976 16.1 16.7 -0.6
SPO 2062 16.8 16.7 0.1
Grune 2076 16.9 16.7 0.2
NEOS 2109 17.2 16.7 0.5
KPO 2053 16.7 16.7 0.0

Rejection Signal Rate

Observed rejection rate (non-far-right): 30% (expected: 30%)

Binomial test: p=0.923, 95% CI: [29.1%, 30.8%]

FPO profiles with rejection signal (should be 0): 0

Binary Attributes

Attribute observed_pct expected_pct deviation ci_lower ci_upper p_value
Bisexual (exp: 25%) 20.0 25 -5.0 19.3 20.8 0.000
Trans (exp: 10%) 9.9 10 -0.1 9.4 10.5 0.764
Vegan (exp: 30%) 29.9 30 -0.1 29.1 30.7 0.875

Categorical Attributes

Distance (expected: 25% each)

DISTANCE n pct expected dev
<1km 3031 24.7 25 -0.3
1-5km 3116 25.4 25 0.4
5-10km 3087 25.1 25 0.1
>10km 3051 24.8 25 -0.2

Relationship Type (expected: 40/20/20/10/10)

RELATIONSHIP_SEEK n pct
Casual 2436 19.8
Committed 4937 40.2
FWB 1210 9.8
Open 2482 20.2
Open to anything 1220 9.9

Nationality Flag (expected: 25% each)

FLAG n pct
No flag 3688 30.0
National flag 2453 20.0
National + EU flag 3680 30.0
EU flag 2464 20.1

Ethnicity

ETHNICITY n pct
Local 6769 55.1
Turkish 1208 9.8
Eastern European 1219 9.9
Other 3089 25.1

Cultural Identity Type (expected: ~53% conservative, ~47% progressive)

CULTURAL_TYPE n pct
Conservative 10662 86.8
Progressive 1623 13.2

Face Deduplication

Respondents with repeated faces: 2

Age Matching

Age match rate: 90.1% (expected: ~80-90%)

Cross-Attribute Independence

Pair p_value Status
PARTY x BI 0.979 OK
PARTY x TRANS 0.047 CONCERN
PARTY x VEGAN 0.593 OK
PARTY x DISTANCE 0.845 OK
PARTY x FLAG 0.973 OK
BI x TRANS 0.611 OK

5. Data Quality

Show code
quality_text <- function(d) {
  # Duration stats
  durations <- d$respondents |>
    mutate(duration_min = as.numeric(duration_seconds) / 60) |>
    filter(!is.na(duration_min))

  if (nrow(durations) > 0) {
    cat("#### Completion Times\n\n")
    cat(paste0("Median: ", round(median(durations$duration_min), 1), " min\n\n"))
    cat(paste0("Mean: ", round(mean(durations$duration_min), 1), " min\n\n"))
    n_speeders <- sum(durations$duration_min < 5)
    cat(paste0("Speeders (<5 min): **", n_speeders, "** (",
               round(n_speeders/nrow(durations)*100, 1), "%)\n\n"))
    n_slow <- sum(durations$duration_min > 60)
    cat(paste0("Very slow (>60 min): ", n_slow, "\n\n"))
  }

  # Attrition by round
  cat("#### Attrition by Round\n\n")
  round_counts <- d$conjoint |>
    count(round) |>
    mutate(round = as.numeric(round)) |>
    arrange(round)
  if (nrow(round_counts) > 0) {
    cat(paste0("Round 1: ", round_counts$n[1],
               " | Round 10: ", round_counts$n[nrow(round_counts)],
               " | Attrition: ",
               round((1 - round_counts$n[nrow(round_counts)] / round_counts$n[1]) * 100, 1),
               "%\n\n"))
  }

  # Duplicate IPs
  cat("#### Duplicate IPs\n\n")
  if ("ip_address" %in% names(d$respondents)) {
    dup_ips <- d$respondents |>
      count(ip_address) |>
      filter(n > 1)
    cat(paste0("Duplicate IPs: ", nrow(dup_ips), "\n\n"))
  } else {
    cat("IP address not available\n\n")
  }
}

quality_plots <- function(d) {
  # Duration histogram
  durations <- d$respondents |>
    mutate(duration_min = as.numeric(duration_seconds) / 60) |>
    filter(!is.na(duration_min))

  if (nrow(durations) > 0) {
    # Cap at 60 min for readability (outliers noted in subtitle)
    n_outliers <- sum(durations$duration_min > 60)
    p1 <- durations |>
      filter(duration_min <= 60) |>
      ggplot(aes(x = duration_min)) +
      geom_histogram(binwidth = 1, fill = pal$primary, color = "white", alpha = 0.8) +
      geom_vline(xintercept = 5, color = "red", linetype = "dashed") +
      labs(title = paste0(d$cfg$label, ": Completion Time Distribution"),
           subtitle = if (n_outliers > 0) paste0(n_outliers, " respondents >60 min not shown") else NULL,
           x = "Duration (minutes)", y = "Count") +
      theme(plot.title = element_text(face = "bold"))
    print(p1)
  }

  # Acceptance rate by round
  round_accept <- d$conjoint |>
    mutate(round_num = as.numeric(round)) |>
    group_by(round_num) |>
    summarise(accept_rate = mean(selected, na.rm = TRUE), .groups = "drop")
  if (nrow(round_accept) > 0) {
    p2 <- round_accept |>
      ggplot(aes(x = round_num, y = accept_rate)) +
      geom_line(color = pal$primary, linewidth = 1) +
      geom_point(color = pal$primary, size = 3) +
      geom_hline(yintercept = mean(d$conjoint$selected, na.rm = TRUE),
                 linetype = "dashed", alpha = 0.5) +
      scale_x_continuous(breaks = 1:10) +
      scale_y_continuous(labels = percent, limits = c(0, 1)) +
      labs(title = paste0(d$cfg$label, ": Acceptance Rate by Round"),
           subtitle = "Dashed line = overall mean",
           x = "Round", y = "Acceptance Rate") +
      theme(plot.title = element_text(face = "bold"))
    print(p2)
  }
}
Show code
if ("germany" %in% names(data)) { quality_text(data[["germany"]]) }

Completion Times

Median: 5.1 min

Mean: 16.8 min

Speeders (<5 min): 829 (47.2%)

Very slow (>60 min): 20

Attrition by Round

Round 1: 1756 | Round 10: 1756 | Attrition: 0%

Duplicate IPs

Duplicate IPs: 8

Show code
if ("germany" %in% names(data)) { quality_plots(data[["germany"]]) }

Show code
if ("austria" %in% names(data)) { quality_text(data[["austria"]]) }

Completion Times

Median: 5.5 min

Mean: 37.9 min

Speeders (<5 min): 578 (42.3%)

Very slow (>60 min): 28

Attrition by Round

Round 1: 1365 | Round 10: 1365 | Attrition: 0%

Duplicate IPs

Duplicate IPs: 9

Show code
if ("austria" %in% names(data)) { quality_plots(data[["austria"]]) }

6. Preliminary Results

Show code
show_results <- any(sapply(data, function(d) nrow(d$respondents) >= params$min_n_for_results))
min_n <- params$min_n_for_results
Show code
cat(paste0("\n**Preliminary results will appear when at least one country reaches N >= ",
           min_n, " respondents.**\n\n"))
cat("Current counts:\n\n")
for (ck in names(data)) {
  d <- data[[ck]]
  cat(paste0("- ", d$cfg$label, ": ", nrow(d$respondents), " / ", min_n, "\n"))
}
Show code
# --- Multi-CI helper ---
add_multi_ci <- function(df) {
  df |> mutate(
    lower90  = estimate - 1.645 * std.error,
    higher90 = estimate + 1.645 * std.error,
    lower95  = estimate - 1.96 * std.error,
    higher95 = estimate + 1.96 * std.error,
    lower99  = estimate - 2.58 * std.error,
    higher99 = estimate + 2.58 * std.error
  )
}

# --- Feature labels ---
feature_labels <- c(
  "PARTY" = "Party", "BI" = "Sexual orientation", "TRANS" = "Gender identity",
  "VEGAN" = "Diet", "ETHNICITY" = "Ethnicity", "FLAG" = "Nationality flag",
  "SOLIDARITY" = "Int'l solidarity", "DISTANCE" = "Distance",
  "RELATIONSHIP_SEEK" = "Relationship type", "CONGRUENCE" = "Gender congruence",
  "EMOTION" = "Facial expression", "CULTURAL_TYPE" = "Cultural identity",
  "JOB_SLIM" = "Occupation", "REJECTION_CLEAN" = "Rejection signal"
)

# --- Core multi-CI plot ---
plot_multi_ci <- function(df, h0 = 0.5, title = "", subtitle = "",
                          xlimits = NULL, show_labels = FALSE, show_facets = FALSE,
                          color_var = NULL, color_vals = NULL) {
  df <- add_multi_ci(df)

  # Clean feature labels
  if ("feature" %in% names(df)) {
    df <- df |> mutate(
      feature = ifelse(feature %in% names(feature_labels), feature_labels[feature], feature),
      feature = factor(feature, levels = unique(feature))
    )
  }

  ci_caption <- "Thin = 99% CI | Medium = 95% CI | Thick = 90% CI"

  if (!is.null(color_var) && color_var %in% names(df)) {
    # Two-group overlay with proper legend
    df$group_label <- as.character(df[[color_var]])
    groups <- unique(df$group_label)
    if (is.null(color_vals)) color_vals <- c(pal$left, pal$right)
    names(color_vals) <- groups

    df$nudge <- ifelse(df$group_label == groups[1], 0.15, -0.15)
    g1 <- df |> filter(group_label == groups[1])
    g2 <- df |> filter(group_label == groups[2])

    p <- ggplot() +
      geom_vline(xintercept = h0, color = "grey80", linewidth = 0.5) +
      # Group 1 (nudged up)
      geom_segment(data = g1, aes(x = lower99, xend = higher99, y = level, yend = level),
                   color = color_vals[1], linewidth = 1.5, alpha = .5, position = position_nudge(y = .15)) +
      geom_segment(data = g1, aes(x = lower95, xend = higher95, y = level, yend = level),
                   color = color_vals[1], linewidth = 2.5, alpha = .7, position = position_nudge(y = .15)) +
      geom_segment(data = g1, aes(x = lower90, xend = higher90, y = level, yend = level),
                   color = color_vals[1], linewidth = 3.5, alpha = .9, position = position_nudge(y = .15)) +
      geom_point(data = g1, aes(x = estimate, y = level, color = group_label),
                 fill = "white", size = 2.5, pch = 21, stroke = 1.2, position = position_nudge(y = .15)) +
      # Group 2 (nudged down)
      geom_segment(data = g2, aes(x = lower99, xend = higher99, y = level, yend = level),
                   color = color_vals[2], linewidth = 1.5, alpha = .5, position = position_nudge(y = -.15)) +
      geom_segment(data = g2, aes(x = lower95, xend = higher95, y = level, yend = level),
                   color = color_vals[2], linewidth = 2.5, alpha = .7, position = position_nudge(y = -.15)) +
      geom_segment(data = g2, aes(x = lower90, xend = higher90, y = level, yend = level),
                   color = color_vals[2], linewidth = 3.5, alpha = .9, position = position_nudge(y = -.15)) +
      geom_point(data = g2, aes(x = estimate, y = level, color = group_label),
                 fill = "white", size = 2.5, pch = 21, stroke = 1.2, position = position_nudge(y = -.15)) +
      scale_color_manual(values = color_vals, name = NULL)
  } else {
    p <- ggplot(df, aes(y = level)) +
      geom_vline(xintercept = h0, color = "grey80", linewidth = 0.5) +
      geom_segment(aes(x = lower99, xend = higher99, y = level, yend = level),
                   color = pal$primary, linewidth = 1.5, alpha = .5) +
      geom_segment(aes(x = lower95, xend = higher95, y = level, yend = level),
                   color = pal$primary, linewidth = 2.5, alpha = .7) +
      geom_segment(aes(x = lower90, xend = higher90, y = level, yend = level),
                   color = pal$primary, linewidth = 3.5, alpha = .9) +
      geom_point(aes(x = estimate, y = level), fill = "white", color = pal$primary,
                 size = 2.5, pch = 21, stroke = 1.2)
  }

  if (show_labels) {
    p <- p + geom_text(data = df, aes(x = estimate, y = level,
                        label = sprintf("%0.2f", estimate)),
                        hjust = -0.3, size = 3.2, fontface = "bold", color = "grey30")
  }

  if (show_facets && "feature" %in% names(df)) {
    p <- p + facet_grid(feature ~ ., scales = "free_y", space = "free_y")
  }

  p <- p +
    labs(title = title, subtitle = subtitle, x = NULL, y = NULL,
         caption = ci_caption) +
    theme(strip.text.y = element_text(angle = 0, hjust = 0))

  if (!is.null(xlimits)) p <- p + xlim(xlimits)

  p
}

# --- Full attribute formula ---
full_attr_formula <- function() {
  selected ~ PARTY + BI + TRANS + VEGAN + ETHNICITY + FLAG +
    SOLIDARITY + DISTANCE + RELATIONSHIP_SEEK + CONGRUENCE + EMOTION + CULTURAL_TYPE
}

# --- Full attribute overview ---
plot_full_attributes <- function(d) {
  conjoint <- d$conjoint
  cfg <- d$cfg

  mm_full <- mm(conjoint, full_attr_formula(), id = ~response_id, h0 = 0.5)
  amce_full <- amce(conjoint, full_attr_formula(), id = ~response_id)

  n_resp <- n_distinct(conjoint$response_id)

  p_mm <- plot_multi_ci(mm_full, h0 = 0.5,
    title = paste0(cfg$label, ": Marginal Means"),
    subtitle = paste0("All attributes | N = ", n_resp, " respondents"),
    show_facets = TRUE)

  p_amce <- plot_multi_ci(amce_full, h0 = 0,
    title = paste0(cfg$label, ": AMCEs"),
    subtitle = paste0("All attributes | N = ", n_resp, " respondents | ref = first level of each attribute"),
    show_facets = TRUE,
    show_labels = TRUE)

  grid.arrange(p_mm, p_amce, ncol = 2)
}

# --- H1a: Party detail with labels ---
plot_party_detail <- function(d) {
  conjoint <- d$conjoint
  cfg <- d$cfg

  mm_party <- mm(conjoint, selected ~ PARTY, id = ~response_id, h0 = 0.5)
  amce_party <- amce(conjoint, selected ~ PARTY, id = ~response_id)

  n_resp <- n_distinct(conjoint$response_id)

  p_mm <- plot_multi_ci(mm_party, h0 = 0.5,
    title = paste0(cfg$label, ": Party Marginal Means"),
    subtitle = paste0("H1a: Far-right stigmatization | N = ", n_resp),
    show_labels = TRUE) +
    labs(x = "MM (Pr of acceptance)")

  p_amce <- plot_multi_ci(amce_party, h0 = 0,
    title = paste0(cfg$label, ": Party AMCEs"),
    subtitle = "Relative to reference category",
    show_labels = TRUE) +
    labs(x = "AMCE")

  grid.arrange(p_mm, p_amce, ncol = 2)
}

# --- Geopolitical attributes: FLAG & SOLIDARITY ---
plot_geopolitical <- function(d) {
  conjoint <- d$conjoint
  cfg <- d$cfg
  n_resp <- n_distinct(conjoint$response_id)

  # FLAG
  mm_flag  <- mm(conjoint, selected ~ FLAG, id = ~response_id, h0 = 0.5)
  amce_flag <- amce(conjoint, selected ~ FLAG, id = ~response_id)

  p_mm_flag <- plot_multi_ci(mm_flag, h0 = 0.5,
    title = paste0(cfg$label, ": Nationality Flag — Marginal Means"),
    subtitle = paste0("N = ", n_resp, " respondents | ref: No flag"))

  p_amce_flag <- plot_multi_ci(amce_flag, h0 = 0,
    title = paste0(cfg$label, ": Nationality Flag — AMCEs"),
    subtitle = paste0("N = ", n_resp, " respondents | ref: No flag"))

  grid.arrange(p_mm_flag, p_amce_flag, ncol = 2)

  # SOLIDARITY
  mm_sol  <- mm(conjoint, selected ~ SOLIDARITY, id = ~response_id, h0 = 0.5)
  amce_sol <- amce(conjoint, selected ~ SOLIDARITY, id = ~response_id)

  p_mm_sol <- plot_multi_ci(mm_sol, h0 = 0.5,
    title = paste0(cfg$label, ": Trump Signal — Marginal Means"),
    subtitle = paste0("N = ", n_resp, " respondents | ref: Anti-Trump"))

  p_amce_sol <- plot_multi_ci(amce_sol, h0 = 0,
    title = paste0(cfg$label, ": Trump Signal — AMCEs"),
    subtitle = paste0("N = ", n_resp, " respondents | ref: Anti-Trump"))

  grid.arrange(p_mm_sol, p_amce_sol, ncol = 2)
}

# --- Subgroup heterogeneity (generic) ---
plot_subgroup_party <- function(d, by_var, by_label, color_vals, h_label) {
  conjoint <- d$conjoint
  cfg <- d$cfg
  by_sym <- rlang::ensym(by_var)

  # Filter to valid subgroup values
  sub_data <- conjoint |> filter(!is.na(!!by_sym))
  groups <- unique(sub_data[[rlang::as_name(by_sym)]])
  if (length(groups) < 2) return(invisible(NULL))

  # Check minimum N per group
  group_ns <- sub_data |> distinct(response_id, !!by_sym) |> count(!!by_sym)
  if (min(group_ns$n) < 30) {
    cat(paste0("Insufficient subgroup N for ", by_label,
               " (min = ", min(group_ns$n), ", need 30)\n\n"))
    return(invisible(NULL))
  }

  # Build subtitle with group Ns
  ns_text <- paste(paste0(group_ns[[1]], " (N=", group_ns$n, ")"), collapse = " vs ")

  # Conditional MMs
  mm_sub <- cj(sub_data, selected ~ PARTY, id = ~response_id,
               estimate = "mm", by = as.formula(paste0("~", rlang::as_name(by_sym))))

  p_mm <- plot_multi_ci(mm_sub, h0 = 0.5,
    title = paste0(cfg$label, ": ", h_label),
    subtitle = ns_text,
    color_var = "BY", color_vals = color_vals) +
    labs(x = "Marginal Mean")

  # mm_diffs
  diff_sub <- mm_diffs(sub_data, selected ~ PARTY, id = ~response_id,
                       by = as.formula(paste0("~", rlang::as_name(by_sym))))
  diff_sub <- add_multi_ci(diff_sub)

  p_diff <- ggplot(diff_sub, aes(y = level)) +
    geom_vline(xintercept = 0, color = "grey80", linewidth = 0.5) +
    geom_segment(aes(x = lower99, xend = higher99, y = level, yend = level),
                 color = pal$muted, linewidth = 1.5, alpha = .5) +
    geom_segment(aes(x = lower95, xend = higher95, y = level, yend = level),
                 color = pal$muted, linewidth = 2.5, alpha = .7) +
    geom_segment(aes(x = lower90, xend = higher90, y = level, yend = level),
                 color = pal$muted, linewidth = 3.5, alpha = .9) +
    geom_point(aes(x = estimate, y = level), fill = "white", color = pal$muted,
               size = 2.5, pch = 21, stroke = 1.2) +
    geom_text(aes(x = estimate, y = level, label = sprintf("%0.2f", estimate)),
              hjust = -0.3, size = 3.2, fontface = "bold", color = "grey30") +
    labs(title = paste0("Difference (", paste(groups, collapse = " - "), ")"),
         subtitle = "0 = no heterogeneity",
         x = "MM Difference", y = NULL,
         caption = "Thin = 99% CI | Medium = 95% CI | Thick = 90% CI")

  grid.arrange(p_mm, p_diff, ncol = 2)
}

# --- Rejection signal analysis ---
plot_rejection_analysis <- function(d) {
  conjoint <- d$conjoint
  cfg <- d$cfg
  conjoint_nofr <- conjoint |> filter(PARTY != cfg$far_right)

  # Overall rejection effect
  mm_rej <- mm(conjoint_nofr, selected ~ REJECTION_CLEAN, id = ~response_id, h0 = 0.5)

  n_nofr <- n_distinct(conjoint_nofr$response_id)

  p_overall <- plot_multi_ci(mm_rej, h0 = 0.5,
    title = paste0(cfg$label, ": H5 Rejection Signal Effect"),
    subtitle = paste0("Non-far-right profiles | N = ", n_nofr),
    show_labels = TRUE) +
    labs(x = "Marginal Mean")

  # Rejection by party
  mm_rej_party <- cj(conjoint_nofr, selected ~ REJECTION_CLEAN, id = ~response_id,
                      estimate = "mm", by = ~PARTY)

  p_party <- plot_multi_ci(mm_rej_party, h0 = 0.5,
    title = "Rejection Signal by Party",
    subtitle = "Does the effect vary by party?",
    show_facets = TRUE,
    color_var = NULL) +
    labs(x = "Marginal Mean")

  grid.arrange(p_overall, p_party, ncol = 2)
}

# --- Rejection subgroup (H5c, H6) ---
plot_rejection_subgroup <- function(d, by_var, by_label, color_vals, h_label) {
  conjoint_nofr <- d$conjoint |> filter(PARTY != d$cfg$far_right)
  by_sym <- rlang::ensym(by_var)

  sub_data <- conjoint_nofr |> filter(!is.na(!!by_sym))
  groups <- unique(sub_data[[rlang::as_name(by_sym)]])
  if (length(groups) < 2) return(invisible(NULL))

  group_ns <- sub_data |> distinct(response_id, !!by_sym) |> count(!!by_sym)
  if (min(group_ns$n) < 10) {
    cat(paste0("Insufficient subgroup N for ", by_label,
               " (min = ", min(group_ns$n), ")\n\n"))
    return(invisible(NULL))
  }

  ns_text <- paste(paste0(group_ns[[1]], " (N=", group_ns$n, ")"), collapse = " vs ")

  mm_sub <- cj(sub_data, selected ~ REJECTION_CLEAN, id = ~response_id,
               estimate = "mm", by = as.formula(paste0("~", rlang::as_name(by_sym))))

  p_mm <- plot_multi_ci(mm_sub, h0 = 0.5,
    title = paste0(d$cfg$label, ": ", h_label),
    subtitle = ns_text,
    color_var = "BY", color_vals = color_vals,
    show_labels = TRUE) +
    labs(x = "Marginal Mean")

  diff_sub <- mm_diffs(sub_data, selected ~ REJECTION_CLEAN, id = ~response_id,
                       by = as.formula(paste0("~", rlang::as_name(by_sym))))
  diff_sub <- add_multi_ci(diff_sub)

  p_diff <- ggplot(diff_sub, aes(y = level)) +
    geom_vline(xintercept = 0, color = "grey80", linewidth = 0.5) +
    geom_segment(aes(x = lower99, xend = higher99, y = level, yend = level),
                 color = pal$muted, linewidth = 1.5, alpha = .5) +
    geom_segment(aes(x = lower95, xend = higher95, y = level, yend = level),
                 color = pal$muted, linewidth = 2.5, alpha = .7) +
    geom_segment(aes(x = lower90, xend = higher90, y = level, yend = level),
                 color = pal$muted, linewidth = 3.5, alpha = .9) +
    geom_point(aes(x = estimate, y = level), fill = "white", color = pal$muted,
               size = 2.5, pch = 21, stroke = 1.2) +
    geom_text(aes(x = estimate, y = level, label = sprintf("%0.2f", estimate)),
              hjust = -0.3, size = 3.2, fontface = "bold", color = "grey30") +
    labs(title = paste0("Difference (", paste(groups, collapse = " - "), ")"),
         subtitle = "0 = no heterogeneity",
         x = "MM Difference", y = NULL,
         caption = "Thin = 99% CI | Medium = 95% CI | Thick = 90% CI")

  grid.arrange(p_mm, p_diff, ncol = 2)
}

# --- PAP contrasts table ---
build_pap_table <- function(d) {
  conjoint <- d$conjoint
  cfg <- d$cfg
  conjoint_nofr <- conjoint |> filter(PARTY != cfg$far_right)
  rows <- list()

  # H1a: Far-right MM vs 0.5
  tryCatch({
    mm_p <- mm(conjoint, selected ~ PARTY, id = ~response_id, h0 = 0.5)
    fr_row <- mm_p |> filter(level == cfg$far_right)
    z <- (fr_row$estimate - 0.5) / fr_row$std.error
    rows$h1a <- tibble(
      Hypothesis = "H1a", Contrast = paste0("MM(", cfg$far_right, ") vs 0.5"),
      Estimate = fr_row$estimate, SE = fr_row$std.error,
      CI_lower = fr_row$lower, CI_upper = fr_row$upper,
      p = 2 * pnorm(-abs(z)))
  }, error = function(e) NULL)

  # H3: mm_diff by party bloc for far-right
  tryCatch({
    sub <- conjoint |> filter(!is.na(party_bloc))
    if (n_distinct(sub$party_bloc) >= 2) {
      diff_bloc <- mm_diffs(sub, selected ~ PARTY, id = ~response_id, by = ~party_bloc)
      fr_diff <- diff_bloc |> filter(level == cfg$far_right)
      if (nrow(fr_diff) > 0) {
        z <- fr_diff$estimate[1] / fr_diff$std.error[1]
        rows$h3 <- tibble(
          Hypothesis = "H3", Contrast = paste0("mm_diff(", cfg$far_right, "): Left vs Right (non-FR) voters"),
          Estimate = fr_diff$estimate[1], SE = fr_diff$std.error[1],
          CI_lower = fr_diff$lower[1], CI_upper = fr_diff$upper[1],
          p = 2 * pnorm(-abs(z)))
      }
    }
  }, error = function(e) NULL)

  # H4.1: mm_diff by gender for far-right
  tryCatch({
    sub <- conjoint |> filter(resp_gender %in% c("Man", "Woman"))
    if (n_distinct(sub$resp_gender) >= 2) {
      diff_gen <- mm_diffs(sub, selected ~ PARTY, id = ~response_id, by = ~resp_gender)
      fr_diff <- diff_gen |> filter(level == cfg$far_right)
      if (nrow(fr_diff) > 0) {
        z <- fr_diff$estimate[1] / fr_diff$std.error[1]
        rows$h41 <- tibble(
          Hypothesis = "H4.1", Contrast = paste0("mm_diff(", cfg$far_right, "): Man vs Woman"),
          Estimate = fr_diff$estimate[1], SE = fr_diff$std.error[1],
          CI_lower = fr_diff$lower[1], CI_upper = fr_diff$upper[1],
          p = 2 * pnorm(-abs(z)))
      }
    }
  }, error = function(e) NULL)

  # H4.2: mm_diff by LGBTQ+ for far-right
  tryCatch({
    if (n_distinct(conjoint$resp_lgbtq) >= 2 && min(table(conjoint$resp_lgbtq)) >= 20) {
      diff_lgbtq <- mm_diffs(conjoint, selected ~ PARTY, id = ~response_id, by = ~resp_lgbtq)
      fr_diff <- diff_lgbtq |> filter(level == cfg$far_right)
      if (nrow(fr_diff) > 0) {
        z <- fr_diff$estimate[1] / fr_diff$std.error[1]
        rows$h42 <- tibble(
          Hypothesis = "H4.2", Contrast = paste0("mm_diff(", cfg$far_right, "): Hetero vs LGBTQ+"),
          Estimate = fr_diff$estimate[1], SE = fr_diff$std.error[1],
          CI_lower = fr_diff$lower[1], CI_upper = fr_diff$upper[1],
          p = 2 * pnorm(-abs(z)))
      }
    }
  }, error = function(e) NULL)

  # H5: Rejection signal difference
  tryCatch({
    mm_rej <- mm(conjoint_nofr, selected ~ REJECTION_CLEAN, id = ~response_id, h0 = 0.5)
    with_sig <- mm_rej |> filter(level == "With rejection signal")
    without_sig <- mm_rej |> filter(level == "Without rejection signal")
    if (nrow(with_sig) > 0 && nrow(without_sig) > 0) {
      diff_est <- with_sig$estimate - without_sig$estimate
      diff_se <- sqrt(with_sig$std.error^2 + without_sig$std.error^2)
      z <- diff_est / diff_se
      rows$h5 <- tibble(
        Hypothesis = "H5", Contrast = "MM(with signal) - MM(without signal)",
        Estimate = diff_est, SE = diff_se,
        CI_lower = diff_est - 1.96 * diff_se, CI_upper = diff_est + 1.96 * diff_se,
        p = 2 * pnorm(-abs(z)))
    }
  }, error = function(e) NULL)

  # H5c: mm_diff rejection by party bloc
  tryCatch({
    sub <- conjoint_nofr |> filter(!is.na(party_bloc))
    if (n_distinct(sub$party_bloc) >= 2) {
      diff_rej_bloc <- mm_diffs(sub, selected ~ REJECTION_CLEAN, id = ~response_id, by = ~party_bloc)
      sig_row <- diff_rej_bloc |> filter(level == "With rejection signal")
      if (nrow(sig_row) > 0) {
        z <- sig_row$estimate[1] / sig_row$std.error[1]
        rows$h5c <- tibble(
          Hypothesis = "H5c", Contrast = "mm_diff(rejection): Left vs Right (non-FR) voters",
          Estimate = sig_row$estimate[1], SE = sig_row$std.error[1],
          CI_lower = sig_row$lower[1], CI_upper = sig_row$upper[1],
          p = 2 * pnorm(-abs(z)))
      }
    }
  }, error = function(e) NULL)

  # H6: mm_diff rejection by far-right support
  tryCatch({
    if (n_distinct(conjoint_nofr$resp_farright) >= 2 &&
        min(table(conjoint_nofr$resp_farright)) >= 10) {
      diff_rej_fr <- mm_diffs(conjoint_nofr, selected ~ REJECTION_CLEAN,
                               id = ~response_id, by = ~resp_farright)
      sig_row <- diff_rej_fr |> filter(level == "With rejection signal")
      if (nrow(sig_row) > 0) {
        z <- sig_row$estimate[1] / sig_row$std.error[1]
        rows$h6 <- tibble(
          Hypothesis = "H6", Contrast = "mm_diff(rejection signal): FR supp vs not",
          Estimate = sig_row$estimate[1], SE = sig_row$std.error[1],
          CI_lower = sig_row$lower[1], CI_upper = sig_row$upper[1],
          p = 2 * pnorm(-abs(z)))
      }
    }
  }, error = function(e) NULL)

  tbl_data <- bind_rows(rows) |>
    mutate(
      Estimate = round(Estimate, 3),
      SE = round(SE, 3),
      CI = paste0("[", round(CI_lower, 3), ", ", round(CI_upper, 3), "]"),
      p = round(p, 4),
      Sig = case_when(p < .01 ~ "***", p < .05 ~ "**", p < .10 ~ "*", TRUE ~ "")
    ) |>
    select(Hypothesis, Contrast, Estimate, SE, CI, p, Sig)

  sig_rows <- which(tbl_data$p < 0.05)
  marg_rows <- which(tbl_data$p >= 0.05 & tbl_data$p < 0.10)

  tbl <- tbl_data |>
    gt() |>
    tab_header(title = paste0(d$cfg$label, ": PAP Contrasts Summary"),
               subtitle = paste0("N = ", nrow(d$respondents), " respondents")) |>
    opt_row_striping() |>
    tab_options(
      table.font.size = 13,
      heading.title.font.size = 16,
      heading.subtitle.font.size = 13,
      column_labels.font.weight = "bold",
      table.border.top.color = "grey80",
      table.border.bottom.color = "grey80"
    ) |>
    cols_align(align = "center", columns = c(Estimate, SE, CI, p, Sig))

  if (length(sig_rows) > 0) {
    tbl <- tbl |> tab_style(style = cell_fill(color = "#dcfce7"),
                             locations = cells_body(rows = sig_rows))
  }
  if (length(marg_rows) > 0) {
    tbl <- tbl |> tab_style(style = cell_fill(color = "#fef9c3"),
                             locations = cells_body(rows = marg_rows))
  }
  tbl
}

6.1 Full Attribute Overview

Show code
if ("germany" %in% names(data) && nrow(data[["germany"]]$respondents) >= min_n) {
  tryCatch(plot_full_attributes(data[["germany"]]),
           error = function(e) cat(paste0("Error: ", e$message, "\n\n")))
}

Show code
if ("austria" %in% names(data) && nrow(data[["austria"]]$respondents) >= min_n) {
  tryCatch(plot_full_attributes(data[["austria"]]),
           error = function(e) cat(paste0("Error: ", e$message, "\n\n")))
}

6.2 H1a: Far-Right Stigma

Show code
if ("germany" %in% names(data) && nrow(data[["germany"]]$respondents) >= min_n) {
  tryCatch(plot_party_detail(data[["germany"]]),
           error = function(e) cat(paste0("Error: ", e$message, "\n\n")))
}

Show code
if ("austria" %in% names(data) && nrow(data[["austria"]]$respondents) >= min_n) {
  tryCatch(plot_party_detail(data[["austria"]]),
           error = function(e) cat(paste0("Error: ", e$message, "\n\n")))
}

6.3 Subgroup Heterogeneity

Show code
if ("germany" %in% names(data) && nrow(data[["germany"]]$respondents) >= min_n) {
  d <- data[["germany"]]
  tryCatch(print(plot_subgroup_party(d, party_bloc, "Party Bloc",
    c(pal$left, pal$right), "H3: Left vs Right (non-FR) party voters")),
    error = function(e) cat(paste0("H3 Error: ", e$message, "\n\n")))
}

TableGrob (1 x 2) "arrange": 2 grobs
  z     cells    name           grob
1 1 (1-1,1-1) arrange gtable[layout]
2 2 (1-1,2-2) arrange gtable[layout]
Show code
if ("germany" %in% names(data) && nrow(data[["germany"]]$respondents) >= min_n) {
  d <- data[["germany"]]
  tryCatch(print(plot_subgroup_party(d, resp_gender, "Gender",
    c(pal$female, pal$male), "H4.1: Women penalize far-right more")),
    error = function(e) cat(paste0("H4.1 Error: ", e$message, "\n\n")))
}
Insufficient subgroup N for Gender (min = 3, need 30)

NULL
Show code
if ("germany" %in% names(data) && nrow(data[["germany"]]$respondents) >= min_n) {
  d <- data[["germany"]]
  tryCatch(print(plot_subgroup_party(d, resp_lgbtq, "LGBTQ+ Status",
    c(pal$hetero, pal$lgbtq), "H4.2: LGBTQ+ penalize far-right more")),
    error = function(e) cat(paste0("H4.2 Error: ", e$message, "\n\n")))
}

TableGrob (1 x 2) "arrange": 2 grobs
  z     cells    name           grob
1 1 (1-1,1-1) arrange gtable[layout]
2 2 (1-1,2-2) arrange gtable[layout]
Show code
if ("austria" %in% names(data) && nrow(data[["austria"]]$respondents) >= min_n) {
  d <- data[["austria"]]
  tryCatch(print(plot_subgroup_party(d, party_bloc, "Party Bloc",
    c(pal$left, pal$right), "H3: Left vs Right (non-FR) party voters")),
    error = function(e) cat(paste0("H3 Error: ", e$message, "\n\n")))
}

TableGrob (1 x 2) "arrange": 2 grobs
  z     cells    name           grob
1 1 (1-1,1-1) arrange gtable[layout]
2 2 (1-1,2-2) arrange gtable[layout]
Show code
if ("austria" %in% names(data) && nrow(data[["austria"]]$respondents) >= min_n) {
  d <- data[["austria"]]
  tryCatch(print(plot_subgroup_party(d, resp_gender, "Gender",
    c(pal$female, pal$male), "H4.1: Women penalize far-right more")),
    error = function(e) cat(paste0("H4.1 Error: ", e$message, "\n\n")))
}
Insufficient subgroup N for Gender (min = 2, need 30)

NULL
Show code
if ("austria" %in% names(data) && nrow(data[["austria"]]$respondents) >= min_n) {
  d <- data[["austria"]]
  tryCatch(print(plot_subgroup_party(d, resp_lgbtq, "LGBTQ+ Status",
    c(pal$hetero, pal$lgbtq), "H4.2: LGBTQ+ penalize far-right more")),
    error = function(e) cat(paste0("H4.2 Error: ", e$message, "\n\n")))
}

TableGrob (1 x 2) "arrange": 2 grobs
  z     cells    name           grob
1 1 (1-1,1-1) arrange gtable[layout]
2 2 (1-1,2-2) arrange gtable[layout]

6.4 Rejection Signal (H5, H5c, H6)

Show code
if ("germany" %in% names(data) && nrow(data[["germany"]]$respondents) >= min_n) {
  tryCatch(print(plot_rejection_analysis(data[["germany"]])),
           error = function(e) cat(paste0("H5 Error: ", e$message, "\n\n")))
}
H5 Error: replacement has 1 row, data has 0
Show code
if ("germany" %in% names(data) && nrow(data[["germany"]]$respondents) >= min_n) {
  tryCatch(print(plot_rejection_subgroup(data[["germany"]], party_bloc, "Party Bloc",
    c(pal$left, pal$right), "H5c: Rejection signal by left vs right (non-FR) voters")),
    error = function(e) cat(paste0("H5c Error: ", e$message, "\n\n")))
}

TableGrob (1 x 2) "arrange": 2 grobs
  z     cells    name           grob
1 1 (1-1,1-1) arrange gtable[layout]
2 2 (1-1,2-2) arrange gtable[layout]
Show code
if ("germany" %in% names(data) && nrow(data[["germany"]]$respondents) >= min_n) {
  tryCatch(print(plot_rejection_subgroup(data[["germany"]], resp_farright, "Far-Right Support",
    c(pal$accent, pal$muted), "H6: Far-right supporters reject anti-far-right signals")),
    error = function(e) cat(paste0("H6 Error: ", e$message, "\n\n")))
}

TableGrob (1 x 2) "arrange": 2 grobs
  z     cells    name           grob
1 1 (1-1,1-1) arrange gtable[layout]
2 2 (1-1,2-2) arrange gtable[layout]
Show code
if ("austria" %in% names(data) && nrow(data[["austria"]]$respondents) >= min_n) {
  tryCatch(print(plot_rejection_analysis(data[["austria"]])),
           error = function(e) cat(paste0("H5 Error: ", e$message, "\n\n")))
}
H5 Error: replacement has 1 row, data has 0
Show code
if ("austria" %in% names(data) && nrow(data[["austria"]]$respondents) >= min_n) {
  tryCatch(print(plot_rejection_subgroup(data[["austria"]], party_bloc, "Party Bloc",
    c(pal$left, pal$right), "H5c: Rejection signal by left vs right (non-FR) voters")),
    error = function(e) cat(paste0("H5c Error: ", e$message, "\n\n")))
}

TableGrob (1 x 2) "arrange": 2 grobs
  z     cells    name           grob
1 1 (1-1,1-1) arrange gtable[layout]
2 2 (1-1,2-2) arrange gtable[layout]
Show code
if ("austria" %in% names(data) && nrow(data[["austria"]]$respondents) >= min_n) {
  tryCatch(print(plot_rejection_subgroup(data[["austria"]], resp_farright, "Far-Right Support",
    c(pal$accent, pal$muted), "H6: Far-right supporters reject anti-far-right signals")),
    error = function(e) cat(paste0("H6 Error: ", e$message, "\n\n")))
}

TableGrob (1 x 2) "arrange": 2 grobs
  z     cells    name           grob
1 1 (1-1,1-1) arrange gtable[layout]
2 2 (1-1,2-2) arrange gtable[layout]

6.5 Geopolitical Attitudes (FLAG & Trump Signal)

Show code
if ("germany" %in% names(data) && nrow(data[["germany"]]$respondents) >= min_n) {
  tryCatch(plot_geopolitical(data[["germany"]]),
           error = function(e) cat(paste0("Error: ", e$message, "\n\n")))
}

Show code
if ("austria" %in% names(data) && nrow(data[["austria"]]$respondents) >= min_n) {
  tryCatch(plot_geopolitical(data[["austria"]]),
           error = function(e) cat(paste0("Error: ", e$message, "\n\n")))
}

6.6 PAP Contrasts Summary

Show code
if ("germany" %in% names(data) && nrow(data[["germany"]]$respondents) >= min_n) {
  tryCatch(build_pap_table(data[["germany"]]),
           error = function(e) cat(paste0("Error: ", e$message, "\n\n")))
}
Germany: PAP Contrasts Summary
N = 1756 respondents
Hypothesis Contrast Estimate SE CI p Sig
H1a MM(AfD) vs 0.5 0.341 0.011 [0.319, 0.363] 0.0000 ***
H3 mm_diff(AfD): Left vs Right (non-FR) voters 0.077 0.027 [0.025, 0.13] 0.0040 ***
H4.2 mm_diff(AfD): Hetero vs LGBTQ+ 0.096 0.046 [0.006, 0.186] 0.0366 **
H5 MM(with signal) - MM(without signal) -0.014 0.013 [-0.038, 0.011] 0.2731
H5c mm_diff(rejection): Left vs Right (non-FR) voters -0.022 0.024 [-0.07, 0.026] 0.3626
H6 mm_diff(rejection signal): FR supp vs not 0.172 0.020 [0.132, 0.212] 0.0000 ***
Show code
if ("austria" %in% names(data) && nrow(data[["austria"]]$respondents) >= min_n) {
  tryCatch(build_pap_table(data[["austria"]]),
           error = function(e) cat(paste0("Error: ", e$message, "\n\n")))
}
Austria: PAP Contrasts Summary
N = 1365 respondents
Hypothesis Contrast Estimate SE CI p Sig
H1a MM(FPO) vs 0.5 0.421 0.013 [0.396, 0.445] 0.0000 ***
H3 mm_diff(FPO): Left vs Right (non-FR) voters -0.016 0.034 [-0.082, 0.05] 0.6339
H4.2 mm_diff(FPO): Hetero vs LGBTQ+ 0.042 0.052 [-0.06, 0.143] 0.4215
H5 MM(with signal) - MM(without signal) -0.002 0.014 [-0.029, 0.026] 0.9118
H5c mm_diff(rejection): Left vs Right (non-FR) voters -0.002 0.029 [-0.059, 0.055] 0.9415
H6 mm_diff(rejection signal): FR supp vs not 0.070 0.023 [0.025, 0.114] 0.0022 ***

6.7 Country Comparison

Show code
if (length(data) == 2) {
  pooled <- map_dfr(names(data), function(ck) {
    d <- data[[ck]]
    d$conjoint |>
      mutate(
        PARTY_HARMONIZED = case_when(
          PARTY == d$cfg$far_right ~ "Far-right",
          PARTY == d$cfg$center_right ~ "Center-right",
          PARTY %in% d$cfg$left_parties ~ "Left",
          TRUE ~ "Other"
        ),
        country = d$cfg$label
      ) |>
      select(response_id, round, selected, PARTY_HARMONIZED, country)
  }) |>
    mutate(
      PARTY_HARMONIZED = factor(PARTY_HARMONIZED,
                                levels = c("Left", "Center-right", "Other", "Far-right")),
      country = factor(country)
    )

  if (nrow(pooled) > 0) {
    tryCatch({
      mm_country <- cj(pooled, selected ~ PARTY_HARMONIZED, id = ~response_id,
                        estimate = "mm", by = ~country) |>
        add_multi_ci()

      p <- plot_multi_ci(mm_country, h0 = 0.5,
        title = "Country Comparison: Party MMs (Harmonized)",
        subtitle = "H1a: Far-right penalty stronger in Germany than Austria?",
        color_var = "BY",
        color_vals = c(pal$germany, pal$austria),
        show_labels = TRUE) +
        labs(x = "Marginal Mean")
      print(p)
    }, error = function(e) cat(paste0("Error: ", e$message, "\n\n")))
  }
}

7. Attention Check

Show code
plot_attention_check <- function(d, country_label) {
  raw <- d$raw
  attn_col <- "feeling_thermometer_9"

  if (!attn_col %in% names(raw)) {
    cat("No attention check data yet.\n\n")
    return(invisible(NULL))
  }

  attn_vals <- raw |>
    filter(!is.na(.data[[attn_col]])) |>
    mutate(value = suppressWarnings(as.numeric(.data[[attn_col]]))) |>
    filter(!is.na(value))

  if (nrow(attn_vals) == 0) {
    cat("No attention check data yet.\n\n")
    return(invisible(NULL))
  }

  n_total <- nrow(attn_vals)
  n_pass <- sum(attn_vals$value == 3)
  n_fail <- n_total - n_pass
  fail_pct <- round(n_fail / n_total * 100, 1)

  # Bar chart of responses
  response_counts <- attn_vals |>
    count(value) |>
    mutate(
      pass = ifelse(value == 3, "Pass (correct = 3)", "Fail"),
      pct = n / sum(n) * 100,
      label = paste0(n, " (", round(pct, 1), "%)")
    )

  p <- ggplot(response_counts, aes(x = factor(value), y = n, fill = pass)) +
    geom_col(color = "white", width = 0.7, alpha = 0.9) +
    geom_text(aes(label = label), vjust = -0.3, size = 3.5, fontface = "bold",
              color = "grey30") +
    scale_fill_manual(values = c("Pass (correct = 3)" = pal$pass, "Fail" = pal$fail)) +
    scale_y_continuous(expand = expansion(mult = c(0, 0.15))) +
    labs(
      title = paste0(country_label, ": Attention Check"),
      subtitle = paste0(n_pass, "/", n_total, " passed | ",
                        n_fail, " failed (", fail_pct, "% failure rate)"),
      x = "Response value (correct answer = 3)", y = "Count", fill = NULL
    )

  print(p)
}
Show code
if ("germany" %in% names(data)) {
  plot_attention_check(data[["germany"]], "Germany")
}

Show code
if ("austria" %in% names(data)) {
  plot_attention_check(data[["austria"]], "Austria")
}

8. Traffic-Light Summary

Show code
build_status <- function(d) {
  cfg <- d$cfg
  conjoint <- d$conjoint
  n_resp <- nrow(d$respondents)
  n_profiles <- nrow(conjoint)

  checks <- list()

  # Collection progress
  pct_complete <- n_resp / cfg$target_n * 100
  checks$collection <- tibble(
    Check = paste0("Collection (", cfg$label, ")"),
    Status = case_when(pct_complete >= 80 ~ "GREEN",
                       pct_complete >= 50 ~ "YELLOW",
                       TRUE ~ "RED"),
    Details = paste0(n_resp, "/", cfg$target_n, " (", round(pct_complete, 1), "%)")
  )

  # Quota deviations
  quota_results <- check_quotas(d) |>
    mutate(tol = case_when(dimension == "Age" ~ 3, dimension == "Education" ~ 7, TRUE ~ 5),
           over_tol = abs(deviation) > tol)
  n_over <- sum(quota_results$over_tol, na.rm = TRUE)
  max_dev <- max(abs(quota_results$deviation), na.rm = TRUE)
  age_max_dev <- max(abs(quota_results$deviation[quota_results$dimension == "Age"]), na.rm = TRUE)
  checks$quotas <- tibble(
    Check = paste0("Quotas (", cfg$label, ")"),
    Status = case_when(n_over == 0 ~ "GREEN",
                       age_max_dev <= 5 & n_over <= 2 ~ "YELLOW",
                       TRUE ~ "RED"),
    Details = paste0("Max deviation: ", round(max_dev, 1), "pp (age max: ", round(age_max_dev, 1), "pp)")
  )

  # Party randomization
  party_dist <- tibble(PARTY = cfg$parties) |>
    left_join(conjoint |> filter(!is.na(PARTY)) |> count(PARTY), by = "PARTY") |>
    mutate(n = replace_na(n, 0L))
  party_chisq_res <- tryCatch(
    chisq.test(party_dist$n, p = rep(1/length(cfg$parties), length(cfg$parties))),
    error = function(e) NULL
  )
  party_p <- if (!is.null(party_chisq_res)) party_chisq_res$p.value else NA_real_
  checks$party <- tibble(
    Check = paste0("Party randomization (", cfg$label, ")"),
    Status = ifelse(is.na(party_p), "N/A", ifelse(party_p > 0.05, "GREEN", ifelse(party_p > 0.01, "YELLOW", "RED"))),
    Details = ifelse(is.na(party_p), "insufficient data", paste0("chi-sq p=", round(party_p, 3)))
  )

  # Rejection signal
  non_fr <- conjoint |> filter(PARTY != cfg$far_right)
  rej_rate <- mean(non_fr$REJECTION_BINARY, na.rm = TRUE)
  checks$rejection <- tibble(
    Check = paste0("Rejection rate (", cfg$label, ")"),
    Status = case_when(abs(rej_rate - 0.3) <= 0.05 ~ "GREEN",
                       abs(rej_rate - 0.3) <= 0.10 ~ "YELLOW",
                       TRUE ~ "RED"),
    Details = paste0(round(rej_rate * 100, 1), "% (target: 30%)")
  )

  # Face deduplication
  face_fails <- d$conjoint |>
    group_by(response_id) |>
    summarise(has_repeat = n() > n_distinct(person), .groups = "drop") |>
    filter(has_repeat)
  checks$faces <- tibble(
    Check = paste0("Face dedup (", cfg$label, ")"),
    Status = ifelse(nrow(face_fails) == 0, "GREEN", "RED"),
    Details = paste0(nrow(face_fails), " failures")
  )

  # Speeders
  durations <- d$respondents |>
    mutate(duration_min = as.numeric(duration_seconds) / 60) |>
    filter(!is.na(duration_min))
  speeder_pct <- mean(durations$duration_min < 5, na.rm = TRUE) * 100
  checks$speeders <- tibble(
    Check = paste0("Speeders (", cfg$label, ")"),
    Status = case_when(speeder_pct <= 5 ~ "GREEN",
                       speeder_pct <= 10 ~ "YELLOW",
                       TRUE ~ "RED"),
    Details = paste0(round(speeder_pct, 1), "%")
  )

  # Attention check
  attn_col <- "feeling_thermometer_9"
  if (attn_col %in% names(d$raw)) {
    attn_vals <- suppressWarnings(as.numeric(d$raw[[attn_col]]))
    attn_vals <- attn_vals[!is.na(attn_vals)]
    if (length(attn_vals) > 0) {
      attn_fail_pct <- mean(attn_vals != 3) * 100
      checks$attention <- tibble(
        Check = paste0("Attention check (", cfg$label, ")"),
        Status = case_when(attn_fail_pct <= 5 ~ "GREEN",
                           attn_fail_pct <= 15 ~ "YELLOW",
                           TRUE ~ "RED"),
        Details = paste0(round(attn_fail_pct, 1), "% failed")
      )
    }
  }

  bind_rows(checks)
}

all_status <- map_dfr(names(data), function(ck) build_status(data[[ck]]))

green_rows <- which(all_status$Status == "GREEN")
yellow_rows <- which(all_status$Status == "YELLOW")
red_rows <- which(all_status$Status == "RED")

tbl <- all_status |>
  gt() |>
  tab_header(title = "Fieldwork Status Dashboard",
             subtitle = paste0("Last updated: ", Sys.time()))

if (length(green_rows) > 0) {
  tbl <- tbl |> tab_style(style = cell_fill(color = "#dcfce7"),
                           locations = cells_body(rows = green_rows))
}
if (length(yellow_rows) > 0) {
  tbl <- tbl |> tab_style(style = cell_fill(color = "#fef9c3"),
                           locations = cells_body(rows = yellow_rows))
}
if (length(red_rows) > 0) {
  tbl <- tbl |> tab_style(style = cell_fill(color = "#fee2e2"),
                           locations = cells_body(rows = red_rows))
}
tbl |>
  tab_options(table.font.size = 13, column_labels.font.weight = "bold") |>
  opt_row_striping()
Fieldwork Status Dashboard
Last updated: 2026-03-25 14:37:31
Check Status Details
Collection (Germany) GREEN 1756/1200 (146.3%)
Quotas (Germany) RED Max deviation: 11.7pp (age max: 7.4pp)
Party randomization (Germany) GREEN chi-sq p=0.837
Rejection rate (Germany) GREEN 30.4% (target: 30%)
Face dedup (Germany) RED 2 failures
Speeders (Germany) RED 47.2%
Attention check (Germany) YELLOW 12.4% failed
Collection (Austria) GREEN 1365/1200 (113.8%)
Quotas (Austria) GREEN Max deviation: 3.8pp (age max: 2.2pp)
Party randomization (Austria) GREEN chi-sq p=0.349
Rejection rate (Austria) GREEN 30% (target: 30%)
Face dedup (Austria) RED 2 failures
Speeders (Austria) RED 42.3%
Attention check (Austria) YELLOW 10.3% failed

Generated by fieldwork_monitor.qmd | Last rendered: 2026-03-25 14:37:31