Using pelican to track my worked and volunteer hours

Graphs, progress-bars and python-markdown extensions

I was tracking my hours in Datasette (article and follow-up), but I wasn’t really happy with the editing process.

I’ve seen David notes, which made me want to do something similar.

I’m consigning everything in markdown files and as such, was already keeping track of everything this way already. Tracking my hours should be simple otherwise I might just oversee it. So I hacked something together with pelican (the software I wrote for this blog).

A graph showing the worked hours and volunteer hours

It’s doing the following:

  1. Defines a specific format for my worklog entries
  2. Parses them (using a regexp), does some computation and ;
  3. Uses a specific template to display a graph and progress bar.

Reading information from the titles

I actually took the format I’ve been already using in my log, and enhanced it a bit. Basically, the files look likes this (I’m writing in french):

---
title: My project
total_days: 25
---

## Mardi 23 Novembre 2023 (9h, 5/5)

What I did this day.
I can include [links](https://domain.tld) and whatever I want.
It won't be processed.

## Lundi 22 Novembre 2023 (8h rémunérées, 2h bénévoles, 4/5)

Something else.

Basically, the second titles (h2) are parsed, and should have the following structure: {day_of_week} {day} {month} {year} ({worked_hours}(, optional {volunteer_hours}), {fun_rank})

The goal here is to retrieve all of this, so I asked ChatGPT for a regexp and iterated on the result which got me:

pattern = re.compile(
        r"""
        (\w+)\s+                      # Day name
        (\d{1,2})\s+                  # Day number
        ([\wéû]+)\s+                  # Month name
        (\d{4})\s+                    # Year
        \(
        (\d{1,2})h                    # Hours (mandatory)
        (?:\s+facturées)?             # Optionally 'facturées', if not present, assume hours are 'facturées'
        (?:,\s*(\d{1,2})h\s*bénévoles)? # Optionally 'volunteer hours 'bénévoles'
        ,?                            # An optional comma
        \s*                           # Optional whitespace
        (?:fun\s+)?                   # Optionally 'fun' (text) followed by whitespace
        (\d)/5                        # Happiness rating (mandatory, always present)
        \)                            # Closing parenthesis
        """,
        re.VERBOSE | re.UNICODE,
    )

The markdown preprocessor

I’m already using a custom pelican plugin, which makes it possible to have pelican behave exactly the way I want. For instance, it’s getting the date from the filesystem.

I just had to add some features to it. The way I’m doing this is by using a custom Markdown reader, on which I add extensions and custom processors.

In my case, I added a preprocessor which will only run when we are handling the worklog. It makes it possible to change what’s being read, before the markdown lib actually transforms it to HTML.

Here is the code for it:

class WorklogPreprocessor(Preprocessor):
    pattern = "the regexp we've seen earlier"

    def run(self, lines):
        new_lines = []
        for line in lines:
            if line.startswith("##"):
                match = re.search(self.pattern, line)
                if not match:
                    raise ValueError("Unable to parse worklog title", line)
                (
                    day_of_week,
                    day,
                    month,
                    year,
                    payed_hours,
                    volunteer_hours,
                    happiness,
                ) = match.groups()

                volunteer_hours = int(volunteer_hours) if volunteer_hours else 0
                payed_hours = int(payed_hours)
                happiness = int(happiness)

                date = datetime.strptime(f"{day} {month} {year}", "%d %B %Y")
                self.data[date.strftime("%Y-%m-%d")] = {
                    "payed_hours": payed_hours,
                    "volunteer_hours": volunteer_hours,
                    "happyness": happiness,
                }

                # Replace the line with just the date
                new_lines.append(f"## 🗓️ {day_of_week} {day} {month} {year}")
            else:
                new_lines.append(line)
        return new_lines

It does the following when it encounters a h2 line:

I’ve also added some computations on top of it, which makes it possible to display a percentage of completion for the project, if “payed_hours” was present in the metadata, and makes it use a specific template (see later).

def compute_data(self, metadata):
    done_hours = sum([item["payed_hours"] for item in self.data.values()])

    data = dict(
        data=self.data,
        done_hours=done_hours,
        template="worklog",
    )

    if "total_days" in metadata:
        total_hours = int(metadata["total_days"]) * 7
        data.update(
            dict(
                total_hours=total_hours,
                percentage=round(done_hours / total_hours * 100),
            )
        )

    return data

Plugging this with pelican

Here’s the code for extending a custom reader, basically adding a pre-processor and adding back its data in the document metadata:

is_worklog = Path(source_path).parent.match("pages/worklog")

if is_worklog:
    worklog = WorklogPreprocessor(self._md)
    self._md.preprocessors.register(worklog, "worklog", 20)

# process the markdown, and then

if is_worklog:
    metadata["worklog"] = worklog.compute_data(metadata)

Adding a graph

Okay, everything is parsed, but it’s not yet displayed on the pages. I’m using vega-lite to display a graph.

Here is my template for this (stored in template/worklog.html), it’s doing a stacked bar chart with my data.

const spec = {
      "$schema": "https://vega.github.io/schema/vega-lite/v5.json",
      "width": 500,
      "height": 200,
      "data": 
        {
          "name": "table",
          "values": [
          {% for date, item in page.metadata.worklog.data.items() %}
            {"date": "{{ date }}", "series": "Rémunéré", "count": {{ item['payed_hours'] }}},
            {"date": "{{ date }}", "series": "Bénévole", "count": {{ item['volunteer_hours'] }}},
          {% endfor %}
          ]
        }
      ,
      "mark": "bar",
      "encoding": {
        "x": {
          "timeUnit": {"unit": "dayofyear", "step": 1},
          "field": "date",
          "axis": {"format": "%d/%m"},
          "title": "Date",
          "step": 1,
        },
        "y": {
          "aggregate": "sum",
          "field": "count",
          "title": "Heures",
        },
        "color": {
          "field": "series",
          "scale": {
            "domain": ["Bénévole", "Rémunéré"],
            "range": ["#e7ba52", "#1f77b4"]
          },
          "title": "Type d'heures"
        }
      }
    };

    vegaEmbed("#vis", spec)
        // result.view provides access to the Vega View API
      .then(result => console.log(result))
      .catch(console.warn);

I’ve also added a small progress bar, made with unicode, which looks like this.

▓▓░░░░░░░░ 29% (51h / 175 prévues) 

Here is the code for it:

{% if "total_days" in page.metadata.keys() %}
      {% set percentage = page.metadata.worklog['percentage'] %}
      {% set total_blocks = 10 %}
      {% set percentage_value = (percentage / 100.0) %}
      {% set full_blocks = ((percentage_value * total_blocks) | round(0, 'floor') ) | int %}
      {% set empty_blocks = total_blocks - full_blocks %}
      <div class="progressbar">
        {# Display full blocks #}
        {% for i in range(full_blocks) %}{% endfor %}
        {# Display empty blocks #}
        {% for i in range(empty_blocks) %}{% endfor %}
        {{ percentage }}% ({{ page.metadata.worklog['done_hours'] }}h / {{ page.metadata.worklog['total_hours'] }} prévues)
      </div>

#Pelican , #Work , #Vega , #Markdown - Posté dans la catégorie code