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.

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