Merge pull request #86 from Gimanh/feat/recurring-by-completion

wip: recurring
This commit is contained in:
Nikolai Giman
2026-07-14 17:46:05 +02:00
committed by GitHub
24 changed files with 627 additions and 61 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "taskview-ce-api-server",
"version": "1.50.2",
"version": "1.50.3",
"scripts": {
"dev": "bun run --watch ./server.ts",
"start": "NODE_ENV=production node ./dist/taskview-server.js",
@@ -83,4 +83,4 @@
"engines": {
"node": ">=24 <25"
}
}
}
+12 -1
View File
@@ -704,5 +704,16 @@
"description": [
"Add external_team_id to messaging_identity_map so Slack identities are keyed by (provider, team, user) — prevents cross-workspace identity collision"
]
},
"55": {
"version": "1.60.0",
"name": "Recurrence schedule mode",
"releaseDate": "20260712",
"scripts": [
"/1.60.0/0.alter-recurrence-add-schedule-mode.sql"
],
"description": [
"Add schedule_mode to recurrence_rules: 'fixed' (calendar schedule) or 'after-completion' (next occurrence = completion day + interval)"
]
}
}
}
@@ -0,0 +1,10 @@
-- 'fixed' — occurrences follow the calendar schedule (rrule anchored at dtstart);
-- 'after-completion' — the next occurrence is one FREQ/INTERVAL step after the
-- day the current instance was completed (Todoist "every!"), no calendar anchor.
ALTER TABLE tasks.recurrence_rules
ADD COLUMN IF NOT EXISTS schedule_mode VARCHAR(20) NOT NULL DEFAULT 'fixed';
ALTER TABLE tasks.recurrence_rules
DROP CONSTRAINT IF EXISTS recurrence_schedule_mode_valid;
ALTER TABLE tasks.recurrence_rules
ADD CONSTRAINT recurrence_schedule_mode_valid CHECK (schedule_mode IN ('fixed', 'after-completion'));
@@ -84,12 +84,20 @@ export class RecurrenceGenerator {
// Completed late → next from today, not a pile of overdue copies (Todoist behavior).
const today = RecurrenceParser.todayInTimezone(rule.timezone);
const afterDate = rule.lastInstanceDate > today ? rule.lastInstanceDate : today;
const nextDate = RecurrenceParser.nextOccurrenceDate({
rrule: rule.rrule,
dtstart: rule.dtstart,
afterDate,
skipDates,
});
// Fixed series follow the calendar schedule; after-completion series
// take one interval step from the completion day. Stepping from
// max(lastInstanceDate, today) keeps instance dates strictly
// increasing, so the (rule_id, instance_date) unique index can
// never collide with an earlier instance of the series.
const nextDate =
rule.scheduleMode === 'after-completion'
? RecurrenceParser.nextDateAfterCompletion({ rrule: rule.rrule, afterDate })
: RecurrenceParser.nextOccurrenceDate({
rrule: rule.rrule,
dtstart: rule.dtstart,
afterDate,
skipDates,
});
if (!nextDate) {
await tx
.update(RecurrenceRulesSchema)
@@ -61,10 +61,12 @@ export class RecurrenceManager {
return fail('invalid_rule', 'timezone must be a valid IANA name');
}
const scheduleMode = args.scheduleMode ?? 'fixed';
let dtstart: Date;
let hasTime: boolean;
try {
RecurrenceParser.validateRuleString(args.rrule);
if (scheduleMode === 'after-completion') RecurrenceParser.validateForAfterCompletion(args.rrule);
({ date: dtstart, hasTime } = RecurrenceParser.parseDtstart(args.dtstart));
} catch (err) {
return fail('invalid_rule', (err as Error).message);
@@ -99,6 +101,7 @@ export class RecurrenceManager {
dtstart,
hasTime,
timezone: args.timezone,
scheduleMode,
lastInstanceDate: originInstanceDate,
notifyOnOccurrence: args.notifyOnOccurrence ?? false,
creatorId: this.initiatorId,
@@ -196,13 +199,26 @@ export class RecurrenceManager {
}
patch.timezone = args.timezone;
}
if (patch.rrule !== undefined || patch.dtstart !== undefined) {
const nextDate = RecurrenceParser.nextOccurrenceDate({
rrule: patch.rrule ?? rule.rrule,
dtstart: patch.dtstart ?? rule.dtstart,
afterDate: RecurrenceParser.todayInTimezone(patch.timezone ?? rule.timezone),
skipDates: new Set<string>(),
});
if (args.scheduleMode !== undefined) patch.scheduleMode = args.scheduleMode;
const effectiveMode = patch.scheduleMode ?? rule.scheduleMode;
if (effectiveMode === 'after-completion') {
try {
RecurrenceParser.validateForAfterCompletion(patch.rrule ?? rule.rrule);
} catch (err) {
return fail('invalid_rule', (err as Error).message);
}
}
if (patch.rrule !== undefined || patch.dtstart !== undefined || patch.scheduleMode !== undefined) {
const afterDate = RecurrenceParser.todayInTimezone(patch.timezone ?? rule.timezone);
const nextDate =
effectiveMode === 'after-completion'
? RecurrenceParser.nextDateAfterCompletion({ rrule: patch.rrule ?? rule.rrule, afterDate })
: RecurrenceParser.nextOccurrenceDate({
rrule: patch.rrule ?? rule.rrule,
dtstart: patch.dtstart ?? rule.dtstart,
afterDate,
skipDates: new Set<string>(),
});
if (!nextDate) return fail('invalid_rule', 'rule produces no occurrences');
}
if (args.notifyOnOccurrence !== undefined) patch.notifyOnOccurrence = args.notifyOnOccurrence;
@@ -1,10 +1,17 @@
import { DateTime } from 'luxon';
import { RRule } from 'rrule';
import type { InstanceWindow, InstanceWindowArgs, NextOccurrenceArgs, ParseRuleArgs } from './types';
import type { InstanceWindow, InstanceWindowArgs, NextDateAfterCompletionArgs, NextOccurrenceArgs, ParseRuleArgs } from './types';
const ALLOWED_FREQUENCIES = new Set<number>([RRule.YEARLY, RRule.MONTHLY, RRule.WEEKLY, RRule.DAILY]);
const MAX_COUNT = 10000;
const FREQ_TO_STEP_UNIT: Record<number, 'years' | 'months' | 'weeks' | 'days'> = {
[RRule.YEARLY]: 'years',
[RRule.MONTHLY]: 'months',
[RRule.WEEKLY]: 'weeks',
[RRule.DAILY]: 'days',
};
/**
* All recurrence math happens in a single floating wall-clock frame:
* `dtstart` is a Date whose UTC components equal the wall-clock components of
@@ -41,6 +48,40 @@ export class RecurrenceParser {
return RRule.parseString(rruleString).count ?? null;
}
/**
* After-completion series step from the completion day, so calendar anchors
* (BYDAY, BYMONTHDAY) have no defined meaning for them — reject instead of
* silently ignoring what the client asked for.
*/
static validateForAfterCompletion(rruleString: string): void {
const options = RRule.parseString(rruleString);
if (options.byweekday !== undefined && options.byweekday !== null) {
throw new Error('BYDAY is not supported for after-completion series');
}
if (options.bymonthday !== undefined && options.bymonthday !== null) {
throw new Error('BYMONTHDAY is not supported for after-completion series');
}
}
/**
* Next date of an after-completion series: one FREQ/INTERVAL step after
* `afterDate` (the completion day), no calendar anchor. Month/year steps
* clamp to the last valid day (Jan 31 + 1 month → Feb 28). COUNT is
* enforced by the caller via instances_created (same as fixed series);
* returns null when the step lands past UNTIL — the series is over.
*/
static nextDateAfterCompletion(args: NextDateAfterCompletionArgs): string | null {
const options = RRule.parseString(args.rrule);
const unit = options.freq !== undefined ? FREQ_TO_STEP_UNIT[options.freq] : undefined;
if (!unit) return null;
const nextDate = DateTime.fromISO(args.afterDate, { zone: 'utc' })
.plus({ [unit]: options.interval ?? 1 })
.toISODate();
if (!nextDate) return null;
if (options.until && nextDate > RecurrenceParser.toIsoDate(options.until)) return null;
return nextDate;
}
/**
* First occurrence date strictly after `afterDate`, skipping explicit skip
* dates. COUNT is intentionally stripped: the cap is "N materialized
+9
View File
@@ -2,6 +2,7 @@ import { type } from 'arktype';
import type {
RecurrenceRulesSchemaTypeForInsert,
RecurrenceRulesSchemaTypeForSelect,
RecurrenceScheduleMode,
TasksSchemaTypeForSelect,
} from 'taskview-db-schemas';
@@ -12,6 +13,7 @@ export const RecurrenceArkTypeCreate = type({
rrule: 'string > 0',
dtstart: 'string', // 'YYYY-MM-DDTHH:mm:ss' floating wall-clock, no TZ suffix
timezone: 'string > 0', // IANA name, e.g. 'Europe/Moscow'
'scheduleMode?': '"fixed" | "after-completion"',
'notifyOnOccurrence?': 'boolean',
});
@@ -20,6 +22,7 @@ export const RecurrenceArkTypeUpdate = type({
'rrule?': 'string > 0',
'dtstart?': 'string',
'timezone?': 'string > 0',
'scheduleMode?': '"fixed" | "after-completion"',
'notifyOnOccurrence?': 'boolean',
'templateOverrides?': type({
'description?': 'string',
@@ -63,6 +66,11 @@ export type NextOccurrenceArgs = {
afterDate: string;
skipDates: Set<string>;
};
export type NextDateAfterCompletionArgs = {
rrule: string;
/** 'YYYY-MM-DD' — the completion day; the next date is one FREQ/INTERVAL step after it. */
afterDate: string;
};
export type InstanceWindowArgs = {
/** 'YYYY-MM-DD' wall-clock occurrence date in the rule's timezone. */
occurrenceDate: string;
@@ -93,6 +101,7 @@ export type RecurrenceRulePatchArgs = {
dtstart: Date;
hasTime: boolean;
timezone: string;
scheduleMode: RecurrenceScheduleMode;
state: 'active' | 'paused' | 'ended';
lastInstanceDate: string;
instancesCreated: number;
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "taskview-ce-monorepo",
"version": "1.50.2",
"version": "1.50.3",
"private": true,
"description": "TaskView CE monorepo containing web, API, and packages",
"workspaces": [
@@ -44,4 +44,4 @@
"passport-github2": "^0.1.12",
"passport-google-oauth20": "^2.0.0"
}
}
}
@@ -32,6 +32,8 @@ services:
condition: service_completed_successfully
env_file:
- .env.taskview
extra_hosts:
- "host.docker.internal:host-gateway"
healthcheck:
test: ["CMD-SHELL", "curl -so /dev/null http://localhost:1401/ || exit 1"]
interval: 3s
@@ -0,0 +1,383 @@
import { TvApi } from '@/tv'
import {
describe,
it,
expect,
beforeAll,
afterAll,
} from 'vitest'
import axios, { type AxiosInstance } from 'axios'
import { initApi, API_URL, DEFAULT_USER, DEFAULT_PASSWORD } from './init-api'
import { ymd } from './test-helpers'
import type { RecurrenceRuleDetails } from '@/api/recurrence.types'
/**
* Integration tests for the 'after-completion' schedule mode.
*
* Unlike the fixed mode (calendar grid anchored to dtstart), an
* after-completion series has no calendar anchor: the next instance date is
* exactly one FREQ/INTERVAL step after max(lastInstanceDate, today) — dates
* stay strictly increasing even when the card is completed early, and BYDAY /
* BYMONTHDAY have no defined meaning and are rejected.
*
* Same deterministic frame as recurrence.test.ts: Europe/Moscow (fixed UTC+3),
* 10:45 wall-clock → 07:45:00 UTC stored.
*/
describe('Recurrence (after-completion)', () => {
let $api: TvApi
let raw: AxiosInstance
let goalId: number
const MSK_TIME = 'T10:45:00'
const UTC_TIME = '07:45:00'
beforeAll(async () => {
const { $tvApi } = await initApi()
$api = $tvApi
const auth = await axios.post(`${API_URL}/module/auth/login`, {
login: DEFAULT_USER,
password: DEFAULT_PASSWORD,
})
raw = axios.create({
baseURL: API_URL,
headers: { Authorization: `Bearer ${auth.data.access}` },
validateStatus: () => true,
})
const goal = await $api.goals.createGoal({ name: `After-completion test project-${Date.now()}` })
if (!goal) throw new Error('Failed to create goal')
goalId = goal.id!
})
afterAll(async () => {
await $api.goals.deleteGoal(goalId).catch(() => {})
})
async function createTask(description: string, startDate = ymd(3)) {
const task = await $api.tasks.createTask({
goalId,
description,
startDate,
startTime: UTC_TIME,
endDate: startDate,
endTime: '08:45:00',
})
if (!task) throw new Error('Failed to create task')
return task
}
async function createAcRule(taskId: number, rrule: string, startDate = ymd(3)) {
return await $api.recurrence.create({
taskId,
rrule,
dtstart: `${startDate}${MSK_TIME}`,
timezone: 'Europe/Moscow',
scheduleMode: 'after-completion',
})
}
/** Last day of the month `monthsAhead` months from now ('YYYY-MM-DD', UTC). */
function lastDayOfMonth(monthsAhead: number): string {
const now = new Date()
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + monthsAhead + 1, 0)).toISOString().slice(0, 10)
}
/** `iso` plus `months` calendar months, day clamped to the target month's length (luxon semantics). */
function addMonthsClamped(iso: string, months: number): string {
const [y, m, d] = iso.split('-').map(Number)
const lastDay = new Date(Date.UTC(y, m - 1 + months + 1, 0)).getUTCDate()
return new Date(Date.UTC(y, m - 1 + months, Math.min(d, lastDay))).toISOString().slice(0, 10)
}
/** Polls the rule details until the predicate holds (materialization is async). */
async function waitFor(
ruleId: number,
predicate: (details: RecurrenceRuleDetails) => boolean,
timeoutMs = 8000,
): Promise<RecurrenceRuleDetails> {
const startedAt = Date.now()
for (;;) {
const details = await $api.recurrence.getById(ruleId).catch(() => null)
if (details && predicate(details)) return details
if (Date.now() - startedAt > timeoutMs) {
throw new Error(`waitFor timed out for rule ${ruleId}: ${JSON.stringify(details)?.slice(0, 300)}`)
}
await new Promise((r) => setTimeout(r, 300))
}
}
describe('validation', () => {
const base = { dtstart: `${ymd(3)}${MSK_TIME}`, timezone: 'Europe/Moscow', scheduleMode: 'after-completion' }
it('rejects an unknown scheduleMode value (request shape, 400)', async () => {
const task = await createTask('Bad mode target')
const res = await raw.post('/module/recurrence', { taskId: task.id, rrule: 'FREQ=DAILY', ...base, scheduleMode: 'whenever' })
expect(res.status).toBe(400)
})
it('rejects an update that leaves no next step (UNTIL in the past)', async () => {
const task = await createTask('Dead-end update')
const rule = await createAcRule(task.id, 'FREQ=DAILY')
const res = await raw.patch(`/module/recurrence/${rule.id}`, { rrule: 'FREQ=DAILY;UNTIL=20000101T000000Z' })
expect(res.status).toBe(422)
})
it('rejects BYDAY on create (no calendar anchor in this mode)', async () => {
const task = await createTask('BYDAY target')
const res = await raw.post('/module/recurrence', { taskId: task.id, rrule: 'FREQ=WEEKLY;BYDAY=MO', ...base })
expect(res.status).toBe(422)
})
it('rejects BYMONTHDAY on create', async () => {
const task = await createTask('BYMONTHDAY target')
const res = await raw.post('/module/recurrence', { taskId: task.id, rrule: 'FREQ=MONTHLY;BYMONTHDAY=-1', ...base })
expect(res.status).toBe(422)
})
it('rejects switching a BYDAY fixed rule to after-completion', async () => {
const task = await createTask('Anchored fixed')
const rule = await $api.recurrence.create({
taskId: task.id,
rrule: 'FREQ=WEEKLY;BYDAY=MO,TH',
dtstart: `${ymd(3)}${MSK_TIME}`,
timezone: 'Europe/Moscow',
})
const res = await raw.patch(`/module/recurrence/${rule.id}`, { scheduleMode: 'after-completion' })
expect(res.status).toBe(422)
const intact = await $api.recurrence.getById(rule.id)
expect(intact?.rule.scheduleMode).toBe('fixed')
})
it('rejects updating the rrule to BYDAY while the mode stays after-completion', async () => {
const task = await createTask('Stays unanchored')
const rule = await createAcRule(task.id, 'FREQ=DAILY')
const res = await raw.patch(`/module/recurrence/${rule.id}`, { rrule: 'FREQ=WEEKLY;BYDAY=FR' })
expect(res.status).toBe(422)
})
it('allows BYDAY when the same PATCH switches the rule back to fixed', async () => {
const task = await createTask('Mode and rrule together')
const rule = await createAcRule(task.id, 'FREQ=DAILY')
// validation must run against the NEW mode, not the stored one
const res = await raw.patch(`/module/recurrence/${rule.id}`, {
scheduleMode: 'fixed',
rrule: 'FREQ=WEEKLY;BYDAY=MO',
})
expect(res.status).toBe(200)
const updated = await $api.recurrence.getById(rule.id)
expect(updated?.rule.scheduleMode).toBe('fixed')
expect(updated?.rule.rrule).toContain('BYDAY=MO')
})
})
describe('lifecycle', () => {
it('a rule created without scheduleMode defaults to fixed', async () => {
const task = await createTask('Default mode')
const rule = await $api.recurrence.create({
taskId: task.id,
rrule: 'FREQ=DAILY',
dtstart: `${ymd(3)}${MSK_TIME}`,
timezone: 'Europe/Moscow',
})
expect(rule.scheduleMode).toBe('fixed')
})
it('origin task becomes the open instance, mode carried in rule and details', async () => {
const task = await createTask('AC standup')
const rule = await createAcRule(task.id, 'FREQ=DAILY')
expect(rule.scheduleMode).toBe('after-completion')
expect(rule.state).toBe('active')
expect(rule.instancesCreated).toBe(1)
const details = await $api.recurrence.getForTask(task.id)
expect(details?.rule.scheduleMode).toBe('after-completion')
expect(details?.openInstance?.id).toBe(task.id)
expect(details?.openInstance?.recurrenceInstanceDate).toBe(ymd(3))
expect(details?.openInstance?.startTime).toBe(UTC_TIME)
})
it('daily: completing steps one day past the scheduled date, even when completed early', async () => {
const task = await createTask('AC daily')
const rule = await createAcRule(task.id, 'FREQ=DAILY')
// completed today, 3 days ahead of schedule — the next date steps from
// the scheduled day (max(lastInstanceDate, today)), NOT from today,
// so instance dates stay strictly increasing
await $api.tasks.updateTask({ id: task.id, complete: true })
const details = await waitFor(rule.id, (d) => !!d.openInstance && d.openInstance.id !== task.id)
expect(details.openInstance?.recurrenceInstanceDate).toBe(ymd(4))
expect(details.openInstance?.startTime).toBe(UTC_TIME)
expect(details.openInstance?.complete).toBe(false)
expect(details.openInstance?.description).toBe('AC daily')
expect(details.rule.instancesCreated).toBe(2)
})
it('INTERVAL is respected: every 3 days lands 3 days after the scheduled date', async () => {
const task = await createTask('AC every 3 days')
const rule = await createAcRule(task.id, 'FREQ=DAILY;INTERVAL=3')
await $api.tasks.updateTask({ id: task.id, complete: true })
const details = await waitFor(rule.id, (d) => !!d.openInstance && d.openInstance.id !== task.id)
expect(details.openInstance?.recurrenceInstanceDate).toBe(ymd(6))
})
it('weekly: exactly +7 days, no weekday grid (contrast with fixed BYDAY)', async () => {
const task = await createTask('AC weekly')
const rule = await createAcRule(task.id, 'FREQ=WEEKLY')
await $api.tasks.updateTask({ id: task.id, complete: true })
const details = await waitFor(rule.id, (d) => !!d.openInstance && d.openInstance.id !== task.id)
expect(details.openInstance?.recurrenceInstanceDate).toBe(ymd(10))
})
it('monthly: the step clamps to the last valid day and does not re-anchor to month end', async () => {
const start = lastDayOfMonth(1)
const task = await createTask('AC monthly close', start)
const rule = await createAcRule(task.id, 'FREQ=MONTHLY', start)
await $api.tasks.updateTask({ id: task.id, complete: true })
const first = addMonthsClamped(start, 1)
const second = await waitFor(rule.id, (d) => !!d.openInstance && d.openInstance.id !== task.id)
// clamped calendar step, NOT a month-end anchor: from a 31-day month end
// it lands on the 30th of a 30-day month
expect(second.openInstance?.recurrenceInstanceDate).toBe(first)
// the clamped day is what steps forward: Aug 31 → Sep 30 → Oct 30
// (fixed BYMONTHDAY=-1 would re-anchor to Oct 31)
await $api.tasks.updateTask({ id: second.openInstance!.id, complete: true })
const third = await waitFor(rule.id, (d) => !!d.openInstance && d.openInstance.id !== second.openInstance!.id)
expect(third.openInstance?.recurrenceInstanceDate).toBe(addMonthsClamped(first, 1))
})
it('yearly: exactly +1 year from the scheduled date', async () => {
const task = await createTask('AC yearly review')
const rule = await createAcRule(task.id, 'FREQ=YEARLY')
await $api.tasks.updateTask({ id: task.id, complete: true })
const details = await waitFor(rule.id, (d) => !!d.openInstance && d.openInstance.id !== task.id)
expect(details.openInstance?.recurrenceInstanceDate).toBe(addMonthsClamped(ymd(3), 12))
})
it('COUNT=1: completing the origin ends the series without a successor', async () => {
const task = await createTask('AC one-shot')
const rule = await createAcRule(task.id, 'FREQ=DAILY;COUNT=1')
await $api.tasks.updateTask({ id: task.id, complete: true })
const ended = await waitFor(rule.id, (d) => d.rule.state === 'ended')
expect(ended.openInstance).toBeNull()
expect(ended.rule.instancesCreated).toBe(1)
})
it('pause blocks the completion step; resume materializes it', async () => {
const task = await createTask('AC pausable')
const rule = await createAcRule(task.id, 'FREQ=DAILY')
await $api.recurrence.pause(rule.id)
await $api.tasks.updateTask({ id: task.id, complete: true })
await new Promise((r) => setTimeout(r, 1500))
const whilePaused = await $api.recurrence.getById(rule.id)
expect(whilePaused?.openInstance).toBeNull()
expect(whilePaused?.rule.instancesCreated).toBe(1)
await $api.recurrence.resume(rule.id)
const restored = await waitFor(rule.id, (d) => !!d.openInstance)
expect(restored.openInstance?.recurrenceInstanceDate).toBe(ymd(4))
})
it('a COUNT-limited series ends after the last instance is completed', async () => {
const task = await createTask('AC twice and done')
const rule = await createAcRule(task.id, 'FREQ=DAILY;COUNT=2')
await $api.tasks.updateTask({ id: task.id, complete: true })
const second = await waitFor(rule.id, (d) => d.rule.instancesCreated === 2)
expect(second.openInstance?.recurrenceInstanceDate).toBe(ymd(4))
await $api.tasks.updateTask({ id: second.openInstance!.id, complete: true })
const ended = await waitFor(rule.id, (d) => d.rule.state === 'ended')
expect(ended.openInstance).toBeNull()
expect(ended.rule.instancesCreated).toBe(2)
})
it('an UNTIL-bounded series ends once the next step lands past the boundary', async () => {
const task = await createTask('AC until')
const until = `${ymd(4).replace(/-/g, '')}T235959Z`
const rule = await createAcRule(task.id, `FREQ=DAILY;UNTIL=${until}`)
await $api.tasks.updateTask({ id: task.id, complete: true })
const second = await waitFor(rule.id, (d) => !!d.openInstance && d.openInstance.id !== task.id)
expect(second.openInstance?.recurrenceInstanceDate).toBe(ymd(4))
// next step would be ymd(5) > UNTIL — the series is over
await $api.tasks.updateTask({ id: second.openInstance!.id, complete: true })
const ended = await waitFor(rule.id, (d) => d.rule.state === 'ended')
expect(ended.openInstance).toBeNull()
})
it('skip jumps the card one interval step and records the skipped date', async () => {
const task = await createTask('AC skippable')
const rule = await createAcRule(task.id, 'FREQ=DAILY')
const details = await $api.recurrence.skip(rule.id)
expect(details.skipDates).toContain(ymd(3))
expect(details.openInstance?.recurrenceInstanceDate).toBe(ymd(4))
expect(details.rule.instancesCreated).toBe(2)
})
it('switching a live fixed series to after-completion takes effect on the next completion', async () => {
const task = await createTask('Mode switch mid-series')
const rule = await $api.recurrence.create({
taskId: task.id,
rrule: 'FREQ=DAILY;INTERVAL=3',
dtstart: `${ymd(3)}${MSK_TIME}`,
timezone: 'Europe/Moscow',
})
const switched = await $api.recurrence.update({ ruleId: rule.id, scheduleMode: 'after-completion' })
expect(switched.scheduleMode).toBe('after-completion')
await $api.tasks.updateTask({ id: task.id, complete: true })
const details = await waitFor(rule.id, (d) => !!d.openInstance && d.openInstance.id !== task.id)
expect(details.openInstance?.recurrenceInstanceDate).toBe(ymd(6))
expect(details.rule.scheduleMode).toBe('after-completion')
})
it('a date-only after-completion series stays date-only on the next instance', async () => {
const task = await $api.tasks.createTask({ goalId, description: 'AC date-only', startDate: ymd(3) })
const rule = await $api.recurrence.create({
taskId: task!.id,
rrule: 'FREQ=DAILY',
dtstart: ymd(3),
timezone: 'Europe/Moscow',
scheduleMode: 'after-completion',
})
expect(rule.hasTime).toBe(false)
await $api.tasks.updateTask({ id: task!.id, complete: true })
const details = await waitFor(rule.id, (d) => !!d.openInstance && d.openInstance.id !== task!.id)
expect(details.openInstance?.startDate).toBe(ymd(4))
expect(details.openInstance?.endDate).toBe(ymd(4))
expect(details.openInstance?.startTime).toBeNull()
expect(details.openInstance?.endTime).toBeNull()
})
it('template overrides apply to the next materialized instance', async () => {
const task = await createTask('AC old name')
const rule = await createAcRule(task.id, 'FREQ=DAILY')
await $api.recurrence.update({
ruleId: rule.id,
templateOverrides: { description: 'AC new name', priorityId: 3 },
})
await $api.tasks.updateTask({ id: task.id, complete: true })
const details = await waitFor(rule.id, (d) => !!d.openInstance && d.openInstance.id !== task.id)
expect(details.openInstance?.description).toBe('AC new name')
expect(details.openInstance?.priorityId).toBe(3)
})
})
})
@@ -1,17 +1,6 @@
import { createServer, type IncomingMessage, type ServerResponse } from 'http'
import { networkInterfaces } from 'os'
function getLocalIp(): string {
const nets = networkInterfaces()
for (const name of Object.keys(nets)) {
for (const net of nets[name]!) {
if (net.family === 'IPv4' && !net.internal) {
return net.address
}
}
}
return '127.0.0.1'
}
const RECEIVER_HOST = process.env.TASKVIEW_TEST_WEBHOOK_HOST || 'host.docker.internal'
export type ReceivedWebhook = {
body: any
@@ -42,12 +31,10 @@ export function createWebhookReceiver() {
})
})
const ip = getLocalIp()
return {
server,
received,
getUrl: () => `http://${ip}:${(server.address() as any).port}/webhook`,
getUrl: () => `http://${RECEIVER_HOST}:${(server.address() as any).port}/webhook`,
start: () => new Promise<void>((resolve) => {
server.listen(0, '0.0.0.0', () => resolve())
}),
@@ -1,6 +1,8 @@
import type { Task } from './tasks.api.types';
export type RecurrenceState = 'active' | 'paused' | 'ended';
/** 'fixed' — calendar schedule; 'after-completion' — next occurrence is one interval step after the completion day. */
export type RecurrenceScheduleMode = 'fixed' | 'after-completion';
export type RecurrenceRule = {
id: number;
@@ -20,6 +22,7 @@ export type RecurrenceRule = {
hasTime: boolean;
/** IANA timezone name, e.g. 'Europe/Moscow'. */
timezone: string;
scheduleMode: RecurrenceScheduleMode;
state: RecurrenceState;
lastInstanceDate: string;
instancesCreated: number;
@@ -41,6 +44,7 @@ export type RecurrenceCreateArgs = {
/** 'YYYY-MM-DD' for a date-only series, 'YYYY-MM-DDTHH:mm:ss' for a timed one (incl. 00:00). */
dtstart: string;
timezone: string;
scheduleMode?: RecurrenceScheduleMode;
notifyOnOccurrence?: boolean;
};
@@ -58,6 +62,7 @@ export type RecurrenceUpdateArgs = {
rrule?: string;
dtstart?: string;
timezone?: string;
scheduleMode?: RecurrenceScheduleMode;
notifyOnOccurrence?: boolean;
templateOverrides?: RecurrenceTemplateOverrides;
};
@@ -4,6 +4,7 @@ import { TasksSchema } from "./tasks.schema";
import { UsersSchema } from "./users.schema";
export type RecurrenceState = 'active' | 'paused' | 'ended';
export type RecurrenceScheduleMode = 'fixed' | 'after-completion';
export const RecurrenceRulesSchema = pgSchema('tasks').table('recurrence_rules', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
@@ -20,6 +21,7 @@ export const RecurrenceRulesSchema = pgSchema('tasks').table('recurrence_rules',
hasTime: boolean('has_time').notNull().default(false),
timezone: varchar({ length: 50 }).notNull(),
state: varchar({ length: 20 }).$type<RecurrenceState>().notNull().default('active'),
scheduleMode: varchar('schedule_mode', { length: 20 }).$type<RecurrenceScheduleMode>().notNull().default('fixed'),
lastInstanceDate: date('last_instance_date').notNull(),
instancesCreated: integer('instances_created').notNull().default(1),
notifyOnOccurrence: boolean('notify_on_occurrence').notNull().default(false),
+2 -2
View File
@@ -2,7 +2,7 @@
"name": "web-nuxt-ui",
"private": true,
"type": "module",
"version": "1.50.2",
"version": "1.50.3",
"scripts": {
"dev": "vite",
"build:packages": "pnpm --filter taskview-db-schemas build && pnpm --filter taskview-api build && pnpm --filter capacitor-widget-bridge build",
@@ -92,4 +92,4 @@
"vue-tsc": "^3.2.2"
},
"packageManager": "pnpm@10.28.1"
}
}
@@ -83,6 +83,7 @@ const summary = computed(() => {
dtstart: parseRuleDtstart(rule.dtstart),
notifyOnOccurrence: rule.notifyOnOccurrence,
hasTime: rule.hasTime,
scheduleMode: rule.scheduleMode,
})
const unit = t(`recurrence.units.${form.frequency}`)
@@ -91,7 +92,8 @@ const summary = computed(() => {
: t(`recurrence.summary.${form.frequency}`)
const parts = [base]
if (form.frequency === 'weekly' && form.weekdays.length > 0) {
if (form.scheduleMode === 'after-completion') parts.push(t('recurrence.summary.afterCompletion'))
if (form.scheduleMode !== 'after-completion' && form.frequency === 'weekly' && form.weekdays.length > 0) {
const dayKeys = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']
parts.push(form.weekdays.map((d) => t(`recurrence.weekdays.${dayKeys[d]}`)).join(', '))
}
@@ -112,6 +112,7 @@ function initialForm(): RecurrenceFormValue {
dtstart: dtstart.value.date,
notifyOnOccurrence: props.rule.notifyOnOccurrence,
hasTime: props.rule.hasTime,
scheduleMode: props.rule.scheduleMode,
})
}
return defaultRecurrenceForm(dtstart.value.date, !!props.task.startTime)
@@ -138,6 +139,7 @@ async function save() {
rrule,
dtstart: dtstartIso,
timezone,
scheduleMode: form.value.scheduleMode,
notifyOnOccurrence: form.value.notifyOnOccurrence,
})
: await tasksStore.createRecurrence({
@@ -145,6 +147,7 @@ async function save() {
rrule,
dtstart: dtstartIso,
timezone,
scheduleMode: form.value.scheduleMode,
notifyOnOccurrence: form.value.notifyOnOccurrence,
})
@@ -1,20 +1,20 @@
<template>
<div class="flex flex-col gap-5">
<div class="flex gap-1 p-1 rounded-2xl bg-elevated">
<UButton
v-for="opt in frequencyItems"
:key="opt.value"
:label="opt.label"
color="neutral"
variant="ghost"
size="md"
block
class="flex-1"
:ui="{ base: form.frequency === opt.value
? 'rounded-xl justify-center bg-default text-highlighted font-semibold shadow-sm hover:bg-default'
: 'rounded-xl justify-center text-muted' }"
@click="form.frequency = opt.value"
<TaskRecurrenceSegmentedControl
v-model="form.frequency"
:items="frequencyItems"
/>
<div class="flex flex-col gap-1.5">
<span class="text-sm font-medium text-muted px-1">{{ t('recurrence.mode.title') }}</span>
<TaskRecurrenceSegmentedControl
v-model="form.scheduleMode"
:items="modeItems"
/>
<span
v-if="isAfterCompletion"
class="text-xs text-muted px-1"
>{{ t('recurrence.mode.hint') }}</span>
</div>
<div class="flex items-center justify-between gap-3">
@@ -54,13 +54,13 @@
</div>
<TaskRecurrenceWeekdays
v-if="form.frequency === 'weekly'"
v-if="form.frequency === 'weekly' && !isAfterCompletion"
v-model="form.weekdays"
:start-weekday="startWeekday"
/>
<USelect
v-if="form.frequency === 'monthly'"
v-if="form.frequency === 'monthly' && !isAfterCompletion"
v-model="form.monthlyMode"
:items="monthlyModeItems"
size="lg"
@@ -180,6 +180,10 @@
v-if="previewFooter"
class="text-xs text-muted"
>{{ previewFooter }}</span>
<span
v-if="isAfterCompletion"
class="text-xs text-muted"
>{{ t('recurrence.previewApprox') }}</span>
</div>
</div>
</template>
@@ -192,7 +196,8 @@ import type { DateValue } from '@internationalized/date'
import { buildRruleString, jsDayToRruleWeekday, previewOccurrences } from '@/helpers/recurrence'
import { useWeekStart } from '@/composables/useWeekStart'
import TaskRecurrenceWeekdays from './TaskRecurrenceWeekdays.vue'
import type { RecurrenceEndsMode, RecurrenceFormValue, RecurrenceFrequency } from '@/types/recurrence.types'
import TaskRecurrenceSegmentedControl from './TaskRecurrenceSegmentedControl.vue'
import type { RecurrenceEndsMode, RecurrenceFormValue, RecurrenceFrequency, RecurrenceScheduleMode } from '@/types/recurrence.types'
const PREVIEW_LIMIT = 5
@@ -212,6 +217,13 @@ const frequencyItems = computed<{ label: string; value: RecurrenceFrequency }[]>
{ label: t('recurrence.freqShort.yearly'), value: 'yearly' },
])
const modeItems = computed<{ label: string; value: RecurrenceScheduleMode }[]>(() => [
{ label: t('recurrence.mode.fixed'), value: 'fixed' },
{ label: t('recurrence.mode.afterCompletion'), value: 'after-completion' },
])
const isAfterCompletion = computed(() => form.value.scheduleMode === 'after-completion')
const monthlyModeItems = computed(() => [
{ label: t('recurrence.monthly.dayOfMonth', { day: startDateObj.value.getUTCDate() }), value: 'dayOfMonth' },
{ label: t('recurrence.monthly.lastDay'), value: 'lastDay' },
@@ -0,0 +1,26 @@
<template>
<div class="flex gap-1 p-1 rounded-2xl bg-elevated">
<UButton
v-for="opt in items"
:key="opt.value"
:label="opt.label"
color="neutral"
variant="ghost"
size="md"
block
class="flex-1"
:ui="{ base: model === opt.value
? 'rounded-xl justify-center bg-default text-highlighted font-semibold shadow-sm hover:bg-default'
: 'rounded-xl justify-center text-muted' }"
@click="model = opt.value"
/>
</div>
</template>
<script setup lang="ts" generic="T extends string">
const model = defineModel<T>({ required: true })
defineProps<{
items: { label: string; value: T }[]
}>()
</script>
+19 -7
View File
@@ -1,5 +1,5 @@
import { RRule, Weekday } from 'rrule'
import type { RecurrenceFormValue } from '@/types/recurrence.types'
import type { RecurrenceFormValue, RecurrenceScheduleMode } from '@/types/recurrence.types'
const FREQ_TO_RRULE = {
daily: RRule.DAILY,
@@ -29,6 +29,7 @@ function timeFromDtstart(dtstart: Date): string {
export function defaultRecurrenceForm(dtstart: Date, hasTime: boolean): RecurrenceFormValue {
return {
frequency: 'daily',
scheduleMode: 'fixed',
startDate: formatUtcDate(dtstart),
interval: 1,
weekdays: [jsDayToRruleWeekday(dtstart.getUTCDay())],
@@ -47,11 +48,15 @@ export function buildRruleString(args: { form: RecurrenceFormValue; dtstart: Dat
freq: FREQ_TO_RRULE[form.frequency],
interval: form.interval > 1 ? form.interval : undefined,
}
if (form.frequency === 'weekly' && form.weekdays.length > 0) {
options.byweekday = [...form.weekdays].sort((a, b) => a - b)
}
if (form.frequency === 'monthly') {
options.bymonthday = form.monthlyMode === 'lastDay' ? -1 : dtstart.getUTCDate()
// After-completion series step from the completion day — calendar anchors
// (weekdays, day of month) have no meaning there and the backend rejects them.
if (form.scheduleMode !== 'after-completion') {
if (form.frequency === 'weekly' && form.weekdays.length > 0) {
options.byweekday = [...form.weekdays].sort((a, b) => a - b)
}
if (form.frequency === 'monthly') {
options.bymonthday = form.monthlyMode === 'lastDay' ? -1 : dtstart.getUTCDate()
}
}
if (form.ends === 'after') {
options.count = form.count
@@ -62,9 +67,16 @@ export function buildRruleString(args: { form: RecurrenceFormValue; dtstart: Dat
return RRule.optionsToString({ ...new RRule(options).origOptions }).replace(/^RRULE:/, '')
}
export function parseRruleToForm(args: { rrule: string; dtstart: Date; notifyOnOccurrence: boolean; hasTime: boolean }): RecurrenceFormValue {
export function parseRruleToForm(args: {
rrule: string
dtstart: Date
notifyOnOccurrence: boolean
hasTime: boolean
scheduleMode: RecurrenceScheduleMode
}): RecurrenceFormValue {
const form = defaultRecurrenceForm(args.dtstart, args.hasTime)
form.notifyOnOccurrence = args.notifyOnOccurrence
form.scheduleMode = args.scheduleMode
const options = RRule.parseString(args.rrule)
if (options.freq !== undefined && RRULE_TO_FREQ[options.freq]) {
form.frequency = RRULE_TO_FREQ[options.freq]
+8
View File
@@ -219,6 +219,13 @@ export default {
occurrenceTime: 'Uhrzeit des Termins',
datesCount: '{n} Termine',
previewFooterTime: '{period} · um {time}',
previewApprox: 'Ungefähre Termine — die tatsächlichen hängen vom Abschlusstag ab',
mode: {
title: 'Nächster Termin',
fixed: 'Nach Zeitplan',
afterCompletion: 'Nach Abschluss',
hint: 'Die nächste Aufgabe wird ein Intervall nach dem tatsächlichen Abschluss der aktuellen erstellt',
},
frequency: {
daily: 'Täglich',
weekly: 'Wöchentlich',
@@ -244,6 +251,7 @@ export default {
monthly: 'Monatlich',
yearly: 'Jährlich',
everyN: 'Alle {n} {unit}',
afterCompletion: 'nach Abschluss',
},
weekdays: {
mon: 'Mo',
+8
View File
@@ -233,6 +233,13 @@ export default {
occurrenceTime: 'Occurrence time',
datesCount: '{n} dates',
previewFooterTime: '{period} · at {time}',
previewApprox: 'Approximate dates — actual ones depend on the completion day',
mode: {
title: 'Next occurrence',
fixed: 'On a schedule',
afterCompletion: 'After completion',
hint: 'The next task is created one interval after the current one is actually completed',
},
frequency: {
daily: 'Daily',
weekly: 'Weekly',
@@ -258,6 +265,7 @@ export default {
monthly: 'Monthly',
yearly: 'Yearly',
everyN: 'Every {n} {unit}',
afterCompletion: 'after completion',
},
weekdays: {
mon: 'Mon',
+8
View File
@@ -219,6 +219,13 @@ export default {
occurrenceTime: 'Hora de la ocurrencia',
datesCount: '{n} fechas',
previewFooterTime: '{period} · a las {time}',
previewApprox: 'Fechas aproximadas — las reales dependen del día de finalización',
mode: {
title: 'Próxima ocurrencia',
fixed: 'Según calendario',
afterCompletion: 'Tras completar',
hint: 'La siguiente tarea se crea un intervalo después de completar realmente la actual',
},
frequency: {
daily: 'Diaria',
weekly: 'Semanal',
@@ -244,6 +251,7 @@ export default {
monthly: 'Mensual',
yearly: 'Anual',
everyN: 'Cada {n} {unit}',
afterCompletion: 'tras completar',
},
weekdays: {
mon: 'Lun',
+8
View File
@@ -233,6 +233,13 @@ export default {
occurrenceTime: 'Время повторения',
datesCount: '{n} дат',
previewFooterTime: '{period} · в {time}',
previewApprox: 'Даты примерные — фактические зависят от дня завершения',
mode: {
title: 'Следующее повторение',
fixed: 'По расписанию',
afterCompletion: 'После завершения',
hint: 'Следующая задача создаётся через интервал после фактического завершения текущей',
},
frequency: {
daily: 'Ежедневно',
weekly: 'Еженедельно',
@@ -258,6 +265,7 @@ export default {
monthly: 'Ежемесячно',
yearly: 'Ежегодно',
everyN: 'Каждые {n} {unit}',
afterCompletion: 'после завершения',
},
weekdays: {
mon: 'Пн',
+5
View File
@@ -1,9 +1,14 @@
import type { RecurrenceScheduleMode } from 'taskview-api'
export type RecurrenceFrequency = 'daily' | 'weekly' | 'monthly' | 'yearly'
export type RecurrenceEndsMode = 'never' | 'after' | 'onDate'
export type RecurrenceMonthlyMode = 'dayOfMonth' | 'lastDay'
export type { RecurrenceScheduleMode }
export type RecurrenceFormValue = {
frequency: RecurrenceFrequency
/** 'fixed' — calendar schedule; 'after-completion' — next occurrence is one interval step after the completion day. */
scheduleMode: RecurrenceScheduleMode
/** 'YYYY-MM-DD' wall-clock start date of the series. */
startDate: string
interval: number