-
Notifications
You must be signed in to change notification settings - Fork 199
feat: add support for recurring tasks #1611
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
cc30c30
a725bcc
6807283
d1a15c8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1355,6 +1355,9 @@ | |
| }, | ||
| "deadline": { | ||
| "label": "Échéance" | ||
| }, | ||
| "recurrence": { | ||
| "label": "Répétition" | ||
| } | ||
| }, | ||
| "states": { | ||
|
|
@@ -1368,13 +1371,21 @@ | |
| "medium": "Moyenne", | ||
| "low": "Basse" | ||
| }, | ||
| "recurrenceIntervalUnits": { | ||
| "none": "Ne se répète pas", | ||
| "day": "Jour", | ||
| "week": "Semaine", | ||
| "month": "Mois", | ||
| "year": "Année" | ||
| }, | ||
| "messages": { | ||
| "created": "Tâche créée avec succès.", | ||
| "updated": "Tâche mise à jour avec succès." | ||
| }, | ||
| "errors": { | ||
| "create": "Échec de la création de la tâche", | ||
| "update": "Échec de la mise à jour de la tâche" | ||
| "update": "Échec de la mise à jour de la tâche", | ||
| "recurrenceRequiresDeadline": "Une échéance est requise pour une tâche récurrente" | ||
| }, | ||
| "actions": { | ||
| "create": "Créer la tâche", | ||
|
|
@@ -1413,6 +1424,10 @@ | |
| "moveToDone": "Déplacer vers Terminé", | ||
| "edit": "Modifier", | ||
| "delete": "Supprimer" | ||
| }, | ||
| "recurringBadge": { | ||
| "tooltip_one": "Se répète tous les {{count}} {{unit}}", | ||
| "tooltip_other": "Se répète tous les {{count}} {{unit}}" | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: The recurring-task tooltip is grammatically incorrect in French for counts greater than one because both plural branches reuse the singular translated unit ( Prompt for AI agents |
||
| } | ||
| }, | ||
| "deleteTrustCenterReferenceDialog": { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -61,6 +61,8 @@ const taskFragment = graphql` | |
| priority | ||
| timeEstimate | ||
| deadline | ||
| recurrenceIntervalUnit | ||
| recurrenceIntervalCount | ||
| assignedTo { | ||
| id | ||
| } | ||
|
|
@@ -101,8 +103,39 @@ export const taskUpdateMutation = graphql` | |
|
|
||
| export const taskStates = ["TODO", "IN_PROGRESS", "DONE"] as const; | ||
| export const taskPriorities = ["URGENT", "HIGH", "MEDIUM", "LOW"] as const; | ||
| export const taskRecurrenceIntervalUnits = ["DAY", "WEEK", "MONTH", "YEAR"] as const; | ||
|
|
||
| const createTaskSchema = z.object({ | ||
| const recurrenceIntervalUnitField = z.preprocess( | ||
| val => (val === "" || val == null ? null : val), | ||
| z.enum(taskRecurrenceIntervalUnits).nullable().optional(), | ||
| ); | ||
|
|
||
| const recurrenceIntervalCountField = z.preprocess( | ||
| val => (typeof val === "number" && Number.isNaN(val) ? null : val), | ||
| z.number().int().min(1).nullable().optional(), | ||
| ); | ||
|
|
||
| function refineRecurrence<T extends z.ZodType<{ | ||
| deadline?: string | null; | ||
| recurrenceIntervalUnit?: string | null; | ||
| recurrenceIntervalCount?: number | null; | ||
| }>>(schema: T) { | ||
| return schema | ||
| .refine( | ||
| data => !(data.recurrenceIntervalUnit && !data.recurrenceIntervalCount), | ||
| { message: "Recurrence count is required", path: ["recurrenceIntervalCount"] }, | ||
| ) | ||
| .refine( | ||
| data => !(data.recurrenceIntervalCount && !data.recurrenceIntervalUnit), | ||
| { message: "Recurrence unit is required", path: ["recurrenceIntervalUnit"] }, | ||
| ) | ||
| .refine( | ||
| data => !(data.recurrenceIntervalUnit && !data.deadline), | ||
| { message: "Deadline is required for a recurring task", path: ["deadline"] }, | ||
|
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
|
||
| ); | ||
| } | ||
|
|
||
| const createTaskSchema = refineRecurrence(z.object({ | ||
| name: z.string().min(1), | ||
| description: z.string().optional().nullable(), | ||
| priority: z.enum(taskPriorities), | ||
|
|
@@ -113,9 +146,11 @@ const createTaskSchema = z.object({ | |
| z.string().nullable().optional(), | ||
| ), | ||
| deadline: z.string().optional().nullable(), | ||
| }); | ||
| recurrenceIntervalUnit: recurrenceIntervalUnitField, | ||
| recurrenceIntervalCount: recurrenceIntervalCountField, | ||
| })); | ||
|
|
||
| const updateTaskSchema = z.object({ | ||
| const updateTaskSchema = refineRecurrence(z.object({ | ||
| name: z.string().min(1), | ||
| description: z.string().optional().nullable(), | ||
| state: z.enum(taskStates), | ||
|
|
@@ -130,7 +165,9 @@ const updateTaskSchema = z.object({ | |
| z.string().nullable().optional(), | ||
| ), | ||
| deadline: z.string().optional().nullable(), | ||
| }); | ||
| recurrenceIntervalUnit: recurrenceIntervalUnitField, | ||
| recurrenceIntervalCount: recurrenceIntervalCountField, | ||
| })); | ||
|
|
||
| type Props = { | ||
| children?: ReactNode; | ||
|
|
@@ -170,6 +207,8 @@ export default function TaskFormDialog(props: Props) { | |
| assignedToId: task?.assignedTo?.id ?? "", | ||
| measureId: task?.measure?.id ?? measureId ?? "", | ||
| deadline: task?.deadline?.split("T")[0] ?? "", | ||
| recurrenceIntervalUnit: task?.recurrenceIntervalUnit ?? "", | ||
| recurrenceIntervalCount: task?.recurrenceIntervalCount ?? 1, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: Non-recurring tasks cannot be submitted with the new defaults: the form starts with count Prompt for AI agents |
||
| }, | ||
| }); | ||
|
|
||
|
|
@@ -184,6 +223,8 @@ export default function TaskFormDialog(props: Props) { | |
| assignedToId: task.assignedTo?.id ?? "", | ||
| measureId: task.measure?.id ?? measureId ?? "", | ||
| deadline: task.deadline?.split("T")[0] ?? "", | ||
| recurrenceIntervalUnit: task.recurrenceIntervalUnit ?? "", | ||
| recurrenceIntervalCount: task.recurrenceIntervalCount ?? 1, | ||
| }); | ||
| } | ||
| }, [ | ||
|
|
@@ -204,6 +245,8 @@ export default function TaskFormDialog(props: Props) { | |
| deadline: formatDatetime(data.deadline) ?? null, | ||
| assignedToId: data.assignedToId ?? null, | ||
| measureId: data.measureId || null, | ||
| recurrenceIntervalUnit: data.recurrenceIntervalUnit || null, | ||
| recurrenceIntervalCount: data.recurrenceIntervalUnit ? data.recurrenceIntervalCount : null, | ||
| }, | ||
| }, | ||
| onCompleted: (_response, errors) => { | ||
|
|
@@ -222,6 +265,8 @@ export default function TaskFormDialog(props: Props) { | |
| deadline: formatDatetime(data.deadline) ?? null, | ||
| assignedToId: data.assignedToId || null, | ||
| measureId: data.measureId || null, | ||
| recurrenceIntervalUnit: data.recurrenceIntervalUnit || null, | ||
| recurrenceIntervalCount: data.recurrenceIntervalUnit ? data.recurrenceIntervalCount : null, | ||
| }, | ||
| connections: [connection!], | ||
| }, | ||
|
|
@@ -393,6 +438,39 @@ export default function TaskFormDialog(props: Props) { | |
| > | ||
| <Input id="deadline" type="date" {...register("deadline")} /> | ||
| </PropertyRow> | ||
| <PropertyRow | ||
| label={t("taskFormDialog.fields.recurrence.label")} | ||
| error={ | ||
| formState.errors.recurrenceIntervalUnit?.message | ||
| ?? formState.errors.recurrenceIntervalCount?.message | ||
| } | ||
| > | ||
| <div className="flex items-center gap-2"> | ||
| <Input | ||
| id="recurrenceIntervalCount" | ||
| type="number" | ||
| min={1} | ||
| className="w-16" | ||
| {...register("recurrenceIntervalCount", { valueAsNumber: true })} | ||
| /> | ||
| <Controller | ||
| name="recurrenceIntervalUnit" | ||
| control={control} | ||
| render={({ field }) => ( | ||
| <Select | ||
| value={field.value ?? ""} | ||
| onValueChange={field.onChange} | ||
| > | ||
| <Option value="">{t("taskFormDialog.recurrenceIntervalUnits.none")}</Option> | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: Opening/rendering this recurrence selector throws because Radix Prompt for AI agents |
||
| <Option value="DAY">{t("taskFormDialog.recurrenceIntervalUnits.day")}</Option> | ||
| <Option value="WEEK">{t("taskFormDialog.recurrenceIntervalUnits.week")}</Option> | ||
| <Option value="MONTH">{t("taskFormDialog.recurrenceIntervalUnits.month")}</Option> | ||
| <Option value="YEAR">{t("taskFormDialog.recurrenceIntervalUnits.year")}</Option> | ||
| </Select> | ||
| )} | ||
| /> | ||
| </div> | ||
| </PropertyRow> | ||
| </div> | ||
| </DialogContent> | ||
| <DialogFooter> | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -27,6 +27,7 @@ import { | |
| IconCircleCheck, | ||
| IconCircleProgress, | ||
| IconPencil, | ||
| IconRotateCw, | ||
| IconTrashCan, | ||
| PriorityLevel, | ||
| TabBadge, | ||
|
|
@@ -495,6 +496,8 @@ const fragment = graphql` | |
| description | ||
| timeEstimate | ||
| deadline | ||
| recurrenceIntervalUnit | ||
| recurrenceIntervalCount | ||
| canUpdate: permission(action: "core:task:update") | ||
| canDelete: permission(action: "core:task:delete") | ||
| assignedTo { | ||
|
|
@@ -621,6 +624,16 @@ function TaskRow(props: TaskRowProps) { | |
| <div className="flex items-center gap-2 pt-[2px]"> | ||
| <PriorityLevel level={task.priority} /> | ||
| <TaskStateIcon state={displayState} /> | ||
| {task.recurrenceIntervalUnit && ( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: The recurring badge icon uses a Prompt for AI agents |
||
| <span | ||
| title={t("tasksCard.recurringBadge.tooltip", { | ||
| count: task.recurrenceIntervalCount ?? 1, | ||
| unit: t(`taskFormDialog.recurrenceIntervalUnits.${task.recurrenceIntervalUnit.toLowerCase()}`), | ||
| })} | ||
| > | ||
| <IconRotateCw size={14} className="text-txt-secondary" /> | ||
| </span> | ||
| )} | ||
| </div> | ||
| <div className="text-sm space-y-1 flex-1"> | ||
| <h2 className="font-medium">{task.name}</h2> | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -864,6 +864,139 @@ func TestTask_OmittableDeadline(t *testing.T) { | |
| }) | ||
| } | ||
|
|
||
| func TestTask_Recurrence(t *testing.T) { | ||
| t.Parallel() | ||
| owner := testutil.NewClient(t, testutil.RoleOwner) | ||
| measureID := factory.NewMeasure(owner).WithName("Task Recurrence Test").Create() | ||
|
|
||
| createQuery := ` | ||
| mutation CreateTask($input: CreateTaskInput!) { | ||
| createTask(input: $input) { | ||
| taskEdge { | ||
| node { | ||
| id | ||
| deadline | ||
| recurrenceIntervalUnit | ||
| recurrenceIntervalCount | ||
| } | ||
| } | ||
| } | ||
| } | ||
| ` | ||
|
|
||
| t.Run("create with recurrence and deadline round-trips", func(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| var result struct { | ||
| CreateTask struct { | ||
| TaskEdge struct { | ||
| Node struct { | ||
| ID string `json:"id"` | ||
| Deadline *string `json:"deadline"` | ||
| RecurrenceIntervalUnit *string `json:"recurrenceIntervalUnit"` | ||
| RecurrenceIntervalCount *int `json:"recurrenceIntervalCount"` | ||
| } `json:"node"` | ||
| } `json:"taskEdge"` | ||
| } `json:"createTask"` | ||
| } | ||
|
|
||
| err := owner.Execute(createQuery, map[string]any{ | ||
| "input": map[string]any{ | ||
| "organizationId": owner.GetOrganizationID().String(), | ||
| "measureId": measureID, | ||
| "name": factory.SafeName("Recurring Task"), | ||
| "priority": "MEDIUM", | ||
| "deadline": "2026-01-15T00:00:00Z", | ||
| "recurrenceIntervalUnit": "WEEK", | ||
| "recurrenceIntervalCount": 3, | ||
| }, | ||
| }, &result) | ||
| require.NoError(t, err) | ||
|
|
||
| node := result.CreateTask.TaskEdge.Node | ||
| assert.NotEmpty(t, node.ID) | ||
| require.NotNil(t, node.RecurrenceIntervalUnit) | ||
| assert.Equal(t, "WEEK", *node.RecurrenceIntervalUnit) | ||
| require.NotNil(t, node.RecurrenceIntervalCount) | ||
| assert.Equal(t, 3, *node.RecurrenceIntervalCount) | ||
| }) | ||
|
|
||
| t.Run("create with recurrence but no deadline fails", func(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| _, err := owner.Do(createQuery, map[string]any{ | ||
| "input": map[string]any{ | ||
| "organizationId": owner.GetOrganizationID().String(), | ||
| "measureId": measureID, | ||
| "name": factory.SafeName("Recurring Task"), | ||
| "priority": "MEDIUM", | ||
| "recurrenceIntervalUnit": "WEEK", | ||
| "recurrenceIntervalCount": 3, | ||
| }, | ||
| }) | ||
| require.Error(t, err) | ||
| assert.Contains(t, err.Error(), "deadline") | ||
| }) | ||
|
|
||
| t.Run("create with unit but no count fails", func(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| _, err := owner.Do(createQuery, map[string]any{ | ||
| "input": map[string]any{ | ||
| "organizationId": owner.GetOrganizationID().String(), | ||
| "measureId": measureID, | ||
| "name": factory.SafeName("Recurring Task"), | ||
| "priority": "MEDIUM", | ||
| "deadline": "2026-01-15T00:00:00Z", | ||
| "recurrenceIntervalUnit": "WEEK", | ||
| }, | ||
| }) | ||
| require.Error(t, err) | ||
| assert.Contains(t, err.Error(), "recurrence_interval_count") | ||
| }) | ||
|
|
||
| t.Run("create with count but no unit fails", func(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| _, err := owner.Do(createQuery, map[string]any{ | ||
| "input": map[string]any{ | ||
| "organizationId": owner.GetOrganizationID().String(), | ||
| "measureId": measureID, | ||
| "name": factory.SafeName("Recurring Task"), | ||
| "priority": "MEDIUM", | ||
| "deadline": "2026-01-15T00:00:00Z", | ||
| "recurrenceIntervalCount": 3, | ||
| }, | ||
| }) | ||
| require.Error(t, err) | ||
| assert.Contains(t, err.Error(), "recurrence_interval_unit") | ||
| }) | ||
|
|
||
| t.Run("update to add recurrence without a deadline fails", func(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| taskID := factory.NewTask(owner, measureID). | ||
| WithName("Task without deadline"). | ||
| Create() | ||
|
|
||
| _, err := owner.Do(` | ||
| mutation UpdateTask($input: UpdateTaskInput!) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Add a subtest that creates a task with a deadline and then updates it with recurrence fields, asserting that recurrenceIntervalUnit and recurrenceIntervalCount are set correctly in the response. This would catch bugs in the update resolver's handling of recurrence fields, which is currently only exercised through the error path. Prompt for AI agents |
||
| updateTask(input: $input) { | ||
| task { id } | ||
| } | ||
| } | ||
| `, map[string]any{ | ||
| "input": map[string]any{ | ||
| "taskId": taskID, | ||
| "recurrenceIntervalUnit": "MONTH", | ||
| "recurrenceIntervalCount": 1, | ||
| }, | ||
| }) | ||
| require.Error(t, err) | ||
| assert.Contains(t, err.Error(), "deadline") | ||
| }) | ||
| } | ||
|
|
||
| func TestTask_TenantIsolation(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2: The recurringBadge tooltip renders improperly in French for singular counts.
tooltip_oneshould use a singular-aware template such as "Se répète chaque {{unit}}" instead of "Se répète tous les {{count}} {{unit}}", which is ungrammatical when count=1 ("tous les 1 jour").Prompt for AI agents