Skip to content

Commit 1a5c7a8

Browse files
committed
Cache username in localStorage
1 parent 96b3969 commit 1a5c7a8

9 files changed

Lines changed: 129 additions & 53 deletions

File tree

src/app/App.test.tsx

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
refreshAccessToken,
1010
setAccessToken,
1111
} from '@/shared/api/spotify'
12-
import { useUserId } from '@/shared/hooks/useUserId'
12+
import { useUsername } from '@/shared/hooks/useUsername'
1313

1414
// Helper to create mock callback params with defaults
1515
const mockCallbackParams = (
@@ -32,8 +32,8 @@ vi.mock('@/features/auth/utils/callback', () => ({
3232
getCallbackParams: vi.fn(),
3333
}))
3434

35-
vi.mock('@/shared/hooks/useUserId', () => ({
36-
useUserId: vi.fn(),
35+
vi.mock('@/shared/hooks/useUsername', () => ({
36+
useUsername: vi.fn(),
3737
}))
3838

3939
// Mock components
@@ -42,8 +42,8 @@ vi.mock('@/features/auth/components/IntroScreen', () => ({
4242
}))
4343

4444
vi.mock('@/features/layout/components/Header', () => ({
45-
Header: ({ userId }: { userId: string | null }) => (
46-
<div>Header: {userId}</div>
45+
Header: ({ username }: { username: string | null }) => (
46+
<div>Header: {username}</div>
4747
),
4848
}))
4949

@@ -111,7 +111,7 @@ describe('App', () => {
111111
state: null,
112112
error: null,
113113
})
114-
vi.mocked(useUserId).mockReturnValue(null)
114+
vi.mocked(useUsername).mockReturnValue(null)
115115
})
116116

117117
afterEach(() => {
@@ -172,14 +172,14 @@ describe('App', () => {
172172
localStorageMock.spotify_access_token = 'test-token'
173173
localStorageMock.spotify_refresh_token = 'test-refresh-token'
174174
localStorageMock.spotify_token_expiry = (Date.now() + 3600000).toString() // 1 hour from now
175-
vi.mocked(useUserId).mockReturnValue('user123')
175+
vi.mocked(useUsername).mockReturnValue('Test User')
176176

177177
await act(async () => {
178178
render(<App />)
179179
})
180180

181181
await waitFor(() => {
182-
expect(screen.getByText('Header: user123')).toBeInTheDocument()
182+
expect(screen.getByText('Header: Test User')).toBeInTheDocument()
183183
expect(screen.getByText('MainContent')).toBeInTheDocument()
184184
})
185185
})

src/app/App.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { MainContent } from '@/features/layout/components/MainContent'
99
import * as m from '@/paraglide/messages'
1010
import { setAccessToken } from '@/shared/api/spotify'
1111
import { Button } from '@/shared/components/ui/button'
12-
import { useUserId } from '@/shared/hooks/useUserId'
12+
import { useUsername } from '@/shared/hooks/useUsername'
1313
import '@/features/layout/styles.css'
1414

1515
export function App(): JSX.Element {
@@ -39,7 +39,7 @@ export function App(): JSX.Element {
3939
})
4040

4141
const isSignedIn = accessToken !== null
42-
const userId = useUserId(isSignedIn)
42+
const username = useUsername(isSignedIn)
4343

4444
if (isLoading) {
4545
return <div>{m.loading()}</div>
@@ -61,7 +61,7 @@ export function App(): JSX.Element {
6161
<div className="min-h-screen bg-zinc-950 text-zinc-100 font-sans selection:bg-spotify selection:text-black flex flex-col">
6262
{isSignedIn && (
6363
<>
64-
<Header userId={userId} />
64+
<Header username={username} />
6565
<MainContent />
6666
</>
6767
)}

src/features/layout/components/Header.tsx

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,19 +10,19 @@ import {
1010
} from '@/shared/components/ui/dropdown-menu'
1111

1212
interface HeaderProps {
13-
userId: string | null
13+
username: string | null
1414
}
1515

16-
export function Header({ userId }: HeaderProps): JSX.Element {
16+
export function Header({ username }: HeaderProps): JSX.Element {
1717
const { logout } = useLogout()
1818
const currentLocale = getLocale()
1919

20-
// Extract initials from userId for avatar
21-
const getInitials = (userId: string | null): string => {
22-
if (!userId) {
20+
// Extract initials from username for avatar
21+
const getInitials = (username: string | null): string => {
22+
if (!username) {
2323
return ''
2424
}
25-
return userId.charAt(0).toUpperCase()
25+
return username.charAt(0).toUpperCase()
2626
}
2727

2828
return (
@@ -64,11 +64,11 @@ export function Header({ userId }: HeaderProps): JSX.Element {
6464
>
6565
<div className="w-8 h-8 rounded-full bg-zinc-800 border border-zinc-700 flex items-center justify-center group-hover:border-zinc-600">
6666
<span className="font-bold text-xs text-zinc-400">
67-
{getInitials(userId)}
67+
{getInitials(username)}
6868
</span>
6969
</div>
7070
<span className="text-sm font-medium text-zinc-300 hidden sm:block group-hover:text-zinc-100">
71-
{userId || m.loading()}
71+
{username || m.loading()}
7272
</span>
7373
</button>
7474
</DropdownMenuTrigger>

src/features/playlists/components/PlaylistItem.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,9 @@ function getStatusIndicator(status: PlaylistStatus): JSX.Element | null {
111111
return (
112112
<div className="flex items-center gap-2">
113113
<XCircle className="w-4 h-4 text-red-500" />
114-
<span className="text-xs text-red-500 hidden sm:inline">{m.error()}</span>
114+
<span className="text-xs text-red-500 hidden sm:inline">
115+
{m.error()}
116+
</span>
115117
</div>
116118
)
117119
default:

src/shared/api/spotify.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
getMyId,
77
getMyPlaylists,
88
getPlaylistTracks,
9+
getUsername,
910
refreshAccessToken,
1011
setAccessToken,
1112
setPlaylistTracks,
@@ -474,6 +475,63 @@ describe('spotify API', () => {
474475
})
475476
})
476477

478+
describe('getUsername()', () => {
479+
test('should return cached username if available', async () => {
480+
localStorage.setItem(STORAGE_KEYS.SPOTIFY_USERNAME, 'Cached User')
481+
482+
const result = await getUsername()
483+
484+
expect(result).toBe('Cached User')
485+
expect(mockGetMe).not.toHaveBeenCalled()
486+
})
487+
488+
test('should fetch and cache username from API if not cached', async () => {
489+
mockGetMe.mockResolvedValueOnce({
490+
id: 'test-user-id',
491+
display_name: 'New User',
492+
})
493+
494+
const result = await getUsername()
495+
496+
expect(result).toBe('New User')
497+
expect(mockGetMe).toHaveBeenCalled()
498+
expect(localStorage.setItem).toHaveBeenCalledWith(
499+
STORAGE_KEYS.SPOTIFY_USERNAME,
500+
'New User',
501+
)
502+
})
503+
504+
test('should use fallback username if display_name is null', async () => {
505+
mockGetMe.mockResolvedValueOnce({
506+
id: 'test-user-id',
507+
display_name: null,
508+
})
509+
510+
const result = await getUsername()
511+
512+
expect(result).toBe('User')
513+
expect(localStorage.setItem).toHaveBeenCalledWith(
514+
STORAGE_KEYS.SPOTIFY_USERNAME,
515+
'User',
516+
)
517+
})
518+
519+
test('should use fallback username if display_name is undefined', async () => {
520+
mockGetMe.mockResolvedValueOnce({
521+
id: 'test-user-id',
522+
display_name: undefined,
523+
})
524+
525+
const result = await getUsername()
526+
527+
expect(result).toBe('User')
528+
expect(localStorage.setItem).toHaveBeenCalledWith(
529+
STORAGE_KEYS.SPOTIFY_USERNAME,
530+
'User',
531+
)
532+
})
533+
})
534+
477535
describe('getMyPlaylists()', () => {
478536
test('should fetch playlists successfully', async () => {
479537
const mockPlaylists = [

src/shared/api/spotify.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,3 +177,18 @@ export async function getMyId(): Promise<string> {
177177

178178
return id
179179
}
180+
181+
export async function getUsername(): Promise<string> {
182+
const cachedUsername = localStorage.getItem(STORAGE_KEYS.SPOTIFY_USERNAME)
183+
184+
if (cachedUsername) {
185+
return cachedUsername
186+
}
187+
188+
const { display_name } = await spotifyClient.getMe()
189+
const username = display_name || 'User'
190+
191+
localStorage.setItem(STORAGE_KEYS.SPOTIFY_USERNAME, username)
192+
193+
return username
194+
}

src/shared/constants/storage.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export const STORAGE_KEYS = {
33
SPOTIFY_ACCESS_TOKEN: 'spotify_access_token',
44
SPOTIFY_REFRESH_TOKEN: 'spotify_refresh_token',
55
SPOTIFY_TOKEN_EXPIRY: 'spotify_token_expiry',
6+
SPOTIFY_USERNAME: 'spotify_username',
67
OAUTH_CODE_VERIFIER: 'code_verifier',
78
OAUTH_STATE: 'oauth_state',
89
} as const

src/shared/hooks/useUserId.ts

Lines changed: 0 additions & 33 deletions
This file was deleted.

src/shared/hooks/useUsername.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { useEffect, useState } from 'react'
2+
3+
import { getUsername } from '@/shared/api/spotify'
4+
5+
/**
6+
* Fetch username asynchronously with localStorage caching.
7+
*
8+
* @param enabled - Whether to enable fetching the username
9+
* @return Username (display name)
10+
*/
11+
export function useUsername(enabled = true): string | null {
12+
const [username, setUsername] = useState<string | null>(null)
13+
14+
useEffect(() => {
15+
if (!enabled) {
16+
setUsername(null)
17+
return
18+
}
19+
20+
async function fetchUsername() {
21+
try {
22+
setUsername(await getUsername())
23+
} catch {
24+
// If API call fails (e.g., no token), set username to null
25+
setUsername(null)
26+
}
27+
}
28+
29+
fetchUsername()
30+
}, [enabled])
31+
32+
return username
33+
}

0 commit comments

Comments
 (0)