diff --git a/builder/src/builders/hooks/index.ts b/builder/src/builders/hooks/index.ts index a888162..8625e12 100644 --- a/builder/src/builders/hooks/index.ts +++ b/builder/src/builders/hooks/index.ts @@ -35,7 +35,13 @@ export const build = async () => { !(await generatePackageJson( libDirectory, `${libDirectory}/${PACKAGE_OUTPUT}`, - "gulp" + "gulp", + { + "./util": { + import: "./mjs/util.js", + require: "./cjs/util.js", + }, + } )) ) { throw `Failed to generate package.json file.`; diff --git a/library/hooks/package.json b/library/hooks/package.json index a2d2969..ecb4e11 100644 --- a/library/hooks/package.json +++ b/library/hooks/package.json @@ -1,7 +1,7 @@ { "name": "@w3ux/hooks-source", "license": "GPL-3.0-only", - "version": "1.3.0", + "version": "1.3.1-beta.7", "type": "module", "scripts": { "clear": "rm -rf node_modules dist tsconfig.tsbuildinfo", @@ -12,12 +12,16 @@ }, "devDependencies": { "@types/react": "^18", - "@w3ux/types": "^0.2.0", + "@w3ux/types": "0.2.1-beta.1", "gulp": "^5.0.0", "gulp-sourcemaps": "^3.0.0", "gulp-strip-comments": "^2.6.0", "gulp-typescript": "^6.0.0-alpha.1", "react": "^18", "typescript": "^5.4.5" + }, + "dependencies": { + "@w3ux/utils": "^1.1.1-beta.9", + "date-fns": "^4.1.0" } } diff --git a/library/hooks/src/index.tsx b/library/hooks/src/index.tsx index 9534568..1abb78f 100644 --- a/library/hooks/src/index.tsx +++ b/library/hooks/src/index.tsx @@ -5,3 +5,4 @@ export * from "./useEffectIgnoreInitial"; export * from "./useOnResize"; export * from "./useOutsideAlerter"; export * from "./useSize"; +export * from "./useTimeLeft"; diff --git a/library/hooks/src/useTimeleft/defaults.ts b/library/hooks/src/useTimeleft/defaults.ts new file mode 100644 index 0000000..4ab8e5c --- /dev/null +++ b/library/hooks/src/useTimeleft/defaults.ts @@ -0,0 +1,14 @@ +/* @license Copyright 2024 w3ux authors & contributors +SPDX-License-Identifier: GPL-3.0-only */ + +import type { TimeleftDuration } from "@w3ux/types"; + +export const defaultDuration: TimeleftDuration = { + days: 0, + hours: 0, + minutes: 0, + seconds: 0, + lastMinute: false, +}; + +export const defaultRefreshInterval = 60; diff --git a/library/hooks/src/useTimeleft/index.tsx b/library/hooks/src/useTimeleft/index.tsx new file mode 100644 index 0000000..12953be --- /dev/null +++ b/library/hooks/src/useTimeleft/index.tsx @@ -0,0 +1,121 @@ +/* @license Copyright 2024 w3ux authors & contributors +SPDX-License-Identifier: GPL-3.0-only */ + +import { setStateWithRef } from "@w3ux/utils"; +import { useEffect, useRef, useState } from "react"; +import type { + TimeLeftAll, + TimeLeftRaw, + TimeleftDuration, + UseTimeleftProps, +} from "@w3ux/types"; +import { getDuration } from "../util"; + +export const useTimeLeft = (props?: UseTimeleftProps) => { + const depsTimeleft = props?.depsTimeleft || []; + const depsFormat = props?.depsFormat || []; + + // check whether timeleft is within a minute of finishing. + const inLastHour = () => { + const { days, hours } = getDuration(toRef.current); + return !days && !hours; + }; + + // get the amount of seconds left if timeleft is in the last minute. + const lastMinuteCountdown = () => { + const { seconds } = getDuration(toRef.current); + if (!inLastHour()) { + return 60; + } + return seconds; + }; + + // calculate resulting timeleft object from latest duration. + const getTimeleft = (c?: TimeleftDuration): TimeLeftAll => { + const { days, hours, minutes, seconds } = c || getDuration(toRef.current); + const raw: TimeLeftRaw = { + days, + hours, + minutes, + }; + if (!days && !hours) { + raw.seconds = seconds; + } + return { + raw, + }; + }; + + // the end time as a date. + const [to, setTo] = useState(null); + const toRef = useRef(to); + + // resulting timeleft object to be returned. + const [timeleft, setTimeleft] = useState(getTimeleft()); + + // timeleft refresh intervals. + const [minInterval, setMinInterval] = useState< + ReturnType | undefined + >(undefined); + const minIntervalRef = useRef(minInterval); + + const [secInterval, setSecInterval] = useState< + ReturnType | undefined + >(undefined); + const secIntervalRef = useRef(secInterval); + + // refresh effects. + useEffect(() => { + setTimeleft(getTimeleft()); + if (inLastHour()) { + // refresh timeleft every second. + if (!secIntervalRef.current) { + const interval = setInterval(() => { + if (!inLastHour()) { + clearInterval(secIntervalRef.current); + setStateWithRef(undefined, setSecInterval, secIntervalRef); + } + setTimeleft(getTimeleft()); + }, 1000); + + setStateWithRef(interval, setSecInterval, secIntervalRef); + } + } + // refresh timeleft every minute. + else if (!minIntervalRef.current) { + const interval = setInterval(() => { + if (inLastHour()) { + clearInterval(minIntervalRef.current); + setStateWithRef(undefined, setMinInterval, minIntervalRef); + } + setTimeleft(getTimeleft()); + }, 60000); + setStateWithRef(interval, setMinInterval, minIntervalRef); + } + }, [to, inLastHour(), lastMinuteCountdown(), ...depsTimeleft]); + + // re-render the timeleft upon formatting changes. + useEffect(() => { + setTimeleft(getTimeleft()); + }, [...depsFormat]); + + // clear intervals on unmount + useEffect( + () => () => { + clearInterval(minInterval); + clearInterval(secInterval); + }, + [] + ); + + // Set the end time and calculate timeleft. + const setFromNow = (dateFrom: Date, dateTo: Date) => { + setTimeleft(getTimeleft(getDuration(dateFrom))); + setStateWithRef(dateTo, setTo, toRef); + }; + + return { + setFromNow, + timeleft, + }; +}; diff --git a/library/hooks/src/util.ts b/library/hooks/src/util.ts new file mode 100644 index 0000000..20f01a1 --- /dev/null +++ b/library/hooks/src/util.ts @@ -0,0 +1,75 @@ +/* @license Copyright 2024 w3ux authors & contributors +SPDX-License-Identifier: GPL-3.0-only */ + +import { TimeleftDuration } from "@w3ux/types"; +import { differenceInDays, getUnixTime, intervalToDuration } from "date-fns"; +import { defaultDuration } from "./useTimeLeft/defaults"; + +// calculates the current timeleft duration. +export const getDuration = (toDate: Date | null): TimeleftDuration => { + if (!toDate) { + return defaultDuration; + } + if (getUnixTime(toDate) <= getUnixTime(new Date())) { + return defaultDuration; + } + + toDate.setSeconds(toDate.getSeconds()); + const d = intervalToDuration({ + start: Date.now(), + end: toDate, + }); + + const days = differenceInDays(toDate, Date.now()); + const hours = d?.hours || 0; + const minutes = d?.minutes || 0; + const seconds = d?.seconds || 0; + const lastHour = days === 0 && hours === 0; + const lastMinute = lastHour && minutes === 0; + + return { + days, + hours, + minutes, + seconds, + lastMinute, + }; +}; + +// Helper: Adds `seconds` to the current time and returns the resulting date. +export const secondsFromNow = (seconds: number): Date => { + const end = new Date(); + end.setSeconds(end.getSeconds() + seconds); + return end; +}; + +// Helper: Calculates the duration between the current time and the provided date. +export const getDurationFromNow = (toDate: Date | null): TimeleftDuration => { + if (!toDate) { + return defaultDuration; + } + if (getUnixTime(toDate) <= getUnixTime(new Date())) { + return defaultDuration; + } + + toDate.setSeconds(toDate.getSeconds()); + const d = intervalToDuration({ + start: Date.now(), + end: toDate, + }); + + const days = differenceInDays(toDate, Date.now()); + const hours = d?.hours || 0; + const minutes = d?.minutes || 0; + const seconds = d?.seconds || 0; + const lastHour = days === 0 && hours === 0; + const lastMinute = lastHour && minutes === 0; + + return { + days, + hours, + minutes, + seconds, + lastMinute, + }; +}; diff --git a/library/types/package.json b/library/types/package.json index 3e09076..3b98205 100644 --- a/library/types/package.json +++ b/library/types/package.json @@ -1,7 +1,7 @@ { "name": "@w3ux/types-source", "license": "GPL-3.0-only", - "version": "0.2.0", + "version": "0.2.1-beta.1", "type": "module", "scripts": { "clear": "rm -rf node_modules dist tsconfig.tsbuildinfo", diff --git a/library/types/src/common.ts b/library/types/src/common.ts new file mode 100644 index 0000000..cfc1826 --- /dev/null +++ b/library/types/src/common.ts @@ -0,0 +1,34 @@ +import { CSSProperties, ReactNode } from "react"; + +// A general purpose sync status, tracking whether an item is not synced, in progress, or completed +// syncing. Useful for tracking async function progress, used as React refs, etc. +export type Sync = "synced" | "unsynced" | "syncing"; + +// A general purpose medium components are being displayed on. +export type DisplayFor = "default" | "modal" | "canvas" | "card"; + +// A generic type for basic React components. We assume the component may have children and styling +// applied to it. +export interface ComponentBase { + // passing react children. + children?: ReactNode; + // passing custom styling. + style?: CSSProperties; +} + +// An extension of the ComponentBase type with an additional className property. +export type ComponentBaseWithClassName = ComponentBase & { + // passing a className string. + className?: string; +}; + +// A funtion with no arguments and no return value. +export type VoidFn = () => void; + +// A JSON value: string, number, object, array, true, false, null. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type AnyJson = any; + +// A function definition. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type AnyFunction = any; diff --git a/library/types/src/index.ts b/library/types/src/index.ts index 501c5c8..e84aee8 100644 --- a/library/types/src/index.ts +++ b/library/types/src/index.ts @@ -1,37 +1,5 @@ /* @license Copyright 2024 w3ux authors & contributors SPDX-License-Identifier: GPL-3.0-only */ -import { CSSProperties, ReactNode } from "react"; - -// A general purpose sync status, tracking whether an item is not synced, in progress, or completed -// syncing. Useful for tracking async function progress, used as React refs, etc. -export type Sync = "synced" | "unsynced" | "syncing"; - -// A general purpose medium components are being displayed on. -export type DisplayFor = "default" | "modal" | "canvas" | "card"; - -// A generic type for basic React components. We assume the component may have children and styling -// applied to it. -export interface ComponentBase { - // passing react children. - children?: ReactNode; - // passing custom styling. - style?: CSSProperties; -} - -// An extension of the ComponentBase type with an additional className property. -export type ComponentBaseWithClassName = ComponentBase & { - // passing a className string. - className?: string; -}; - -// A funtion with no arguments and no return value. -export type VoidFn = () => void; - -// A JSON value: string, number, object, array, true, false, null. -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export type AnyJson = any; - -// A function definition. -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export type AnyFunction = any; +export * from "./common"; +export * from "./time"; diff --git a/library/types/src/time.ts b/library/types/src/time.ts new file mode 100644 index 0000000..7189eef --- /dev/null +++ b/library/types/src/time.ts @@ -0,0 +1,39 @@ +/* @license Copyright 2024 w3ux authors & contributors +SPDX-License-Identifier: GPL-3.0-only */ + +export interface UseTimeleftProps { + // Dependencies to trigger re-calculation of timeleft. + depsTimeleft: unknown[]; + // Dependencies to trigger re-render of timeleft, e.g. if language switching occurs. + depsFormat: unknown[]; +} + +export interface TimeleftDuration { + days: number; + hours: number; + minutes: number; + seconds: number; + lastMinute: boolean; +} + +export interface TimeLeftRaw { + days: number; + hours: number; + minutes: number; + seconds?: number; +} + +export interface TimeLeftFormatted { + days: [number, string]; + hours: [number, string]; + minutes: [number, string]; + seconds?: [number, string]; +} + +export interface TimeLeftAll { + raw: TimeLeftRaw; +} + +export interface TimeleftHookProps { + refreshInterval: number; +} diff --git a/yarn.lock b/yarn.lock index 962523b..d806d8a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1769,7 +1769,9 @@ __metadata: resolution: "@w3ux/hooks-source@workspace:library/hooks" dependencies: "@types/react": "npm:^18" - "@w3ux/types": "npm:^0.2.0" + "@w3ux/types": "npm:0.2.1-beta.1" + "@w3ux/utils": "npm:^1.1.1-beta.9" + date-fns: "npm:^4.1.0" gulp: "npm:^5.0.0" gulp-sourcemaps: "npm:^3.0.0" gulp-strip-comments: "npm:^2.6.0" @@ -1855,6 +1857,13 @@ __metadata: languageName: unknown linkType: soft +"@w3ux/types@npm:0.2.1-beta.1": + version: 0.2.1-beta.1 + resolution: "@w3ux/types@npm:0.2.1-beta.1" + checksum: 10c0/34336e708a7b4881476cc8b88c716d331a00cb44e32ba2a6223ded45efb30d1a574c4e3f92fc3449089196a3860c2740c1bf3c97e5ddf176b62addab6ff2628b + languageName: node + linkType: hard + "@w3ux/types@npm:^0.2.0": version: 0.2.0 resolution: "@w3ux/types@npm:0.2.0" @@ -1884,6 +1893,15 @@ __metadata: languageName: node linkType: hard +"@w3ux/utils@npm:^1.1.1-beta.9": + version: 1.1.1-beta.9 + resolution: "@w3ux/utils@npm:1.1.1-beta.9" + dependencies: + "@polkadot-api/substrate-bindings": "npm:^0.9.3" + checksum: 10c0/b18cf8274d43cf3419e8b47fc822164653da7a48a83a6eb84820b9fe14386e5f167ad1f634bfddac938f472839bc605e9bb04e8a88cfd12223b5b40b24730934 + languageName: node + linkType: hard + "@w3ux/validator-assets-source@workspace:library/validator-assets": version: 0.0.0-use.local resolution: "@w3ux/validator-assets-source@workspace:library/validator-assets" @@ -2734,6 +2752,13 @@ __metadata: languageName: node linkType: hard +"date-fns@npm:^4.1.0": + version: 4.1.0 + resolution: "date-fns@npm:4.1.0" + checksum: 10c0/b79ff32830e6b7faa009590af6ae0fb8c3fd9ffad46d930548fbb5acf473773b4712ae887e156ba91a7b3dc30591ce0f517d69fd83bd9c38650fdc03b4e0bac8 + languageName: node + linkType: hard + "debug-fabulous@npm:^1.0.0": version: 1.1.0 resolution: "debug-fabulous@npm:1.1.0"