coundown fixed

This commit is contained in:
Michael Freno
2026-01-06 14:19:39 -05:00
parent 374c924119
commit b981f953d6
5 changed files with 170 additions and 111 deletions

1
.gitignore vendored
View File

@@ -20,7 +20,6 @@ app.config.timestamp_*.js
*.launch *.launch
.settings/ .settings/
tasks tasks
main.go
# Temp # Temp
gitignore gitignore

View File

@@ -1,4 +1,4 @@
import { Component, createSignal, onMount, onCleanup } from "solid-js"; import { Component, createSignal, createEffect, onCleanup } from "solid-js";
interface CountdownCircleTimerProps { interface CountdownCircleTimerProps {
duration: number; duration: number;
@@ -19,33 +19,65 @@ const CountdownCircleTimer: Component<CountdownCircleTimerProps> = (props) => {
props.initialRemainingTime ?? props.duration props.initialRemainingTime ?? props.duration
); );
const progress = () => remainingTime() / props.duration; const progress = () => {
const strokeDashoffset = () => circumference * (1 - progress()); const time = remainingTime();
if (isNaN(time) || !props.duration) return 0;
return Math.max(0, Math.min(1, time / props.duration));
};
onMount(() => { const strokeDashoffset = () => {
const startTime = Date.now(); const prog = progress();
const initialTime = remainingTime(); if (isNaN(prog)) return 0;
let animationFrameId: number; return circumference * (1 - prog);
};
const animate = () => { // If isPlaying is set, manage countdown internally
const elapsed = (Date.now() - startTime) / 1000; createEffect(() => {
const newTime = Math.max(0, initialTime - elapsed); if (props.isPlaying !== undefined && props.isPlaying) {
const startTime = Date.now();
const initialTime = props.initialRemainingTime ?? props.duration;
setRemainingTime(initialTime);
setRemainingTime(newTime); let animationFrameId: number;
if (newTime <= 0) { const animate = () => {
props.onComplete?.(); const elapsed = (Date.now() - startTime) / 1000;
return; const newTime = Math.max(0, initialTime - elapsed);
}
setRemainingTime(newTime);
if (newTime <= 0) {
props.onComplete?.();
return;
}
animationFrameId = requestAnimationFrame(animate);
};
animationFrameId = requestAnimationFrame(animate); animationFrameId = requestAnimationFrame(animate);
};
animationFrameId = requestAnimationFrame(animate); onCleanup(() => {
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
}
});
onCleanup(() => { return;
cancelAnimationFrame(animationFrameId); }
});
// Otherwise, just sync with the prop value - parent controls the countdown
const newTime = props.initialRemainingTime ?? props.duration;
if (isNaN(newTime)) {
setRemainingTime(0);
return;
}
setRemainingTime(newTime);
if (newTime <= 0) {
props.onComplete?.();
}
}); });
return ( return (

78
src/lib/useCountdown.ts Normal file
View File

@@ -0,0 +1,78 @@
import { createSignal, onCleanup } from "solid-js";
export interface UseCountdownOptions {
/**
* Initial remaining time in seconds
*/
initialTime?: number;
/**
* Cookie name to read expiration time from
*/
cookieName?: string;
/**
* Callback when countdown reaches zero
*/
onComplete?: () => void;
}
/**
* Hook for managing countdown timers from expiration timestamps
* @param options Configuration options
* @returns [remainingTime, startCountdown]
*/
export function useCountdown(options: UseCountdownOptions = {}) {
const [remainingTime, setRemainingTime] = createSignal(
options.initialTime ?? 0
);
let intervalId: ReturnType<typeof setInterval> | null = null;
const calculateRemaining = (expiresAt: string | Date) => {
const expires =
typeof expiresAt === "string" ? new Date(expiresAt) : expiresAt;
const remaining = expires.getTime() - Date.now();
const remainingInSeconds = remaining / 1000;
if (remainingInSeconds <= 0) {
setRemainingTime(0);
if (intervalId !== null) {
clearInterval(intervalId);
intervalId = null;
}
options.onComplete?.();
} else {
setRemainingTime(remainingInSeconds);
}
};
const startCountdown = (expiresAt: string | Date) => {
// Clear any existing interval
if (intervalId !== null) {
clearInterval(intervalId);
}
// Calculate immediately
calculateRemaining(expiresAt);
// Then update every second
intervalId = setInterval(() => calculateRemaining(expiresAt), 1000);
};
const stopCountdown = () => {
if (intervalId !== null) {
clearInterval(intervalId);
intervalId = null;
}
};
// Cleanup on unmount
onCleanup(() => {
stopCountdown();
});
return {
remainingTime,
startCountdown,
stopCountdown,
setRemainingTime
};
}

View File

@@ -1,4 +1,4 @@
import { createSignal, onMount, onCleanup, Show } from "solid-js"; import { createSignal, onMount, createEffect, Show } from "solid-js";
import { import {
useSearchParams, useSearchParams,
useNavigate, useNavigate,
@@ -15,6 +15,7 @@ import CountdownCircleTimer from "~/components/CountdownCircleTimer";
import RevealDropDown from "~/components/RevealDropDown"; import RevealDropDown from "~/components/RevealDropDown";
import Input from "~/components/ui/Input"; import Input from "~/components/ui/Input";
import { Button } from "~/components/ui/Button"; import { Button } from "~/components/ui/Button";
import { useCountdown } from "~/lib/useCountdown";
import type { UserProfile } from "~/types/user"; import type { UserProfile } from "~/types/user";
import { getCookie, setCookie } from "vinxi/http"; import { getCookie, setCookie } from "vinxi/http";
import { z } from "zod"; import { z } from "zod";
@@ -154,7 +155,6 @@ export default function ContactPage() {
deferStream: true deferStream: true
}); });
const [countDown, setCountDown] = createSignal<number>(0);
const [emailSent, setEmailSent] = createSignal<boolean>( const [emailSent, setEmailSent] = createSignal<boolean>(
searchParams.success === "true" searchParams.success === "true"
); );
@@ -165,36 +165,11 @@ export default function ContactPage() {
const [user, setUser] = createSignal<UserProfile | null>(null); const [user, setUser] = createSignal<UserProfile | null>(null);
const [jsEnabled, setJsEnabled] = createSignal<boolean>(false); const [jsEnabled, setJsEnabled] = createSignal<boolean>(false);
let timerIdRef: ReturnType<typeof setInterval> | null = null; const { remainingTime, startCountdown, setRemainingTime } = useCountdown();
const calcRemainder = (timer: string) => {
const expires = new Date(timer);
const remaining = expires.getTime() - Date.now();
const remainingInSeconds = remaining / 1000;
if (remainingInSeconds <= 0) {
setCountDown(0);
if (timerIdRef !== null) {
clearInterval(timerIdRef);
}
} else {
setCountDown(remainingInSeconds);
}
};
onMount(() => { onMount(() => {
setJsEnabled(true); setJsEnabled(true);
const serverData = contactData();
if (serverData?.remainingTime) {
setCountDown(serverData.remainingTime);
}
const timer = getClientCookie("contactRequestSent");
if (timer) {
timerIdRef = setInterval(() => calcRemainder(timer), 1000);
}
api.user.getProfile api.user.getProfile
.query() .query()
.then((userData) => { .then((userData) => {
@@ -203,25 +178,28 @@ export default function ContactPage() {
} }
}) })
.catch(() => {}); .catch(() => {});
});
if (searchParams.success || searchParams.error) { createEffect(() => {
const timer = setTimeout(() => { // Try server data first (more accurate)
const newUrl = const serverData = contactData();
location.pathname + if (serverData?.remainingTime && serverData.remainingTime > 0) {
(viewer() !== "default" ? `?viewer=${viewer()}` : ""); const expirationTime = new Date(
navigate(newUrl, { replace: true }); Date.now() + serverData.remainingTime * 1000
setEmailSent(false); );
setError(""); startCountdown(expirationTime);
}, 5000); return;
onCleanup(() => clearTimeout(timer));
} }
onCleanup(() => { // Fall back to client cookie if server data not available yet
if (timerIdRef !== null) { const timer = getClientCookie("contactRequestSent");
clearInterval(timerIdRef); if (timer) {
try {
startCountdown(timer);
} catch (e) {
console.error("Failed to start countdown from cookie:", e);
} }
}); }
}); });
const sendEmailTrigger = async (e: Event) => { const sendEmailTrigger = async (e: Event) => {
@@ -251,13 +229,11 @@ export default function ContactPage() {
setError(""); setError("");
(e.target as HTMLFormElement).reset(); (e.target as HTMLFormElement).reset();
const timer = getClientCookie("contactRequestSent"); // Set countdown directly - cookie might not be readable immediately
if (timer) { const expirationTime = new Date(
if (timerIdRef !== null) { Date.now() + COOLDOWN_TIMERS.CONTACT_REQUEST_MS
clearInterval(timerIdRef); );
} startCountdown(expirationTime);
timerIdRef = setInterval(() => calcRemainder(timer), 1000);
}
} }
} catch (err: any) { } catch (err: any) {
setError(err.message || "An error occurred"); setError(err.message || "An error occurred");
@@ -351,9 +327,10 @@ export default function ContactPage() {
}; };
const renderTime = ({ remainingTime }: { remainingTime: number }) => { const renderTime = ({ remainingTime }: { remainingTime: number }) => {
const time = isNaN(remainingTime) ? 0 : Math.max(0, remainingTime);
return ( return (
<div class="timer"> <div class="timer">
<div class="value">{remainingTime.toFixed(0)}</div> <div class="value">{time.toFixed(0)}</div>
</div> </div>
); );
}; };
@@ -418,7 +395,8 @@ export default function ContactPage() {
<div class="mx-auto flex w-full justify-end pt-4"> <div class="mx-auto flex w-full justify-end pt-4">
<Show <Show
when={ when={
countDown() > 0 || (contactData()?.remainingTime ?? 0) > 0 remainingTime() > 0 ||
(contactData()?.remainingTime ?? 0) > 0
} }
fallback={ fallback={
<Button type="submit" loading={loading()} class="w-36"> <Button type="submit" loading={loading()} class="w-36">
@@ -438,11 +416,11 @@ export default function ContactPage() {
> >
<CountdownCircleTimer <CountdownCircleTimer
duration={COUNTDOWN_CONFIG.CONTACT_FORM_DURATION_S} duration={COUNTDOWN_CONFIG.CONTACT_FORM_DURATION_S}
initialRemainingTime={countDown()} initialRemainingTime={remainingTime()}
size={48} size={48}
strokeWidth={6} strokeWidth={6}
colors={"#60a5fa"} colors={"#60a5fa"}
onComplete={() => setCountDown(0)} onComplete={() => setRemainingTime(0)}
> >
{renderTime} {renderTime}
</CountdownCircleTimer> </CountdownCircleTimer>

View File

@@ -1,4 +1,4 @@
import { createSignal, createEffect, onCleanup, Show } from "solid-js"; import { createSignal, createEffect, Show } from "solid-js";
import { A, useNavigate } from "@solidjs/router"; import { A, useNavigate } from "@solidjs/router";
import { PageHead } from "~/components/PageHead"; import { PageHead } from "~/components/PageHead";
import CountdownCircleTimer from "~/components/CountdownCircleTimer"; import CountdownCircleTimer from "~/components/CountdownCircleTimer";
@@ -8,47 +8,24 @@ import { COUNTDOWN_CONFIG } from "~/config";
import Input from "~/components/ui/Input"; import Input from "~/components/ui/Input";
import { Button } from "~/components/ui/Button"; import { Button } from "~/components/ui/Button";
import FormFeedback from "~/components/ui/FormFeedback"; import FormFeedback from "~/components/ui/FormFeedback";
import { useCountdown } from "~/lib/useCountdown";
export default function RequestPasswordResetPage() { export default function RequestPasswordResetPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [loading, setLoading] = createSignal(false); const [loading, setLoading] = createSignal(false);
const [countDown, setCountDown] = createSignal(0);
const [showSuccessMessage, setShowSuccessMessage] = createSignal(false); const [showSuccessMessage, setShowSuccessMessage] = createSignal(false);
const [error, setError] = createSignal(""); const [error, setError] = createSignal("");
const { remainingTime, startCountdown } = useCountdown();
let emailRef: HTMLInputElement | undefined; let emailRef: HTMLInputElement | undefined;
let timerInterval: number | undefined;
const calcRemainder = (timer: string) => {
const expires = new Date(timer);
const remaining = expires.getTime() - Date.now();
const remainingInSeconds = remaining / 1000;
if (remainingInSeconds <= 0) {
setCountDown(0);
if (timerInterval) {
clearInterval(timerInterval);
}
} else {
setCountDown(remainingInSeconds);
}
};
createEffect(() => { createEffect(() => {
const timer = getClientCookie("passwordResetRequested"); const timer = getClientCookie("passwordResetRequested");
if (timer) { if (timer) {
timerInterval = setInterval( startCountdown(timer);
() => calcRemainder(timer),
1000
) as unknown as number;
} }
onCleanup(() => {
if (timerInterval) {
clearInterval(timerInterval);
}
});
}); });
const requestPasswordResetTrigger = async (e: Event) => { const requestPasswordResetTrigger = async (e: Event) => {
@@ -85,12 +62,7 @@ export default function RequestPasswordResetPage() {
const timer = getClientCookie("passwordResetRequested"); const timer = getClientCookie("passwordResetRequested");
if (timer) { if (timer) {
if (timerInterval) { startCountdown(timer);
clearInterval(timerInterval);
}
timerInterval = setInterval(() => {
calcRemainder(timer);
}, 1000) as unknown as number;
} }
} else { } else {
const errorMsg = result.error?.message || "Failed to send reset email"; const errorMsg = result.error?.message || "Failed to send reset email";
@@ -151,7 +123,7 @@ export default function RequestPasswordResetPage() {
/> />
<Show <Show
when={countDown() > 0} when={remainingTime() > 0}
fallback={ fallback={
<Button type="submit" loading={loading()} class="my-6"> <Button type="submit" loading={loading()} class="my-6">
Request Password Reset Request Password Reset
@@ -162,7 +134,7 @@ export default function RequestPasswordResetPage() {
<CountdownCircleTimer <CountdownCircleTimer
isPlaying={true} isPlaying={true}
duration={COUNTDOWN_CONFIG.PASSWORD_RESET_DURATION_S} duration={COUNTDOWN_CONFIG.PASSWORD_RESET_DURATION_S}
initialRemainingTime={countDown()} initialRemainingTime={remainingTime()}
size={48} size={48}
strokeWidth={6} strokeWidth={6}
colors="#60a5fa" colors="#60a5fa"