1import { buildProjectGroups } from "@t3tools/client-runtime/state/project-grouping";
2import type {
3 EnvironmentProject,
4 EnvironmentThreadShell,
5} from "@t3tools/client-runtime/state/shell";
6import {
7 getThreadSortTimestamp,
8 toSortableTimestamp,
9} from "@t3tools/client-runtime/state/thread-sort";
10import type {
11 EnvironmentId,
12 ScopedProjectRef,
13 SidebarProjectGroupingMode,
14 SidebarProjectSortOrder,
15} from "@t3tools/contracts";
16import * as Arr from "effect/Array";
17import * as Order from "effect/Order";
18
19import { scopedProjectKey } from "../../lib/scopedEntities";
20import type { PendingNewTask } from "../../state/use-pending-new-tasks";
21
22export type HomeProjectSortOrder = Exclude<SidebarProjectSortOrder, "manual">;
23
24export 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
32function 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
43export 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.
71export 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
89export 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}