From 8b0c2a4403c0fdb568816be3573d367ecfc06366 Mon Sep 17 00:00:00 2001 From: Brenden Manquen Date: Thu, 15 Jan 2026 22:34:43 -0600 Subject: [PATCH 1/5] feat(ui,hooks): add recently used version to the version picker - filter out the recently used versions in the useFilterVersions hook to remove them from the main list so they don't show twice. - add top 3 most recently clicked on bible versions and store them in localStorage. - allow both lists to be filtered through the searchQuery. --- packages/hooks/src/useFilteredVersions.ts | 9 +- .../src/components/bible-version-picker.tsx | 137 +++++++++++++++--- 2 files changed, 125 insertions(+), 21 deletions(-) diff --git a/packages/hooks/src/useFilteredVersions.ts b/packages/hooks/src/useFilteredVersions.ts index e6013e8..84c040b 100644 --- a/packages/hooks/src/useFilteredVersions.ts +++ b/packages/hooks/src/useFilteredVersions.ts @@ -11,6 +11,7 @@ export function useFilteredVersions( versions: BibleVersion[], searchTerm: string, selectedLanguage: string, + recentVersions?: Pick[], ): BibleVersion[] { return useMemo(() => { let result = [...versions]; @@ -33,6 +34,12 @@ export function useFilteredVersions( ); } + // Recently Used Filter + if (recentVersions) { + const recentVersionIds = recentVersions.map((version) => version.id); + result = result.filter((version) => !recentVersionIds.includes(version.id)); + } + return result; - }, [versions, searchTerm, selectedLanguage]); + }, [versions, recentVersions, searchTerm, selectedLanguage]); } diff --git a/packages/ui/src/components/bible-version-picker.tsx b/packages/ui/src/components/bible-version-picker.tsx index b42cacb..17f6c8d 100644 --- a/packages/ui/src/components/bible-version-picker.tsx +++ b/packages/ui/src/components/bible-version-picker.tsx @@ -11,18 +11,41 @@ import { ArrowLeft, Globe, Search } from 'lucide-react'; import { createContext, type ReactNode, + useCallback, useContext, useEffect, useMemo, useRef, useState, } from 'react'; + +const RECENT_VERSIONS_KEY = 'youversion-platform:picker:recent-versions'; +const MAX_RECENT_VERSIONS = 3; + +type RecentVersion = Pick; + +function getRecentVersions(): RecentVersion[] { + if (typeof window === 'undefined') return []; + try { + const stored = localStorage.getItem(RECENT_VERSIONS_KEY); + const recentVersions: RecentVersion[] = stored ? (JSON.parse(stored) as RecentVersion[]) : []; + return recentVersions; + } catch { + return []; + } +} + +function saveRecentVersions(versions: RecentVersion[]): void { + if (typeof window === 'undefined') return; + localStorage.setItem(RECENT_VERSIONS_KEY, JSON.stringify(versions)); +} + import { cn } from '@/lib/utils'; import { Badge } from './ui/badge'; import { Button } from './ui/button'; -import { Input } from './ui/input'; import { Item, ItemContent, ItemDescription, ItemGroup, ItemMedia, ItemTitle } from './ui/item'; import { Popover, PopoverContent, PopoverTrigger } from './ui/popover'; +import { InputGroup, InputGroupInput, InputGroupAddon } from './ui/input-group'; // Displays a version abbreviation (e.g., "NIV", "KJV2") centered within a fixed-size icon. // Dynamically scales the font size to fit the text within the container with padding. @@ -124,6 +147,8 @@ type BibleVersionPickerContextType = { filteredVersions: BibleVersion[]; isLanguagesOpen: boolean; setIsLanguagesOpen: (open: boolean) => void; + recentVersions: RecentVersion[]; + addRecentVersion: (version: RecentVersion) => void; }; const BibleVersionPickerContext = createContext(null); @@ -163,6 +188,16 @@ function Root({ const [selectedLanguageId, setSelectedLanguageId] = useState('en'); const [searchQuery, setSearchQuery] = useState(''); const [isLanguagesOpen, setIsLanguagesOpen] = useState(false); + const [recentVersions, setRecentVersions] = useState(getRecentVersions); + + const addRecentVersion = useCallback((version: RecentVersion) => { + setRecentVersions((prev) => { + const filtered = prev.filter((v) => v.id !== version.id); + const updated = [version, ...filtered].slice(0, MAX_RECENT_VERSIONS); + saveRecentVersions(updated); + return updated; + }); + }, []); // Fetch languages from hook with default country 'US' const { languages: hookLanguages } = useLanguages({ country: 'US' }); @@ -198,6 +233,7 @@ function Root({ versions?.data || [], searchQuery, selectedLanguageId, + recentVersions, ); const contextValue: BibleVersionPickerContextType = { @@ -213,6 +249,8 @@ function Root({ filteredVersions, isLanguagesOpen, setIsLanguagesOpen, + recentVersions, + addRecentVersion, }; return ( @@ -265,18 +303,35 @@ function Content() { languages, selectedLanguageId, setSelectedLanguageId, + recentVersions, + addRecentVersion, } = useBibleVersionPickerContext(); const providerTheme = useTheme(); const theme = background || providerTheme; const closeRef = useRef(null); + const filteredRecentVersions = useMemo(() => { + if (!searchQuery.trim()) return recentVersions; + const query = searchQuery.trim().toLowerCase(); + return recentVersions.filter( + (v) => + v.title.toLowerCase().includes(query) || + v.localized_abbreviation.toLowerCase().includes(query), + ); + }, [recentVersions, searchQuery]); + const handleSelectLanguage = (languageId: string) => { setSelectedLanguageId(languageId); setIsLanguagesOpen(false); }; - const handleSelectVersion = (versionId: number) => { - setVersionId(versionId); + const handleSelectVersion = (version: BibleVersion | RecentVersion) => { + setVersionId(version.id); + addRecentVersion({ + id: version.id, + title: version.title, + localized_abbreviation: version.localized_abbreviation, + }); setIsLanguagesOpen(false); closeRef.current?.click(); }; @@ -315,15 +370,59 @@ function Content() { > {/* Versions View */}
+ {/* Recent Versions */} + {filteredRecentVersions.length > 0 && ( + <> +

+ Recently Used Versions +

+ + {filteredRecentVersions.map((version) => ( + + + + ))} + + + )} + {/* All Versions */} {filteredVersions && filteredVersions.length > 0 ? ( +

All Versions

{filteredVersions.map((version: BibleVersion) => ( handleSelectVersion(version.id)} + onClick={() => handleSelectVersion(version)} > ))}
- ) : ( + ) : null} + {!filteredVersions.length && !filteredRecentVersions.length ? (
No versions found
- )} + ) : null}
- -
-
- - + + setSearchQuery(e.target.value)} - /> -
+ > + + + +
From cf186a46c33cb68e8d9ea1036942c2bfb5bf4064 Mon Sep 17 00:00:00 2001 From: Brenden Manquen Date: Fri, 16 Jan 2026 12:33:50 -0600 Subject: [PATCH 2/5] test(ui): add storybook tests for recently updated versions in version picker --- .../bible-version-picker.stories.tsx | 158 +++- .../src/components/bible-version-picker.tsx | 2 +- packages/ui/src/test/mock-data/bibles.json | 726 +++++++++++++++++- 3 files changed, 878 insertions(+), 8 deletions(-) diff --git a/packages/ui/src/components/bible-version-picker.stories.tsx b/packages/ui/src/components/bible-version-picker.stories.tsx index 29d5880..202ceff 100644 --- a/packages/ui/src/components/bible-version-picker.stories.tsx +++ b/packages/ui/src/components/bible-version-picker.stories.tsx @@ -1,10 +1,22 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { BibleVersionPicker, type RootProps } from './bible-version-picker'; import { useState } from 'react'; -import { screen, userEvent, within, expect } from 'storybook/test'; +import { screen, userEvent, within, expect, waitFor } from 'storybook/test'; import { BookOpen } from 'lucide-react'; import { Button } from './ui/button'; +const RECENT_VERSIONS_KEY = 'youversion-platform:picker:recent-versions'; + +type StoredRecentVersion = { + id: number; + title: string; + localized_abbreviation: string; +}; + +function getStoredRecentVersions(): StoredRecentVersion[] { + return JSON.parse(localStorage.getItem(RECENT_VERSIONS_KEY) || '[]') as StoredRecentVersion[]; +} + const withLayout = (Story: React.ComponentType) => (
@@ -28,6 +40,9 @@ const meta = { layout: 'fullscreen', }, decorators: [withLayout], + beforeEach: () => { + localStorage.removeItem(RECENT_VERSIONS_KEY); + }, argTypes: { versionId: { control: 'number', @@ -174,3 +189,144 @@ export const RealAPI: Story = { }, }, }; + +export const RecentVersionsSelection: Story = { + args: { + versionId: 111, + background: 'light', + }, + tags: ['integration'], + play: async () => { + // Open popover + const trigger = await screen.findByRole('button', { name: /NIV/i }, { timeout: 10_000 }); + await userEvent.click(trigger); + + // Verify initially there's no recent versions section + const dialog = await screen.findByRole('dialog'); + await expect(dialog).toBeInTheDocument(); + await expect(screen.queryByText('Recently Used Versions')).not.toBeInTheDocument(); + + // Select a different version (Amplified Bible) + const ampOption = await screen.findByRole('listitem', { name: /Amplified Bible/i }); + await userEvent.click(ampOption); + + // Reopen the popover and verify the recent versions section now appears + const updatedTrigger = await screen.findByRole('button', { name: /AMP/i }); + await expect(updatedTrigger).toBeInTheDocument(); + await userEvent.click(updatedTrigger); + + await expect(await screen.findByText('Recently Used Versions')).toBeInTheDocument(); + const recentVersionList = await screen.findByTestId('recent-version-list'); + await expect(within(recentVersionList).getByText(/Amplified Bible/i)).toBeInTheDocument(); + + // Verify localStorage was updated + let storedVersions = getStoredRecentVersions(); + await expect(storedVersions.length).toBe(1); + await expect(storedVersions[0].title).toContain('Amplified'); + + // Now select NIV from the main list + const nivOption = await screen.findByRole('listitem', { + name: /New International Version 2011/i, + }); + await userEvent.click(nivOption); + + // Reopen and select AMP from recent versions + const nivTrigger = await screen.findByRole('button', { name: /NIV/i }); + await expect(nivTrigger).toBeInTheDocument(); + await userEvent.click(nivTrigger); + + const recentList = await screen.findByTestId('recent-version-list'); + const ampRecentOption = within(recentList).getByRole('listitem', { + name: /Amplified Bible/i, + }); + await userEvent.click(ampRecentOption); + + // Verify AMP is selected and moved to top of recent versions + const ampTrigger = await screen.findByRole('button', { name: /AMP/i }); + await expect(ampTrigger).toBeInTheDocument(); + + storedVersions = getStoredRecentVersions(); + await expect(storedVersions[0].localized_abbreviation).toBe('AMP'); + }, +}; + +export const RecentVersionsSearchFilter: Story = { + args: { + versionId: 111, + background: 'light', + }, + tags: ['integration'], + beforeEach: () => { + // Pre-populate localStorage with recent versions before component mounts + localStorage.setItem( + RECENT_VERSIONS_KEY, + JSON.stringify([ + { id: 1588, title: 'Amplified Bible', localized_abbreviation: 'AMP' }, + { id: 12, title: 'American Standard Version', localized_abbreviation: 'ASV' }, + ]), + ); + }, + play: async () => { + // Open popover + const trigger = await screen.findByRole('button', { name: /NIV/i }, { timeout: 10_000 }); + await userEvent.click(trigger); + + // Verify recent versions are displayed + await expect(await screen.findByText('Recently Used Versions')).toBeInTheDocument(); + const recentVersionList = await screen.findByTestId('recent-version-list'); + await expect(within(recentVersionList).getByText(/Amplified Bible/i)).toBeInTheDocument(); + await expect( + within(recentVersionList).getByText(/American Standard Version/i), + ).toBeInTheDocument(); + + // Search for "ASV" + const searchInput = screen.getByPlaceholderText('Search'); + await userEvent.type(searchInput, 'ASV', { delay: 50 }); + + // Verify only ASV appears in recent versions after filtering + await waitFor(async () => { + await expect(screen.queryByText(/Amplified Bible/i)).not.toBeInTheDocument(); + }); + await expect( + within(await screen.findByTestId('recent-version-list')).getByText( + /American Standard Version/i, + ), + ).toBeInTheDocument(); + }, +}; + +export const RecentVersionsMaxLimit: Story = { + args: { + versionId: 111, + background: 'light', + }, + tags: ['integration'], + beforeEach: () => { + // Pre-populate localStorage with 3 recent versions (max limit) before component mounts + localStorage.setItem( + RECENT_VERSIONS_KEY, + JSON.stringify([ + { id: 1588, title: 'Amplified Bible', localized_abbreviation: 'AMP' }, + { id: 100, title: 'New American Standard Bible 1995', localized_abbreviation: 'NASB1995' }, + { id: 12, title: 'American Standard Version', localized_abbreviation: 'ASV' }, + ]), + ); + }, + play: async () => { + // Open popover + const trigger = await screen.findByRole('button', { name: /NIV/i }, { timeout: 10_000 }); + await userEvent.click(trigger); + + // Select a new version (NASB2020) - this should push out ASV + const nasbOption = await screen.findByRole('listitem', { + name: /New American Standard Bible 2020/i, + }); + await userEvent.click(nasbOption); + + // Verify localStorage was updated with max 3 versions, NASB2020 at the top + const storedVersions = getStoredRecentVersions(); + await expect(storedVersions.length).toBe(3); + await expect(storedVersions[0].title).toContain('New American Standard Bible 2020'); + await expect(storedVersions.map((v) => v.localized_abbreviation)).not.toContain('ASV'); + }, +}; diff --git a/packages/ui/src/components/bible-version-picker.tsx b/packages/ui/src/components/bible-version-picker.tsx index 17f6c8d..2e0bf0d 100644 --- a/packages/ui/src/components/bible-version-picker.tsx +++ b/packages/ui/src/components/bible-version-picker.tsx @@ -468,7 +468,7 @@ function Content() { setSearchQuery(e.target.value)} > diff --git a/packages/ui/src/test/mock-data/bibles.json b/packages/ui/src/test/mock-data/bibles.json index 641aab6..f9866ce 100644 --- a/packages/ui/src/test/mock-data/bibles.json +++ b/packages/ui/src/test/mock-data/bibles.json @@ -115,33 +115,747 @@ { "id": 110, "abbreviation": "NIrV", - "copyright": "Holy Bible, New International Reader's Version®, NIrV®\nCopyright © 1995, 1996, 1998, 2014 by Biblica, Inc.®", + "promotional_content": "Biblica is a global Bible ministry inspired by radical generosity. We are motivated by the belief that God’s Word radically changes lives, and that everyone everywhere deserves to experience its truth for themselves. To that end, we pioneer ways to break down barriers to the Bible, doing whatever it takes to activate Scripture where needed most.\nNow in its third century of ministry, Biblica continues to produce relevant, reliable Scripture translations and innovative resources that power the Bible ministry of hundreds of global mission organizations. Together, we invite millions to discover the love of Jesus Christ through our three core pillars of ministry:\nGateway Translation\nWe translate God’s Word for the world’s most strategic languages, catalyzing greater reach to Bibleless language communities.\nFrontline Church\nWe equip the frontlines of Gospel ministry with Scripture resources that serve the unreached, unengaged, and unseen.\nKids in Crisis\nWe develop and deploy Bible programs that bring the love of Jesus to children and youth in the world’s hardest places.", + "copyright": "Holy Bible, New International Reader’s Version®, NIrV®\nCopyright © 1995, 1996, 1998, 2014 by Biblica, Inc.®\nUsed by permission. All rights reserved worldwide.", + "info": null, + "publisher_url": "https://www.biblica.com/yv-learn-more/", "language_tag": "en", "localized_abbreviation": "NIrV", - "localized_title": "New International Reader's Version", - "title": "New International Reader's Version 2014", + "localized_title": "New International Reader’s Version", + "title": "New International Reader’s Version 2014", + "books": [ + "GEN", + "EXO", + "LEV", + "NUM", + "DEU", + "JOS", + "JDG", + "RUT", + "1SA", + "2SA", + "1KI", + "2KI", + "1CH", + "2CH", + "EZR", + "NEH", + "EST", + "JOB", + "PSA", + "PRO", + "ECC", + "SNG", + "ISA", + "JER", + "LAM", + "EZK", + "DAN", + "HOS", + "JOL", + "AMO", + "OBA", + "JON", + "MIC", + "NAM", + "HAB", + "ZEP", + "HAG", + "ZEC", + "MAL", + "MAT", + "MRK", + "LUK", + "JHN", + "ACT", + "ROM", + "1CO", + "2CO", + "GAL", + "EPH", + "PHP", + "COL", + "1TH", + "2TH", + "1TI", + "2TI", + "TIT", + "PHM", + "HEB", + "JAS", + "1PE", + "2PE", + "1JN", + "2JN", + "3JN", + "JUD", + "REV" + ], "youversion_deep_link": "https://www.bible.com/versions/110", "organization_id": "05a9aa40-37b6-4e34-b9f1-a443fa4b1fff" }, { "id": 111, "abbreviation": "NIV11", - "copyright": "The Holy Bible, New International Version® NIV®\nCopyright © 1973, 1978, 1984, 2011 by Biblica, Inc.®", + "promotional_content": "Biblica is the worldwide publisher and translation sponsor of the New International Version—one of the most widely read contemporary English versions of the Bible.\nAt Biblica, we believe that with God, all things are possible. Partnering with other ministries and people like you, we are reaching the world with God’s Word, providing Bibles that are easier to understand and faster to receive. When God’s Word is put into someone’s hands, it has the power to change everything.\nTo learn more, visit biblica.com and facebook.com/Biblica.", + "copyright": "The Holy Bible, New International Version® NIV®\nCopyright © 1973, 1978, 1984, 2011 by Biblica, Inc.®\nUsed by Permission of Biblica, Inc.® All rights reserved worldwide.", + "info": null, + "publisher_url": "https://www.biblica.com/yv-learn-more/", "language_tag": "en", - "localized_abbreviation": "NIV2011", + "localized_abbreviation": "NIV", "localized_title": "New International Version", "title": "New International Version 2011", + "books": [ + "GEN", + "EXO", + "LEV", + "NUM", + "DEU", + "JOS", + "JDG", + "RUT", + "1SA", + "2SA", + "1KI", + "2KI", + "1CH", + "2CH", + "EZR", + "NEH", + "EST", + "JOB", + "PSA", + "PRO", + "ECC", + "SNG", + "ISA", + "JER", + "LAM", + "EZK", + "DAN", + "HOS", + "JOL", + "AMO", + "OBA", + "JON", + "MIC", + "NAM", + "HAB", + "ZEP", + "HAG", + "ZEC", + "MAL", + "MAT", + "MRK", + "LUK", + "JHN", + "ACT", + "ROM", + "1CO", + "2CO", + "GAL", + "EPH", + "PHP", + "COL", + "1TH", + "2TH", + "1TI", + "2TI", + "TIT", + "PHM", + "HEB", + "JAS", + "1PE", + "2PE", + "1JN", + "2JN", + "3JN", + "JUD", + "REV" + ], "youversion_deep_link": "https://www.bible.com/versions/111", "organization_id": "05a9aa40-37b6-4e34-b9f1-a443fa4b1fff" }, + { + "id": 113, + "abbreviation": "NIVUK11", + "promotional_content": "Biblica is the worldwide publisher and translation sponsor of the New International Version – one of the most widely read contemporary English versions of the Bible.\nAt Biblica, we believe that with God, all things are possible. Partnering with other ministries and people like you, we are reaching the world with God’s Word, providing Bibles that are easier to understand and faster to receive. When God’s Word is put into someone’s hands, it has the power to change everything.\nTo learn more, visit biblica.com and facebook.com/Biblica.", + "copyright": "The Holy Bible, New International Version® (Anglicised), NIV®\nCopyright © 1979, 1984, 2011 by Biblica, Inc.®\nUsed by permission of Biblica, Inc.® All rights reserved worldwide.", + "info": null, + "publisher_url": "https://www.biblica.com/yv-learn-more/", + "language_tag": "en", + "localized_abbreviation": "NIVUK", + "localized_title": "New International Version (Anglicised)", + "title": "New International Version (Anglicized) 2011", + "books": [ + "GEN", + "EXO", + "LEV", + "NUM", + "DEU", + "JOS", + "JDG", + "RUT", + "1SA", + "2SA", + "1KI", + "2KI", + "1CH", + "2CH", + "EZR", + "NEH", + "EST", + "JOB", + "PSA", + "PRO", + "ECC", + "SNG", + "ISA", + "JER", + "LAM", + "EZK", + "DAN", + "HOS", + "JOL", + "AMO", + "OBA", + "JON", + "MIC", + "NAM", + "HAB", + "ZEP", + "HAG", + "ZEC", + "MAL", + "MAT", + "MRK", + "LUK", + "JHN", + "ACT", + "ROM", + "1CO", + "2CO", + "GAL", + "EPH", + "PHP", + "COL", + "1TH", + "2TH", + "1TI", + "2TI", + "TIT", + "PHM", + "HEB", + "JAS", + "1PE", + "2PE", + "1JN", + "2JN", + "3JN", + "JUD", + "REV" + ], + "youversion_deep_link": "https://www.bible.com/versions/113", + "organization_id": "05a9aa40-37b6-4e34-b9f1-a443fa4b1fff" + }, { "id": 1588, "abbreviation": "AMP", + "promotional_content": "The Amplified Bible is a Literal Equivalent translation that, by using synonyms and definitions, both explains and expands the meaning of words in the text by placing amplification in parentheses, brackets, and after key words. This unique system of translation allows the reader to more completely and clearly grasp the meaning as it was understood in the original languages. Additionally, amplifications may provide further theological, historical, and other details for a better understanding of the text.", + "copyright": "Amplified® Bible\nCopyright © 2015 by\nThe Lockman Foundation, La Habra, CA 90631\nAll rights reserved. http://www.lockman.org", + "info": null, + "publisher_url": null, "language_tag": "en", "localized_abbreviation": "AMP", "localized_title": "Amplified Bible", "title": "Amplified Bible", - "organization_id": "05a9aa40-37b6-4e34-b9f1-a443fa4b1fff" + "books": [ + "GEN", + "EXO", + "LEV", + "NUM", + "DEU", + "JOS", + "JDG", + "RUT", + "1SA", + "2SA", + "1KI", + "2KI", + "1CH", + "2CH", + "EZR", + "NEH", + "EST", + "JOB", + "PSA", + "PRO", + "ECC", + "SNG", + "ISA", + "JER", + "LAM", + "EZK", + "DAN", + "HOS", + "JOL", + "AMO", + "OBA", + "JON", + "MIC", + "NAM", + "HAB", + "ZEP", + "HAG", + "ZEC", + "MAL", + "MAT", + "MRK", + "LUK", + "JHN", + "ACT", + "ROM", + "1CO", + "2CO", + "GAL", + "EPH", + "PHP", + "COL", + "1TH", + "2TH", + "1TI", + "2TI", + "TIT", + "PHM", + "HEB", + "JAS", + "1PE", + "2PE", + "1JN", + "2JN", + "3JN", + "JUD", + "REV" + ], + "youversion_deep_link": "https://www.bible.com/versions/1588", + "organization_id": "798d8fa4-f640-4155-8cfb-fa91d1d8a06c" + }, + { + "id": 100, + "abbreviation": "NASB1995", + "promotional_content": "NEW AMERICAN STANDARD BIBLE®\nCopyright © 1960, 1962, 1963, 1968, 1971, 1972, 1973, 1975, 1977, 1995 by THE LOCKMAN FOUNDATION\nA Corporation Not for Profit\nLA HABRA, CA\nAll Rights Reserved\nhttp://www.lockman.org", + "copyright": "NEW AMERICAN STANDARD BIBLE®\nCopyright © 1960, 1962, 1963, 1968, 1971, 1972, 1973, 1975, 1977, 1995 by THE LOCKMAN FOUNDATION\nA Corporation Not for Profit\nLA HABRA, CA\nAll Rights Reserved\nhttp://www.lockman.org", + "info": null, + "publisher_url": null, + "language_tag": "en", + "localized_abbreviation": "NASB1995", + "localized_title": "New American Standard Bible - NASB 1995", + "title": "New American Standard Bible 1995", + "books": [ + "GEN", + "EXO", + "LEV", + "NUM", + "DEU", + "JOS", + "JDG", + "RUT", + "1SA", + "2SA", + "1KI", + "2KI", + "1CH", + "2CH", + "EZR", + "NEH", + "EST", + "JOB", + "PSA", + "PRO", + "ECC", + "SNG", + "ISA", + "JER", + "LAM", + "EZK", + "DAN", + "HOS", + "JOL", + "AMO", + "OBA", + "JON", + "MIC", + "NAM", + "HAB", + "ZEP", + "HAG", + "ZEC", + "MAL", + "MAT", + "MRK", + "LUK", + "JHN", + "ACT", + "ROM", + "1CO", + "2CO", + "GAL", + "EPH", + "PHP", + "COL", + "1TH", + "2TH", + "1TI", + "2TI", + "TIT", + "PHM", + "HEB", + "JAS", + "1PE", + "2PE", + "1JN", + "2JN", + "3JN", + "JUD", + "REV" + ], + "youversion_deep_link": "https://www.bible.com/versions/100", + "organization_id": "798d8fa4-f640-4155-8cfb-fa91d1d8a06c" + }, + { + "id": 2692, + "abbreviation": "NASB2020", + "promotional_content": "The NASB 2020 is an update of the NASB 1995 that further improves accuracy where possible, modernizes language, and improves readability. These refinements maintain faithful accuracy to the original texts and provide a clear understanding of God’s Word to those who prefer more modern English standards. The long-established translation standard for the NASB remains the same as it always has been, that is to accurately translate the inspired Word of God from the Hebrew, Aramaic, and Greek texts into modern English that is clearly understandable today.", + "copyright": "NEW AMERICAN STANDARD BIBLE® NASB®\nCopyright © 1960, 1971, 1977,1995, 2020 by The Lockman Foundation\nA Corporation Not for Profit\nLa Habra, CA\nAll Rights Reserved\nwww.lockman.org", + "info": null, + "publisher_url": null, + "language_tag": "en", + "localized_abbreviation": "NASB2020", + "localized_title": "New American Standard Bible - NASB", + "title": "New American Standard Bible 2020", + "books": [ + "GEN", + "EXO", + "LEV", + "NUM", + "DEU", + "JOS", + "JDG", + "RUT", + "1SA", + "2SA", + "1KI", + "2KI", + "1CH", + "2CH", + "EZR", + "NEH", + "EST", + "JOB", + "PSA", + "PRO", + "ECC", + "SNG", + "ISA", + "JER", + "LAM", + "EZK", + "DAN", + "HOS", + "JOL", + "AMO", + "OBA", + "JON", + "MIC", + "NAM", + "HAB", + "ZEP", + "HAG", + "ZEC", + "MAL", + "MAT", + "MRK", + "LUK", + "JHN", + "ACT", + "ROM", + "1CO", + "2CO", + "GAL", + "EPH", + "PHP", + "COL", + "1TH", + "2TH", + "1TI", + "2TI", + "TIT", + "PHM", + "HEB", + "JAS", + "1PE", + "2PE", + "1JN", + "2JN", + "3JN", + "JUD", + "REV" + ], + "youversion_deep_link": "https://www.bible.com/versions/2692", + "organization_id": "798d8fa4-f640-4155-8cfb-fa91d1d8a06c" + }, + { + "id": 12, + "abbreviation": "ASV", + "promotional_content": null, + "copyright": null, + "info": null, + "publisher_url": null, + "language_tag": "en", + "localized_abbreviation": "ASV", + "localized_title": "American Standard Version", + "title": "American Standard Version", + "books": [ + "GEN", + "EXO", + "LEV", + "NUM", + "DEU", + "JOS", + "JDG", + "RUT", + "1SA", + "2SA", + "1KI", + "2KI", + "1CH", + "2CH", + "EZR", + "NEH", + "EST", + "JOB", + "PSA", + "PRO", + "ECC", + "SNG", + "ISA", + "JER", + "LAM", + "EZK", + "DAN", + "HOS", + "JOL", + "AMO", + "OBA", + "JON", + "MIC", + "NAM", + "HAB", + "ZEP", + "HAG", + "ZEC", + "MAL", + "MAT", + "MRK", + "LUK", + "JHN", + "ACT", + "ROM", + "1CO", + "2CO", + "GAL", + "EPH", + "PHP", + "COL", + "1TH", + "2TH", + "1TI", + "2TI", + "TIT", + "PHM", + "HEB", + "JAS", + "1PE", + "2PE", + "1JN", + "2JN", + "3JN", + "JUD", + "REV" + ], + "youversion_deep_link": "https://www.bible.com/versions/12", + "organization_id": null + }, + { + "id": 42, + "abbreviation": "CPDV", + "promotional_content": null, + "copyright": null, + "info": null, + "publisher_url": null, + "language_tag": "en", + "localized_abbreviation": "CPDV", + "localized_title": "Catholic Public Domain Version", + "title": "Catholic Public Domain Version", + "books": [ + "GEN", + "EXO", + "LEV", + "NUM", + "DEU", + "JOS", + "JDG", + "RUT", + "1SA", + "2SA", + "1KI", + "2KI", + "1CH", + "2CH", + "EZR", + "NEH", + "TOB", + "JDT", + "EST", + "JOB", + "PSA", + "PRO", + "ECC", + "SNG", + "WIS", + "SIR", + "ISA", + "JER", + "LAM", + "BAR", + "EZK", + "DAN", + "HOS", + "JOL", + "AMO", + "OBA", + "JON", + "MIC", + "NAM", + "HAB", + "ZEP", + "HAG", + "ZEC", + "MAL", + "1MA", + "2MA", + "MAT", + "MRK", + "LUK", + "JHN", + "ACT", + "ROM", + "1CO", + "2CO", + "GAL", + "EPH", + "PHP", + "COL", + "1TH", + "2TH", + "1TI", + "2TI", + "TIT", + "PHM", + "HEB", + "JAS", + "1PE", + "2PE", + "1JN", + "2JN", + "3JN", + "JUD", + "REV" + ], + "youversion_deep_link": "https://www.bible.com/versions/42", + "organization_id": null + }, + { + "id": 130, + "abbreviation": "TOJB2011", + "promotional_content": "THE ORTHODOX JEWISH TANAKH\nArtists For Israel Intl Inc.\nThis version also appears with the Orthodox Yiddish Triglot which presents the Hebrew Yiddish script in Latin script and an English word for word translation, available at afii.org/Torah\nTo donate PayPal.Me", + "copyright": "THE ORTHODOX JEWISH BIBLE\nFOURTH EDITION REVISED © Artists For Israel Intl Inc., 2002-2011, 2021, 2024.", + "info": null, + "publisher_url": null, + "language_tag": "en", + "localized_abbreviation": "TOJB2011", + "localized_title": "The Orthodox Jewish Bible", + "title": "The Orthodox Jewish Bible", + "books": [ + "GEN", + "EXO", + "LEV", + "NUM", + "DEU", + "JOS", + "JDG", + "1SA", + "2SA", + "1KI", + "2KI", + "ISA", + "JER", + "EZK", + "HOS", + "JOL", + "AMO", + "OBA", + "JON", + "MIC", + "NAM", + "HAB", + "ZEP", + "HAG", + "ZEC", + "MAL", + "PSA", + "PRO", + "JOB", + "SNG", + "RUT", + "LAM", + "ECC", + "EST", + "DAN", + "EZR", + "NEH", + "1CH", + "2CH", + "MAT", + "MRK", + "LUK", + "JHN", + "ACT", + "ROM", + "1CO", + "2CO", + "GAL", + "EPH", + "PHP", + "COL", + "1TH", + "2TH", + "1TI", + "2TI", + "TIT", + "PHM", + "HEB", + "JAS", + "1PE", + "2PE", + "1JN", + "2JN", + "3JN", + "JUD", + "REV" + ], + "youversion_deep_link": "https://www.bible.com/versions/130", + "organization_id": "f2ac19b4-768c-4564-acaf-f28724235ad0" } ], "next_page_token": null, From 6b01ea5dcdc5a31c03b518cfa9da64d568836de5 Mon Sep 17 00:00:00 2001 From: Brenden Manquen Date: Fri, 16 Jan 2026 13:07:13 -0600 Subject: [PATCH 3/5] test(hooks): add tests for recent versions in useFilteredVersions hook --- .changeset/red-ducks-accept.md | 11 ++++ .../hooks/src/useFilteredVersions.test.tsx | 58 +++++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 .changeset/red-ducks-accept.md diff --git a/.changeset/red-ducks-accept.md b/.changeset/red-ducks-accept.md new file mode 100644 index 0000000..4bc45be --- /dev/null +++ b/.changeset/red-ducks-accept.md @@ -0,0 +1,11 @@ +--- +'@youversion/platform-react-hooks': minor +'@youversion/platform-react-ui': minor +'@youversion/platform-core': minor +--- + +Add recently used versions to the Bible Version Picker + +- Display up to 3 recently selected Bible versions at the top of the picker +- Persist recent version selections in localStorage +- Recent versions are searchable and excluded from the main "All Versions" list diff --git a/packages/hooks/src/useFilteredVersions.test.tsx b/packages/hooks/src/useFilteredVersions.test.tsx index c1a132d..d0b4095 100644 --- a/packages/hooks/src/useFilteredVersions.test.tsx +++ b/packages/hooks/src/useFilteredVersions.test.tsx @@ -198,6 +198,64 @@ describe('useFilteredVersions', () => { }); }); + describe('recent versions exclusion', () => { + it('should not exclude any versions when recentVersions is undefined', () => { + const { result } = renderHook(() => useFilteredVersions(mockVersions, '', '*', undefined)); + + expect(result.current).toEqual(mockVersions); + expect(result.current).toHaveLength(5); + }); + + it('should exclude recent versions from the results', () => { + const recentVersions = [ + { id: 1, title: 'King James Version', localized_abbreviation: 'KJV' }, + ]; + + const { result } = renderHook(() => + useFilteredVersions(mockVersions, '', '*', recentVersions), + ); + + expect(result.current).toHaveLength(4); + expect(result.current.find((v) => v.id === 1)).toBeUndefined(); + }); + + it('should exclude multiple recent versions from the results', () => { + const recentVersions = [ + { id: 1, title: 'King James Version', localized_abbreviation: 'KJV' }, + { id: 2, title: 'New International Version', localized_abbreviation: 'NIV' }, + ]; + + const { result } = renderHook(() => + useFilteredVersions(mockVersions, '', '*', recentVersions), + ); + + expect(result.current).toHaveLength(3); + expect(result.current.find((v) => v.id === 1)).toBeUndefined(); + expect(result.current.find((v) => v.id === 2)).toBeUndefined(); + }); + + it('should not exclude any versions when recentVersions is empty array', () => { + const { result } = renderHook(() => useFilteredVersions(mockVersions, '', '*', [])); + + expect(result.current).toEqual(mockVersions); + expect(result.current).toHaveLength(5); + }); + + it('should exclude recent versions even when they match search term', () => { + const recentVersions = [ + { id: 2, title: 'New International Version', localized_abbreviation: 'NIV' }, + ]; + + const { result } = renderHook(() => + useFilteredVersions(mockVersions, 'Version', '*', recentVersions), + ); + + // "Version" matches KJV and NIV, but NIV is excluded as a recent version + expect(result.current).toHaveLength(1); + expect(result.current[0]?.title).toBe('King James Version'); + }); + }); + describe('memoization', () => { it('should return the same reference when inputs do not change', () => { const { result, rerender } = renderHook( From 87e71fe7f85f6ad092a5da9dd80460b7e0f60a96 Mon Sep 17 00:00:00 2001 From: Brenden Manquen Date: Fri, 16 Jan 2026 13:17:14 -0600 Subject: [PATCH 4/5] fix(ui): fix type error in test and add abbreviation to recent versions --- .../components/bible-version-picker.stories.tsx | 6 +++--- .../ui/src/components/bible-version-picker.tsx | 14 +++++++------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/ui/src/components/bible-version-picker.stories.tsx b/packages/ui/src/components/bible-version-picker.stories.tsx index 202ceff..7fcd8aa 100644 --- a/packages/ui/src/components/bible-version-picker.stories.tsx +++ b/packages/ui/src/components/bible-version-picker.stories.tsx @@ -222,7 +222,7 @@ export const RecentVersionsSelection: Story = { // Verify localStorage was updated let storedVersions = getStoredRecentVersions(); await expect(storedVersions.length).toBe(1); - await expect(storedVersions[0].title).toContain('Amplified'); + await expect(storedVersions[0]?.title).toContain('Amplified'); // Now select NIV from the main list const nivOption = await screen.findByRole('listitem', { @@ -246,7 +246,7 @@ export const RecentVersionsSelection: Story = { await expect(ampTrigger).toBeInTheDocument(); storedVersions = getStoredRecentVersions(); - await expect(storedVersions[0].localized_abbreviation).toBe('AMP'); + await expect(storedVersions[0]?.localized_abbreviation).toBe('AMP'); }, }; @@ -326,7 +326,7 @@ export const RecentVersionsMaxLimit: Story = { // Verify localStorage was updated with max 3 versions, NASB2020 at the top const storedVersions = getStoredRecentVersions(); await expect(storedVersions.length).toBe(3); - await expect(storedVersions[0].title).toContain('New American Standard Bible 2020'); + await expect(storedVersions[0]?.title).toContain('New American Standard Bible 2020'); await expect(storedVersions.map((v) => v.localized_abbreviation)).not.toContain('ASV'); }, }; diff --git a/packages/ui/src/components/bible-version-picker.tsx b/packages/ui/src/components/bible-version-picker.tsx index 2e0bf0d..654688a 100644 --- a/packages/ui/src/components/bible-version-picker.tsx +++ b/packages/ui/src/components/bible-version-picker.tsx @@ -22,7 +22,7 @@ import { const RECENT_VERSIONS_KEY = 'youversion-platform:picker:recent-versions'; const MAX_RECENT_VERSIONS = 3; -type RecentVersion = Pick; +type RecentVersion = Pick; function getRecentVersions(): RecentVersion[] { if (typeof window === 'undefined') return []; @@ -315,8 +315,9 @@ function Content() { const query = searchQuery.trim().toLowerCase(); return recentVersions.filter( (v) => - v.title.toLowerCase().includes(query) || - v.localized_abbreviation.toLowerCase().includes(query), + v.title?.toLowerCase().includes(query) || + v.localized_abbreviation?.toLowerCase().includes(query) || + v.abbreviation?.toLowerCase().includes(query), ); }, [recentVersions, searchQuery]); @@ -331,6 +332,7 @@ function Content() { id: version.id, title: version.title, localized_abbreviation: version.localized_abbreviation, + abbreviation: version.abbreviation, }); setIsLanguagesOpen(false); closeRef.current?.click(); @@ -380,9 +382,7 @@ function Content() { {/* Recent Versions */} {filteredRecentVersions.length > 0 && ( <> -

- Recently Used Versions -

+

Recently Used Versions

{filteredRecentVersions.map((version) => ( 0 ? ( -

All Versions

+

All Versions

{filteredVersions.map((version: BibleVersion) => ( Date: Fri, 16 Jan 2026 14:37:30 -0600 Subject: [PATCH 5/5] fix(ui): add max height to version picker and wrap JSON.parse in try/catch --- .../ui/src/components/bible-version-picker.stories.tsx | 9 ++++++--- packages/ui/src/components/bible-version-picker.tsx | 4 ++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/components/bible-version-picker.stories.tsx b/packages/ui/src/components/bible-version-picker.stories.tsx index 7fcd8aa..fa38f29 100644 --- a/packages/ui/src/components/bible-version-picker.stories.tsx +++ b/packages/ui/src/components/bible-version-picker.stories.tsx @@ -4,8 +4,7 @@ import { useState } from 'react'; import { screen, userEvent, within, expect, waitFor } from 'storybook/test'; import { BookOpen } from 'lucide-react'; import { Button } from './ui/button'; - -const RECENT_VERSIONS_KEY = 'youversion-platform:picker:recent-versions'; +import { RECENT_VERSIONS_KEY } from './bible-version-picker'; type StoredRecentVersion = { id: number; @@ -14,7 +13,11 @@ type StoredRecentVersion = { }; function getStoredRecentVersions(): StoredRecentVersion[] { - return JSON.parse(localStorage.getItem(RECENT_VERSIONS_KEY) || '[]') as StoredRecentVersion[]; + try { + return JSON.parse(localStorage.getItem(RECENT_VERSIONS_KEY) || '[]') as StoredRecentVersion[]; + } catch { + return []; + } } const withLayout = (Story: React.ComponentType) => ( diff --git a/packages/ui/src/components/bible-version-picker.tsx b/packages/ui/src/components/bible-version-picker.tsx index 654688a..b9cc8a9 100644 --- a/packages/ui/src/components/bible-version-picker.tsx +++ b/packages/ui/src/components/bible-version-picker.tsx @@ -19,7 +19,7 @@ import { useState, } from 'react'; -const RECENT_VERSIONS_KEY = 'youversion-platform:picker:recent-versions'; +export const RECENT_VERSIONS_KEY = 'youversion-platform:picker:recent-versions'; const MAX_RECENT_VERSIONS = 3; type RecentVersion = Pick; @@ -372,7 +372,7 @@ function Content() { > {/* Versions View */}