| 1 | import { Suspense, useEffect } from "react"; |
| 2 | import { Await, data } from "react-router"; |
| 3 | |
| 4 | import { hasCodeAccess } from "@g1t/contracts"; |
| 5 | |
| 6 | import type { Route } from "./+types/home"; |
| 7 | import { |
| 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"; |
| 22 | import { Skeleton } from "../../components/ui/skeleton"; |
| 23 | import { sidebarOrNull } from "../../lib/chat.server"; |
| 24 | import { |
| 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"; |
| 43 | import { |
| 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"; |
| 58 | import { readCookie } from "../../lib/mission"; |
| 59 | import { loadMissionControl } from "../../lib/mission-control.server"; |
| 60 | import { page } from "../../lib/meta"; |
| 61 | import { inbox } from "../../lib/services.server"; |
| 62 | import { requireUser, roleIn } from "../../lib/session.server"; |
| 63 | import { homePagePath } from "../../lib/workspace-nav"; |
| 64 | |
| 65 | export function meta({ params, ...args }: Route.MetaArgs) { |
| 66 | return page(args, { title: `Home · ${params.owner} · g1t` }); |
| 67 | } |
| 68 | |
| 69 | /** Unread notifications read for Needs you. */ |
| 70 | const 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 | */ |
| 84 | export 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. */ |
| 166 | const 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 | */ |
| 173 | function 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 | |
| 200 | export 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 | |
| 308 | function 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 | } |