-
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 all commits
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,23 @@ | |
| "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", | ||
| "recurrenceRequiresCount": "Le nombre de répétitions est requis", | ||
| "recurrenceRequiresUnit": "L'unité de répétition est requise" | ||
| }, | ||
| "actions": { | ||
| "create": "Créer la tâche", | ||
|
|
@@ -1413,6 +1426,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,49 @@ 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(), | ||
| ); | ||
|
|
||
| // Recurrence refinements emit translation keys instead of literal messages so | ||
| // the form can localize them at render time. | ||
| const recurrenceErrorKeys = [ | ||
| "taskFormDialog.errors.recurrenceRequiresCount", | ||
| "taskFormDialog.errors.recurrenceRequiresUnit", | ||
| "taskFormDialog.errors.recurrenceRequiresDeadline", | ||
| ] as const; | ||
|
|
||
| const recurrenceErrorKeySet = new Set<string>(recurrenceErrorKeys); | ||
|
|
||
| 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: recurrenceErrorKeys[0], path: ["recurrenceIntervalCount"] }, | ||
| ) | ||
| .refine( | ||
| data => !(data.recurrenceIntervalCount && !data.recurrenceIntervalUnit), | ||
| { message: recurrenceErrorKeys[1], path: ["recurrenceIntervalUnit"] }, | ||
| ) | ||
| .refine( | ||
| data => !(data.recurrenceIntervalUnit && !data.deadline), | ||
| { message: recurrenceErrorKeys[2], path: ["deadline"] }, | ||
| ); | ||
| } | ||
|
|
||
| const createTaskSchema = refineRecurrence(z.object({ | ||
| name: z.string().min(1), | ||
| description: z.string().optional().nullable(), | ||
| priority: z.enum(taskPriorities), | ||
|
|
@@ -113,9 +156,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 +175,9 @@ const updateTaskSchema = z.object({ | |
| z.string().nullable().optional(), | ||
| ), | ||
| deadline: z.string().optional().nullable(), | ||
| }); | ||
| recurrenceIntervalUnit: recurrenceIntervalUnitField, | ||
| recurrenceIntervalCount: recurrenceIntervalCountField, | ||
| })); | ||
|
|
||
| type Props = { | ||
| children?: ReactNode; | ||
|
|
@@ -170,6 +217,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,12 +233,18 @@ 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, | ||
| }); | ||
| } | ||
| }, [ | ||
| task, reset, measureId, | ||
| ]); | ||
|
|
||
| // Zod default messages come back as plain text, recurrence ones as keys. | ||
| const translateError = (message?: string) => | ||
| message && recurrenceErrorKeySet.has(message) ? t(message) : message; | ||
|
|
||
| const onSubmit = async (data: z.infer<typeof updateTaskSchema | typeof createTaskSchema>) => { | ||
| if (task) { | ||
| await mutate({ | ||
|
|
@@ -204,6 +259,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 +279,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!], | ||
| }, | ||
|
|
@@ -389,10 +448,43 @@ export default function TaskFormDialog(props: Props) { | |
| </PropertyRow> | ||
| <PropertyRow | ||
| label={t("taskFormDialog.fields.deadline.label")} | ||
| error={formState.errors.deadline?.message} | ||
| error={translateError(formState.errors.deadline?.message)} | ||
| > | ||
| <Input id="deadline" type="date" {...register("deadline")} /> | ||
| </PropertyRow> | ||
| <PropertyRow | ||
| label={t("taskFormDialog.fields.recurrence.label")} | ||
| error={translateError( | ||
| 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> | ||
|
|
||
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