Consolidate and optimize data fetching for ProjectCard

We consolidate the code that fetches the data for `ProjectCard` in one
module, adjacent to the `ProjectCard` component. We also consolidate all
the data into one interface to simplify the component API.

With the data loading in one place we can eliminate duplicate code and a
handful of helper functions.

The consolidation also allows us to discover some optimizations to
fetching: We parallelize requests and we use the commit detail endpoint
instead of the commit list endpoint. For the latter to pay off, we need
to deploy caching to the API.
This commit is contained in:
Thomas Scholtes
2024-03-05 10:49:25 +01:00
committed by Rūdolfs Ošiņš
parent d315380e01
commit 67e16ea7dd
7 changed files with 94 additions and 147 deletions
+16 -23
View File
@@ -1,32 +1,25 @@
<script lang="ts">
import type { BaseUrl } from "@httpd-client";
import type { WeeklyActivity } from "@app/lib/commit";
import { formatTimestamp, twemoji } from "@app/lib/utils";
import ActivityDiagram from "@app/components/ActivityDiagram.svelte";
import IconSmall from "@app/components/IconSmall.svelte";
import Link from "@app/components/Link.svelte";
import type { ProjectInfo } from "./ProjectCard";
export let compact = false;
export let activity: WeeklyActivity[];
export let description: string;
export let baseUrl: BaseUrl;
export let numberOfIssues: number;
export let numberOfPatches: number;
export let projectInfo: ProjectInfo;
export let isDelegate: boolean;
export let isSeeding: boolean;
export let isPrivate: boolean;
export let lastUpdatedTimestamp: number;
$: lastUpdated = formatTimestamp(lastUpdatedTimestamp);
export let id: string;
export let name: string;
$: project = projectInfo.project;
$: baseUrl = projectInfo.baseUrl;
$: isPrivate = project.visibility?.type === "private";
$: lastUpdated = formatTimestamp(
projectInfo.lastCommit.commit.committer.time,
);
</script>
<style>
@@ -138,21 +131,21 @@
<Link
route={{
resource: "project.source",
project: id,
project: project.id,
node: baseUrl,
}}>
<div class="project-card" class:compact>
<div class="activity">
<div class="fadeout-overlay" />
<ActivityDiagram
{id}
id={project.id}
viewBoxHeight={200}
styleColor="var(--color-foreground-primary"
{activity} />
activity={projectInfo.activity} />
</div>
<div class="title">
<div class="headline-and-badges">
<h4 use:twemoji>{name}</h4>
<h4 use:twemoji>{project.name}</h4>
<div class="badges">
{#if isPrivate}
<div
@@ -180,14 +173,14 @@
{/if}
</div>
</div>
<p class="txt-small" use:twemoji>{description}</p>
<p class="txt-small" use:twemoji>{project.description}</p>
</div>
<div class="stats-row txt-tiny" style:color="var(--color-foreground-dim)">
<IconSmall name="issue" />
{numberOfIssues} ·
{project.issues.open} ·
<IconSmall name="patch" />
<span style:overflow="hidden" style:text-overflow="ellipsis">
{numberOfPatches} · Updated {lastUpdated}
{project.patches.open} · Updated {lastUpdated}
</span>
</div>
</div>
+38
View File
@@ -0,0 +1,38 @@
import { loadProjectActivity, type WeeklyActivity } from "@app/lib/commit";
import {
HttpdClient,
type BaseUrl,
type Commit,
type Project,
} from "@httpd-client";
export interface ProjectInfo {
project: Project;
baseUrl: BaseUrl;
activity: WeeklyActivity[];
lastCommit: Commit;
}
export async function fetchProjectInfos(
baseUrl: BaseUrl,
show: "all" | "pinned",
): Promise<ProjectInfo[]> {
const api = new HttpdClient(baseUrl);
const projects = await api.project.getAll({ show });
const info = await Promise.all(
projects.map(async project => {
const [activity, lastCommit] = await Promise.all([
loadProjectActivity(project.id, baseUrl),
api.project.getCommitBySha(project.id, project.head),
]);
return { project, activity, lastCommit, baseUrl };
}),
);
return info.sort((a, b) => {
const aLastCommit = a.lastCommit.commit.committer.time;
const bLastCommit = b.lastCommit.commit.committer.time;
return bLastCommit - aLastCommit;
});
}
-7
View File
@@ -116,10 +116,3 @@ export async function loadProjectActivity(id: string, baseUrl: BaseUrl) {
return groupCommitsByWeek(commits.activity);
}
export async function fetchLastCommit(id: string, baseUrl: BaseUrl) {
const api = new HttpdClient(baseUrl);
const res = await api.project.getAllCommits(id, { perPage: 1 });
return res.commits[0];
}
-36
View File
@@ -2,22 +2,12 @@ import type { BaseUrl, Project } from "@httpd-client";
import { HttpdClient } from "@httpd-client";
import { isFulfilled } from "@app/lib/utils";
import {
fetchLastCommit,
loadProjectActivity,
type WeeklyActivity,
} from "./commit";
export interface ProjectBaseUrl {
project: Project;
baseUrl: BaseUrl;
}
export interface ProjectWithListingData extends ProjectBaseUrl {
activity: WeeklyActivity[];
lastCommit: Awaited<ReturnType<typeof fetchLastCommit>>;
}
export async function getProjectsFromNodes(
params: { id: string; baseUrl: BaseUrl }[],
): Promise<ProjectBaseUrl[]> {
@@ -34,32 +24,6 @@ export async function getProjectsFromNodes(
return results.filter(isFulfilled).map(r => r.value);
}
export async function getProjectListingData(id: string, baseUrl: BaseUrl) {
const activity = await loadProjectActivity(id, baseUrl);
const lastCommit = await fetchLastCommit(id, baseUrl);
return { activity, lastCommit };
}
export async function getProjectsListingData(projects: ProjectBaseUrl[]) {
const result = await Promise.all(
projects.map(async ({ project, baseUrl }) => {
const { activity, lastCommit } = await getProjectListingData(
project.id,
baseUrl,
);
return { project, activity, lastCommit, baseUrl };
}),
);
return result.sort((a, b) => {
const aLastCommit = a.lastCommit?.commit.committer.time ?? 0;
const bLastCommit = b.lastCommit?.commit.committer.time ?? 0;
return bLastCommit - aLastCommit;
});
}
export async function queryProject(
baseUrl: BaseUrl,
projectId: string,
+20 -46
View File
@@ -1,17 +1,13 @@
<script lang="ts">
import type { BaseUrl } from "@httpd-client";
import type { ProjectWithListingData } from "@app/lib/projects";
import type { ComponentProps } from "svelte";
import storedWritable from "@efstajas/svelte-stored-writable";
import { HttpdClient } from "@httpd-client";
import { derived } from "svelte/store";
import { literal, union } from "zod";
import { api, httpdStore } from "@app/lib/httpd";
import { deduplicateStore } from "@app/lib/deduplicateStore";
import { baseUrlToString } from "@app/lib/utils";
import { getProjectsListingData } from "@app/lib/projects";
import { handleError } from "@app/views/home/error";
import { isDelegate } from "@app/lib/roles";
import { preferredSeeds } from "@app/lib/seeds";
@@ -25,6 +21,10 @@
import HomepageSection from "./components/HomepageSection.svelte";
import NewProjectButton from "./components/NewProjectButton.svelte";
import PreferredSeedDropdown from "./components/PreferredSeedDropdown.svelte";
import {
fetchProjectInfos,
type ProjectInfo,
} from "@app/components/ProjectCard";
const selectedSeed = deduplicateStore(
derived(preferredSeeds, $ => $?.selected),
@@ -40,30 +40,17 @@
);
let localProjects:
| ProjectWithListingData[]
| ProjectInfo[]
| ComponentProps<ErrorMessage>["error"]
| undefined;
let preferredSeedProjects:
| ProjectWithListingData[]
| ProjectInfo[]
| ComponentProps<ErrorMessage>["error"]
| undefined;
async function fetchProjects(baseUrl: BaseUrl, show: "all" | "pinned") {
const api = new HttpdClient(baseUrl);
const projects = (await api.project.getAll({ perPage: 30, show })).map(
project => ({
project,
baseUrl,
}),
);
return await getProjectsListingData(projects);
}
async function loadLocalProjects() {
localProjects = undefined;
localProjects = await fetchProjects(api.baseUrl, "all").catch(
localProjects = await fetchProjectInfos(api.baseUrl, "all").catch(
error => error,
);
}
@@ -72,9 +59,10 @@
preferredSeedProjects = undefined;
if (!$selectedSeed) return;
preferredSeedProjects = await fetchProjects($selectedSeed, "pinned").catch(
error => error,
);
preferredSeedProjects = await fetchProjectInfos(
$selectedSeed,
"pinned",
).catch(error => error);
}
function isSeeding(projectId: string) {
@@ -176,19 +164,12 @@
</svelte:fragment>
<div class="project-grid">
{#if filteredLocalProjects && !(filteredLocalProjects instanceof Error)}
{#each filteredLocalProjects as { project, baseUrl, activity, lastCommit }}
{#each filteredLocalProjects as projectInfo}
<ProjectCard
id={project.id}
name={project.name}
description={project.description}
numberOfIssues={project.issues.open}
numberOfPatches={project.patches.open}
isPrivate={project.visibility?.type === "private"}
{projectInfo}
isSeeding={true}
isDelegate={isDelegate(nodeId, project.delegates) ?? false}
lastUpdatedTimestamp={lastCommit.commit.committer.time}
{activity}
{baseUrl} />
isDelegate={isDelegate(nodeId, projectInfo.project.delegates) ??
false} />
{/each}
{/if}
</div>
@@ -228,19 +209,12 @@
</svelte:fragment>
<div class="project-grid">
{#if preferredSeedProjects && !(preferredSeedProjects instanceof Error)}
{#each preferredSeedProjects as { project, baseUrl, activity, lastCommit }}
{#each preferredSeedProjects as projectInfo}
<ProjectCard
id={project.id}
name={project.name}
description={project.description}
numberOfIssues={project.issues.open}
numberOfPatches={project.patches.open}
isPrivate={project.visibility?.type === "private"}
isSeeding={isSeeding(project.id)}
isDelegate={isDelegate(nodeId, project.delegates) ?? false}
lastUpdatedTimestamp={lastCommit.commit.committer.time}
{activity}
{baseUrl} />
{projectInfo}
isSeeding={isSeeding(projectInfo.project.id)}
isDelegate={isDelegate(nodeId, projectInfo.project.delegates) ??
false} />
{/each}
{/if}
</div>
+9 -15
View File
@@ -1,6 +1,5 @@
<script lang="ts">
import type { BaseUrl, Policy, Scope } from "@httpd-client";
import type { ProjectWithListingData } from "@app/lib/projects";
import capitalize from "lodash/capitalize";
@@ -14,11 +13,12 @@
import ProjectCard from "@app/components/ProjectCard.svelte";
import ScopePolicyExplainer from "@app/components/ScopePolicyExplainer.svelte";
import HoverPopover from "@app/components/HoverPopover.svelte";
import type { ProjectInfo } from "@app/components/ProjectCard";
export let baseUrl: BaseUrl;
export let nid: string;
export let externalAddresses: string[];
export let projects: ProjectWithListingData[] = [];
export let projectInfos: ProjectInfo[];
export let version: string;
export let policy: Policy | undefined = undefined;
export let scope: Scope | undefined = undefined;
@@ -135,7 +135,7 @@
<div class="subtitle">
<div class="pinned txt-semibold">
{projects.length}
{projectInfos.length}
{isLocal(baseUrl.hostname) ? "" : "pinned"} projects
</div>
@@ -188,20 +188,14 @@
</div>
<div class="project-grid">
{#each projects as { project, baseUrl, activity, lastCommit }}
{#each projectInfos as projectInfo}
<ProjectCard
id={project.id}
name={project.name}
description={project.description}
numberOfIssues={project.issues.open}
numberOfPatches={project.patches.open}
isPrivate={project.visibility?.type === "private"}
{projectInfo}
isSeeding={false}
isDelegate={isDelegate(session?.publicKey, project.delegates) ??
false}
lastUpdatedTimestamp={lastCommit.commit.committer.time}
{activity}
{baseUrl} />
isDelegate={isDelegate(
session?.publicKey,
projectInfo.project.delegates,
) ?? false} />
{/each}
</div>
</div>
+11 -20
View File
@@ -1,12 +1,14 @@
import type { BaseUrl, Policy, Scope } from "@httpd-client";
import type { ErrorRoute, NotFoundRoute } from "@app/lib/router/definitions";
import type { ProjectWithListingData } from "@app/lib/projects";
import { HttpdClient } from "@httpd-client";
import { config } from "@app/lib/config";
import { baseUrlToString, isLocal } from "@app/lib/utils";
import { getProjectsListingData } from "@app/lib/projects";
import { handleError } from "@app/views/nodes/error";
import {
fetchProjectInfos,
type ProjectInfo,
} from "@app/components/ProjectCard";
export interface NodesRouteParams {
baseUrl: BaseUrl;
@@ -25,26 +27,12 @@ export interface NodesLoadedRoute {
version: string;
externalAddresses: string[];
nid: string;
projects: ProjectWithListingData[];
projectInfos: ProjectInfo[];
policy?: Policy;
scope?: Scope;
};
}
async function loadProjects(
baseUrl: BaseUrl,
): Promise<ProjectWithListingData[]> {
const api = new HttpdClient(baseUrl);
const projects = await api.project.getAll({
show: isLocal(baseUrl.hostname) ? "all" : "pinned",
});
const results = await getProjectsListingData(
projects.map(p => ({ project: p, baseUrl })),
);
return results;
}
export function nodePath(baseUrl: BaseUrl) {
const port = baseUrl.port ?? config.nodes.defaultHttpdPort;
@@ -60,9 +48,12 @@ export async function loadNodeRoute(
): Promise<NodesLoadedRoute | NotFoundRoute | ErrorRoute> {
const api = new HttpdClient(params.baseUrl);
try {
const [node, projects] = await Promise.all([
const [node, projectInfos] = await Promise.all([
api.getNode(),
loadProjects(params.baseUrl),
fetchProjectInfos(
params.baseUrl,
isLocal(params.baseUrl.hostname) ? "all" : "pinned",
),
]);
return {
resource: "nodes",
@@ -71,7 +62,7 @@ export async function loadNodeRoute(
nid: node.id,
externalAddresses: node.config?.externalAddresses ?? [],
version: node.version,
projects: projects,
projectInfos: projectInfos,
policy: node.config?.policy,
scope: node.config?.scope,
},