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.

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