Skip to content

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 and others added 11 commits January 6, 2026 10:08
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>
@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?

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

More precise settings for the recurrence of a task

2 participants