Add task priority enum and rename priority to rank

The existing integer priority field represents positional ordering
within a state, not semantic importance. Rename it to rank and
introduce a new priority field with enum values URGENT, HIGH,
MEDIUM and LOW across the entire stack.

Rank is now scoped to (state, priority) so tasks are ordered
within each priority group. A generated priority_rank column
combines both fields into a single sortable integer for cursor
pagination.

Dragging a task across priority groups updates its priority
automatically based on the drop position neighbors. The backend
first moves the task to the new group then repositions it at the
target rank.

The migration defaults existing rows to MEDIUM priority and
backfills ranks per (state, priority) group.

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2026-03-30 17:12:03 +02:00
parent a2f0a37b7b
commit 324f4ce793
21 changed files with 444 additions and 87 deletions

View File

@@ -23,7 +23,10 @@ import {
DurationPicker, DurationPicker,
Input, Input,
Label, Label,
Option,
PriorityLevel,
PropertyRow, PropertyRow,
Select,
Textarea, Textarea,
useDialogRef, useDialogRef,
} from "@probo/ui"; } from "@probo/ui";
@@ -47,6 +50,7 @@ const taskFragment = graphql`
id id
description description
name name
priority
timeEstimate timeEstimate
deadline deadline
assignedTo { assignedTo {
@@ -87,9 +91,12 @@ export const taskUpdateMutation = graphql`
} }
`; `;
export const taskPriorities = ["URGENT", "HIGH", "MEDIUM", "LOW"] as const;
const createTaskSchema = z.object({ const createTaskSchema = z.object({
name: z.string().min(1), name: z.string().min(1),
description: z.string().optional().nullable(), description: z.string().optional().nullable(),
priority: z.enum(taskPriorities),
timeEstimate: z.string().optional().nullable(), timeEstimate: z.string().optional().nullable(),
assignedToId: z.string().optional().nullable(), assignedToId: z.string().optional().nullable(),
measureId: z.preprocess( measureId: z.preprocess(
@@ -102,6 +109,7 @@ const createTaskSchema = z.object({
const updateTaskSchema = z.object({ const updateTaskSchema = z.object({
name: z.string().min(1), name: z.string().min(1),
description: z.string().optional().nullable(), description: z.string().optional().nullable(),
priority: z.enum(taskPriorities),
timeEstimate: z.string().optional().nullable(), timeEstimate: z.string().optional().nullable(),
assignedToId: z.preprocess( assignedToId: z.preprocess(
val => (val === "" || val == null ? null : val), val => (val === "" || val == null ? null : val),
@@ -120,10 +128,11 @@ type Props = {
connection?: string; connection?: string;
ref?: DialogRef; ref?: DialogRef;
measureId?: string; measureId?: string;
onCompleted?: () => void;
}; };
export default function TaskFormDialog(props: Props) { export default function TaskFormDialog(props: Props) {
const { children, connection, ref, task: taskKey, measureId } = props; const { children, connection, ref, task: taskKey, measureId, onCompleted } = props;
const { __ } = useTranslate(); const { __ } = useTranslate();
const newRef = useDialogRef(); const newRef = useDialogRef();
const dialogRef = ref ?? newRef; const dialogRef = ref ?? newRef;
@@ -145,6 +154,7 @@ export default function TaskFormDialog(props: Props) {
defaultValues: { defaultValues: {
name: task?.name ?? "", name: task?.name ?? "",
description: task?.description ?? "", description: task?.description ?? "",
priority: task?.priority ?? "MEDIUM",
timeEstimate: task?.timeEstimate ?? "", timeEstimate: task?.timeEstimate ?? "",
assignedToId: task?.assignedTo?.id ?? "", assignedToId: task?.assignedTo?.id ?? "",
measureId: task?.measure?.id ?? measureId ?? "", measureId: task?.measure?.id ?? measureId ?? "",
@@ -160,12 +170,16 @@ export default function TaskFormDialog(props: Props) {
taskId: task.id, taskId: task.id,
name: data.name, name: data.name,
description: data.description || null, description: data.description || null,
priority: data.priority,
timeEstimate: data.timeEstimate || null, timeEstimate: data.timeEstimate || null,
deadline: formatDatetime(data.deadline) ?? null, deadline: formatDatetime(data.deadline) ?? null,
assignedToId: data.assignedToId ?? null, assignedToId: data.assignedToId ?? null,
measureId: data.measureId || null, measureId: data.measureId || null,
}, },
}, },
onCompleted: (_response, errors) => {
if (!errors) onCompleted?.();
},
}); });
} else { } else {
await mutate({ await mutate({
@@ -174,6 +188,7 @@ export default function TaskFormDialog(props: Props) {
organizationId, organizationId,
name: data.name, name: data.name,
description: data.description || null, description: data.description || null,
priority: data.priority,
timeEstimate: data.timeEstimate || null, timeEstimate: data.timeEstimate || null,
deadline: formatDatetime(data.deadline) ?? null, deadline: formatDatetime(data.deadline) ?? null,
assignedToId: data.assignedToId || null, assignedToId: data.assignedToId || null,
@@ -182,9 +197,12 @@ export default function TaskFormDialog(props: Props) {
connections: [connection!], connections: [connection!],
}, },
onCompleted: (_response, errors) => { onCompleted: (_response, errors) => {
if (!errors && data.measureId) { if (!errors) {
if (data.measureId) {
updateStoreCounter(relayEnv, data.measureId, "tasks(first:0)", 1); updateStoreCounter(relayEnv, data.measureId, "tasks(first:0)", 1);
} }
onCompleted?.();
}
}, },
}); });
reset(); reset();
@@ -224,6 +242,46 @@ export default function TaskFormDialog(props: Props) {
{/* Properties form */} {/* Properties form */}
<div className="py-5 px-6 bg-subtle"> <div className="py-5 px-6 bg-subtle">
<Label>{__("Properties")}</Label> <Label>{__("Properties")}</Label>
<PropertyRow
label={__("Priority")}
error={formState.errors.priority?.message}
>
<Controller
name="priority"
control={control}
render={({ field }) => (
<Select
value={field.value}
onValueChange={field.onChange}
>
<Option value="URGENT">
<span className="flex items-center gap-2">
<PriorityLevel level="URGENT" />
{__("Urgent")}
</span>
</Option>
<Option value="HIGH">
<span className="flex items-center gap-2">
<PriorityLevel level="HIGH" />
{__("High")}
</span>
</Option>
<Option value="MEDIUM">
<span className="flex items-center gap-2">
<PriorityLevel level="MEDIUM" />
{__("Medium")}
</span>
</Option>
<Option value="LOW">
<span className="flex items-center gap-2">
<PriorityLevel level="LOW" />
{__("Low")}
</span>
</Option>
</Select>
)}
/>
</PropertyRow>
<PropertyRow <PropertyRow
label={__("Assigned to")} label={__("Assigned to")}
error={formState.errors.assignedToId?.message} error={formState.errors.assignedToId?.message}

View File

@@ -43,7 +43,10 @@ import {
import { Link, useLocation, useParams } from "react-router"; import { Link, useLocation, useParams } from "react-router";
import type { TaskFormDialogFragment$key } from "#/__generated__/core/TaskFormDialogFragment.graphql"; import type { TaskFormDialogFragment$key } from "#/__generated__/core/TaskFormDialogFragment.graphql";
import type { TaskFormDialogUpdateMutation } from "#/__generated__/core/TaskFormDialogUpdateMutation.graphql"; import type {
TaskFormDialogUpdateMutation,
TaskPriority,
} from "#/__generated__/core/TaskFormDialogUpdateMutation.graphql";
import type { TasksCard_task$key } from "#/__generated__/core/TasksCard_task.graphql"; import type { TasksCard_task$key } from "#/__generated__/core/TasksCard_task.graphql";
import type { TasksCard_TaskRowFragment$key } from "#/__generated__/core/TasksCard_TaskRowFragment.graphql"; import type { TasksCard_TaskRowFragment$key } from "#/__generated__/core/TasksCard_TaskRowFragment.graphql";
import type { TasksCardDeleteMutation } from "#/__generated__/core/TasksCardDeleteMutation.graphql"; import type { TasksCardDeleteMutation } from "#/__generated__/core/TasksCardDeleteMutation.graphql";
@@ -53,11 +56,35 @@ import type {
} from "#/__generated__/core/TasksCardOrganizationFragment.graphql"; } from "#/__generated__/core/TasksCardOrganizationFragment.graphql";
import type { TasksCardOrganizationQuery } from "#/__generated__/core/TasksCardOrganizationQuery.graphql"; import type { TasksCardOrganizationQuery } from "#/__generated__/core/TasksCardOrganizationQuery.graphql";
import TaskFormDialog, { import TaskFormDialog, {
taskPriorities,
taskUpdateMutation, taskUpdateMutation,
} from "#/components/tasks/TaskFormDialog"; } from "#/components/tasks/TaskFormDialog";
import { updateStoreCounter } from "#/hooks/useMutationWithIncrement"; import { updateStoreCounter } from "#/hooks/useMutationWithIncrement";
import { useOrganizationId } from "#/hooks/useOrganizationId"; import { useOrganizationId } from "#/hooks/useOrganizationId";
function resolveDropPriority(
dragged: TaskPriority,
above?: TaskPriority,
below?: TaskPriority,
): TaskPriority | undefined {
// If any neighbor shares the dragged priority, keep it.
if (above === dragged || below === dragged) return undefined;
// At edges, take the single neighbor's priority.
if (!above && below) return below !== dragged ? below : undefined;
if (!below && above) return above !== dragged ? above : undefined;
// Both neighbors differ — pick the one closest to dragged.
if (above && below) {
const di = taskPriorities.indexOf(dragged);
const dAbove = Math.abs(taskPriorities.indexOf(above) - di);
const dBelow = Math.abs(taskPriorities.indexOf(below) - di);
return dAbove <= dBelow ? above : below;
}
return undefined;
}
type Props = { type Props = {
tasks: TasksCardOrganizationFragment$data["tasks"]["edges"]; tasks: TasksCardOrganizationFragment$data["tasks"]["edges"];
connectionId: string; connectionId: string;
@@ -70,6 +97,7 @@ const taskInlineFragment = graphql`
id id
state state
priority priority
rank
} }
`; `;
@@ -82,7 +110,7 @@ const organizationTasksFragment = graphql`
@refetchable(queryName: "TasksCardOrganizationQuery") @refetchable(queryName: "TasksCardOrganizationQuery")
@argumentDefinitions( @argumentDefinitions(
first: { type: "Int", defaultValue: 500 } first: { type: "Int", defaultValue: 500 }
order: { type: "TaskOrder", defaultValue: { field: PRIORITY, direction: ASC } } order: { type: "TaskOrder", defaultValue: { field: PRIORITY_RANK, direction: ASC } }
after: { type: "CursorKey", defaultValue: null } after: { type: "CursorKey", defaultValue: null }
before: { type: "CursorKey", defaultValue: null } before: { type: "CursorKey", defaultValue: null }
last: { type: "Int", defaultValue: null } last: { type: "Int", defaultValue: null }
@@ -110,7 +138,7 @@ const organizationTasksFragment = graphql`
type OrganizationTasksCardProps = { type OrganizationTasksCardProps = {
organizationRef: TasksCardOrganizationFragment$key; organizationRef: TasksCardOrganizationFragment$key;
header?: (params: { connectionId: string; canCreateTask: boolean }) => ReactNode; header?: (params: { connectionId: string; canCreateTask: boolean; refetch: () => void }) => ReactNode;
}; };
export function OrganizationTasksCard({ organizationRef, header }: OrganizationTasksCardProps) { export function OrganizationTasksCard({ organizationRef, header }: OrganizationTasksCardProps) {
@@ -119,9 +147,13 @@ export function OrganizationTasksCard({ organizationRef, header }: OrganizationT
TasksCardOrganizationFragment$key TasksCardOrganizationFragment$key
>(organizationTasksFragment, organizationRef); >(organizationTasksFragment, organizationRef);
const handleRefetch = () => {
refetch({}, { fetchPolicy: "store-and-network" });
};
return ( return (
<> <>
{header?.({ connectionId: data.tasks.__id, canCreateTask: data.canCreateTask })} {header?.({ connectionId: data.tasks.__id, canCreateTask: data.canCreateTask, refetch: handleRefetch })}
<TasksCard <TasksCard
tasks={data.tasks.edges} tasks={data.tasks.edges}
connectionId={data.tasks.__id} connectionId={data.tasks.__id}
@@ -132,12 +164,13 @@ export function OrganizationTasksCard({ organizationRef, header }: OrganizationT
); );
} }
const updatePriorityMutation = graphql` const updateRankMutation = graphql`
mutation TasksCardUpdatePriorityMutation($input: UpdateTaskInput!) { mutation TasksCardUpdateRankMutation($input: UpdateTaskInput!) {
updateTask(input: $input) { updateTask(input: $input) {
task { task {
id id
priority priority
rank
} }
} }
} }
@@ -151,7 +184,7 @@ export function TasksCard({ tasks, connectionId, canReorder, refetch }: Props) {
const { toast } = useToast(); const { toast } = useToast();
const [draggedId, setDraggedId] = useState<string | null>(null); const [draggedId, setDraggedId] = useState<string | null>(null);
const [previewOrder, setPreviewOrder] = useState<string[] | null>(null); const [previewOrder, setPreviewOrder] = useState<string[] | null>(null);
const [updatePriority] = useMutation<TaskFormDialogUpdateMutation>(updatePriorityMutation); const [updateRank] = useMutation<TaskFormDialogUpdateMutation>(updateRankMutation);
const handleStateChange = () => { const handleStateChange = () => {
if (refetch) { if (refetch) {
@@ -207,18 +240,32 @@ export function TasksCard({ tasks, connectionId, canReorder, refetch }: Props) {
const newIdx = previewOrder.indexOf(draggedId); const newIdx = previewOrder.indexOf(draggedId);
const originalIds = filteredTasks.map(({ node }) => readTask(node).id); const originalIds = filteredTasks.map(({ node }) => readTask(node).id);
const originalIdx = originalIds.indexOf(draggedId); const originalIdx = originalIds.indexOf(draggedId);
if (originalIdx === -1) {
setDraggedId(null);
setPreviewOrder(null);
return;
}
let targetOriginalIdx = newIdx; let targetOriginalIdx = newIdx;
if (targetOriginalIdx >= originalIdx) targetOriginalIdx++; if (targetOriginalIdx >= originalIdx) targetOriginalIdx++;
if (targetOriginalIdx >= filteredTasks.length) targetOriginalIdx = filteredTasks.length - 1; if (targetOriginalIdx >= filteredTasks.length) targetOriginalIdx = filteredTasks.length - 1;
const targetPriority = readTask(filteredTasks[targetOriginalIdx].node).priority; const targetTask = readTask(filteredTasks[targetOriginalIdx].node);
const draggedTask = readTask(filteredTasks[originalIdx].node);
// Determine target priority from neighbors at the drop position.
const aboveId = newIdx > 0 ? previewOrder[newIdx - 1] : null;
const belowId = newIdx < previewOrder.length - 1 ? previewOrder[newIdx + 1] : null;
const aboveTask = aboveId ? readTask(filteredTasks[originalIds.indexOf(aboveId)].node) : null;
const belowTask = belowId ? readTask(filteredTasks[originalIds.indexOf(belowId)].node) : null;
const targetPriority = resolveDropPriority(draggedTask.priority, aboveTask?.priority, belowTask?.priority);
setDraggedId(null); setDraggedId(null);
updatePriority({ updateRank({
variables: { variables: {
input: { input: {
taskId: draggedId, taskId: draggedId,
priority: targetPriority, rank: targetTask.rank,
...(targetPriority && { priority: targetPriority }),
}, },
}, },
onCompleted: (_, errors) => { onCompleted: (_, errors) => {
@@ -351,6 +398,7 @@ const fragment = graphql`
id id
name name
state state
priority
description description
timeEstimate timeEstimate
deadline deadline
@@ -452,6 +500,7 @@ function TaskRow(props: TaskRowProps) {
<TaskFormDialog <TaskFormDialog
task={props.fKey as TaskFormDialogFragment$key} task={props.fKey as TaskFormDialogFragment$key}
ref={dialogRef} ref={dialogRef}
onCompleted={props.onStateChange}
/> />
<div <div
className={`flex items-center justify-between py-3 px-6 ${className}`} className={`flex items-center justify-between py-3 px-6 ${className}`}
@@ -466,7 +515,7 @@ function TaskRow(props: TaskRowProps) {
> >
<div className="flex gap-2 items-start"> <div className="flex gap-2 items-start">
<div className="flex items-center gap-2 pt-[2px]"> <div className="flex items-center gap-2 pt-[2px]">
<PriorityLevel level={1} /> <PriorityLevel level={task.priority} />
<button <button
onClick={() => void onToggle()} onClick={() => void onToggle()}
className="cursor-pointer -m-1 p-1 disabled:opacity-60" className="cursor-pointer -m-1 p-1 disabled:opacity-60"

View File

@@ -29,7 +29,7 @@ const tasksQuery = graphql`
... on Measure { ... on Measure {
id id
canCreateTask: permission(action: "core:task:create") canCreateTask: permission(action: "core:task:create")
tasks(first: 100, orderBy: { field: PRIORITY, direction: ASC }) tasks(first: 100, orderBy: { field: PRIORITY_RANK, direction: ASC })
@connection(key: "Measure__tasks") @connection(key: "Measure__tasks")
@required(action: THROW) { @required(action: THROW) {
__id __id

View File

@@ -46,7 +46,7 @@ export default function TasksPage({ queryRef }: Props) {
<div className="space-y-6"> <div className="space-y-6">
<OrganizationTasksCard <OrganizationTasksCard
organizationRef={query.organization as TasksCardOrganizationFragment$key} organizationRef={query.organization as TasksCardOrganizationFragment$key}
header={({ connectionId, canCreateTask }) => ( header={({ connectionId, canCreateTask, refetch }) => (
<PageHeader <PageHeader
title={__("Tasks")} title={__("Tasks")}
description={__( description={__(
@@ -54,7 +54,7 @@ export default function TasksPage({ queryRef }: Props) {
)} )}
> >
{canCreateTask && ( {canCreateTask && (
<TaskFormDialog connection={connectionId}> <TaskFormDialog connection={connectionId} onCompleted={refetch}>
<Button icon={IconPlusLarge}>{__("New task")}</Button> <Button icon={IconPlusLarge}>{__("New task")}</Button>
</TaskFormDialog> </TaskFormDialog>
)} )}

View File

@@ -631,7 +631,7 @@ func TestRBAC(t *testing.T) {
client: owner, client: owner,
query: createTaskMutation, query: createTaskMutation,
variables: func() map[string]any { variables: func() map[string]any {
return map[string]any{"input": map[string]any{"organizationId": owner.GetOrganizationID().String(), "measureId": measureID, "name": factory.SafeName("Task")}} return map[string]any{"input": map[string]any{"organizationId": owner.GetOrganizationID().String(), "measureId": measureID, "name": factory.SafeName("Task"), "priority": "MEDIUM"}}
}, },
shouldAllow: true, shouldAllow: true,
}, },
@@ -641,7 +641,7 @@ func TestRBAC(t *testing.T) {
client: admin, client: admin,
query: createTaskMutation, query: createTaskMutation,
variables: func() map[string]any { variables: func() map[string]any {
return map[string]any{"input": map[string]any{"organizationId": owner.GetOrganizationID().String(), "measureId": measureID, "name": factory.SafeName("Task")}} return map[string]any{"input": map[string]any{"organizationId": owner.GetOrganizationID().String(), "measureId": measureID, "name": factory.SafeName("Task"), "priority": "MEDIUM"}}
}, },
shouldAllow: true, shouldAllow: true,
}, },
@@ -651,7 +651,7 @@ func TestRBAC(t *testing.T) {
client: viewer, client: viewer,
query: createTaskMutation, query: createTaskMutation,
variables: func() map[string]any { variables: func() map[string]any {
return map[string]any{"input": map[string]any{"organizationId": owner.GetOrganizationID().String(), "measureId": measureID, "name": factory.SafeName("Task")}} return map[string]any{"input": map[string]any{"organizationId": owner.GetOrganizationID().String(), "measureId": measureID, "name": factory.SafeName("Task"), "priority": "MEDIUM"}}
}, },
shouldAllow: false, shouldAllow: false,
}, },

View File

@@ -58,6 +58,7 @@ func TestTask_Create(t *testing.T) {
"measureId": measureID, "measureId": measureID,
"name": "Owner Task", "name": "Owner Task",
"description": "Created by owner", "description": "Created by owner",
"priority": "MEDIUM",
}, },
}, &result) }, &result)
require.NoError(t, err) require.NoError(t, err)
@@ -106,6 +107,7 @@ func TestTask_CreateWithoutMeasure(t *testing.T) {
"organizationId": owner.GetOrganizationID().String(), "organizationId": owner.GetOrganizationID().String(),
"name": "Task without measure", "name": "Task without measure",
"description": "Created without a measure", "description": "Created without a measure",
"priority": "HIGH",
}, },
}, &result) }, &result)
require.NoError(t, err) require.NoError(t, err)
@@ -253,6 +255,7 @@ func TestTask_RequiredFields(t *testing.T) {
name: "missing organizationId", name: "missing organizationId",
input: map[string]any{ input: map[string]any{
"name": "Test Task", "name": "Test Task",
"priority": "MEDIUM",
}, },
skipOrganization: true, skipOrganization: true,
wantErrorContains: "organizationId", wantErrorContains: "organizationId",
@@ -261,9 +264,18 @@ func TestTask_RequiredFields(t *testing.T) {
name: "missing name", name: "missing name",
input: map[string]any{ input: map[string]any{
"organizationId": "placeholder", "organizationId": "placeholder",
"priority": "MEDIUM",
}, },
wantErrorContains: "name", wantErrorContains: "name",
}, },
{
name: "missing priority",
input: map[string]any{
"organizationId": "placeholder",
"name": "Test Task",
},
wantErrorContains: "priority",
},
} }
for _, tt := range tests { for _, tt := range tests {
@@ -770,6 +782,7 @@ func TestTask_OmittableDeadline(t *testing.T) {
"organizationId": owner.GetOrganizationID().String(), "organizationId": owner.GetOrganizationID().String(),
"measureId": measureID, "measureId": measureID,
"name": "Deadline Test Task", "name": "Deadline Test Task",
"priority": "MEDIUM",
"deadline": "2025-12-31T00:00:00Z", "deadline": "2025-12-31T00:00:00Z",
}, },
}, &createResult) }, &createResult)

View File

@@ -339,6 +339,7 @@ func CreateTask(c *testutil.Client, measureID *string, attrs ...Attrs) string {
input := map[string]any{ input := map[string]any{
"organizationId": c.GetOrganizationID().String(), "organizationId": c.GetOrganizationID().String(),
"name": a.getString("name", SafeName("Task")), "name": a.getString("name", SafeName("Task")),
"priority": a.getString("priority", "MEDIUM"),
} }
if measureID != nil { if measureID != nil {
input["measureId"] = *measureID input["measureId"] = *measureID

View File

@@ -24,8 +24,26 @@ export default {
type Story = StoryObj<typeof PriorityLevel>; type Story = StoryObj<typeof PriorityLevel>;
export const Default: Story = { export const Low: Story = {
args: { args: {
level: 1, level: "LOW",
},
};
export const Medium: Story = {
args: {
level: "MEDIUM",
},
};
export const High: Story = {
args: {
level: "HIGH",
},
};
export const Urgent: Story = {
args: {
level: "URGENT",
}, },
}; };

View File

@@ -15,28 +15,46 @@
import { clsx } from "clsx"; import { clsx } from "clsx";
type Props = { type Props = {
level: number; level: "LOW" | "MEDIUM" | "HIGH" | "URGENT";
}; };
export function PriorityLevel({ level }: Props) { export function PriorityLevel({ level }: Props) {
if (level === "URGENT") {
return (
<div className="w-max flex items-center justify-center text-txt-danger">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M7 1.75v5.25M7 10.5h.005"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
);
}
const bars = level === "HIGH" ? 3 : level === "MEDIUM" ? 2 : 1;
return ( return (
<div className="w-max p-[2px] flex gap-[2px] items-end"> <div className="w-max p-[2px] flex gap-[2px] items-end">
<div <div
className={clsx( className={clsx(
"h-1 w-[3px] bg-txt-quaternary rounded", "h-1 w-[3px] rounded",
level >= 1 ? "bg-txt-secondary" : "bg-txt-quaternary", bars >= 1 ? "bg-txt-secondary" : "bg-txt-quaternary",
)} )}
/> />
<div <div
className={clsx( className={clsx(
"h-2 w-[3px] bg-txt-quaternary rounded", "h-2 w-[3px] rounded",
level >= 2 ? "bg-txt-secondary" : "bg-txt-quaternary", bars >= 2 ? "bg-txt-secondary" : "bg-txt-quaternary",
)} )}
/> />
<div <div
className={clsx( className={clsx(
"h-3 w-[3px] bg-txt-quaternary rounded", "h-3 w-[3px] rounded",
level >= 3 ? "bg-txt-secondary" : "bg-txt-quaternary", bars >= 3 ? "bg-txt-secondary" : "bg-txt-quaternary",
)} )}
/> />
</div> </div>

View File

@@ -0,0 +1,36 @@
-- Rename priority to rank
ALTER TABLE tasks RENAME COLUMN priority TO rank;
ALTER TABLE tasks DROP CONSTRAINT tasks_organization_id_state_priority_key;
-- Add task priority enum
CREATE TYPE task_priority AS ENUM ('URGENT', 'HIGH', 'MEDIUM', 'LOW');
ALTER TABLE tasks ADD COLUMN priority task_priority NOT NULL DEFAULT 'MEDIUM'::task_priority;
ALTER TABLE tasks ALTER COLUMN priority DROP DEFAULT;
-- Rank is now scoped to (state, priority) — backfill ranks per group
WITH ranked AS (
SELECT id, ROW_NUMBER() OVER (
PARTITION BY organization_id, state, priority
ORDER BY rank
) AS new_rank
FROM tasks
)
UPDATE tasks SET rank = ranked.new_rank FROM ranked WHERE tasks.id = ranked.id;
ALTER TABLE tasks
ADD CONSTRAINT tasks_organization_id_state_priority_rank_key
UNIQUE (organization_id, state, priority, rank)
DEFERRABLE INITIALLY DEFERRED;
-- Computed column for composite ordering (priority level then rank)
ALTER TABLE tasks ADD COLUMN priority_rank int GENERATED ALWAYS AS (
(CASE priority
WHEN 'URGENT' THEN 1
WHEN 'HIGH' THEN 2
WHEN 'MEDIUM' THEN 3
WHEN 'LOW' THEN 4
END) * 1000000 + rank
) STORED;

View File

@@ -37,13 +37,17 @@ type (
Name string `db:"name"` Name string `db:"name"`
Description *string `db:"description"` Description *string `db:"description"`
State TaskState `db:"state"` State TaskState `db:"state"`
Priority TaskPriority `db:"priority"`
ReferenceID string `db:"reference_id"` ReferenceID string `db:"reference_id"`
TimeEstimate *time.Duration `db:"time_estimate"` TimeEstimate *time.Duration `db:"time_estimate"`
AssignedToID *gid.GID `db:"assigned_to_profile_id"` AssignedToID *gid.GID `db:"assigned_to_profile_id"`
Deadline *time.Time `db:"deadline"` Deadline *time.Time `db:"deadline"`
Priority int `db:"priority"` Rank int `db:"rank"`
CreatedAt time.Time `db:"created_at"` CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"` UpdatedAt time.Time `db:"updated_at"`
// ordering only
PriorityRank int `db:"priority_rank"`
} }
Tasks []*Task Tasks []*Task
@@ -51,8 +55,8 @@ type (
func (t Task) CursorKey(orderBy TaskOrderField) page.CursorKey { func (t Task) CursorKey(orderBy TaskOrderField) page.CursorKey {
switch orderBy { switch orderBy {
case TaskOrderFieldPriority: case TaskOrderFieldPriorityRank:
return page.NewCursorKey(t.ID, t.Priority) return page.NewCursorKey(t.ID, t.PriorityRank)
case TaskOrderFieldCreatedAt: case TaskOrderFieldCreatedAt:
return page.NewCursorKey(t.ID, t.CreatedAt) return page.NewCursorKey(t.ID, t.CreatedAt)
} }
@@ -88,11 +92,13 @@ SELECT
name, name,
description, description,
state, state,
priority,
reference_id, reference_id,
time_estimate, time_estimate,
assigned_to_profile_id, assigned_to_profile_id,
deadline, deadline,
priority, rank,
priority_rank,
created_at, created_at,
updated_at updated_at
FROM FROM
@@ -141,11 +147,13 @@ SELECT
name, name,
description, description,
state, state,
priority,
reference_id, reference_id,
time_estimate, time_estimate,
assigned_to_profile_id, assigned_to_profile_id,
deadline, deadline,
priority, rank,
priority_rank,
created_at, created_at,
updated_at updated_at
FROM FROM
@@ -181,10 +189,10 @@ func (t *Task) Insert(
scope Scoper, scope Scoper,
) error { ) error {
q := ` q := `
WITH next_priority AS ( WITH next_rank AS (
SELECT COALESCE(MAX(priority), 0) + 1 AS value SELECT COALESCE(MAX(rank), 0) + 1 AS value
FROM tasks FROM tasks
WHERE organization_id = @organization_id AND state = @state WHERE organization_id = @organization_id AND state = @state AND priority = @priority
) )
INSERT INTO INSERT INTO
tasks ( tasks (
@@ -196,10 +204,11 @@ INSERT INTO
description, description,
reference_id, reference_id,
state, state,
priority,
time_estimate, time_estimate,
assigned_to_profile_id, assigned_to_profile_id,
deadline, deadline,
priority, rank,
created_at, created_at,
updated_at updated_at
) )
@@ -212,14 +221,15 @@ VALUES (
@description, @description,
@reference_id, @reference_id,
@state, @state,
@priority,
@time_estimate, @time_estimate,
@assigned_to_profile_id, @assigned_to_profile_id,
@deadline, @deadline,
(SELECT value FROM next_priority), (SELECT value FROM next_rank),
@created_at, @created_at,
@updated_at @updated_at
) )
RETURNING priority; RETURNING rank, priority_rank;
` `
args := pgx.StrictNamedArgs{ args := pgx.StrictNamedArgs{
@@ -231,6 +241,7 @@ RETURNING priority;
"description": t.Description, "description": t.Description,
"reference_id": t.ReferenceID, "reference_id": t.ReferenceID,
"state": t.State, "state": t.State,
"priority": t.Priority,
"time_estimate": t.TimeEstimate, "time_estimate": t.TimeEstimate,
"assigned_to_profile_id": t.AssignedToID, "assigned_to_profile_id": t.AssignedToID,
"deadline": t.Deadline, "deadline": t.Deadline,
@@ -238,7 +249,7 @@ RETURNING priority;
"updated_at": t.UpdatedAt, "updated_at": t.UpdatedAt,
} }
err := conn.QueryRow(ctx, q, args).Scan(&t.Priority) err := conn.QueryRow(ctx, q, args).Scan(&t.Rank, &t.PriorityRank)
if err != nil { if err != nil {
var pgErr *pgconn.PgError var pgErr *pgconn.PgError
if errors.As(err, &pgErr) { if errors.As(err, &pgErr) {
@@ -258,10 +269,10 @@ func (t *Task) Upsert(
scope Scoper, scope Scoper,
) error { ) error {
q := ` q := `
WITH next_priority AS ( WITH next_rank AS (
SELECT COALESCE(MAX(priority), 0) + 1 AS value SELECT COALESCE(MAX(rank), 0) + 1 AS value
FROM tasks FROM tasks
WHERE organization_id = @organization_id AND state = @state WHERE organization_id = @organization_id AND state = @state AND priority = @priority
) )
INSERT INTO INSERT INTO
tasks ( tasks (
@@ -273,10 +284,11 @@ INSERT INTO
description, description,
reference_id, reference_id,
state, state,
priority,
time_estimate, time_estimate,
assigned_to_profile_id, assigned_to_profile_id,
deadline, deadline,
priority, rank,
created_at, created_at,
updated_at updated_at
) )
@@ -289,10 +301,11 @@ VALUES (
@description, @description,
@reference_id, @reference_id,
@state, @state,
@priority,
@time_estimate, @time_estimate,
@assigned_to_profile_id, @assigned_to_profile_id,
@deadline, @deadline,
(SELECT value FROM next_priority), (SELECT value FROM next_rank),
@created_at, @created_at,
@updated_at @updated_at
) )
@@ -309,10 +322,12 @@ RETURNING
description, description,
reference_id, reference_id,
state, state,
priority,
time_estimate, time_estimate,
assigned_to_profile_id, assigned_to_profile_id,
deadline, deadline,
priority, rank,
priority_rank,
created_at, created_at,
updated_at updated_at
` `
@@ -326,6 +341,7 @@ RETURNING
"description": t.Description, "description": t.Description,
"reference_id": t.ReferenceID, "reference_id": t.ReferenceID,
"state": t.State, "state": t.State,
"priority": t.Priority,
"time_estimate": t.TimeEstimate, "time_estimate": t.TimeEstimate,
"assigned_to_profile_id": t.AssignedToID, "assigned_to_profile_id": t.AssignedToID,
"deadline": t.Deadline, "deadline": t.Deadline,
@@ -394,11 +410,13 @@ func (t *Tasks) LoadByOrganizationID(
name, name,
description, description,
state, state,
priority,
reference_id, reference_id,
time_estimate, time_estimate,
assigned_to_profile_id, assigned_to_profile_id,
deadline, deadline,
priority, rank,
priority_rank,
created_at, created_at,
updated_at updated_at
FROM FROM
@@ -476,11 +494,13 @@ SELECT
name, name,
description, description,
state, state,
priority,
reference_id, reference_id,
time_estimate, time_estimate,
assigned_to_profile_id, assigned_to_profile_id,
deadline, deadline,
priority, rank,
priority_rank,
created_at, created_at,
updated_at updated_at
FROM FROM
@@ -523,6 +543,7 @@ SET
description = @description, description = @description,
state = @state, state = @state,
priority = @priority, priority = @priority,
rank = @rank,
time_estimate = @time_estimate, time_estimate = @time_estimate,
updated_at = @updated_at, updated_at = @updated_at,
assigned_to_profile_id = @assigned_to_profile_id, assigned_to_profile_id = @assigned_to_profile_id,
@@ -538,6 +559,7 @@ WHERE %s
"description": t.Description, "description": t.Description,
"state": t.State, "state": t.State,
"priority": t.Priority, "priority": t.Priority,
"rank": t.Rank,
"time_estimate": t.TimeEstimate, "time_estimate": t.TimeEstimate,
"updated_at": t.UpdatedAt, "updated_at": t.UpdatedAt,
"assigned_to_profile_id": t.AssignedToID, "assigned_to_profile_id": t.AssignedToID,
@@ -550,17 +572,18 @@ WHERE %s
return err return err
} }
func (t *Task) NextPriorityForState( func (t *Task) NextRankForStatePriority(
ctx context.Context, ctx context.Context,
conn pg.Conn, conn pg.Conn,
scope Scoper, scope Scoper,
) error { ) error {
q := ` q := `
SELECT COALESCE(MAX(priority), 0) + 1 SELECT COALESCE(MAX(rank), 0) + 1
FROM tasks FROM tasks
WHERE WHERE
organization_id = @organization_id organization_id = @organization_id
AND state = @state AND state = @state
AND priority = @priority
AND id != @id AND id != @id
AND %s; AND %s;
` `
@@ -570,24 +593,25 @@ WHERE
"id": t.ID, "id": t.ID,
"organization_id": t.OrganizationID, "organization_id": t.OrganizationID,
"state": t.State, "state": t.State,
"priority": t.Priority,
} }
maps.Copy(args, scope.SQLArguments()) maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args) rows, err := conn.Query(ctx, q, args)
if err != nil { if err != nil {
return fmt.Errorf("cannot get next priority: %w", err) return fmt.Errorf("cannot get next rank: %w", err)
} }
priority, err := pgx.CollectExactlyOneRow(rows, pgx.RowTo[int]) rank, err := pgx.CollectExactlyOneRow(rows, pgx.RowTo[int])
if err != nil { if err != nil {
return fmt.Errorf("cannot get next priority: %w", err) return fmt.Errorf("cannot get next rank: %w", err)
} }
t.Priority = priority t.Rank = rank
return nil return nil
} }
func (t *Task) UpdatePriority( func (t *Task) UpdateRank(
ctx context.Context, ctx context.Context,
conn pg.Conn, conn pg.Conn,
scope Scoper, scope Scoper,
@@ -595,18 +619,18 @@ func (t *Task) UpdatePriority(
q := ` q := `
WITH old AS ( WITH old AS (
SELECT SELECT
priority AS old_priority rank AS old_rank
FROM tasks FROM tasks
WHERE %s AND id = @id AND organization_id = @organization_id AND state = @state WHERE %s AND id = @id AND organization_id = @organization_id AND state = @state AND priority = @priority
) )
UPDATE tasks UPDATE tasks
SET SET
priority = CASE rank = CASE
WHEN id = @id THEN @new_priority WHEN id = @id THEN @new_rank
ELSE priority + CASE ELSE rank + CASE
WHEN @new_priority < old.old_priority THEN 1 WHEN @new_rank < old.old_rank THEN 1
WHEN @new_priority > old.old_priority THEN -1 WHEN @new_rank > old.old_rank THEN -1
END END
END, END,
updated_at = @updated_at updated_at = @updated_at
@@ -614,9 +638,10 @@ FROM old
WHERE %s WHERE %s
AND organization_id = @organization_id AND organization_id = @organization_id
AND state = @state AND state = @state
AND priority = @priority
AND ( AND (
id = @id id = @id
OR (priority BETWEEN LEAST(old.old_priority, @new_priority) AND GREATEST(old.old_priority, @new_priority)) OR (rank BETWEEN LEAST(old.old_rank, @new_rank) AND GREATEST(old.old_rank, @new_rank))
); );
` `
@@ -625,16 +650,17 @@ WHERE %s
args := pgx.StrictNamedArgs{ args := pgx.StrictNamedArgs{
"id": t.ID, "id": t.ID,
"new_priority": t.Priority, "new_rank": t.Rank,
"organization_id": t.OrganizationID, "organization_id": t.OrganizationID,
"state": t.State, "state": t.State,
"priority": t.Priority,
"updated_at": t.UpdatedAt, "updated_at": t.UpdatedAt,
} }
maps.Copy(args, scope.SQLArguments()) maps.Copy(args, scope.SQLArguments())
_, err := conn.Exec(ctx, q, args) _, err := conn.Exec(ctx, q, args)
if err != nil { if err != nil {
return fmt.Errorf("cannot update task priority: %w", err) return fmt.Errorf("cannot update task rank: %w", err)
} }
return nil return nil

View File

@@ -21,14 +21,14 @@ type (
) )
const ( const (
TaskOrderFieldPriority TaskOrderField = "PRIORITY" TaskOrderFieldPriorityRank TaskOrderField = "PRIORITY_RANK" // ordering only
TaskOrderFieldCreatedAt TaskOrderField = "CREATED_AT" TaskOrderFieldCreatedAt TaskOrderField = "CREATED_AT"
) )
func (p TaskOrderField) Column() string { func (p TaskOrderField) Column() string {
switch p { switch p {
case TaskOrderFieldPriority: case TaskOrderFieldPriorityRank:
return "priority" return "priority_rank"
case TaskOrderFieldCreatedAt: case TaskOrderFieldCreatedAt:
return "created_at" return "created_at"
} }
@@ -37,7 +37,7 @@ func (p TaskOrderField) Column() string {
func (p TaskOrderField) IsValid() bool { func (p TaskOrderField) IsValid() bool {
switch p { switch p {
case TaskOrderFieldPriority, TaskOrderFieldCreatedAt: case TaskOrderFieldPriorityRank, TaskOrderFieldCreatedAt:
return true return true
} }
return false return false

View File

@@ -0,0 +1,72 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package coredata
import (
"database/sql/driver"
"fmt"
)
type TaskPriority string
const (
TaskPriorityUrgent TaskPriority = "URGENT"
TaskPriorityHigh TaskPriority = "HIGH"
TaskPriorityMedium TaskPriority = "MEDIUM"
TaskPriorityLow TaskPriority = "LOW"
)
func TaskPriorities() []TaskPriority {
return []TaskPriority{
TaskPriorityUrgent,
TaskPriorityHigh,
TaskPriorityMedium,
TaskPriorityLow,
}
}
func (tp TaskPriority) String() string {
return string(tp)
}
func (tp *TaskPriority) Scan(value any) error {
var s string
switch v := value.(type) {
case string:
s = v
case []byte:
s = string(v)
default:
return fmt.Errorf("unsupported type for TaskPriority: %T", value)
}
switch s {
case "URGENT":
*tp = TaskPriorityUrgent
case "HIGH":
*tp = TaskPriorityHigh
case "MEDIUM":
*tp = TaskPriorityMedium
case "LOW":
*tp = TaskPriorityLow
default:
return fmt.Errorf("invalid TaskPriority value: %q", s)
}
return nil
}
func (tp TaskPriority) Value() (driver.Value, error) {
return tp.String(), nil
}

View File

@@ -411,6 +411,7 @@ func (s MeasureService) Import(
Description: &taskDescription, Description: &taskDescription,
ReferenceID: req.Measures[i].Tasks[j].ReferenceID, ReferenceID: req.Measures[i].Tasks[j].ReferenceID,
State: coredata.TaskStateTodo, State: coredata.TaskStateTodo,
Priority: coredata.TaskPriorityMedium,
CreatedAt: now, CreatedAt: now,
UpdatedAt: now, UpdatedAt: now,
} }

View File

@@ -37,6 +37,7 @@ type (
MeasureID *gid.GID MeasureID *gid.GID
Name string Name string
Description *string Description *string
Priority coredata.TaskPriority
TimeEstimate *time.Duration TimeEstimate *time.Duration
AssignedToID *gid.GID AssignedToID *gid.GID
Deadline *time.Time Deadline *time.Time
@@ -47,11 +48,12 @@ type (
Name *string Name *string
Description **string Description **string
State *coredata.TaskState State *coredata.TaskState
Priority *coredata.TaskPriority
TimeEstimate **time.Duration TimeEstimate **time.Duration
Deadline **time.Time Deadline **time.Time
AssignedToID **gid.GID AssignedToID **gid.GID
MeasureID **gid.GID MeasureID **gid.GID
Priority *int Rank *int
} }
) )
@@ -62,6 +64,7 @@ func (ctr *CreateTaskRequest) Validate() error {
v.Check(ctr.MeasureID, "measure_id", validator.GID(coredata.MeasureEntityType)) v.Check(ctr.MeasureID, "measure_id", validator.GID(coredata.MeasureEntityType))
v.Check(ctr.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength)) v.Check(ctr.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength))
v.Check(ctr.Description, "description", validator.SafeText(ContentMaxLength)) v.Check(ctr.Description, "description", validator.SafeText(ContentMaxLength))
v.Check(ctr.Priority, "priority", validator.Required(), validator.OneOfSlice(coredata.TaskPriorities()))
v.Check(ctr.TimeEstimate, "time_estimate", validator.RangeDuration(0, 1000*time.Hour)) v.Check(ctr.TimeEstimate, "time_estimate", validator.RangeDuration(0, 1000*time.Hour))
v.Check(ctr.AssignedToID, "assigned_to_id", validator.GID(coredata.MembershipProfileEntityType)) v.Check(ctr.AssignedToID, "assigned_to_id", validator.GID(coredata.MembershipProfileEntityType))
@@ -74,10 +77,12 @@ func (utr *UpdateTaskRequest) Validate() error {
v.Check(utr.TaskID, "task_id", validator.Required(), validator.GID(coredata.TaskEntityType)) v.Check(utr.TaskID, "task_id", validator.Required(), validator.GID(coredata.TaskEntityType))
v.Check(utr.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength)) v.Check(utr.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength))
v.Check(utr.Description, "description", validator.SafeText(ContentMaxLength)) v.Check(utr.Description, "description", validator.SafeText(ContentMaxLength))
v.Check(utr.Priority, "priority", validator.OneOfSlice(coredata.TaskPriorities()))
v.Check(utr.TimeEstimate, "time_estimate", validator.RangeDuration(0, 1000*time.Hour)) v.Check(utr.TimeEstimate, "time_estimate", validator.RangeDuration(0, 1000*time.Hour))
v.Check(utr.State, "state", validator.OneOfSlice(coredata.TaskStates())) v.Check(utr.State, "state", validator.OneOfSlice(coredata.TaskStates()))
v.Check(utr.AssignedToID, "assigned_to_id", validator.GID(coredata.MembershipProfileEntityType)) v.Check(utr.AssignedToID, "assigned_to_id", validator.GID(coredata.MembershipProfileEntityType))
v.Check(utr.MeasureID, "measure_id", validator.GID(coredata.MeasureEntityType)) v.Check(utr.MeasureID, "measure_id", validator.GID(coredata.MeasureEntityType))
v.Check(utr.Rank, "rank", validator.Min(1))
return v.Error() return v.Error()
} }
@@ -104,6 +109,7 @@ func (s TaskService) Create(
MeasureID: req.MeasureID, MeasureID: req.MeasureID,
Name: req.Name, Name: req.Name,
Description: req.Description, Description: req.Description,
Priority: req.Priority,
TimeEstimate: req.TimeEstimate, TimeEstimate: req.TimeEstimate,
AssignedToID: req.AssignedToID, AssignedToID: req.AssignedToID,
Deadline: req.Deadline, Deadline: req.Deadline,
@@ -275,6 +281,7 @@ func (s TaskService) Update(
} }
oldState := task.State oldState := task.State
oldPriority := task.Priority
if req.Name != nil { if req.Name != nil {
task.Name = *req.Name task.Name = *req.Name
@@ -320,16 +327,19 @@ func (s TaskService) Update(
} }
} }
task.UpdatedAt = time.Now()
if req.Priority != nil { if req.Priority != nil {
task.Priority = *req.Priority task.Priority = *req.Priority
if err := task.UpdatePriority(ctx, conn, s.svc.scope); err != nil {
return fmt.Errorf("cannot update task priority: %w", err)
} }
} else if task.State != oldState {
if err := task.NextPriorityForState(ctx, conn, s.svc.scope); err != nil { task.UpdatedAt = time.Now()
return fmt.Errorf("cannot get next priority: %w", err)
targetRank := req.Rank
priorityChanged := task.Priority != oldPriority
stateChanged := task.State != oldState
if priorityChanged || stateChanged {
if err := task.NextRankForStatePriority(ctx, conn, s.svc.scope); err != nil {
return fmt.Errorf("cannot get next rank: %w", err)
} }
} }
@@ -337,6 +347,13 @@ func (s TaskService) Update(
return fmt.Errorf("cannot update task: %w", err) return fmt.Errorf("cannot update task: %w", err)
} }
if targetRank != nil {
task.Rank = *targetRank
if err := task.UpdateRank(ctx, conn, s.svc.scope); err != nil {
return fmt.Errorf("cannot update task rank: %w", err)
}
}
return nil return nil
}, },
) )

View File

@@ -69,6 +69,26 @@ enum TaskState @goModel(model: "go.probo.inc/probo/pkg/coredata.TaskState") {
DONE @goEnum(value: "go.probo.inc/probo/pkg/coredata.TaskStateDone") DONE @goEnum(value: "go.probo.inc/probo/pkg/coredata.TaskStateDone")
} }
enum TaskPriority
@goModel(model: "go.probo.inc/probo/pkg/coredata.TaskPriority") {
URGENT
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.TaskPriorityUrgent"
)
HIGH
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.TaskPriorityHigh"
)
MEDIUM
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.TaskPriorityMedium"
)
LOW
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.TaskPriorityLow"
)
}
enum EvidenceState enum EvidenceState
@goModel(model: "go.probo.inc/probo/pkg/coredata.EvidenceState") { @goModel(model: "go.probo.inc/probo/pkg/coredata.EvidenceState") {
FULFILLED FULFILLED
@@ -507,7 +527,7 @@ enum MeasureOrderField
enum TaskOrderField enum TaskOrderField
@goModel(model: "go.probo.inc/probo/pkg/coredata.TaskOrderField") { @goModel(model: "go.probo.inc/probo/pkg/coredata.TaskOrderField") {
PRIORITY PRIORITY_RANK
CREATED_AT CREATED_AT
} }
@@ -2404,7 +2424,8 @@ type Task implements Node {
name: String! name: String!
description: String description: String
state: TaskState! state: TaskState!
priority: Int! priority: TaskPriority!
rank: Int!
timeEstimate: Duration timeEstimate: Duration
deadline: Datetime deadline: Datetime
assignedTo: Profile @goField(forceResolver: true) assignedTo: Profile @goField(forceResolver: true)
@@ -4274,6 +4295,7 @@ input CreateTaskInput {
measureId: ID measureId: ID
name: String! name: String!
description: String description: String
priority: TaskPriority!
timeEstimate: Duration timeEstimate: Duration
assignedToId: ID assignedToId: ID
deadline: Datetime deadline: Datetime
@@ -4284,7 +4306,8 @@ input UpdateTaskInput {
name: String name: String
description: String @goField(omittable: true) description: String @goField(omittable: true)
state: TaskState state: TaskState
priority: Int priority: TaskPriority
rank: Int
timeEstimate: Duration @goField(omittable: true) timeEstimate: Duration @goField(omittable: true)
deadline: Datetime @goField(omittable: true) deadline: Datetime @goField(omittable: true)
assignedToId: ID @goField(omittable: true) assignedToId: ID @goField(omittable: true)

View File

@@ -71,6 +71,7 @@ func NewTask(t *coredata.Task) *Task {
Description: t.Description, Description: t.Description,
State: t.State, State: t.State,
Priority: t.Priority, Priority: t.Priority,
Rank: t.Rank,
TimeEstimate: t.TimeEstimate, TimeEstimate: t.TimeEstimate,
Deadline: t.Deadline, Deadline: t.Deadline,
CreatedAt: t.CreatedAt, CreatedAt: t.CreatedAt,

View File

@@ -4061,6 +4061,7 @@ func (r *mutationResolver) CreateTask(ctx context.Context, input types.CreateTas
OrganizationID: input.OrganizationID, OrganizationID: input.OrganizationID,
Name: input.Name, Name: input.Name,
Description: input.Description, Description: input.Description,
Priority: input.Priority,
TimeEstimate: input.TimeEstimate, TimeEstimate: input.TimeEstimate,
AssignedToID: input.AssignedToID, AssignedToID: input.AssignedToID,
Deadline: input.Deadline, Deadline: input.Deadline,
@@ -4099,6 +4100,7 @@ func (r *mutationResolver) UpdateTask(ctx context.Context, input types.UpdateTas
Description: gqlutils.UnwrapOmittable(input.Description), Description: gqlutils.UnwrapOmittable(input.Description),
State: input.State, State: input.State,
Priority: input.Priority, Priority: input.Priority,
Rank: input.Rank,
TimeEstimate: gqlutils.UnwrapOmittable(input.TimeEstimate), TimeEstimate: gqlutils.UnwrapOmittable(input.TimeEstimate),
Deadline: gqlutils.UnwrapOmittable(input.Deadline), Deadline: gqlutils.UnwrapOmittable(input.Deadline),
AssignedToID: gqlutils.UnwrapOmittable(input.AssignedToID), AssignedToID: gqlutils.UnwrapOmittable(input.AssignedToID),

View File

@@ -1896,6 +1896,7 @@ func (r *Resolver) UpdateTaskTool(ctx context.Context, req *mcp.CallToolRequest,
Description: UnwrapOmittable(input.Description), Description: UnwrapOmittable(input.Description),
State: input.State, State: input.State,
Priority: input.Priority, Priority: input.Priority,
Rank: input.Rank,
TimeEstimate: UnwrapOmittable(input.TimeEstimate), TimeEstimate: UnwrapOmittable(input.TimeEstimate),
Deadline: UnwrapOmittable(input.Deadline), Deadline: UnwrapOmittable(input.Deadline),
AssignedToID: UnwrapOmittable(input.AssignedToID), AssignedToID: UnwrapOmittable(input.AssignedToID),

View File

@@ -4621,7 +4621,7 @@ components:
TaskOrderField: TaskOrderField:
type: string type: string
enum: enum:
- PRIORITY - PRIORITY_RANK
- CREATED_AT - CREATED_AT
go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.TaskOrderField go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.TaskOrderField
@@ -4638,6 +4638,15 @@ components:
$ref: "#/components/schemas/OrderDirection" $ref: "#/components/schemas/OrderDirection"
description: Task order direction description: Task order direction
TaskPriority:
type: string
enum:
- URGENT
- HIGH
- MEDIUM
- LOW
go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.TaskPriority
Task: Task:
type: object type: object
required: required:
@@ -4646,6 +4655,7 @@ components:
- name - name
- state - state
- priority - priority
- rank
- created_at - created_at
- updated_at - updated_at
properties: properties:
@@ -4676,8 +4686,11 @@ components:
$ref: "#/components/schemas/TaskState" $ref: "#/components/schemas/TaskState"
description: Task state description: Task state
priority: priority:
$ref: "#/components/schemas/TaskPriority"
description: Task priority level
rank:
type: integer type: integer
description: Task priority within state description: Task rank within state
time_estimate: time_estimate:
anyOf: anyOf:
- $ref: "#/components/schemas/Duration" - $ref: "#/components/schemas/Duration"
@@ -4830,8 +4843,15 @@ components:
description: No state description: No state
description: Task state description: Task state
priority: priority:
anyOf:
- $ref: "#/components/schemas/TaskPriority"
description: Task priority level
- type: "null"
description: No priority
description: Task priority level
rank:
type: integer type: integer
description: Task priority within state description: Task rank within state
time_estimate: time_estimate:
anyOf: anyOf:
- $ref: "#/components/schemas/Duration" - $ref: "#/components/schemas/Duration"

View File

@@ -26,6 +26,7 @@ func NewTask(t *coredata.Task) *Task {
Description: t.Description, Description: t.Description,
State: t.State, State: t.State,
Priority: t.Priority, Priority: t.Priority,
Rank: t.Rank,
TimeEstimate: t.TimeEstimate, TimeEstimate: t.TimeEstimate,
CreatedAt: t.CreatedAt, CreatedAt: t.CreatedAt,
UpdatedAt: t.UpdatedAt, UpdatedAt: t.UpdatedAt,