From 83c7815e0ed62d1c4420034c5562d16ed2c9819d Mon Sep 17 00:00:00 2001 From: RUKAYAT-CODER Date: Sat, 30 May 2026 11:25:44 +0100 Subject: [PATCH 01/12] feat(fe-07): add skeleton loader components --- frontend/cntr/Skeletons/WorkspaceCardSkeleton.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 frontend/cntr/Skeletons/WorkspaceCardSkeleton.tsx diff --git a/frontend/cntr/Skeletons/WorkspaceCardSkeleton.tsx b/frontend/cntr/Skeletons/WorkspaceCardSkeleton.tsx new file mode 100644 index 0000000..fd48618 --- /dev/null +++ b/frontend/cntr/Skeletons/WorkspaceCardSkeleton.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +export const WorkspaceCardSkeleton: React.FC = () => ( +
+
+
+
+
+
+
+
+
+); \ No newline at end of file From 48b0aaa10076eef720138b775facee1a4215335d Mon Sep 17 00:00:00 2001 From: RUKAYAT-CODER Date: Sat, 30 May 2026 11:25:45 +0100 Subject: [PATCH 02/12] feat(fe-07): add skeleton loader components --- frontend/cntr/Skeletons/Skeletons.test.tsx | 30 ++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 frontend/cntr/Skeletons/Skeletons.test.tsx diff --git a/frontend/cntr/Skeletons/Skeletons.test.tsx b/frontend/cntr/Skeletons/Skeletons.test.tsx new file mode 100644 index 0000000..5cc9954 --- /dev/null +++ b/frontend/cntr/Skeletons/Skeletons.test.tsx @@ -0,0 +1,30 @@ +import { render } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import { WorkspaceCardSkeleton } from './WorkspaceCardSkeleton'; +import { InvoiceRowSkeleton } from './InvoiceRowSkeleton'; +import { MemberRowSkeleton } from './MemberRowSkeleton'; +import { TableRowSkeleton } from './TableRowSkeleton'; + +describe('Skeleton components', () => { + it('WorkspaceCardSkeleton renders with animate-pulse', () => { + const { container } = render(); + expect(container.firstChild).toHaveClass('animate-pulse'); + }); + it('InvoiceRowSkeleton renders with animate-pulse', () => { + const { container } = render(); + expect(container.firstChild).toHaveClass('animate-pulse'); + }); + it('MemberRowSkeleton renders with animate-pulse', () => { + const { container } = render(); + expect(container.firstChild).toHaveClass('animate-pulse'); + }); + it('TableRowSkeleton renders default 4 columns', () => { + const { container } = render(); + expect(container.firstChild).toHaveClass('animate-pulse'); + expect(container.querySelectorAll('.flex-1').length).toBe(4); + }); + it('TableRowSkeleton renders custom column count', () => { + const { container } = render(); + expect(container.querySelectorAll('.flex-1').length).toBe(6); + }); +}); \ No newline at end of file From 40ee441be5319b835ff9b1492026083e5579bd96 Mon Sep 17 00:00:00 2001 From: RUKAYAT-CODER Date: Sat, 30 May 2026 11:25:46 +0100 Subject: [PATCH 03/12] feat(fe-07): add skeleton loader components --- frontend/cntr/Skeletons/TableRowSkeleton.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 frontend/cntr/Skeletons/TableRowSkeleton.tsx diff --git a/frontend/cntr/Skeletons/TableRowSkeleton.tsx b/frontend/cntr/Skeletons/TableRowSkeleton.tsx new file mode 100644 index 0000000..f41869d --- /dev/null +++ b/frontend/cntr/Skeletons/TableRowSkeleton.tsx @@ -0,0 +1,9 @@ +import React from 'react'; +interface Props { cols?: number; } +export const TableRowSkeleton: React.FC = ({ cols = 4 }) => ( +
+ {Array.from({ length: cols }).map((_, i) => ( +
+ ))} +
+); \ No newline at end of file From fbd720b4f3633b775307afde7703301d3bc9b9d8 Mon Sep 17 00:00:00 2001 From: RUKAYAT-CODER Date: Sat, 30 May 2026 11:25:48 +0100 Subject: [PATCH 04/12] feat(fe-07): add skeleton loader components --- frontend/cntr/Skeletons/MemberRowSkeleton.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 frontend/cntr/Skeletons/MemberRowSkeleton.tsx diff --git a/frontend/cntr/Skeletons/MemberRowSkeleton.tsx b/frontend/cntr/Skeletons/MemberRowSkeleton.tsx new file mode 100644 index 0000000..e9962e9 --- /dev/null +++ b/frontend/cntr/Skeletons/MemberRowSkeleton.tsx @@ -0,0 +1,11 @@ +import React from 'react'; +export const MemberRowSkeleton: React.FC = () => ( +
+
+
+
+
+
+
+
+); \ No newline at end of file From 770f9972ab869ae71e2150a3434678ec14ab08e1 Mon Sep 17 00:00:00 2001 From: RUKAYAT-CODER Date: Sat, 30 May 2026 11:25:49 +0100 Subject: [PATCH 05/12] feat(fe-07): add skeleton loader components --- frontend/cntr/Skeletons/InvoiceRowSkeleton.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 frontend/cntr/Skeletons/InvoiceRowSkeleton.tsx diff --git a/frontend/cntr/Skeletons/InvoiceRowSkeleton.tsx b/frontend/cntr/Skeletons/InvoiceRowSkeleton.tsx new file mode 100644 index 0000000..fbb0d35 --- /dev/null +++ b/frontend/cntr/Skeletons/InvoiceRowSkeleton.tsx @@ -0,0 +1,9 @@ +import React from 'react'; +export const InvoiceRowSkeleton: React.FC = () => ( +
+
+
+
+
+
+); \ No newline at end of file From 9e69c54d4c239e296c107c5e88e06ed46c36b549 Mon Sep 17 00:00:00 2001 From: RUKAYAT-CODER Date: Sat, 30 May 2026 11:26:07 +0100 Subject: [PATCH 06/12] feat(fe-09): add ErrorBoundary with fallback UI and retry --- frontend/cntr/ErrorBoundary/ErrorBoundary.tsx | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 frontend/cntr/ErrorBoundary/ErrorBoundary.tsx diff --git a/frontend/cntr/ErrorBoundary/ErrorBoundary.tsx b/frontend/cntr/ErrorBoundary/ErrorBoundary.tsx new file mode 100644 index 0000000..d8c7841 --- /dev/null +++ b/frontend/cntr/ErrorBoundary/ErrorBoundary.tsx @@ -0,0 +1,43 @@ +import React from 'react'; + +interface Props { + children: React.ReactNode; + fallback?: React.ReactNode; +} + +interface State { + hasError: boolean; +} + +export class ErrorBoundary extends React.Component { + state: State = { hasError: false }; + + static getDerivedStateFromError(): State { + return { hasError: true }; + } + + componentDidCatch(error: Error, info: React.ErrorInfo): void { + console.error('[ErrorBoundary]', error, info); + } + + reset = () => this.setState({ hasError: false }); + + render() { + if (this.state.hasError) { + if (this.props.fallback) return this.props.fallback; + return ( +
+

Something went wrong

+

An unexpected error occurred. Please try again.

+ +
+ ); + } + return this.props.children; + } +} \ No newline at end of file From 88c0ec8966d3997ca970b836681a26a22b3a1bde Mon Sep 17 00:00:00 2001 From: RUKAYAT-CODER Date: Sat, 30 May 2026 11:26:08 +0100 Subject: [PATCH 07/12] test(fe-09): add ErrorBoundary tests --- .../cntr/ErrorBoundary/ErrorBoundary.test.tsx | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 frontend/cntr/ErrorBoundary/ErrorBoundary.test.tsx diff --git a/frontend/cntr/ErrorBoundary/ErrorBoundary.test.tsx b/frontend/cntr/ErrorBoundary/ErrorBoundary.test.tsx new file mode 100644 index 0000000..44f7831 --- /dev/null +++ b/frontend/cntr/ErrorBoundary/ErrorBoundary.test.tsx @@ -0,0 +1,33 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ErrorBoundary } from './ErrorBoundary'; + +const Bomb = ({ shouldThrow }: { shouldThrow: boolean }) => { + if (shouldThrow) throw new Error('Test error'); + return
OK
; +}; + +beforeEach(() => { vi.spyOn(console, 'error').mockImplementation(() => {}); }); + +describe('ErrorBoundary', () => { + it('renders children when no error', () => { + render(); + expect(screen.getByText('OK')).toBeInTheDocument(); + }); + + it('renders default fallback UI on error', () => { + render(); + expect(screen.getByText(/something went wrong/i)).toBeInTheDocument(); + }); + + it('renders custom fallback when provided', () => { + render(Custom error
}>); + expect(screen.getByText('Custom error')).toBeInTheDocument(); + }); + + it('resets on Try again click', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: /try again/i })); + expect(screen.queryByText(/something went wrong/i)).not.toBeInTheDocument(); + }); +}); \ No newline at end of file From f325df2d9f6815a092fb6c97e6b4d04cb9be0e30 Mon Sep 17 00:00:00 2001 From: RUKAYAT-CODER Date: Sat, 30 May 2026 11:26:26 +0100 Subject: [PATCH 08/12] feat(fe-10): add PasswordStrengthMeter component --- .../PasswordStrengthMeter.tsx | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 frontend/cntr/PasswordStrengthMeter/PasswordStrengthMeter.tsx diff --git a/frontend/cntr/PasswordStrengthMeter/PasswordStrengthMeter.tsx b/frontend/cntr/PasswordStrengthMeter/PasswordStrengthMeter.tsx new file mode 100644 index 0000000..c058637 --- /dev/null +++ b/frontend/cntr/PasswordStrengthMeter/PasswordStrengthMeter.tsx @@ -0,0 +1,43 @@ +import React from 'react'; + +interface Props { password: string; } + +const LEVELS = [ + { label: 'Weak', colour: 'bg-red-500' }, + { label: 'Fair', colour: 'bg-orange-400' }, + { label: 'Good', colour: 'bg-yellow-400' }, + { label: 'Strong', colour: 'bg-green-500' }, +]; + +function getScore(password: string): number { + let score = 0; + if (password.length >= 8) score++; + if (/[A-Z]/.test(password)) score++; + if (/[a-z]/.test(password)) score++; + if (/[0-9]/.test(password)) score++; + if (/[^A-Za-z0-9]/.test(password)) score++; + if (score <= 1) return 0; + if (score === 2) return 1; + if (score === 3) return 2; + return 3; +} + +export const PasswordStrengthMeter: React.FC = ({ password }) => { + if (!password) return null; + const level = getScore(password); + const { label, colour } = LEVELS[level]; + + return ( +
+
+ {LEVELS.map((l, i) => ( +
+ ))} +
+

{label}

+
+ ); +}; \ No newline at end of file From f3d3fa93717714fc6ad5cff66ddc239ab8576ea9 Mon Sep 17 00:00:00 2001 From: RUKAYAT-CODER Date: Sat, 30 May 2026 11:26:27 +0100 Subject: [PATCH 09/12] test(fe-10): add PasswordStrengthMeter tests --- .../PasswordStrengthMeter.test.tsx | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 frontend/cntr/PasswordStrengthMeter/PasswordStrengthMeter.test.tsx diff --git a/frontend/cntr/PasswordStrengthMeter/PasswordStrengthMeter.test.tsx b/frontend/cntr/PasswordStrengthMeter/PasswordStrengthMeter.test.tsx new file mode 100644 index 0000000..e29e70a --- /dev/null +++ b/frontend/cntr/PasswordStrengthMeter/PasswordStrengthMeter.test.tsx @@ -0,0 +1,30 @@ +import { render, screen } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import { PasswordStrengthMeter } from './PasswordStrengthMeter'; + +describe('PasswordStrengthMeter', () => { + it('renders nothing for empty password', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('shows Weak for short simple password', () => { + render(); + expect(screen.getByText('Weak')).toBeInTheDocument(); + }); + + it('shows Fair for moderate password', () => { + render(); + expect(screen.getByText(/fair|good|strong/i)).toBeInTheDocument(); + }); + + it('shows Strong for complex password', () => { + render(); + expect(screen.getByText('Strong')).toBeInTheDocument(); + }); + + it('renders 4 segments', () => { + const { container } = render(); + expect(container.querySelectorAll('.flex-1').length).toBe(4); + }); +}); \ No newline at end of file From 4f06f58fd34dcf509daf7338ec3df9a021ade6fb Mon Sep 17 00:00:00 2001 From: RUKAYAT-CODER Date: Sat, 30 May 2026 11:26:50 +0100 Subject: [PATCH 10/12] feat(fe-14): add useSessionTimer hook --- .../SessionTimeoutModal/useSessionTimer.ts | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 frontend/cntr/SessionTimeoutModal/useSessionTimer.ts diff --git a/frontend/cntr/SessionTimeoutModal/useSessionTimer.ts b/frontend/cntr/SessionTimeoutModal/useSessionTimer.ts new file mode 100644 index 0000000..72c004c --- /dev/null +++ b/frontend/cntr/SessionTimeoutModal/useSessionTimer.ts @@ -0,0 +1,22 @@ +import { useEffect, useState } from 'react'; + +const WARN_BEFORE_SECONDS = 5 * 60; // 5 minutes + +export function useSessionTimer(expiresAt: number | null): { secondsLeft: number; showWarning: boolean } { + const [secondsLeft, setSecondsLeft] = useState(0); + + useEffect(() => { + if (!expiresAt) return; + + const tick = () => { + const remaining = Math.max(0, Math.floor((expiresAt - Date.now()) / 1000)); + setSecondsLeft(remaining); + }; + + tick(); + const id = setInterval(tick, 1000); + return () => clearInterval(id); + }, [expiresAt]); + + return { secondsLeft, showWarning: secondsLeft > 0 && secondsLeft <= WARN_BEFORE_SECONDS }; +} \ No newline at end of file From cd2e8bd2098a62940098d39b4b3906b88f19da17 Mon Sep 17 00:00:00 2001 From: RUKAYAT-CODER Date: Sat, 30 May 2026 11:26:51 +0100 Subject: [PATCH 11/12] feat(fe-14): add SessionTimeoutModal component --- .../SessionTimeoutModal.tsx | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 frontend/cntr/SessionTimeoutModal/SessionTimeoutModal.tsx diff --git a/frontend/cntr/SessionTimeoutModal/SessionTimeoutModal.tsx b/frontend/cntr/SessionTimeoutModal/SessionTimeoutModal.tsx new file mode 100644 index 0000000..cff8d21 --- /dev/null +++ b/frontend/cntr/SessionTimeoutModal/SessionTimeoutModal.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { useSessionTimer } from './useSessionTimer'; + +interface Props { + expiresAt: number | null; + onExtend: () => void; + onSignOut: () => void; +} + +export const SessionTimeoutModal: React.FC = ({ expiresAt, onExtend, onSignOut }) => { + const { secondsLeft, showWarning } = useSessionTimer(expiresAt); + + if (!showWarning) return null; + + const mins = Math.floor(secondsLeft / 60); + const secs = secondsLeft % 60; + const display = `${mins}:${String(secs).padStart(2, '0')}`; + + return ( +
+
+

Session expiring soon

+

Your session will expire in

+

{display}

+
+ + +
+
+
+ ); +}; \ No newline at end of file From a9487bbc97e05dbd64f8c9b5a6aaaef329cfe381 Mon Sep 17 00:00:00 2001 From: RUKAYAT-CODER Date: Sat, 30 May 2026 11:26:52 +0100 Subject: [PATCH 12/12] test(fe-14): add SessionTimeoutModal tests --- .../SessionTimeoutModal.test.tsx | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 frontend/cntr/SessionTimeoutModal/SessionTimeoutModal.test.tsx diff --git a/frontend/cntr/SessionTimeoutModal/SessionTimeoutModal.test.tsx b/frontend/cntr/SessionTimeoutModal/SessionTimeoutModal.test.tsx new file mode 100644 index 0000000..3aa2302 --- /dev/null +++ b/frontend/cntr/SessionTimeoutModal/SessionTimeoutModal.test.tsx @@ -0,0 +1,50 @@ +import { render, screen, act } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { SessionTimeoutModal } from './SessionTimeoutModal'; + +beforeEach(() => { vi.useFakeTimers(); }); +afterEach(() => { vi.useRealTimers(); }); + +describe('SessionTimeoutModal', () => { + it('does not render when expiresAt is null', () => { + render(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('does not render when more than 5 minutes remain', () => { + const expiresAt = Date.now() + 10 * 60 * 1000; + render(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('renders modal when ≤ 5 minutes remain', () => { + const expiresAt = Date.now() + 3 * 60 * 1000; + render(); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + it('calls onExtend when Stay signed in clicked', () => { + const onExtend = vi.fn(); + const expiresAt = Date.now() + 2 * 60 * 1000; + render(); + screen.getByText('Stay signed in').click(); + expect(onExtend).toHaveBeenCalledTimes(1); + }); + + it('calls onSignOut when Sign out clicked', () => { + const onSignOut = vi.fn(); + const expiresAt = Date.now() + 2 * 60 * 1000; + render(); + screen.getByText('Sign out').click(); + expect(onSignOut).toHaveBeenCalledTimes(1); + }); + + it('countdown decrements every second', () => { + const expiresAt = Date.now() + 2 * 60 * 1000; // 2 min + render(); + const before = screen.getByText(/^\d+:\d+$/).textContent; + act(() => { vi.advanceTimersByTime(1000); }); + const after = screen.getByText(/^\d+:\d+$/).textContent; + expect(before).not.toBe(after); + }); +}); \ No newline at end of file