feat(repeat): migrate from legacy repeat fields to RFC 5545 RRULE#2032
feat(repeat): migrate from legacy repeat fields to RFC 5545 RRULE#2032IAMSamuelRodda wants to merge 18 commits intogo-vikunja:mainfrom
Conversation
- 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>
00a87dc to
08cc19a
Compare
kolaente
left a comment
There was a problem hiding this comment.
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.
frontend/src/helpers/rrule.ts
Outdated
| * Parses an RRULE string into a structured object. | ||
| * Example: "FREQ=DAILY;INTERVAL=2" -> { freq: 'DAILY', interval: 2 } | ||
| */ | ||
| export function parseRRule(rrule: string): ParsedRRule | null { |
There was a problem hiding this comment.
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.
pkg/migration/20251228214425.go
Outdated
| } | ||
| 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"` |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
We should validate the rule when saving as well (does this already happen?)
pkg/models/tasks.go
Outdated
| 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 |
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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?
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>
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>
|
Thanks for the review @kolaente. Here is where I am at working through it all thus far: 1. Frontend no longer parses RRULE stringsI 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": {
"freq": "monthly",
"interval": 1,
"by_month_day": [15]
}
}The frontend consumes this directly: no RRULE parsing on the client side. The raw 2. RepeatDay column removedNoted regarding your comment around 3. RRULE validated on saveTask create and update now validate the RRULE string via 4. No auto-set due date on repeating tasksAs requested, we've reverted the repeating tasks without a due date functionality - thus it now remain unchanged (no-op behavior preserved). 5. Todoist language gateSo, 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 ( 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. |
08cc19a to
5df3c3b
Compare
kolaente
left a comment
There was a problem hiding this comment.
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> |
There was a problem hiding this comment.
With the additional options, is this still readable?
| <label class="is-fullwidth"> | ||
| <input | ||
| v-model="repeatsFromCurrentDate" | ||
| type="checkbox" |
There was a problem hiding this comment.
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 } { | |||
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
This seems like a rebasing error
| } | ||
|
|
||
| // Remember which fields were open before saving (to preserve edit state) | ||
| const repeatWasOpen = activeFields.repeatAfter |
| // 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"` |
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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`." |
| assert.NotEqual(t, oldEndDate.Month(), newTask.EndDate.Month()) | ||
| assert.False(t, newTask.Done) | ||
| }) | ||
| t.Run("start and end date", func(t *testing.T) { |
| DoneAt: t.CompletedTime.Time, | ||
| Position: t.Order, | ||
| Labels: labels, | ||
| Repeats: normalizeTickTickRepeat(t.Repeat), |
Closes #1369
This PR migrates Vikunja's recurrence system from the legacy
repeat_after/repeat_mode/repeat_dayfields to RFC 5545 compliant RRULE strings, as suggested by @kolaente.Changes
Backend
teambition/rrule-golibrary for RRULE parsing20251229100000) converts legacy repeat data to RRULE format and drops old columnsFrontend
rrule.tshelper for parsing/formatting RRULE stringsRepeatAfter.vuecomponent with new UIRRULE Format
Recurrence is now stored as RFC 5545 RRULE strings:
FREQ=DAILY;INTERVAL=1- Every dayFREQ=WEEKLY;INTERVAL=1- Every weekFREQ=MONTHLY;INTERVAL=1;BYMONTHDAY=15- Monthly on the 15thFREQ=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.