Skip to content
Open
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
165 changes: 165 additions & 0 deletions frontend/cntr/CountdownTimer/CountdownTimer.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import React from "react";

import {
render,
screen,
act,
} from "@testing-library/react";

import {
CountdownTimer,
} from "./CountdownTimer";

describe(
"CountdownTimer",
() => {
beforeEach(() => {
jest.useFakeTimers();

jest.setSystemTime(
new Date(
"2026-01-01T12:00:00Z",
),
);
});

afterEach(() => {
jest.useRealTimers();
});

it(
"renders HH:MM:SS format",
() => {
render(
<CountdownTimer
endsAt={
new Date(
"2026-01-01T13:01:05Z",
)
}
/>,
);

expect(
screen.getByText(
"01:01:05",
),
).toBeInTheDocument();
},
);

it(
"turns red when 5 minutes remain",
() => {
render(
<CountdownTimer
endsAt={
new Date(
"2026-01-01T12:05:00Z",
)
}
/>,
);

expect(
screen.getByTestId(
"countdown-timer",
),
).toHaveClass(
"text-red-600",
);
},
);

it(
"calls onExpire exactly once",
() => {
const onExpire =
jest.fn();

render(
<CountdownTimer
endsAt={
new Date(
"2026-01-01T12:00:02Z",
)
}
onExpire={
onExpire
}
/>,
);

act(() => {
jest.advanceTimersByTime(
5000,
);
});

expect(
onExpire,
).toHaveBeenCalledTimes(
1,
);
},
);

it(
"updates every second",
() => {
render(
<CountdownTimer
endsAt={
new Date(
"2026-01-01T12:00:10Z",
)
}
/>,
);

act(() => {
jest.advanceTimersByTime(
1000,
);
});

expect(
screen.getByText(
"00:00:09",
),
).toBeInTheDocument();
},
);

it(
"clears interval on unmount",
() => {
const clearSpy =
jest.spyOn(
window,
"clearInterval",
);

const {
unmount,
} = render(
<CountdownTimer
endsAt={
new Date(
"2026-01-01T12:00:10Z",
)
}
/>,
);

unmount();

expect(
clearSpy,
).toHaveBeenCalled();

clearSpy.mockRestore();
},
);
},
);
151 changes: 151 additions & 0 deletions frontend/cntr/CountdownTimer/CountdownTimer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import React, {
useEffect,
useMemo,
useRef,
useState,
} from "react";

export interface CountdownTimerProps {
endsAt: Date | string;
onExpire?: () => void;
}

const WARNING_THRESHOLD_SECONDS = 300;

function getRemainingSeconds(
endsAt: Date | string,
): number {
const endTime =
new Date(endsAt).getTime();

const now = Date.now();

return Math.max(
0,
Math.floor(
(endTime - now) / 1000,
),
);
}

function formatTime(
totalSeconds: number,
): string {
const hours = Math.floor(
totalSeconds / 3600,
);

const minutes = Math.floor(
(totalSeconds % 3600) / 60,
);

const seconds =
totalSeconds % 60;

return [
hours,
minutes,
seconds,
]
.map((value) =>
String(value).padStart(
2,
"0",
),
)
.join(":");
}

export const CountdownTimer =
({
endsAt,
onExpire,
}: CountdownTimerProps) => {
const [
remainingSeconds,
setRemainingSeconds,
] = useState(() =>
getRemainingSeconds(
endsAt,
),
);

const hasExpiredRef =
useRef(false);

useEffect(() => {
setRemainingSeconds(
getRemainingSeconds(
endsAt,
),
);

hasExpiredRef.current =
false;
}, [endsAt]);

useEffect(() => {
const tick = () => {
const remaining =
getRemainingSeconds(
endsAt,
);

setRemainingSeconds(
remaining,
);

if (
remaining === 0 &&
!hasExpiredRef.current
) {
hasExpiredRef.current =
true;

onExpire?.();
}
};

tick();

const intervalId =
window.setInterval(
tick,
1000,
);

return () => {
window.clearInterval(
intervalId,
);
};
}, [endsAt, onExpire]);

const formattedTime =
useMemo(
() =>
formatTime(
remainingSeconds,
),
[remainingSeconds],
);

const isWarning =
remainingSeconds <=
WARNING_THRESHOLD_SECONDS;

return (
<span
data-testid="countdown-timer"
className={
isWarning
? "text-red-600 font-semibold"
: ""
}
>
{formattedTime}
</span>
);
};

export default CountdownTimer;
4 changes: 4 additions & 0 deletions frontend/cntr/CountdownTimer/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export * from "./CountdownTimer";
export {
default,
} from "./CountdownTimer";
Loading
Loading