Skip to content

feat(repeat): migrate from legacy repeat fields to RFC 5545 RRULE#2032

Open
IAMSamuelRodda wants to merge 18 commits intogo-vikunja:mainfrom
IAMSamuelRodda:feat/rrule-upstream-pr
Open

feat(repeat): migrate from legacy repeat fields to RFC 5545 RRULE#2032
IAMSamuelRodda wants to merge 18 commits intogo-vikunja:mainfrom
IAMSamuelRodda:feat/rrule-upstream-pr

Conversation

@IAMSamuelRodda
Copy link
Contributor

Closes #1369

This PR migrates Vikunja's recurrence system from the legacy repeat_after/repeat_mode/repeat_day fields to RFC 5545 compliant RRULE strings, as suggested by @kolaente.

Changes

Backend

  • Uses teambition/rrule-go library for RRULE parsing
  • Database migration (20251229100000) converts legacy repeat data to RRULE format and drops old columns
  • CalDAV: Full roundtrip support - RRULE from incoming VTODO is preserved
  • Todoist and TickTick migrations now import recurrence patterns as RRULE

Frontend

  • New rrule.ts helper for parsing/formatting RRULE strings
  • Updated RepeatAfter.vue component with new UI
  • Quick buttons: Every Day, Week, 30 Days, Month, Quarter, 6 Months, Year
  • i18n strings for new UI elements

RRULE Format

Recurrence is now stored as RFC 5545 RRULE strings:

  • FREQ=DAILY;INTERVAL=1 - Every day
  • FREQ=WEEKLY;INTERVAL=1 - Every week
  • FREQ=MONTHLY;INTERVAL=1;BYMONTHDAY=15 - Monthly on the 15th
  • FREQ=YEARLY;INTERVAL=1 - Yearly (calendar-aware)

This format supports flexible patterns like BYDAY for weekly recurrence and BYMONTHDAY for monthly, providing the foundation for more complex patterns (like "first Tuesday of every other month") in future iterations.


Reopened from #2029 to use a dedicated branch and avoid polluting the PR with unrelated fork commits.

IAMSamuelRodda pushed a commit to IAMSamuelRodda/vikunja that referenced this pull request Jan 4, 2026
- Add FORK.md documenting fork purpose, features, and installation
- Add docs/fork/ directory for demo videos and screenshots
- Add fork banner to README.md pointing to FORK.md
- Document open PRs: go-vikunja#2032 (RRULE), go-vikunja#2031 (filter favorite)
- Include Docker image info: ghcr.io/iamsamuelrodda/vikunja:unstable-fork

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@IAMSamuelRodda IAMSamuelRodda force-pushed the feat/rrule-upstream-pr branch from 00a87dc to 08cc19a Compare January 5, 2026 23:40
Copy link
Member

@kolaente kolaente left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey thanks for the PR!

I've only done a light review now, will take another look at this after the 1.0 release (early February). This is quite a large feature, I'd like to get it right and right now we're pretty close to the 1.0 release.

* Parses an RRULE string into a structured object.
* Example: "FREQ=DAILY;INTERVAL=2" -> { freq: 'DAILY', interval: 2 }
*/
export function parseRRule(rrule: string): ParsedRRule | null {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should try to not parse as much in the frontend about this, because it essentially doubles the logic to what we have in the API. But I'm unsure here how much is actually feasible and what a good data model would look like to pass this across.

}
type tasks20251228214425 struct {
// The day of month (1-31) to repeat on for monthly repeats. 0 means use the due date's day.
RepeatDay int8 `xorm:"tinyint null default 0"`
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we really need this as a new field? Isn't this part of the RRULE?


func setTaskDatesDefault(oldTask, newTask *Task) {
if oldTask.RepeatAfter == 0 {
// Parse the RRULE string
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should validate the rule when saving as well (does this already happen?)

timeDiff = nextOccurrence.Sub(baseDate)
}
// Always set the due date for repeating tasks - if there was no due date,
// the next occurrence becomes the new due date
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a breaking change, right now the repeating interval does nothing when no dates are specified. Please keep it that way (we should add better validation and error handling around this, but that's another topic)


// todoistDueStringToRRule converts Todoist's natural language due string to an RRULE.
// Supports common patterns like "every day", "every week", "every monday", "every 2 weeks", etc.
func todoistDueStringToRRule(dueString string, isRecurring bool) string {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Their docs seem to indicate that it is possible to do this in a lot of languages, not sure if we'll be able to handle all of this (and maintain it). Maybe there's a library to do it?

IAMSamuelRodda and others added 18 commits January 27, 2026 17:35
Adds support for months and years in the repeat interval selector and
implements fixed day-of-month repeating for monthly tasks.

Changes:
- Add months and years options to the repeat interval dropdown
- Add calendar-aware yearly repeat mode (REPEAT_MODE_YEAR = 3)
- Add quick buttons: quarterly, semi-annual, monthly, yearly
- Add "On day" dropdown for monthly repeats (1-31 or same as due date)
- Add repeat_day column to tasks table (migration 20251228214425)
- Add repeat configuration tooltip on repeat icon in task lists
- Fix repeat icon visibility for calendar-aware modes

The fixed day feature allows users to set a task to repeat on a specific
day each month (e.g., always on the 15th). If the selected day doesn't
exist in a month (e.g., 31st in February), it uses the last day.

Closes go-vikunja#1369
BREAKING CHANGE: Replaces repeatAfter/repeatMode/repeatDay with RRULE string

Backend:
- Add migration to convert legacy fields to RRULE format
- Update Task struct to use `repeats` (RRULE string) and `repeatsFromCurrentDate`
- Update CalDAV to parse/generate RRULE directly
- Update Typesense indexing for new field
- Update Microsoft Todo migration

Frontend:
- Add RRULE helper library (src/helpers/rrule.ts)
- Update ITask interface and TaskModel
- Rewrite RepeatAfter.vue component for RRULE
- Update task display components to use isRepeating()
- Update parseTaskText to return RRULE strings
- Remove legacy type files (IRepeatAfter, IRepeatMode)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add translations for everyHour, everyMonthOnDay, and everyN keys
used by describeRRule() function for repeat interval tooltips.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
When a repeating task had no due date, marking it as done would reset
the done status but leave the due date empty. This fix ensures the
next occurrence is always set as the due date for repeating tasks.

Also:
- Fix legacy repeat_after field in test fixture
- Remove obsolete CalDAV repeat test for deleted function

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
CalDAV export already included RRULE in the output, but import was
missing RRULE parsing. Now recurring tasks synced from calendar apps
will have their recurrence pattern preserved.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
The repeat_after column was removed in favor of the new RRULE-based
repeats field. Sorting by repeat_after no longer works.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Update test assertions to use the new repeats/repeats_from_current_date
fields instead of the deprecated repeat_after/repeat_mode fields.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Convert Todoist's natural language recurrence patterns to RFC 5545 RRULE
format during migration. Supports common patterns:
- Basic frequencies (daily, weekly, monthly, yearly)
- Interval variations (every 2 days, every 3 weeks, etc.)
- Weekday patterns (every monday, every friday)
- Special patterns (weekdays, weekends)
- "every other" variations

Unknown or complex patterns are gracefully skipped with debug logging.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
TickTick exports the Repeat field directly in RRULE format, so we can
use it as-is. Added normalizeTickTickRepeat() to handle edge cases:
- Remove RRULE: prefix if present
- Handle multiline repeat rules (take first one)
- Trim whitespace

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Convert if-else chain to switch statement in tasks.go (gocritic)
- Fix gofmt formatting in typesense.go
- Update test expectation to match fixture title (task go-vikunja#28 "repeats")

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Search from now (not baseDate) when due date is in the past to avoid
  returning past dates
- Search from due date when it's in the future to get proper interval
- Calculate timeDiff from baseDate when no due date exists
- Check RepeatsFromCurrentDate before timeDiff to ensure it takes priority
- Use nextOccurrence instead of now for RepeatsFromCurrentDate dates

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Repeating tasks without a due date should not get one auto-assigned
when marked as done. This restores the upstream behavior where
repeat + no due date = no-op.

Addresses maintainer review comment on PR go-vikunja#2032.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
The repeat_day column was a fork-specific field that doesn't exist in
upstream. Remove the migration that added it (20251228214425) and strip
RepeatDay from the RRULE migration struct and conversion function.

Addresses maintainer review comment on PR go-vikunja#2032.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Invalid RRULE strings are now rejected with a 400 error when creating
or updating a task, rather than being silently accepted and only
failing at task completion time.

Addresses maintainer review comment on PR go-vikunja#2032.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Replace raw RRULE string in JSON API with structured TaskRepeat object.
The DB column (repeats) remains an RRULE string; conversion happens at
the API boundary. TaskRepeat mirrors rrule-go ROption 1:1 (14 fields,
excluding Dtstart and Byeaster) for lossless round-tripping.

- Add TaskRepeat struct with JSON serialization
- Add taskRepeatFromRRule/toRRule conversion functions
- Populate Repeat field in addMoreInfoToTasks, createTask, updateSingleTask
- Hide raw Repeats string from JSON (json:"-")
- 34 unit tests (FromRRule, ToRRule, RoundTrip)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Only attempt natural language to RRULE conversion for English (lang="en")
Todoist due strings. Non-English languages are silently skipped with a
debug log, as the regex-based parser only handles English patterns.

The Todoist API provides a `lang` field on due dates indicating the
language of the natural language string. Empty lang defaults to English
(Todoist's default behavior).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Replace all frontend RRULE string parsing with the structured
ITaskRepeat object from the API. The backend now provides structured
repeat data via the 'repeat' field; the raw 'repeats' RRULE string
is hidden from the JSON API.

- Add ITaskRepeat interface to ITask.ts (14 fields matching backend)
- Replace task.repeats (string) with task.repeat (ITaskRepeat | null)
- Rewrite rrule.ts to work with structured objects (no RRULE parsing)
- Update RepeatAfter.vue to read/write structured repeat data
- Update display components (KanbanCard, SingleTaskInProject,
  SingleTaskInlineReadonly) to use isRepeating(task.repeat)
- Update TaskDetailView.vue removeRepeatAfter to set repeat=null
- Update parseTaskText.ts to generate structured repeat objects
- Update task store quick-add to use repeat field
- Update parseTaskText tests for structured objects (721 tests pass)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Update task and task collection web tests to use the new structured
repeat object instead of raw RRULE strings in request/response bodies.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
IAMSamuelRodda pushed a commit to IAMSamuelRodda/vikunja that referenced this pull request Jan 27, 2026
Repeating tasks without a due date should not get one auto-assigned
when marked as done. This restores the upstream behavior where
repeat + no due date = no-op.

Addresses maintainer review comment on PR go-vikunja#2032.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
IAMSamuelRodda pushed a commit to IAMSamuelRodda/vikunja that referenced this pull request Jan 27, 2026
The repeat_day column was a fork-specific field that doesn't exist in
upstream. Remove the migration that added it (20251228214425) and strip
RepeatDay from the RRULE migration struct and conversion function.

Addresses maintainer review comment on PR go-vikunja#2032.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
IAMSamuelRodda pushed a commit to IAMSamuelRodda/vikunja that referenced this pull request Jan 27, 2026
Invalid RRULE strings are now rejected with a 400 error when creating
or updating a task, rather than being silently accepted and only
failing at task completion time.

Addresses maintainer review comment on PR go-vikunja#2032.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@IAMSamuelRodda
Copy link
Contributor Author

Thanks for the review @kolaente. Here is where I am at working through it all thus far:

1. Frontend no longer parses RRULE strings

I had a look with claude at what rrule go library had natively we've come up with an API that now returns a structured repeat object instead of a raw RRULE string:

{
  "repeat": {
    "freq": "monthly",
    "interval": 1,
    "by_month_day": [15]
  }
}

The frontend consumes this directly: no RRULE parsing on the client side. The raw repeats string is hidden from JSON (json:"-") and only used internally for DB storage and CalDAV. As to whether this is a good structure, I'll let you weigh in on that.

2. RepeatDay column removed

Noted regarding your comment around repeat_day being redundant with RRULE's BYMONTHDAY. We've drop this along with the other legacy fields.

3. RRULE validated on save

Task create and update now validate the RRULE string via rrule-go before persisting. Invalid rules return a 400 error.

4. No auto-set due date on repeating tasks

As requested, we've reverted the repeating tasks without a due date functionality - thus it now remain unchanged (no-op behavior preserved).

5. Todoist language gate

So, as I understand it, the NLP parsing that Todoist does it limited to key words specific to each supported language. It appears this is maintained directly by todoist; at least, I have not yet found an existing library that would handle this across Non-English languages. So, for now, Non-English recurrence strings are skipped during import (due.lang field check). The NLP parsing only runs for lang == "" || lang == "en". This avoids incorrect conversions without requiring a multi-language NLP library.

Let me know your thoughts.

I'm currently deploying this into my personal server; I will update the PR if I find any bugs over the next day or so.

Copy link
Member

@kolaente kolaente left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few more things, but it feels like we're getting somewhere.

Please reply to each comment individually or mark them as resolved, replying in one bunch to all comments may be easier for claude but it makes it harder for me to follow.

@click="() => setQuickRepeat('yearly', 1)"
>
{{ $t('task.repeat.everyYear') }}
</XButton>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the additional options, is this still readable?

<label class="is-fullwidth">
<input
v-model="repeatsFromCurrentDate"
type="checkbox"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should use the Fancycheckbox component

@@ -11,6 +13,21 @@ export type PeriodUnit = 'seconds' | 'minutes' | 'hours' | 'days' | 'weeks' | 'm
* Convert time period given as seconds to days, hour, minutes, seconds
*/
export function secondsToPeriod(seconds: number): { unit: PeriodUnit, amount: number } {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this still required? From what I understand, the RRULE should replace this.

useTitle(taskTitle)

// See https://github.com/github/hotkey/discussions/85#discussioncomment-5214660
function saveTaskViaHotkey(event) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems like a rebasing error

}

// Remember which fields were open before saving (to preserve edit state)
const repeatWasOpen = activeFields.repeatAfter
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is this needed?

// The recurrence frequency: "yearly", "monthly", "weekly", "daily", "hourly", "minutely", or "secondly".
Freq string `json:"freq"`
// The interval between occurrences. Defaults to 1.
Interval int `json:"interval"`
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this defined as int in the spec or int64 or uint?


// taskRepeatFromRRule converts an RRULE string to a TaskRepeat struct.
// Returns nil if the input is empty.
func taskRepeatFromRRule(rruleStr string) *TaskRepeat {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When is this function actually used?

// @Param per_page query int false "The maximum number of items per page. Note this parameter is limited by the configured maximum of items per page."
// @Param s query string false "Search tasks by task text."
// @Param sort_by query string false "The sorting parameter. You can pass this multiple times to get the tasks ordered by multiple different parametes, along with `order_by`. Possible values to sort by are `id`, `title`, `description`, `done`, `done_at`, `due_date`, `created_by_id`, `project_id`, `repeat_after`, `priority`, `start_date`, `end_date`, `hex_color`, `percent_done`, `uid`, `created`, `updated`. Default is `id`."
// @Param sort_by query string false "The sorting parameter. You can pass this multiple times to get the tasks ordered by multiple different parametes, along with `order_by`. Possible values to sort by are `id`, `title`, `description`, `done`, `done_at`, `due_date`, `created_by_id`, `project_id`, `priority`, `start_date`, `end_date`, `hex_color`, `percent_done`, `uid`, `created`, `updated`. Default is `id`."
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing repeat

assert.NotEqual(t, oldEndDate.Month(), newTask.EndDate.Month())
assert.False(t, newTask.Done)
})
t.Run("start and end date", func(t *testing.T) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why was this test removed?

DoneAt: t.CompletedTime.Time,
Position: t.Order,
Labels: labels,
Repeats: normalizeTickTickRepeat(t.Repeat),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this should be validated

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

More precise settings for the recurrence of a task

2 participants

Comments