diff --git a/app/components/RelativeTime.tsx b/app/components/RelativeTime.tsx new file mode 100644 index 000000000..d8cd4d45e --- /dev/null +++ b/app/components/RelativeTime.tsx @@ -0,0 +1,30 @@ +import type * as React from "react"; +import { useIsMounted } from "~/hooks/useIsMounted"; + +export function RelativeTime({ + children, + timestamp, +}: { + children: React.ReactNode; + timestamp: number; +}) { + const isMounted = useIsMounted(); + + return ( + + {children} + + ); +} diff --git a/app/db/models/plusVotes.server.ts b/app/db/models/plusVotes.server.ts index 06cda5b68..1a83e36b8 100644 --- a/app/db/models/plusVotes.server.ts +++ b/app/db/models/plusVotes.server.ts @@ -1,6 +1,6 @@ import shuffle from "just-shuffle"; import invariant from "tiny-invariant"; -import type { MonthYear} from "~/modules/plus-server"; +import type { MonthYear } from "~/modules/plus-server"; import { upcomingVoting } from "~/modules/plus-server"; import { dateToDatabaseTimestamp } from "~/utils/dates"; import type { Unpacked } from "~/utils/types"; diff --git a/app/hooks/useIsMounted.ts b/app/hooks/useIsMounted.ts new file mode 100644 index 000000000..6996dc622 --- /dev/null +++ b/app/hooks/useIsMounted.ts @@ -0,0 +1,11 @@ +import * as React from "react"; + +export function useIsMounted() { + const [isMounted, setIsMounted] = React.useState(false); + + React.useEffect(() => { + setIsMounted(true); + }, []); + + return isMounted; +} diff --git a/app/permissions.ts b/app/permissions.ts index 7a4d4203b..4af91ea03 100644 --- a/app/permissions.ts +++ b/app/permissions.ts @@ -164,7 +164,7 @@ export function canSuggestNewUserBE({ ]); } -function isVotingActive() { +export function isVotingActive() { const now = new Date(); const { endDate, startDate } = monthsVotingRange({ month: now.getMonth(), diff --git a/app/routes/plus/voting/index.tsx b/app/routes/plus/voting/index.tsx index 860e66a16..98d5553ae 100644 --- a/app/routes/plus/voting/index.tsx +++ b/app/routes/plus/voting/index.tsx @@ -1,36 +1,76 @@ import type { LoaderFunction } from "@remix-run/node"; import { json } from "@remix-run/node"; import { useLoaderData } from "@remix-run/react"; +import { formatDistance } from "date-fns"; +import { RelativeTime } from "~/components/RelativeTime"; import { db } from "~/db"; import type { UsersForVoting } from "~/db/models/plusVotes.server"; import { getUser } from "~/modules/auth"; import { monthsVotingRange, upcomingVoting } from "~/modules/plus-server"; +import { isVotingActive } from "~/permissions"; -interface PlusVotingLoaderData { - usersForVoting?: UsersForVoting; -} +type PlusVotingLoaderData = + // voting is not active OR user is not eligible to vote + | { + type: "timeInfo"; + relativeTime: string; + timestamp: number; + timing: "starts" | "ends"; + } + // user can vote + | { + type: "voting"; + usersForVoting?: UsersForVoting; + } + // user already voted + | { type: "votingInfo"; votingInfo: { placeholder: true } }; export const loader: LoaderFunction = async ({ request }) => { const user = await getUser(request); + const now = new Date(); + const { startDate, endDate } = monthsVotingRange(upcomingVoting(now)); + if (!isVotingActive()) { + return json({ + type: "timeInfo", + relativeTime: formatDistance(startDate, now, { addSuffix: true }), + timestamp: startDate.getTime(), + timing: "starts", + }); + } + + const usersForVoting = db.plusVotes.usersForVoting(user); + + if (!usersForVoting) { + return json({ + type: "timeInfo", + relativeTime: formatDistance(endDate, now, { addSuffix: true }), + timestamp: endDate.getTime(), + timing: "ends", + }); + } + return json({ - usersForVoting: db.plusVotes.usersForVoting(user), + type: "voting", + usersForVoting, }); }; export default function PlusVotingPage() { - const data = useLoaderData(); + const data = useLoaderData(); - return ; -} + if (data.type === "timeInfo") { + return ( +
+ {data.timing === "starts" + ? "Next voting starts" + : "Voting is currently happening. Ends"}{" "} + + {data.relativeTime} + +
+ ); + } -function NextVotingInfo() { - return ( -
- Next voting starts{" "} - {monthsVotingRange(upcomingVoting(new Date())).startDate.toLocaleString( - "en-US" - )} -
- ); + return null; } diff --git a/app/styles/global.css b/app/styles/global.css index 411018671..0157b802b 100644 --- a/app/styles/global.css +++ b/app/styles/global.css @@ -334,6 +334,14 @@ hr { border-color: var(--theme-transparent); } +abbr:not([title]) { + text-decoration: none; +} + +abbr[title] { + cursor: help; +} + dialog { border: 0; margin: auto; diff --git a/package-lock.json b/package-lock.json index 0fe6a4f27..2f75c4baa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "better-sqlite3": "^7.5.3", "clsx": "^1.1.1", "countries-list": "^2.6.1", + "date-fns": "^2.28.0", "fuse.js": "^6.6.2", "just-shuffle": "^4.0.1", "randomcolor": "^0.6.2", @@ -4284,6 +4285,18 @@ "node": ">= 6" } }, + "node_modules/date-fns": { + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.28.0.tgz", + "integrity": "sha512-8d35hViGYx/QH0icHYCeLmsLmMUheMmTyV9Fcm6gvNwdw31yXXH+O85sOBJ+OLnLQMKZowvpKb6FgMIQjcpvQw==", + "engines": { + "node": ">=0.11" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/date-fns" + } + }, "node_modules/dayjs": { "version": "1.11.2", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.2.tgz", @@ -17365,6 +17378,11 @@ "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-3.0.1.tgz", "integrity": "sha512-WboRycPNsVw3B3TL559F7kuBUM4d8CgMEvk6xEJlOp7OBPjt6G7z8WMWlD2rOFZLk6OYfFIUGsCOWzcQH9K2og==" }, + "date-fns": { + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.28.0.tgz", + "integrity": "sha512-8d35hViGYx/QH0icHYCeLmsLmMUheMmTyV9Fcm6gvNwdw31yXXH+O85sOBJ+OLnLQMKZowvpKb6FgMIQjcpvQw==" + }, "dayjs": { "version": "1.11.2", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.2.tgz", diff --git a/package.json b/package.json index 3d9844a91..7fa9ae349 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "better-sqlite3": "^7.5.3", "clsx": "^1.1.1", "countries-list": "^2.6.1", + "date-fns": "^2.28.0", "fuse.js": "^6.6.2", "just-shuffle": "^4.0.1", "randomcolor": "^0.6.2",