diff --git a/api/package.json b/api/package.json index 73d3a45..fa618f1 100644 --- a/api/package.json +++ b/api/package.json @@ -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" } -} +} \ No newline at end of file diff --git a/api/src/migrations/taskview/migrate.json b/api/src/migrations/taskview/migrate.json index da33f90..c401e45 100644 --- a/api/src/migrations/taskview/migrate.json +++ b/api/src/migrations/taskview/migrate.json @@ -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)" + ] } -} \ No newline at end of file +} diff --git a/api/src/migrations/taskview/sql/1.60.0/0.alter-recurrence-add-schedule-mode.sql b/api/src/migrations/taskview/sql/1.60.0/0.alter-recurrence-add-schedule-mode.sql new file mode 100644 index 0000000..a635666 --- /dev/null +++ b/api/src/migrations/taskview/sql/1.60.0/0.alter-recurrence-add-schedule-mode.sql @@ -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')); diff --git a/api/src/tv-modules/recurrence/RecurrenceGenerator.ts b/api/src/tv-modules/recurrence/RecurrenceGenerator.ts index 50aea2b..76a6336 100644 --- a/api/src/tv-modules/recurrence/RecurrenceGenerator.ts +++ b/api/src/tv-modules/recurrence/RecurrenceGenerator.ts @@ -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) diff --git a/api/src/tv-modules/recurrence/RecurrenceManager.ts b/api/src/tv-modules/recurrence/RecurrenceManager.ts index 3941601..a5689f4 100644 --- a/api/src/tv-modules/recurrence/RecurrenceManager.ts +++ b/api/src/tv-modules/recurrence/RecurrenceManager.ts @@ -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(), - }); + 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(), + }); if (!nextDate) return fail('invalid_rule', 'rule produces no occurrences'); } if (args.notifyOnOccurrence !== undefined) patch.notifyOnOccurrence = args.notifyOnOccurrence; diff --git a/api/src/tv-modules/recurrence/RecurrenceParser.ts b/api/src/tv-modules/recurrence/RecurrenceParser.ts index 23501d2..0a83cf3 100644 --- a/api/src/tv-modules/recurrence/RecurrenceParser.ts +++ b/api/src/tv-modules/recurrence/RecurrenceParser.ts @@ -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([RRule.YEARLY, RRule.MONTHLY, RRule.WEEKLY, RRule.DAILY]); const MAX_COUNT = 10000; +const FREQ_TO_STEP_UNIT: Record = { + [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 diff --git a/api/src/tv-modules/recurrence/types.ts b/api/src/tv-modules/recurrence/types.ts index 6276e73..efbbca7 100644 --- a/api/src/tv-modules/recurrence/types.ts +++ b/api/src/tv-modules/recurrence/types.ts @@ -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; }; +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; diff --git a/package.json b/package.json index 035d614..2ef8b4e 100644 --- a/package.json +++ b/package.json @@ -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" } -} +} \ No newline at end of file diff --git a/taskview-packages/taskview-api/src/api/__tests__/docker/docker-compose.yml b/taskview-packages/taskview-api/src/api/__tests__/docker/docker-compose.yml index 5a28fe2..e5a6b65 100644 --- a/taskview-packages/taskview-api/src/api/__tests__/docker/docker-compose.yml +++ b/taskview-packages/taskview-api/src/api/__tests__/docker/docker-compose.yml @@ -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 diff --git a/taskview-packages/taskview-api/src/api/__tests__/recurrence-after-completion.test.ts b/taskview-packages/taskview-api/src/api/__tests__/recurrence-after-completion.test.ts new file mode 100644 index 0000000..09ed333 --- /dev/null +++ b/taskview-packages/taskview-api/src/api/__tests__/recurrence-after-completion.test.ts @@ -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 { + 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) + }) + }) +}) diff --git a/taskview-packages/taskview-api/src/api/__tests__/webhook-receiver.ts b/taskview-packages/taskview-api/src/api/__tests__/webhook-receiver.ts index 061f69c..d3366b1 100644 --- a/taskview-packages/taskview-api/src/api/__tests__/webhook-receiver.ts +++ b/taskview-packages/taskview-api/src/api/__tests__/webhook-receiver.ts @@ -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((resolve) => { server.listen(0, '0.0.0.0', () => resolve()) }), diff --git a/taskview-packages/taskview-api/src/api/recurrence.types.ts b/taskview-packages/taskview-api/src/api/recurrence.types.ts index e43614b..a1eb243 100644 --- a/taskview-packages/taskview-api/src/api/recurrence.types.ts +++ b/taskview-packages/taskview-api/src/api/recurrence.types.ts @@ -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; }; diff --git a/taskview-packages/taskview-db-schemas/src/schemas/recurrence-rules.schema.ts b/taskview-packages/taskview-db-schemas/src/schemas/recurrence-rules.schema.ts index 27382a8..bd8cef8 100644 --- a/taskview-packages/taskview-db-schemas/src/schemas/recurrence-rules.schema.ts +++ b/taskview-packages/taskview-db-schemas/src/schemas/recurrence-rules.schema.ts @@ -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().notNull().default('active'), + scheduleMode: varchar('schedule_mode', { length: 20 }).$type().notNull().default('fixed'), lastInstanceDate: date('last_instance_date').notNull(), instancesCreated: integer('instances_created').notNull().default(1), notifyOnOccurrence: boolean('notify_on_occurrence').notNull().default(false), diff --git a/web/package.json b/web/package.json index ab86970..762f8c8 100644 --- a/web/package.json +++ b/web/package.json @@ -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" -} +} \ No newline at end of file diff --git a/web/src/components/features/tasks/parts/TaskRecurrence.vue b/web/src/components/features/tasks/parts/TaskRecurrence.vue index 2050274..1f61c4c 100644 --- a/web/src/components/features/tasks/parts/TaskRecurrence.vue +++ b/web/src/components/features/tasks/parts/TaskRecurrence.vue @@ -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(', ')) } diff --git a/web/src/components/features/tasks/parts/TaskRecurrenceDialog.vue b/web/src/components/features/tasks/parts/TaskRecurrenceDialog.vue index 4e7cb89..d17e686 100644 --- a/web/src/components/features/tasks/parts/TaskRecurrenceDialog.vue +++ b/web/src/components/features/tasks/parts/TaskRecurrenceDialog.vue @@ -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, }) diff --git a/web/src/components/features/tasks/parts/TaskRecurrenceForm.vue b/web/src/components/features/tasks/parts/TaskRecurrenceForm.vue index 7429de6..5801bf2 100644 --- a/web/src/components/features/tasks/parts/TaskRecurrenceForm.vue +++ b/web/src/components/features/tasks/parts/TaskRecurrenceForm.vue @@ -1,20 +1,20 @@ @@ -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' }, diff --git a/web/src/components/features/tasks/parts/TaskRecurrenceSegmentedControl.vue b/web/src/components/features/tasks/parts/TaskRecurrenceSegmentedControl.vue new file mode 100644 index 0000000..db61991 --- /dev/null +++ b/web/src/components/features/tasks/parts/TaskRecurrenceSegmentedControl.vue @@ -0,0 +1,26 @@ + + + diff --git a/web/src/helpers/recurrence.ts b/web/src/helpers/recurrence.ts index acdb117..0421c75 100644 --- a/web/src/helpers/recurrence.ts +++ b/web/src/helpers/recurrence.ts @@ -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] diff --git a/web/src/locales/de.ts b/web/src/locales/de.ts index badbc42..8bb1325 100644 --- a/web/src/locales/de.ts +++ b/web/src/locales/de.ts @@ -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', diff --git a/web/src/locales/en.ts b/web/src/locales/en.ts index 01e5ef8..882a1b8 100644 --- a/web/src/locales/en.ts +++ b/web/src/locales/en.ts @@ -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', diff --git a/web/src/locales/es.ts b/web/src/locales/es.ts index 3839029..8056c45 100644 --- a/web/src/locales/es.ts +++ b/web/src/locales/es.ts @@ -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', diff --git a/web/src/locales/ru.ts b/web/src/locales/ru.ts index 4bbfbd9..cddd8bd 100644 --- a/web/src/locales/ru.ts +++ b/web/src/locales/ru.ts @@ -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: 'Пн', diff --git a/web/src/types/recurrence.types.ts b/web/src/types/recurrence.types.ts index 63c08ad..837ee66 100644 --- a/web/src/types/recurrence.types.ts +++ b/web/src/types/recurrence.types.ts @@ -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