Austrian National Council: Time limits of speeches, and other ramblings…

Austria
parliament
r-bloggers
Who speaks for how long in the National Council of Austria’s Parliament? And who respects the relevant time limits?
Author

Roland Schmidt

Published

19 Jun 2024

Modified

9 Jul 2024

Just the results, please

On the respect of speech time limits

On legislative periods in comparison

1 Context

After quite a while, finally a new blog post. As already a few times before, it’s again something on Austria’s lower chamber, the National Council (for previous posts see e.g. here and here).

While working recently on a somewhat related project, I also had a look at the pages detailing the proceedings of the National Council’s plenary sessions. Here, the item “Redner:innen” (speakers) caught my eye, not only because it details which MP took the floor on which topic, but it also provides data on how long each speech lasted and whether it was within the set or voluntary agreed upon time limits. Here an example:

The screenshot shows a partial snippet of the speakers list of agenda item 1 and 2 (TOP 1-2) of the 241st session of the National Council on 24 November 2023. Most interestingly, the list of speakers shows e.g. Hans Stefan Hitner of the Peoples Party (V) held a speech at the plenary at 9:08 am which lasted 2 minutes and 58 seconds. Generally, for this type of debate (here ‘Normaldebatte’) there is a limit of 20 minutes to a speech. However, this limit is superseded by a - in this case - limit of 5 minutes. The star indicates that it is a ‘voluntary limit’.

This data brought a few questions to my mind: Who speaks for how long? And who exceeds the set or agreed upon time limits? Are there MPs who are particularly inclined to exceed these limits? Are there parties whose members are particularly often speaking longer than agreed? This blog post is about answering such questions. And as so often, before I eventually get to the point which I initially intended to look into, I took a few detours and collected and analyzed other data on the way.

As always, the purpose of this post is to demonstrate a) how to use R when it comes to analyzing data i.e. of the Austrian Parliament, and b) to provide some hopefully relevant insights on a topic of interest. If you are primarily interested in the substantive angle, I would suggest you simply scroll through the post and stop at the related graphs and tables.

On a more general note, let me emphasize that I do not consider myself as an expert on the Austrian political system, let alone the National Council (I happen to have - checks CV - a PhD in PolSci, but that was from an university which was then even not in Austria…). Hence, if you spot any errors etc. please don’t hesitate to let me know (best via mastadon or the platform previously known as twitter).

1.1 Packages

But before I start, let’s get the required packages and define some auxiliary functions.

Load required packages, define auxiliary functions and plot theme
library(tidyverse, warn.conflicts = FALSE, quietly = TRUE)
library(httr2)
library(curl)
library(rvest)
library(reactable)
library(htmltools)
library(lubridate)
library(ggiraph)
library(clock)
library(reactablefmtr)
library(ggrepel)
library(ggtext)
library(tictoc)
library(furrr)
library(patchwork)

plan(multisession, workers = 3)

# define colors
col_bar <- "#262a33"
vec_party_colors <- c(
  FPÖ = "#005DA8", Neos = "#EA5290", ÖVP = "#5DC2CC", SPÖ = "#FC0204", Grünen = "#A3C630",
  none = "darkgrey"
)

# function inserting hyperlink in reactable
fn_reactable_url <- function(value, index) {
  if (is.na(value)) {
    # Option 1: Display nothing or some placeholder text
    htmltools::tags$span("No link available")
    # Option 2: Simply return an empty string or a non-clickable placeholder
    # ""
  } else {
    htmltools::tags$a(
      href = value,
      "link",
      target = "_blank"
    )
  }
}

# function inserting drop-down filter in reactable packages
fn_reactable_filter <- function(elementId) {
  return(function(values, name) {
    tags$select(
      onchange = sprintf("Reactable.setFilter('%s', '%s', event.target.value || undefined)", elementId, name),
      tags$option(value = "", "All"),
      lapply(sort(unique(values)), tags$option),
      "aria-label" = sprintf("Filter %s", name),
      style = "width: 100%; height: 28px;"
    )
  })
}

# function to get MPs name
fn_get_name <- function(pad_intern) {
  # pad_intern <- "35520"

  url <- glue::glue("https://www.parlament.gv.at/person/{pad_intern}?json=TRUE")
  txt <- jsonlite::fromJSON(url)

  # listviewer::jsonedit(txt)
  name_current <- txt$meta$description
  name_previous <- txt$content$personInfo$frueherenamen %>% str_remove_all(., regex("[\\(\\)]"))

  if (purrr::is_empty(name_previous)) {
    return(name_current)
  } else {
    return(glue::glue("{name_current}({name_previous})"))
  }
}

# define theme for plots
theme_post <- function() {
  hrbrthemes::theme_ipsum_rc() +
    theme(
      plot.title = element_textbox_simple(size = rel(1.2), margin = ggplot2::margin(0, 0, .25, 0, unit = "cm")),
      plot.subtitle = element_textbox_simple(size = rel(.9), color = "grey30", face = "italic", family='Roboto condensed',margin = ggplot2::margin(0, 0, b = 1, 0, unit = "cm")),
      axis.title.x = element_blank(),
      axis.title.y = element_blank(),
      axis.text.y = element_text(size = rel(.8)),
      axis.text.x = element_text(size = rel(.8)),
      panel.background = element_rect(fill = "white", color = NA),
      plot.background = element_rect(fill = "white  ", color = NA),
      panel.border = element_blank(),
      plot.title.position = "plot",
      plot.margin = ggplot2::margin(l = 0, 0, 0, 0, "cm"),
      legend.position = "top",
      legend.margin = ggplot2::margin(l = 0, 0, 0, 0, "cm"),
      legend.justification = "left",
      legend.location = "plot",
      legend.title = element_blank(),
      plot.caption = element_textbox_simple(hjust = 0, color = "grey30", margin=ggplot2::margin(t=0.5, unit="cm"))
    )
}

theme_set(theme_post())

# theme_post() %>%  listviewer::jsonedit()

txt_caption_graph <- "Data: https:&#47;&#47;www.parlament.gv.at<br>Analysis: Roland Schmidt | @zoowalk | <span style='font-weight:400'>https:&#47;&#47;werk.statt.codes</span>"

# function adding units to last axis label
fn_label_unit <- function(x, label) {
  x <- as.character(x)
  index_last_label <- max(which(!is.na(x)))
  x[index_last_label] <- paste(x[index_last_label], label)
  return(x)
}

df_lookup_wortmeldung <- c(
  "wm" = "Wortmeldung in Plenarsitzung",
  "un" = "Wortmeldung einer Unterzeichnerin bzw. eines Unterzeichners einer Aktuellen Stunde",
  "rb" = "Wortmeldung eines Regierungsmitglieds",
  "as" = "Wortmeldung in einer Aktuellen Stunde",
  "c" = "Contra-Wortmeldung in einer Debatte",
  "p" = "Pro-Wortmeldung in einer Debatte",
  "el" = "Wortmeldung in einer Ersten Lesung",
  "kd" = "Wortmeldung in einer kurzen Debatte",
  "bg" = "Begründung eines Dringlichen Antrags in einer Plenarsitzung",
  "da" = "Wortmeldung zu einer Dringlichen Anfrage",
  "de" = "Wortmeldung zu einem Dringlichen Antrag",
  "er" = "Regierungserklärung",
  "tb" = "Tatsächliche Berichtigung in einer Plenarsitzung",
  "rs" = "Wortmeldung einer ressortzuständigen Staatssekretärin bzw. eines ressortzuständigen Staatssekretärs im Rahmen der Budgetberatungen",
  "et" = "Erwiderung auf eine tatsächliche Berichtigung in einer Plenarsitzung",
  "gb" = "Wortmeldung zur Geschäftsbehandlung",
  "rf" = "Wortmeldung eines ressortfremden Regierungsmitglieds bzw. einer Staatssekretärin oder eines Staatssekretärs im Rahmen der Budgetberatungen"
) %>%
  enframe(name = "wortmeldungsart", value = "wortmeldungsart_long")

2 Get Data

2.1 Data on Legislative Periods

As a first step to eventually obtain the data on speakers’ speech lengths, we need a dataset comprising the links to all plenary sessions of the National Council. The function fn_get_sessions, which is defined below, does exactly this. By making use of parliament’s API, it takes the respective body (National or Federal Council) as well as the legislative period as an input and returns data on all sessions within this scope.

2.1.1 define function

API request to obtain list of all sessions of a legislative period
fn_get_sessions <- function(legis_period, body) {
  base_url <- "https://www.parlament.gv.at/Filter/api/json/post"

  params <- list(
    `jsMode` = "EVAL",
    `FBEZ` = "WFP_007",
    `showAll` = "true"
  )

  data <- list(
    GP = {{ legis_period }},
    NRBRBV = {{ body }}
  ) %>% discard(., is.null)

  # print(data)

  # run the actual request
  res <- request(base_url) %>%
    req_headers("Accept" = "application/json") %>%
    req_url_query(!!!params) %>%
    req_body_json(data = data, auto_unbox = F) %>%
    req_perform()

  vec_headings <- res %>%
    resp_body_json(., simplifyVector = T) %>%
    pluck(., "header", "label") %>%
    janitor::make_clean_names()

  # extract the actual substantive data
  df_res <- res %>%
    resp_body_json(., simplifyVector = T) %>%
    pluck(., "rows") %>%
    as.data.frame()

  # asign the column names as names to the main dataframe
  colnames(df_res) <- vec_headings

  df_sessions <- df_res %>%
    mutate(link = paste0("https://www.parlament.gv.at", link))

  return(df_sessions)
}

2.1.2 apply function

Below I apply this function. But since we are already at it, I not only request the data for the current XXVII legislative period, but for all periods since the beginning of the 2nd Republic (legislative period V).

Apply function
df_sessions_all <- 5:27 %>%
  as.roman() %>%
  as.character() %>%
  future_map(., \(x) fn_get_sessions(legis_period = x, body = "NR"), .progress = T) %>%
  list_rbind()

#remove sessions which are only announced/in the future
# nrow(df_sessions_all)
df_sessions_all <- df_sessions_all %>%
mutate(datum=lubridate::dmy(datum)) %>%
filter(datum<=lubridate::today()) 
# nrow(df_sessions_all)

2.1.3 result

What we obtain is a data frame with each row containing data on one distinct session day. If a session comprised multiple days, it appears multiple times in the dataframe. This has to be accounted for. To get a better idea, below some sample rows.

Rows: 5
Columns: 11
$ datum        <date> 1999-07-16, 1999-07-15, 1999-07-14, 1999-07-13, 1999-07-…
$ sitzung      <chr> "182. Sitzung ", "181. Sitzung ", "180. Sitzung ", "179. …
$ tagesordnung <chr> "<div class=\"link-list\" inline><ul><li><span>Tagesordnu…
$ gp_code      <chr> "XX", "XX", "XX", "XX", "XX"
$ ityp         <chr> "NRSITZ", "NRSITZ", "NRSITZ", "NRSITZ", "NRSITZ"
$ inr          <chr> "00182", "00181", "00180", "00179", "00178"
$ zukz         <chr> NA, NA, NA, NA, NA
$ datum_sort   <chr> "19990716", "19990715", "19990714", "19990713", "19990708"
$ pfad         <chr> "/gegenstand/XX/NRSITZ/182", "/gegenstand/XX/NRSITZ/181",…
$ sitzungstag  <chr> "1", "1", "1", "1", "1"
$ link         <chr> "https://www.parlament.gv.at/gegenstand/XX/NRSITZ/182", "…

2.2 Data on Speeches

2.2.1 Get data

With the data on sessions during legislative periods available, we now need to retrieve the data pertaining to each individual session, i.e. which speeches were actually held during a specific session. The results from above include the column pfad which contains an url leading to the list of speeches for a specific session. The function fn_get_speeches takes this url as an input and returns a tidy dataset.

define function

Define function to get dataframe of all plenary statements during a distinct session
fn_get_speeches <- function(session_url) {
  # print(session_url)
  
  # session_url <- "/gegenstand/XXVII/NRSITZ/268"

  json_link <- glue::glue("https://www.parlament.gv.at{session_url}?json=TRUE")
  # print(json_link)
  js_sessions <- jsonlite::fromJSON(json_link)
  # listviewer::jsonedit(js_sessions)

  content <- js_sessions$content
  # listviewer::jsonedit(content)
  past_debates <- content$past_debates
  # listviewer::jsonedit(past_debates)

  if (is.null(past_debates)) {
    return(NULL)
  } else {
    df_debates <- past_debates %>%
      enframe() %>%
      unnest_longer(col = "value") %>%
      unnest_wider("value") %>%
      unnest_wider("agenda", names_sep = "_") %>%
      unnest_wider("agenda_1", names_sep = "_") %>%
      mutate(across(starts_with("agenda_1"), \(x) as.list(x))) %>%
      unnest_wider("speeches", names_sep = "_")
  }

  df_debates_wide <- df_debates %>%
    unnest_wider(col = speeches_1, names_sep = "_")

  df_debates_long <- df_debates %>%
    unnest_longer(col = speeches_1)

  df_debates_long <- df_debates_long %>%
    mutate(session_url = session_url, .before = 1)

  is.logical(df_debates_long$speeches_1)

  if (is.logical(df_debates_long$speeches_1)) {
    return(df_debates_long %>%
      select(
        -state,
        -speeches_1,
        -current_speech
        # -V1,
        # -V2
      ) %>%
      rename_with(\(x) case_when(
        x == "agenda_1_zitation" ~ "zitation",
        x == "agenda_1_url" ~ "item_url",
        .default = x
      )) %>%
      select(-contains("agenda")) %>%
      mutate(legis_period = str_extract(session_url, regex("(?<=/gegenstand/)[XVI]+")), .before = 1) %>%
      # mutate(pad_intern=as.numeric(pad_intern)) %>%
      mutate(starttime = lubridate::ymd_hms(starttime)))
  } else {
    df_debates_long <- df_debates_long %>%
      mutate(speeches_1_df = as.data.frame(speeches_1)) %>%
      unnest_wider(col = speeches_1_df) %>%
      rename(
        speech_speaker = V3,
        pad_intern = V4,
        wortmeldungsart = V6,
        meldung_start = V7,
        dauer = V8,
        speech_limit = V9
      ) %>%
      select(
        -state,
        -speeches_1,
        -current_speech,
        -V1,
        -V2
      ) %>%
      rename_with(\(x) case_when(
        x == "agenda_1_zitation" ~ "zitation",
        x == "agenda_1_url" ~ "item_url",
        .default = x
      )) %>%
      select(-contains("agenda")) %>%
      mutate(legis_period = str_extract(session_url, regex("(?<=/gegenstand/)[XVI]+")), .before = 1) %>%
      # mutate(pad_intern=as.numeric(pad_intern)) %>%
      mutate(starttime = lubridate::ymd_hms(starttime)) %>%
      mutate(speech_limit=as.numeric(speech_limit))
  }

  return(df_debates_long)
}

apply function

Here we apply the function to all session urls which we obtained above when checking for all legislative periods. While this should return a dataset of all speeches ever given in the plenary since the beginning of the 2nd Republic, we’ll see that the record of individual speeches starts only by the legislative period XVIII.

Apply function
# get vector with paths to every session of all legilsative period
# ok to use df_sessions_all and not df_sessions_all_unique since we use "unique"
# at the end
vec_sessions_all_path <- df_sessions_all %>%
  mutate(gp_code = as.roman(gp_code) %>% as.numeric()) %>%
  mutate(inr = as.numeric(inr)) %>%
  arrange(gp_code, inr) %>%
  distinct(pfad) %>%
  pull(pfad) %>%
  unique()

length(vec_sessions_all_path) 
## [1] 3118

# feed vector with paths to all sessions to function which returns df of statements of each session
li_session_speeches <- vec_sessions_all_path %>%
  purrr::set_names() %>%
  future_map(., possibly(\(x) fn_get_speeches(session_url = x), otherwise=NA), .progress = T)

# future_map(., \(x) fn_get_speeches(session_url = x), .progress = T)


# each row is a statement
# if there was no statement in a session, there is an empty row => to account for => can mean two things: might be missing data or session without speaker
#
# add session_id

df_session_speeches <- li_session_speeches %>%
  enframe(., name = "session_url_input") %>%
  unnest_longer(value, keep_empty = T) %>% # note keep_empty!
  unnest_wider(value) %>%
  mutate(session_id = str_extract(session_url_input, regex("(?<=NRSITZ/)\\d+")), .after = session_url)

df_session_speeches <- df_session_speeches %>%
  rename(speech_limit_type = V10) %>%
  mutate(speech_limit_type = case_when(
    speech_limit_type == "*" ~ "voluntary",
    is.na(speech_limit_type) ~ "mandatory",
    .default = NA
  ))

Note that the dataframe contains 1) one row for each speech, and b) if there was no speech during/no data available for a session it features an empty row (only the url to the session is included). The latter point has to be kept in mind for the later analysis.

Glimpse of results
df_session_speeches %>%
  filter(legis_period == "XXVII") %>%
  slice_tail(., n = 10) %>%
  glimpse()
Rows: 10
Columns: 36
$ session_url_input <chr> "/gegenstand/XXVII/NRSITZ/268", "/gegenstand/XXVII/N…
$ legis_period      <chr> "XXVII", "XXVII", "XXVII", "XXVII", "XXVII", "XXVII"…
$ session_url       <chr> "/gegenstand/XXVII/NRSITZ/268", "/gegenstand/XXVII/N…
$ session_id        <chr> "268", "268", "268", "268", "268", "268", "268", "26…
$ name              <int> 2, 2, 2, 2, 2, 2, 2, 2, 2, 2
$ id                <int> 6, 6, 6, 6, 6, 6, 7, 7, 7, 7
$ verlauf           <chr> "D", "D", "D", "D", "D", "D", "D", "D", "D", "D"
$ type              <chr> "ND", "ND", "ND", "ND", "ND", "ND", "ND", "ND", "ND"…
$ typetext          <chr> "Normaldebatte", "Normaldebatte", "Normaldebatte", "…
$ text              <chr> "47. Bericht der Volksanwaltschaft (1. Jänner bis 31…
$ limit             <int> 20, 20, 20, 20, 20, 20, 20, 20, 20, 20
$ starttime         <dttm> 2024-06-13 13:44:00, 2024-06-13 13:44:00, 2024-06-13…
$ endtime           <chr> "16:09", "16:09", "16:09", "16:09", "16:09", "16:09"…
$ quorum            <lgl> FALSE, FALSE, FALSE, FALSE, FALSE, FALSE, TRUE, TRUE…
$ names             <lgl> FALSE, FALSE, FALSE, FALSE, FALSE, FALSE, FALSE, FAL…
$ tops              <lgl> TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRU…
$ top               <chr> "TOP 7", "TOP 7", "TOP 7", "TOP 7", "TOP 7", "TOP 7"…
$ openspeaker       <int> 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
$ openminutes       <int> 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
$ to_min            <int> 7, 7, 7, 7, 7, 7, 8, 8, 8, 8
$ to_max            <int> 7, 7, 7, 7, 7, 7, 11, 11, 11, 11
$ haslimit          <lgl> FALSE, FALSE, FALSE, FALSE, FALSE, FALSE, FALSE, FAL…
$ link_start        <lgl> NA, NA, NA, NA, NA, NA, NA, NA, NA, NA
$ link_start_id     <int> NA, NA, NA, NA, NA, NA, NA, NA, NA, NA
$ link_end_id       <int> NA, NA, NA, NA, NA, NA, NA, NA, NA, NA
$ blocktimes        <list<list<list>>> [<NULL>], [<NULL>], [<NULL>], [<NULL>], [<NULL>], [<…
$ speech_speaker    <chr> "Ing. Reinhold Einwallner (S)", "VA Mag. Bernhard Ac…
$ pad_intern        <chr> "24257", "5502", "2326", "5649", "5638", "51568", "8…
$ V5                <chr> "0", "0", "0", "0", "0", "0", "0", "0", "0", "0"
$ wortmeldungsart   <chr> "p", "rb", "rb", "p", "p", "p", "p", "p", "p", "p"
$ meldung_start     <chr> "14:46", "15:37", "15:49", "15:55", "16:00", "16:04"…
$ dauer             <chr> "8:05", "11:45", "5:36", "4:31", "4:00", "3:48", "6:…
$ speech_limit      <dbl> 3, 0, 0, 4, 3, 2, 5, 3, 4, 4
$ speech_limit_type <chr> "voluntary", "mandatory", "mandatory", "voluntary", …
$ zitation          <list<list>> ["2578 d.B."], ["2578 d.B."], ["2578 d.B."], ["2578 …
$ item_url          <list<list>> ["/gegenstand/XXVII/I/2578"], ["/gegenstand/XXVII/I/…

Let’s add the session date to those sessions which did not return any speeches.

Add session dates
# One row per session; number of dates in nested dataframe
df_sessions_all_unique <- df_sessions_all %>%
  mutate(datum = lubridate::dmy(datum)) %>%
  distinct(gp_code, datum, pfad) %>%
  arrange(datum) %>%
  nest(datum_all = datum) %>%
  mutate(datum_length = map_dbl(datum_all, \(x) nrow(x)))

nrow(df_sessions_all)
## [1] 3158
nrow(df_sessions_all_unique)
## [1] 3118
table(df_sessions_all_unique$datum_length)
## 
##    1 
## 3118

df_session_speeches <- df_session_speeches %>%
  left_join(., df_sessions_all_unique, by = c("session_url_input" = "pfad")) %>%
  mutate(legis_period = dplyr::coalesce(legis_period, gp_code)) %>% # add missing legis_period
  mutate(legis_period_num = legis_period %>% as.roman() %>% as.numeric())

# infer speech_date from start time of speech
# take first date as session date if no speech date
tic()
df_session_speeches <- df_session_speeches %>%
  mutate(speech_date = as_date(starttime), .after = starttime) %>%
  mutate(session_date = case_when(
    is.na(speech_date) ~ map_chr(datum_all, \(x) x %>%
      pull(datum) %>%
      first() %>%
      as.character(), .progress = T),
    .default = speech_date %>% as.character()
  ))
toc()
## 29.64 sec elapsed

2.2.2 Result

With the data on speeches per legislative sessions available, there are already a few things which we can have a look at.

Duration, number of sessions and statements per legislative period

First, let’s compare the numbers across legislative periods and create an overview table which shows us the length of legislative periods, the number of sessions, and the number of speeches during each legislative period.

I strongly assume that I simply missed it, but for some reason I couldn’t find the start and end date of legislative periods on parliament’s website. Hence, I scrape the dates from Wikipedia.

Get start/end dates of legislative sessions
# Cummulative number of statements in days since start of legislative
# only sessions with plenary statements
#| cache: true
wiki_url <- "https://de.wikipedia.org/wiki/Nationalrat_(%C3%96sterreich)"

tbl_legis_periods <- wiki_url %>%
  rvest::read_html() %>%
  rvest::html_table() %>%
  .[[2]] %>%
  janitor::clean_names() %>%
  filter(!str_detect(gesetzgebungsperiode_nationalversammlung, regex("^Gesetz")))

tbl_legis_periods <- tbl_legis_periods %>%
  mutate(
    legis_period = str_extract(gesetzgebungsperiode_nationalversammlung, regex("^[VXI]+"))
  ) %>%
  mutate(
    legis_period_num = as.roman(legis_period) %>% as.numeric()
  ) %>%
  filter(legis_period_num > 4) %>%
  tidyr::separate_wider_delim(zeitraumvon_bis, delim = regex("\\p{Pd}"), names = c("date_start", "date_end"), too_few = "debug") %>%
  mutate(date_start = case_when(
    legis_period_num == 27 ~ "23.10.2019",
    .default = date_start
  )) %>%
  mutate(date_end = case_when(
    legis_period_num == 27 ~ as.character(Sys.Date() %>% format(., "%d.%m.%Y")),
    .default = date_end
  )) %>%
  # filter(legis_period_num>17) %>%
  select(
    matches("legis_period"),
    matches("^date")
  ) %>%
  mutate(across(matches("date"), \(x) lubridate::dmy(x))) %>%
  mutate(legis_duration = difftime(date_end, date_start, unit = "days") %>% as.numeric())
Get number of sessions per legislative period
df_sessions_n <- df_sessions_all %>%
  distinct(gp_code, sitzung) %>%
  count(gp_code) %>%
  mutate(gp_code = as.numeric(as.roman(gp_code))) %>%
  arrange(gp_code)


df_legis_session_n <- df_sessions_n %>%
  rename(
    legis_period_num = gp_code,
    legis_session_n = n
  )


# build table: add number of sessions to duration of legislative periods
tbl_legis_period_overview <- tbl_legis_periods %>%
  left_join(., df_legis_session_n) %>%
  mutate(date_end = case_when(
    legis_period_num == 27 ~ NA,
    .default = date_end
  ))
Get number of statements per period (XVIII onward)
df_legis_period_n_statements <- df_session_speeches %>%
  group_by(legis_period, legis_period_num) %>%
  summarise(legis_speech_n = sum(!is.na(speech_speaker))) %>% # count only where speech is not empty
  mutate(legis_speech_n = dplyr::na_if(legis_speech_n, 0)) %>%
  filter(legis_period > 18) %>%
  ungroup()

# Add to table
tbl_legis_period_overview <- tbl_legis_period_overview %>%
  left_join(., df_legis_period_n_statements)

When compiling the dataset, I noticed that the XVIII legislative period featured a relatively low number of speeches, considering the length of the period. As it turns out, the data on speeches only starts way into the XVIII legislative period, i.e. there is a considerable chuck of speeches from the period’s beginning missing (the sessions’ transcripts reveal that speeches were held). To avoid any misunderstandings, I remove the speech data of the 18th session in the subsequent overview table.

As a side note, I have been working on a script which extracts the individual speeches from the pdf transcripts of all sessions prior to legislative period XIX. If time (…and with a little help of some funding…) permits, I hope to finalize it in the near future.

Speech data for legis period XXVIII not complete
df_speechees_18 <- df_session_speeches %>%
  filter(legis_period_num == 18)

df_speechees_18_dates <- df_speechees_18 %>%
  reframe(date_rage = min(speech_date, na.rm = T))
df_speechees_18_dates
## # A tibble: 1 × 1
##   date_rage 
##   <date>    
## 1 1992-04-02

tbl_legis_periods %>%
  filter(legis_period_num == 18)
## # A tibble: 1 × 5
##   legis_period legis_period_num date_start date_end   legis_duration
##   <chr>                   <dbl> <date>     <date>              <dbl>
## 1 XVIII                      18 1990-11-05 1994-11-06           1462
Table: Duration, number of sessions, and speeches
tbl_legis_period_overview %>%
  arrange(desc(legis_period_num)) %>%
  mutate(legis_speech_n = case_when(
    legis_period_num == 18 ~ NA,
    .default = legis_speech_n
  )) %>%
  select(-legis_period_num) %>%
  reactable(,
    columns = list(
      legis_period = colDef(
        name = "Legis. Period"
      ),
      date_start = colDef(name = "start"),
      date_end = colDef(name = "end"),
      legis_duration = colDef(
        name = "Duration (days)",
        width = 200,
        align = "left",
        cell = data_bars(., fill_color = col_bar, number_fmt = scales::label_number(big.mark = ",", decimal.mark = "."), background = "white")
      ),
      legis_session_n = colDef(
        name = "Number of sessions",
        align = "left",
        width = 150,
        cell = data_bars(.,
          fill_color = "#FFBF00", max_value = 1826,
          background = "white",
          text_position = "outside-end"
        )
      ),
      legis_speech_n = colDef(
        name = "Number of speeches",
        align = "left",
        width = 350,
        cell = data_bars(.,
          fill_color = "#839791",
          background = "white",
          text_position = "inside-end",
          number_fmt = scales::label_number(big.mark = ",", decimal.mark = "."),
          force_outside = c(0, 4000)
        )
      )
    ),
    fullWidth = TRUE,
    compact = TRUE,
    highlight = FALSE,
    outlined = TRUE,
    defaultPageSize = 23,
    theme = fivethirtyeight(font_size = 12)
  ) %>%
  add_title(
    title = html("<span style='font-size:12pt;'>Austrian National Council (Nationalrat): Legislative periods in comparision: duration, number of sessions, and speeches.</span>")
  ) %>%
  add_subtitle(
    subtitle = html(glue::glue("<span style='font-size:10pt;'>Number of sessions: Data also includes sessions without statements in the plenary. Duration: XXVII legislative period as of {today() %>% format(., '%d.%m.%Y')}. No data on individual speeches for periods prior to legis. preiod XIX available.</span>")),
    font_color = "grey30", font_weight = "normal", font_style="italic") %>%
  add_source(
    source = html("<span style='font-size:8pt;color:grey30;font-family:Segoe UI !important;'>Source: www.parlament.gv.at; de.wikipedia.org (start/end dates of legislative periods). Analysis: Roland Schmidt - @zoowalk - https://werk.statt.codes</span>"))

Austrian National Council (Nationalrat): Legislative periods in comparision: duration, number of sessions, and speeches.

Number of sessions: Data also includes sessions without statements in the plenary. Duration: XXVII legislative period as of 19.06.2024. No data on individual speeches for periods prior to legis. preiod XIX available.

Source: www.parlament.gv.at; de.wikipedia.org (start/end dates of legislative periods). Analysis: Roland Schmidt - @zoowalk - https://werk.statt.codes

Number of sessions vs duration of legislative periods

Having the data for the duration starting from legislative period V to the current XXVII (as of 09.07.2024), we can check how the total number of sessions per period developed over time. Below the pertaining graph. Note that the bars indicate the number of sessions, and not session days (i.e. multi-day sessions are counted as one single instance). As it turns out, the current legislative period XXVII, which is even not yet concluded by the time of writing, has been featuring more sessions than any other pervious legislative period of the 2nd Republic. While this ‘record’ is at least partly due to the extension of legislative periods from four to five years (since 2007), I found it still a noteworthy detail.

Number of sessions and duration of legislative period
df_sessions_n <- df_sessions_all %>%
  distinct(gp_code, sitzung) %>%
  count(gp_code) %>%
  mutate(gp_code = as.numeric(as.roman(gp_code))) %>%
  arrange(gp_code)

vec_colors_period_length <- c("5"="#ff0000", "4"="#262a33")

tbl_legis_period_overview <- tbl_legis_period_overview %>%
mutate(legis_duration_formal=case_when(
  legis_period_num>23 ~ 5,
  .default=4
)) %>%
mutate(days_per_session=legis_duration/legis_session_n) %>%
mutate(date_start_end=glue::glue("{date_start %>% format(., '%b\\'%y')}-{ifelse(!is.na(date_end), date_end %>% format(., '%b\\'%y'), 'ongoing')}")) %>%
mutate(x_label=glue::glue("{legis_period} *({date_start_end})*"))

txt_subtitle <- glue::glue("No other legislative period featured more sessions of the National Council than the current period XXVII. Multi-day sessions were counted as one single item. Data also includes sessions without plenary speeches. 
Data for legis. period XXVII as of {lubridate::today() %>% format(., '%d.%m.%y')}. In 2007, the maximum duration of a legislative period was raised from four to five years.")

tbl_legis_period_overview %>%
ggplot(.,aes(x=legis_duration, y=legis_session_n, label=legis_period, 
color=factor(legis_duration_formal)
))+
labs(
  title="Number of National Council sessions and duration of legislative periods",
  x="De facto duration of legislative period",
  subtitle=txt_subtitle,
  caption=txt_caption_graph,
)+
geom_point()+
ggrepel::geom_text_repel(
    size=3,
    # fill="transparent",
    # label.color="transparent",
    lineheight=.8,
    family="Roboto Condensed")+
# geom_text_repel()+
geom_vline(xintercept=4*365, color=vec_colors_period_length["4"])+
geom_text(
  x=4*365+25, 
  y=310, 
  label = glue::glue("4 year term limit."),
  family = "Roboto condensed",
  color = vec_colors_period_length["4"],
  check_overlap=T,
  hjust = 0,
  size = 3)+
geom_text(
  x=5*365+25, 
  y=310, 
  label = glue::glue("5 year term limit."),
  family = "Roboto condensed",
  color = vec_colors_period_length["5"],
  check_overlap=T,
  hjust = 0,
  size = 3)+
geom_vline(xintercept=5*365, color=vec_colors_period_length["5"])+
scale_y_continuous(
  labels=\(x) fn_label_unit(x, "sessions") %>% str_wrap(., 10), 
  limits=c(0, 315),
  expand=expansion(mult=c(0,0))
  )+
scale_x_continuous(
  label=\(x) x %>% scales::number(., big.mark=',', decimal.mark='.') %>% fn_label_unit(., "days"), 
  limits=c(0,NA),
  expand=expansion(mult=c(0,.25))
  )+
scale_color_manual(values=vec_colors_period_length, labels=c("4"="4 years", "5"="5 years"), name="Formal maximum length of legislative period")+
theme(
  axis.text.x=element_text(hjust=0),
  legend.position="none"
)

If we want to get an idea of the relative frequency of sessions, i.e. take the de facto duration of the legislative periods into account, we can calculate a days/session ratio (a lower value means relatively more sessions). And, as already suggested by the previous graph, it’s the current XXVII legislative period which comes out on top.

Code
txt_subtitle_pl_2 <- glue::glue("To account for the different de facto lengths of legislative periods, let's divide the number of days by the number of sessions. A lower ratio indicates a higher relative frequency of sessions. No legislative period of the 2nd Republic (legis period V onwards) features a higher relative frequency of sessions than the current period XXVII.")

tbl_legis_period_overview %>%
mutate(x_label=case_when(
  legis_period_num==27 ~ glue::glue("<span style='color:dodgerblue;'>**{x_label}**</span>"),
  .default=glue::glue("{x_label}")
)) %>%
mutate(x_label=forcats::fct_infreq(x_label, w=days_per_session)) %>%
ggplot()+
labs(
  title="Relative frequency of sessions in the National Council",
  subtitle=txt_subtitle_pl_2,
  x="days/session ratio",
  y="legislative period",
  caption=txt_caption_graph
  )+
geom_point(aes(y=x_label, x=days_per_session, color=ifelse(legis_period_num==27, "T", "F")))+
scale_x_continuous(limits=c(0,NA))+
scale_y_discrete(expand=expansion(mult=c(.05,0.01)))+
scale_color_manual(values=c("T"="dodgerblue", "F"="black"))+
theme(
  axis.text.y=element_markdown(),
  axis.title.x=element_text(hjust=1),
  legend.position="none",
  plot.caption.position="plot"
)

Cumulative number of statements over time

In the next “detour” - remember this post is actually meant to be about excessive speech lengths… - I look into the development of the cumulative number of speeches in the course of each legislative period. I was primarily wondering whether the COVID pandemic left any mark on the number of speeches held in the National Council’s plenary session.

As the graph below shows, the number of cumulative speeches did not stagnate or slow down during the pandemic’s height (which I take here, for good or for worse, as the period between the start of the first and the end of the last lockdown). On the contrary, if we compare its dynamic with those of other legislative periods during the same stage (days since start of the legislative session), we can see that the legislative period XXVII features a rather steep increase. Admittedly, each legislative period may have its distinct events and hence the assumption that the trajectories of the cumulative speech numbers have to run in parallel has its limits. But it’s nevertheless noteworthy, and you may say a good sign, that debates in the legislature did not abate during the pandemic.

Cumulative number of speeches; highlight pandemic
vec_covid_start <- lubridate::ymd("2020-03-16")
vec_covid_end <- lubridate::ymd("2021-12-11")

df_session_speeches_n <- df_session_speeches %>%
  filter(!is.na(speech_speaker)) %>% # only when speech was made
  filter(legis_period_num > 18) %>% # no speech data before legis period 19
  count(legis_period, legis_period_num, session_id, session_url, session_date, name = "speeches_n") %>%
  mutate(session_date = ymd(session_date)) %>%
  mutate(covid_period = between(session_date, vec_covid_start, vec_covid_end))

# nrow(df_session_speeches_n)
# df_27 <- df_session_speeches %>%
#   filter(!is.na(speech_speaker)) %>% # only when speech was made
#   filter(legis_period_num == 27) %>%
#   distinct(session_url_input) %>%
#   count()


# add start of legis period
df_session_speeches_n <- df_session_speeches_n %>%
  left_join(., tbl_legis_periods %>% select(-legis_duration, -date_end)) %>%
  mutate(days_since_start = difftime(session_date, date_start, unit = "days") %>% as.numeric()) %>%
  group_by(legis_period) %>%
  arrange(session_date, .by_group = T) %>%
  mutate(speeches_n_cum = cumsum(speeches_n)) %>%
  ungroup() %>%
  # mutate(legis_27=ifelse(legis_period_num==27, "XXVII", "others"))
  mutate(legis_27_covid = case_when(
    legis_period_num != 27 ~ "others",
    covid_period == T ~ "covid",
    covid_period == F ~ "not_covid",
    .default = NA
  ))
# nrow(df_session_speeches_n)
Create plot
txt_explanatory <- glue::glue("The graph depicts the cumulative number of plenary speeches in the Austrian National Council per legislative period. As of {lubridate::today() %>% format(., '%d.%m.%Y')} there were {df_session_speeches_n %>% filter(legis_period_num==27) %>% arrange(speeches_n_cum) %>% pull(speeches_n_cum) %>% last() %>% scales::number(.,big.mark=',', decimal.mark='.')} speeches held during the legislative period XXVII. Only during period XXIV more speeches were made. Remarkably, during the height of the pandemic - between the start of the first and the end of the last lockdown - there was a considerable increase in the number of speeches, compared with other legislative periods during the same stage of their duration. There is no comprehensive data available on the number of individual speeches prior to legislative period XIX.")

df_legis_27_covid_start <- df_session_speeches_n %>%
  filter(legis_period_num == 27) %>%
  filter(covid_period == T) %>%
  slice_min(., n = 1, order_by = days_since_start)

df_legis_covid_start <- df_session_speeches_n %>%
  semi_join(., df_legis_27_covid_start, by = join_by(days_since_start <= days_since_start)) %>%
  group_by(legis_period) %>%
  arrange(days_since_start, session_id, .by_group = T) %>%
  ungroup() %>%
  slice_tail(., n = 1, by = legis_period) %>%
  arrange(desc(speeches_n_cum)) %>%
  mutate(index = row_number())

df_legis_27_covid_end <- df_session_speeches_n %>%
  filter(legis_period_num == 27) %>%
  filter(covid_period == T) %>%
  slice_max(., n = 1, order_by = days_since_start)

df_legis_covid_end <- df_session_speeches_n %>%
  semi_join(., df_legis_27_covid_end, by = join_by(days_since_start <= days_since_start)) %>%
  group_by(legis_period) %>%
  arrange(days_since_start, session_id, .by_group = T) %>%
  ungroup() %>%
  slice_tail(., n = 1, by = legis_period) %>%
  arrange(desc(speeches_n_cum)) %>%
  mutate(index = row_number())

vec_legis_XXVII_pos_start <- df_legis_covid_start %>%
  filter(legis_period_num == 27) %>%
  pull(index)

vec_legis_XXVII_pos_end <- df_legis_covid_end %>%
  filter(legis_period_num == 27) %>%
  pull(index)

df_speeches_covid <- df_legis_covid_start %>%
  dplyr::bind_rows(., df_legis_covid_end) %>%
  arrange(session_date) %>%
  group_by(legis_period_num) %>%
  summarise(
    speeches_n = diff(speeches_n_cum),
    duration = diff(session_date) %>% as.numeric()
  ) %>%
  mutate(
    speeches_avg = speeches_n / duration
  )

txt_details <- glue::glue("In the period between the start of the first and the last lockdown {df_speeches_covid %>% filter(legis_period_num==27) %>%pull(speeches_n) %>% scales::number(., big.mark=',', decimal.mark='.')} speeches were held. No other legislative period - for which data is available - features a higher number during the same stage (days since start of period).")

col_covid <- "#b3001b"

vec_breaks <- df_session_speeches_n %>%
  filter(legis_27_covid == "covid") %>%
  slice(1, n()) %>%
  pull(days_since_start) %>%
  c(., seq(0, 1500, 500))


pl_cumsum <- df_session_speeches_n %>%
  filter(legis_period_num > 18) %>%
  ggplot() +
  labs(
    title = "Cumulative Number of Speeches from legislative period XIX to XXVII",
    # subtitle="Plenary sessions in the National Council only."
    subtitle = txt_explanatory,
    caption = txt_caption_graph
  ) +
  geom_step(
    data = . %>% filter(legis_period_num != 27),
    aes(
      x = days_since_start,
      y = speeches_n_cum,
      group = legis_period,
      # linewidth=legis_period,
      # color=legis_27
    ),
    color = "grey"
  ) +
  geom_step(
    data = . %>% filter(legis_period_num == 27),
    aes(
      x = days_since_start,
      y = speeches_n_cum,
      group = legis_period,
      color = legis_27_covid
    ),
    linewidth = 1.2
  ) +
  geom_text(
    data = . %>% filter(covid_period == T) %>%
      slice_min(., n = 1, order_by = days_since_start),
    aes(
      x = days_since_start,
      y = 7500
    ),
    label = glue::glue("Start of first lockdown."),
    family = "Roboto condensed",
    color = col_covid,
    hjust = 0,
    size = 3
  ) +
  geom_segment(
    data = . %>% filter(covid_period == T) %>%
      slice_min(., n = 1, order_by = days_since_start),
    aes(
      x = days_since_start,
      y = speeches_n_cum,
      yend = 7000
    ),
    color = col_covid
  ) +
  geom_text(
    data = . %>% filter(covid_period == T) %>%
      slice_max(., n = 1, order_by = days_since_start),
    aes(
      x = days_since_start,
      y = 12500
    ),
    label = glue::glue("End of last lockdown."),
    family = "Roboto condensed",
    color = col_covid,
    hjust = 0,
    size = 3
  ) +
  geom_segment(
    data = . %>% filter(covid_period == T) %>%
      slice_max(., n = 1, order_by = days_since_start),
    aes(
      x = days_since_start,
      y = speeches_n_cum,
      yend = 12000
    ),
    color = col_covid
  ) +
  geom_textbox(
    data = . %>% filter(covid_period == T) %>%
      slice_max(., n = 1, order_by = days_since_start),
    aes(
      x = days_since_start + 250,
      y = speeches_n_cum
    ),
    family = "Roboto condensed",
    label = txt_details,
    color = "grey10",
    hjust = 0,
    vjust = 1,
    size = 3,
    # linewidth=0,
    box.padding = unit(0.1, "cm"),
    box.color = "white"
  ) +
  geom_text(
    data = . %>% group_by(legis_period) %>% slice_max(., n = 1, order_by = speeches_n_cum) %>% ungroup() %>% slice_max(., n = 2, order_by = speeches_n_cum),
    aes(
      label = legis_period,
      x = days_since_start + 10,
      y = speeches_n_cum
    ),
    color = "grey10",
    hjust = 0,
    size = 3
  ) +
  scale_y_continuous(
    label = \(x) scales::number(x, big.mark = ",", decimal.mark = ".") %>%
      fn_label_unit(x = ., "speeches") %>%
      str_wrap(., 10),
    expand = expansion(mult = c(0.01, 0.02))
  ) +
  scale_x_continuous(
    label = \(x) scales::number(x, big.mark = ",", decimal.mark = ".") %>%
      fn_label_unit(x = ., "days since start of legislative period") %>%
      str_wrap(., 20),
    expand = expansion(mult = c(0, 0.05)),
    breaks = vec_breaks
  ) +
  scale_color_manual(values = c("covid" = col_covid, "not_covid" = "grey10", "others" = "darkgrey")) +
  theme(
    legend.position = "none",
    panel.grid.minor.x = element_blank(),
    panel.grid.minor.y = element_blank(),
    axis.text.x = element_text(hjust = 0),
    plot.subtitle = element_textbox_simple(size = rel(.9))
  )

pl_cumsum

2.3 Data on MPs

2.3.1 Only speeches by MPs

So far we have considered all speeches in the plenary. For the next steps, I want to keep only speeches held by MPs (and not by e.g. members of the government or Ombudspersons). To differentiate between them, I need a dataset of all MPs (and the period of their mandate(s)) of the XXVII legislative period. This dataframe is used to drop other speakers during plenary sessions.1

1 For a (not entirely serious) analysis of government speeches see here.

To get such a dataset, I first, obtain all individuals who were MPs during the legislative period, and second, augment this dataset with a the start and end date of their time in parliament. By matching the dataset on MPs and speeches by the personal identifier (pad_intern) as well as the speech’s date with the start and end date of an MP’s mandate, we can identify which speeches were (not) by an MP. With this approach, we also include speeches of an individual who was at one point an MP, but exclude the speeches of the same individual if he/she later became e.g. a member of government.

Furthermore, to account for changing family names, I include a unified name column so that we can group together speeches by the same MP who gave speeches with different family names.

Look at the code chunks below, if you’re interested in the details.

Get all MPs of the XXVII legislative period
resp_mps <- request("https://www.parlament.gv.at/Filter/api/json/post") |>
  req_url_query(
    jsMode = "EVAL",
    FBEZ = "WFW_004",
    listeId = "undefined",
    showAll = "True",
    # pageNumber = "1",
    # pagesize = "10",
    ascDesc = "ASC",
  ) |>
  req_headers(
    authority = "www.parlament.gv.at",
    accept = "*/*",
    `accept-language` = "en-AT,en;q=0.9,de-AT;q=0.8,de;q=0.7,en-GB;q=0.6,en-US;q=0.5",
    `content-type` = "application/json",
    cookie = "JSESSIONID=OXVn_Rnh2uhhLyeuWdwxcO9xTTr5YsHbH0xRa3-S.appsrv04e; JSESSIONID=6SuuP4uN67Tzfy5YSSTebU_drcVJsXaonUCi2Ip2.appsrv04e; pddsgvo=j; _pk_id.1.26ca=7fce6f38a899aedc.1706609353.; _pk_ref.1.26ca=%5B%22%22%2C%22%22%2C1707683917%2C%22https%3A%2F%2Fwww.google.com%2F%22%5D; _pk_ses.1.26ca=1",
    dnt = "1",
    origin = "https://www.parlament.gv.at",
    `user-agent` = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36",
  ) |>
  req_body_raw('{"R_WF":["FR"],"M":["M"],"W":["W"],"GP":["XXVII"]}', "application/json") |>
  req_perform()

# Dealing with response
vec_headings <- resp_mps %>%
  resp_body_json(., simplifyVector = T) %>%
  pluck(., "header", "label") %>%
  janitor::make_clean_names()

# extract the actual substantive data
df_mps <- resp_mps %>%
  resp_body_json(., simplifyVector = T) %>%
  pluck(., "rows") %>%
  as.data.frame()

colnames(df_mps) <- vec_headings
nrow(df_mps)
## [1] 217

df_mps <- df_mps %>%
  select(-gesetzgebungsperioden, -bundesland) %>%
  mutate(pad_intern = as.numeric(pad_intern))
Get start/end dates of MPs time(s) in office
fn_get_mandates_mp <- function(pad_intern) {
  # pad_intern <- "15526"

  url <- glue::glue("https://www.parlament.gv.at/person/{pad_intern}?json=TRUE")
  txt <- jsonlite::fromJSON(url)
  listviewer::jsonedit(txt)

  # keep only NR mandates; start-end dates;
  mandates <- txt$content$biografie$mandatefunktionen$mandate %>%
    select(
      gremium, mandat, mandatVon, mandatBis, klub, aktiv
    ) %>%
    filter(
      str_detect(mandat, regex("Abgeordneter? zum Nationalrat"))
    )

  return(mandates)
}

df_mps <- df_mps %>%
  mutate(
    mandate = map(pad_intern, \(x) fn_get_mandates_mp(pad_intern = x), .progress = T)
  ) %>%
  mutate(pad_intern = as.numeric(pad_intern))

# only pad_intern
df_mps_mandates <- df_mps %>%
  unnest_longer(mandate) %>%
  unnest_wider(mandate, names_sep = "_") %>%
  mutate(across(matches("mandatVon|mandatBis"), \(x) lubridate::dmy(x))) %>%
  mutate(mandate_mandatBis = case_when(
    (is.na(mandate_mandatBis) & mandate_aktiv == TRUE) ~ Sys.Date(),
    .default = mandate_mandatBis
  )) %>%
  mutate(pad_intern = as.numeric(pad_intern)) %>%
  select(-matches("name")) %>%
  distinct()
Keep only speeches of XXVII MPs
df_session_speeches_27_mps <- df_session_speeches %>%
  filter(legis_period_num == 27) %>%
  filter(!is.na(speech_speaker)) %>%
  mutate(pad_intern = as.numeric(pad_intern)) %>%
  mutate(session_date = lubridate::ymd(session_date)) %>%
  semi_join(., df_mps_mandates, by = join_by("pad_intern", between(session_date, mandate_mandatVon, mandate_mandatBis)))

# expand party names
df_session_speeches_27_mps <- df_session_speeches_27_mps %>%
  mutate(party = str_extract(speech_speaker, regex("(?<=\\().?(?=\\))"))) %>%
  mutate(party = case_when(
    party == "V" ~ "ÖVP",
    party == "S" ~ "SPÖ",
    party == "G" ~ "Grünen",
    party == "N" ~ "Neos",
    party == "F" ~ "FPÖ",
    party == "A" ~ "none",
    .default = NA
  ))
Add unified name to account for name changes during legislative period
df_mps_name <- df_mps %>%
  distinct(pad_intern) %>%
  mutate(mp_name = map_chr(pad_intern, \(x) fn_get_name(x), .progress = T))

row_check <- nrow(df_session_speeches_27_mps)
df_session_speeches_27_mps <- df_session_speeches_27_mps %>%
  left_join(., df_mps_name)
nrow(df_session_speeches_27_mps) == row_check
## [1] TRUE

3 Analysis

Now, after this lengthy but hopefully worthwhile detour, let’s finally take up the data on speeches during the current XXVII legislative period.

3.1 Number of speeches by party

To start with, let’s see how many speeches the MPs of each party gave as of 09.07.2024.

Number of speeches per Party
tbl_speeches_party <- df_session_speeches_27_mps %>%
  count(party, name = "speeches_n") %>%
  mutate(speeches_n_rel = speeches_n / sum(speeches_n)) %>%
  arrange(desc(speeches_n)) %>%
  mutate(abs_rel = glue::glue('{speeches_n %>% scales::number(big.mark=",", decimal.mark=".")}<br>*({speeches_n_rel %>% scales::percent(.)})*')) 

tbl_speeches_party %>%
mutate(party=fct_infreq(party, w=speeches_n) %>% fct_rev) %>%
ggplot()+
labs(
  title="Number of speeches in the National Council by party",
  subtitle=glue::glue("XXVII legislative period as of {lubridate::today() %>% format(., '%d.%m.%Y')}. Speeches by MPs in plenary sessions only."),
  caption=txt_caption_graph
)+
geom_bar(
  aes(y=party, x=speeches_n, fill=party),
  stat="identity",
  orientation="y",
  width=.5
)+
geom_richtext(
  aes(y=party, x=speeches_n, label=abs_rel),
  hjust=0,
  family="Roboto Condensed",
  label.colour="transparent",
  size=3
)+
scale_fill_manual(values=vec_party_colors)+
scale_x_continuous(label=\(x) x %>% scales::number(., big.mark=',', decimal.mark='.'), expand=expansion(mult=c(0,.1)))+
scale_y_discrete(expand=expansion(mult=c(0.05,0.05)))+
theme(
  legend.position="none",
  axis.text.x=element_text(hjust=0),
  panel.grid.major.y=element_blank(),
  panel.grid.minor.x=element_blank()
)

3.2 Speeches’ time limits

3.2.1 Types of limits & frequency

Now, let’s get an overview of speeches’ time limits.

I am grateful to the Infoteam of the Austrian Parliament for having replied very swiftly to my pertaining question and clarified this issue for me. As always, if I got something wrong here, the onus is on me.

Generally, at least that’s how I got it, speeches’ time limits are subject to a rather detailed and somewhat complex set of regulations. As we can take it from the parliament’s website, there are different formal time limits applicable depending on the type of debate during which a speech is held as well as on who is speaking (e.g. speeches by members of government are generally not subject to a time limit; MPs tabling a request can speak longer than others etc). Furthermore, there are different types of statements which again may be subject to different limits.

Importantly for the present focus, some limits which are formally stipulated in the National Council’s rules of procedure can be reduced to ‘voluntary’ limits, which are agreed upon by the MPs. For details see e.g. here and here.

The graph below tries to give an empirical overview of the relation between, on the one hand, formal time limits governing the maximum speech length in different debate types, and on the other hand, voluntary time limits. As it becomes clear, the practice of reduced, voluntary time limits is rather common.

Note that there were 19 speeches where data on the debate-typical time limit was missing. All of these speeches were held during the first session, when the presidents of the chamber were elected. See here. I am not sure whether this is simply due to an omission, or whether the ‘Normaldebatte’ during the election of the presidents follows a different set of rules.

Prevalence of voluntary speech limits
# df_session_speeches_27_mps %>%
# count(typetext, limit)

# df_session_speeches_27_mps %>%
# count(typetext, limit, speech_limit_type, speech_limit)

col_limit <- "red"

txt_subtitle <- glue::glue("Each graph shows the number of speeches per type of debate (y-axis) and the pertaining time limit in minutes (x-axis). Each type of debate has a <span style='color:{col_limit};'>formally mandated, debate-specific maximum length</span> for a plenary speech. For some debate types, this maximum length, however, can be shortened by a <span style='color:orange'>'voluntary' limit</span> agreed upon among the MPs. Overall, the graph reveals that most speeches held during the debates of type 'Dringliche Anfrage', 'Dringlicher Antrag', 'Erste Lesung', and 'Normaldebatte' are subject to shorter, voluntary time limits. There are also some instances of speeches where the actual applied time limit exceeds the limit formally mandated. These speeches pertain to specific speakers who are entitled to speak longer (e.g. MP tabling a request). There are also some speeches which are subject to a mandatory time limit shorter than the debate-specific time limit (blue, left of the dotted red line). These speeches are of a specific type. Note different scales on the y-axis.")

df_session_speeches_27_mps %>%
  mutate(speech_limit = as.numeric(speech_limit)) %>%
  group_by(typetext, limit) %>%
  mutate(speeches_n = n()) %>%
  ungroup() %>%
  mutate(label_strip = glue::glue("**<span style='font-size:10pt; color:black;'>{typetext}</span>**<br>Total number of speeches: {speeches_n}<br><span style='color:{col_limit};'>Debate type typical, mandatory limit: {limit} min</span>")) %>%
  mutate(label_strip = case_when(
    is.na(limit) ~ glue::glue("**<span style='font-size:10pt;color:black;'>{typetext}</span>**<br>{speeches_n %>% scales::number(., big.mark=',', decimal.mark='.')}; <span style='color:{col_limit}'>No data"),
    typetext != "Aktuelle Europastunde" ~ glue::glue("**<span style='font-size:10pt;color:black;'>{typetext}</span>**<br>{speeches_n %>% scales::number(., big.mark=',', decimal.mark='.')}; <span style='color:{col_limit}'>{limit} min"),
    .default = label_strip
  )) %>%
  ggplot() +
  labs(
    title = "Number of speeches per debate type and applicable time limit",
    subtitle = txt_subtitle,
    caption = txt_caption_graph,
    y = "Number of speeches",
    x = "time limit applied (min)"
  ) +
  geom_bar(
    aes(
      x = speech_limit,
      fill = speech_limit_type
    ),
    stat = "count",
    key_glyph = "dotplot"
  ) +
  geom_vline(
    aes(
      xintercept = limit,
      linetype = "dummy"
    ),
    color = col_limit,
    linewidth=1.3
  ) +
  scale_x_continuous(expand = expansion(mult = c(0, .1)),
  labels=\(x) fn_label_unit(x, label="min")
  ) +
  scale_y_continuous(label = \(x) scales::number(x, accuracy = 1, big.mark = ",", decimal.mark = "."), expand = expansion(mult = c(0, .1))) +
  scale_fill_manual(values = c("voluntary" = "orange", "mandatory" = "dodgerblue4"), name = "Type of speech's time limit") +
  scale_linetype_manual(
    values = c("dummy" = "dashed"), 
    labels = c("dummy" = ""),
    name = "Debate-type specific limit:"
  ) +
  facet_wrap(
    vars(label_strip),
    ncol = 3,
    axes = "all",
    scale = "free_y"
  ) +
  theme(
    plot.margin = ggplot2::margin(l = 0, 0, 0, 0, unit = "cm"),
    axis.title.x = element_text(hjust = 0, size = 11.5 * .6, color = "grey30"),
    axis.title.y = element_text(hjust = 1, size = 11.5 * .6, angle = 90, color = "grey30"),
    axis.text.x = element_text(size = rel(.6)),
    axis.text.y = element_text(size = rel(.6)),
    strip.text = element_textbox_simple(
      size = rel(.6),
      vjust = 1,
      color = "grey30"
    ),
    plot.subtitle = element_textbox_simple(
      size = rel(.8),
      margin = ggplot2::margin(0, 0, 0, 0)
    ),
    plot.caption = element_textbox_simple(margin = ggplot2::margin(t = 0.5, unit = "cm")),
    panel.grid.minor.y = element_blank(),
    panel.grid.minor.x = element_blank(),
    legend.title = element_text(color = "grey30", face = "italic", size = rel(0.7)),
    legend.title.position = "left",
    legend.location = "plot",
    legend.position = "top",
    legend.key.height = unit(0.2, "cm"),
    legend.margin = ggplot2::margin(b = 0, l = 0, t = .3, unit = "cm"),
    legend.text = element_text(hjust = 1, color = "grey30", face = "italic", size = rel(.6), margin=ggplot2::margin(l=0, unit="cm")),
    legend.box = "vertical",
    legend.box.just = "left",
    legend.direction = "horizontal",
    legend.spacing.y = unit(0, "cm"),
    panel.spacing.x = unit(0.1, "cm"),
    panel.spacing.y = unit(0.3, "cm")
  )

As the graph above also shows, there are a few instances where the applicable time limit for a speech is longer than the one formally stipulated for the pertaining type of debate. All these speeches have a mandatory length limit. See the bars right (!) to the vertical red line. As it turns out, all these speeches are of the type “Wortmeldung einer Unterzeichnerin bzw. eines Unterzeichners einer Aktuellen Stunde” or “Begründung eines Dringlichen Antrags in einer Plenarsitzung”. AFAIK, this means that a certain type of speech or speaker can speak longer than others, even when it’s during the same type of debate. From the name of these speech types, I take it that those who are initiating a specific type of debate, e.g. by tabling a request, are allowed to speak longer than those MPs speaking after them.

Check speeches where applicable speech limit is longer than formal limit of the pertaining debate type
df_longer_limit <- df_session_speeches_27_mps %>%
  filter(limit < speech_limit) %>%
  select(
    session_url, typetext, limit, haslimit, speech_limit, wortmeldungsart
  ) %>%
  left_join(., df_lookup_wortmeldung, by = "wortmeldungsart")

df_longer_limit %>%
  count(wortmeldungsart_long)
# A tibble: 2 × 2
  wortmeldungsart_long                                                         n
  <chr>                                                                    <int>
1 Begründung eines Dringlichen Antrags in einer Plenarsitzung                 54
2 Wortmeldung einer Unterzeichnerin bzw. eines Unterzeichners einer Aktue…   111

3.3 Overtime & Limit type

After this somewhat technical part, let’s now look at the actual length of individual speeches, and calculate the overtime (the time by which a speech exceeded its applicable time limit). Conveniently, the data provided by parliament indicates explicitly the relevant time limit for each individual speech. Hence, it’s straightforward to calculate the respective overtime.

Calculate speeches’ total duration and overtime time.
df_session_speeches_27_mps <- df_session_speeches_27_mps %>%
  mutate(dauer_sec = lubridate::ms(dauer) %>% as.numeric(., "seconds"), .after = "dauer") %>%
  mutate(speech_limit = ifelse(speech_limit == 0, NA, speech_limit)) %>% # only relevant if gov members included
  mutate(speech_limit=as.numeric(speech_limit)) %>%
  mutate(speech_overtime = dauer_sec - (as.numeric(speech_limit) * 60), .after = speech_limit) %>%
  mutate(speech_overtime_rel = speech_overtime / (as.numeric(speech_limit) * 60)) %>%
  mutate(speech_overtime_rel_cat = cut(speech_overtime_rel, breaks = c(-1, seq(-0.975, 0.975, by = 0.05), 3))) %>%
  mutate(speech_overtime_yn=ifelse(speech_overtime>0, T, F))
Removing missing observations
n_missing <- df_session_speeches_27_mps %>%
  filter(is.na(speech_overtime)) %>%
  nrow()
n_missing
## [1] 21

# df_session_speeches_27_mps <- df_session_speeches_27_mps %>%
#   filter(!is.na(speech_overtime))

Note that there were 21 speeches where no speech duration or applicable time limits was available.

In a first step, let’s check how speech limits are respected, depending on the type of speech limit.

Overtime and speech limit types
df_pl_limit_overtime <- df_session_speeches_27_mps %>%
count(speech_limit_type, speech_overtime_yn) %>%
group_by(speech_limit_type) %>%
mutate(speech_limit_type_N=sum(n)) %>%
mutate(n_rel=n/speech_limit_type_N) %>%
mutate(speech_overtime_yn=factor(speech_overtime_yn, levels=c("TRUE", "FALSE")))  %>%
# mutate(speech_overtime_ny=fct_na_value_to_level(speech_overtime_yn, NA)) %>%
ungroup() %>%
mutate(speech_limit_type_N_share=speech_limit_type_N/sum(n)) %>%
mutate(x_label=glue::glue("speeches with a **{speech_limit_type}** time limit<br>(total: {speech_limit_type_N %>% scales::number(., big.mark=',', decimal.mark='.')}, share: {speech_limit_type_N_share %>% scales::percent(., accuracy=.1)})"))

vec_n_na <- df_session_speeches_27_mps %>%
filter(is.na(speech_overtime_yn)) %>%
nrow() 

txt_subtitle <- glue::glue("As of {lubridate::today() %>% format(., '%d.%m.%Y')}, there were {nrow(df_session_speeches_27_mps) %>% scales::number(., big.mark=',', decimal.mark='.')} speeches held in total by MPs in the National Council's plenary. Out of these, {df_pl_limit_overtime %>% filter(speech_limit_type=='voluntary') %>% pull(speech_limit_type_N) %>% unique() %>% scales::number(., big.mark=',', decimal.mark='.')} speeches were subject to a 'voluntary' time limit, agreed upon among the MPs; {df_pl_limit_overtime %>% filter(speech_limit_type=='voluntary') %>% filter(speech_overtime_yn==T) %>% pull(n_rel) %>% scales::percent(., accuracy=.1)} exceeded their limit. In contrast, out of the {df_pl_limit_overtime %>% distinct(speech_limit_type, speech_limit_type_N) %>% filter(speech_limit_type=='mandatory') %>% pull(speech_limit_type_N) %>% scales::number(., big.mark=',', decimal.mark='.')} speeches with a 'mandatory' time limit, only {df_pl_limit_overtime %>% filter(speech_limit_type=='mandatory')  %>% filter(speech_overtime_yn==TRUE) %>% pull(n_rel) %>% scales::percent(., accuracy=0.1)} went beyond their  limit. {vec_n_na} speeches with missing data.")

df_pl_limit_overtime %>%
ggplot()+
labs(
  title="Number of speeches by limit type and overtime",
  subtitle=txt_subtitle,
  caption=txt_caption_graph,
  x="Type of time limit")+
geom_bar(aes(x=speech_overtime_yn, y=n, fill=speech_overtime_yn), stat="identity", position=position_dodge(), na.rm=F)+
scale_fill_manual(values=c("TRUE"="#7D98A1", "FALSE"=col_bar))+
scale_x_discrete(
  position="top",
  labels=c("TRUE"="overtime", "FALSE"="no overtime"),
  na.translate=FALSE
)+
geom_text(
  aes(x=speech_overtime_yn,
  y=n+250,
  label=glue::glue("{n %>% scales::number(., big.mark=',', decimal.mark='.')} ({n_rel %>% scales::percent(., accuracy=0.1)})")),
  family="Roboto Condensed",
  size=11*.8,
  size.unit='pt',
  color="grey30"
)+
scale_y_continuous(labels=\(x) scales::number(x, big.mark=',', decimal.mark='.') %>% fn_label_unit(., "speeches"), expand=expansion(mult=c(0, .1)))+
theme(
  axis.text.x=element_markdown(),
  panel.grid.major.x=element_blank(),
  legend.position="none",
  strip.text=element_markdown(
    family="Roboto Condensed",
    size=11.5,
    color="grey30",
    hjust=0.5
    ),
  strip.placement="outside"
)+
facet_wrap(vars(x_label))

3.4 Overtime & Speeches

Let’s now look at individual speeches and their respective overtime.

3.4.1 Top 5 speeches with the largest overtime

The graph below shows the five speeches by MPs with the longest overtime (during the XXVII legislative period, as of the time of writing 09.07.2024). Note that all of the highlighted overtimes pertain to speeches exceeding ‘voluntary’ time limits.

Out of the five speeches, three were given by speakers of the FPÖ, including two by the party’s leader Herbert Kickl. The longest overtime pertains to a passionate speech by ÖVP’s Martin Engelberg - an MP with Jewish family ties - in the aftermath of the October 7 Hamas terror attacks.

Top 5 overtimes
txt_subtitle <- glue::glue("**Three of the five speeches with the longest overtime were held by FPÖ MPs, including two by its leader Herbert Kickl.**
Note that all overtimes pertain to 'voluntary' time limits. Data covers all speeches held by MPs in the plenary of the National Council during the XXVII legislative period as of {lubridate::today() %>%  format(., '%d.%m.%Y')}.")

df_pl <- df_session_speeches_27_mps %>%
  slice_max(., n = 5, order_by = speech_overtime) %>%
  select(session_id, session_date, party, mp_name, dauer_sec, speech_limit, speech_limit_type, speech_overtime, text) %>%
  mutate(dauer_ok = dauer_sec - speech_overtime) %>%
  arrange(desc(speech_overtime)) %>%
  mutate(index = row_number()) %>%
  pivot_longer(
    cols = c(dauer_ok, speech_overtime),
    names_to = "speech_phase",
    values_to = "duration"
  ) %>%
  mutate(speech_phase = fct_rev(speech_phase)) %>%
  # arrange(desc(duration))
  # mutate(index=cur_group_id())
  mutate(duration = duration / 60) %>%
  mutate(mp_name_label = str_remove(mp_name, regex("\\(.*\\)"))) %>%
  mutate(mp_label = glue::glue("{index}\\. <b>{mp_name_label}</b> ({party}), {session_date %>% format(., '%d.%m. %Y')}<br>{text %>% str_trunc(., width=80, side='right')}")) %>%
  mutate(mp_label = fct(mp_label) %>% fct_rev()) %>%
  group_by(session_id, mp_name) %>%
  mutate(duration_rel = duration / sum(duration)) %>%
  mutate(duration_mid = duration / 2 + lag(duration), .after = duration) %>%
  ungroup() %>%
  mutate(duration_label = glue::glue("{duration %>% scales::number(accuracy=.1)} min ({duration_rel %>% scales::percent(accuracy=.1)})"))

df_pl %>%
  ggplot() +
  labs(
    title = "Five plenary speeches with the longest <span style='color:#AD2341'>overtime</span>",
    subtitle = txt_subtitle, ,
    caption = txt_caption_graph,
    # title="Five Plenary speeches with the longest <span style='color:darkgrey'>overtime</span> ",
    x = "Total length of speech (min)"
  ) +
  geom_bar(
    aes(
      x = duration,
      y = mp_label,
      fill = speech_phase
    ),
    stat = "identity",
    width = 0.3
  ) +
  geom_segment(
    data = . %>% filter(speech_phase == "dauer_ok"),
    aes(
      x = duration,
      y = as.numeric(mp_label) - 0.15,
      yend = as.numeric(mp_label) + 0.15,
      group = mp_label,
      color = speech_limit_type
    ),
    # color="orange",
    linewidth = 1
  ) +
  geom_richtext(
    aes(
      x = 0,
      y = mp_label,
      label = mp_label
    ),
    nudge_y = 0.15,
    hjust = 0,
    vjust = 0,
    size = 3,
    lineheight = .9,
    fill = "transparent",
    label.colour = "transparent",
    color = "grey30",
    label.padding = unit(0, "lines")
  ) +
  geom_text(
    data = . %>% filter(speech_phase == "speech_overtime"),
    aes(
      x = duration_mid,
      y = mp_label,
      label = duration_label,
      group = speech_phase
    ),
    position = position_stack(),
    size = 3,
    color = "white"
  ) +
  geom_text(
    data = . %>% filter(index != 1),
    aes(
      x = (dauer_sec / 60),
      y = mp_label,
      label = (dauer_sec / 60) %>% scales::number(., accuracy = .1, suffix = " min")
    ),
    size = 3,
    hjust = 0,
    nudge_x = .1,
    color = "grey30"
  ) +
  geom_text(
    data = . %>% filter(index == 1),
    aes(
      x = (dauer_sec / 60),
      y = mp_label,
      label = (dauer_sec / 60) %>% scales::number(., accuracy = .1, suffix = " min\ntotal duration")
    ),
    lineheight = .7,
    size = 3,
    hjust = 0,
    nudge_x = .1,
    color = "grey30"
  ) +
  scale_color_manual(
    values = c("voluntary" = "orange"),
    labels = c("voluntary" = "voluntary time limit")
  ) +
  scale_fill_manual(
    values = c("speech_overtime" = "#AD2341", "dauer_ok" = col_bar)
  ) +
  scale_x_continuous(
    label = \(x) fn_label_unit(x, "minutes total speech duration"),
    expand = expansion(mult = c(0, 0.15))
  ) +
  scale_y_discrete(
    expand = expansion(mult = c(0.01, .2))
  ) +
  guides(
    fill = "none",
    color = guide_legend(keywidth = unit(0.25, "cm"))
  ) +
  theme(
    axis.text.y = element_blank(),
    panel.grid.major.y = element_blank(),
    panel.grid.minor.x = element_blank(),
    # axis.text.x=element_text(hjust=0),
    axis.text.x = element_blank(),
    axis.title = element_blank(),
    plot.subtitle = element_textbox_simple(size = rel(.8), margin = ggplot2::margin(b = 0, unit = "cm")),
    # legend.position="none",
    legend.text = element_textbox_simple(size = rel(.8), color = "grey30", face = "italic", margin = ggplot2::margin(b = 0, l = 0.1, unit = "cm")),
    legend.margin = ggplot2::margin(b = 0, unit = "cm"),
    legend.box.margin = ggplot2::margin(b = 0, unit = "cm"),
    legend.box.spacing = unit(0, "cm"),
    panel.grid.major = element_blank(),
    panel.grid.minor = element_blank()
  )

3.4.2 Table: all speeches

To get a more comprehensive view, the table below provides details on all 14,591 speeches held so far (hence the page may take a bit longer to load). You can search by party, speaker etc. and get details on the speeches’ total duration and the time by which the pertaining limits were exceeded or not fully made use of. If you sort the table by time difference, you’ll see that out of the ten speeches with the largest overtime, six are speeches held by FPÖ MPs.

Table speeches
df_table_speeches <- df_session_speeches_27_mps %>%
  # slice_head(., n=50) %>%
  mutate(session_url_link = glue::glue("https://www.parlament.gv.at/{session_url}?selectedStage=110")) %>%
  # mutate(dauer_sec=lubridate::ms(dauer) %>% as.numeric())  %>%
  mutate(dauer_min = dauer_sec / 60) %>%
  mutate(speech_overtime_min = speech_overtime / 60) %>%
  mutate(party_logo = case_when(
    party == "FPÖ" ~ "https://upload.wikimedia.org/wikipedia/commons/thumb/f/fc/Logo_of_Freedom_Party_of_Austria.svg/320px-Logo_of_Freedom_Party_of_Austria.svg.png",
    party == "Neos" ~ "https://upload.wikimedia.org/wikipedia/commons/thumb/f/f9/NEOS_%E2%80%93_Das_Neue_%C3%96sterreich_logo.svg/320px-NEOS_%E2%80%93_Das_Neue_%C3%96sterreich_logo.svg.png",
    party == "SPÖ" ~ "https://rotbewegt.at/wp-content/uploads/2023/11/SPOe-Logo-Rot500-px.png",
    party == "ÖVP" ~ "https://upload.wikimedia.org/wikipedia/commons/0/0c/Volkspartei_Logo_2022.svg",
    party == "Grünen" ~ "https://upload.wikimedia.org/wikipedia/commons/thumb/b/b0/Logo_Die_Gruenen_2.svg/320px-Logo_Die_Gruenen_2.svg.png",
    party == "none" ~ "https://upload.wikimedia.org/wikipedia/commons/d/d2/Solid_white.png",
    .default = NA
  ), .after = mp_name) %>%
  select(
    session_id,
    session_date,
    session_url_link,
    typetext,
    party_logo,
    mp_name,
    # text,
    # zitation,
    dauer_min,
    speech_limit_type,
    # limit,
    speech_limit,
    speech_overtime_min,
    speech_overtime_rel
  )

df_table_speeches %>%
  # slice_head(.,n=50) %>%
  reactable(.,
    columns = list(
      session_id = colDef(
        name = "Session",
        align = "center",
        width = 60,
      ),
      session_date = colDef(
        name = "Date",
        width = 100,
      ),
      session_url_link = colDef(
        name = "Link",
        cell = fn_reactable_url, filterable = F
      ),
      typetext = colDef(name = "Speech type", filterInput = fn_reactable_filter("selector")),
      mp_name = colDef(
        name = "MP name",
        width = 150
      ),
      dauer_min = colDef(
        name = "duration<br>(min)",
        html=T,
        align = "left",
        filterable = F,
        cell = data_bars(.,
          fill_color = "#558C8C",
          text_color = "black",
          brighten_text_color = "black",
          number_fmt = scales::label_number(
            big.mark = ",",
            decimal.mark = ".",
            accuracy = .1
          ),
          background = "transparent",
          force_outside = c(0, 5)
        )
      ),
      speech_limit = colDef(
        width = 75,
        name = "limit applied",
        align = "center",
        filterable = F
      ),
      speech_overtime_min = colDef(
        name = "time difference",
        align = "center",
        filterable = F,
        cell = data_bars(.,
          fill_color = "#AD2341",
          text_color = "white",
          brighten_text_color = "black",
          number_fmt = scales::label_number(
            big.mark = ",",
            decimal.mark = ".",
            accuracy = .1
          ),
          background = "transparent",
          force_outside = c(0, 5)
        )
      ),
      speech_limit_type = colDef(
        name = "limit type", 
        filterable = T,
        filterInput=fn_reactable_filter("selector")
        ),
      speech_overtime_rel = colDef(
        name = "time difference rel",
        filterable = F,
        align = "center",
        cell = data_bars(.,
          text_color = "black",
          brighten_text_color = "black",
          fill_color = "#EDCB96",
          # max_value=,
          number_fmt = scales::percent,
          background = "transparent",
          force_outside = c(-5, 5)
        )
      ),
      party_logo = colDef(
        filterable = T,
        name = "",
        align = "left",
        width = 50,
        cell = function(value) {
          img_src <- value
          image <- img(src = img_src, style = "height: 12px;", alt = value)
          tagList(
            div(style = "display: inline-block; width: 45px", image)
          )
        }
      )
    ),
    columnGroups = list(colGroup(name = "Speech", columns = c("dauer_min", "speech_limit", "speech_overtime_min", "speech_overtime_rel", "speech_limit_type"))),
    fullWidth = FALSE,
    compact = TRUE,
    defaultSorted = list(speech_overtime_min = "desc"),
    highlight = FALSE,
    elementId = "selector",
    filterable = T,
    defaultPageSize = 10,
    theme = nytimes(font_size = 12)
  ) %>%
    add_source(source = html("<span style='font-size:9pt; font-family:'Segoe UI';'>Data: www.parlament.gv.at. Analysis: Roland Schmidt - https://werk.statt.codes - @zoowalk</span>"), font_color = "#grey30")

Data: www.parlament.gv.at. Analysis: Roland Schmidt - https://werk.statt.codes - @zoowalk