Skip to content

Commit

Permalink
feat: goal kanban ranking
Browse files Browse the repository at this point in the history
  • Loading branch information
tsumo authored and 9teen90nine committed Oct 31, 2024
1 parent 9279108 commit fdcec4a
Show file tree
Hide file tree
Showing 15 changed files with 256 additions and 66 deletions.
7 changes: 7 additions & 0 deletions generated/kysely/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,12 @@ export type goalParticipants = {
A: string;
B: string;
};
export type GoalRank = {
id: Generated<string>;
activityId: string;
goalId: string;
value: number;
};
export type goalStargizers = {
A: string;
B: string;
Expand Down Expand Up @@ -380,6 +386,7 @@ export type DB = {
Goal: Goal;
GoalAchieveCriteria: GoalAchieveCriteria;
GoalHistory: GoalHistory;
GoalRank: GoalRank;
Job: Job;
Priority: Priority;
Project: Project;
Expand Down
21 changes: 21 additions & 0 deletions prisma/migrations/20241029143242_goal_ranks/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
-- CreateTable
CREATE TABLE "GoalRank" (
"id" TEXT NOT NULL DEFAULT gen_random_uuid(),
"activityId" TEXT NOT NULL,
"goalId" TEXT NOT NULL,
"value" DOUBLE PRECISION NOT NULL,

CONSTRAINT "GoalRank_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE INDEX "GoalRank_activityId_idx" ON "GoalRank"("activityId");

-- CreateIndex
CREATE UNIQUE INDEX "GoalRank_activityId_goalId_key" ON "GoalRank"("activityId", "goalId");

-- AddForeignKey
ALTER TABLE "GoalRank" ADD CONSTRAINT "GoalRank_activityId_fkey" FOREIGN KEY ("activityId") REFERENCES "Activity"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "GoalRank" ADD CONSTRAINT "GoalRank_goalId_fkey" FOREIGN KEY ("goalId") REFERENCES "Goal"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
15 changes: 15 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ model Activity {
tags Tag[]
goalActions GoalHistory[] @relation("goalActions")
criterion GoalAchieveCriteria[]
goalRanks GoalRank[]
releasesRead Release[] @relation("releasesRead")
releasesDelayed Release[] @relation("releasesDelayed")
Expand Down Expand Up @@ -221,6 +222,7 @@ model Goal {
history GoalHistory[]
goalAchiveCriteria GoalAchieveCriteria[] @relation("GoalCriterion")
goalInCriteria GoalAchieveCriteria[] @relation("GoalInCriteria")
ranks GoalRank[]
completedCriteriaWeight Int?
partnershipProjects Project[] @relation("partnershipProjects")
Expand Down Expand Up @@ -458,3 +460,16 @@ model ExternalTask {
@@index([externalId])
@@index([title])
}

model GoalRank {
id String @id @default(dbgenerated("gen_random_uuid()"))
activity Activity @relation(fields: [activityId], references: [id])
activityId String
goal Goal @relation(fields: [goalId], references: [id])
goalId String
value Float
@@unique([activityId, goalId])
@@index([activityId])
}
61 changes: 35 additions & 26 deletions src/components/FiltersPanel/FiltersPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@ export const FiltersPanel: FC<{
preset: filterPreset,
});

const enableGoalsSort = view !== 'kanban';

const [filterQuery, setFilterQuery] = useState<Partial<FilterQueryState> | undefined>(queryFilterState);
const filterQueryRef = useLatest(filterQuery);

Expand Down Expand Up @@ -256,32 +258,39 @@ export const FiltersPanel: FC<{
</>
))}

<FiltersBarDropdownTitle>{tr('Goals sort')}</FiltersBarDropdownTitle>
<FiltersBarDropdownContent>
<SortList
value={filterQuery?.sort}
onChange={(key, dir) => {
let sortParams = (filterQuery?.sort ?? []).slice();

if (!dir) {
sortParams = sortParams.filter(({ key: k }) => key !== k);
} else {
const paramExistingIndex = sortParams.findIndex(({ key: k }) => key === k);

if (paramExistingIndex > -1) {
sortParams[paramExistingIndex] = {
key: key as SortableGoalsProps,
dir,
};
} else {
sortParams.push({ key: key as SortableGoalsProps, dir });
}
}

setSortFilter(sortParams);
}}
/>
</FiltersBarDropdownContent>
{nullable(enableGoalsSort, () => (
<>
<FiltersBarDropdownTitle>{tr('Goals sort')}</FiltersBarDropdownTitle>
<FiltersBarDropdownContent>
<SortList
value={filterQuery?.sort}
onChange={(key, dir) => {
let sortParams = (filterQuery?.sort ?? []).slice();

if (!dir) {
sortParams = sortParams.filter(({ key: k }) => key !== k);
} else {
const paramExistingIndex = sortParams.findIndex(
({ key: k }) => key === k,
);

if (paramExistingIndex > -1) {
sortParams[paramExistingIndex] = {
key: key as SortableGoalsProps,
dir,
};
} else {
sortParams.push({ key: key as SortableGoalsProps, dir });
}
}

setSortFilter(sortParams);
}}
/>
</FiltersBarDropdownContent>
</>
))}

{nullable(enableProjectsSort, () => (
<>
<FiltersBarDropdownTitle>{tr('Projects sort')}</FiltersBarDropdownTitle>
Expand Down
99 changes: 63 additions & 36 deletions src/components/Kanban/Kanban.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ const KanbanStateColumn: FC<KanbanStateColumnProps> = ({
id: projectId,
goalsQuery: {
...queryState,
sort: [{ key: 'rank', dir: 'asc' } as const],
partnershipProject: partnershipProject || undefined,
state: [stateId],
},
Expand Down Expand Up @@ -152,31 +153,80 @@ const KanbanStateColumn: FC<KanbanStateColumnProps> = ({
);

const stateChangeMutations = trpc.goal.switchState.useMutation();
const updateGoalRankMutation = trpc.v2.goal.updateRank.useMutation();
const utils = trpc.useContext();

const onDragEnd = useCallback<NonNullable<ComponentProps<typeof ReactSortable>['onEnd']>>(
async (result) => {
const goalId = result.item.id;
const newStateId = result.to.id;
const oldStateId = result.from.id;
const {
item: { id: goalId },
to: { id: newStateId },
from: { id: oldStateId },
oldIndex,
newIndex,
} = result;

if (oldIndex === undefined || newIndex === undefined) {
return;
}

const newState = newStateId ? shownStates.find(({ id }) => newStateId === id) : null;

const oldStateData = utils.v2.project.getProjectGoalsById.getInfiniteData(getColumnQuery(oldStateId));

const oldStateGoals =
oldStateData?.pages?.reduce<(typeof oldStateData)['pages'][number]['goals']>((acc, cur) => {
acc.push(...cur.goals);
return acc;
}, []) || [];

const state = newStateId ? shownStates.find(({ id }) => newStateId === id) : null;
const goal = oldStateGoals.find((goal) => goal.id === goalId);

if (!state) {
if (!goal) {
return;
}

const data = utils.v2.project.getProjectGoalsById.getInfiniteData(getColumnQuery(oldStateId));
const sameColumnReorder = goal !== undefined && newStateId === oldStateId && oldIndex !== newIndex;

if (sameColumnReorder) {
const lowGoal = newIndex < oldIndex ? oldStateGoals[newIndex - 1] : oldStateGoals[newIndex];
const highGoal = newIndex < oldIndex ? oldStateGoals[newIndex] : oldStateGoals[newIndex + 1];

await updateGoalRankMutation.mutateAsync({
id: goal.id,
low: lowGoal?.id,
high: highGoal?.id,
});

utils.v2.project.getProjectGoalsById.invalidate({
id: projectId,
});

return;
}

const goals =
data?.pages?.reduce<(typeof data)['pages'][number]['goals']>((acc, cur) => {
if (!newState) {
return;
}

const newStateData = utils.v2.project.getProjectGoalsById.getInfiniteData(getColumnQuery(newStateId));

const newStateGoals =
newStateData?.pages?.reduce<(typeof newStateData)['pages'][number]['goals']>((acc, cur) => {
acc.push(...cur.goals);
return acc;
}, []) || [];

const goal = goals.find((goal) => goal.id === goalId);
const lowGoal = newStateGoals[newIndex - 1];
const highGoal = newStateGoals[newIndex];

await utils.v2.project.getProjectGoalsById.setInfiniteData(getColumnQuery(oldStateId), (data) => {
await updateGoalRankMutation.mutateAsync({
id: goal.id,
low: lowGoal?.id,
high: highGoal?.id,
});

utils.v2.project.getProjectGoalsById.setInfiniteData(getColumnQuery(oldStateId), (data) => {
if (!data) {
return {
pages: [],
Expand All @@ -193,33 +243,10 @@ const KanbanStateColumn: FC<KanbanStateColumnProps> = ({
};
});

if (newStateId && goal) {
await utils.v2.project.getProjectGoalsById.setInfiniteData(getColumnQuery(newStateId), (data) => {
if (!data) {
return {
pages: [],
pageParams: [],
};
}

const [first, ...rest] = data.pages;

const updatedFirst = {
...first,
goals: [goal, ...first.goals],
};

return {
...data,
pages: [updatedFirst, ...rest],
};
});
}

await stateChangeMutations.mutate(
stateChangeMutations.mutate(
{
id: goalId,
state,
state: newState,
},
{
onSettled: () => {
Expand All @@ -230,7 +257,7 @@ const KanbanStateColumn: FC<KanbanStateColumnProps> = ({
},
);
},
[stateChangeMutations, utils, getColumnQuery, shownStates, projectId],
[stateChangeMutations, updateGoalRankMutation, utils, getColumnQuery, shownStates, projectId],
);

return (
Expand Down
1 change: 1 addition & 0 deletions src/schema/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const sortGoalsPropEnum = z.enum([
'project',
'activity',
'owner',
'rank',
'updatedAt',
'createdAt',
]);
Expand Down
16 changes: 16 additions & 0 deletions src/utils/ranking.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export const getMiddleRank = (values: { low?: number | null; high?: number | null }): number => {
const low = values.low ?? Number.MIN_VALUE;
const high = values.high ?? Number.MAX_VALUE;
if (low === high) throw new Error('Exhausted precision');
if (low > high) throw new Error('Low cannot be greater than high');
const middle = low + (high - low) / 2;
if (middle === low || middle === high) throw new Error('Exhausted precision');
return middle;
};

export const getRankSeries = (count: number) => {
const start = 1;
const end = 1000;
const step = (end - start) / Math.max(count - 1, 1);
return Array.from({ length: count }, (v, i) => start + i * step);
};
6 changes: 6 additions & 0 deletions trpc/queries/goalV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { getUserActivity } from './activity';
export const mapSortParamsToTableColumns = <T extends DB, K extends keyof T, R = T[K]>(
sort: QueryWithFilters['sort'],
key: K,
activityId: string,
): Array<OrderByExpression<T, K, R>> => {
const dbKey = db.dynamic.ref(key as string);

Expand Down Expand Up @@ -55,6 +56,10 @@ export const mapSortParamsToTableColumns = <T extends DB, K extends keyof T, R =
asc: sql`(select name from "User" where "User"."activityId" = ${dbKey}."ownerId") asc`,
desc: sql`(select name from "User" where "User"."activityId" = ${dbKey}."ownerId") desc`,
},
rank: {
asc: sql`(select value from "GoalRank" where "activityId" = ${activityId} and "goalId" = ${dbKey}.id) asc`,
desc: sql`(select value from "GoalRank" where "activityId" = ${activityId} and "goalId" = ${dbKey}.id) desc`,
},
};

return sort.map<OrderByExpression<T, K, R>>(({ key, dir }) => mapToTableColumn[key][dir]);
Expand Down Expand Up @@ -315,6 +320,7 @@ export const getGoalsQuery = (params: GetGoalsQueryParams) =>
mapSortParamsToTableColumns<DB & { proj_goals: DB['Goal'] }, 'proj_goals'>(
params.goalsQuery?.sort,
'proj_goals',
params.activityId,
),
)
.limit(params.limit ?? 10)
Expand Down
2 changes: 2 additions & 0 deletions trpc/queries/goals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,8 +225,10 @@ export const goalsFilter = (
asc: { user: { name: 'asc' } },
desc: { user: { name: 'desc' } },
},
rank: undefined,
};
data.sort.forEach(({ key, dir }) => {
if (key === 'rank') return;
const sortField = mapToSortedField[key];
if (sortField == null) {
orderBy.push({ [key]: dir });
Expand Down
2 changes: 1 addition & 1 deletion trpc/queries/projectV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -467,7 +467,7 @@ export const getUserDashboardProjects = (params: GetUserDashboardProjectsParams)
.where(getGoalsFiltersWhereExpressionBuilder(params.goalsQuery))
.where('Goal.archived', 'is not', true)
.groupBy('Goal.id')
.orderBy(mapSortParamsToTableColumns(params.goalsQuery?.sort, 'Goal')),
.orderBy(mapSortParamsToTableColumns(params.goalsQuery?.sort, 'Goal', params.activityId)),
)
.selectFrom('Project')
.leftJoinLateral(
Expand Down
Loading

0 comments on commit fdcec4a

Please sign in to comment.