Deutschlandticket WorthIt Analysis

Some years ago I aggregated my travel costs in a post to show the savings I had because of the 9€-Ticket. Since then I tracked all my Deutschlandticket saving using a hledger virtual entries.

Tracking in hledger

The "WorthIt Sum" in table below is based on a hledger virtual entries. For example one trip, without Deutschlandticket:

2025-12-30 VVS Ticket (Stuttgart -> Schorndorf)
    Expenses:ÖPNV:VVS                          €6.80
    Assets:Girokonto
    (worthIt:Deutschlandticket)                €6.80

And one trip where I only track the usage, because it is paid via Deutschlandticket:

2025-10-10 Regional train (Göppingen -> Stuttgart)
    (worthIt:Deutschlandticket)                €-8.3

The value tracked in the virtual entry is negative, because it is the amount saved.

The Deutschlandticket itself in my ledger looks like this:

2025-10-01 Deutschlandticket
    Expenses:ÖPNV:Deutschlandticket              €58
    Assets:Girokonto
    (worthIt:Deutschlandticket)                  €58

To get the worthIt data from the ledger files into Python I use subprocess and call this one:

hledger register ^worthIt:Deutschlandticket -O csv

The first lines of my data:

"txnidx","date","code","description","account","amount","total"
"4928","2023-05-01","","Deutschlandticket bei der SSB","(worthIt:Deutschlandticket)","€49.00","€49.00"
"4970","2023-05-04","","Bahn: Stgt -> Reutlingen","(worthIt:Deutschlandticket)","€-11.00","€38.00"
"4970","2023-05-04","","Bahn: Tübingen -> Herrenberg","(worthIt:Deutschlandticket)","€-6.00","€32.00"
...
"5011","2023-05-30","","VVS: Office day","(worthIt:Deutschlandticket)","€-2.75","€-52.55"

The total column from the last entry of a month is the value you see in the table below (€-52.55 for 2023-05). So the virtual entry sums per month are calculated by hledger.

Data analysis

The Python script is analysing the csv and returns a table with additional columns. Actually a second hledger csv export is needed: the one returning all Expenses:ÖPNV:Deutschlandticket to know if I booked a Deutschlandticket that month.

For the months where I didn't buy a Deutschlandticket, the script still needs to know what it would have costed. The ticket price increased in the last years, so I have this in my Python code to track the price that month:

def get_ticket_price_for_month(year_month):
    if year_month >= "2026-01":
        return Decimal("63")
    elif year_month >= "2025-01":
        return Decimal("58")
    return Decimal("49")

With all this data a table is generated. The "WorthIt Sum" is exactly the virtual value at the end of the month, as described above. The "Actual Cost" is either the Deutschlandticket, or in months without one, the virtual entry. This virtual entries are always postive, because no Deutschlandticket to save with. And finally a "Not WorthIt" column. For this the actual costs need to be over the costs of the Deutschlandticket (I never had such a month, yet). Or the "WorthIt Sum" is positive, so not enough was saved to reach the costs of the Deutschlandticket. I had this 4 times in the past as you can see in the table below. As a result of the months where I didn't save enough with the Deutschlandticket I started to not have a ticket in winter months.

Year-Month

Ticket Bought

WorthIt Sum

Actual Cost

Not WorthIt

2025-12

No

€42.67

€42.67

2025-11

No

€11.70

€11.70

2025-10

Yes

€-5.35

€58.00

2025-09

Yes

€-141.15

€58.00

2025-08

Yes

€-219.87

€58.00

2025-07

Yes

€-55.03

€58.00

2025-06

Yes

€-234.21

€58.00

2025-05

Yes

€-184.15

€58.00

2025-04

Yes

€-21.47

€58.00

2025-03

Yes

€20.01

€58.00

x

2025-02

No

€45.56

€45.56

2025-01

No

€13.22

€13.22

2024-12

Yes

€-3.63

€49.00

2024-11

Yes

€-80.49

€49.00

2024-10

Yes

€-184.65

€49.00

2024-09

Yes

€-76.91

€49.00

2024-08

Yes

€-31.20

€49.00

2024-07

Yes

€-135.47

€49.00

2024-06

Yes

€-135.79

€49.00

2024-05

Yes

€-109.42

€49.00

2024-04

Yes

€-143.54

€49.00

2024-03

Yes

€-16.84

€49.00

2024-02

Yes

€39.95

€49.00

x

2024-01

Yes

€7.86

€49.00

x

2023-12

Yes

€-51.02

€49.00

2023-11

Yes

€9.65

€49.00

x

2023-10

Yes

€-4.88

€49.00

2023-09

Yes

€-154.49

€49.00

2023-08

Yes

€-158.42

€49.00

2023-07

Yes

€-144.95

€49.00

2023-06

Yes

€-227.04

€49.00

2023-05

Yes

€-52.55

€49.00


I am a bit torn in not having the Deutschlandticket. On the one hand the Deutschlandticket is not worth it for me in the winter. On the other hand I am limiting myself using public transport, because I have to buy a single trip ticket or daily ticket.

Personal IMDB Calendar Heatmaps

In the previous post I changed the reading challenge personal folder to a submodule, so that I can use the personal repository for another project: This one. A Python script is processing the IMDB Rating export and generates calendar heatmaps similar to the GitHub contribution activity plots.

The first version used the dayplot Python library, but this doesn't allow per-cell categorical colors. So I asked Claude to change the code to pure matplotlib.

In the IMDB Ratings every entry has a Type, I chose to color them differently. But first I merged the Types like this:

TV Movie -> Movie
TV Mini Series -> TV Series
TV Short -> Short
TV Special -> TV Series

And I removed "Video" and "Video Game". Both are rare in my data. And especially for Video Game the date of rating has nothing to do with the date I actually played the game.

As a result we get these Types, and they get colors when mixed have visible differences:

"Movie": "#16a34a",       # green
"TV Episode": "#4f46e5",  # indigo
"TV Series": "#ea580c",   # orange
"Short": "#0d9488",       # teal

The code would be much shorter without any Type colors, like the green in the GitHub contribution calendar heatmaps.

The full source code is mirrored on GitHub.

A few times in the past, I rated a large number of TV episodes in a single day. The highest count was 140. Obviously, I didn’t actually watch that many episodes on that day. So the data is not perfect for this kind of plot. Still the resulting images are a good indicator how much I watched Movies or TV Episodes.

Next are some example years from my data. In the first years I used IMDB (2004 till 2010) I only rated Movies. I don't know anymore why I started to rate TV Episodes in 2010. But since then I use IMDB ratings to track what I have already watched.

My ratings plot for 2010, the year I started to rate TV Episodes:

img1

I can see in the plots the years where a lot happened and I had no time to watch Movies or Series, for example 2015:

img2

And there is the obvious year where we all spent time at home and watched Movies and Series, 2020:

img3

Overall a good way to visualize my personal Movie and TV Series consumption.

Using Git Submodules

Two months ago I wrote a blog post about a Reading Challenge.

The personal data (yaml files + IMDB export) are stored in a folder named "personal", which is a checkout of another repository. In the personal repository I kept the Forgejo Action that updates the statistics.svg. The script itself is in the reading-challenge repository.

I want to move the Forgejo Action into the reading-challenge repository, but then we need to detect changes in the personal repository. This can be handled by using git submodules.

First we need to replace the personal folder with a submodule:

# move personal folder away / important: is everything commited and pushed!
mv personal ../reading-challenge-personal
# add a submodule instead (this is my git url as example)
git submodule add ssh://git@forgejo/mfa/reading-challenge-personal.git personal
# add a config setting to always pull the submodule
git config submodule.recurse true

With the recurse setting a git pull is enough to pull the submodule too.

The previous Forgejo Action only updated the plot. But now we can actually update the movies, the mermaid file and the svg. This is the full Forgejo Action: statistics.yaml.

One important change, is that the changed files need to be commited to the personal repository and not the current one:

- name: Commit statistics.svg to personal repo
  run: |-
    cd personal
    git checkout main
    git config user.name "Automated"
    git config user.email "actions@users.noreply.github.com"
    git add *.yaml statistics.mmd statistics.svg
    git commit -m "Update statistics and watched movies" || exit 0
    git push

At first I updated the submodule reference too. But this would create an infinite loop because of the trigger I used. A change to the submodule reference for personal will trigger, and if we bump the submodule reference after we changed the files, the Action will run again.

The code to update the reference would look like this:

run: |-
  git config user.name "Automated"
  git config user.email "actions@users.noreply.github.com"
  git add personal
  git commit -m "Update personal submodule reference" || exit 0
  git push

But again, this is not added to the Forgejo Action to not create an infinite trigger loop.

Another tricky thing was that my local setup is using ssh to interact with Forgejo, but the Forgejo Action needs to use HTTPS. For this two changes where needed:

- name: Configure git credentials
  run: |-
    git config --global credential.helper store
    echo "https://oauth2:${{ secrets.PERSONAL_REPO_TOKEN }}@forgejo.tail07efb.ts.net" > ~/.git-credentials
- name: Checkout submodules with HTTPS
  run: |-
    git config --global url."https://forgejo.tail07efb.ts.net/".insteadOf "ssh://git@forgejo.tail07efb.ts.net/"
    git submodule update --init --recursive

The first adds credentials to allow reading (and writing) the personal repository by using a PERSONAL_REPO_TOKEN. This Personal Access Token was generated in the User settings and allows read/write on repositories. There is currently no more granular way to set access rights without a bot user account. The bot could be set to read both repositories and not every repository.

And the second step changes the url first to the https one, and then pulls the submodule.

Having only the data (and the generated svg) in the personal repository feels like the better solution.