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
.settings/
tasks
main.go
# Temp
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 {
duration: number;
@@ -19,33 +19,65 @@ const CountdownCircleTimer: Component<CountdownCircleTimerProps> = (props) => {
props.initialRemainingTime ?? props.duration
);
const progress = () => remainingTime() / props.duration;
const strokeDashoffset = () => circumference * (1 - progress());
const progress = () => {
const time = remainingTime();
if (isNaN(time) || !props.duration) return 0;
return Math.max(0, Math.min(1, time / props.duration));
};
onMount(() => {
const startTime = Date.now();
const initialTime = remainingTime();
let animationFrameId: number;
const strokeDashoffset = () => {
const prog = progress();
if (isNaN(prog)) return 0;
return circumference * (1 - prog);
};
const animate = () => {
const elapsed = (Date.now() - startTime) / 1000;
const newTime = Math.max(0, initialTime - elapsed);
// If isPlaying is set, manage countdown internally
createEffect(() => {
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) {
props.onComplete?.();
return;
}
const animate = () => {
const elapsed = (Date.now() - startTime) / 1000;
const newTime = Math.max(0, initialTime - elapsed);
setRemainingTime(newTime);
if (newTime <= 0) {
props.onComplete?.();
return;
}
animationFrameId = requestAnimationFrame(animate);
};
animationFrameId = requestAnimationFrame(animate);
};
animationFrameId = requestAnimationFrame(animate);
onCleanup(() => {
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
}
});
onCleanup(() => {
cancelAnimationFrame(animationFrameId);
});
return;
}
// 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 (

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