@johnnyboy11234/esg-idea-contest 0.3.1 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/backend/api/openapi.yaml +36 -0
  2. package/backend/cmd/server/main.go +1 -0
  3. package/backend/internal/account/internal/passwords/NOTICE +36 -0
  4. package/backend/internal/account/internal/passwords/common-passwords.txt +10001 -0
  5. package/backend/internal/account/internal/passwords/passwords.go +37 -0
  6. package/backend/internal/account/internal/passwords/passwords_test.go +35 -0
  7. package/backend/internal/account/password.go +8 -2
  8. package/backend/internal/account/sequence.go +54 -0
  9. package/backend/internal/account/sequence_test.go +38 -0
  10. package/backend/internal/api/api.gen.go +38 -0
  11. package/backend/internal/idea/create.go +5 -1
  12. package/backend/internal/idea/create_integration_test.go +33 -3
  13. package/backend/internal/idea/download.go +193 -0
  14. package/backend/internal/idea/download_integration_test.go +311 -0
  15. package/backend/internal/idea/download_test.go +25 -0
  16. package/backend/internal/idea/handler.go +3 -0
  17. package/backend/internal/idea/service.go +2 -1
  18. package/backend/internal/server/api.go +5 -0
  19. package/backend/internal/server/binding.go +4 -0
  20. package/backend/internal/server/server.go +20 -1
  21. package/backend/internal/server/slides_test.go +82 -0
  22. package/backend/internal/shared/web/transport.go +4 -3
  23. package/backend/test/arch/arch_test.go +5 -1
  24. package/backend/test/matrix/matrix_integration_test.go +6 -0
  25. package/docs/Architecture/Architecture.md +1 -1
  26. package/docs/Architecture/Backend.md +2 -1
  27. package/frontend/Dockerfile +2 -0
  28. package/frontend/package-lock.json +1 -0
  29. package/frontend/package.json +1 -0
  30. package/frontend/src/app/pages/ideaCreate.test.tsx +308 -0
  31. package/frontend/src/app/pages/ideas.tsx +23 -9
  32. package/frontend/src/app/pages/login.test.tsx +38 -1
  33. package/frontend/src/app/pages/register.test.tsx +32 -0
  34. package/frontend/src/features/auth/LoginForm.tsx +21 -3
  35. package/frontend/src/features/auth/PasswordChecklist.test.tsx +73 -0
  36. package/frontend/src/features/auth/PasswordChecklist.tsx +96 -0
  37. package/frontend/src/features/auth/RegisterForm.tsx +11 -2
  38. package/frontend/src/features/auth/common-passwords.ts +31 -0
  39. package/frontend/src/features/auth/password-policy.test.ts +64 -0
  40. package/frontend/src/features/auth/password-policy.ts +168 -0
  41. package/frontend/src/features/auth/useCountdown.test.tsx +33 -0
  42. package/frontend/src/features/auth/useCountdown.ts +28 -0
  43. package/frontend/src/features/idea/CreateIdeaForm.tsx +279 -0
  44. package/frontend/src/features/idea/create.ts +123 -0
  45. package/frontend/src/shared/api/types.generated.ts +319 -1
  46. package/frontend/src/shared/i18n/locales/en.ts +45 -4
  47. package/frontend/src/shared/i18n/locales/zh-TW.ts +45 -4
  48. package/frontend/src/shared/slides/index.ts +74 -0
  49. package/frontend/src/shared/slides/slides.test.ts +51 -0
  50. package/frontend/src/shared/ui/copy-button.tsx +52 -0
  51. package/frontend/src/shared/ui/native-select.tsx +27 -0
  52. package/frontend/src/shared/ui/textarea.tsx +19 -0
  53. package/frontend/src/test-setup.ts +37 -2
  54. package/frontend/vite.config.ts +3 -1
  55. package/package.json +1 -1
  56. package/testdata/password.json +164 -7
  57. package/testdata/permission-matrix.json +23 -0
  58. package/testdata/schema/permission-matrix.schema.json +1 -1
@@ -0,0 +1,96 @@
1
+ import { Check, Circle, X } from "lucide-react";
2
+ import { useEffect, useState } from "react";
3
+ import { useTranslation } from "react-i18next";
4
+ import { cn } from "@/shared/ui/utils";
5
+ import { loadCommonPasswords } from "./common-passwords";
6
+ import {
7
+ checkPassword,
8
+ empNoForPolicy,
9
+ PASSWORD_RULES,
10
+ type CommonCheck,
11
+ type PasswordRule,
12
+ } from "./password-policy";
13
+
14
+ /** `idle`: nothing typed yet; `server`: the blocklist is not (yet) loaded, the server checks this rule. */
15
+ type Status = "met" | "unmet" | "idle" | "server";
16
+
17
+ type ListState = { check: CommonCheck } | { failed: true } | null;
18
+
19
+ /** Loads the blocklist chunk on mount; until it arrives (or if it fails) COMMON_PASSWORD is left to the server. */
20
+ function useCommonList(): CommonCheck | undefined {
21
+ const [state, setState] = useState<ListState>(null);
22
+ useEffect(() => {
23
+ let live = true;
24
+ loadCommonPasswords().then(
25
+ (check) => live && setState({ check }),
26
+ () => live && setState({ failed: true }),
27
+ );
28
+ return () => {
29
+ live = false;
30
+ };
31
+ }, []);
32
+ return state && "check" in state ? state.check : undefined;
33
+ }
34
+
35
+ const ICON = { met: Check, unmet: X, idle: Circle, server: Circle } as const;
36
+
37
+ /**
38
+ * Live checklist of the 8 password rules (PRD §6.4, Frontend.md §5.6), keyed by
39
+ * the rule ids of PASSWORD_POLICY. It is not a live region: the password field
40
+ * points at it with `aria-describedby`, so the current state is read on focus
41
+ * instead of after every keystroke. State is carried by text, not colour alone.
42
+ */
43
+ export function PasswordChecklist({
44
+ id,
45
+ password,
46
+ empNo,
47
+ }: {
48
+ id: string;
49
+ password: string;
50
+ empNo: string;
51
+ }) {
52
+ const { t } = useTranslation();
53
+ const isCommon = useCommonList();
54
+ const violated = new Set<PasswordRule>(
55
+ checkPassword(password, empNoForPolicy(empNo), isCommon),
56
+ );
57
+ const status = (r: PasswordRule): Status => {
58
+ if (password === "") return "idle";
59
+ if (r === "COMMON_PASSWORD" && !isCommon) return "server";
60
+ return violated.has(r) ? "unmet" : "met";
61
+ };
62
+
63
+ return (
64
+ <div id={id} className="text-sm">
65
+ <p className="text-muted-foreground">{t("passwordRules.title")}</p>
66
+ <ul className="mt-1 space-y-0.5">
67
+ {PASSWORD_RULES.map((r) => {
68
+ const s = status(r);
69
+ const Icon = ICON[s];
70
+ return (
71
+ <li
72
+ key={r}
73
+ data-rule={r}
74
+ data-status={s}
75
+ className={cn(
76
+ "flex items-start gap-1.5",
77
+ s === "met" && "text-emerald-700 dark:text-emerald-400",
78
+ s === "unmet" && "text-destructive",
79
+ (s === "idle" || s === "server") && "text-muted-foreground",
80
+ )}
81
+ >
82
+ <Icon aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
83
+ <span>
84
+ {t(`passwordRules.${r}`)}
85
+ <span className="sr-only">
86
+ {" "}
87
+ ({t(`passwordRules.status.${s}`)})
88
+ </span>
89
+ </span>
90
+ </li>
91
+ );
92
+ })}
93
+ </ul>
94
+ </div>
95
+ );
96
+ }
@@ -10,6 +10,7 @@ import {
10
10
  FieldLabel,
11
11
  } from "@/shared/ui/field";
12
12
  import { Input } from "@/shared/ui/input";
13
+ import { PasswordChecklist } from "./PasswordChecklist";
13
14
  import {
14
15
  mapServerError,
15
16
  precheck,
@@ -48,7 +49,6 @@ const FIELD_ATTRS: Record<
48
49
  const HINTS: Partial<Record<RegisterField, string>> = {
49
50
  empNo: "pages.register.empNoHint",
50
51
  email: "pages.register.emailHint",
51
- password: "pages.register.passwordHint",
52
52
  };
53
53
 
54
54
  /** The register form: format pre-check, error summary with focus, server errors mapped to fields (Frontend.md §5.6, §8). */
@@ -155,8 +155,10 @@ export function RegisterForm({
155
155
  const a = FIELD_ATTRS[f];
156
156
  const problem = problems[f];
157
157
  const hint = HINTS[f];
158
+ // the password's hint is the live rule checklist (F1b-01)
159
+ const hintId = f === "password" ? "password-rules" : hint && `${f}-hint`;
158
160
  const describedBy =
159
- [hint && `${f}-hint`, problem && `${f}-error`]
161
+ [hintId, problem && `${f}-error`]
160
162
  .filter(Boolean)
161
163
  .join(" ") || undefined;
162
164
  return (
@@ -180,6 +182,13 @@ export function RegisterForm({
180
182
  {hint && (
181
183
  <FieldDescription id={`${f}-hint`}>{t(hint)}</FieldDescription>
182
184
  )}
185
+ {f === "password" && (
186
+ <PasswordChecklist
187
+ id="password-rules"
188
+ password={values.password}
189
+ empNo={values.empNo}
190
+ />
191
+ )}
183
192
  {problem && (
184
193
  <FieldError id={`${f}-error`} role={undefined}>
185
194
  {message(problem)}
@@ -0,0 +1,31 @@
1
+ import { foldCase, type CommonCheck } from "./password-policy";
2
+
3
+ /** Builds the lookup from the list file: one entry per line, folded, `\r` and empty lines dropped. */
4
+ export function parseCommonList(text: string): CommonCheck {
5
+ const set = new Set<string>();
6
+ for (const line of text.split("\n")) {
7
+ const l = line.endsWith("\r") ? line.slice(0, -1) : line;
8
+ if (l !== "") set.add(foldCase(l));
9
+ }
10
+ // the whole password as typed: nothing trimmed, an entry inside a longer password does not match
11
+ return (pw) => set.has(foldCase(pw));
12
+ }
13
+
14
+ let loading: Promise<CommonCheck> | undefined;
15
+
16
+ /**
17
+ * Loads the bundled Top-10,000 list (the backend's own file, NOTICE next to it)
18
+ * as a separate chunk, once. Only the register page calls this, so no other
19
+ * page downloads it (Frontend.md §5.6). A failed load is not cached, so a
20
+ * later mount may retry.
21
+ */
22
+ export function loadCommonPasswords(): Promise<CommonCheck> {
23
+ loading ??= import("passwords/common-passwords.txt?raw").then(
24
+ (m) => parseCommonList(m.default),
25
+ (err: unknown) => {
26
+ loading = undefined;
27
+ throw err;
28
+ },
29
+ );
30
+ return loading;
31
+ }
@@ -0,0 +1,64 @@
1
+ import cases from "testdata/password.json";
2
+ import { loadCommonPasswords, parseCommonList } from "./common-passwords";
3
+ import { checkPassword, empNoForPolicy, foldCase, PASSWORD_RULES } from "./password-policy";
4
+
5
+ describe("checkPassword: testdata/password.json (same cases as the backend)", () => {
6
+ it.each(cases.cases.map((c) => [c.id, c] as const))("%s", async (_id, c) => {
7
+ const isCommon = await loadCommonPasswords();
8
+ expect(checkPassword(c.password, c.empNo, isCommon)).toEqual(c.wantRules);
9
+ });
10
+
11
+ it("covers every rule id at least once", () => {
12
+ const seen = new Set(cases.cases.flatMap((c) => c.wantRules));
13
+ expect(PASSWORD_RULES.filter((r) => !seen.has(r))).toEqual([]);
14
+ });
15
+ });
16
+
17
+ describe("checkPassword: client specifics", () => {
18
+ it("without the blocklist never reports COMMON_PASSWORD, the rest unchanged", () => {
19
+ for (const c of cases.cases) {
20
+ expect(checkPassword(c.password, c.empNo)).toEqual(
21
+ c.wantRules.filter((r) => r !== "COMMON_PASSWORD"),
22
+ );
23
+ }
24
+ });
25
+
26
+ it("counts a lone surrogate as one code point outside printable ASCII", () => {
27
+ expect(checkPassword("Correct-Hors\uD800", "")).toEqual(["CHARSET"]);
28
+ });
29
+
30
+ it("folds case like Go strings.ToLower, not toLowerCase (U+0130, U+212A)", () => {
31
+ expect(foldCase("PASSİK")).toBe("passik");
32
+ // U+212A (Kelvin) folds to k, so "jkl;" is a keyboard run on the server too
33
+ expect(checkPassword("Zq-JKL;-Mx81", "")).toEqual(["CHARSET", "KEYBOARD_RUN"]);
34
+ });
35
+ });
36
+
37
+ describe("empNoForPolicy", () => {
38
+ it.each([
39
+ ["089290", "089290"],
40
+ ["08929", ""],
41
+ ["0892901", ""],
42
+ ["08929a", ""],
43
+ ["089290", ""],
44
+ ])("%j → %j", (input, want) => expect(empNoForPolicy(input)).toBe(want));
45
+ });
46
+
47
+ describe("common-password list", () => {
48
+ it("loads once as a lazy chunk and matches whole passwords ignoring case", async () => {
49
+ const a = loadCommonPasswords();
50
+ expect(loadCommonPasswords()).toBe(a);
51
+ const isCommon = await a;
52
+ expect(isCommon("password")).toBe(true);
53
+ expect(isCommon("PassWord")).toBe(true);
54
+ expect(isCommon(" password")).toBe(false);
55
+ expect(isCommon("password1x")).toBe(false);
56
+ });
57
+
58
+ it("parses CRLF lines and skips empty ones", () => {
59
+ const isCommon = parseCommonList("Alpha\r\n\r\nbeta\n");
60
+ expect(isCommon("alpha")).toBe(true);
61
+ expect(isCommon("BETA")).toBe(true);
62
+ expect(isCommon("")).toBe(false);
63
+ });
64
+ });
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Password policy of PRD §6.4, ported rule for rule from the backend
3
+ * (backend/internal/account/password.go, keyboard.go, sequence.go) and checked
4
+ * against the same cases (testdata/password.json). The server stays the
5
+ * authority; this drives the live checklist on the register page.
6
+ */
7
+
8
+ /** Rule ids in PRD §6.4 table order; they are the wire contract (`errors[].rule`). */
9
+ export const PASSWORD_RULES = [
10
+ "LENGTH",
11
+ "CHARSET",
12
+ "COMPLEXITY",
13
+ "COMMON_PASSWORD",
14
+ "KEYBOARD_RUN",
15
+ "SEQUENCE",
16
+ "REPEAT",
17
+ "CONTAINS_EMP_NO",
18
+ ] as const;
19
+ export type PasswordRule = (typeof PASSWORD_RULES)[number];
20
+
21
+ /** Whole-password blocklist lookup (case-insensitive); see `common-passwords.ts`. */
22
+ export type CommonCheck = (password: string) => boolean;
23
+
24
+ const MIN_LEN = 12;
25
+ const MAX_LEN = 64; // code points
26
+ const MIN_CLASSES = 3;
27
+ const MAX_REPEAT = 2; // 3+ identical characters in a row is a violation
28
+ const MIN_KEYBOARD_RUN = 4;
29
+ const MIN_SEQUENCE = 4;
30
+
31
+ /** The password as code points (a JS string is UTF-16; the server counts code points). */
32
+ const codePoints = (s: string): number[] => Array.from(s, (c) => c.codePointAt(0)!);
33
+
34
+ /**
35
+ * Lower-cases one code point exactly as Go's `strings.ToLower` does for every
36
+ * result that matters here (ASCII): A–Z, plus the only two non-ASCII code
37
+ * points whose lower case is ASCII, U+0130 → `i` and U+212A (Kelvin) → `k`.
38
+ * `String.prototype.toLowerCase` differs (U+0130 becomes two code points).
39
+ */
40
+ const foldCp = (cp: number): number =>
41
+ cp >= 0x41 && cp <= 0x5a ? cp + 0x20 : cp === 0x130 ? 0x69 : cp === 0x212a ? 0x6b : cp;
42
+
43
+ /** Case-folds a string with {@link foldCp}; used for the blocklist lookup. */
44
+ export const foldCase = (s: string): string =>
45
+ String.fromCodePoint(...codePoints(s).map(foldCp));
46
+
47
+ const violatesLength = (cps: number[]) => cps.length < MIN_LEN || cps.length > MAX_LEN;
48
+
49
+ const violatesCharset = (cps: number[]) => cps.some((cp) => cp < 0x20 || cp > 0x7e);
50
+
51
+ /** Upper, lower, digit, symbol (printable non-alphanumeric ASCII, space included). Non-ASCII is no class. */
52
+ function violatesComplexity(cps: number[]): boolean {
53
+ let upper = false, lower = false, digit = false, symbol = false;
54
+ for (const cp of cps) {
55
+ if (cp >= 0x41 && cp <= 0x5a) upper = true;
56
+ else if (cp >= 0x61 && cp <= 0x7a) lower = true;
57
+ else if (cp >= 0x30 && cp <= 0x39) digit = true;
58
+ else if (cp >= 0x20 && cp <= 0x7e) symbol = true;
59
+ }
60
+ return [upper, lower, digit, symbol].filter(Boolean).length < MIN_CLASSES;
61
+ }
62
+
63
+ /** The four rows of US QWERTY; rows do not join and do not wrap. */
64
+ const KEYBOARD_ROWS = ["1234567890-=", "qwertyuiop[]", "asdfghjkl;'", "zxcvbnm,./"];
65
+ /** The shifted glyph of each key, position for position (letters are folded by case instead). */
66
+ const SHIFTED_ROWS = ["!@#$%^&*()_+", "QWERTYUIOP{}", "ASDFGHJKL:\"", "ZXCVBNM<>?"];
67
+
68
+ /** Glyph → [row, column]. Glyphs on no row (` ~ \ | and anything non-ASCII) break a run. */
69
+ const KEY_OF = new Map<number, [number, number]>();
70
+ [KEYBOARD_ROWS, SHIFTED_ROWS].forEach((rows) =>
71
+ rows.forEach((row, r) =>
72
+ Array.from(row).forEach((g, c) => KEY_OF.set(g.codePointAt(0)!, [r, c])),
73
+ ),
74
+ );
75
+
76
+ /**
77
+ * Longest run of positions that step by exactly ±1 in one direction within one
78
+ * group reaches `min`. `pos` maps a code point to [group, position] or undefined
79
+ * (which breaks the run). Same position twice is not a step (that is REPEAT).
80
+ * Shared by KEYBOARD_RUN (group = row) and SEQUENCE (group = alphabet).
81
+ */
82
+ function hasRun(
83
+ cps: number[],
84
+ pos: (cp: number) => readonly [number, number] | undefined,
85
+ min: number,
86
+ ): boolean {
87
+ let prev: readonly [number, number] | undefined;
88
+ let run = 0, dir = 0;
89
+ for (const cp of cps) {
90
+ const k = pos(cp);
91
+ if (!k) {
92
+ prev = undefined;
93
+ run = 0;
94
+ dir = 0;
95
+ continue;
96
+ }
97
+ const step = prev && k[0] === prev[0] ? k[1] - prev[1] : 0;
98
+ if (step === 1 || step === -1) {
99
+ if (run >= 2 && step === dir) run++;
100
+ else {
101
+ run = 2;
102
+ dir = step;
103
+ }
104
+ } else {
105
+ run = 1;
106
+ dir = 0;
107
+ }
108
+ prev = k;
109
+ if (run >= min) return true;
110
+ }
111
+ return false;
112
+ }
113
+
114
+ /** 4+ adjacent keys on one row, either direction, case-insensitive, shifted glyph = its key. */
115
+ const violatesKeyboardRun = (cps: number[]) =>
116
+ hasRun(cps.map(foldCp), (cp) => KEY_OF.get(cp), MIN_KEYBOARD_RUN);
117
+
118
+ /** Letters a–z (case folded) are group 0, digits 0–9 group 1; anything else breaks a sequence. */
119
+ function seqKey(cp: number): [number, number] | undefined {
120
+ if (cp >= 0x61 && cp <= 0x7a) return [0, cp - 0x61];
121
+ if (cp >= 0x41 && cp <= 0x5a) return [0, cp - 0x41];
122
+ if (cp >= 0x30 && cp <= 0x39) return [1, cp - 0x30];
123
+ return undefined;
124
+ }
125
+
126
+ /** 4+ consecutive letters or digits, ascending or descending; no wrap; letters and digits never join. */
127
+ const violatesSequence = (cps: number[]) => hasRun(cps, seqKey, MIN_SEQUENCE);
128
+
129
+ /** The same code point 3+ times in a row, compared exactly ("aAa" is fine). */
130
+ function violatesRepeat(cps: number[]): boolean {
131
+ let run = 0, prev = -1;
132
+ for (const cp of cps) {
133
+ if (cp === prev) run++;
134
+ else {
135
+ run = 1;
136
+ prev = cp;
137
+ }
138
+ if (run > MAX_REPEAT) return true;
139
+ }
140
+ return false;
141
+ }
142
+
143
+ /**
144
+ * Every violated rule in table order. `empNo` is "" when the employee ID is
145
+ * malformed (CONTAINS_EMP_NO is then skipped, as on the server). Without
146
+ * `isCommon` (blocklist not loaded) COMMON_PASSWORD is never reported.
147
+ */
148
+ export function checkPassword(
149
+ password: string,
150
+ empNo: string,
151
+ isCommon?: CommonCheck,
152
+ ): PasswordRule[] {
153
+ const cps = codePoints(password);
154
+ const violated: Record<PasswordRule, boolean> = {
155
+ LENGTH: violatesLength(cps),
156
+ CHARSET: violatesCharset(cps),
157
+ COMPLEXITY: violatesComplexity(cps),
158
+ COMMON_PASSWORD: isCommon?.(password) ?? false,
159
+ KEYBOARD_RUN: violatesKeyboardRun(cps),
160
+ SEQUENCE: violatesSequence(cps),
161
+ REPEAT: violatesRepeat(cps),
162
+ CONTAINS_EMP_NO: empNo !== "" && password.includes(empNo),
163
+ };
164
+ return PASSWORD_RULES.filter((r) => violated[r]);
165
+ }
166
+
167
+ /** The employee ID as the server sees it for CONTAINS_EMP_NO: "" unless it is well-formed. */
168
+ export const empNoForPolicy = (empNo: string): string => (/^[0-9]{6}$/.test(empNo) ? empNo : "");
@@ -0,0 +1,33 @@
1
+ import { act, renderHook } from "@testing-library/react";
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { useCountdown } from "./useCountdown";
4
+
5
+ describe("useCountdown", () => {
6
+ beforeEach(() => vi.useFakeTimers());
7
+ afterEach(() => vi.useRealTimers());
8
+
9
+ it("counts down in whole seconds and stops at zero", () => {
10
+ const { result } = renderHook(() => useCountdown());
11
+ expect(result.current.remaining).toBe(0);
12
+ act(() => result.current.start(3));
13
+ expect(result.current.remaining).toBe(3);
14
+ act(() => void vi.advanceTimersByTime(1000));
15
+ expect(result.current.remaining).toBe(2);
16
+ act(() => void vi.advanceTimersByTime(2000));
17
+ expect(result.current.remaining).toBe(0);
18
+ });
19
+
20
+ it("treats 0 as at least one second", () => {
21
+ const { result } = renderHook(() => useCountdown());
22
+ act(() => result.current.start(0));
23
+ expect(result.current.remaining).toBe(1);
24
+ });
25
+
26
+ it("restarts from a new value", () => {
27
+ const { result } = renderHook(() => useCountdown());
28
+ act(() => result.current.start(10));
29
+ act(() => void vi.advanceTimersByTime(4000));
30
+ act(() => result.current.start(30));
31
+ expect(result.current.remaining).toBe(30);
32
+ });
33
+ });
@@ -0,0 +1,28 @@
1
+ import { useCallback, useEffect, useState } from "react";
2
+
3
+ /**
4
+ * Whole seconds left of a countdown started with `start(seconds)`. It advances with
5
+ * `performance.now()`, so changing the system clock does not affect it (Frontend.md §5.3).
6
+ */
7
+ export function useCountdown() {
8
+ const [endsAt, setEndsAt] = useState<number | null>(null);
9
+ const [remaining, setRemaining] = useState(0);
10
+
11
+ useEffect(() => {
12
+ if (endsAt === null) return;
13
+ const tick = () => {
14
+ const left = Math.max(0, Math.ceil((endsAt - performance.now()) / 1000));
15
+ setRemaining(left);
16
+ if (left === 0) setEndsAt(null);
17
+ };
18
+ tick();
19
+ const id = setInterval(tick, 250);
20
+ return () => clearInterval(id);
21
+ }, [endsAt]);
22
+
23
+ const start = useCallback((seconds: number) => {
24
+ setEndsAt(performance.now() + Math.max(1, seconds) * 1000);
25
+ }, []);
26
+
27
+ return { remaining, start };
28
+ }