mirror of
https://github.com/Gimanh/taskview-community.git
synced 2026-09-11 21:38:56 +00:00
Merge pull request #86 from Gimanh/feat/recurring-by-completion
wip: recurring
This commit is contained in:
+2
-2
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
@@ -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]
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'Пн',
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user