Repeating tasks by day of week

Using n8n with Vikunja, I implemented a way to have repeating tasks on specific weekdays. It uses Vixie Cron patterns, which includes L for last and # for nth. For example, 0 10 * * MON#2 will set the end date to the second Monday of the month at 10:00.

If you’re willing to set it up, this provides a solution for topics such as More repeating intervals, Specific weekdays repeat mode, and Repeat intervals: Set weekdays.

To do this, I set up n8n via Docker Compose and installed Croner via Dockerfile. Then, I created a workflow that reads the description of tasks as they are completed. You’ll need a node for each parent project with cron tasks. Next, if Cron: * * * * * is in the description, the workflow parses the cron expression, updates the end date to the next valid date based on the cron expression, and marks the task as incomplete. Additionally, the start date, due date, and reminder date(s) are all adjusted to retain the same relative duration to the end date.

If anyone is interested, I can post details on how to set this up and share the code to my n8n workflow shown below.

1 Like

This is awesome! I’d love to see more details and the n8n code! Trying to host n8n in a firewalled environment alongside vikunja myself, and was wondering about how to setup some of the automations like chron and webhooks since webhooks looked a little limiting I was unsure (havn’t read the docs yet). practice example of yours is amazing ! Thanks in advance k4j8!

Below is how I set up my workflow. Below, I’ll walk through deploying n8n, deploying Vikunja, creating the Vikunja credential within n8n, and then creating the workflow above. You don’t need to use webhooks since n8n can directly interact with the Vikunja API, although you could do the same with webhooks instead by swapping out the “Get Personal/Work Tasks” nodes. See The beginner's guide to webhooks for workflow automation – n8n Blog for details on webhooks vs. API.

n8n Docker Compose

Source: Docker Compose | n8n Docs. Docker Compose below.

services:
  n8n:
    build: .  # Use `image: docker.n8n.io/n8nio/n8n` if you don't need to use cron
    container_name: n8n
    ports:
      - 5678:5678
    environment:
      - GENERIC_TIMEZONE=Etc/UTC  # Or see https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
      - TZ=Etc/UTC  # Same as above
      - WEBHOOK_URL=https://n8n.yourdomain.tld  # See https://docs.n8n.io/hosting/configuration/configuration-examples/webhook-url/
      - NODE_FUNCTION_ALLOW_EXTERNAL=croner  # Not needed if not using cron
    volumes:
      - ./data:/home/node/.n8n
    networks:
      - api_n8n
    restart: unless-stopped

networks:
  api_n8n:
    name: api_n8n

If using cron, you’ll also need the following Dockerfile in the same directory:

FROM docker.n8n.io/n8nio/n8n
USER root
RUN npm install --global croner
USER node

Run docker compose build before docker compose up --detach.

Vikunja Docker Compose

To allow n8n to communicate with Vikunja, I created a Docker network I call api_n8n. Use the code available at Docker Walkthrough for your Docker Compose and then make the following additions to connect it to n8n:

services:
  vikunja:
    networks:
      - vikunja
      - api_n8n
  db:
    networks:
      - vikunja

networks:
  vikunja:  # creating this network is necessary or the above two containers can't talk to each other
  api_n8n:
    external: true

API Credentials

In Vikunja, create an API token and copy the API key.

In n8n, create a Vikunja API credential with the above API key. For the API URL, reference Vikunja by the name of the Docker container like this: http://vikunja:3456/api/v1.

You should then be able to create a workflow, including Vikunja nodes.

Repeating Workflow via Cron

Save the JSON below to a file. Create a new workflow and then import the JSON file. See Export and import workflows | n8n Docs.

The nodes for “Get Personal/Work Tasks” are tied to a specific project. You’ll need to update those numbers by copying the project number from the Vikunja URL. For example, https://vikunja.yourdomain.tld/projects/7/1 is project 7. You could also add more nodes before the “Get Task” node if you have additional projects. FYI, sub-projects are automatically included and do not need their own node.

For details on how the flow works, check the notes for each node. Good luck!

JSON for above workflow
{
  "name": "Vikunja - Recurrence",
  "nodes": [
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "1f229ea3-e848-45e2-a857-b851051973c6",
              "leftValue": "={{ $('Get Task').item.json.data.task.start_date }}",
              "rightValue": "2000-01-01T00:00:00",
              "operator": {
                "type": "dateTime",
                "operation": "after"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1360,
        -300
      ],
      "id": "8e383e2c-42de-45af-b44d-8e4b2b5a78bb",
      "name": "If Start Date"
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "1f229ea3-e848-45e2-a857-b851051973c6",
              "leftValue": "={{ $('Get Task').item.json.data.task.due_date }}",
              "rightValue": "2000-01-01T00:00:00",
              "operator": {
                "type": "dateTime",
                "operation": "after"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1280,
        -80
      ],
      "id": "eac342a4-4743-447e-9390-659acd90f396",
      "name": "If Due Date"
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "41a231bc-a81d-4978-b99d-8c234fca0f71",
              "name": "end date",
              "value": "={{ $json.data.task.end_date }}",
              "type": "string"
            },
            {
              "id": "6cdb0b81-e0c2-4567-9b08-4aa6ebe7447d",
              "name": "cron",
              "value": "={{ $json.data.task.description.split(\"Cron: \")[1].split(\"<\")[0] }}",
              "type": "string"
            }
          ]
        },
        "options": {}
      },
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        880,
        0
      ],
      "id": "3eeb4b37-817e-4c4e-aeb8-94dfc0725c5c",
      "name": "Parse Vars",
      "notesInFlow": true,
      "notes": "Extract the cron expression by looking for text between \"Cron: \" and the hidden \"<p>\" at the end of each paragraph."
    },
    {
      "parameters": {
        "mode": "chooseBranch",
        "numberInputs": 4,
        "useDataOfInput": 3
      },
      "type": "n8n-nodes-base.merge",
      "typeVersion": 3,
      "position": [
        1700,
        -20
      ],
      "id": "ae9a01b0-479a-4c20-9b54-a2571e7072a9",
      "name": "Merge"
    },
    {
      "parameters": {
        "method": "POST",
        "url": "=http://vikunja:3456/api/v1/tasks/{{ $('Get Task').last().json.data.task.id }}",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "vikunjaApi",
        "sendBody": true,
        "bodyParameters": {
          "parameters": [
            {
              "name": "end_date",
              "value": "={{ $json.next_cron_end_date }}"
            },
            {
              "name": "start_date",
              "value": "={{\n  $if(\n    $('Get Task').last().json.data.task.start_date != \"0001-01-01T00:00:00Z\",\n    $json['next_cron_end_date'].toDateTime().minus($('Start Date Delta').last().json.timeDifference.minutes, 'minutes'),\n    $('Get Task').last().json.data.task.start_date\n  )\n}}"
            },
            {
              "name": "due_date",
              "value": "={{\n  $if(\n    $('Get Task').last().json.data.task.due_date != \"0001-01-01T00:00:00Z\",\n    $json['next_cron_end_date'].toDateTime().minus($('Due Date Delta').last().json.timeDifference.minutes, 'minutes'),\n    $('Get Task').last().json.data.task.due_date\n  )\n}}"
            },
            {
              "name": "reminders",
              "value": "={{\n  $if(\n    $('Get Task').last().json.data.task.reminders != null,\n    $('Calculate Reminders').last().json.reminders,\n    $('Get Task').last().json.data.task.reminders\n  )\n}}"
            },
            {
              "name": "done",
              "value": "={{ false }}"
            },
            {
              "name": "assignees",
              "value": "={{ $('Get Task').last().json.data.task.assignees }}"
            },
            {
              "name": "bucket_id",
              "value": "={{ $('Get Task').last().json.data.task.bucket_id }}"
            },
            {
              "name": "cover_image_attachment_id",
              "value": "={{ $('Get Task').last().json.data.task.cover_image_attachment_id }}"
            },
            {
              "name": "description",
              "value": "={{ $('Get Task').last().json.data.task.description }}"
            },
            {
              "name": "percent_done",
              "value": "={{ $('Get Task').last().json.data.task.percent_done }}"
            }
          ]
        },
        "options": {}
      },
      "id": "242331b1-6d98-4e7b-986a-5ec89dc57417",
      "name": "Update Task",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1880,
        0
      ],
      "alwaysOutputData": true,
      "notesInFlow": true,
      "credentials": {
        "vikunjaApi": {
          "id": "jIQLzVfq62CYQWXV",
          "name": "your Vikunja credential"
        }
      },
      "notes": "Update task in Vikunja."
    },
    {
      "parameters": {
        "jsCode": "const event = $('Get Task').first().json;\nconst reminders = event.data.task.reminders;\n\nconst nextCronEndDate = new Date($input.first().json['next_cron_end_date'])\nlet endDate = new Date(event.data.task.end_date);\n\n// Build the adjusted reminders\nconst adjustedReminders = reminders.map(r => {\n  if (r.relative_to === '') {  // Only adjust reminders that are a custom date/time and ignore reminders relative to other start/due/end dates\n    const reminderDate = new Date(r.reminder);\n    const diffMs = endDate.getTime() - reminderDate.getTime();\n    const adjustedDate = new Date(nextCronEndDate.getTime() - diffMs);\n\n    // Return updated reminder object\n    return {\n      ...r,\n      reminder: adjustedDate.toISOString()\n    };\n  }\n\n  // Leave other reminders unchanged\n  return r;\n});\n\nreturn [{ json: { reminders: adjustedReminders } }];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1420,
        80
      ],
      "id": "60555db4-910a-41a3-a87f-2cc327a9b918",
      "name": "Calculate Reminders",
      "notesInFlow": true,
      "notes": "Adjust each reminder that has a custom date/time to reflect the same duration to the new end date."
    },
    {
      "parameters": {
        "project": 7,
        "events": [
          "task.updated"
        ]
      },
      "id": "c5f60ea5-d579-452b-bb40-c445d78ac929",
      "name": "Get Personal Tasks",
      "type": "n8n-nodes-vikunja.vikunjaTrigger",
      "typeVersion": 1,
      "position": [
        340,
        -80
      ],
      "webhookId": "a858e228-ea7f-4036-8eb7-e50559e6d871",
      "credentials": {
        "vikunjaApi": {
          "id": "jIQLzVfq62CYQWXV",
          "name": "your Vikunja credential"
        }
      }
    },
    {
      "parameters": {
        "project": 20,
        "events": [
          "task.updated"
        ]
      },
      "id": "6dd96a61-66a7-46cb-bdda-908a74671f0e",
      "name": "Get Work Tasks",
      "type": "n8n-nodes-vikunja.vikunjaTrigger",
      "typeVersion": 1,
      "position": [
        340,
        80
      ],
      "webhookId": "a858e228-ea7f-4036-8eb7-e50559e6d871",
      "credentials": {
        "vikunjaApi": {
          "id": "jIQLzVfq62CYQWXV",
          "name": "your Vikunja credential"
        }
      }
    },
    {
      "parameters": {},
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        520,
        0
      ],
      "id": "0bf7eb99-1141-4a5d-ad4d-efc8a459e003",
      "name": "Get Task",
      "notesInFlow": true,
      "notes": "This node is used as a single point of reference for the other nodes."
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "1f229ea3-e848-45e2-a857-b851051973c6",
              "leftValue": "={{ $('Get Task').item.json.data.task.reminders }}",
              "rightValue": "2000-01-01T00:00:00",
              "operator": {
                "type": "array",
                "operation": "exists",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1260,
        160
      ],
      "id": "0b8709fe-22f6-462f-a165-2fd5020d25ec",
      "name": "If Reminders"
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 1
          },
          "conditions": [
            {
              "id": "fb62750f-ac83-4d86-a31b-cc37f6214987",
              "leftValue": "={{ $json.data.task.description }}",
              "rightValue": "<p>Cron: ",
              "operator": {
                "type": "string",
                "operation": "contains"
              }
            },
            {
              "id": "1e227ef5-48c5-4701-9700-c0c83736896f",
              "leftValue": "={{ $json.data.task.end_date }}",
              "rightValue": "2000-01-01T00:00:00",
              "operator": {
                "type": "dateTime",
                "operation": "after"
              }
            },
            {
              "id": "4fe6a14d-961f-4e40-9638-c0bbef838ed2",
              "leftValue": "={{ $json.data.task.done }}",
              "rightValue": "={{ true }}",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "7d891bae-be8d-4e1d-bc98-85333969580b",
      "name": "Check for Criteria",
      "type": "n8n-nodes-base.filter",
      "typeVersion": 2,
      "position": [
        700,
        -20
      ],
      "notesInFlow": true,
      "notes": "This node verifies that the cron expression is in the description, an end date is set, and the task is marked as done."
    },
    {
      "parameters": {
        "language": "python",
        "pythonCode": "from datetime import datetime, timedelta, timezone\nimport re\n\ndef get_next_cron_end_date(current_end_date: str, cron_expr: str) -> str:\n    \"\"\"\n    current_end_date: ISO 8601 string, e.g., '2025-05-14T00:00:00-04:00'\n    cron_expr: Crontab string with 5 fields, e.g., '0 12 * * 3#2' or '0 12 * * 1-5'.\n    - First minute and hour are used.\n    - If nth weekday is defined, day is ignored.\n    - Steps are not allowed.\n    Returns next end date as ISO string (UTC).\n    \"\"\"\n    minute_field, hour_field, day_field, month_field, weekday_field = cron_expr.split()\n\n    local = datetime.fromisoformat(current_end_date)\n    current = local.astimezone(timezone.utc)\n    year = current.year\n    month = current.month\n\n    def parse_cron_field(field, min_val, max_val):\n        \"\"\"\n        Parse a cron field into a sorted list of allowed integers.\n        Evaluate the following: * , -\n        But do not evaluate: # (nth)\n        \"\"\"\n        # Evaluate * (wildcard)\n        if field == \"*\":\n            return list(range(min_val, max_val + 1))\n\n        result = set()\n        # Evaluate , (list)\n        for part in field.split(','):\n            # Evaluate - (range)\n            if '-' in part:\n                start, end = map(int, part.split('-'))\n                result.update(range(start, end + 1))\n            else:\n                result.add(int(part))\n\n        return sorted(v for v in result if min_val <= v <= max_val)\n\n    # Determine allowed values for each field based on cron expression\n    minute = parse_cron_field(minute_field, 0, 59)[0]\n    hour   = parse_cron_field(hour_field, 0, 23)[0]\n    days   = parse_cron_field(day_field, 1, 31)\n    months = parse_cron_field(month_field, 1, 12)\n\n    # Evaluate # (nth) be determining `weekdays` and `nth`\n    if \"#\" in weekday_field:\n        weekday_part, nth_part = weekday_field.split(\"#\")\n        weekdays = [int(weekday_part)]  # example 1 = Monday\n        nth = int(nth_part)  # example 2 = 2nd Xday of the month\n    else:\n        weekdays = parse_cron_field(weekday_field, 0, 6)  # example 1 = Monday\n        nth = None\n\n    def get_nth_weekday(year, month, weekday, nth):\n        \"\"\"\n        This function is only used if `nth` is defined. It returns the next nth weekday.\n        It gets looped by a call below.\n        \"\"\"\n        count = 0\n        for day in range(1, 32):\n            try:\n                date = datetime(year, month, day, tzinfo=local.tzinfo)\n            except ValueError:\n                break\n            if (date.weekday() + 1) % 7 == weekday:  # add 1 to test date to adjust from Python format (`.weekday()`, Monday = 0) to cron format (Monday = 1)\n                count += 1\n                if count == nth:\n                    return date\n        return None\n\n    # Loop up to 24 months\n    for i in range(24):\n        test_year = year + (month + i - 1) // 12\n        test_month = (month + i - 1) % 12 + 1\n\n        if test_month not in months:\n            continue\n\n        candidates = []\n\n        # Define candidates. If nth is defined, get valid weekdays. Otherwise, get valid day of month and weekday.\n        if nth is not None:\n            for weekday in weekdays:\n                candidate = get_nth_weekday(test_year, test_month, weekday, nth)\n                if candidate:\n                    candidates.append(candidate)\n        else:\n            for day in range(1, 32):\n                try:\n                    candidate = datetime(test_year, test_month, day, tzinfo=local.tzinfo)\n                except ValueError:\n                    break\n                if days and candidate.day not in days:\n                    continue\n                if weekdays and candidate.weekday() not in weekdays:\n                    continue\n                candidates.append(candidate)\n\n        for candidate in candidates:\n            candidate = candidate.replace(hour=hour, minute=minute, second=0, microsecond=0)\n            if candidate > current:\n                return candidate.isoformat()\n\n    raise ValueError(\"No valid next end date found in the next 24 months.\")\n\n# Determine next end date based off of inputs\nnext_cron_end_date = get_next_cron_end_date(_input.first().json['end date'], _input.first().json.cron)\nd = {'next_cron_end_date': next_cron_end_date}\n\nreturn d"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1060,
        180
      ],
      "id": "800a9513-7a3d-45d6-93c1-b43a000be634",
      "name": "Calculate Next End Date (Python)"
    },
    {
      "parameters": {
        "jsCode": "const { Cron } = require('croner');  // this requires n8n to have Croner installed and enabled via environmental variable\n\nconst job = new Cron( $input.first().json.cron );  // input cron expression\nconst next_cron_end_date = job.nextRun( $input.first().json['end date'] );  // begin searching after current end date\n\nreturn [{ json: { next_cron_end_date: next_cron_end_date } }];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1060,
        0
      ],
      "id": "a1e53b60-01f0-4374-a491-f7a5f15d8eb2",
      "name": "Calculate Next End Date (JavaScript with Croner)",
      "notesInFlow": true,
      "notes": "Use Croner to determine the next date that matches the cron expression and is after the end date."
    },
    {
      "parameters": {
        "operation": "getTimeBetweenDates",
        "startDate": "={{ $('Get Task').item.json.data.task.start_date }}",
        "endDate": "={{ $('Get Task').item.json.data.task.end_date }}",
        "units": [
          "minute"
        ],
        "options": {}
      },
      "type": "n8n-nodes-base.dateTime",
      "typeVersion": 2,
      "position": [
        1540,
        -320
      ],
      "id": "f4d6bd3e-a881-4b5b-bf0d-94ba3a0403bf",
      "name": "Start Date Delta",
      "notesInFlow": true,
      "notes": "Determine duration between start date and end date."
    },
    {
      "parameters": {
        "operation": "getTimeBetweenDates",
        "startDate": "={{ $('Get Task').item.json.data.task.due_date }}",
        "endDate": "={{ $('Get Task').item.json.data.task.end_date }}",
        "units": [
          "minute"
        ],
        "options": {}
      },
      "type": "n8n-nodes-base.dateTime",
      "typeVersion": 2,
      "position": [
        1440,
        -140
      ],
      "id": "32479801-2f74-4b09-9a46-7f0dfbc22f8f",
      "name": "Due Date Delta",
      "notesInFlow": true,
      "notes": "Determine duration between due date and end date."
    }
  ],
  "pinData": {},
  "connections": {
    "If Start Date": {
      "main": [
        [
          {
            "node": "Start Date Delta",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Merge",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "If Due Date": {
      "main": [
        [
          {
            "node": "Due Date Delta",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Merge",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "Parse Vars": {
      "main": [
        [
          {
            "node": "Calculate Next End Date (JavaScript with Croner)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Merge": {
      "main": [
        [
          {
            "node": "Update Task",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Update Task": {
      "main": [
        []
      ]
    },
    "Calculate Reminders": {
      "main": [
        [
          {
            "node": "Merge",
            "type": "main",
            "index": 3
          }
        ]
      ]
    },
    "Get Personal Tasks": {
      "main": [
        [
          {
            "node": "Get Task",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get Work Tasks": {
      "main": [
        [
          {
            "node": "Get Task",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get Task": {
      "main": [
        [
          {
            "node": "Check for Criteria",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "If Reminders": {
      "main": [
        [
          {
            "node": "Calculate Reminders",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Merge",
            "type": "main",
            "index": 3
          }
        ]
      ]
    },
    "Check for Criteria": {
      "main": [
        [
          {
            "node": "Parse Vars",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Calculate Next End Date (Python)": {
      "main": [
        []
      ]
    },
    "Calculate Next End Date (JavaScript with Croner)": {
      "main": [
        [
          {
            "node": "If Reminders",
            "type": "main",
            "index": 0
          },
          {
            "node": "If Start Date",
            "type": "main",
            "index": 0
          },
          {
            "node": "If Due Date",
            "type": "main",
            "index": 0
          },
          {
            "node": "Merge",
            "type": "main",
            "index": 2
          }
        ]
      ]
    },
    "Start Date Delta": {
      "main": [
        [
          {
            "node": "Merge",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Due Date Delta": {
      "main": [
        [
          {
            "node": "Merge",
            "type": "main",
            "index": 1
          }
        ]
      ]
    }
  },
  "active": true,
  "settings": {
    "executionOrder": "v1",
    "callerPolicy": "workflowsFromSameOwner",
    "saveDataSuccessExecution": "none"
  },
  "versionId": "de354075-8066-4459-a079-629b41bb0776",
  "meta": {
    "templateCredsSetupCompleted": true,
    "instanceId": "2463bf03b26cd468961fab42d7d116247f91b33a4e53561eee0272102a1112ac"
  },
  "id": "a29B8M98eEC417Yf",
  "tags": [
    {
      "createdAt": "2025-02-25T14:42:34.725Z",
      "updatedAt": "2025-02-25T14:42:34.725Z",
      "id": "7PYkCl9kT9xOUaPs",
      "name": "Active"
    }
  ]
}