diff --git a/client/src/locales/de-DE.ftl b/client/src/locales/de-DE.ftl
index 168e984..df8a2f6 100644
--- a/client/src/locales/de-DE.ftl
+++ b/client/src/locales/de-DE.ftl
@@ -52,11 +52,13 @@ times-start-time-label = Startzeit
times-end-time-label = Endzeit
times-delete = Löschen
times-calendar-week = KW { $week }
+times-loading-more = Lade mehr…
# Projects
projects-title = Projekte
projects-create = Projekt erstellen
projects-loading = Projekte werden geladen …
+projects-loading-more = Lade mehr…
projects-edit = Bearbeiten
projects-delete = Löschen
@@ -64,6 +66,7 @@ projects-delete = Löschen
organizations-title = Organisationen
organizations-create = Organisation erstellen
organizations-loading = Organisationen werden geladen …
+organizations-loading-more = Lade mehr…
organizations-open = Öffnen
organizations-edit = Bearbeiten
organizations-delete = Löschen
diff --git a/client/src/locales/en-US.ftl b/client/src/locales/en-US.ftl
index 3645b28..158ff4b 100644
--- a/client/src/locales/en-US.ftl
+++ b/client/src/locales/en-US.ftl
@@ -52,11 +52,13 @@ times-start-time-label = Start time
times-end-time-label = End time
times-delete = Delete
times-calendar-week = CW { $week }
+times-loading-more = Loading more...
# Projects
projects-title = Projects
projects-create = Create project
projects-loading = Loading projects...
+projects-loading-more = Loading more...
projects-edit = Edit
projects-delete = Delete
@@ -64,6 +66,7 @@ projects-delete = Delete
organizations-title = Organizations
organizations-create = Create organization
organizations-loading = Loading organizations...
+organizations-loading-more = Loading more...
organizations-open = Open
organizations-edit = Edit
organizations-delete = Delete
diff --git a/client/src/organization.ts b/client/src/organization.ts
index 4e7631c..ce0e43e 100644
--- a/client/src/organization.ts
+++ b/client/src/organization.ts
@@ -1,4 +1,4 @@
-import { queryOptions } from "@tanstack/solid-query";
+import { infiniteQueryOptions, queryOptions } from "@tanstack/solid-query";
import { QUERY_BASE, type UserId } from "@/user";
@@ -77,6 +77,30 @@ export const isOrganization = (
organization: Organization | OptimisticOrganization,
): organization is Organization => "id" in organization;
+const ORG_PAGE_SIZE = 20;
+
+export const pagedQuery = (userId: UserId | undefined) =>
+ infiniteQueryOptions({
+ queryKey: [QUERY_BASE, userId, "organizations", "paged"],
+ async queryFn({ pageParam, signal }): Promise {
+ const response = await fetch(
+ `/api/users/${userId}/organizations?page=${pageParam}&size=${ORG_PAGE_SIZE}`,
+ { method: "GET", signal },
+ );
+
+ if (!response.ok)
+ throw new Error(
+ `Error fetching organizations: ${response.status} ${await response.text()}`,
+ );
+
+ return (await response.json()) as Organization[];
+ },
+ initialPageParam: 0,
+ getNextPageParam: (lastPage, allPages) =>
+ lastPage.length === ORG_PAGE_SIZE ? allPages.length : undefined,
+ enabled: Boolean(userId),
+ });
+
export async function addMember(organizationId: Id, username: string) {
const response = await fetch(`/api/organizations/${organizationId}/members/registrations`, {
method: "POST",
diff --git a/client/src/project.ts b/client/src/project.ts
index 40c5fcf..434178d 100644
--- a/client/src/project.ts
+++ b/client/src/project.ts
@@ -1,4 +1,4 @@
-import { queryOptions, useQueryClient } from "@tanstack/solid-query";
+import { infiniteQueryOptions, queryOptions, useQueryClient } from "@tanstack/solid-query";
import { createResource } from "solid-js";
import { type Organization, type Id as OrganizationId } from "./organization";
@@ -68,6 +68,28 @@ export async function updateProject({ userId, id, ...parameters }: UpdateProject
export const isProject = (project: Project | OptimisticProject): project is Project =>
"id" in project;
+const PROJECT_PAGE_SIZE = 20;
+
+export const pagedQuery = (userId: string | undefined) =>
+ infiniteQueryOptions({
+ queryKey: [QUERY_BASE, userId, "projects", "paged"],
+ async queryFn({ pageParam, signal }): Promise {
+ const response = await fetch(
+ `/api/users/${userId}/projects?page=${pageParam}&size=${PROJECT_PAGE_SIZE}`,
+ { method: "GET", signal },
+ );
+
+ if (!response.ok)
+ throw new Error(`Error fetching projects: ${response.status} ${await response.text()}`);
+
+ return (await response.json()) as Project[];
+ },
+ initialPageParam: 0,
+ getNextPageParam: (lastPage, allPages) =>
+ lastPage.length === PROJECT_PAGE_SIZE ? allPages.length : undefined,
+ enabled: Boolean(userId),
+ });
+
export async function deleteProject(id: Id) {
const response = await fetch(`/api/projects/${id}`, {
method: "DELETE",
diff --git a/client/src/routes/_app/organizations/index.tsx b/client/src/routes/_app/organizations/index.tsx
index ee030bc..163758d 100644
--- a/client/src/routes/_app/organizations/index.tsx
+++ b/client/src/routes/_app/organizations/index.tsx
@@ -1,6 +1,6 @@
-import { useMutation, useQuery, useQueryClient } from "@tanstack/solid-query";
+import { useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/solid-query";
import { createFileRoute, Link } from "@tanstack/solid-router";
-import { For, Show } from "solid-js";
+import { For, onCleanup, onMount, Show } from "solid-js";
import { FloatingActionButton } from "../../../FloatingActionButton";
import Icon from "../../../Icon";
@@ -8,7 +8,7 @@ import { useI18n } from "../../../i18n";
import {
deleteOrganization,
isOrganization,
- query,
+ pagedQuery,
type Id as OrganizationId,
} from "../../../organization";
import { Title } from "../../../Title";
@@ -16,11 +16,10 @@ import { idQuery } from "../../../user";
export const Route = createFileRoute("/_app/organizations/")({
component: Organizations,
- async loader({ context: { queryClient }, abortController }) {
+ async loader({ context: { queryClient } }) {
const userId = await queryClient.ensureQueryData(idQuery);
- const organizations = await queryClient.ensureQueryData(query(userId, abortController.signal));
-
- return { organizations, userId };
+ await queryClient.prefetchInfiniteQuery({ ...pagedQuery(userId), pages: 1 });
+ return { userId };
},
});
@@ -29,13 +28,13 @@ function Organizations() {
const userId = () => routeData().userId;
const { t } = useI18n();
- const options = () => query(userId());
- const organizationsQuery = useQuery(options);
+ const organizationsQuery = useInfiniteQuery(() => pagedQuery(userId()));
+ const allOrganizations = () => organizationsQuery.data?.pages.flat() ?? [];
const queryClient = useQueryClient();
const deleteMutation = useMutation(() => {
const currentUserId = userId();
- const queryKey = ["user", currentUserId, "organizations"];
+ const queryKey = pagedQuery(currentUserId).queryKey;
return {
mutationFn: deleteOrganization,
@@ -48,8 +47,25 @@ function Organizations() {
const isDeleting = (id: OrganizationId) =>
deleteMutation.isPending && deleteMutation.variables === id;
- const isLastOrganization = () =>
- organizationsQuery.status === "success" && organizationsQuery.data?.length === 1;
+ const isLastOrganization = () => allOrganizations().length === 1;
+
+ let sentinel!: HTMLDivElement;
+ onMount(() => {
+ const observer = new IntersectionObserver(
+ ([entry]) => {
+ if (
+ entry.isIntersecting &&
+ organizationsQuery.hasNextPage &&
+ !organizationsQuery.isFetchingNextPage
+ ) {
+ void organizationsQuery.fetchNextPage();
+ }
+ },
+ { rootMargin: "200px" },
+ );
+ observer.observe(sentinel);
+ onCleanup(() => observer.disconnect());
+ });
return (
<>
@@ -61,7 +77,7 @@ function Organizations() {
/>
- {t("organizations-loading")}
}>
+ {t("organizations-loading")}}>
{(organization) => {
return (
@@ -109,6 +125,10 @@ function Organizations() {
}}
+
+
+ {t("organizations-loading-more")}
+
>
);
diff --git a/client/src/routes/_app/projects.tsx b/client/src/routes/_app/projects.tsx
index fcb01e6..136c6c4 100644
--- a/client/src/routes/_app/projects.tsx
+++ b/client/src/routes/_app/projects.tsx
@@ -1,21 +1,20 @@
-import { useMutation, useQuery, useQueryClient } from "@tanstack/solid-query";
+import { useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/solid-query";
import { createFileRoute, Link } from "@tanstack/solid-router";
-import { For, Show } from "solid-js";
+import { For, onCleanup, onMount, Show } from "solid-js";
import { FloatingActionButton } from "../../FloatingActionButton";
import Icon from "../../Icon";
import { useI18n } from "../../i18n";
-import { deleteProject, isProject, query, type Id as ProjectId } from "../../project";
+import { deleteProject, isProject, pagedQuery, type Id as ProjectId } from "../../project";
import { Title } from "../../Title";
import { idQuery } from "../../user";
export const Route = createFileRoute("/_app/projects")({
component: Projects,
- async loader({ context: { queryClient }, abortController }) {
+ async loader({ context: { queryClient } }) {
const userId = await queryClient.ensureQueryData(idQuery);
- const projects = await queryClient.ensureQueryData(query(userId, abortController.signal));
-
- return { projects, userId };
+ await queryClient.prefetchInfiniteQuery({ ...pagedQuery(userId), pages: 1 });
+ return { userId };
},
});
@@ -24,13 +23,14 @@ function Projects() {
const userId = () => routeData().userId;
const { t } = useI18n();
- const options = () => query(userId());
- const projectsQuery = useQuery(options);
+ const projectsQuery = useInfiniteQuery(() => pagedQuery(userId()));
+ const allProjects = () => projectsQuery.data?.pages.flat() ?? [];
+
const queryClient = useQueryClient();
const deleteMutation = useMutation(() => {
const currentUserId = userId();
- const queryKey = ["user", currentUserId, "projects"];
+ const queryKey = pagedQuery(currentUserId).queryKey;
return {
mutationFn: deleteProject,
@@ -42,8 +42,25 @@ function Projects() {
const isDeleting = (id: ProjectId) => deleteMutation.isPending && deleteMutation.variables === id;
- const isLastProject = () =>
- projectsQuery.status === "success" && projectsQuery.data?.length === 1;
+ const isLastProject = () => allProjects().length === 1;
+
+ let sentinel!: HTMLDivElement;
+ onMount(() => {
+ const observer = new IntersectionObserver(
+ ([entry]) => {
+ if (
+ entry.isIntersecting &&
+ projectsQuery.hasNextPage &&
+ !projectsQuery.isFetchingNextPage
+ ) {
+ void projectsQuery.fetchNextPage();
+ }
+ },
+ { rootMargin: "200px" },
+ );
+ observer.observe(sentinel);
+ onCleanup(() => observer.disconnect());
+ });
return (
<>
@@ -51,7 +68,7 @@ function Projects() {
- {t("projects-loading")}}>
+ {t("projects-loading")}}>
{(project) => {
return (
@@ -89,8 +106,11 @@ function Projects() {
}}
+
+
+ {t("projects-loading-more")}
+
>
);
}
-
diff --git a/client/src/routes/_app/times.tsx b/client/src/routes/_app/times.tsx
index f0b9104..24cb63e 100644
--- a/client/src/routes/_app/times.tsx
+++ b/client/src/routes/_app/times.tsx
@@ -1,6 +1,6 @@
-import { useMutation, useQuery } from "@tanstack/solid-query";
+import { useInfiniteQuery, useMutation } from "@tanstack/solid-query";
import { createFileRoute } from "@tanstack/solid-router";
-import { createMemo, createSignal, For, type VoidProps } from "solid-js";
+import { createMemo, createSignal, For, onCleanup, onMount, Show, type VoidProps } from "solid-js";
import { FloatingActionButton } from "@/FloatingActionButton";
import { useI18n } from "@/i18n";
@@ -16,27 +16,8 @@ export const Route = createFileRoute("/_app/times")({
component: Times,
async loader({ context: { queryClient } }) {
const userId = await queryClient.ensureQueryData(idQuery);
-
- const response = await fetch(`/api/users/${userId}/times`);
- const times: Time[] = await response.json();
- for (const time of times) {
- time.start = Temporal.Instant.from(time.start);
- time.end = Temporal.Instant.from(time.end);
- }
-
- const timesByDate = Map.groupBy(times, (time) => {
- return time.start.toZonedDateTimeISO(timeZone).toPlainDate().toString();
- });
-
- return {
- userId,
- times: timesByDate
- .entries()
- .map(([day, times]) => {
- return [Temporal.PlainDate.from(day), times] as const;
- })
- .toArray(),
- };
+ await queryClient.prefetchInfiniteQuery({ ...query(userId), pages: 1 });
+ return { userId };
},
});
@@ -367,11 +348,14 @@ function Times() {
const routeData = Route.useLoaderData();
const { t } = useI18n();
- const timesQuery = useQuery(() => query(routeData().userId));
+ const timesQuery = useInfiniteQuery(() => query(routeData().userId));
+
const weeks = () => {
if (timesQuery.status !== "success") return;
- const timesByDate = Map.groupBy(timesQuery.data, (time) =>
+ const allTimes = timesQuery.data.pages.flat();
+
+ const timesByDate = Map.groupBy(allTimes, (time) =>
time.start.toZonedDateTimeISO(timeZone).toPlainDate().toString(),
);
@@ -392,6 +376,20 @@ function Times() {
.sort((left, right) => right.key.localeCompare(left.key));
};
+ let sentinel!: HTMLDivElement;
+ onMount(() => {
+ const observer = new IntersectionObserver(
+ ([entry]) => {
+ if (entry.isIntersecting && timesQuery.hasNextPage && !timesQuery.isFetchingNextPage) {
+ void timesQuery.fetchNextPage();
+ }
+ },
+ { rootMargin: "200px" },
+ );
+ observer.observe(sentinel);
+ onCleanup(() => observer.disconnect());
+ });
+
return (
<>
@@ -402,6 +400,10 @@ function Times() {
)}
+
+
+ {t("times-loading-more")}
+
>
);
diff --git a/client/src/time.ts b/client/src/time.ts
index 79a0c84..859db5b 100644
--- a/client/src/time.ts
+++ b/client/src/time.ts
@@ -1,4 +1,4 @@
-import { queryOptions } from "@tanstack/solid-query";
+import { infiniteQueryOptions } from "@tanstack/solid-query";
import type { UserId } from "./user";
@@ -10,14 +10,16 @@ export type Time = {
end: Temporal.Instant;
};
-export const query = (userId: string | undefined, signal?: AbortSignal) =>
- queryOptions({
+const PAGE_SIZE = 30;
+
+export const query = (userId: string | undefined) =>
+ infiniteQueryOptions({
queryKey: ["users", userId, "times"],
- async queryFn(): Promise