| 1 | import { buildProjectGroups } from "@t3tools/client-runtime/state/project-grouping"; |
| 2 | import type { |
| 3 | EnvironmentProject, |
| 4 | EnvironmentThreadShell, |
| 5 | } from "@t3tools/client-runtime/state/shell"; |
| 6 | import { |
| 7 | getThreadSortTimestamp, |
| 8 | toSortableTimestamp, |
| 9 | } from "@t3tools/client-runtime/state/thread-sort"; |
| 10 | import type { |
| 11 | EnvironmentId, |
| 12 | ScopedProjectRef, |
| 13 | SidebarProjectGroupingMode, |
| 14 | SidebarProjectSortOrder, |
| 15 | } from "@t3tools/contracts"; |
| 16 | import * as Arr from "effect/Array"; |
| 17 | import * as Order from "effect/Order"; |
| 18 | |
| 19 | import { scopedProjectKey } from "../../lib/scopedEntities"; |
| 20 | import type { PendingNewTask } from "../../state/use-pending-new-tasks"; |
| 21 | |
| 22 | export type HomeProjectSortOrder = Exclude<SidebarProjectSortOrder, "manual">; |
| 23 | |
| 24 | export interface HomeProjectScope { |
| 25 | readonly key: string; |
| 26 | readonly title: string; |
| 27 | readonly representative: EnvironmentProject; |
| 28 | readonly projects: ReadonlyArray<EnvironmentProject>; |
| 29 | readonly projectRefs: ReadonlyArray<ScopedProjectRef>; |
| 30 | } |
| 31 | |
| 32 | function getProjectSortTimestamp( |
| 33 | project: EnvironmentProject, |
| 34 | sortOrder: HomeProjectSortOrder, |
| 35 | ): number { |
| 36 | return sortOrder === "created_at" |
| 37 | ? (toSortableTimestamp(project.createdAt) ?? Number.NEGATIVE_INFINITY) |
| 38 | : (toSortableTimestamp(project.updatedAt) ?? |
| 39 | toSortableTimestamp(project.createdAt) ?? |
| 40 | Number.NEGATIVE_INFINITY); |
| 41 | } |
| 42 | |
| 43 | export function buildHomeProjectScopes(input: { |
| 44 | readonly projects: ReadonlyArray<EnvironmentProject>; |
| 45 | readonly environmentId: EnvironmentId | null; |
| 46 | readonly projectGroupingMode: SidebarProjectGroupingMode; |
| 47 | }): ReadonlyArray<HomeProjectScope> { |
| 48 | const projects = input.projects.filter( |
| 49 | (project) => input.environmentId === null || project.environmentId === input.environmentId, |
| 50 | ); |
| 51 | return buildProjectGroups({ |
| 52 | projects, |
| 53 | groupScratchProjects: true, |
| 54 | settings: { |
| 55 | sidebarProjectGroupingMode: input.projectGroupingMode, |
| 56 | sidebarProjectGroupingOverrides: {}, |
| 57 | }, |
| 58 | }).map((group) => { |
| 59 | return { |
| 60 | key: group.key, |
| 61 | title: group.label, |
| 62 | representative: group.representative, |
| 63 | projects: group.members.map((member) => member.project), |
| 64 | projectRefs: group.memberProjectRefs, |
| 65 | }; |
| 66 | }); |
| 67 | } |
| 68 | |
| 69 | // Prefers an exact scope key so a caller-supplied project ID that equals another |
| 70 | // project's normalized workspace root cannot select the wrong group. |
| 71 | export function findHomeProjectScope( |
| 72 | scopes: ReadonlyArray<HomeProjectScope>, |
| 73 | key: string | null, |
| 74 | ): HomeProjectScope | null { |
| 75 | if (key === null) { |
| 76 | return null; |
| 77 | } |
| 78 | return ( |
| 79 | scopes.find((scope) => scope.key === key) ?? |
| 80 | scopes.find((scope) => |
| 81 | scope.projectRefs.some( |
| 82 | (projectRef) => scopedProjectKey(projectRef.environmentId, projectRef.projectId) === key, |
| 83 | ), |
| 84 | ) ?? |
| 85 | null |
| 86 | ); |
| 87 | } |
| 88 | |
| 89 | export function sortHomeProjectScopes(input: { |
| 90 | readonly scopes: ReadonlyArray<HomeProjectScope>; |
| 91 | readonly threads: ReadonlyArray<EnvironmentThreadShell>; |
| 92 | readonly pendingTasks: ReadonlyArray<PendingNewTask>; |
| 93 | readonly projectSortOrder: HomeProjectSortOrder; |
| 94 | }): ReadonlyArray<HomeProjectScope> { |
| 95 | const scopeKeyByProjectRef = new Map( |
| 96 | input.scopes.flatMap((scope) => |
| 97 | scope.projectRefs.map( |
| 98 | (projectRef) => |
| 99 | [scopedProjectKey(projectRef.environmentId, projectRef.projectId), scope.key] as const, |
| 100 | ), |
| 101 | ), |
| 102 | ); |
| 103 | const latestActivityByScope = new Map<string, number>(); |
| 104 | const recordActivity = (scopeKey: string | undefined, timestamp: number) => { |
| 105 | if (!scopeKey || !Number.isFinite(timestamp)) return; |
| 106 | latestActivityByScope.set( |
| 107 | scopeKey, |
| 108 | Math.max(latestActivityByScope.get(scopeKey) ?? Number.NEGATIVE_INFINITY, timestamp), |
| 109 | ); |
| 110 | }; |
| 111 | |
| 112 | for (const thread of input.threads) { |
| 113 | if (thread.archivedAt !== null) continue; |
| 114 | recordActivity( |
| 115 | scopeKeyByProjectRef.get(scopedProjectKey(thread.environmentId, thread.projectId)), |
| 116 | getThreadSortTimestamp(thread, input.projectSortOrder), |
| 117 | ); |
| 118 | } |
| 119 | for (const pendingTask of input.pendingTasks) { |
| 120 | recordActivity( |
| 121 | scopeKeyByProjectRef.get(scopedProjectKey(pendingTask.environmentId, pendingTask.projectId)), |
| 122 | Date.parse(pendingTask.createdAt), |
| 123 | ); |
| 124 | } |
| 125 | |
| 126 | return Arr.sort( |
| 127 | input.scopes, |
| 128 | Order.mapInput( |
| 129 | Order.Struct({ |
| 130 | timestamp: Order.flip(Order.Number), |
| 131 | title: Order.String, |
| 132 | key: Order.String, |
| 133 | }), |
| 134 | (scope: HomeProjectScope) => ({ |
| 135 | timestamp: |
| 136 | latestActivityByScope.get(scope.key) ?? |
| 137 | Math.max( |
| 138 | ...scope.projects.map((project) => |
| 139 | getProjectSortTimestamp(project, input.projectSortOrder), |
| 140 | ), |
| 141 | ), |
| 142 | title: scope.title, |
| 143 | key: scope.key, |
| 144 | }), |
| 145 | ), |
| 146 | ); |
| 147 | } |