refactor: enhance TestAreaComponent with infinite scrolling, sticky date indicator, and improved loading state handling

This commit is contained in:
Mathias Wagner
2025-06-11 19:17:07 +02:00
parent f670ff82fe
commit b0ea7d6eb6
2 changed files with 193 additions and 27 deletions

View File

@@ -1,4 +1,5 @@
import {useContext} from "react";
import {useContext, useEffect, useRef, useState, useCallback} from "react";
import {createPortal} from "react-dom";
import {ConfigContext} from "@/common/contexts/Config";
import {SpeedtestContext} from "@/common/contexts/Speedtests";
import Speedtest from "../Speedtest";
@@ -6,41 +7,162 @@ import {getIconBySpeed} from "@/common/utils/TestUtil";
import "./styles.sass";
import {t} from "i18next";
function TestArea() {
const TestArea = () => {
const config = useContext(ConfigContext)[0];
const [speedtests] = useContext(SpeedtestContext);
const {speedtests, loadMoreTests, loading, hasMore} = useContext(SpeedtestContext);
const [stickyDate, setStickyDate] = useState(null);
const [showStickyDate, setShowStickyDate] = useState(false);
const [initialLoadComplete, setInitialLoadComplete] = useState(false);
const containerRef = useRef();
const lastElementRef = useRef();
useEffect(() => {
if (!loading && !initialLoadComplete) {
setInitialLoadComplete(true);
}
}, [loading, initialLoadComplete]);
useEffect(() => {
if (speedtests.length > 0) {
const initialDate = getDateFromTest(speedtests[0]);
setStickyDate(initialDate);
}
}, [speedtests]);
const getDateFromTest = (test) => {
const date = new Date(Date.parse(test.created));
return date.toLocaleDateString("default", {weekday: 'long', year: 'numeric', month: 'long', day: 'numeric'});
};
const handleScroll = useCallback(() => {
const scrollTop = Math.max(window.scrollY, document.documentElement.scrollTop, document.body.scrollTop);
const shouldShow = scrollTop > 50;
setShowStickyDate(shouldShow);
const windowHeight = window.innerHeight;
const documentHeight = Math.max(document.body.scrollHeight, document.body.offsetHeight,
document.documentElement.clientHeight, document.documentElement.scrollHeight,
document.documentElement.offsetHeight);
const nearBottom = scrollTop + windowHeight >= documentHeight - 500;
const atBottom = scrollTop + windowHeight >= documentHeight - 50;
if ((nearBottom || atBottom) && hasMore && !loading && speedtests.length > 0) {
loadMoreTests();
}
if (shouldShow && speedtests.length > 0) {
const testElements = document.querySelectorAll('.speedtest');
if (testElements.length > 0) {
for (let i = 0; i < testElements.length; i++) {
const element = testElements[i];
const elementRect = element.getBoundingClientRect();
if (elementRect.top <= 200 && elementRect.bottom > 50) {
if (speedtests[i]) {
const newDate = getDateFromTest(speedtests[i]);
setStickyDate(newDate);
}
break;
}
}
}
}
}, [speedtests, stickyDate, hasMore, loading, loadMoreTests]);
useEffect(() => {
let ticking = false;
const throttledScrollHandler = () => {
if (!ticking) {
requestAnimationFrame(() => {
handleScroll();
ticking = false;
});
ticking = true;
}
};
window.addEventListener('scroll', throttledScrollHandler, {passive: true});
document.body.addEventListener('scroll', throttledScrollHandler, {passive: true});
setTimeout(handleScroll, 100);
return () => {
window.removeEventListener('scroll', throttledScrollHandler);
document.body.removeEventListener('scroll', throttledScrollHandler);
};
}, [handleScroll]);
useEffect(() => {
if (!lastElementRef.current || !hasMore || loading) return;
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && hasMore && !loading) loadMoreTests();
}, {threshold: 0.01, rootMargin: '200px 0px'});
observer.observe(lastElementRef.current);
return () => {
observer.disconnect();
};
}, [hasMore, loading, loadMoreTests, speedtests.length]);
if (Object.entries(config).length === 0) return (<></>);
if (speedtests.length === 0) return <h2 className="error-text">{t("test.not_available")}</h2>
if (!initialLoadComplete || (speedtests.length === 0 && loading)) return <></>;
if (speedtests.length === 0 && initialLoadComplete)
return <h2 className="error-text">{t("test.not_available")}</h2>;
return (
<div className="speedtest-area">
{speedtests.map ? speedtests.map(test => {
const dateArray = test.created.split("-").map((value) => parseInt(value));
const date = test.type === "average" ? new Date(dateArray[0], dateArray[1] - 1, dateArray[2])
: new Date(Date.parse(test.created));
<>
{showStickyDate && stickyDate && createPortal(
<div className="floating-date-indicator">
<span>{stickyDate}</span>
</div>, document.body)}
let item = localStorage.getItem("testTime");
if ((item === "3" || item === "4") && test.type !== "average") return;
<div className="speedtest-area" ref={containerRef}>
{speedtests.map((test, index) => {
const date = new Date(Date.parse(test.created));
const isLast = index === speedtests.length - 1;
let id = (test.type === "average") ? date.getDate() + "-" + date.getMonth() : test.id;
return (
<div key={test.id} ref={isLast ? lastElementRef : null}>
<Speedtest
time={date}
ping={test.ping}
pingLevel={getIconBySpeed(test.ping, config.ping, false)}
down={test.download}
downLevel={getIconBySpeed(test.download, config.download, true)}
up={test.upload}
upLevel={getIconBySpeed(test.upload, config.upload, true)}
error={test.error}
url={test.url}
type={test.type}
duration={test.time}
amount={test.amount}
resultId={test.resultId}
id={test.id}
/>
</div>
);
})}
return <Speedtest time={date}
ping={test.ping} pingLevel={getIconBySpeed(test.ping, config.ping, false)}
down={test.download} downLevel={getIconBySpeed(test.download, config.download, true)}
up={test.upload} upLevel={getIconBySpeed(test.upload, config.upload, true)}
error={test.error}
key={id}
url={test.url}
type={test.type}
duration={test.time}
amount={test.amount}
resultId={test.resultId}
id={id}
/>
}) : ""}
</div>
{loading && (
<div className="loading-more">
<p>{t("test.loading_more")}</p>
</div>
)}
{!hasMore && speedtests.length > 0 && (
<div className="end-of-list">
<p>{t("test.no_more_tests")}</p>
</div>
)}
</div>
</>
);
}

View File

@@ -1,5 +1,42 @@
@use "@/common/styles/colors" as *
.floating-date-indicator
position: fixed
user-select: none
top: 30px
display: flex
transform: translateX(-50%)
left: 50%
z-index: 50
background: $dark-gray
backdrop-filter: blur(10px)
border: $light-gray 2px solid
border-radius: 12px
padding: 12px 24px
text-align: center
font-weight: 600
color: $white
box-shadow: 0 0 15px rgba($light-gray, 0.5)
animation: slideInDown 0.3s ease-out
span
font-size: 22px
letter-spacing: 0.5px
@keyframes slideInDown
from
opacity: 0
transform: translateX(-50%) translateY(-20px)
to
opacity: 1
transform: translateX(-50%) translateY(0)
.loading-more, .end-of-list
text-align: center
padding: 20px
color: $subtext
font-style: italic
.error-text
margin: 0
font-size: 26pt
@@ -10,6 +47,13 @@
@media (max-width: 605px)
.error-text
width: 28rem
.floating-date-indicator
top: 15px
padding: 10px 20px
span
font-size: 13px
@media (max-width: 475px)
.error-text