Fieldwork Monitor: Dating Conjoint (Germany & Austria)

Turnbull-Dugarte, Lopez Ortega & Wurthmann

Published

March 17, 2026

0. Setup

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

set.seed(12345)
theme_set(theme_ggdist())

# 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 Schulabschluss", "Hauptschulabschluss"),
      "Medium" = c("Mittlere Reife / Realschulabschluss"),
      "High" = c("Fachhochschulreife", "Allgemeine Hochschulreife / Abitur", "Hochschulabschluss")
    ),
    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"),
      "Medium" = c("Lehrabschluss", "Berufsbildende mittlere Schule (BMS)"),
      "High" = c("AHS-Matura", "BHS-Matura (HTL, HAK, HLW etc.)", "Hochschulabschluss",
                  "Allgemeine Hochschulreife / Abitur", "Fachhochschulreife")
    ),
    age_qid = "qid499",
    sex_id_qid = "qid396"
  )
)

# Ethnicity map (same for both countries - based on face IDs)
ethnicity_map <- c(
  "W1" = "German", "W2" = "German", "W3" = "German", "W4" = "German",
  "W5" = "Other", "W6" = "German", "W7" = "Turkish",
  "W8" = "Other", "W9" = "German", "W10" = "Eastern European",
  "M1" = "German", "M2" = "Other", "M3" = "Turkish",
  "M4" = "Other", "M5" = "Other", "M6" = "German",
  "M7" = "German", "M8" = "German", "M9" = "German", "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("German", "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+")),
    derecha = factor(case_when(
      ideology_numeric > 5 ~ "Right-wing",
      ideology_numeric < 5 ~ "Left-wing",
      TRUE ~ NA_character_
    ), levels = c("Left-wing", "Right-wing")),
    resp_farright = factor(ifelse(
      str_detect(party_id, cfg$far_right) | str_detect(coalesce(party_id_squeeze, ""), cfg$far_right),
      paste0(cfg$far_right, " supporter"),
      paste0("Not ", cfg$far_right, " supporter")
    )),
    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+"))
  )

  # 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 = names(cfg$party_detect)),
    FLAG = factor(FLAG, levels = c("No flag", "National flag", "National + EU flag", "EU flag")),
    SOLIDARITY = factor(SOLIDARITY, levels = c("None", "Anti-Trump", "Pro-Trump", "Pro-Putin", "Pro-Ukraine")),
    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: 34 respondents, 330 profile evaluations

Austria: 50 respondents, 438 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" = "#DD1100", "Austria" = "#EE3333")) +
    labs(title = "Data Collection Progress",
         subtitle = "Dashed lines = target N (1,200 per country)",
         x = "Date", y = "Cumulative Respondents", color = NULL) +
    theme(plot.title = element_text(face = "bold"), legend.position = "bottom")

  # 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 = "#d4edda"),
              locations = cells_body(rows = `%` >= 80)) |>
    tab_style(style = cell_fill(color = "#fff3cd"),
              locations = cells_body(rows = `%` >= 50 & `%` < 80)) |>
    tab_style(style = cell_fill(color = "#f8d7da"),
              locations = cells_body(rows = `%` < 50))
}
Collection Status
Country N Target % First response Last response
Germany 34 1200 2.8 2026-03-17 2026-03-17
Austria 50 1200 4.2 2026-03-17 2026-03-17

3. Quota Tracking

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

  results <- list()

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

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

  # Education
  edu_obs <- resp |>
    count(resp_edu_level) |>
    mutate(observed_pct = round(n / sum(n) * 100, 1)) |>
    rename(level = resp_edu_level) |>
    mutate(level = as.character(level))
  edu_check <- cfg$quotas$education |>
    left_join(edu_obs, by = "level") |>
    mutate(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)
  quota_results |>
    mutate(label = paste0(dimension, ": ", level)) |>
    ggplot(aes(x = label)) +
    geom_col(aes(y = observed_pct), fill = "steelblue", alpha = 0.7, width = 0.6) +
    geom_point(aes(y = target_pct), color = "red", size = 3, shape = 18) +
    coord_flip() +
    facet_wrap(~dimension, scales = "free_y", ncol = 1) +
    labs(title = paste0(d$cfg$label, ": Observed vs Target Quotas"),
         subtitle = "Blue bars = observed, Red diamonds = target",
         x = NULL, y = "Percentage") +
    theme(plot.title = element_text(face = "bold"),
          strip.text = element_blank())
}
Show code
if ("germany" %in% names(data)) {
  plot_quotas(data[["germany"]])
  check_quotas(data[["germany"]]) |>
    gt() |> tab_header(title = "Germany: Quota Compliance")
}
Germany: Quota Compliance
level target_pct n observed_pct deviation dimension
Woman 50.60 18 52.9 2.30 Gender
Man 49.40 16 47.1 -2.30 Gender
18-29 21.27 5 14.7 -6.57 Age
30-39 17.65 8 23.5 5.85 Age
40-49 16.30 6 17.6 1.30 Age
50-59 19.94 10 29.4 9.46 Age
60-74 24.84 5 14.7 -10.14 Age
Low 36.20 NA NA NA Education
Medium 30.20 16 47.1 16.90 Education
High 33.60 3 8.8 -24.80 Education
Show code
if ("austria" %in% names(data)) {
  plot_quotas(data[["austria"]])
  check_quotas(data[["austria"]]) |>
    gt() |> tab_header(title = "Austria: Quota Compliance")
}
Austria: Quota Compliance
level target_pct n observed_pct deviation dimension
Woman 50.10 26 52 1.90 Gender
Man 49.90 24 48 -1.90 Gender
18-29 21.10 5 10 -11.10 Age
30-39 18.46 11 22 3.54 Age
40-49 17.51 12 24 6.49 Age
50-59 19.84 11 22 2.16 Age
60-74 23.09 11 22 -1.09 Age
Low 22.60 NA NA NA Education
Medium 44.36 NA NA NA Education
High 33.04 NA NA NA Education

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=34 respondents, 330 profiles)

Party Distribution

Chi-squared: X2=3.43, p=0.634

Expected: ~16.7% each
PARTY n observed_pct expected_pct deviation
AfD 64 19.9 16.7 3.2
CDU/CSU 55 17.1 16.7 0.4
SPD 49 15.3 16.7 -1.4
Grune 47 14.6 16.7 -2.1
FDP 55 17.1 16.7 0.4
Die Linke 51 15.9 16.7 -0.8

Rejection Signal Rate

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

Binomial test: p=0.025, 95% CI: [30.7%, 42.8%]

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 15.8 24.7 0.036
Trans (exp: 10%) 10.0 10 0.0 7.0 13.8 1.000
Vegan (exp: 30%) 30.9 30 0.9 26.0 36.2 0.719

Categorical Attributes

Distance (expected: 25% each)

DISTANCE n pct expected dev
<1km 85 25.8 25 0.8
1-5km 83 25.2 25 0.2
5-10km 91 27.6 25 2.6
>10km 71 21.5 25 -3.5

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

RELATIONSHIP_SEEK n pct
Casual 66 20.0
Committed 131 39.7
FWB 34 10.3
Open 66 20.0
Open to anything 33 10.0

Nationality Flag (expected: 25% each)

FLAG n pct
No flag 97 29.4
National flag 66 20.0
National + EU flag 98 29.7
EU flag 69 20.9

Ethnicity

ETHNICITY n pct
German 184 55.8
Turkish 34 10.3
Eastern European 33 10.0
Other 79 23.9

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

CULTURAL_TYPE n pct
Conservative 292 88.5
Progressive 38 11.5

Face Deduplication

Respondents with repeated faces: 0

Age Matching

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

Cross-Attribute Independence

Pair p_value Status
PARTY x BI 0.368 OK
PARTY x TRANS 0.198 OK
PARTY x VEGAN 0.570 OK
PARTY x DISTANCE 0.823 OK
PARTY x FLAG 0.813 OK
BI x TRANS 1.000 OK
Show code
if ("austria" %in% names(data)) {
  run_randomization_checks(data[["austria"]])
}

Austria (N=50 respondents, 438 profiles)

Party Distribution

Chi-squared: X2=2.19, p=0.822

Expected: ~16.7% each
PARTY n observed_pct expected_pct deviation
FPO 66 15.1 16.7 -1.6
OVP 67 15.3 16.7 -1.4
SPO 74 16.9 16.7 0.2
Grune 81 18.5 16.7 1.8
NEOS 76 17.4 16.7 0.7
KPO 74 16.9 16.7 0.2

Rejection Signal Rate

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

Binomial test: p=0, 95% CI: [35.6%, 45.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.1 25 -4.9 16.4 24.2 0.018
Trans (exp: 10%) 10.5 10 0.5 7.8 13.8 0.691
Vegan (exp: 30%) 31.7 30 1.7 27.4 36.3 0.434

Categorical Attributes

Distance (expected: 25% each)

DISTANCE n pct expected dev
<1km 98 22.4 25 -2.6
1-5km 114 26.0 25 1.0
5-10km 111 25.3 25 0.3
>10km 115 26.3 25 1.3

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

RELATIONSHIP_SEEK n pct
Casual 83 18.9
Committed 178 40.6
FWB 46 10.5
Open 87 19.9
Open to anything 44 10.0

Nationality Flag (expected: 25% each)

FLAG n pct
No flag 129 29.5
National flag 92 21.0
National + EU flag 130 29.7
EU flag 87 19.9

Ethnicity

ETHNICITY n pct
German 243 55.5
Turkish 43 9.8
Eastern European 43 9.8
Other 109 24.9

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

CULTURAL_TYPE n pct
Conservative 390 89
Progressive 48 11

Face Deduplication

Respondents with repeated faces: 0

Age Matching

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

Cross-Attribute Independence

Pair p_value Status
PARTY x BI 0.313 OK
PARTY x TRANS 0.369 OK
PARTY x VEGAN 0.779 OK
PARTY x DISTANCE 0.251 OK
PARTY x FLAG 0.993 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) {
    p1 <- durations |>
      ggplot(aes(x = duration_min)) +
      geom_histogram(binwidth = 2, fill = "steelblue", color = "white", alpha = 0.8) +
      geom_vline(xintercept = 5, color = "red", linetype = "dashed") +
      labs(title = paste0(d$cfg$label, ": Completion Time Distribution"),
           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 = "steelblue", linewidth = 1) +
      geom_point(color = "steelblue", 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: 3.4 min

Mean: 3.9 min

Speeders (<5 min): 27 (79.4%)

Very slow (>60 min): 0

Attrition by Round

Round 1: 34 | Round 10: 32 | Attrition: 5.9%

Duplicate IPs

Duplicate IPs: 3

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: 4 min

Mean: 4.4 min

Speeders (<5 min): 41 (82%)

Very slow (>60 min): 0

Attrition by Round

Round 1: 50 | Round 10: 47 | Attrition: 6%

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

Preliminary results will appear when at least one country reaches N >= 100 respondents.

Show code
cat("Current counts:\n\n")

Current counts:

Show code
for (ck in names(data)) {
  d <- data[[ck]]
  cat(paste0("- ", d$cfg$label, ": ", nrow(d$respondents), " / ", min_n, "\n"))
}
  • Germany: 34 / 100
  • Austria: 50 / 100
Show code
plot_country_results <- function(d, min_n) {
  if (nrow(d$respondents) < min_n) {
    cat(paste0("N = ", nrow(d$respondents), " (< ", min_n, "). Results gated.\n\n"))
    return(invisible(NULL))
  }

  conjoint <- d$conjoint
  cfg <- d$cfg

  # --- H1a: Party MMs ---
  tryCatch({
    mm_party <- mm(conjoint, selected ~ PARTY, id = ~response_id, h0 = 0.5)
    p <- mm_party |>
      ggplot(aes(y = reorder(level, estimate), x = estimate)) +
      geom_vline(xintercept = 0.5, linetype = "dashed", alpha = 0.4) +
      geom_pointrange(aes(xmin = lower, xmax = upper), color = "steelblue", size = 0.8) +
      labs(title = paste0(cfg$label, ": Party Marginal Means"),
           subtitle = "H1a: Far-right profiles receive lower positive evaluations",
           x = "Marginal Mean (Pr of acceptance)", y = NULL) +
      theme(plot.title = element_text(face = "bold"))
    print(p)
  }, error = function(e) cat(paste0("H1a Error: ", e$message, "\n\n")))

  # --- H5: Rejection signal effect ---
  conjoint_nofr <- conjoint |> filter(PARTY != cfg$far_right)
  tryCatch({
    mm_rej <- mm(conjoint_nofr, selected ~ REJECTION_CLEAN, id = ~response_id, h0 = 0.5)
    p <- mm_rej |>
      ggplot(aes(y = level, x = estimate)) +
      geom_vline(xintercept = 0.5, linetype = "dashed", alpha = 0.4) +
      geom_pointrange(aes(xmin = lower, xmax = upper), color = "steelblue", size = 0.8) +
      labs(title = paste0(cfg$label, ": Rejection Signal Effect"),
           subtitle = "H5: Profiles with anti-far-right signal vs without (non-far-right only)",
           x = "Marginal Mean", y = NULL) +
      theme(plot.title = element_text(face = "bold"))
    print(p)
  }, error = function(e) cat(paste0("H5 Error: ", e$message, "\n\n")))

  # --- H3: By ideology ---
  conjoint_ideo <- conjoint |> filter(!is.na(derecha))
  if (n_distinct(conjoint_ideo$derecha) >= 2) {
    tryCatch({
      mm_ideo <- cj(conjoint_ideo, selected ~ PARTY, id = ~response_id,
                     estimate = "mm", by = ~derecha)
      p <- mm_ideo |>
        ggplot(aes(y = level, x = estimate, color = derecha)) +
        geom_vline(xintercept = 0.5, linetype = "dashed", alpha = 0.4) +
        geom_pointrange(aes(xmin = lower, xmax = upper),
                        position = position_dodge(width = 0.4), size = 0.6) +
        scale_color_manual(values = c("Left-wing" = "#E74C3C", "Right-wing" = "#3498DB")) +
        labs(title = paste0(cfg$label, ": Party MMs by Left-Right Self-Placement"),
             subtitle = "H3: Center-right supporters evaluate far-right more positively",
             x = "Marginal Mean", y = NULL, color = NULL) +
        theme(plot.title = element_text(face = "bold"), legend.position = "bottom")
      print(p)
    }, error = function(e) cat(paste0("H3 Error: ", e$message, "\n\n")))
  }

  # --- H4.1: By gender ---
  conjoint_gender <- conjoint |> filter(resp_gender %in% c("Man", "Woman"))
  if (n_distinct(conjoint_gender$resp_gender) >= 2) {
    tryCatch({
      mm_gender <- cj(conjoint_gender, selected ~ PARTY, id = ~response_id,
                       estimate = "mm", by = ~resp_gender)
      p <- mm_gender |>
        ggplot(aes(y = level, x = estimate, color = resp_gender)) +
        geom_vline(xintercept = 0.5, linetype = "dashed", alpha = 0.4) +
        geom_pointrange(aes(xmin = lower, xmax = upper),
                        position = position_dodge(width = 0.4), size = 0.6) +
        scale_color_manual(values = c("Man" = "#3498DB", "Woman" = "#E74C3C")) +
        labs(title = paste0(cfg$label, ": Party MMs by Gender"),
             subtitle = "H4.1: Women penalize far-right more than men",
             x = "Marginal Mean", y = NULL, color = NULL) +
        theme(plot.title = element_text(face = "bold"), legend.position = "bottom")
      print(p)
    }, error = function(e) cat(paste0("H4.1 Error: ", e$message, "\n\n")))
  }

  # --- H4.2: By LGBTQ+ ---
  if (n_distinct(conjoint$resp_lgbtq) >= 2 &&
      min(table(conjoint$resp_lgbtq)) >= 20) {
    tryCatch({
      mm_lgbtq <- cj(conjoint, selected ~ PARTY, id = ~response_id,
                      estimate = "mm", by = ~resp_lgbtq)
      p <- mm_lgbtq |>
        ggplot(aes(y = level, x = estimate, color = resp_lgbtq)) +
        geom_vline(xintercept = 0.5, linetype = "dashed", alpha = 0.4) +
        geom_pointrange(aes(xmin = lower, xmax = upper),
                        position = position_dodge(width = 0.4), size = 0.6) +
        labs(title = paste0(cfg$label, ": Party MMs by LGBTQ+ Status"),
             subtitle = "H4.2: LGBTQ+ penalize far-right more",
             x = "Marginal Mean", y = NULL, color = NULL) +
        theme(plot.title = element_text(face = "bold"), legend.position = "bottom")
      print(p)
    }, error = function(e) cat(paste0("H4.2 Error: ", e$message, "\n\n")))
  }

  # --- H5c: Rejection signal by ideology ---
  conjoint_nofr_ideo <- conjoint_nofr |> filter(!is.na(derecha))
  if (n_distinct(conjoint_nofr_ideo$derecha) >= 2) {
    tryCatch({
      mm_rej_ideo <- cj(conjoint_nofr_ideo, selected ~ REJECTION_CLEAN,
                         id = ~response_id, estimate = "mm", by = ~derecha)
      p <- mm_rej_ideo |>
        ggplot(aes(y = level, x = estimate, color = derecha)) +
        geom_vline(xintercept = 0.5, linetype = "dashed", alpha = 0.4) +
        geom_pointrange(aes(xmin = lower, xmax = upper),
                        position = position_dodge(width = 0.4), size = 0.6) +
        scale_color_manual(values = c("Left-wing" = "#E74C3C", "Right-wing" = "#3498DB")) +
        labs(title = paste0(cfg$label, ": Rejection Signal by Ideology"),
             subtitle = "H5c: Rejection signal premium larger among left-leaning",
             x = "Marginal Mean", y = NULL, color = NULL) +
        theme(plot.title = element_text(face = "bold"), legend.position = "bottom")
      print(p)
    }, error = function(e) cat(paste0("H5c Error: ", e$message, "\n\n")))
  }

  # --- H6: Far-right supporters vs rejection ---
  if (n_distinct(conjoint_nofr$resp_farright) >= 2 &&
      min(table(conjoint_nofr$resp_farright)) >= 10) {
    tryCatch({
      mm_rej_fr <- cj(conjoint_nofr, selected ~ REJECTION_CLEAN,
                       id = ~response_id, estimate = "mm", by = ~resp_farright)
      p <- mm_rej_fr |>
        ggplot(aes(y = level, x = estimate, color = resp_farright)) +
        geom_vline(xintercept = 0.5, linetype = "dashed", alpha = 0.4) +
        geom_pointrange(aes(xmin = lower, xmax = upper),
                        position = position_dodge(width = 0.4), size = 0.6) +
        labs(title = paste0(cfg$label, ": Rejection Signal by Far-Right Support"),
             subtitle = "H6: Far-right supporters reject anti-far-right signals more",
             x = "Marginal Mean", y = NULL, color = NULL) +
        theme(plot.title = element_text(face = "bold"), legend.position = "bottom")
      print(p)
    }, error = function(e) cat(paste0("H6 Error: ", e$message, "\n\n")))
  }
}
Show code
if ("germany" %in% names(data)) { plot_country_results(data[["germany"]], min_n) }
Show code
if ("austria" %in% names(data)) { plot_country_results(data[["austria"]], min_n) }
Show code
# --- H1a Country Comparison: pooled data ---
if (length(data) == 2) {
  cat("\n## Country Comparison (H1a)\n\n")

  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",
          TRUE ~ "Other"
        ),
        country = d$cfg$label
      ) |>
      select(response_id, round, selected, PARTY_HARMONIZED, country)
  }) |>
    mutate(
      PARTY_HARMONIZED = factor(PARTY_HARMONIZED,
                                levels = c("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)
      p <- mm_country |>
        ggplot(aes(y = level, x = estimate, color = country)) +
        geom_vline(xintercept = 0.5, linetype = "dashed", alpha = 0.4) +
        geom_pointrange(aes(xmin = lower, xmax = upper),
                        position = position_dodge(width = 0.4), size = 0.8) +
        scale_color_manual(values = c("Germany" = "#DD1100", "Austria" = "#EE3333")) +
        labs(title = "Country Comparison: Party MMs (Harmonized)",
             subtitle = "H1a country: Far-right penalty stronger in Germany than Austria?",
             x = "Marginal Mean", y = NULL, color = NULL) +
        theme(plot.title = element_text(face = "bold"), legend.position = "bottom")
      print(p)
    }, error = function(e) cat(paste0("Error: ", e$message, "\n\n")))
  }
}

7. 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), "%")
  )

  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 = "#d4edda"),
                           locations = cells_body(rows = green_rows))
}
if (length(yellow_rows) > 0) {
  tbl <- tbl |> tab_style(style = cell_fill(color = "#fff3cd"),
                           locations = cells_body(rows = yellow_rows))
}
if (length(red_rows) > 0) {
  tbl <- tbl |> tab_style(style = cell_fill(color = "#f8d7da"),
                           locations = cells_body(rows = red_rows))
}
tbl
Fieldwork Status Dashboard
Last updated: 2026-03-17 13:00:45
Check Status Details
Collection (Germany) RED 34/1200 (2.8%)
Quotas (Germany) RED Max deviation: 24.8pp
Party randomization (Germany) GREEN chi-sq p=0.634
Rejection rate (Germany) YELLOW 36.6% (target: 30%)
Face dedup (Germany) GREEN 0 failures
Speeders (Germany) RED 79.4%
Collection (Austria) RED 50/1200 (4.2%)
Quotas (Austria) RED Max deviation: 11.1pp
Party randomization (Austria) GREEN chi-sq p=0.822
Rejection rate (Austria) RED 40.6% (target: 30%)
Face dedup (Austria) GREEN 0 failures
Speeders (Austria) RED 82%

Generated by fieldwork_monitor.qmd | Last rendered: 2026-03-17 13:00:45