Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve performance of participant table for large number of participants #48

Merged
merged 18 commits into from
Aug 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,23 @@
import './App.css';
import {useEffect, lazy, Suspense} from 'react';
import {createBrowserRouter, RouterProvider, Params, Outlet, useLocation} from 'react-router-dom';
import {
createBrowserRouter,
RouterProvider,
Params,
Outlet,
useLocation,
ScrollRestoration,
} from 'react-router-dom';
import BottomNav from './Components/BottomNav';
import Modal from './Components/Tailwind/Modal/Modal';
import db, {
getEvent,
getEvents,
getParticipant,
getParticipants,
getRegform,
getRegforms,
getServers,
countParticipants,
} from './db/db';
import useSettings from './hooks/useSettings';
import AuthRedirectPage from './pages/Auth/AuthRedirectPage';
Expand Down Expand Up @@ -50,6 +57,7 @@ function RootPage() {

return (
<>
<ScrollRestoration />
<Outlet />
{bottomNavVisible && <BottomNav />}
</>
Expand Down Expand Up @@ -89,8 +97,8 @@ const router = createBrowserRouter([
const {id: eventId, regformId} = getNumericParams(params);
const event = await getEvent(eventId);
const regform = await getRegform({id: regformId, eventId});
const participants = await getParticipants(regformId);
return {event, regform, participants, params: {eventId, regformId}};
const participantCount = await countParticipants(regformId);
return {event, regform, participantCount, params: {eventId, regformId}};
},
},
{
Expand Down
2 changes: 1 addition & 1 deletion src/Components/GrowingTextArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export default function GrowingTextArea({
return (
<div data-value={value} className={styles.sizer}>
<textarea
placeholder="Add notes.."
placeholder="Add notes..."
rows={1}
value={value}
onInput={onChange}
Expand Down
51 changes: 51 additions & 0 deletions src/Components/Tailwind/Table.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
.animated {
// Should be in sync with `ROW_HEIGHT_PX` in Table.tsx
--row-height-px: 56px;
}

:global(html.dark) .animated {
background: repeating-linear-gradient(
90deg,
rgba(18, 24, 33, 0.5),
rgba(55, 65, 81, 0.5),
rgba(18, 24, 33, 0.5),
rgba(55, 65, 81, 0.5),
rgba(18, 24, 33, 0.5)
),
repeating-linear-gradient(
rgb(31, 41, 55) 0 var(--row-height-px),
rgb(55, 65, 81) 0 calc(2 * var(--row-height-px))
);

background-repeat: repeat-x;
background-size: 400% 100%;
animation: TableLoading 1.5s linear infinite;
}

:global(html:not(.dark)) .animated {
background: repeating-linear-gradient(
90deg,
rgba(200, 204, 213, 0.5),
rgba(243, 244, 246, 0.5),
rgba(200, 204, 213, 0.5),
rgba(243, 244, 246, 0.5),
rgba(200, 204, 213, 0.5)
),
repeating-linear-gradient(
rgb(229, 231, 235) 0 var(--row-height-px),
rgb(243, 244, 246) 0 calc(2 * var(--row-height-px))
);

background-repeat: repeat-x;
background-size: 400% 100%;
animation: TableLoading 1.5s linear infinite;
}

@keyframes TableLoading {
0% {
background-position: 100% 50%;
}
100% {
background-position: -33% 50%;
}
}
179 changes: 147 additions & 32 deletions src/Components/Tailwind/Table.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {ChangeEvent, useState, useMemo, useRef} from 'react';
import {ChangeEvent, useState, useMemo, useRef, useEffect, forwardRef} from 'react';
import {
ArrowSmallLeftIcon,
BanknotesIcon,
Expand All @@ -19,50 +19,34 @@ import {
makeDefaultFilterState,
} from './filters';
import Typography from './Typography';
import styles from './Table.module.scss';

const ROW_HEIGHT_PX = 56;

export interface SearchData {
searchValue: string;
filters: Filters;
}

export default function Table({
participants,
export function TableFilters({
searchData,
setSearchData,
onRowClick,
resultCount,
}: {
participants: Participant[];
searchData: SearchData;
setSearchData: (data: SearchData) => void;
onRowClick: (p: Participant) => void;
resultCount: number;
}) {
const [filtersVisible, setFiltersVisible] = useState(false);
const {filters, searchValue} = searchData;
const filtersActive = searchValue !== '' || !isDefaultFilterState(filters);

const setFilters = (f: Filters) => setSearchData({...searchData, filters: f});
const setSearchValue = (v: string) => setSearchData({...searchData, searchValue: v});
const resetSearchData = () => setSearchData({searchValue: '', filters: makeDefaultFilterState()});

const filteredParticipants = useMemo(
() => filterParticipants(participants, searchData),
[participants, searchData]
);

const rows = filteredParticipants.map((p, i) => (
<Row
key={p.id}
fullName={p.fullName}
checkedIn={p.checkedIn}
state={p.state}
isEven={i % 2 === 0}
onClick={() => onRowClick(p)}
/>
));

const filtersActive = searchValue !== '' || !isDefaultFilterState(filters);

return (
<div>
<>
<div className="flex gap-2 px-4 pb-2 pt-4">
<SearchInput searchValue={searchValue} setSearchValue={setSearchValue} />
<ToggleFiltersButton
Expand All @@ -82,9 +66,91 @@ export default function Table({
)}
{filtersActive && (
<div className="mb-4 mt-2">
<ResultCount count={rows.length} onClick={resetSearchData} />
<ResultCount count={resultCount} onClick={resetSearchData} />
</div>
)}
</>
);
}

export default function Table({
participants,
searchData,
setSearchData: _setSearchData,
onRowClick,
}: {
participants: Participant[];
searchData: SearchData;
setSearchData: (data: SearchData) => void;
onRowClick: (p: Participant) => void;
}) {
const defaultVisibleParticipants = getNumberVisibleParticipants();
const [numberVisibleParticipants, setNumberVisibleParticipants] = useState(
defaultVisibleParticipants
);
const dummyRowRef = useRef<HTMLTableRowElement>(null);

const setSearchData = (data: SearchData) => {
_setSearchData(data);
setNumberVisibleParticipants(defaultVisibleParticipants);
};

const filteredParticipants = useMemo(
() => filterParticipants(participants, searchData),
[participants, searchData]
);
const dummyRowHeight =
Math.max(filteredParticipants.length - numberVisibleParticipants, 0) * ROW_HEIGHT_PX;

const rows = filteredParticipants
.slice(0, numberVisibleParticipants)
.map((p, i) => (
<Row
key={p.id}
fullName={p.fullName}
checkedIn={p.checkedIn}
state={p.state}
isEven={i % 2 === 0}
onClick={() => onRowClick(p)}
/>
));

/**
* Check if the dummy row is within 200vh of the top of the screen
*/
function shouldLoadMore(): boolean {
if (!dummyRowRef.current) {
return false;
}
const top = dummyRowRef.current.getBoundingClientRect().top;
return top < 2 * window.innerHeight;
}

function getNumberVisibleParticipants() {
const scroll = document.documentElement.scrollTop;
// Load 5 additional screen heights worth of participants
const padded = scroll + 5 * window.innerHeight;
return Math.ceil(padded / ROW_HEIGHT_PX);
}

useEffect(() => {
function onScroll() {
if (shouldLoadMore()) {
setNumberVisibleParticipants(getNumberVisibleParticipants());
}
}

window.addEventListener('scroll', onScroll);
return () => window.removeEventListener('scroll', onScroll);
}, [filteredParticipants.length]);

return (
<div>
<TableFilters
searchData={searchData}
setSearchData={setSearchData}
resultCount={filteredParticipants.length}
/>
<div className="mx-4 mt-2">
{rows.length === 0 && (
<div className="mt-10 flex flex-col items-center justify-center rounded-xl">
Expand All @@ -94,7 +160,10 @@ export default function Table({
</div>
)}
<table className="w-full overflow-hidden rounded-xl text-left text-sm text-gray-500 dark:text-gray-400">
<tbody>{rows}</tbody>
<tbody>
{rows}
<DummyRow height={dummyRowHeight} ref={dummyRowRef} />
</tbody>
</table>
</div>
</div>
Expand All @@ -114,6 +183,21 @@ function compareDefault(a: any, b: any): number {
return 0;
}
}
/**
* Dummy row to artificially increase the height of the participant table.
* This keeps the table height constant as more participants become visible
* while scrolling down and keeps the scroll bar from jumping around.
*/
const DummyRow = forwardRef(function DummyRow(
{height}: {height: number},
ref: React.Ref<HTMLTableRowElement>
) {
return (
<tr className="block bg-gray-200 dark:bg-gray-800" style={{height}} ref={ref}>
<td></td>
</tr>
);
});

function filterParticipants(participants: Participant[], data: SearchData) {
const {searchValue, filters} = data;
Expand Down Expand Up @@ -164,21 +248,23 @@ function Row({fullName, checkedIn, state, onClick, isEven}: RowProps) {
? 'bg-gray-200 dark:bg-gray-800 active:bg-gray-300 dark:active:bg-gray-600'
: 'bg-gray-100 dark:bg-gray-700 active:bg-gray-300 dark:active:bg-gray-600';

const fullNameClass = 'select-none overflow-x-hidden text-ellipsis whitespace-nowrap';

return (
<tr
style={{WebkitTapHighlightColor: 'transparent'}}
className={`${background} cursor-pointer select-none active:bg-gray-300
active:transition-all dark:active:bg-gray-600`}
className={`${background} max-h-[${ROW_HEIGHT_PX}px] cursor-pointer select-none
active:bg-gray-300 active:transition-all dark:active:bg-gray-600`}
onClick={onClick}
>
<td className="p-4">
<td className="max-w-0 p-4">
<div className="flex items-center justify-between">
<Typography
variant="body1"
className={
state === 'rejected' || state === 'withdrawn'
? 'select-none line-through'
: 'select-none'
? `${fullNameClass} line-through`
: fullNameClass
}
>
{fullName}
Expand Down Expand Up @@ -270,3 +356,32 @@ function ClearSearchButton({onClick}: {onClick: () => void}) {
</div>
);
}

export function TableSkeleton({
searchData,
setSearchData,
participantCount,
}: {
participantCount: number;
searchData: SearchData;
setSearchData: (data: SearchData) => void;
}) {
const rowsPerScreen = Math.ceil(window.innerHeight / ROW_HEIGHT_PX);
const minRows = 2 * rowsPerScreen;
const height = (participantCount > 0 ? participantCount : minRows) * ROW_HEIGHT_PX;

return (
<div>
<TableFilters searchData={searchData} setSearchData={setSearchData} resultCount={0} />
<div className="mx-4 mt-2">
<table className="w-full overflow-hidden rounded-xl text-left text-sm text-gray-500 dark:text-gray-400">
<tbody>
<tr className={`bg-gray-200 dark:bg-gray-800 ${styles.animated}`} style={{height}}>
<td></td>
</tr>
</tbody>
</table>
</div>
</div>
);
}
8 changes: 6 additions & 2 deletions src/db/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,10 @@ export async function getParticipants(regformId: number) {
return await db.participants.where({regformId, deleted: 0}).toArray();
}

export async function countParticipants(regformId: number) {
return await db.participants.where({regformId, deleted: 0}).count();
}

export function useLiveServers(defaultValue?: Server[]) {
return useLiveQuery(getServers, [], defaultValue || []);
}
Expand Down Expand Up @@ -214,8 +218,8 @@ export function useLiveParticipant(id: GetParticipant, defaultValue?: Participan
return useLiveQuery(() => getParticipant(id), deps, defaultValue);
}

export function useLiveParticipants(regformId: number, defaultValue?: Participant[]) {
return useLiveQuery(() => getParticipants(regformId), [regformId], defaultValue || []);
export function useLiveParticipants(regformId: number) {
return useLiveQuery(() => getParticipants(regformId), [regformId]);
}

export async function addServer(data: AddServer): Promise<number> {
Expand Down
Loading
Loading