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.

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