From b56cbf600d1a3d04c2805bc27f62f900ac7a0d7f Mon Sep 17 00:00:00 2001 From: mikewheeleer Date: Thu, 30 Jul 2026 08:54:19 +0530 Subject: [PATCH] feat: add a recently-viewed commitments rail to the detail page There's no "My Commitments" list page in this repo currently (it was among the ~2,500 files deleted in a prior incident and hasn't been rebuilt), so this wires the feature into the commitment detail page instead -- the one real, existing page in the commitments domain. - Generalizes useRecentlyViewed to accept an optional storageKey (defaulting to the existing marketplace key, so its current behavior/tests are unchanged) instead of duplicating the hook for a second domain. - Adds RECENTLY_VIEWED_COMMITMENTS_KEY and a small RecentlyViewedCommitmentsRail presentational component. - The detail page now records each view and renders a rail of other recently-viewed commitments (excluding the current one), linking to their own detail pages. Renders nothing when there's nothing to show. Note: this page currently doesn't build end-to-end (pre-existing, unrelated missing modules -- @/lib/clientEnv, @/components/ErrorBoundary, etc., part of the same deletion incident). Confirmed via git stash A/B comparison that this diff adds zero new build errors and zero new test failures. Closes #968 --- src/app/commitments/[id]/page.tsx | 23 ++++++++- .../RecentlyViewedCommitmentsRail.test.tsx | 48 +++++++++++++++++ .../RecentlyViewedCommitmentsRail.tsx | 51 +++++++++++++++++++ src/hooks/useRecentlyViewed.test.ts | 30 ++++++++++- src/hooks/useRecentlyViewed.ts | 31 +++++++---- 5 files changed, 170 insertions(+), 13 deletions(-) create mode 100644 src/components/RecentlyViewedCommitmentsRail.test.tsx create mode 100644 src/components/RecentlyViewedCommitmentsRail.tsx diff --git a/src/app/commitments/[id]/page.tsx b/src/app/commitments/[id]/page.tsx index 8f375f27e..3286d47ad 100644 --- a/src/app/commitments/[id]/page.tsx +++ b/src/app/commitments/[id]/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import React, { useCallback, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { notFound, useRouter } from 'next/navigation'; import CommitmentDetailHeader from '@/components/Commitmentdetailheader'; import CommitmentHealthMetrics from '@/components/dashboard/CommitmentHealthMetrics'; @@ -20,6 +20,8 @@ import { CommitmentStatusProvider, useCommitmentStatus } from '@/context/Commitm import { useShareLink } from '@/hooks/useShareLink'; import { useToast } from '@/components/toast/ToastProvider'; import { getAppExplorerNetwork } from './explorerNetwork'; +import { useRecentlyViewed, RECENTLY_VIEWED_COMMITMENTS_KEY } from '@/hooks/useRecentlyViewed'; +import { RecentlyViewedCommitmentsRail } from '@/components/RecentlyViewedCommitmentsRail'; // Mock Commitments const MOCK_COMMITMENTS: Record< @@ -168,6 +170,23 @@ export default function CommitmentDetailPage({ const attestationsRef = useRef(null); const { success: showSuccess, error: showError } = useToast(); + const { recentIds, addView } = useRecentlyViewed(5, RECENTLY_VIEWED_COMMITMENTS_KEY); + + useEffect(() => { + addView(commitment.id); + // Only record a view when the viewed commitment id changes -- `addView` + // is stable across renders but is intentionally omitted here since + // including it would re-run this on every render of the hook's own + // setState (it's recreated whenever `recentIds` changes). + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [commitment.id]); + + const recentlyViewedEntries = recentIds + .filter((id) => id !== commitment.id) + .map((id) => getCommitmentById(id)) + .filter((c): c is NonNullable => c !== null) + .map((c) => ({ id: c.id, type: c.type, durationDays: c.duration })); + const handleCopy = async (text: string, label: string) => { if (navigator.clipboard && navigator.clipboard.writeText) { try { @@ -289,6 +308,8 @@ export default function CommitmentDetailPage({ onSettle={handleSettle} commitmentId={commitment.id} /> + + diff --git a/src/components/RecentlyViewedCommitmentsRail.test.tsx b/src/components/RecentlyViewedCommitmentsRail.test.tsx new file mode 100644 index 000000000..2e609a8d5 --- /dev/null +++ b/src/components/RecentlyViewedCommitmentsRail.test.tsx @@ -0,0 +1,48 @@ +/** + * @vitest-environment happy-dom + */ + +import React from 'react'; +import { afterEach, describe, expect, it } from 'vitest'; +import { cleanup, render, screen } from '@testing-library/react'; +import { RecentlyViewedCommitmentsRail } from '@/components/RecentlyViewedCommitmentsRail'; + +describe('RecentlyViewedCommitmentsRail', () => { + afterEach(() => { + cleanup(); + }); + + it('renders nothing when there are no entries', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('renders a link per entry with type and duration', () => { + render( + + ); + + const rail = screen.getByTestId('recently-viewed-commitments-rail'); + expect(rail).toBeTruthy(); + + const link2 = screen.getByText('Safe Commitment').closest('a'); + expect(link2?.getAttribute('href')).toBe('/commitments/2'); + expect(screen.getByText('30d')).toBeTruthy(); + + const link3 = screen.getByText('Aggressive Commitment').closest('a'); + expect(link3?.getAttribute('href')).toBe('/commitments/3'); + expect(screen.getByText('90d')).toBeTruthy(); + }); + + it('exposes an accessible nav label', () => { + render( + + ); + expect(screen.getByRole('navigation', { name: 'Recently viewed commitments' })).toBeTruthy(); + }); +}); diff --git a/src/components/RecentlyViewedCommitmentsRail.tsx b/src/components/RecentlyViewedCommitmentsRail.tsx new file mode 100644 index 000000000..3d71db379 --- /dev/null +++ b/src/components/RecentlyViewedCommitmentsRail.tsx @@ -0,0 +1,51 @@ +'use client'; + +import Link from 'next/link'; +import { History } from 'lucide-react'; + +export interface RecentlyViewedCommitmentEntry { + id: string; + type: string; + durationDays: number; +} + +export interface RecentlyViewedCommitmentsRailProps { + entries: RecentlyViewedCommitmentEntry[]; +} + +/** + * Sidebar rail listing other commitments the user has recently viewed, + * excluding the one currently on screen. Renders nothing when there are no + * other entries to show, so it never adds an empty section to the page. + */ +export function RecentlyViewedCommitmentsRail({ entries }: RecentlyViewedCommitmentsRailProps) { + if (entries.length === 0) return null; + + return ( + + ); +} + +export default RecentlyViewedCommitmentsRail; diff --git a/src/hooks/useRecentlyViewed.test.ts b/src/hooks/useRecentlyViewed.test.ts index 59dec3897..76e750554 100644 --- a/src/hooks/useRecentlyViewed.test.ts +++ b/src/hooks/useRecentlyViewed.test.ts @@ -2,7 +2,7 @@ import { renderHook, act } from '@testing-library/react'; import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { useRecentlyViewed } from '@/hooks/useRecentlyViewed'; +import { useRecentlyViewed, RECENTLY_VIEWED_COMMITMENTS_KEY } from '@/hooks/useRecentlyViewed'; describe('useRecentlyViewed', () => { beforeEach(() => { @@ -99,4 +99,32 @@ describe('useRecentlyViewed', () => { expect(result.current.recentIds).toHaveLength(0); expect(localStorage.getItem('marketplace-recently-viewed')).toBe('[]'); }); + + it('tracks a custom storage key independently of the default marketplace key', async () => { + localStorage.setItem('marketplace-recently-viewed', JSON.stringify(['listing-1'])); + + const { result } = renderHook(() => + useRecentlyViewed(5, RECENTLY_VIEWED_COMMITMENTS_KEY) + ); + + await vi.waitFor(() => { + expect(result.current.isHydrated).toBe(true); + }); + + // Unaffected by the unrelated marketplace key already in storage. + expect(result.current.recentIds).toEqual([]); + + act(() => { + result.current.addView('commitment-1'); + }); + + expect(result.current.recentIds).toEqual(['commitment-1']); + expect(localStorage.getItem(RECENTLY_VIEWED_COMMITMENTS_KEY)).toBe( + JSON.stringify(['commitment-1']) + ); + // The unrelated marketplace key is untouched. + expect(localStorage.getItem('marketplace-recently-viewed')).toBe( + JSON.stringify(['listing-1']) + ); + }); }); diff --git a/src/hooks/useRecentlyViewed.ts b/src/hooks/useRecentlyViewed.ts index e27d7a8ff..687b5f681 100644 --- a/src/hooks/useRecentlyViewed.ts +++ b/src/hooks/useRecentlyViewed.ts @@ -3,43 +3,52 @@ import { useCallback, useEffect, useState } from 'react'; export const MAX_RECENT_LISTINGS = 10; -const STORAGE_KEY = 'marketplace-recently-viewed'; +const DEFAULT_STORAGE_KEY = 'marketplace-recently-viewed'; -function readStoredRecentIds(): string[] { +/** Storage key for the "recently viewed commitments" rail on the commitment detail page. */ +export const RECENTLY_VIEWED_COMMITMENTS_KEY = 'commitments-recently-viewed'; + +function readStoredRecentIds(storageKey: string, cap: number): string[] { if (typeof window === 'undefined') return []; try { - const raw = localStorage.getItem(STORAGE_KEY); + const raw = localStorage.getItem(storageKey); if (!raw) return []; const parsed = JSON.parse(raw); if (!Array.isArray(parsed)) return []; - return parsed.filter((item): item is string => typeof item === 'string').slice(0, MAX_RECENT_LISTINGS); + return parsed.filter((item): item is string => typeof item === 'string').slice(0, cap); } catch { return []; } } -function writeStoredRecentIds(ids: string[]): void { +function writeStoredRecentIds(storageKey: string, ids: string[]): void { if (typeof window === 'undefined') return; try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(ids)); + localStorage.setItem(storageKey, JSON.stringify(ids)); } catch { // Ignore quota/privacy errors } } -export function useRecentlyViewed(cap = MAX_RECENT_LISTINGS) { +/** + * Tracks the most recently viewed item ids (marketplace listings by default; + * pass a different `storageKey` -- e.g. `RECENTLY_VIEWED_COMMITMENTS_KEY` -- + * to track a different domain of ids independently). + */ +export function useRecentlyViewed(cap = MAX_RECENT_LISTINGS, storageKey = DEFAULT_STORAGE_KEY) { const [recentIds, setRecentIds] = useState([]); const [isHydrated, setIsHydrated] = useState(false); useEffect(() => { - setRecentIds(readStoredRecentIds()); + setRecentIds(readStoredRecentIds(storageKey, cap)); setIsHydrated(true); - }, []); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [storageKey]); useEffect(() => { if (!isHydrated) return; - writeStoredRecentIds(recentIds); - }, [recentIds, isHydrated]); + writeStoredRecentIds(storageKey, recentIds); + }, [recentIds, isHydrated, storageKey]); const addView = useCallback( (id: string) => {