Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions apps/console/src/_locales/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -779,12 +779,14 @@
"assignedTo": { "label": "Assigned to" },
"measure": { "label": "Measure" },
"timeEstimate": { "label": "Time estimate" },
"deadline": { "label": "Deadline" }
"deadline": { "label": "Deadline" },
"recurrence": { "label": "Repeats" }
},
"states": { "todo": "To do", "inProgress": "In progress", "done": "Done" },
"priorities": { "urgent": "Urgent", "high": "High", "medium": "Medium", "low": "Low" },
"recurrenceIntervalUnits": { "none": "Does not repeat", "day": "Day", "week": "Week", "month": "Month", "year": "Year" },
"messages": { "created": "Task created successfully.", "updated": "Task updated successfully." },
"errors": { "create": "Failed to create task", "update": "Failed to update task" },
"errors": { "create": "Failed to create task", "update": "Failed to update task", "recurrenceRequiresDeadline": "Deadline is required for a recurring task" },
"actions": { "create": "Create task", "update": "Update task" }
},
"tasksCard": {
Expand All @@ -806,7 +808,11 @@
"days_other": "{{count}} Days"
},
"deleteConfirmation": "Are you sure you want to delete this task?",
"actions": { "moveToInProgress": "Move to In progress", "moveToDone": "Move to Done", "edit": "Edit", "delete": "Delete" }
"actions": { "moveToInProgress": "Move to In progress", "moveToDone": "Move to Done", "edit": "Edit", "delete": "Delete" },
"recurringBadge": {
"tooltip_one": "Repeats every {{unit}}",
"tooltip_other": "Repeats every {{count}} {{unit}}s"
}
},
"deleteTrustCenterReferenceDialog": {
"title": "Delete Reference",
Expand Down
17 changes: 16 additions & 1 deletion apps/console/src/_locales/fr-FR.json
Original file line number Diff line number Diff line change
Expand Up @@ -1355,6 +1355,9 @@
},
"deadline": {
"label": "Échéance"
},
"recurrence": {
"label": "Répétition"
}
},
"states": {
Expand All @@ -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",
Expand Down Expand Up @@ -1413,6 +1424,10 @@
"moveToDone": "Déplacer vers Terminé",
"edit": "Modifier",
"delete": "Supprimer"
},
"recurringBadge": {
"tooltip_one": "Se répète tous les {{count}} {{unit}}",

Copy link
Copy Markdown
Contributor

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_one should 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
Check if this issue is valid — if so, understand the root cause and fix it. At apps/console/src/_locales/fr-FR.json, line 1429:

<comment>The recurringBadge tooltip renders improperly in French for singular counts. `tooltip_one` should 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").</comment>

<file context>
@@ -1413,6 +1424,10 @@
       "delete": "Supprimer"
+    },
+    "recurringBadge": {
+      "tooltip_one": "Se répète tous les {{count}} {{unit}}",
+      "tooltip_other": "Se répète tous les {{count}} {{unit}}"
     }
</file context>

"tooltip_other": "Se répète tous les {{count}} {{unit}}"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 (2 Semaine, 2 Année). Providing pluralized unit translations or unit-specific tooltip variants would make the badge readable for all recurrence units.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/console/src/_locales/fr-FR.json, line 1430:

<comment>The recurring-task tooltip is grammatically incorrect in French for counts greater than one because both plural branches reuse the singular translated unit (`2 Semaine`, `2 Année`). Providing pluralized unit translations or unit-specific tooltip variants would make the badge readable for all recurrence units.</comment>

<file context>
@@ -1413,6 +1424,10 @@
+    },
+    "recurringBadge": {
+      "tooltip_one": "Se répète tous les {{count}} {{unit}}",
+      "tooltip_other": "Se répète tous les {{count}} {{unit}}"
     }
   },
</file context>

}
},
"deleteTrustCenterReferenceDialog": {
Expand Down
86 changes: 82 additions & 4 deletions apps/console/src/components/tasks/TaskFormDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ const taskFragment = graphql`
priority
timeEstimate
deadline
recurrenceIntervalUnit
recurrenceIntervalCount
assignedTo {
id
}
Expand Down Expand Up @@ -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"] },
Comment thread
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),
Expand All @@ -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),
Expand All @@ -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;
Expand Down Expand Up @@ -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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 1 and no recurrence unit, while the new refinement treats that as an invalid count-without-unit combination. Clearing recurrence on an existing task has the same problem because the reset path restores 1; initializing the count as null when no unit is present (or clearing it when the unit becomes none) would preserve the intended optional recurrence behavior.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/console/src/components/tasks/TaskFormDialog.tsx, line 211:

<comment>Non-recurring tasks cannot be submitted with the new defaults: the form starts with count `1` and no recurrence unit, while the new refinement treats that as an invalid count-without-unit combination. Clearing recurrence on an existing task has the same problem because the reset path restores `1`; initializing the count as `null` when no unit is present (or clearing it when the unit becomes `none`) would preserve the intended optional recurrence behavior.</comment>

<file context>
@@ -170,6 +207,8 @@ export default function TaskFormDialog(props: Props) {
         measureId: task?.measure?.id ?? measureId ?? "",
         deadline: task?.deadline?.split("T")[0] ?? "",
+        recurrenceIntervalUnit: task?.recurrenceIntervalUnit ?? "",
+        recurrenceIntervalCount: task?.recurrenceIntervalCount ?? 1,
       },
     });
</file context>

},
});

Expand All @@ -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,
});
}
}, [
Expand All @@ -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) => {
Expand All @@ -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!],
},
Expand Down Expand Up @@ -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>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Opening/rendering this recurrence selector throws because Radix Select.Item disallows value="". Use a non-empty sentinel option and map it to null/empty before schema validation and mutation submission.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/console/src/components/tasks/TaskFormDialog.tsx, line 464:

<comment>Opening/rendering this recurrence selector throws because Radix `Select.Item` disallows `value=""`. Use a non-empty sentinel option and map it to null/empty before schema validation and mutation submission.</comment>

<file context>
@@ -393,6 +438,39 @@ export default function TaskFormDialog(props: Props) {
+                      value={field.value ?? ""}
+                      onValueChange={field.onChange}
+                    >
+                      <Option value="">{t("taskFormDialog.recurrenceIntervalUnits.none")}</Option>
+                      <Option value="DAY">{t("taskFormDialog.recurrenceIntervalUnits.day")}</Option>
+                      <Option value="WEEK">{t("taskFormDialog.recurrenceIntervalUnits.week")}</Option>
</file context>

<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>
Expand Down
13 changes: 13 additions & 0 deletions apps/console/src/components/tasks/TasksCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
IconCircleCheck,
IconCircleProgress,
IconPencil,
IconRotateCw,
IconTrashCan,
PriorityLevel,
TabBadge,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 && (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The recurring badge icon uses a <span title="..."> to convey recurrence information, but screen readers won't reliably announce this. The embedded <IconRotateCw /> SVG likely has no accessible name (no aria-label, role="img"), and the <span> isn't keyboard-focusable. Consider adding aria-label directly on the icon or using a visually-hidden text element for screen reader users, matching the pattern used by <Button> with title in this same component.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/console/src/components/tasks/TasksCard.tsx, line 627:

<comment>The recurring badge icon uses a `<span title="...">` to convey recurrence information, but screen readers won't reliably announce this. The embedded `<IconRotateCw />` SVG likely has no accessible name (no `aria-label`, `role="img"`), and the `<span>` isn't keyboard-focusable. Consider adding `aria-label` directly on the icon or using a visually-hidden text element for screen reader users, matching the pattern used by `<Button>` with `title` in this same component.</comment>

<file context>
@@ -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 && (
+              <span
+                title={t("tasksCard.recurringBadge.tooltip", {
</file context>

<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>
Expand Down
133 changes: 133 additions & 0 deletions e2e/console/task_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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!) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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
Check if this issue is valid — if so, understand the root cause and fix it. At e2e/console/task_test.go, line 983:

<comment>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.</comment>

<file context>
@@ -864,6 +864,139 @@ func TestTask_OmittableDeadline(t *testing.T) {
+			Create()
+
+		_, err := owner.Do(`
+			mutation UpdateTask($input: UpdateTaskInput!) {
+				updateTask(input: $input) {
+					task { id }
</file context>

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()

Expand Down
Loading
Loading