diff --git a/.gitignore b/.gitignore
index fae8299aeacc95d89cb63617ccaa5351800bf891..5cc2df59b7f1e078c4a262222b7765c6a85c236d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,6 +4,7 @@
 
 # Session Data files
 .RData
+.DS_Store
 
 # User-specific files
 .Ruserdata
@@ -37,3 +38,13 @@ vignettes/*.pdf
 
 # R Environment Variables
 .Renviron
+
+# This file
+.gitignore
+
+# Test and sample data
+/testdaten/
+/vorlagen/
+/daten/
+/png/
+/R/Vorbereitung/
diff --git a/R/aktualisiere_karten.R b/R/aktualisiere_karten.R
index 28626b76e347cde275a6d73884451345badcdb84..406c3bbc5804856d22415b61676d8f428c00a35d 100644
--- a/R/aktualisiere_karten.R
+++ b/R/aktualisiere_karten.R
@@ -1,49 +1,443 @@
 #' aktualisiere_karten.R
+#' 
+#' Die Funktionen, um die Grafiken zu aktualisieren - und Hilfsfunktionen
+#' 
 
+#---- Generiere den Fortschrittsbalken ----
 
-aktualisiere_karten <- function(wl_url = stimmbezirke_url) {
-  # Lies Ortsteil-Daten ein und vergleiche
-  neue_orts_df <- lies_gebiet(wl_url) %>% 
-    aggregiere_stadtteile() %>% 
-    mutate(quorum = ifelse(wahlberechtigt == 0,
-                           0,
-                           ja / wahlberechtigt * 100)) %>% 
-    mutate(status = ifelse(meldungen_anz == 0,
-                           "KEINE DATEN",
-                           paste0(ifelse(meldungen_anz < meldungen_max,
-                                          "TREND ",""),
-                                   ifelse(ja < nein,
-                                          "NEIN",
-                                          ifelse(quorum < 30,
-                                                 "JA",
-                                                 "JA QUORUM")))
-    ))
-  alte_orts_df <- hole_letztes_df("daten/ortsteile")
-  # Datenstand identisch? Dann brich ab. 
-  if(vergleiche_stand(alte_orts_df,neue_orts_df)) {
-    return(FALSE) 
+
+generiere_auszählungsbalken <- function(anz = gezaehlt,max_s = stimmbezirke_n,ts = ts) {
+  fortschritt <- floor(anz/max_s*100)
+  annotate_str <- paste0("Ausgezählt sind ",
+                         # Container Fake-Balken
+                         "<span style='height:24px;display: flex;justify-content: space-around;align-items: flex-end; width: 100%;'>",
+                         # Vordere Pufferzelle 70px
+                         "<span style='width:70px; text-align:center;'>",
+                         anz,
+                         "</span>",
+                         # dunkelblauer Balken
+                         "<span style='width:",
+                         fortschritt,
+                         "%; background:#002747; height:16px;'></span>",
+                         # grauer Balken
+                         "<span style='width:",
+                         100-fortschritt,
+                         "%; background:#CCC; height:16px;'></span>",
+                         # Hintere Pufferzelle 5px
+                         "<span style='width:5px;'></span>",
+                         # Ende Fake-Balken
+                         "</span>",
+                         "<br>",
+                         " von ",max_s,
+                         " Stimmbezirken - ",
+                         "<strong>Stand: ",
+                         format.Date(ts, "%d.%m.%y, %H:%M Uhr"),
+                         "</strong>"
+  )
+  
+}
+
+generiere_auszählung_nurtext <- function(anz = gezaehlt,max_s = stimmbezirke_n,ts = ts) {
+  fortschritt <- floor(anz/max_s*100)
+  annotate_str <- paste0("Ausgezählt: ",
+                         anz,
+                         " von ",max_s,
+                         " Stimmbezirken - ",
+                         "<strong>Stand: ",
+                         format.Date(ts, "%d.%m.%y, %H:%M Uhr"),
+                         "</strong>"
+  )
+  
+}
+
+#---- Hilfsfunktionen: Switcher generieren, Farben anpassen ----
+# Funktion gibt Schwarz zurück, wenn Farbe hell ist, und Weiß, wenn sie 
+# relativ dunkel ist. 
+font_colour <- function(colour) {
+  # convert color to hexadecimal format and extract RGB values
+  hex <- substr(colour, 2, 7)
+  r <- strtoi(paste0("0x",substr(hex, 1, 2)))
+  g <- strtoi(paste0("0x",substr(hex, 3, 4)))
+  b <- strtoi(paste0("0x",substr(hex, 5, 6)))
+
+  # suggested by chatGPT: 
+  # calculate the brightness of the color using the formula Y = 0.2126*R + 0.7152*G + 0.0722*B
+  brightness <- 0.2126 * r + 0.7152 * g + 0.0722 * b
+  # compare the brightness to a reference value and return the result
+  return(ifelse(brightness > 128,"#000000","#FFFFFF"))
+}
+
+aufhellen <- function(Farbwert, heller = 128) {
+  #' Funktion gibt einen um 0x404040 aufgehellten Farbwert zurück
+  hex <- substr(Farbwert, 2, 7)
+  r <- strtoi(paste0("0x",substr(hex, 1, 2))) + heller
+  g <- strtoi(paste0("0x",substr(hex, 3, 4))) + heller
+  b <- strtoi(paste0("0x",substr(hex, 5, 6))) + heller
+  if (r > 255) r <- 255
+  if (g > 255) g <- 255
+  if (b > 255) b <- 255
+  return(paste0("#",as.hexmode(r),as.hexmode(g),as.hexmode(b)))
+  
+}
+
+# Generiere den Linktext für den Switcher
+link_text <- function(id,id_colour,text) {
+  lt = paste0("<a target='_self' href='https://datawrapper.dwcdn.net/",
+                id,"' style='background:",id_colour,
+                "; padding:1px 3px; border-radius:3px; color:",font_colour(id_colour),
+                "; cursor:pointer;' rel='nofollow noopener noreferrer'>",
+                text,"</a> ")
+  return(lt)
+}
+
+# Gibt einen String mit HTML/CSS zurück. 
+# Nutzt die Kandidierenden-Datei 
+generiere_switcher <- function(switcher_df,selected = 0) {
+  # Ist der Switcher auf 0 - der Stärkste-Kandidaten-Übersichtskarte?
+  if (selected == 0) {
+    text <- link_text(karte_sieger_id,"#F0F0FF","<strong>Sieger nach Stadtteil</strong>")
   } else {
-    # Zeitstempel holen
-    archiviere(neue_orts_df,"daten/ortsteile")
-    ts <- neue_orts_df %>% pull(zeitstempel) %>% last()
-    # Datentabelle übertragen
-    dw_data_to_chart(neue_orts_df,choropleth_id)
-    dw_data_to_chart(neue_orts_df,symbol_id)
-    dw_data_to_chart(neue_orts_df,tabelle_id)
-    # Anmerkungen aktualisieren
-    wahlberechtigt <- neue_orts_df %>% pull(wahlberechtigt) %>% sum()
-    # Prozentsatz ausgezählte Stimmen: abgerundet auf ganze Prozent
-    ausgezählt <- floor(wahlberechtigt / ffm_waehler *100)
-    annotate_str <- generiere_auszählungsbalken(ausgezählt,
-                                                anz = neue_orts_df %>% pull(meldungen_anz) %>% sum(),
-                                                max = neue_orts_df %>% pull(meldungen_max) %>% sum(),
-                                                ts = ts)
-    dw_edit_chart(symbol_id,annotate=annotate_str)
-    dw_edit_chart(choropleth_id,annotate=annotate_str)
-    dw_edit_chart(tabelle_id,annotate=annotate_str)
-    dw_publish_chart(symbol_id)
-    dw_publish_chart(choropleth_id)
-    dw_publish_chart(tabelle_id)
-    return(TRUE)
+    text <- link_text(karte_sieger_id,"#333333","Sieger nach Stadtteil")
+  }
+  for (i in 1:nrow(switcher_df)) {
+    if (i == selected) {
+      switcher_df$html[i] <- link_text(switcher_df$dw_id[i],
+                                       "#F0F0FF",
+                                       paste0("<strong>",switcher_df$Name[i],"</strong>"))
+    } else {
+      switcher_df$html[i] <- link_text(switcher_df$dw_id[i],
+                                       switcher_df$Farbwert[i],
+                                       switcher_df$Name[i])
+    }
+
+  }
+  return(paste0(text,paste0(switcher_df$html,collapse="")))
+} 
+
+# HTML-Code für die Tooltipp-Mouseovers generieren
+# Für alle Karten: Den jeweiligen Kandidaten in den Titel, 
+# orientieren an top (Anzahl der Top-Kandidaten),
+# Bargraph-Code generieren. 
+#
+# Die Mouseovers stehen in den Schlüsseln
+# - visualize[["tooltip"]][["title"]]
+# - visualize[["tooltip"]][["body"]]
+
+karten_titel_html <- function(kandidat_s) {
+  # TBD
+}
+
+karten_body_html <- function(top = 5) {
+  # TBD
+  text <- "<p style='font-weight: 700;'>Ausgezählt: {{ meldungen_anz }} von {{ meldungen_max }} Wahllokalen{{ meldungen_max != meldungen_anz ? ' - Trendergebnis' : ''}}</p>"
+  # Generiere String mit allen Prozent-Variablen
+  prozent_s <- paste0(paste0("prozent",1:top),collapse=",")
+  # Tabellenöffnung
+  text <- text %>% paste0("<table style='width: 100%; border-spacing: 0.1em; border-collapse: collapse;'><thead>")
+  for (i in 1:top) {
+    text <- text %>%  paste0("<tr><th>{{ kand",i,
+                             " }}</th><td style='width: 120px; height=16px;'>",
+                             "<div style='width: {{ ROUND(((prozent",i,
+                             " / MAX(",
+                             prozent_s,
+                             ")) *100),1) }}%; background-color: {{ farbe",i,
+                             " }}; padding-bottom: 5%; padding-top: 5%; border-radius: 5px;'></div></div></td>",
+                              "<td style='padding-left: 3px; text-align: right; font-size: 0.8em;'>{{ FORMAT(prozent",i,
+                              ", '0.0%') }}</td></tr>")
+  }
+  # Tabelle abbinden
+  text <- text %>% paste0(
+    "</thead></table>",
+    "<p>Wahlberechtigte: {{ FORMAT(wahlberechtigt, '0,0') }}, abgegebene Stimmen: {{ FORMAT(stimmen, '0,0') }}, Briefwahl: {{ FORMAT(stimmen_wahlschein, '0,0') }}, ungültig {{ FORMAT(ungueltig, '0,0') }}"
+  )
+} 
+
+
+# Schreibe die Switcher und die Farbtabellen in alle betroffenen Datawrapper-Karten
+vorbereitung_alle_karten <- function() {
+  # Vorbereiten: df mit Kandidierenden und Datawrapper-ids
+  # Alle Datawrapper-IDs in einen Vektor extrahieren
+  id_df <- config_df %>% 
+    filter(str_detect(name,"_kand[0-9]+")) %>% 
+    mutate(Nummer = as.integer(str_extract(name,"[0-9]+"))) %>% 
+    select(Nummer,dw_id = value)#
+  # Mach aus der Switcher-Tabelle eine globale Variable
+  switcher_df <<- kandidaten_df %>% 
+    select(Nummer, Vorname, Name, Parteikürzel, Farbwert) %>% 
+    left_join(id_df, by="Nummer")
+  text_pre <- "<strong>Wählen Sie eine Karte über die Felder:</strong><br>"
+  text_post <- "<br><br>Klick auf den Stadtteil zeigt, wer dort führt"
+  balken_text <- generiere_auszählungsbalken(gezaehlt,stimmbezirke_n,ts)
+  dw_intro=paste0(text_pre,generiere_switcher(switcher_df,0),text_post)
+  # Farbskala und Mouseover anpassen
+  metadata_chart <- dw_retrieve_chart_metadata(karte_sieger_id)
+  visualize <- metadata_chart[["content"]][["metadata"]][["visualize"]]
+  # Farbwerte für die Kandidierenden
+  # erst mal löschen
+  visualize[["colorscale"]][["map"]] <- NULL
+  visualize[["colorscale"]][["map"]] <- setNames(as.list(kandidaten_df$Farbwert),
+                                                 kandidaten_df$Name)
+  # Karten-Tooltipp anpassen
+  # visualize[["tooltip"]][["title"]] <- karten_title_html(kandidat_s)
+  visualize[["tooltip"]][["body"]] <- karten_body_html(top)
+  
+  dw_edit_chart(karte_sieger_id,intro = dw_intro, annotate = balken_text, visualize = visualize)
+  # dw_data_to_chart()
+  # dw_publish_chart(karte_sieger_id)
+  
+  # Jetzt die n Choroplethkarten für alle Kandidaten
+  # Müssen als Kopie angelegt sein. 
+  for (i in 1:nrow(switcher_df)) {
+    dw_intro <- paste0(text_pre,generiere_switcher(switcher_df,0),text_post)
+    titel_s <- paste0(switcher_df$Vorname[i]," ",
+                     switcher_df$Name[i]," (",
+                     switcher_df$Parteikürzel[i],") - ",
+                     "Ergebnis nach Stadtteil")
+    kandidat_s <- paste0(switcher_df$name[i],
+                         " (",
+                         switcher_df$Parteikürzel[i])
+    metadata_chart <- dw_retrieve_chart_metadata(switcher_df$dw_id[i])
+    visualize <- metadata_chart[["content"]][["metadata"]][["visualize"]]
+    # Zwei Farben: Parteifarbe bei Pos. 1, aufgehellte Parteifarbe
+    # (zu RGB jeweils 0x40 addiert) bei Pos. 0
+    visualize[["colorscale"]][["colors"]][[2]]$color <- switcher_df$Farbwert[i]
+    visualize[["colorscale"]][["colors"]][[2]]$position <- 1
+    visualize[["colorscale"]][["colors"]][[1]]$color <- aufhellen(switcher_df$Farbwert[i])
+    visualize[["colorscale"]][["colors"]][[1]]$position <- 0
+    # Karten-Tooltipp anpassen
+    # visualize[["tooltip"]][["title"]] <- karten_title_html(kandidat_s)
+    visualize[["tooltip"]][["body"]] <- karten_body_html(top)
+    dw_edit_chart(switcher_df$dw_id[i],
+                  title = titel_s,
+                  intro = dw_intro,
+                  visualize = visualize,
+                  annotate = balken_text)
+    # dw_data_to_chart()
+    # dw_publish_chart(switcher_df$dw_id)
+  }
+}
+
+#---- Generiere und pushe die Grafiken für Social Media
+generiere_socialmedia <- function() {
+  # Fairly straightforward. Rufe zwei Datawrapper-Karten über die API auf, 
+  # generiere aus ihnen PNGs, benenne sie mit Zeitstempel, schiebe die auf 
+  # den Bucket und gib einen Text mit Link zurück. 
+  #
+  # Die beiden Karten sind: 
+  # - die Aufmacher-Grafik = top_id
+  # - die nüchterne Balkengrafik mit allen 20 Kandidat:innen S9BbQ
+  #
+  # Die Funktion aktualisiert KEINE Daten, sondern nimmt das, was gerade im 
+  # Datawrapper ist. Das ggf extra mit dw_data_to_chart(meine_df,social1_id,parse_dates =TRUE)
+  # 
+  # Setzt gültigen Zeitstempel ts voraus!
+  
+  # Erste Grafik ist sowieso aktuell und wird nur anders exportiert.
+  # dw_data_to_chart(tag_df,social1_id,parse_dates =TRUE)
+  social1_png <- dw_export_chart(social1_id,type = "png",unit="px",mode="rgb", scale = 1, 
+                  width = 640, height = 640, plain = TRUE, transparent = T)
+  social1_fname <- paste0("png/social1_",format.Date(ts,"%Y-%m-%d--%H%Mh"),".png")
+  # Zweite Grafik muss aktualisiert und vermetadatet werden
+  # Metadaten anpassen: Farbcodes für Parteien
+  metadata_chart <- dw_retrieve_chart_metadata(social2_id)
+  # Save the visualise path
+  visualize <- metadata_chart[["content"]][["metadata"]][["visualize"]]
+  visualize[["color-category"]][["map"]] <- 
+    setNames(as.list(kandidaten_df$Farbwert), 
+             paste0(kandidaten_df$Name," (",
+                    kandidaten_df$Parteikürzel,")"))
+  dw_edit_chart(chart_id = social2_id, visualize = visualize)
+  dw_data_to_chart(kand_tabelle_df, chart_id = social2_id)
+  social2_png <- dw_export_chart(social2_id,type = "png",unit="px",mode="rgb", scale = 1, 
+                  width = 640, height = 640, plain = TRUE, transparent = T)
+  social2_fname <- paste0("png/social2_",format.Date(ts,"%Y-%m-%d--%H%Mh"),".png")
+  # PNG-Dateien generieren...
+  if (!dir.exists("png")) {dir.create("png")}
+  magick::image_write(social1_png,social1_fname)
+  magick::image_write(social2_png,social2_fname)
+  #...und auf den Bucket schieben. 
+  if (SERVER) {
+    system(paste0('gsutil -h "Cache-Control:no-cache, max_age=0" ',
+                  'cp ',social1_fname,' gs://d.data.gcp.cloud.hr.de/', social1_fname))
+    system(paste0('gsutil -h "Cache-Control:no-cache, max_age=0" ',
+                  'cp ',social2_fname,' gs://d.data.gcp.cloud.hr.de/', social2_fname))
   }
-}
\ No newline at end of file
+  linktext <- paste0("<a href='https://d.data.gcp.cloud.hr.de/",social1_fname,
+                     "'>Download Social-Grafik 1 (5 stärkste)</a><br/>",
+                     "<a href='https://d.data.gcp.cloud.hr.de/",social2_fname,
+                     "'>Download Social-Grafik 2 (alle Stimmen)</a><br/>")
+  return(linktext)
+}
+
+#---- Datawrapper-Grafiken generieren ----
+aktualisiere_top <- function(kand_tabelle_df,top=5) {
+  daten_df <- kand_tabelle_df %>% 
+    arrange(desc(Prozent)) %>% 
+    select(`Kandidat/in`,Stimmenanteil = Prozent) %>% 
+    head(top)
+  # Daten pushen
+  dw_data_to_chart(daten_df,chart_id = top_id)
+  # Intro_Text nicht anpassen. 
+  # Balken reinrendern
+  balken_text <- generiere_auszählungsbalken(gezaehlt,stimmbezirke_n,ts)
+  # Metadaten anpassen: Farbcodes für Parteien
+  metadata_chart <- dw_retrieve_chart_metadata(top_id)
+  # Save the visualise path
+  visualize <- metadata_chart[["content"]][["metadata"]][["visualize"]]
+  # Der Schlüssel liegt unter custom-colors als Liste
+  visualize[["custom-colors"]] <- 
+    setNames(as.list(kandidaten_df$Farbwert), 
+             paste0(kandidaten_df$Name," (",
+                    kandidaten_df$Parteikürzel,")"))
+  dw_edit_chart(chart_id = top_id,annotate = balken_text, visualize=visualize)
+  dw <- dw_publish_chart(chart_id = top_id)
+}
+
+aktualisiere_tabelle_alle <- function(kand_tabelle_df) {
+  dw_data_to_chart(kand_tabelle_df, chart_id = tabelle_alle_id)
+  balken_text <- generiere_auszählung_nurtext(gezaehlt,stimmbezirke_n,ts)
+  # Metadaten anpassen: Farbcodes für Parteien
+  metadata_chart <- dw_retrieve_chart_metadata(tabelle_alle_id)
+  # Save the visualise path
+  visualize <- metadata_chart[["content"]][["metadata"]][["visualize"]]
+  visualize[["columns"]][["Prozent"]][["customColorBarBackground"]] <- NULL
+  visualize[["columns"]][["Stimmen"]][["customColorBarBackground"]] <- 
+    setNames(as.list(kandidaten_df$Farbwert), 
+             kandidaten_df$Nummer)
+  # Irrtümlich waren die Werte auch noch in visualize[["custom-color"]] gespeichert.
+  visualize[["custom-colors"]] <- NULL
+  visualize[["color-category"]] <- NULL
+  dw_edit_chart(chart_id = tabelle_alle_id, annotate = balken_text, visualize = visualize)
+  dw_publish_chart(chart_id = tabelle_alle_id)
+}
+
+aktualisiere_karten <- function(ergänzt_df) {
+  # Als erstes die Übersichtskarte
+  cat("Aktualisiere Karten\n")
+  # Die noch überhaupt nicht gezählten Bezirke ausfiltern
+  ergänzt_f_df <- ergänzt_df %>% filter(meldungen_anz > 0)
+  balken_text = generiere_auszählungsbalken(gezaehlt,stimmbezirke_n,ts)
+  dw_edit_chart(chart_id = karte_sieger_id, annotate = balken_text)
+  dw_data_to_chart(ergänzt_f_df,chart_id = karte_sieger_id)
+  dw <- dw_publish_chart(karte_sieger_id)
+  # Jetzt die Choropleth-Karten für alle Kandidierenden
+  for (i in 1:nrow(switcher_df)) {
+    dw_edit_chart(chart_id=switcher_df$dw_id[i],annotate = balken_text)
+    dw_data_to_chart(ergänzt_f_df, chart_id = switcher_df$dw_id[i])
+    dw <- dw_publish_chart(switcher_df$dw_id[i])
+  }
+  cat("Karten neu publiziert\n")
+}
+
+aktualisiere_hochburgen <- function(hochburgen_df) {
+  # Das ist ziemlich geradeheraus. 
+  dw_data_to_chart(hochburgen_df, chart_id = hochburgen_id)
+  balken_text <- generiere_auszählung_nurtext(gezaehlt,stimmbezirke_n,ts)
+  # Metadaten anpassen: Farbcodes für Parteien
+  metadata_chart <- dw_retrieve_chart_metadata(hochburgen_id)
+  # Save the visualise path
+  visualize <- metadata_chart[["content"]][["metadata"]][["visualize"]]
+  # Die Farben für die Kandidaten werden in dieser Tabelle nur für die Balkengrafiken
+  # in der Spalte "Prozent" benötigt und richten sich nach der Nummer.
+  visualize[["columns"]][["Prozent"]][["customColorBarBackground"]] <- 
+    setNames(as.list(kandidaten_df$Farbwert), 
+             kandidaten_df$Nummer)
+  # Irrtümlich waren die Werte auch noch in visualize[["custom-color"]] gespeichert.
+  visualize[["custom-colors"]] <- NULL
+  dw_edit_chart(chart_id = hochburgen_id, annotate = balken_text, visualize = visualize)
+  dw_publish_chart(chart_id = hochburgen_id)
+  cat("Hochburgen-Grafik neu publiziert\n")
+}
+
+aktualisiere_ergebnistabelle <- function(stadtteildaten_df) {
+  # Nr des Stadtteils, Stadtteil, Wahlbeteiligung (Info), Ergebnis
+  # Wahlbeteiligung und Ergebnis sind jeweils HTML-Text mit den Daten
+  # Unleserlich, aber funktional
+  e_tmp_df <- stadtteildaten_df %>% 
+    select(nr,name,meldungen_anz:ncol(.)) %>% 
+    # Nach Stadtteil sortieren
+    arrange(name) %>% 
+    mutate(sort = row_number())
+  # Nochmal ansetzen, um als erste Zeile das Gesamtergebnis einzusetzen
+  ergebnistabelle_df <- e_tmp_df %>% summarize(nr = 0, sort = 0,
+                               name = "GESAMTERGEBNIS",
+                               across(meldungen_anz:ncol(.), ~sum(.,na.r =FALSE))) %>% 
+    bind_rows(e_tmp_df) %>% 
+    # Mit den Kandidaten-Namen anreichern
+    # Ins Langformat umformen, Nummer ist die Kandidatennummer
+    pivot_longer(cols=starts_with("D"),names_to = "Nummer", values_to = "Stimmen") %>% 
+    mutate(Prozent = if_else(Stimmen == 0,0,Stimmen / gueltig * 100)) %>% 
+    # D1... in Integer umwandeln
+    mutate(Nummer = as.numeric(str_extract(Nummer,"[0-9]+"))) %>% 
+    left_join(kandidaten_df %>% select (Nummer, Vorname, Name, Parteikürzel),
+              by = "Nummer") %>% 
+    mutate(`Kandidat/in` = paste0(Name," (",Parteikürzel,")")) %>% 
+    # Kandidaten-Tabelle wieder zurückpivotieren
+    select(-Vorname, -Name, -Parteikürzel, -Nummer) %>%
+    # Nach Stadtteil gruppieren
+    group_by(sort,nr,name) %>% 
+    # Zusätzliche Variable: Stadtteil gezählt oder TREND?
+    mutate(trend = meldungen_anz < meldungen_max) %>% 
+    # Big bad summary - jeweils Daten aus den Spalten generieren
+    summarize(Stadtteil = paste0("<strong>",first(name),
+                                "</strong><br><br>",
+                                # TREND oder ERGEBNIS?
+                                if_else(first(trend),
+                                        paste0("TREND: ",
+                                               first(meldungen_anz)," von ",
+                                               first(meldungen_max)," Stimmbezirken ausgezählt"),
+                                        #...oder alles ausgezählt?
+                                        paste0("Alle ",
+                                               first(meldungen_max),
+                                               " Stimmbezirke ausgezählt"))),
+              Wahlbeteiligung = paste0("Wahlberechtigt: ",
+                                       # Wenn noch nicht ausgezählt, leer lassen
+                                       if_else(first(trend),"",
+                                               first(wahlberechtigt) %>% 
+                                         format(big.mark = ".", decimal.mark =",")),
+                                       "<br>",
+                                       "abg. Stimmen: ",first(stimmen) %>% format(big.mark = ".", decimal.mark =","),
+                                       " (",
+                                       # Nicht gezählte Bezirke haben 0 Wahlberechtigte
+                                       if_else(first(trend),"--", 
+                                                (first(stimmen)/first(wahlberechtigt) *100) %>% 
+                                         round(digits=1) %>% format(decimal.mark=",", nsmall = 1)),
+                                       "%)<br>",
+                                       "davon Briefwahl: ",
+                                       first(stimmen_wahlschein) %>% format(big.mark = ".", decimal.mark =","),
+                                       " (",
+                                       # Falls noch nicht alles ausgezählt, keinen Prozentwert angeben
+                                       if_else(first(trend),
+                                               "--",
+                                       (first(stimmen_wahlschein) / first(stimmen) * 100) %>% 
+                                         round(digits = 1) %>% format(decimal.mark=",", nsmall = 1)),
+                                       "%)<br>",
+                                       "<br>Ungültig: ",
+                                       first(ungueltig) %>% format(big.mark = ".", decimal.mark =","),
+                                       "<br>Gültige Stimmen: ",
+                                       first(gueltig) %>% format(big.mark = ".", decimal.mark =",")),
+              # Hier geschachtelte paste0-Aufrufe: 
+              # Der innere baut einen Vektor mit allen Kandidaten plus Ergebnissen
+              # Der äußere fügt diesen Vektor zu einem String zusammen (getrennt durch <br>)
+              Ergebnis = paste0(paste0("<strong>",`Kandidat/in`,"</strong>: ",
+                                       Stimmen %>% format(big.mark=".",decimal.mark=","),
+                                       " (",
+                                       Prozent %>% round(1) %>% format(decimal.mark=",", nsmall=1),"%)"),
+                collapse="<br>")
+              ) %>% 
+    ungroup() %>% 
+    arrange(sort) %>% 
+    select(-name,-sort)
+  dw_data_to_chart(ergebnistabelle_df %>% select(-nr), chart_id = tabelle_stadtteile_id)
+    # Trendergebnis? Schreibe "Trend" oder "Endergebnis" in den Titel
+  gezählt <- e_tmp_df %>% pull(meldungen_anz) %>% sum(.)
+  stimmbezirke_n <- e_tmp_df %>% pull(meldungen_max) %>% sum(.)
+  ts <- stadtteildaten_df %>% pull(zeitstempel) %>% first()
+  titel_s <- paste0(ifelse(gezählt < stimmbezirke_n,"TREND: ",""),
+                    "Ergebnisse nach Stadtteil")
+  dw_edit_chart(chart_id = tabelle_stadtteile_id,title = titel_s,
+                annotate=generiere_auszählung_nurtext(gezählt,stimmbezirke_n,ts))
+  dw_publish_chart(tabelle_stadtteile_id)
+  cat("Ergebnistabelle nach Stadtteil publiziert\n")
+  return(ergebnistabelle_df)
+}
diff --git a/R/generiere_testdaten.R b/R/generiere_testdaten.R
deleted file mode 100644
index 3eed9587e7a82d8e51b2252b2ce46d563e810348..0000000000000000000000000000000000000000
--- a/R/generiere_testdaten.R
+++ /dev/null
@@ -1,140 +0,0 @@
-#' generiere_testdaten.R
-#' 
-#' Macht aus den Templates für Ortsteil- und Wahllokal-Ergebnisse
-#' jeweils eine Serie von fiktiven Livedaten, um das Befüllen der
-#' Grafiken testen zu können. 
-#' 
-
-require(tidyr)
-require(dplyr)
-require(readr)
-
-# Alles weg, was noch im Speicher rumliegt
-rm(list=ls())
-
-source("R/lies_aktuellen_stand.R")
-
-#---- Funktion zum Testdaten-Löschen ----
-lösche_testdaten <- function(){
-  q <- tolower(readline(prompt = "Testdaten löschen - sicher? "))
-  if (!(q %in% c("j","y","ja"))) { return() }
-  # Datenarchiv weg
-  if (file.exists("daten/fom_df.rds")){
-    file.remove("daten/fom_df.rds")
-  }
-  # Testdaten
-  testdaten_files <- list.files("testdaten", full.names=TRUE) 
-  for (f in testdaten_files) {
-    # Grausam, I know. 
-    if (str_detect(f,"ortsteile[0-9]+\\.csv") |
-        str_detect(f,"wahllokale[0-9]+\\.csv")) {
-      file.remove(f)
-    }
-  }
-}
-
-# Vorlagen laden
-vorlage_wahllokale_df <- read_delim("testdaten/Open-Data-06412000-Buergerentscheid-zur-Abwahl-des-Oberbuergermeisters-der-Stadt-Frankfurt-am-Main_-Herrn-Peter-Feldmann-Stimmbezirk.csv", 
-                                   delim = ";", escape_double = FALSE, 
-                                   locale = locale(date_names = "de", 
-                                                   decimal_mark = ",", 
-                                                   grouping_mark = "."), 
-                                   trim_ws = TRUE)
-
-wahllokale_max <- sum(vorlage_wahllokale_df$`max-schnellmeldungen`)
-
-# Konstanten für die Simulation - werden jeweils um bis zu +/-25% variiert
-c_wahlberechtigt = 510000 / wahllokale_max # Gleich große Wahlbezirke
-c_wahlbeteiligung = 0.3 # Wahlbeteiligung um 30%, wird im Lauf der "Wahl" erhöht (kleinere WL sind schneller ausgezählt)
-c_wahlschein = 0.25 # 25% Briefwähler
-c_nv = 0.05 # 0,5% wählen "spontan" und sind nicht verzeichnet (nv) im Wählerverzeichnis
-c_ungültig = 0.01 # 1% Ungültige
-c_nein = 0.15 # unter den gültigen: 85% Ja-Stimmen (Varianz also von ca 81-89%)
-
-variiere <- function(x = 1) {
-  # Variiert den übergebenen Wert zufällig um -25% bis +25%:
-  # Zufallswerte zwischen 0,75 und 1,25 erstellen und multiplizieren
-  #
-  # Die Length-Funktion ist wichtig - sonst erstellt runif() nur einen 
-  # Zufallswert, mit dem alle Werte von x multipliziert werden. 
-  return(floor(x * (runif(length(x),0.75,1.25))))
-}
-
-
-
-i = 1
-# Schleife für die Wahllokale: Solange noch nicht alle "ausgezählt" sind...
-while(sum(vorlage_wahllokale_df$`anz-schnellmeldungen`) < wahllokale_max) {
-  # ...splitte das df in die gemeldeten (meldungen_anz == 1) und nicht gemeldeten Zeilen
-  tmp_gemeldet_df <- vorlage_wahllokale_df %>% filter(`anz-schnellmeldungen` == 1)
-  # Die Variable rand wird als Anteil von 20 Meldungen an debn noch offenen Wahllokale berechnet
-  rand <- 20 / (nrow(vorlage_wahllokale_df) - nrow(tmp_gemeldet_df)) 
-  tmp_sample_df <- vorlage_wahllokale_df %>% 
-    filter(`anz-schnellmeldungen` == 0) %>% 
-    # Bei den noch nicht ausgefüllten "Meldungen" mit einer Wahrscheinlichkeit
-    # von rand in die Gruppe sortieren, die neu "gemeldet" wird
-    mutate(sample = (runif(nrow(.)) < rand))
-  tmp_offen_df <- tmp_sample_df %>% 
-    filter(sample == 0) %>%
-    # sample-Variable wieder raus
-    select(-sample)
-  tmp_neu_df <- tmp_sample_df %>% 
-    filter(sample == 1) %>% 
-  select(-sample) %>% 
-    # Alle als gemeldet markieren
-    mutate(`anz-schnellmeldungen` = 1) %>%
-    # Und jetzt der Reihe nach (weil die Werte z.T. aufeinander aufbauen)
-    # Wahlberechtigte
-    mutate(A = floor(c_wahlberechtigt * runif(nrow(.),0.75,1.25))) %>% 
-    # Wahlschein
-    mutate(A2 = floor(A * c_wahlschein * runif(nrow(.),0.75,1.25))) %>% 
-    # Nicht verzeichnet
-    mutate(A3 = floor(A * c_nv * runif(nrow(.),0.75,1.25))) %>% 
-    # Regulär Wahlberechtigte (ohne Wahlschein oder nv)
-    mutate(A1 = A - A2 - A3) %>% 
-    # Abgegebene Stimmen
-    mutate(B = floor(A * c_wahlbeteiligung * runif(nrow(.),0.75,1.25))) %>% 
-    # davon mit Wahlschein
-    mutate(B1 = floor(B * c_wahlschein * runif(nrow(.),0.75,1.25))) %>% 
-    # davon ungültig
-    mutate(C = floor(B * c_ungültig * runif(nrow(.),0.75,1.25))) %>% 
-    # gültig
-    mutate(D = B - C) %>% 
-    # davon ja
-    mutate(D2 = floor(D * c_nein *runif(nrow(.),0.75,1.25))) %>% 
-    mutate(D1 = D - D2)
-  # Kurze Statusmeldung
-  cat("Neu gemeldet:",nrow(tmp_neu_df),"noch offen:",nrow(tmp_offen_df))
-  # Phew. Aktualisierte Testdatei zusammenführen und anlegen. 
-  vorlage_wahllokale_df <- tmp_gemeldet_df %>% 
-    bind_rows(tmp_neu_df) %>% 
-    bind_rows(tmp_offen_df) %>%
-    # wieder in die Reihenfolge nach Wahllokal-Nummer
-    arrange(`gebiet-nr`)
-  
-  write_csv2(vorlage_wahllokale_df,
-             paste0("testdaten/wahllokale",
-                    sprintf("%02i",i),
-                    ".csv"),
-             escape = "backslash")
-  # Generiere die passende Ortsteil-Meldung
-  # Geht aus irgeneindem Grund nicht, aber wir brauchens ja auch nicht. 
-  # ortsteile_df <- zuordnung_wahllokale_df %>% 
-  #   select(`gebiet-name` = name,ortsteilnr) %>% 
-  #   left_join(vorlage_wahllokale_df,by="gebiet-name") %>% 
-  #   # Zuordnung der Wahllokale
-  #   group_by(ortsteilnr) %>% 
-  #   # Das crasht - WTF???
-  #   summarize(across(7:18, ~ sum(.,na.rm = T))) %>%
-  #   left_join(stadtteile_df %>% select(ortsteilnr = nr,name),by="ortsteilnr") %>% 
-  #   rename(`gebiet-nr` = ortsteilnr) %>% 
-  #   mutate(`gebiet-name` = name) %>% 
-  #   select(-ortsteilnr) 
-    
-  i <- i+1
-  # Wahlbeteiligung schrittweise ein wenig anheben - um zu simulieren, 
-  # dass "kleinere" Wahllokale zuerst ausgezählt werden
-  c_wahlbeteiligung <- c_wahlbeteiligung + 0.002
-}
-
-
diff --git a/R/its_alive.R b/R/its_alive.R
new file mode 100644
index 0000000000000000000000000000000000000000..750c52b4049059d72c746cdd1602abae866eecb4
--- /dev/null
+++ b/R/its_alive.R
@@ -0,0 +1,41 @@
+# its_alive.R - Watchdog-Skript für update_all.R
+#
+# Wird per CRON-Job aufgerufen: Wenn das letzte Update der Log-Datei "wahl.log"
+# länger als x Minuten zurückliegt, löst das Skript einen Alarm über Teams aus.
+
+# Mehr als die Datumsfunktionen brauchen wir nicht
+library(lubridate)
+library(this.path)
+
+# Das Projektverzeichnis "obwahl" als Arbeitsverzeichnis wählen
+# Aktuelles Verzeichnis als workdir
+setwd(this.path::this.dir())
+# Aus dem R-Verzeichnis eine Ebene rauf
+setwd("..")
+
+# Teams-Funktionen einbinden
+source("R/messaging.R")
+
+# Maximales Alter in Sekunden?
+max_alter = 120
+
+# Startzeit festhalten
+ts = now()
+
+# Gibt es überhaupt eine Logdatei? 
+
+if (file.exists("obwahl.log")) {
+  metadaten <- file.info("obwahl.log")
+  # Berechne Alter der Logdatei in Sekunden
+  alter = as.integer(difftime(ts,metadaten$mtime,units="secs"))
+  if (alter > max_alter)
+  {
+    cat("WATCHDOG its_alive.R: obwahl.log seit ",alter," Sekunden unverändert")
+    cat("Benenne obwahl.log um in obwahl_crash.log")
+    file.rename("obwahl.log","obwahl_crash.log")
+    teams_error("PROGRAMM STEHEN GEBLIEBEN? obwahl.log ist seit ",alter," Sekunden unverändert")
+  }
+} else {
+  # Tue nichts.
+  cat("its_alive.R: obwahl.log im Arbeitsverzeichnis",getwd(),"nicht gefunden")
+}
diff --git a/R/lies_aktuellen_stand.R b/R/lies_aktuellen_stand.R
index 70b7bca9985413a10982c16b486276077c7c0b19..2ffc5fcd4cadd952b90337bfabecd094bd80fb06 100644
--- a/R/lies_aktuellen_stand.R
+++ b/R/lies_aktuellen_stand.R
@@ -1,56 +1,40 @@
+# Library-Aufrufe kann man sich eigentlich sparen, aber...
 library(readr)
 library(lubridate)
 library(tidyr)
 library(stringr)
 library(dplyr)
+library(openxlsx)
+library(curl)
 
 # lies_aktuellen_stand.R
 #
 # Enthält die Funktion zum Lesen der aktuellen Daten. 
 
-#---- Vorbereitung ----
-# Statische Daten einlesen
-# (das später durch ein schnelleres .rda ersetzen)
+#---- Hilfsfunktionen ----
 
-# Enthält drei Datensätze: 
-# - opendata_wahllokale_df mit der Liste aller Stimmwahlbezirke nach Wahllokal
-# - statteile_df: Stadtteil mit Namen und laufender Nummer, Geokoordinaten, Ergebnissen 2018
-# - zuordnung_stimmbezirke: Stimmbezirk-Nummer (als int und String) -> Stadtteilnr.
- 
-load ("index/index.rda")
-
-# Konfiguration auslesen und in Variablen schreiben
-config_df <- read_csv("index/config.csv")
-for (i in c(1:nrow(config_df))) {
-  # Erzeuge neue Variablen mit den Namen und Werten aus der CSV
-  assign(config_df$name[i],
-         # Kann man den Wert auch als Zahl lesen?
-         # Fieses Regex sucht nach reiner Zahl oder Kommawerten.
-         # Keine Exponentialschreibweise!
-         ifelse(grepl("^[0-9]*\\.*[0-9]+$",config_df$value[i]),
-                # Ist eine Zahl - wandle um
-                as.numeric(config_df$value[i]),
-                # Keine Zahl - behalte den String
-                config_df$value[i]))
-}
-
-
-#---- Daten ins Archiv schreiben oder daraus lesen
 archiviere <- function(df,a_directory = "daten/stimmbezirke") {
+  #' Schreibt das Dataframe mit den zuletzt geholten Stimmbezirks-Daten
+  #' als Sicherungskopie in das angegebene Verzeichnis
+  #' 
   if (!dir.exists(a_directory)) {
     dir.create(a_directory)
   }
-  write_csv(df,
-            paste0(a_directory,"/",
-                   # Zeitstempel isolieren und alle Doppelpunkte
-                   # durch Bindestriche ersetzen
-                   str_replace_all(df %>% pull(zeitstempel) %>% last(),
-                               "\\:","_"),
-                   ".csv"))
+  fname = paste0(a_directory,"/",
+                # Zeitstempel isolieren und alle Doppelpunkte
+                # durch Bindestriche ersetzen
+                str_replace_all(df %>% pull(zeitstempel) %>% last(),
+                                "\\:","_"),
+                ".csv")
+  write_csv(df,fname)
+  cat(as.character(now())," - Daten archiviert als ",paste0(a_directory,fname))
 }
 
 hole_letztes_df <- function(a_directory = "daten/stimmbezirke") {
+  #' Schaut im angegebenen Verzeichnis nach der zuletzt angelegten Datei
+  #' und holt die Daten zurück in ein df
   if (!dir.exists(a_directory)) return(tibble())
+  # Die zuletzt geschriebene Datei finden und einlesen
   neuester_file <- list.files(a_directory, full.names=TRUE) %>% 
     file.info() %>% 
     # Legt eine Spalte namens path an
@@ -67,11 +51,56 @@ hole_letztes_df <- function(a_directory = "daten/stimmbezirke") {
   }
 }
 
+# Sind die beiden df abgesehen vom Zeitstempel identisch?
+# Funktion vergleicht die numerischen Werte - Spalte für Spalte.
+vergleiche_stand <- function(alt_df, neu_df) {
+  #' Spaltenweiser Vergleich: Haben die Daten sich verändert? 
+  #' (Anders gefragt: ist die Summe aller numerischen Spalten gleich?)
+  #' Wurde für die Feldmann-Wahl benötigt; bei OB-Wahlen eigentlich überflüssig
+  neu_sum_df <- alt_df %>% summarize_if(is.numeric,sum,na.rm=T)
+  alt_sum_df <- neu_df %>% summarize_if(is.numeric,sum,na.rm=T)
+  # Unterschiedliche Spaltenzahlen? Dann können sie keine von Finns Männern sein.
+  if (length(neu_sum_df) != length(alt_sum_df)) return(FALSE)
+  # Differenzen? Dann können sie keine von Finns Männern sein. 
+  return(sum(abs(neu_sum_df - alt_sum_df))==0)
+}
+
+
+#--- CURL-Polling (experimentell!)
+#
+# Gibt das Änderungsdatum der Daten-Datei auf dem Wahlamtsserver zurück - 
+# wenn es sich verändert hat, ist das das Signal, neue Daten zu holen. 
+check_for_timestamp <- function(my_url) {
+  # Erst checken: Wirklich eine Internet-Verbindung? 
+  # Sonst behandle als lokale Datei. 
+  if(str_detect(my_url,"^http")) {
+    tmp <- curlGetHeaders(my_url, redirect = T, verify = F)
+    # Redirect 
+    # if (stringr::str_detect(tmp[1]," 404")) {
+      # Library(curl)
+      h <- new_handle()
+      # Das funktioniert, holt aber alle Daten -> hohe Last
+      t <- curl_fetch_memory(my_url,handle=h)$modified %>% 
+        as_datetime()
+    # } else {
+    #   t <- tmp[stringr::str_detect(tmp,"last-modified")] %>% 
+    #     stringr::str_replace("last-modified: ","") %>% 
+    #     parse_date_time("%a, %d %m %Y %H:%M:%S",tz = "CET") + hours(1)
+    # }
+  } else { # lokale Datei
+    t = file.info(my_url)$mtime %>%  as_datetime
+  }
+  return(t)
+}
+
 
 #---- Lese-Funktionen ----
-lies_gebiet <- function(stand_url = stimmbezirke_url) {
-  ts <- now()
-  # Versuch Daten zu lesen - und gib ggf. Warnung oder Fehler zurück
+
+# Das hier ist die Haupt-Lese-Funktion
+lies_stimmbezirke <- function(stand_url = stimmbezirke_url) {
+  #' Versuche, Daten vom Wahlamtsserver zu lesen - und gib ggf. Warnung oder Fehler zurück
+  #' Schreibt eine Meldung ins Logfile - zugleich ein Lesezeichen
+  cat(as.character(now())," - Neue Daten lesen\n") # Touch logfile
   check = tryCatch(
     { stand_df <- read_delim(stand_url, 
                          delim = ";", escape_double = FALSE, 
@@ -80,7 +109,10 @@ lies_gebiet <- function(stand_url = stimmbezirke_url) {
                                          grouping_mark = "."), 
                          trim_ws = TRUE) %>% 
       # Spalten umbenennen, Zeitstempel-Spalte einfügen
-                    mutate(zeitstempel=ts) %>% 
+                    mutate(zeitstempel=ts) %>%
+      # Sonderregel: wir haben einen Zeitstempel, die "datum"-Spalte macht
+      # Probleme, weil: starts_with("D"). 
+                    select(-datum) %>% 
                     select(zeitstempel,
                            nr = `gebiet-nr`,
                            name = `gebiet-name`,
@@ -96,57 +128,243 @@ lies_gebiet <- function(stand_url = stimmbezirke_url) {
                            stimmen_wahlschein = B1, 
                            ungueltig = C,
                            gueltig = D,
-                           ja = D1,
-                           nein = D2)
+                           # neu: alle Zeilen mit Stimmen (D1..Dn)
+                           starts_with("D"))
+      
       },
-    warning = function(w) {teams_warning(w,title="Feldmann: Datenakquise")},
-    error = function(e) {teams_warning(e,title="Feldmann: Datenakquise")})
-  # Spalten umbenennen, 
+    warning = function(w) {teams_warning(w,title="OB-Wahl: Datenakquise")},
+    error = function(e) {teams_warning(e,title="OB-Wahl: Datenakquise")})
   return(stand_df)
 }
 
-
-# Sind die beiden df abgesehen vom Zeitstempel identisch?
-# Funktion vergleicht die numerischen Werte - Spalte für Spalte.
-vergleiche_stand <- function(alt_df, neu_df) {
-  neu_sum_df <- alt_df %>% summarize_if(is.numeric,sum,na.rm=T)
-  alt_sum_df <- neu_df %>% summarize_if(is.numeric,sum,na.rm=T)
-  # Unterschiedliche Spaltenzahlen? Dann können sie keine von Finns Männern sein.
-  if (length(neu_sum_df) != length(alt_sum_df)) return(FALSE)
-  # Differenzen? Dann können sie keine von Finns Männern sein. 
-  return(sum(abs(neu_sum_df - alt_sum_df))==0)
-}
-
-#' Liest Stimmbezirke, gibt nach Ortsteil aggregierte Daten zurück
-#' (hier: kein Sicherheitscheck)
-aggregiere_stadtteile <- function(stimmbezirke_df) {
-  ortsteile_df <- stimmbezirke_df %>% 
-    left_join(zuordnung_stimmbezirke_df,by=c("nr","name")) %>% 
-    group_by(ortsteilnr) %>% 
+aggregiere_stadtteildaten <- function(stimmbezirksdaten_df = stimmbezirksdaten_df) {
+  #' Liest Stimmbezirke, gibt nach Ortsteil aggregierte Daten zurück
+  #' (hier: kein Sicherheitscheck)
+  stadtteildaten_df <- stimmbezirksdaten_df %>% 
+    left_join(stimmbezirke_df %>% select(nr,ortsteilnr,stadtteil),
+              by="nr") %>% 
+    group_by(ortsteilnr)   %>% 
+    # Fasse alle Spalten von meldungen_anz bis Ende der Tabelle zusammen - 
+    # mit der sum()-Funktion (NA wird wie null behandelt)
     summarize(zeitstempel = last(zeitstempel),
-              across(meldungen_anz:nein, ~ sum(.,na.rm = T))) %>%
-    rename(nr = ortsteilnr) %>% 
-    # Stadtteilnamen, 2018er Ergebnisse, Geokoordinaten dazuholen
+              nr = first(ortsteilnr), 
+              meldungen_anz = sum(meldungen_anz,na.rm =T),
+              meldungen_max = sum(meldungen_max,na.rm = T),
+              wahlberechtigt = sum(wahlberechtigt, na.rm = T),
+              waehler_regulaer = sum(waehler_regulaer, na.rm = T),
+              waehler_wahlschein = sum(waehler_wahlschein, na.rm = T),
+              waehler_nv = sum(waehler_nv, na.rm = T),
+              stimmen = sum(stimmen, na.rm = T),
+              stimmen_wahlschein = sum(stimmen_wahlschein, na.rm = T),
+              ungueltig = sum(ungueltig, na.rm = T),
+              gueltig = sum(gueltig, na.rm = T),
+              across(starts_with("D"), ~ sum(.,na.rm = T))) %>%
+    mutate(across(where(is.numeric), ~ifelse(is.na(.), 0, .))) %>% 
+    # Stadtteilnamen, Geokoordinaten dazuholen
     left_join(stadtteile_df, by="nr") %>% 
-    # Nach Ortsteil sortieren
-    arrange(nr) %>% 
     # Wichtige Daten für bessere Lesbarkeit nach vorn
     relocate(zeitstempel,nr,name,lon,lat)
     
   # Sicherheitscheck: Warnen, wenn nicht alle Ortsteile zugeordnet
-  if (nrow(ortsteile_df) != nrow(stadtteile_df)) teams_warnung("Nicht alle Ortsteile zugeordnet")
-  if (nrow(zuordnung_stimmbezirke_df) != length(unique(stimmbezirke_df$nr))) teams_warnung("Nicht alle Stimmbezirke zugeordnet")
-  return(ortsteile_df)
+  if (nrow(stadtteildaten_df) != nrow(stadtteile_df)) teams_warnung("Nicht alle Stadtteile zugeordnet")
+  if (nrow(stimmbezirke_df) != length(unique(stimmbezirke_df$nr))) teams_warnung("Nicht alle Stimmbezirke zugeordnet")
+  cat("Stadtteildaten aggregiert.\n")
+  return(stadtteildaten_df)
+}
+
+#---- Die Tabellen für die DW-Grafiken ergänzen ----
+berechne_ergänzt <- function(stadtteildaten_df = stadtteildaten_df, top = top) {
+  #' Ergänze die jeweils fünf führenden Kandidaten, ihre Prozentanteile ihre 
+  #' Stimmen und ihre Farbwerte. Und benenne die D1...Dn-Spalten nach 
+  #' Kandidat/in/Partei in der Form "Müller (ABC)" - gleichzeitig der Index für DW
+  #' 
+  #' Gibt eine megalange Tabelle für Datawrapper zurück. 
+
+  # Zuerst ein temporäres Langformat, bei dem jede/d Kand in jedem Stadtteil eine Zeile 
+  # hat. Das brauchen wir 2x, um es wieder zusammenführen zu können. 
+  tmp_long_df <- stadtteildaten_df %>%
+    pivot_longer(cols = starts_with("D"), names_to = "kand_nr", values_to = "kand_stimmen") %>% 
+    mutate(kand_nr = as.integer(str_extract(kand_nr,"[0-9]+"))) %>% 
+    # Ortsteil- bzw. Stimmbezirks-Gruppen, um dort nach Stimmen zu sortieren
+    group_by(nr,name) %>% 
+    arrange(desc(kand_stimmen)) %>% 
+    mutate(Platz = row_number()) %>%
+    left_join(kandidaten_df %>% select(kand_nr = Nummer, 
+                                       kand_name = Name,
+                                       kand_partei = Parteikürzel,
+                                       farbe= Farbwert), by="kand_nr") %>% 
+    mutate(kand = paste0(kand_name," (",kand_partei,")")) %>% 
+    mutate(prozent = if_else(gueltig != 0,kand_stimmen / gueltig * 100, 0)) 
+  ergänzt_df <- tmp_long_df %>% 
+    # Ist noch nach Stadtteil (name, nr) sortiert
+    arrange(kand_nr) %>% 
+    # Alles weg, was verhindert, was individuell auf den Kand ist - außer
+    #  kand und Prozentwert
+    select(-kand_stimmen, -kand_nr, -Platz, -kand_name, -kand_partei, -farbe) %>% 
+    # Kandidatennamen in die Spalten zurückverteilen
+    pivot_wider(names_from = kand, values_from = prozent) %>% 
+    ungroup() %>% 
+    # und die zweite Hälfte dazu: 
+    left_join(
+      tst <- tmp_long_df %>% 
+                # Brauchen nur die Kand-Ergebnisse - und den (Stadtteil-)name
+                select(name, Platz, kand=kand_name,prozent,farbe) %>% 
+                # Nur die ersten (top) Plätze
+                filter(Platz <= (top)) %>% 
+    #The Big Pivot: Breite die ersten (top) aus. 
+    pivot_wider(names_from = Platz,
+                values_from = c(kand,prozent,farbe),
+                names_glue = "{.value}{Platz}") %>% 
+        ungroup() %>% 
+        select(-nr),   
+    by="name") %>% 
+    # Sonderregelung: Wenn keine Stimmen, weise kand1-(top) NA zu (wg. Stadtteilen ohne Daten)
+    mutate(across(starts_with("kand"), ~ if_else(meldungen_anz > 0, .,""))) %>% 
+    mutate(across(starts_with("farbe"),  ~ if_else(meldungen_anz > 0, .,"#aaaaaa")))
+  cat("Ergänzte Stadtteildaten berechnet.\n")
+  return(ergänzt_df)
 }
 
-lies_stadtteil_direkt <- function(stand_url = ortsteile_url) {
-  neu_df <- lies_gebiet(stand_url)  %>%
-    # nr bei Ortsteil-Daten leer/ignorieren
-    select(!nr) %>% 
-    # Stadtteilnr., Geodaten und Feldmann-2018-Daten reinholen:
-    left_join(stadtteile_df, by=c("name")) %>% 
-    mutate(trend = (meldungen_anz < meldungen_max),
-           quorum_erreicht = (ja >= (wahlberechtigt * 0.3)))
-  return(neu_df)
+berechne_kand_tabelle <- function(stimmbezirksdaten_df = stimmbezirksdaten_df) {
+  # Nimmt die Stadtteildaten - oder auch die Wahllokale - und berechne daraus die
+  # Nummer, Kandidat(in) in der Form "Müller (XYZ)", Parteikürzel, Stimmen, Prozent
+  kand_tabelle_df <- stimmbezirksdaten_df %>% 
+    summarize(gueltig = sum(gueltig, na.rm = T),
+      across(starts_with("D"), ~ sum(.,na.rm=TRUE))) %>% 
+    pivot_longer(cols=starts_with("D"),names_to = "nr", values_to = "Stimmen") %>% 
+    # Namen in Nr. umwandeln
+    mutate(Nummer = as.integer(str_extract(nr,"[0-9]+"))) %>% 
+    left_join(kandidaten_df %>%  select(Nummer, Name, Parteikürzel, Farbwert), 
+              by="Nummer") %>% 
+    mutate(name = paste0(Name," (",Parteikürzel,")")) %>% 
+    mutate(Prozent = Stimmen / gueltig * 100) %>% 
+    select(Nummer, `Kandidat/in` = name, Parteikürzel, Stimmen, Prozent)
+  cat("Gesamttabelle alle Kandidaten berechnet.\n")
+  return(kand_tabelle_df)
 }
 
+berechne_hochburgen <- function(stadtteildaten_df = stadtteildaten_df) {
+  # Tabelle mit den drei stärksten und drei schwächsten Stadtteilen
+  # im Vergleich zu GESAMT
+  hochburgen_df <- stadtteildaten_df %>% 
+    select(name,gueltig,D1:ncol(.)) %>% 
+    # Eine Zeile für Frankfurt dazu
+    bind_rows(stadtteildaten_df %>% 
+                select(name,gueltig,D1:ncol(.)) %>% 
+                summarize(gueltig = sum(gueltig, na.rm = T),
+                          across(starts_with("D"), ~ sum(.,na.rm=TRUE))) %>% 
+                mutate(name = "GESAMT")) %>% 
+    # Ins Langformat umformen, Nummer ist die Kandidatennummer
+    pivot_longer(cols=starts_with("D"),names_to = "Nummer", values_to = "Stimmen") %>% 
+    mutate(Prozent = if_else(Stimmen == 0,0,Stimmen / gueltig * 100)) %>% 
+    # D1... in Integer umwandeln
+    mutate(Nummer = as.numeric(str_extract(Nummer,"[0-9]+"))) %>% 
+    mutate(ist_gesamt = (name == "GESAMT")) %>% 
+    # Wichtig: "Currently, group_by() internally orders in ascending order."
+    group_by(Nummer,ist_gesamt) %>% 
+    arrange(desc(Prozent)) %>% 
+    mutate(Platz = row_number()) %>% 
+    filter(Platz <= 3 | Platz > (nrow(stadtteile_df) - 3)) %>% 
+    mutate(Platz = if_else(ist_gesamt, as.integer(0),row_number())) %>% 
+    ungroup(ist_gesamt) %>% 
+    arrange(Platz) %>% 
+    # Namen dazuholen
+    left_join(kandidaten_df %>% select (Nummer, Vorname, Name, Parteikürzel),
+              by = "Nummer") %>% 
+    mutate(`Kandidat/in` = if_else(ist_gesamt,
+                                   paste0(Vorname," ",Name," (",Parteikürzel,")"),
+                                   "")) %>% 
+    # sortieren
+    mutate(sort = 7* Nummer + Platz) %>% 
+    ungroup() %>% 
+    arrange(sort) %>% 
+    select(Nummer, `Kandidat/in`, Stadtteil = name, Prozent)
+  cat("Hochburgen nach Kandidaten berechnet.\n")
+  return(hochburgen_df)    
+}
+#---- Haupt-Funktion ----
+#
+# 
+hole_wahldaten <- function() {
+      # Hole und archiviere die Stimmbezirks-Daten; 
+      # erzeuge ein df mit den Stimmen nach Stadtteil. 
+      stimmbezirksdaten_df <<- lies_stimmbezirke(stimmbezirke_url)
+      gezaehlt <<- stimmbezirksdaten_df %>% pull(meldungen_anz) %>% sum(.)
+      archiviere(stimmbezirksdaten_df,paste0("daten/",wahl_name,"/"))
+      kand_tabelle_df <<- berechne_kand_tabelle(stimmbezirksdaten_df)
+      stadtteildaten_df <<- aggregiere_stadtteildaten(stimmbezirksdaten_df)
+      ergänzt_df <<- berechne_ergänzt(stadtteildaten_df,top)
+      hochburgen_df <<- berechne_hochburgen(stadtteildaten_df)
+      # Neue Daten: Die Stimmdaten-zeilen, die ausgezählt sind. 
+      neue_daten <<- stimmbezirksdaten_df %>%
+        # Filtere auf alle gezählten Stimmbezirke
+        filter(meldungen_anz == 1) %>% 
+        # Ziehe die ab, die schon in den alten Daten gezählt waren
+        anti_join(alte_daten %>% 
+                    filter(meldungen_anz == 1) %>% 
+                    select(name),
+                  by="name")
+      alte_daten <<- stimmbezirksdaten_df
+  # Aktualisiere die Karten (bzw. warne, wenn keine neuen da.)
+  if (nrow(neue_daten)==0) {
+    # teams_warning("Neue Stimmbezirk-Daten, aber keine neuen Ortsdaten?",title=wahl_name)
+    cat("Frischer Zeitstempel, aber keine neu ausgezählten Stimmbezirke")
+  } 
+  
+  check = tryCatch(
+    { # Die Ergebnistabellen mit allen Stimmen/Top-Kandidaten und Ergebnistabelle.
+      aktualisiere_top(kand_tabelle_df,top)
+      aktualisiere_tabelle_alle(kand_tabelle_df)
+    },
+    warning = function(w) {teams_warning(w,title=paste0(wahl_name,": Grafiken A"))},
+    error = function(e) {teams_warning(e,title=paste0(wahl_name,": Grafiken A"))}
+  )  
+  ergebnistabelle_df <<- aktualisiere_ergebnistabelle(stadtteildaten_df)
+  # Jetzt erst mal die Teams-Meldung absetzen. 
+  meldung_s <- paste0(nrow(neue_daten),
+                      " Stimmbezirke neu ausgezählt ",
+                      "(insgesamt ",gezaehlt,
+                      " von ",stimmbezirke_n,")<br>",
+                      "<br><strong>DERZEITIGER STAND: GANZE STADT</strong><br>",
+                      # Oberste Zeile der Ergebnistabelle ausgeben
+                      ergebnistabelle_df %>% head(1) %>% pull(Wahlbeteiligung),
+                      "<br>",
+                      ergebnistabelle_df %>% head(1) %>% pull(Ergebnis))
+  # Neue Stadtteile? Dann 
+  neue_stadtteile <- stadtteildaten_df %>%
+    # Ausgezählte Stadtteile ausfiltern
+    filter(meldungen_anz == meldungen_max) %>% 
+    # ...und schauen, ob da ein neuer dabei ist
+    inner_join(neue_daten %>%
+                 # Neu gezählte Stimmbezirks-Meldung um Stadtteile ergänzen
+                 left_join(stimmbezirke_df, by="nr") %>% 
+                 select(name = stadtteil) %>% unique(),
+               by="name")
+  #                
+  if(nrow(neue_stadtteile)>0) {
+    for (s in neue_stadtteile %>% pull(nr)) {
+      # Isoliere den Stadtteil, dessen Nummer wir gerade anschauen
+      stadtteil <- stadtteildaten_df %>% filter(nr == s)
+      meldung_s <- paste0(meldung_s,
+                          "<br><br><strong>Ausgezählter Stadtteil: ",
+                          stadtteil$name,
+                          "</strong><br>",
+                          ergebnistabelle_df %>% filter(nr == s) %>% pull(Wahlbeteiligung),
+                          "<br>",
+                          ergebnistabelle_df %>% filter(nr == s) %>% pull(Ergebnis))
+    }
+    # Stadtteil neu ausgezählt?
+  }
+  meldung_s <- paste0(meldung_s,"<br><br>",
+                      generiere_socialmedia())
+  teams_meldung(meldung_s,title=wahl_name)
+  
+  check = tryCatch(
+    {
+      aktualisiere_karten(ergänzt_df)
+      aktualisiere_hochburgen(hochburgen_df)
+    },
+    warning = function(w) {teams_warning(w,title=paste0(wahl_name,": Grafiken B"))},
+    error = function(e) {teams_warning(e,title=paste0(wahl_name,": Grafiken B"))}
+  )
+}
\ No newline at end of file
diff --git a/R/lies_konfiguration.R b/R/lies_konfiguration.R
new file mode 100644
index 0000000000000000000000000000000000000000..b36523afcf71e436a4663c4bce56ab973a57ac7a
--- /dev/null
+++ b/R/lies_konfiguration.R
@@ -0,0 +1,87 @@
+#---- Vorbereitung ----
+# Statische Daten einlesen
+# (das später durch ein schnelleres .rda ersetzen)
+
+# Enthält drei Datensätze: 
+# - opendata_wahllokale_df mit der Liste aller Stimmwahlbezirke nach Wahllokal
+# - statteile_df: Stadtteil mit Namen und laufender Nummer, Geokoordinaten, Ergebnissen 2018
+# - zuordnung_stimmbezirke: Stimmbezirk-Nummer (als int und String) -> Stadtteilnr.
+
+# load ("index/index.rda")
+
+
+# Konfiguration auslesen und in Variablen schreiben
+#
+# Generiert für jede Zeile die genannte Variable mit dem Wert value
+#
+# Derzeit erwartet das Programm: 
+# - wahl_name - Name der Wahl ("obwahl_ffm", "obwahl_kassel_stichwahl" etc.)
+# - stimmbezirke_url - URL auf Ergebnisdaten
+# - kandidaten_fname - Dateiname der Kandidierenden-Liste (s.u.)
+# - datawrapper_fname - Dateiname für die Datawrapper-Verweis-Datei
+# - stadtteile_fname
+# - zuordnung_fname
+# - startdatum - wann beginne ich zu arbeiten?
+# - wahlberechtigt - Zahl der Wahlberechtigen (kommt Sonntag)
+# - briefwahl - Zahl der Briefwahlstimmen (kommt Sonntag)
+
+if (TEST) {
+  config_df <- read_csv("index/config_test.csv")
+} else {
+  config_df <- read_csv("index/config.csv")
+}
+for (i in c(1:nrow(config_df))) {
+  # Erzeuge neue Variablen mit den Namen und Werten aus der CSV
+  assign(config_df$name[i],
+         # Kann man den Wert auch als Zahl lesen?
+         # Fieses Regex sucht nach reiner Zahl oder Kommawerten.
+         # Keine Exponentialschreibweise!
+         ifelse(grepl("^[0-9]*\\.*[0-9]+$",config_df$value[i]),
+                # Ist eine Zahl - wandle um
+                as.numeric(config_df$value[i]),
+                # Keine Zahl - behalte den String
+                config_df$value[i]))
+}
+
+lies_daten <- function(fname) {
+  if (toupper(str_extract(fname,"(?<=\\.)[A-zA-Z]+$")) %in% c("XLS","XLSX")) {
+    # Ist offensichtlich eine Excel-Datei. Lies das erste Sheet.
+    return(read.xlsx(fname))
+  } else {
+    # Geh von einer CSV-Datei aus.
+    first_line <- readLines(fname, n = 1)
+    commas <- str_split(first_line, ",") %>% unlist() %>% length()
+    semicolons <- str_split(first_line, ";") %>% unlist() %>% length()
+    if (commas > semicolons) {
+      return(read_csv(fname))
+    } else {
+      # Glaube an das Gute im Menschen: Erwarte UTF-8 und deutsche Kommasetzung.
+      return(read_csv2(fname,
+                       locale = locale(
+                         date_names = "de",
+                         date_format = "%Y-%m-%d",
+                         time_format = "%H:%M:%S",
+                         decimal_mark = ",",
+                         grouping_mark = ".",
+                         encoding = "UTF-8",
+                         asciify = FALSE
+                       )))
+    }
+    
+  }
+}
+# Stadtteilname und -nr; Geokoordinaten. Später: Ergebnisse
+# der 2021er Kommunalwahl
+stadtteile_df <- lies_daten(paste0("index/",wahl_name,"/",stadtteile_fname))
+# Zuordnung Stimmbezirk (Wahllokale und Briefwahl-Bezirke) -> Stadtteil
+stimmbezirke_df <- lies_daten(paste0("index/",wahl_name,"/",zuordnung_fname)) %>% 
+  # Nummer-Spalten in numerische INdizes umwandeln
+  mutate(ortsteilnr = as.integer(ortsteilnr)) %>% 
+  mutate(nr = as.integer(nr)) %>% 
+  left_join(stadtteile_df %>% select(nr=1,stadtteil=2), by=c("ortsteilnr"="nr"))
+# Kandidat:innen-Index
+kandidaten_df <- lies_daten(paste0("index/",wahl_name,"/",kandidaten_fname))
+
+# Läufst du auf dem Server?
+SERVER <- dir.exists("/home/jan_eggers_hr_de") 
+
diff --git a/R/main.R b/R/main.R
new file mode 100644
index 0000000000000000000000000000000000000000..6ddc7f1dc1e503b82b444b007c20fada3aff5b2b
--- /dev/null
+++ b/R/main.R
@@ -0,0 +1,117 @@
+library(pacman)
+
+# Laden und ggf. installieren
+p_load(this.path)
+p_load(readr)
+p_load(lubridate)
+p_load(tidyr)
+p_load(stringr)
+p_load(dplyr)
+p_load(DatawRappr)
+p_load(curl)
+p_load(magick)
+p_load(openxlsx)
+p_load(R.utils)
+
+rm(list=ls())
+
+TEST = TRUE
+DO_PREPARE_MAPS = TRUE
+
+
+
+# Aktuelles Verzeichnis als workdir
+setwd(this.path::this.dir())
+# Aus dem R-Verzeichnis eine Ebene rauf
+setwd("..")
+
+# Logfile anlegen, wenn kein Test
+if (!TEST) {
+  logfile = file("obwahl.log")
+  sink(logfile, append=T)
+  sink(logfile, append=T, type="message")
+
+}
+
+# Messaging-Funktionen einbinden
+source("R/messaging.R")
+
+
+# Hole die Konfiguration und die Index-Daten
+check = tryCatch(
+  { 
+    source("R/lies_konfiguration.R")
+  },
+  warning = function(w) {teams_warning(w,title="OBWAHL: Warnung beim Lesen der Konfigurationsdatei")},
+  error = function(e) {teams_error(e,title="OBWAHL: Konfigurationsdatei nicht gelesen!")})
+
+# Funktionen einbinden
+# Das könnte man auch alles hier in diese Datei schreiben, aber ist es übersichtlicher.
+source("R/lies_aktuellen_stand.R")
+source("R/aktualisiere_karten.R")
+
+#---- MAIN ----
+# Vorbereitung
+gezaehlt <- 0 # Ausgezählte Stimmbezirke
+ts <- as_datetime(startdatum) # ts, Zeitstempel, der letzten gelesenen Daten
+stimmbezirke_n <- nrow(stimmbezirke_df) # Anzahl aller Stimmbezirke bei der Wahl
+alte_daten <- lies_stimmbezirke(stimmbezirke_url) # Leere Stimmbezirke
+# Grafiken einrichten: Farbwerte und Switcher für die Karten
+# Richtet auch die globale Variable switcher ein, deshalb brauchen wir sie
+
+
+if (DO_PREPARE_MAPS) {
+  check = tryCatch(
+    vorbereitung_alle_karten(),
+    warning = function(w) {teams_warning(w,title=paste0(wahl_name,": Vorbereitung"))},
+    error = function(e) {teams_warning(e,title=paste0(wahl_name,": Vorbereitung"))}
+  )
+} else {
+  # Alle Datawrapper-IDs in einen Vektor extrahieren
+  id_df <- config_df %>% 
+    filter(str_detect(name,"_kand[0-9]+")) %>% 
+    mutate(Nummer = as.integer(str_extract(name,"[0-9]+"))) %>% 
+    select(Nummer,dw_id = value)#
+  # Mach aus der Switcher-Tabelle eine globale Variable
+  # Nur die Globale switcher_df definieren (mit den IDs der DW-Karten zum Kandidaten/Farbwert)
+  switcher_df <- kandidaten_df %>% 
+    select(Nummer, Vorname, Name, Parteikürzel, Farbwert) %>% 
+    left_join(id_df, by="Nummer")
+}
+
+# Schleife.
+# Arbeitet so lange, bis alle Wahlbezirke ausgezählt sind. 
+# Als erstes immer einmal durchlaufen lassen. 
+while (gezaehlt < stimmbezirke_n) {
+  check = tryCatch(
+    { # Zeitstempel der Daten holen
+      ts_daten <- check_for_timestamp(stimmbezirke_url)
+    },
+    warning = function(w) {teams_warning(w,title=paste0(wahl_name,": CURL-Polling"))},
+    error = function(e) {teams_warning(e,title=paste0(wahl_name,": CURL-Polling"))}
+  )  
+  # Neuere Daten? 
+  if (ts_daten > ts) {
+    ts <- ts_daten
+    hole_wahldaten()
+  } else {
+    # Logfile erneuern und 15 Sekunden schlafen
+    system("touch obwahl.log")
+    if (TEST) cat("Warte...\n")
+    Sys.sleep(15)
+  }
+}
+# Titel der Grafik "top" umswitchen
+dw_edit_chart(top_id,title="Ergebnis: Wahlsieger")
+dw_publish_chart(top_id)
+
+# Logging beenden
+if (!TEST) {
+  sink()
+  sink(type="message")
+  file.rename("obwahl.log","obwahl_success.log")
+}
+teams_meldung(wahl_name," erfolgreich abgeschlossen.")
+
+
+# EOF
\ No newline at end of file
diff --git a/R/main_oneshot.R b/R/main_oneshot.R
new file mode 100644
index 0000000000000000000000000000000000000000..d4914a895036cd0f446a0b0e3a48dfa802061d8a
--- /dev/null
+++ b/R/main_oneshot.R
@@ -0,0 +1,95 @@
+library(pacman)
+
+# Laden und ggf. installieren
+p_load(this.path)
+p_load(readr)
+p_load(lubridate)
+p_load(tidyr)
+p_load(stringr)
+p_load(dplyr)
+p_load(DatawRappr)
+p_load(curl)
+p_load(magick)
+p_load(openxlsx)
+
+rm(list=ls())
+
+TEST = TRUE
+DO_PREPARE_MAPS = FALSE
+
+
+
+# Aktuelles Verzeichnis als workdir
+setwd(this.path::this.dir())
+# Aus dem R-Verzeichnis eine Ebene rauf
+setwd("..")
+
+# Logfile anlegen, wenn kein Test
+# if (!TEST) {
+#   logfile = file("obwahl.log")
+#   sink(logfile, append=T)
+#   sink(logfile, append=T, type="message")
+#   
+# }
+
+# Messaging-Funktionen einbinden
+source("R/messaging.R")
+
+
+# Hole die Konfiguration und die Index-Daten
+check = tryCatch(
+  { 
+    source("R/lies_konfiguration.R")
+  },
+  warning = function(w) {teams_warning(w,title="OBWAHL: Warnung beim Lesen der Konfigurationsdatei")},
+  error = function(e) {teams_error(e,title="OBWAHL: Konfigurationsdatei nicht gelesen!")})
+
+# Funktionen einbinden
+# Das könnte man auch alles hier in diese Datei schreiben, aber ist es übersichtlicher.
+source("R/lies_aktuellen_stand.R")
+source("R/aktualisiere_karten.R")
+
+#---- MAIN ----
+# Vorbereitung
+gezaehlt <- 0 # Ausgezählte Stimmbezirke
+ts <- as_datetime(startdatum) # ts, Zeitstempel, der letzten gelesenen Daten
+stimmbezirke_n <- nrow(stimmbezirke_df) # Anzahl aller Stimmbezirke bei der Wahl
+alte_daten <- lies_stimmbezirke(stimmbezirke_url) # Leere Stimmbezirke
+# Grafiken einrichten: Farbwerte und Switcher für die Karten
+# Richtet auch die globale Variable switcher ein, deshalb brauchen wir sie
+
+
+if (DO_PREPARE_MAPS) {
+  check = tryCatch(
+    vorbereitung_alle_karten(),
+    warning = function(w) {teams_warning(w,title=paste0(wahl_name,": Vorbereitung"))},
+    error = function(e) {teams_warning(e,title=paste0(wahl_name,": Vorbereitung"))}
+  )
+} else {
+  # Alle Datawrapper-IDs in einen Vektor extrahieren
+  id_df <- config_df %>% 
+    filter(str_detect(name,"_kand[0-9]+")) %>% 
+    mutate(Nummer = as.integer(str_extract(name,"[0-9]+"))) %>% 
+    select(Nummer,dw_id = value)#
+  # Mach aus der Switcher-Tabelle eine globale Variable
+  # Nur die Globale switcher_df definieren (mit den IDs der DW-Karten zum Kandidaten/Farbwert)
+  switcher_df <- kandidaten_df %>% 
+    select(Nummer, Vorname, Name, Parteikürzel, Farbwert) %>% 
+    left_join(id_df, by="Nummer")
+}
+
+# One-shot. 
+check = tryCatch(
+    { # Zeitstempel der Daten holen
+      ts_daten <- check_for_timestamp(stimmbezirke_url)
+    },
+    warning = function(w) {teams_warning(w,title=paste0(wahl_name,": CURL-Polling"))},
+    error = function(e) {teams_warning(e,title=paste0(wahl_name,": CURL-Polling"))}
+  )  
+    # Zeitstempel aktualisieren, Datenverarbeitung anstoßen
+ts <- ts_daten
+# Hole die neuen Daten
+
+hole_wahldaten()
+
+# EOF
\ No newline at end of file
diff --git a/R/messaging.R b/R/messaging.R
index b2b93b0e1470153dc8f474648344f2546ad7dfc2..77807cabdd5cdf51c44314c1398d25a8c7c75a46 100644
--- a/R/messaging.R
+++ b/R/messaging.R
@@ -9,15 +9,18 @@ library(teamr)
 #' 
 #' Kommunikation mit Teams
 #' 
-#' Webhook wird als URL im Environment gespeichert. Wenn nicht dort, dann 
+#' Webhook wird als URL im Environment gespeichert. Wenn nicht dort, dann Datei im Nutzerverzeichnis ~/key/ einlesen.
+#' MSG-Funktion schreibt alles in die Logdatei und auf den Bildschirm. (Vgl. Corona.)
+
+
 
 # Webhook schon im Environment? 
-if (Sys.getenv("WEBHOOK_REFERENDUM") == "") {
-  t_txt <- read_file("../key/webhook_referendum.key")
+if (Sys.getenv("WEBHOOK_OBWAHL") == "") {
+  t_txt <- read_file("~/key/webhook_obwahl.key")
   Sys.setenv(WEBHOOK_REFERENDUM = t_txt)
 }
 
-teams_meldung <- function(...,title="Feldmann-Update") {
+teams_meldung <- function(...,title="OB-Wahl-Update") {
   cc <- teamr::connector_card$new(hookurl = t_txt)
   cc$title(paste0(title," - ",lubridate::with_tz(lubridate::now(),
                                                  "Europe/Berlin")))
@@ -29,13 +32,13 @@ teams_meldung <- function(...,title="Feldmann-Update") {
 
 teams_error <- function(...) {
   alert_str <- paste0(...)
-  teams_meldung(title="Feldmann: FEHLER: ", ...)
+  teams_meldung(title="OB-Wahl: FEHLER: ", ...)
   stop(alert_str)
 } 
 
 teams_warning <- function(...) {
   alert_str <- paste0(...)
-  teams_meldung("Feldmann: WARNUNG: ",...)
+  teams_meldung("OB-Wahl: WARNUNG: ",...)
   warning(alert_str)
 } 
 
diff --git a/R/update_all.R b/R/update_all.R
deleted file mode 100644
index e0dd5d6d73505c1a8d37304dcf8eb2236688b0f4..0000000000000000000000000000000000000000
--- a/R/update_all.R
+++ /dev/null
@@ -1,203 +0,0 @@
-library(pacman)
-
-# Laden und ggf. installieren
-p_load(this.path)
-p_load(readr)
-p_load(lubridate)
-p_load(tidyr)
-p_load(stringr)
-p_load(dplyr)
-p_load(DatawRappr)
-
-rm(list=ls())
-
-# Aktuelles Verzeichnis als workdir
-setwd(this.path::this.dir())
-# Aus dem R-Verzeichnis eine Ebene rauf
-setwd("..")
-
-
-source("R/messaging.R")
-source("R/lies_aktuellen_stand.R")
-source("R/aktualisiere_karten.R")
-source("R/generiere_balken.R")  
-
-
-#----aktualisiere_fom() ----
-# fom ist das "Feldmann-o-meter", die zentrale Grafik mit dem Stand der Auszählung.
-
-aktualisiere_fom <- function(wl_url = stimmbezirke_url) {
-  
-  # Einlesen: Feldmann-o-meter-Daten so far. 
-  # Wenn die Daten noch nicht existieren, generiere ein leeres df. 
-  if(file.exists("daten/fom_df.rds")) {
-    fom_df <- readRDS("daten/fom_df.rds")
-  } else {
-    # Leeres df mit einer Zeile
-    fom_df <- tibble(zeitstempel = as_datetime(startdatum),
-                           meldungen_anz = 0,
-                           meldungen_max = 575,
-                           # Ergebniszellen
-                           wahlberechtigt = 0,
-                           # Mehr zum Wahlschein hier: https://www.bundeswahlleiter.de/service/glossar/w/wahlscheinvermerk.html
-                           waehler_regulaer = 0,
-                           waehler_wahlschein = 0,
-                           waehler_nv = 0,
-                           stimmen = 0,
-                           stimmen_wahlschein = 0, 
-                           ungueltig = 0,
-                           gueltig = 0,
-                           ja = 0,
-                           nein = 0)
-    # SAVE kann man sich schenken; df ist schneller neu erzeugt
-    # save(feldmann_df,"daten/feldmann_df.rda")
-  }
-  # Daten zur Sicherheit sortieren, dann die letzte  Zeile rausziehen
-  letzte_fom_df <- fom_df %>% 
-    arrange(zeitstempel) %>% 
-    tail(1)
-  # Neue Daten holen (mit Fehlerbehandlung)
-  stimmbezirke_df <- lies_gebiet(wl_url) 
-  neue_fom_df <- stimmbezirke_df %>% 
-    # Namen raus
-    select(-name,-nr) %>% 
-    # Daten aufsummieren
-    summarize(zeitstempel = last(zeitstempel),
-      across(2:ncol(.), ~ sum(.,na.rm=T)))
-  # Alte und neue Daten identisch? Dann brich ab. 
-  if (vergleiche_stand(letzte_fom_df,neue_fom_df)) {
-    return(FALSE)
-  } else {
-    # Archiviere die Rohdaten 
-    archiviere(stimmbezirke_df,"daten/stimmbezirke/")
-    # Ergänze das fom_df um die neuen Daten und sichere es
-    fom_df <- fom_df %>% bind_rows(neue_fom_df)
-    saveRDS(fom_df,"daten/fom_df.rds")
-    # Bilde das Dataframe
-    # Sende die Daten an Datawrapper und aktualisiere
-    fom_dw_df <- fom_df %>% 
-      mutate(ausgezählt = meldungen_anz / meldungen_max *100) %>% 
-      mutate(prozent30 = NA) %>% 
-      mutate(quorum = ja / wahlberechtigt * 100) %>% 
-      select(ausgezählt, wahlberechtigt, ungueltig, ja, nein, quorum, prozent30) %>% 
-      # Noch den Endpunkt der 30-Prozent-Linie
-      bind_rows(tibble(ausgezählt = 100, prozent30 = ffm_waehler * 0.3))
-    dw_data_to_chart(fom_dw_df,fom_id)
-    # Parameter setzen
-    alles_ausgezählt <- (neue_fom_df$meldungen_max == neue_fom_df$meldungen_anz)
-    if (neue_fom_df$meldungen_anz == 0) {
-      quorum = 0
-      feldmann_str <- "Es liegen noch keine Auszählungsdaten des Bürgerentscheids vor."
-    } else {
-      quorum <- (neue_fom_df$ja / neue_fom_df$wahlberechtigt * 100)
-      if (quorum >= 30) {
-        if (alles_ausgezählt ) {
-          feldmann_str <- "Peter Feldmann ist als OB abgewählt."
-        } else {
-          feldmann_str <- "Nach dem derzeitigen Auszählungsstand wäre Peter Feldmann als OB abgewählt."
-        }
-      } else {
-        if (alles_ausgezählt ) {
-          feldmann_str <- "Peter Feldmann bleibt OB von Frankfurt."
-        } else {
-          feldmann_str <- "Nach dem derzeitigen Auszählungsstand bliebe Peter Feldmann OB von Frankfurt."
-        }
-      }
-    }
-    
-    # Breite des Balkens: Wenn das Quorum erreicht ist, hat er die volle Breite,
-    # wenn nicht, einen Anteil von 30%, um die Entfernung von der Markierung zu zeigen
-    
-    # Jetzt die Beschreibungstexte mit den Fake-Balkengrafiken generieren
-    beschreibung_str <- paste0(
-      "Die Abwahl ist beschlossen, wenn mindestens 30 Prozent aller Wahlberechtigten mit &quot;Ja&quot; stimmen.<br/><br>",
-      "<b style='font-weight:700;font-size:120%;'>",
-      # Erste dynamisch angepasste Textstelle: Bleibt Feldmann?
-      feldmann_str,
-      "</b><br/><br>",
-      generiere_balken(wb = neue_fom_df$wahlberechtigt,
-                       ja = neue_fom_df$ja,
-                       nein = neue_fom_df$nein,
-                       auszählung_beendet = alles_ausgezählt))
-    annotate_str <- generiere_auszählungsbalken(
-      ausgezählt = floor(neue_fom_df$wahlberechtigt / ffm_waehler * 100),
-      anz = neue_fom_df$meldungen_anz,
-      max = neue_fom_df$meldungen_max,
-      ts = neue_fom_df$zeitstempel)
-    briefwahl_anz <- stimmbezirke_df %>% filter(str_detect(nr,"^9")) %>% 
-      pull(meldungen_anz) %>% sum()
-    briefwahl_max <- stimmbezirke_df %>% filter(str_detect(nr,"^9")) %>% 
-      nrow()
-    annotate_str <- paste0("<strong>Derzeit sind ",
-                           briefwahl_anz,
-                           " von ",
-                           briefwahl_max, 
-                           " Briefwahl-Stimmbezirken ausgezählt.</strong><br/><br/>",
-                           annotate_str)
-    dw_edit_chart(fom_id,intro = beschreibung_str,annotate = annotate_str)
-    dw_publish_chart(fom_id)
-    return(TRUE)
-  }
-}
-
-
-#---- MAIN ----
-# Ruft aktualisiere_fom() auf
-# (die dann wieder aktualisiere_karten() aufruft)
-check = tryCatch(
-  { 
-    neue_daten <- aktualisiere_fom(stimmbezirke_url)
-  },
-  warning = function(w) {teams_warning(w,title="Feldmann: fom")},
-  error = function(e) {teams_warning(e,title="Feldmann: fom")})
-# Neue Daten? Dann aktualisiere die Karten
-if (neue_daten) {
-  check = tryCatch(
-    { 
-      neue_daten <- aktualisiere_karten(stimmbezirke_url)
-    },
-    warning = function(w) {teams_warning(w,title="Feldmann: Karten")},
-    error = function(e) {teams_warning(e,title="Feldmann: Karten")})
-  if (neue_daten) {
-    # Alles OK, letzte Daten nochmal holen und ausgeben
-    fom_df <- readRDS("daten/fom_df.rds") %>% 
-      arrange(zeitstempel) %>% 
-      tail(1)
-    if(fom_df$meldungen_anz > 0) {
-      stimmbezirke_df <- lies_gebiet(stimmbezirke_url)
-      briefwahl_anz <- stimmbezirke_df %>% filter(str_detect(nr,"^9")) %>% 
-        pull(meldungen_anz) %>% sum()
-      briefwahl_max <- stimmbezirke_df %>% filter(str_detect(nr,"^9")) %>% 
-        nrow()
-      fom_update_str <- paste0(
-        "<strong>Update OK</strong><br/><br/>",
-        fom_df$meldungen_anz,
-        " von ",
-        fom_df$meldungen_max," Stimmbezirke ausgezählt.<br> ",
-                              "Derzeit sind ",
-                               briefwahl_anz,
-                               " von ",
-                               briefwahl_max, 
-                               " Briefwahl-Stimmbezirken ausgezählt.<br/>",
-        "<ul><li><strong>Quorum zur Abwahl ist derzeit",
-        ifelse(fom_df$ja / fom_df$wahlberechtigt < 0.3, " nicht ", " "),
-        "erreicht</strong></li>",
-        "<li><strong>Anteil der Ja-Stimmen an den Wahlberechtigten: ",
-        format(fom_df$ja / fom_df$wahlberechtigt * 100,decimal.mark=",",big.mark=".",nsmall=1, digits=3),"%",
-        "</li><li>Ja-Stimmen: ",
-        format(fom_df$ja,decimal.mark=",",big.mark="."),
-        "</li><li>Nein-Stimmen: ",
-        format(fom_df$nein,decimal.mark=",",big.mark="."),
-        "</li><li>Verhältnis Ja:Nein: ",
-        format(fom_df$ja / (fom_df$ja + fom_df$nein) * 100,decimal.mark=",",big.mark=".",nsmall=1, digits=3),"% : ",
-        format(fom_df$nein / (fom_df$ja + fom_df$nein) *100,decimal.mark=",",big.mark=".",nsmall=1, digits=3),"%</li></ul>"
-        
-      )
-      teams_meldung(fom_update_str,title="Feldmann-Referendum")
-
-    }
-  } else {
-    teams_warning("Neue Stimmbezirk-Daten, aber keine neuen Ortsdaten?")
-  }
-} 
-# Auch hier TRUE zurückbekommen;; alles OK?
\ No newline at end of file
diff --git a/README.md b/README.md
index b56eda9e4e778d815d162f3c68c3a50b71e370c7..f74ad16c0f3903e3067922758129d3ca147f8952 100644
--- a/README.md
+++ b/README.md
@@ -1,27 +1,25 @@
 # obwahlen PRE
 
-**DIES IST IM AUGENBLICK NUR EINE NOCH NICHT ANGEPASSTE KOPIE DES REFERENDUMS-CODES** - bitte nicht nutzen und wundern! Anpassung spätestens zur [1. Runde der OB-Wahl in Frankfurt am 5. März 2023](https://frankfurt.de/aktuelle-meldung/meldungen/direktwahl-oberbuergermeisterin-oberbuergermeister-frankfurt/). 
-
 R-Code, um den Auszählungsstand hessischer Bürgermeisterwahlen in Echtzeit abzurufen und mit Datawrapper darzustellen
 
 ## Ordnerstruktur
 
 - **R** enthält den Code
-- **index** enthält Index-, Konfigurations-, und Template-Dateien
+- **index** enthält die Konfigurationsdatei index.csv und Unterordner mit den Indexdateien: Kandidaten, Stadtteile, Stimmbezirke, Datawrapper-Zuordnungen. 
 - **daten** wird vom Code beschrieben und enthält den aktuellen Datenstand.
 
 ## Daten aufarbeiten
 
 ### Ziele
 
-Folgende Grafiken wären denkbar: 
-* Balkengrafik Ergebnis nach derzeitigem Auszählungsstand mit "Fortschrittsbalken"
-* Choropleth Stadtteil-Sieger
+Grafiken: 
+* Säulengrafik erste fünf; Ergebnis nach derzeitigem Auszählungsstand mit "Fortschrittsbalken"
+
+* Balkengrafik alle
+* Choropleth Stadtteil-Sieger (mit Switcher alle, die gewonnen haben)
 * Choropleth Ergebnis nach Kandidat
-* Choropleth Wahlbeteiligung
-* Choropleth Briefwahl
+* Tabelle nach Kandidaten (3 beste, 3 schlechteste Stadtteile)
 * Tabelle nach Stadtteil 
-* Tabelle nach Kandidaten (Erste drei? fünf?)
 
 ### Konfiguration
 
@@ -41,15 +39,20 @@ Aggregation auf Stadtebene
 - Fortschrittsbalken ausgezählte Stimmen (mit akt. Briefwahlstimmendaten)
 
 
-## Struktur des Codes
+## Struktur des Codes: Was tut was?
+
+(siehe ["Sitemap"](./sitemap.md) für den Code)
+
+
+# TODO
 
-### Hauptroutinen
 
-- **update_all.R** ist das Skript für den CRON-Job. Es pollt nach Daten, ruft die Abruf-, Aggregations- und Auswertungsfunktionen auf und gibt Meldungen aus. 
-- **lies_aktuellen_stand.R** enthält Funktionen, die die Daten lesen, aggregieren und archivieren
-- **aktualisiere_karten.R** enthält die Funktionen zur Datenausgabe
-- **messaging.R** enthält Funktionen, die Teams-Updates und -Fehlermeldungen generieren
+- Upload aufs Repository
 
-### Hilfsfunktionen
+## NTH
 
-- **generiere_testdaten.R** ist ein Skript, das zufällige, aber plausible CSV-Daten auf Stimmbezirks-Ebene zum Testen generiert
+- Umschalten Top5-Titel Ergebnis
+- Zusatzfeature: Briefwahlprognostik - wieviele Stimmen fehlen vermutlich noch?
+- Shapefiles KS, DA verbessern
+- Datensparsamere Alternativ-CURL-Poll-Datei (zB mit dem Gesamtergebnis)
+- Mehr Licht in den Choropleth-Karten farbabhängig
diff --git a/howto_shapefiles.md b/howto_shapefiles.md
new file mode 100644
index 0000000000000000000000000000000000000000..b60bcfdb77d78f2857ed9873e4595b74e196b904
--- /dev/null
+++ b/howto_shapefiles.md
@@ -0,0 +1,26 @@
+1. Shapefile in QGIS importieren 
+
+2. GEOJSON im richtigen Koordinatensystem erstellen
+Dazu Rechtsklick auf den Layer; Koordinatensystem WGS84, exportieren
+
+3. Stadtteile generieren
+Menü "Vektor", "Geometrieverarbeitungswerkzeuge", "Auflösen" - und dann in der Dialogbox auswählen "Felder auflösen [optional]", und dann die Attribute hinzufügen, nach denen zusammengeführt werden soll. 
+
+In KS beispielsweise gab es die 
+
+
+- Rechtsklick auf den Layer; Exportieren als GEOJSON - nicht vergessen, das Bezugssystem auf WGS84 umzustellen!
+- Rechtsklick auf den Layer; Export als XLSX - ggf. Geo-Attribute abschalten
+
+4. Mittelpunkte der Stadtteile
+
+Menü "Vektor", "Geometrie-Werkzeuge", "Zentroide"
+
+Dann noch Geokoordinaten der Zentroidpunkte: Rechte Seite die Toolbox, dort "Vektortabelle" aufklappen, "X/Y-Felder zu Attributen hinzufügen"
+
+- Rechtsklick auf den neu erzeugten Layer, exportieren als XLSX bzw CSV
+
+5. CSV-/XLSX-Dateien putzen
+
+- Brauchen eine Stadtteil-Datei mit nr,name,lon,lat (erzeugt aus den Zentroiden)
+- Brauchen einen Wahlbezirks-Zuordnung
diff --git a/index/config.csv b/index/config.csv
index 04090781806af46ccd34af31eaf562a4a8cb9201..45880e515f46a13f9512ce861df01a77b3ac9e85 100644
--- a/index/config.csv
+++ b/index/config.csv
@@ -1,8 +1,23 @@
 name,value,comment
-stimmbezirke_url,https://votemanager-ffm.ekom21cdn.de/2022-11-06/06412000/praesentation/Open-Data-06412000-Buergerentscheid-zur-Abwahl-des-Oberbuergermeisters-der-Stadt-Frankfurt-am-Main_-Herrn-Peter-Feldmann-Stimmbezirk.csv?ts=1667662273015,URL Daten-CSV Stimmbezirke
-ffm_waehler,508182,Wahlamt
-fom_id,bIm87,Datawrapper-ID Feldmann-o-meter
-choropleth_id,UwKOO,Datawrapper-ID Stadtteile Choropleth-Karte
-symbol_id,RWqrf,Datawrapper-ID Stadtteile Symbole (absolute Stimmen)
-tabelle_id,hLqMi,Datawrapper-ID Tabelle Stadtteile
-startdatum,2022-11-06 18:00:00 CET,Beginn der Auszählung
+wahl_name,obwahl_ks_2023,Welche Wahl?
+stimmbezirke_url,https://votemanager-ks.ekom21cdn.de/2023-03-12/06611000/daten/opendata/Open-Data-06611000-Direktwahl-zur-Oberbuergermeisterin-zum-Oberbuergermeister-Wahlbezirk.csv?ts=1678486050153,URL Daten-CSV Stimmbezirke
+wahlberechtigt,147463,Anzahl Wahlberechtigte lt. Wahlamt (kommt Sonntag)
+briefwahl,39092,Anzahl Briefwahlstimmen lt. Wahlamt (kommt Sonntag)
+top,6,Anzahl der Top-Kandidaten in den Darstellungen
+kandidaten_fname,kandidaten.xlsx,"XLSX oder CSV, wird im Ordner <wahl_name> erwartet"
+zuordnung_fname,wahlbezirke.xlsx,"XLSX oder CSV, wird im Ordner <wahl_name> erwartet"
+stadtteile_fname,stadtteile.csv,"XLSX oder CSV, wird im Ordner <wahl_name> erwartet"
+startdatum,2023-03-12 16:00:00,Beginn der Auszählung
+top_id,Ts1oS,
+karte_sieger_id,O9wPT,
+karte_kand1_id,hM9SE,Schöller
+karte_kand2_id,07CR4,Carqueville
+karte_kand3_id,whgzp,Kühne-Hörmann
+karte_kand4_id,5CpYu,Bock
+karte_kand5_id,pc6vH,Käufler
+karte_kand6_id,sEJhl,Geselle
+tabelle_alle_id,EQ4dd,
+hochburgen_id,GMTSJ,
+tabelle_stadtteile_id,q7yjs,
+social1_id,Ts1oS,5 stärkste
+social2_id,S9BbQ,Alle Stimmen angepasst
diff --git a/index/config_test.csv b/index/config_test.csv
new file mode 100644
index 0000000000000000000000000000000000000000..8c2e436731078e59a1d0619b113f72e81e26311d
--- /dev/null
+++ b/index/config_test.csv
@@ -0,0 +1,23 @@
+name,value,comment
+wahl_name,obwahl_ks_2023,Welche Wahl?
+stimmbezirke_url,https://www.eggers-elektronik.de/files/test.csv,URL Daten-CSV Stimmbezirke
+wahlberechtigt,147463,Anzahl Wahlberechtigte lt. Wahlamt (kommt Sonntag)
+briefwahl,39092,Anzahl Briefwahlstimmen lt. Wahlamt (kommt Sonntag)
+kandidaten_fname,kandidaten.xlsx,"XLSX oder CSV, wird im Ordner <wahl_name> erwartet"
+zuordnung_fname,wahlbezirke.xlsx,"XLSX oder CSV, wird im Ordner <wahl_name> erwartet"
+stadtteile_fname,ks-stadtteile.csv,"XLSX oder CSV, wird im Ordner <wahl_name> erwartet"
+startdatum,2023-01-01 18:00:00 CET,Beginn der Auszählung
+top,6,
+top_id,028Fp,
+karte_sieger_id,7gscI,
+karte_kand1_id,hM9SE,Schöller
+karte_kand2_id,07CR4,Carqueville
+karte_kand3_id,whgzp,Kühne-Hörmann
+karte_kand4_id,5CpYu,Bock
+karte_kand5_id,pc6vH,Käufler
+karte_kand6_id,sEJhl,Geselle
+tabelle_alle_id,PLwHI,
+hochburgen_id,Im2PX,
+tabelle_stadtteile_id,BM8kD,
+social1_id,028Fp,5 stärkste
+social2_id,S9BbQ,Alle Stimmen angepasst
diff --git a/index/ffm_config.csv b/index/ffm_config.csv
new file mode 100644
index 0000000000000000000000000000000000000000..2f97d66ece55a4ae2800db52ad5c9972853d3f90
--- /dev/null
+++ b/index/ffm_config.csv
@@ -0,0 +1,37 @@
+name,value,comment
+wahl_name,obwahl_ffm_2023,Welche Wahl?
+stimmbezirke_url,https://votemanager-ffm.ekom21cdn.de/2023-03-05/06412000/daten/opendata/Open-Data-06412000-OB-Wahl-Wahlbezirk.csv?ts=1677904123448,URL Daten-CSV Stimmbezirke
+wahlberechtigt,508182,Anzahl Wahlberechtigte lt. Wahlamt (kommt Sonntag)
+briefwahl,250000,Anzahl Briefwahlstimmen lt. Wahlamt (kommt Sonntag)
+kandidaten_fname,kandidaten.xlsx,"XLSX oder CSV, wird im Ordner <wahl_name> erwartet"
+datawrapper_fname,datawrapper.xlsx,"XLSX oder CSV, wird im Ordner <wahl_name> erwartet"
+zuordnung_fname,zuordnung_wahllokale.csv,"XLSX oder CSV, wird im Ordner <wahl_name> erwartet"
+stadtteile_fname,stadtteile.csv,"XLSX oder CSV, wird im Ordner <wahl_name> erwartet"
+startdatum,2023-03-05 16:00:00,Beginn der Auszählung
+top5_id,2DYBQ,
+karte_sieger_id,ANKmx,
+karte_kand1_id,RcvQp,Rottmann (Grüne)
+karte_kand2_id,jrm2v,Becker (CDU)
+karte_kand3_id,bKR8r,Josef (SPD)
+karte_kand4_id,etN3J,Mehler-Würzbach (Linke)
+karte_kand5_id,3mydT,Pürsün (FDP)
+karte_kand6_id,K3aCw,Lobenstein (AfD)
+karte_kand7_id,vtG4Y,Pfeiffer (BFF)
+karte_kand8_id,tRHeI,Tanczos (PARTEI)
+karte_kand9_id,v4Y5m,Schwichtenberg (Gartenpartei)
+karte_kand10_id,g3iBN,Wirth (unabh.)
+karte_kand11_id,4LxcN,Camara (FPF)
+karte_kand12_id,RZDF7,Pauli (unabh.)
+karte_kand13_id,F86gf,Junghans (unabh.)
+karte_kand14_id,bLPXL,Xu (unabh.)
+karte_kand15_id,Ktufa,Wolff (unabh.)
+karte_kand16_id,MO41j,Akhtar (Todenhöfer)
+karte_kand17_id,ccrfL,Großenbach (Basis)
+karte_kand18_id,q2S6m,Pawelski (unabh.)
+karte_kand19_id,697CL,Schulte (unabh.)
+karte_kand20_id,3lMmu,Eulig (unabh.)
+tabelle_alle_id,7kRPR,
+hochburgen_id,oB3KH,
+tabelle_stadtteile_id,LiXnz,
+social1_id,2DYBQ,5 stärkste
+social2_id,S9BbQ,Alle Stimmen angepasst
diff --git a/index/ffm_config_test.csv b/index/ffm_config_test.csv
new file mode 100644
index 0000000000000000000000000000000000000000..07d5d1ab78591ee0f2e7376fb6d28c043eb752fa
--- /dev/null
+++ b/index/ffm_config_test.csv
@@ -0,0 +1,37 @@
+name,value,comment
+wahl_name,obwahl_ffm_2023,Welche Wahl?
+stimmbezirke_url,testdaten/dummy.csv,URL Daten-CSV Stimmbezirke
+wahlberechtigt,508182,Anzahl Wahlberechtigte lt. Wahlamt (kommt Sonntag)
+briefwahl,250000,Anzahl Briefwahlstimmen lt. Wahlamt (kommt Sonntag)
+kandidaten_fname,kandidaten.xlsx,"XLSX oder CSV, wird im Ordner <wahl_name> erwartet"
+datawrapper_fname,datawrapper.xlsx,"XLSX oder CSV, wird im Ordner <wahl_name> erwartet"
+zuordnung_fname,zuordnung_wahllokale.csv,"XLSX oder CSV, wird im Ordner <wahl_name> erwartet"
+stadtteile_fname,stadtteile.csv,"XLSX oder CSV, wird im Ordner <wahl_name> erwartet"
+startdatum,2023-01-01 18:00:00 CET,Beginn der Auszählung
+top5_id,028Fp,
+karte_sieger_id,7gscI,
+karte_kand1_id,hM9SE,Rottmann (Grüne)
+karte_kand2_id,hM9SE,Becker (CDU)
+karte_kand3_id,07CR4,Josef (SPD)
+karte_kand4_id,07CR4,Mehler-Würzbach (Linke)
+karte_kand5_id,07CR4,Pürsün (FDP)
+karte_kand6_id,07CR4,Lobenstein (AfD)
+karte_kand7_id,07CR4,Pfeiffer (BFF)
+karte_kand8_id,07CR4,Tanczos (PARTEI)
+karte_kand9_id,07CR4,Schwichtenberg (Gartenpartei)
+karte_kand10_id,07CR4,Wirth (unabh.)
+karte_kand11_id,07CR4,Camara (FPF)
+karte_kand12_id,07CR4,Pauli (unabh.)
+karte_kand13_id,07CR4,Junghans (unabh.)
+karte_kand14_id,07CR4,Xu (unabh.)
+karte_kand15_id,07CR4,Wolff (unabh.)
+karte_kand16_id,07CR4,Akhtar (Todenhöfer)
+karte_kand17_id,07CR4,Großenbach (Basis)
+karte_kand18_id,07CR4,Pawelski (unabh.)
+karte_kand19_id,07CR4,Schulte (unabh.)
+karte_kand20_id,07CR4,Eulig (unabh.)
+tabelle_alle_id,PLwHI,
+hochburgen_id,Im2PX,
+tabelle_stadtteile_id,BM8kD,
+social1_id,028Fp,5 stärkste
+social2_id,S9BbQ,Alle Stimmen angepasst
diff --git a/index/obwahl_ffm_2023/kandidaten.xlsx b/index/obwahl_ffm_2023/kandidaten.xlsx
new file mode 100644
index 0000000000000000000000000000000000000000..11bdb590d825bbe4cd94aab1bb12e0e987c7a954
Binary files /dev/null and b/index/obwahl_ffm_2023/kandidaten.xlsx differ
diff --git a/index/obwahl_ffm_2023/stadtteile.csv b/index/obwahl_ffm_2023/stadtteile.csv
new file mode 100644
index 0000000000000000000000000000000000000000..b07902a0b1c6a861a2489ce79713d62c611f502f
--- /dev/null
+++ b/index/obwahl_ffm_2023/stadtteile.csv
@@ -0,0 +1,45 @@
+nr,name,lon,lat
+1,Altstadt,8.682385346400634,50.11059669873516
+2,Innenstadt,8.682664888207869,50.113790989177375
+4,Westend-Süd,8.6594393465439925,50.11682467903111
+5,Westend-Nord,8.666488952498467,50.128769620533795
+6,Nordend-West,8.684596043386934,50.13022858244322
+7,Nordend-Ost,8.69761974358955,50.127318654892264
+8,Ostend,8.719218276147204,50.11554639352114
+9,Bornheim,8.712407551447747,50.13090801018288
+10,Gutleut- und Bahnhofsviertel,8.652137942960298,50.099479845695974
+11,Gallus,8.636377745265355,50.10300477630784
+12,Bockenheim,8.632922516089874,50.12128753657858
+13,Sachsenhausen-Nord,8.684579993638577,50.10051804775371
+14,Sachsenhausen-Süd und Flughafen,8.629663957345496,50.0607635296547
+16,Oberrad,8.727138168461476,50.09922899999497
+17,Niederrad,8.636199605275262,50.081631798202295
+18,Schwanheim,8.572652070944704,50.081760019105545
+19,Griesheim,8.600109548586545,50.09781734654457
+20,Rödelheim,8.603076601631098,50.127692431637506
+21,Hausen,8.626134516198546,50.13524298077671
+22,Praunheim,8.61444644716483,50.14547905112678
+24,Heddernheim,8.64020132453368,50.158128239125205
+25,Niederursel,8.616911198776547,50.16683966510584
+26,Ginnheim,8.648134546192246,50.14388748058928
+27,Dornbusch,8.670541998003081,50.14434313041997
+28,Eschersheim,8.659950213724542,50.16002839395001
+29,Eckenheim,8.683795236784233,50.148564823086005
+30,Preungesheim,8.697198159142667,50.15544843144313
+31,Bonames,8.665887880254154,50.18258113675648
+32,Berkersheim,8.702941786636124,50.17015956481773
+33,Riederwald,8.73274589886058,50.12667040584185
+34,Seckbach,8.726644066440096,50.147246840458955
+35,Fechenheim,8.762275115113775,50.12551773891441
+36,Höchst,8.539657322936813,50.098523172532
+37,Nied,8.57676379509509,50.103479453362766
+38,Sindlingen,8.51273746688725,50.07800492013246
+39,Zeilsheim,8.495768400332896,50.097784690964886
+40,Unterliederbach,8.525490184772172,50.10992510336304
+41,Sossenheim,8.574019745075416,50.12010605434964
+42,Nieder-Erlenbach,8.709219115856458,50.20871646737095
+43,Kalbach-Riedberg,8.639008245521376,50.1846309062546
+44,Harheim,8.689844680792895,50.18583895746724
+45,Nieder-Eschbach,8.668094243977997,50.20106152063871
+46,Bergen-Enkheim,8.766772308170399,50.15913246211432
+47,Frankfurter Berg,8.673427563622,50.169778678198924
diff --git a/index/zuordnung_wahllokale.csv b/index/obwahl_ffm_2023/zuordnung_wahllokale.csv
similarity index 100%
rename from index/zuordnung_wahllokale.csv
rename to index/obwahl_ffm_2023/zuordnung_wahllokale.csv
diff --git a/index/obwahl_ks_2023/kandidaten.xlsx b/index/obwahl_ks_2023/kandidaten.xlsx
new file mode 100644
index 0000000000000000000000000000000000000000..e10b674797487ee43e2033972e604fe8f5e8c83b
Binary files /dev/null and b/index/obwahl_ks_2023/kandidaten.xlsx differ
diff --git "a/index/obwahl_ks_2023/ks-stadtteile-mit-d\303\266nchelandschaft.csv" "b/index/obwahl_ks_2023/ks-stadtteile-mit-d\303\266nchelandschaft.csv"
new file mode 100644
index 0000000000000000000000000000000000000000..55d79178ae67d8e7dfaf41be60ed3a8967e9e4d9
--- /dev/null
+++ "b/index/obwahl_ks_2023/ks-stadtteile-mit-d\303\266nchelandschaft.csv"
@@ -0,0 +1,25 @@
+nr,name,lon,lat
+1,Mitte,9.4894928677,51.3151211858
+2,Südstadt,9.4882009490,51.3005087449
+3,Vorderer Westen,9.4640787357,51.3163544480
+4,Wehlheiden,9.4654088136,51.3056032748
+5,Bad Wilhelmshöhe,9.4011506791,51.3116605680
+6,Brasselsberg,9.3972256665,51.2912775844
+7,Süsterfeld-Helleböhn,9.4469292484,51.2964342813
+8,Harleshausen,9.4151173612,51.3391059157
+9,Kirchditmold,9.4391198473,51.3249890808
+10,Rothenditmold,9.4771802265,51.3287075207
+11,Nord-Holland,9.4937754140,51.3323742823
+12,Philippinenhof-Warteberg,9.4941950806,51.3457423064
+13,Fasanenhof,9.5123139521,51.3347662999
+14,Wesertor,9.5148784085,51.3221887268
+15,Wolfsanger-Hasenhecke,9.5457989433,51.3396806416
+16,Bettenhausen,9.5384097499,51.3044330002
+17,Forstfeld,9.5418612035,51.2915878853
+18,Waldau,9.5100487565,51.2868080527
+19,Niederzwehren,9.4709628876,51.2783155707
+20,Oberzwehren,9.4443563894,51.2747255123
+21,Nordshausen,9.4245341169,51.2790202220
+22,Jungfernkopf,9.4607713668,51.3409314760
+23,Unterneustadt,9.5160276635,51.3119155114
+25,Dönchelandschaft (ortsbezirksfrei),9.4320779328,51.2918551834
diff --git a/index/obwahl_ks_2023/ks-stadtteile.csv b/index/obwahl_ks_2023/ks-stadtteile.csv
new file mode 100644
index 0000000000000000000000000000000000000000..ddb583ea89f10e3957368e62487ab0c38976dd13
--- /dev/null
+++ b/index/obwahl_ks_2023/ks-stadtteile.csv
@@ -0,0 +1,24 @@
+nr,name,lon,lat
+1,Mitte,9.4894928677,51.3151211858
+2,Südstadt,9.4882009490,51.3005087449
+3,Vorderer Westen,9.4640787357,51.3163544480
+4,Wehlheiden,9.4654088136,51.3056032748
+5,Bad Wilhelmshöhe,9.4011506791,51.3116605680
+6,Brasselsberg,9.3972256665,51.2912775844
+7,Süsterfeld-Helleböhn,9.4469292484,51.2964342813
+8,Harleshausen,9.4151173612,51.3391059157
+9,Kirchditmold,9.4391198473,51.3249890808
+10,Rothenditmold,9.4771802265,51.3287075207
+11,Nord-Holland,9.4937754140,51.3323742823
+12,Philippinenhof-Warteberg,9.4941950806,51.3457423064
+13,Fasanenhof,9.5123139521,51.3347662999
+14,Wesertor,9.5148784085,51.3221887268
+15,Wolfsanger-Hasenhecke,9.5457989433,51.3396806416
+16,Bettenhausen,9.5384097499,51.3044330002
+17,Forstfeld,9.5418612035,51.2915878853
+18,Waldau,9.5100487565,51.2868080527
+19,Niederzwehren,9.4709628876,51.2783155707
+20,Oberzwehren,9.4443563894,51.2747255123
+21,Nordshausen,9.4245341169,51.2790202220
+22,Jungfernkopf,9.4607713668,51.3409314760
+23,Unterneustadt,9.5160276635,51.3119155114
diff --git a/index/obwahl_ks_2023/wahlbezirke.xlsx b/index/obwahl_ks_2023/wahlbezirke.xlsx
new file mode 100644
index 0000000000000000000000000000000000000000..83f1863a5b2b328129005c5cc33f5690644392c6
Binary files /dev/null and b/index/obwahl_ks_2023/wahlbezirke.xlsx differ
diff --git a/index/stadtteile.csv b/index/stadtteile.csv
deleted file mode 100644
index 66dba9e3eb2e8c5a481e3be95da03d9da6e1352d..0000000000000000000000000000000000000000
--- a/index/stadtteile.csv
+++ /dev/null
@@ -1,45 +0,0 @@
-nr,name,lon,lat,wahlberechtigt_2018,waehler_2018,gueltig_2018,feldmann_2018
-1,Altstadt,8.682385346400634,50.11059669873516,2772,1035,1030,427
-2,Innenstadt,8.682664888207869,50.113790989177375,4343,1074,1066,435
-4,Westend-Süd,8.6594393465439925,50.11682467903111,13376,5835,5814,1814
-5,Westend-Nord,8.666488952498467,50.128769620533795,6887,2761,2751,1064
-6,Nordend-West,8.684596043386934,50.13022858244322,22988,10779,10743,4368
-7,Nordend-Ost,8.69761974358955,50.127318654892264,17390,7852,7817,3578
-8,Ostend,8.719218276147204,50.11554639352114,20946,8411,8358,3873
-9,Bornheim,8.712407551447747,50.13090801018288,22232,9682,9633,4954
-10,Gutleut- und Bahnhofsviertel,8.652137942960298,50.099479845695974,6802,2113,2103,831
-11,Gallus,8.636377745265355,50.10300477630784,23958,6529,6488,2926
-12,Bockenheim,8.632922516089874,50.12128753657858,27090,10377,10332,4546
-13,Sachsenhausen-Nord,8.684579993638577,50.10051804775371,23441,9940,9897,4128
-14,Sachsenhausen-Süd und Flughafen,8.629663957345496,50.0607635296547,20710,8739,8700,3702
-16,Oberrad,8.727138168461476,50.09922899999497,9206,3109,3093,1491
-17,Niederrad,8.636199605275262,50.081631798202295,16799,5412,5366,2712
-18,Schwanheim,8.572652070944704,50.081760019105545,13936,5261,5217,2524
-19,Griesheim,8.600109548586545,50.09781734654457,14910,3670,3632,1921
-20,Rödelheim,8.603076601631098,50.127692431637506,12446,4518,4487,2108
-21,Hausen,8.626134516198546,50.13524298077671,4518,1814,1796,889
-22,Praunheim,8.61444644716483,50.14547905112678,11093,4365,4337,2109
-24,Heddernheim,8.64020132453368,50.158128239125205,11867,4579,4546,2385
-25,Niederursel,8.616911198776547,50.16683966510584,10458,3843,3818,1797
-26,Ginnheim,8.648134546192246,50.14388748058928,11185,4310,4285,2101
-27,Dornbusch,8.670541998003081,50.14434313041997,13527,6140,6102,2671
-28,Eschersheim,8.659950213724542,50.16002839395001,11166,5005,4982,2159
-29,Eckenheim,8.683795236784233,50.148564823086005,9638,3457,3425,1764
-30,Preungesheim,8.697198159142667,50.15544843144313,10222,3983,3965,2025
-31,Bonames,8.665887880254154,50.18258113675648,4467,1566,1549,862
-32,Berkersheim,8.702941786636124,50.17015956481773,2607,1137,1129,515
-33,Riederwald,8.73274589886058,50.12667040584185,3209,1070,1063,669
-34,Seckbach,8.726644066440096,50.147246840458955,7419,2926,2909,1381
-35,Fechenheim,8.762275115113775,50.12551773891441,10381,2571,2551,1475
-36,Höchst,8.539657322936813,50.098523172532,9859,2527,2506,1251
-37,Nied,8.57676379509509,50.103479453362766,12659,3730,3708,1975
-38,Sindlingen,8.51273746688725,50.07800492013246,5763,1768,1748,1011
-39,Zeilsheim,8.495768400332896,50.097784690964886,8012,2328,2299,1199
-40,Unterliederbach,8.525490184772172,50.10992510336304,10485,3201,3176,1533
-41,Sossenheim,8.574019745075416,50.12010605434964,10126,2684,2652,1270
-42,Nieder-Erlenbach,8.709219115856458,50.20871646737095,3544,1808,1796,665
-43,Kalbach-Riedberg,8.639008245521376,50.1846309062546,12409,5359,5333,2349
-44,Harheim,8.689844680792895,50.18583895746724,3548,1846,1836,601
-45,Nieder-Eschbach,8.668094243977997,50.20106152063871,8041,2979,2950,1204
-46,Bergen-Enkheim,8.766772308170399,50.15913246211432,13431,5774,5742,2583
-47,Frankfurter Berg,8.673427563622,50.169778678198924,5409,2049,2030,978
diff --git a/sitemap.md b/sitemap.md
new file mode 100644
index 0000000000000000000000000000000000000000..316479bb14452ca8288f6f499b0c777e6a8fed85
--- /dev/null
+++ b/sitemap.md
@@ -0,0 +1,41 @@
+# Was ist wo? Die Sitemap für das Projekt
+
+## Programme
+...liegen im Ordner "R":
+
+* **main.R**: Hauptprogramm, das alle Funktionen aufruft: Lies die Konfigurationsdateien ein. Schaue nach neuen Daten, lade sie herunter, verarbeite sie, gibt sie aus, versendet Teams-Messages, loggt jede Aktion in der Datei "obwahl.log"" mit. Die anderen R-Dateien sind über die "source()"-Funktion eingebunden - als Includes, gewissermaßen.
+  * **lies_konfiguration.R ** liest die Konfiguration für das Programm (Start der Wahl, Daten über Wahlberechtigte, Datawrapper-IDs) und liest die Index-Dateien ein: Kandidaten, Stadtteile, Wahllokale, Kommunalwahl-Ergebnisse. 
+  * **lies_aktuellen_stand.R**: Funktionen, um auf neue Daten zu überprüfen, sie herunterzuladen und zu archivieren. 
+  * **aktualisiere_karten.R**: Funktionen, um die Datawrapper-Karten zu aktualisieren. 
+  * **messaging.R**: Funktionen, die über Teams Status- und Fehlermeldungen ausgeben. 
+* **its_alive.R**: Programm, das über einen CRON-Job alle zwei Minuten aufgerufen wird - und nachschaut, wann das letzte mal etwas in die Logdatei "obwahl.log" geschrieben wurde. Wenn das länger als zwei Minuten her ist, ist das Skript vermutlich stehen geblieben - und das Skript versendet einen Alarm über Teams. 
+  
+Im Ordner "R" gibt es einen Unterordner "Vorbereitung", der diese Skripte enthält:
+
+* generiere_testdaten.R
+* teste-curl-polling.R - Testskript, um veränderte Daten über einen CURL-Aufruf so schnell wie möglich zu erkennen (sekundengenau)
+* prepare.R - generiert die Datawrapper-Skripte und -karten und Indexdaten. 
+
+## Wo man welche Funktionen findet (und was sie tun)
+### lies_aktuellen_stand.R
+
+- archiviere(dir) - Hilfsfunktion, schreibt geholte Stimmbezirks-Daten auf die Festplatte
+- hole_letztes_df(dir) - Hilfsfunktion, holt die zuletzt geschriebene Stimmbezirks-Datei aus dem Verzeichnis zurück  
+- check_for_timestamp(url) - liest das Änderungsdatum der Datei unter der URL aus
+- lies_stimmbezirke(url) - liest aus der Datei unter der URL die Stimmbezirke
+- aggregiere_stadtteildaten(stimmbezirke_df) - aggregiert auf Ortsteil-Ebene
+- berechne_führende
+
+
+
+### aktualisiere_karten.R
+## Wie das Programm arbeitet
+
+```main.R``` wird einmal aufgerufen und arbeitet dann, bis die Wahl vorbei ist
+oder ein Fehler auftritt: 
+
+- Lies zunächst die Konfigurationsdatei ein und hole Index-Dateien
+- Starte eine Schleife, solange nicht alle Stimmbezirke ausgezählt sind
+  - Checke, ob sich der Zeitstempel der Daten verändert hat (```check_timestamp()```)
+  - Lies sie ein (```lies_gebiet()```)
+