Fieldwork Monitor: Dating Conjoint (Germany & Austria)

Turnbull-Dugarte, Lopez Ortega & Wurthmann

Published

March 19, 2026

Last updated: Thursday March 19, 2026 at 19:12 CET | Next auto-update: every 30 minutes via GitHub Actions (until March 22, 2026)

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.6, 49.4)),
      education = tibble(level = c("Low", "Medium", "High"), target_pct = c(36.2, 30.2, 33.6)),
      age = tibble(level = c("18-29", "30-39", "40-49", "50-59", "60-74"),
                   target_pct = c(21.27, 17.65, 16.30, 19.94, 24.84))
    ),
    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.1, 49.9)),
      education = tibble(level = c("Low", "Medium", "High"), target_pct = c(22.6, 44.36, 33.04)),
      age = tibble(level = c("18-29", "30-39", "40-49", "50-59", "60-74"),
                   target_pct = c(21.10, 18.46, 17.51, 19.84, 23.09))
    ),
    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")
    )

  # --- 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: 1088 respondents, 10545 profile evaluations

Austria: 605 respondents, 5317 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 1088 1200 90.7 2026-03-17 2026-03-19
Austria 605 1200 50.4 2026-03-17 2026-03-19

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)

  quota_results <- quota_results |>
    mutate(
      level = factor(level, levels = rev(unique(level))),
      status = case_when(
        abs(deviation) <= 3 ~ "On target",
        deviation > 3 ~ "Over-represented",
        deviation < -3 ~ "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 - 3, xend = target_pct + 3, 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 = target +/- 3pp | 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 538 668 130 80.5 49.4 50.60 -1.20
Gender Man 546 653 107 83.6 50.2 49.40 0.80
Age 18-29 282 281 0 100.4 25.9 21.27 4.63
Age 30-39 194 233 39 83.3 17.8 17.65 0.15
Age 40-49 180 216 36 83.3 16.5 16.30 0.20
Age 50-59 213 264 51 80.7 19.6 19.94 -0.34
Age 60-74 219 328 109 66.8 20.1 24.84 -4.74
Education Low 474 478 4 99.2 43.6 36.20 7.40
Education Medium 313 399 86 78.4 28.8 30.20 -1.40
Education High 301 444 143 67.8 27.7 33.60 -5.90
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 365 662 297 55.1 60.3 50.10 10.20
Gender Man 238 659 421 36.1 39.3 49.90 -10.60
Age 18-29 193 279 86 69.2 31.9 21.10 10.80
Age 30-39 78 244 166 32.0 12.9 18.46 -5.56
Age 40-49 95 232 137 40.9 15.7 17.51 -1.81
Age 50-59 121 262 141 46.2 20.0 19.84 0.16
Age 60-74 118 305 187 38.7 19.5 23.09 -3.59
Education Low 121 299 178 40.5 20.0 22.60 -2.60
Education Medium 304 586 282 51.9 50.2 44.36 5.84
Education High 180 437 257 41.2 29.8 33.04 -3.24

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"))

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

  n_parties <- length(cfg$parties)
  party_chisq <- chisq.test(party_dist$n, p = rep(1/n_parties, n_parties))
  cat(paste0("Chi-squared: X2=", round(party_chisq$statistic, 2),
             ", p=", round(party_chisq$p.value, 3), "\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(PARTY != cfg$far_right)
  rej_rate <- mean(non_fr$REJECTION_BINARY, na.rm = TRUE)
  rej_test <- binom.test(sum(non_fr$REJECTION_BINARY), nrow(non_fr), p = 0.3)
  cat(paste0("Observed rejection rate (non-far-right): **",
             round(rej_rate * 100, 1), "%** (expected: 30%)\n\n"))
  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=1088 respondents, 10545 profiles)

Party Distribution

Chi-squared: X2=4.2, p=0.521

Expected: ~16.7% each
PARTY n observed_pct expected_pct deviation
CDU/CSU 1714 16.3 16.7 -0.4
AfD 1797 17.1 16.7 0.4
SPD 1708 16.2 16.7 -0.5
Grune 1795 17.0 16.7 0.3
FDP 1766 16.8 16.7 0.1
Die Linke 1756 16.7 16.7 0.0

Rejection Signal Rate

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

Binomial test: p=0.148, 95% CI: [29.7%, 31.7%]

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.0 25 -5.0 19.3 20.8 0.000
Trans (exp: 10%) 10.0 10 0.0 9.4 10.6 0.987
Vegan (exp: 30%) 30.6 30 0.6 29.7 31.5 0.164

Categorical Attributes

Distance (expected: 25% each)

DISTANCE n pct expected dev
<1km 2684 25.5 25 0.5
1-5km 2632 25.0 25 0.0
5-10km 2625 24.9 25 -0.1
>10km 2604 24.7 25 -0.3

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

RELATIONSHIP_SEEK n pct
Casual 2112 20.0
Committed 4218 40.0
FWB 1060 10.1
Open 2096 19.9
Open to anything 1059 10.0

Nationality Flag (expected: 25% each)

FLAG n pct
No flag 3089 29.3
National flag 2135 20.2
National + EU flag 3199 30.3
EU flag 2122 20.1

Ethnicity

ETHNICITY n pct
Local 5803 55.0
Turkish 1059 10.0
Eastern European 1053 10.0
Other 2630 24.9

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

CULTURAL_TYPE n pct
Conservative 9125 86.5
Progressive 1420 13.5

Face Deduplication

Respondents with repeated faces: 1

Age Matching

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

Cross-Attribute Independence

Pair p_value Status
PARTY x BI 0.646 OK
PARTY x TRANS 0.175 OK
PARTY x VEGAN 0.676 OK
PARTY x DISTANCE 0.510 OK
PARTY x FLAG 0.467 OK
BI x TRANS 0.759 OK
Show code
if ("austria" %in% names(data)) {
  run_randomization_checks(data[["austria"]])
}

Austria (N=605 respondents, 5317 profiles)

Party Distribution

Chi-squared: X2=4.27, p=0.511

Expected: ~16.7% each
PARTY n observed_pct expected_pct deviation
OVP 839 15.8 16.7 -0.9
FPO 893 16.8 16.7 0.1
SPO 900 16.9 16.7 0.2
Grune 876 16.5 16.7 -0.2
NEOS 921 17.3 16.7 0.6
KPO 888 16.7 16.7 0.0

Rejection Signal Rate

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

Binomial test: p=0.317, 95% CI: [29.3%, 32.1%]

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%) 19.8 25 -5.2 18.8 20.9 0.000
Trans (exp: 10%) 10.0 10 0.0 9.2 10.9 0.945
Vegan (exp: 30%) 30.7 30 0.7 29.5 32.0 0.255

Categorical Attributes

Distance (expected: 25% each)

DISTANCE n pct expected dev
<1km 1299 24.4 25 -0.6
1-5km 1324 24.9 25 -0.1
5-10km 1376 25.9 25 0.9
>10km 1318 24.8 25 -0.2

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

RELATIONSHIP_SEEK n pct
Casual 1056 19.9
Committed 2131 40.1
FWB 537 10.1
Open 1072 20.2
Open to anything 521 9.8

Nationality Flag (expected: 25% each)

FLAG n pct
No flag 1550 29.2
National flag 1076 20.2
National + EU flag 1616 30.4
EU flag 1075 20.2

Ethnicity

ETHNICITY n pct
Local 2902 54.6
Turkish 530 10.0
Eastern European 528 9.9
Other 1357 25.5

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

CULTURAL_TYPE n pct
Conservative 4599 86.5
Progressive 718 13.5

Face Deduplication

Respondents with repeated faces: 1

Age Matching

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

Cross-Attribute Independence

Pair p_value Status
PARTY x BI 0.933 OK
PARTY x TRANS 0.655 OK
PARTY x VEGAN 0.707 OK
PARTY x DISTANCE 0.838 OK
PARTY x FLAG 0.856 OK
BI x TRANS 1.000 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 min

Mean: 10.8 min

Speeders (<5 min): 542 (49.8%)

Very slow (>60 min): 12

Attrition by Round

Round 1: 1088 | Round 10: 1021 | Attrition: 6.2%

Duplicate IPs

Duplicate IPs: 6

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.2 min

Mean: 9.2 min

Speeders (<5 min): 286 (47.3%)

Very slow (>60 min): 11

Attrition by Round

Round 1: 605 | Round 10: 573 | Attrition: 5.3%

Duplicate IPs

Duplicate IPs: 3

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 = 4, 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 = 1088 respondents
Hypothesis Contrast Estimate SE CI p Sig
H1a MM(AfD) vs 0.5 0.366 0.015 [0.337, 0.394] 0.0000 ***
H3 mm_diff(AfD): Left vs Right (non-FR) voters 0.081 0.036 [0.01, 0.152] 0.0250 **
H4.2 mm_diff(AfD): Hetero vs LGBTQ+ 0.129 0.049 [0.034, 0.224] 0.0079 ***
H5 MM(with signal) - MM(without signal) -0.015 0.016 [-0.047, 0.017] 0.3621
H5c mm_diff(rejection): Left vs Right (non-FR) voters -0.022 0.033 [-0.086, 0.042] 0.4929
H6 mm_diff(rejection signal): FR supp vs not 0.189 0.025 [0.139, 0.239] 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 = 605 respondents
Hypothesis Contrast Estimate SE CI p Sig
H1a MM(FPO) vs 0.5 0.419 0.019 [0.382, 0.456] 0.0000 ***
H3 mm_diff(FPO): Left vs Right (non-FR) voters 0.007 0.051 [-0.092, 0.107] 0.8838
H4.2 mm_diff(FPO): Hetero vs LGBTQ+ -0.032 0.060 [-0.15, 0.086] 0.5958
H5 MM(with signal) - MM(without signal) -0.003 0.021 [-0.045, 0.039] 0.8801
H5c mm_diff(rejection): Left vs Right (non-FR) voters 0.010 0.045 [-0.079, 0.098] 0.8292
H6 mm_diff(rejection signal): FR supp vs not 0.063 0.033 [-0.002, 0.128] 0.0574 *

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)
  max_dev <- max(abs(quota_results$deviation), na.rm = TRUE)
  checks$quotas <- tibble(
    Check = paste0("Quotas (", cfg$label, ")"),
    Status = case_when(max_dev <= 3 ~ "GREEN",
                       max_dev <= 5 ~ "YELLOW",
                       TRUE ~ "RED"),
    Details = paste0("Max deviation: ", round(max_dev, 1), "pp")
  )

  # Party randomization
  party_dist <- conjoint |> filter(!is.na(PARTY)) |> count(PARTY)
  party_p <- chisq.test(party_dist$n, p = rep(1/length(cfg$parties),
                                               length(cfg$parties)))$p.value
  checks$party <- tibble(
    Check = paste0("Party randomization (", cfg$label, ")"),
    Status = ifelse(party_p > 0.05, "GREEN", ifelse(party_p > 0.01, "YELLOW", "RED")),
    Details = 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-19 19:12:22
Check Status Details
Collection (Germany) GREEN 1088/1200 (90.7%)
Quotas (Germany) RED Max deviation: 7.4pp
Party randomization (Germany) GREEN chi-sq p=0.521
Rejection rate (Germany) GREEN 30.7% (target: 30%)
Face dedup (Germany) RED 1 failures
Speeders (Germany) RED 49.8%
Attention check (Germany) YELLOW 14.9% failed
Collection (Austria) YELLOW 605/1200 (50.4%)
Quotas (Austria) RED Max deviation: 10.8pp
Party randomization (Austria) GREEN chi-sq p=0.511
Rejection rate (Austria) GREEN 30.7% (target: 30%)
Face dedup (Austria) RED 1 failures
Speeders (Austria) RED 47.3%
Attention check (Austria) YELLOW 10.6% failed

Generated by fieldwork_monitor.qmd | Last rendered: 2026-03-19 19:12:22