Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
313 linesCodeBlameRaw
1import { Suspense, useEffect } from "react";
2import { Await, data } from "react-router";
3
4import { hasCodeAccess } from "@g1t/contracts";
5
6import type { Route } from "./+types/home";
7import {
8 Card,
9 DecisionsCard,
10 DeploysCard,
11 LandedCard,
12 NeedsList,
13 PeopleCard,
14 RowsSkeleton,
15 RunningList,
16 SpanSkeleton,
17 SpendCard,
18 SpendSkeleton,
19 StartHere,
20 WindowSwitch,
21} from "../../components/home";
22import { Skeleton } from "../../components/ui/skeleton";
23import { sidebarOrNull } from "../../lib/chat.server";
24import {
25 type Attention,
26 type Digest,
27 attention,
28 decisionsIn,
29 deploysIn,
30 digestOf,
31 landed,
32 landedDeploys,
33 landedFromActivity,
34 landedIn,
35 parseWindow,
36 running,
37 runningDeploys,
38 runningWorkflows,
39 spanFor,
40 spanSentence,
41 waitingSentence,
42} from "../../lib/home";
43import {
44 actorKeys,
45 loadActivity,
46 loadChatActivity,
47 loadCodeWork,
48 loadDeploys,
49 loadLastVisit,
50 loadMemories,
51 loadNames,
52 loadRepos,
53 loadSpend,
54 loadWorkflowRuns,
55 reachFor,
56 repoRefs,
57} from "../../lib/home.server";
58import { readCookie } from "../../lib/mission";
59import { loadMissionControl } from "../../lib/mission-control.server";
60import { page } from "../../lib/meta";
61import { inbox } from "../../lib/services.server";
62import { requireUser, roleIn } from "../../lib/session.server";
63import { homePagePath } from "../../lib/workspace-nav";
64
65export function meta({ params, ...args }: Route.MetaArgs) {
66 return page(args, { title: `Home · ${params.owner} · g1t` });
67}
68
69/** Unread notifications read for Needs you. */
70const NOTIFICATIONS_READ = 50;
71
72/**
73 * Home: the workspace's front page. Where you're needed now, with no time
74 * window; what happened since you were last here (or over the last 24
75 * hours or 7 days, `?window=`): what people did, from the events
76 * service's digest of the span and chat's, then what landed; what is
77 * running; what the span cost. The last visit is kept by notify, per
78 * person and workspace, and marked by the page once it has been looked
79 * at (routes/workspace/home-seen.ts), so a refresh never wipes it. Only
80 * the last visit (for the span) is waited for; every section streams in
81 * on its own, and one whose service does not answer says so.
82 * Definitions: lib/home.ts and the Home guide.
83 */
84export async function loader({ params, context, request }: Route.LoaderArgs) {
85 const viewer = requireUser(context, request);
86 const slug = params.owner.toLowerCase();
87 if (!roleIn(viewer, slug)) throw data(null, { status: 404 });
88 const membership = viewer.workspaces?.find((m) => m.slug === slug) ?? null;
89 const code = hasCodeAccess(membership);
90 const now = Date.now();
91 const tz = readCookie(request.headers.get("cookie"), "g1t_tz");
92 const windowKey = parseWindow(new URL(request.url).searchParams.get("window"));
93
94 // What needs you has no window: read at once, beside the last visit.
95 const missionP = code ? loadMissionControl(viewer, request, slug).then(({ value }) => value).catch(() => null) : Promise.resolve(null);
96 const chatP = sidebarOrNull(slug, viewer);
97 const notificationsP = inbox
98 .list(viewer, { unread: true, limit: NOTIFICATIONS_READ })
99 .then((result) => result.items)
100 .catch(() => null);
101 const lastSeenP = windowKey === "last" ? loadLastVisit(viewer, slug).catch(() => undefined) : Promise.resolve(null);
102
103 const waiting: Promise<Attention> = Promise.all([missionP, notificationsP, chatP])
104 .then(([mission, notifications, sidebar]) =>
105 attention({ slug, code, codeNeeds: mission?.needs ?? null, notifications, chat: sidebar?.entries ?? null }),
106 )
107 .catch(() => ({ rows: [], start: null, missing: ["Code", "Notifications", "Chat"] }));
108
109 const span = spanFor(windowKey, now, await lastSeenP, tz);
110
111 // What happened in the span: the repositories once, for every section that reads them.
112 const reposP = code ? loadRepos(viewer, slug).catch(() => null) : Promise.resolve(null);
113 const codeWorkP = code ? reposP.then((owned) => loadCodeWork(viewer, slug, reachFor(span), owned)).catch(() => null) : Promise.resolve("no_access" as const);
114 const deploysP = code ? loadDeploys(viewer, slug, span.from).catch(() => null) : Promise.resolve(null);
115 const memoriesP = code ? loadMemories(viewer, slug).catch(() => null) : Promise.resolve(null);
116 const activityP = reposP.then((owned) => loadActivity(slug, span, owned)).catch(() => null);
117 const chatActivityP = loadChatActivity(viewer, slug, span).catch(() => null);
118 const namesP = Promise.all([activityP, chatActivityP, deploysP])
119 .then(([activity, chatActivity, deploys]) => loadNames(actorKeys(activity, chatActivity, deploys)))
120 .catch(() => ({}) as Record<string, string>);
121
122 // What people did, from events and chat.
123 const digest: Promise<Digest | null> = Promise.all([activityP, chatActivityP, namesP, reposP])
124 .then(([activity, chatActivity, names, owned]) => digestOf({ slug, repos: repoRefs(owned), activity, chat: chatActivity, names, code }))
125 .catch(() => null);
126 const happened = Promise.all([codeWorkP, deploysP, memoriesP, activityP, namesP, reposP])
127 .then(([codeWork, deploys, memories, activity, names, owned]) => {
128 const deployRows = deploys ? deploysIn(deploys.projects, slug, span, names) : null;
129 const merges = codeWork && codeWork !== "no_access" ? landedIn(codeWork.pulls, span) : null;
130 return {
131 // What landed: merges, direct pushes, releases and packages, production builds; null when nothing of Code could be read.
132 landed: merges == null && activity == null ? null : landed([merges ?? [], landedFromActivity(activity, repoRefs(owned), names, slug), landedDeploys(deployRows ?? [])]),
133 landedMissing: [...(code && merges == null ? ["Pull requests"] : []), ...(code && activity == null ? ["Pushes and releases"] : []), ...(code && deployRows == null ? ["Deployments"] : [])],
134 deploys: deployRows,
135 deploysComplete: deploys?.complete ?? true,
136 decisions: decisionsIn(memories, slug, span),
137 };
138 })
139 .catch(() => null);
140 const workflowsP = code ? Promise.all([activityP, reposP]).then(([activity, owned]) => loadWorkflowRuns(viewer, activity, owned)).catch(() => null) : Promise.resolve(null);
141 const runningNow = Promise.all([deploysP, workflowsP])
142 .then(([deploys, workflows]) => ({
143 rows: running([runningWorkflows(workflows?.projects ?? []), runningDeploys(deploys?.overview ?? [], slug)]),
144 missing: [...(code && (!workflows || !workflows.complete) ? ["Workflows"] : []), ...(code && !deploys ? ["Deployments"] : [])],
145 }))
146 .catch(() => ({ rows: [], missing: ["Workflows", "Deployments"] }));
147 const spend = loadSpend(viewer, slug, span).catch(() => null);
148
149 return {
150 slug,
151 code,
152 now,
153 tz,
154 span,
155 digest,
156 happened,
157 attention: waiting,
158 running: runningNow,
159 /** The sentence under the heading needs the digest, what landed and what waits. */
160 head: Promise.all([digest, happened, waiting]),
161 spend,
162 };
163}
164
165/** How long the page must have been in view before it counts as a visit. */
166const SEEN_AFTER_MS = 5_000;
167
168/**
169 * Marks this visit once the page has been in view for a few seconds, with
170 * the time it loaded: what happened after that shows next time. Never on
171 * first paint, so a quick refresh keeps what was not looked at yet.
172 */
173function useMarkSeen(slug: string, loadedAt: number) {
174 useEffect(() => {
175 let timer: ReturnType<typeof setTimeout> | null = null;
176 let done = false;
177 const mark = () => {
178 if (done) return;
179 done = true;
180 void fetch(`${homePagePath(slug)}/seen`, {
181 method: "POST",
182 headers: { "content-type": "application/json" },
183 body: JSON.stringify({ at: loadedAt }),
184 keepalive: true,
185 }).catch(() => undefined);
186 };
187 const arm = () => {
188 if (timer) clearTimeout(timer);
189 timer = document.visibilityState === "visible" ? setTimeout(mark, SEEN_AFTER_MS) : null;
190 };
191 arm();
192 document.addEventListener("visibilitychange", arm);
193 return () => {
194 document.removeEventListener("visibilitychange", arm);
195 if (timer) clearTimeout(timer);
196 };
197 }, [slug, loadedAt]);
198}
199
200export default function Home({ loaderData }: Route.ComponentProps) {
201 const { slug, now, tz, span, digest, happened, attention: waiting, running: runningNow, head, spend } = loaderData;
202 useMarkSeen(slug, now);
203 return (
204 <div className="mx-auto w-full max-w-[1040px] space-y-5 px-4 py-5 md:px-10 md:py-8">
205 <header className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
206 <div className="min-w-0">
207 <h1 className="text-[1.75rem] leading-tight font-semibold tracking-tight">{span.key === "last" && span.note == null ? "Welcome back" : "Home"}</h1>
208 <p className="mt-1.5 max-w-2xl text-sm leading-relaxed text-muted">
209 <Suspense fallback={<Skeleton className="inline-block h-3.5 w-72 align-middle" />}>
210 <Await resolve={head}>
211 {([digest, happened, att]) => (
212 <>
213 <span className="text-fg-soft">{waitingSentence(att.rows.length)}</span> {spanSentence(span, digest, happened?.landed?.length ?? null)}
214 </>
215 )}
216 </Await>
217 </Suspense>
218 </p>
219 </div>
220 <WindowSwitch span={span} />
221 </header>
222
223 {/* 1. Where you're needed now: no window, first and biggest. Start here beside it on wide screens, under it on phones. */}
224 <div className="grid items-start gap-5 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]">
225 <div className="lg:col-start-1 lg:row-start-1">
226 <Card
227 title="Where you're needed"
228 hint="Everything waiting on you, however long ago it started"
229 link={{ label: "Notifications", to: "/notifications" }}
230 >
231 <Suspense fallback={<RowsSkeleton rows={4} />}>
232 <Await resolve={waiting}>{(att) => <NeedsList attention={att} now={now} />}</Await>
233 </Suspense>
234 </Card>
235 </div>
236 <div className="lg:col-start-2 lg:row-start-1">
237 <Suspense
238 fallback={
239 <Card title="Start here">
240 <RowsSkeleton rows={1} />
241 </Card>
242 }
243 >
244 <Await resolve={waiting}>{(att) => <StartHere attention={att} now={now} />}</Await>
245 </Suspense>
246 </div>
247 </div>
248
249 {/* 2. What happened in the span. */}
250 <section aria-labelledby="since" className="space-y-4">
251 <div className="flex flex-wrap items-baseline gap-x-3 gap-y-1 pt-2">
252 <h2 id="since" className="text-lg font-semibold tracking-tight text-fg">
253 {span.key === "last" && span.note == null ? "Since you were last here" : span.words}
254 </h2>
255 <p className="text-sm text-muted">
256 {span.key === "last" && span.note == null ? `${span.words} · ${span.length}` : spanNote(span.note)}
257 </p>
258 </div>
259 <Suspense fallback={<SpanSkeleton />}>
260 <Await resolve={digest}>{(value) => <PeopleCard digest={value} span={span} code={loaderData.code} />}</Await>
261 </Suspense>
262 <div className="grid items-start gap-5 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]">
263 <div className="space-y-5">
264 <Suspense
265 fallback={
266 <Card title="Landed">
267 <RowsSkeleton rows={3} />
268 </Card>
269 }
270 >
271 <Await resolve={happened}>{(value) => <LandedCard landed={value?.landed ?? null} missing={value?.landedMissing ?? []} code={loaderData.code} now={now} tz={tz} />}</Await>
272 </Suspense>
273 <Suspense fallback={null}>
274 <Await resolve={happened}>
275 {(value) => <DeploysCard deploys={value?.deploys ?? null} complete={value?.deploysComplete ?? true} code={loaderData.code} now={now} tz={tz} />}
276 </Await>
277 </Suspense>
278 <Suspense fallback={null}>
279 <Await resolve={happened}>
280 {(value) => <DecisionsCard decisions={value?.decisions ?? null} now={now} tz={tz} />}
281 </Await>
282 </Suspense>
283 </div>
284 <div className="space-y-5">
285 <Suspense
286 fallback={
287 <Card title="Spent">
288 <SpendSkeleton />
289 </Card>
290 }
291 >
292 <Await resolve={spend}>{(value) => <SpendCard spend={value} slug={slug} span={span} />}</Await>
293 </Suspense>
294 </div>
295 </div>
296 </section>
297
298 {/* 3. Running now. */}
299 <Card title="Running now">
300 <Suspense fallback={<RowsSkeleton rows={2} />}>
301 <Await resolve={runningNow}>{(value) => <RunningList rows={value.rows} missing={value.missing} now={now} />}</Await>
302 </Suspense>
303 </Card>
304 </div>
305 );
306}
307
308function spanNote(note: "first" | "capped" | "unknown" | null): string {
309 if (note === "first") return "Your first visit here, so this is the last 24 hours.";
310 if (note === "capped") return "You were away longer than that; this is as far back as Home looks.";
311 if (note === "unknown") return "Your last visit couldn't be read, so this is the last 24 hours.";
312 return "";
313}