Spotify Data Layer#1
Conversation
- add cache-aside utility with typed CacheKey enum and TTL constants - add Spotify API Client with wrappers for fetching playlists, top artists, and top tracks - implement /api/spotify routes that utilize the Spotify API Client and cache results - handle Spotify 401 and 429 errors explicitly in API routes - transform Spotify responses into simplified formats before caching and returning to clients Note: need to update deprecated attributes or endpoints - audio-features - top artists genres and popularity
- Add Last.fm API client with artist.getTopTags integration - Add genre tag filtering, normalization, and within-artist weighting - Add /api/lastfm/genre-breakdown route with 7-day DB cache - Remove deprecated /api/spotify/audio-features route - Remove deprecated genres and popularity attributes from SpotifyArtist type - Fix SpotifyPlaylist tracks -> items field rename - Add genre_breakdown CacheKey with 7-day TTL - Add LASTFM_API_KEY to env configuration
- Document Last.fm genre integration replacing deprecated Spotify endpoints - Note Spotify 2026 breaking changes (genres, popularity, tracks->items) - Update cache TTL reference table with genre_breakdown 7-day TTL - Add Spotify development mode 25-user limit to target users section - Remove audio features from architecture and SonaUserContext
- Add QueryClientProvider with optimized staleTime and gcTime defaults - Configure Google fonts in root layout - Add useTopTracks, useTopArtists, usePlaylists, useGenreBreakdown hooks - Add ReactQueryDevtools for development debugging
- Add SonaVoice and SectionLabel shared components - Build ArtistsSection with editorial top-3 grid and time range selector - Build TracksSection with ranked list, album art, and duration formatting - Build GenreSection with bars from Last.fm genre breakdown - Build PlaylistsSection with 6-up grid layout - Wire profile page with sticky nav, identity hero, and section anchors - Configure next/image remote patterns for Spotify CDN domains - Fix LCP warning with priority loading on first artist image
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 5
🧹 Nitpick comments (8)
docs/SYSTEM_DESIGN.md (1)
228-256: Add language specifiers to fenced code blocks.The code blocks starting at lines 228 and 254 lack language specifiers. While these appear to be plain text flow diagrams, adding a language identifier (e.g.,
textorplaintext) improves consistency and satisfies markdown linting.📝 Suggested fix
-``` +```text GET /api/lastfm/genre-breakdownAnd similarly for line 254:
-``` +```text SYSTEM DESIGN NOTE: This route has an implicit dependency on🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@docs/SYSTEM_DESIGN.md` around lines 228 - 256, Add a language specifier (e.g., text) to the two fenced code blocks that contain the flow diagram — the block starting with "GET /api/lastfm/genre-breakdown" and the block containing "SYSTEM DESIGN NOTE: This route has an implicit dependency..." — so change their opening fences to include the language (```text) to satisfy markdown linting and improve consistency.src/app/(protected)/profile/_components/playlists-section.tsx (1)
8-10: Consider handling error state from the hook.The component only handles
isLoadingbut doesn't account for potential fetch errors. If the API call fails, the component will render an empty grid without any user feedback.♻️ Proposed enhancement to handle errors
export function PlaylistsSection() { - const { data, isLoading } = usePlaylists(); + const { data, isLoading, isError } = usePlaylists(); const playlists = data?.playlists.slice(0, 6) ?? []; + + if (isError) { + return ( + <section id="playlists" aria-labelledby="playlists-heading" className="py-16"> + <SectionLabel className="mb-2">Your Collections</SectionLabel> + <h2 id="playlists-heading" className="mb-6 font-serif text-3xl font-medium tracking-tight"> + Playlists + </h2> + <p className="text-muted-foreground">Unable to load playlists.</p> + </section> + ); + }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/app/`(protected)/profile/_components/playlists-section.tsx around lines 8 - 10, PlaylistsSection currently only checks isLoading and slices data but ignores errors from the usePlaylists hook; update PlaylistsSection to read the hook's error (e.g., const { data, isLoading, error } = usePlaylists()), and when error is present return or render a simple error/fallback UI (a message, retry button, or empty state) instead of the empty grid, while preserving the existing isLoading behavior and using the existing playlists variable (data?.playlists.slice(0,6) ?? []) when no error.src/app/(protected)/profile/_components/tracks-section.tsx (1)
31-32: Minor: Section label "This Month" doesn't match all time ranges.The
SectionLabeldisplays "This Month" regardless of which time range is selected. When the user selects "6 Months" or "All Time", this label becomes misleading.♻️ Proposed fix to make label dynamic
+const TIME_RANGE_LABELS: Record<TimeRange, string> = { + short_term: "This Month", + medium_term: "Past 6 Months", + long_term: "All Time", +}; + // Inside the component: -<SectionLabel className="mb-2">This Month</SectionLabel> +<SectionLabel className="mb-2">{TIME_RANGE_LABELS[timeRange]}</SectionLabel>🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/app/`(protected)/profile/_components/tracks-section.tsx around lines 31 - 32, The SectionLabel currently hardcodes "This Month"; change it to render a dynamic label based on the current time range state/prop (e.g., selectedRange or timeRange) used by the component (TracksSection in tracks-section.tsx) instead of the literal string; implement a small mapping (e.g., "month" -> "This Month", "6months" -> "Last 6 Months", "all" -> "All Time") and use that mapped value inside the SectionLabel component usage, with a sensible default fallback if the range is undefined.src/app/api/lastfm/genre-breakdown/route.ts (1)
46-57: Sequential Last.fm calls may cause slow response times.Fetching 20 artists sequentially with 100ms delays takes a minimum of ~2 seconds, plus actual API latency. Consider parallel fetching with controlled concurrency (e.g., batches of 5) to improve response time while still respecting rate limits.
♻️ Proposed optimization with batched parallel requests
- for (let i = 0; i < artistsToQuery.length; i++) { - const artist = artistsToQuery[i]; - if (!artist) continue; - - const tags = await fetchArtistTopTags(artist.name); - artistTags.push({ artistRank: i + 1, artistName: artist.name, tags }); - - // Small delay to avoid rate limiting (Last.fm terms) - if (i < artistsToQuery.length - 1) { - await new Promise((resolve) => setTimeout(resolve, 100)); - } - } + // Fetch in batches of 5 to balance speed and rate limiting + const BATCH_SIZE = 5; + for (let batch = 0; batch < artistsToQuery.length; batch += BATCH_SIZE) { + const batchArtists = artistsToQuery.slice(batch, batch + BATCH_SIZE); + const batchResults = await Promise.all( + batchArtists.map(async (artist, idx) => { + const tags = await fetchArtistTopTags(artist.name); + return { artistRank: batch + idx + 1, artistName: artist.name, tags }; + }) + ); + artistTags.push(...batchResults); + + // Delay between batches to respect rate limits + if (batch + BATCH_SIZE < artistsToQuery.length) { + await new Promise((resolve) => setTimeout(resolve, 200)); + } + }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/app/api/lastfm/genre-breakdown/route.ts` around lines 46 - 57, The current loop in route.ts iterates artistsToQuery sequentially calling fetchArtistTopTags for each artist and awaiting a 100ms delay per iteration, causing slow responses; refactor to perform batched parallel requests (e.g., concurrency = 5) by slicing artistsToQuery into chunks, for each chunk call fetchArtistTopTags for all artists in the chunk with Promise.all, push results into artistTags preserving artistRank/artistName, then await a short delay (e.g., 100ms) between chunks to respect Last.fm rate limits; ensure you reference and update the same symbols (artistsToQuery, fetchArtistTopTags, artistTags) and preserve ordering of artistRank (i + 1) when aggregating results.src/app/(protected)/profile/_components/artists-section.tsx (1)
11-15: Consider extracting sharedTIME_RANGESconstant.This constant is duplicated in
tracks-section.tsx. Extracting it to a shared location (e.g.,@/lib/constantsor@/types) would improve maintainability.♻️ Proposed refactor
Create a shared constant:
// src/lib/constants.ts import type { TimeRange } from "@/types"; export const TIME_RANGES: { label: string; value: TimeRange }[] = [ { label: "4 Weeks", value: "short_term" }, { label: "6 Months", value: "medium_term" }, { label: "All Time", value: "long_term" }, ];Then import in both components:
-const TIME_RANGES: { label: string; value: TimeRange }[] = [ - { label: "4 Weeks", value: "short_term" }, - { label: "6 Months", value: "medium_term" }, - { label: "All Time", value: "long_term" }, -]; +import { TIME_RANGES } from "@/lib/constants";🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/app/`(protected)/profile/_components/artists-section.tsx around lines 11 - 15, Extract the duplicate TIME_RANGES constant into a single exported constant in a new shared constants module and import it from both components; specifically create and export export const TIME_RANGES: { label: string; value: TimeRange }[] (ensuring you import the TimeRange type) and then replace the in-file TIME_RANGES in artists-section.tsx and tracks-section.tsx with an import from that shared module so both files reference the same symbol.src/app/api/spotify/top-tracks/route.ts (1)
22-36: Type assertion before validation allows invalid values to bypass type checking.Line 30 casts the raw query param to
TimeRangebefore the validation check on line 34. While the validation will catch invalid values at runtime, the type system thinkstimeRangeis always a validTimeRangeeven before validation. This is a minor type-safety concern but the runtime validation makes it safe in practice.♻️ Suggested improvement for stricter type handling
const { searchParams } = new URL(request.url); - const timeRange = (searchParams.get("range") ?? "short_term") as TimeRange; + const rawRange = searchParams.get("range") ?? "short_term"; // Validate time range param const validRanges: TimeRange[] = ["short_term", "medium_term", "long_term"]; - if (!validRanges.includes(timeRange)) { + if (!validRanges.includes(rawRange as TimeRange)) { return NextResponse.json({ error: "Invalid time range" }, { status: 400 }); } + const timeRange = rawRange as TimeRange;🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/app/api/spotify/top-tracks/route.ts` around lines 22 - 36, The current GET handler casts the raw query param to TimeRange before validating, so change the flow in GET: read the raw value from searchParams.get("range") into a string (e.g. rawRange), validate it against the validRanges array, and only after successful validation assign or cast it to timeRange: TimeRange; update references to use the validated variable. Keep the existing runtime error responses (401/400) but remove the early type assertion on timeRange so the TypeScript type reflects validation order in GET, validRanges, and timeRange.src/app/api/spotify/top-artists/route.ts (1)
1-82: Significant code duplication withtop-tracks/route.ts.This route shares ~80% identical code structure with the top-tracks route: session handling, time range validation, cache checking, error handling. Consider extracting common patterns into a shared utility to reduce maintenance burden.
♻️ Example of a shared route helper
// src/lib/spotify/route-helpers.ts export async function withSpotifyAuth<T>( request: NextRequest, handler: (session: Session, timeRange: TimeRange, cacheKey: CacheKey) => Promise<T> ) { const session = await getSession(); if (!session.userId) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } const { searchParams } = new URL(request.url); const rawRange = searchParams.get("range") ?? "short_term"; const validRanges: TimeRange[] = ["short_term", "medium_term", "long_term"]; if (!validRanges.includes(rawRange as TimeRange)) { return NextResponse.json({ error: "Invalid time range" }, { status: 400 }); } // ... common try/catch with error mapping }This could be deferred to a follow-up refactoring effort if the current structure is preferred for readability.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/app/api/spotify/top-artists/route.ts` around lines 1 - 82, The GET route duplicates session checking, time-range validation, cache lookups and error mapping already used in top-tracks/route.ts; extract that common logic into a shared helper (e.g., withSpotifyAuth or withSpotifyRoute) in a new file like src/lib/spotify/route-helpers.ts that accepts the incoming NextRequest and a handler callback that receives the resolved session, validated timeRange and a cacheKey and returns the route payload; then refactor this file's GET to call withSpotifyAuth and keep only the specific parts: building cacheKey, calling getCached/setCached, calling withValidToken and fetchTopArtists, transforming items to the artists shape, and returning the JSON response—preserve the existing error mapping for "SPOTIFY_UNAUTHORIZED" and "SPOTIFY_RATE_LIMITED" inside the shared helper so both top-artists and top-tracks use the same behavior.src/lib/spotify/client.ts (1)
79-84: Function is currently unused; consider batching before integrating into application.This function is not called anywhere in the codebase. However, if integrated, it should handle the Spotify API constraint: the
/audio-featuresendpoint accepts a maximum of 100 track IDs. With each ID being ~22 characters, a full batch would generate a URL around 2300+ characters for the IDs alone, which may exceed practical limits. While Spotify doesn't officially document a maximum URL length, this approach is fragile if the function is ever called with >100 IDs.🛡️ Suggested defensive batching
-export async function fetchAudioFeatures(accessToken: string, trackIds: string[]) { - return spotifyFetch<SpotifyAudioFeaturesResponse>( - `/audio-features?ids=${trackIds.join(",")}`, - accessToken - ); +const AUDIO_FEATURES_BATCH_SIZE = 100; // Spotify's documented limit + +export async function fetchAudioFeatures(accessToken: string, trackIds: string[]) { + if (trackIds.length <= AUDIO_FEATURES_BATCH_SIZE) { + return spotifyFetch<SpotifyAudioFeaturesResponse>( + `/audio-features?ids=${trackIds.join(",")}`, + accessToken + ); + } + + // Batch requests for large track lists + const batches = []; + for (let i = 0; i < trackIds.length; i += AUDIO_FEATURES_BATCH_SIZE) { + batches.push(trackIds.slice(i, i + AUDIO_FEATURES_BATCH_SIZE)); + } + + const results = await Promise.all( + batches.map(batch => + spotifyFetch<SpotifyAudioFeaturesResponse>( + `/audio-features?ids=${batch.join(",")}`, + accessToken + ) + ) + ); + + return { + audio_features: results.flatMap(r => r.audio_features), + }; }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/lib/spotify/client.ts` around lines 79 - 84, The fetchAudioFeatures function currently naively joins all trackIds into one URL and can break when given >100 IDs; update fetchAudioFeatures to defensively batch trackIds into chunks of at most 100 (Spotify /audio-features limit), call spotifyFetch<SpotifyAudioFeaturesResponse> for each chunk using the same accessToken, aggregate/merge the returned audio_features arrays into a single response (preserving order or returning them flattened), and handle empty input by returning an empty array or appropriate SpotifyAudioFeaturesResponse; keep references to fetchAudioFeatures, spotifyFetch, and SpotifyAudioFeaturesResponse when implementing the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/app/`(protected)/profile/_components/genre-section.tsx:
- Around line 9-12: The genre query is not gated on the top-artists warm-up so
useGenreBreakdown() may run before useTopArtists("short_term") populates the
cache; update useGenreBreakdown to accept an options param with enabled (as in
the suggested snippet) and then call useGenreBreakdown({ enabled:
topArtistsQuery.isSuccess }) (or enabled: !topArtistsQuery.isLoading &&
!!topArtistsQuery.data) where topArtistsQuery comes from
useTopArtists("short_term"); this ensures fetchGenreBreakdown (queryKey
"genre-breakdown") only runs after useTopArtists completes.
In `@src/app/`(protected)/profile/page.tsx:
- Around line 54-56: The heading currently renders "Your's" when
session.displayName is falsy; update the JSX where session.displayName is used
(the expression session.displayName ?? "Your") to conditionally append the
possessive only when a displayName exists (e.g., use session.displayName ?
`${session.displayName}'s` : "Your") so the fallback becomes "Your music story."
while named users still get "Name's music story."
In `@src/app/api/lastfm/genre-breakdown/route.ts`:
- Line 26: Update the inline comment in
src/app/api/lastfm/genre-breakdown/route.ts that reads "// 1Check genre
breakdown cache first (24hr TTL)" to "// 1. Check genre breakdown cache first
(24hr TTL)"; locate the comment near the exported route/handler for the genre
breakdown API and correct the typo so numbering and spacing are consistent.
In `@src/app/api/spotify/playlists/route.ts`:
- Around line 53-59: The catch block in route.ts currently only checks for
"SPOTIFY_UNAUTHORIZED" and falls back to 500, so add explicit handling for the
"SPOTIFY_RATE_LIMITED" error (thrown by spotifyFetch and surfaced by
fetchPlaylists) in the same catch: detect if (error instanceof Error &&
error.message === "SPOTIFY_RATE_LIMITED") and return a NextResponse with a 429
status and a descriptive body (e.g., { error: "Spotify rate limited" }); keep
the existing 401 handling for SPOTIFY_UNAUTHORIZED and the 500 fallback for
other errors.
In `@src/lib/lastfm/client.ts`:
- Around line 12-34: The current Last.fm call can throw or return unexpected
shapes; wrap the fetch/parse logic in a try/catch and return [] on any error,
first validate process.env.LASTFM_API_KEY before building params and return []
if missing, handle non-ok responses as now, then after parsing (the variable
data / LastFmTagsResponse) explicitly check that data is an object with a
toptags property and that toptags.tag is an array before returning it, otherwise
return []; ensure JSON.parse/fetch errors are caught and result in the same
graceful [] fallback so callers never see exceptions from this module.
---
Nitpick comments:
In `@docs/SYSTEM_DESIGN.md`:
- Around line 228-256: Add a language specifier (e.g., text) to the two fenced
code blocks that contain the flow diagram — the block starting with "GET
/api/lastfm/genre-breakdown" and the block containing "SYSTEM DESIGN NOTE: This
route has an implicit dependency..." — so change their opening fences to include
the language (```text) to satisfy markdown linting and improve consistency.
In `@src/app/`(protected)/profile/_components/artists-section.tsx:
- Around line 11-15: Extract the duplicate TIME_RANGES constant into a single
exported constant in a new shared constants module and import it from both
components; specifically create and export export const TIME_RANGES: { label:
string; value: TimeRange }[] (ensuring you import the TimeRange type) and then
replace the in-file TIME_RANGES in artists-section.tsx and tracks-section.tsx
with an import from that shared module so both files reference the same symbol.
In `@src/app/`(protected)/profile/_components/playlists-section.tsx:
- Around line 8-10: PlaylistsSection currently only checks isLoading and slices
data but ignores errors from the usePlaylists hook; update PlaylistsSection to
read the hook's error (e.g., const { data, isLoading, error } = usePlaylists()),
and when error is present return or render a simple error/fallback UI (a
message, retry button, or empty state) instead of the empty grid, while
preserving the existing isLoading behavior and using the existing playlists
variable (data?.playlists.slice(0,6) ?? []) when no error.
In `@src/app/`(protected)/profile/_components/tracks-section.tsx:
- Around line 31-32: The SectionLabel currently hardcodes "This Month"; change
it to render a dynamic label based on the current time range state/prop (e.g.,
selectedRange or timeRange) used by the component (TracksSection in
tracks-section.tsx) instead of the literal string; implement a small mapping
(e.g., "month" -> "This Month", "6months" -> "Last 6 Months", "all" -> "All
Time") and use that mapped value inside the SectionLabel component usage, with a
sensible default fallback if the range is undefined.
In `@src/app/api/lastfm/genre-breakdown/route.ts`:
- Around line 46-57: The current loop in route.ts iterates artistsToQuery
sequentially calling fetchArtistTopTags for each artist and awaiting a 100ms
delay per iteration, causing slow responses; refactor to perform batched
parallel requests (e.g., concurrency = 5) by slicing artistsToQuery into chunks,
for each chunk call fetchArtistTopTags for all artists in the chunk with
Promise.all, push results into artistTags preserving artistRank/artistName, then
await a short delay (e.g., 100ms) between chunks to respect Last.fm rate limits;
ensure you reference and update the same symbols (artistsToQuery,
fetchArtistTopTags, artistTags) and preserve ordering of artistRank (i + 1) when
aggregating results.
In `@src/app/api/spotify/top-artists/route.ts`:
- Around line 1-82: The GET route duplicates session checking, time-range
validation, cache lookups and error mapping already used in top-tracks/route.ts;
extract that common logic into a shared helper (e.g., withSpotifyAuth or
withSpotifyRoute) in a new file like src/lib/spotify/route-helpers.ts that
accepts the incoming NextRequest and a handler callback that receives the
resolved session, validated timeRange and a cacheKey and returns the route
payload; then refactor this file's GET to call withSpotifyAuth and keep only the
specific parts: building cacheKey, calling getCached/setCached, calling
withValidToken and fetchTopArtists, transforming items to the artists shape, and
returning the JSON response—preserve the existing error mapping for
"SPOTIFY_UNAUTHORIZED" and "SPOTIFY_RATE_LIMITED" inside the shared helper so
both top-artists and top-tracks use the same behavior.
In `@src/app/api/spotify/top-tracks/route.ts`:
- Around line 22-36: The current GET handler casts the raw query param to
TimeRange before validating, so change the flow in GET: read the raw value from
searchParams.get("range") into a string (e.g. rawRange), validate it against the
validRanges array, and only after successful validation assign or cast it to
timeRange: TimeRange; update references to use the validated variable. Keep the
existing runtime error responses (401/400) but remove the early type assertion
on timeRange so the TypeScript type reflects validation order in GET,
validRanges, and timeRange.
In `@src/lib/spotify/client.ts`:
- Around line 79-84: The fetchAudioFeatures function currently naively joins all
trackIds into one URL and can break when given >100 IDs; update
fetchAudioFeatures to defensively batch trackIds into chunks of at most 100
(Spotify /audio-features limit), call spotifyFetch<SpotifyAudioFeaturesResponse>
for each chunk using the same accessToken, aggregate/merge the returned
audio_features arrays into a single response (preserving order or returning them
flattened), and handle empty input by returning an empty array or appropriate
SpotifyAudioFeaturesResponse; keep references to fetchAudioFeatures,
spotifyFetch, and SpotifyAudioFeaturesResponse when implementing the change.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 09ef3dce-609d-4707-9ef3-96fc439c2127
📒 Files selected for processing (27)
.env.exampledocs/PRD.mddocs/SYSTEM_DESIGN.mdnext.config.tssrc/app/(protected)/profile/_components/artists-section.tsxsrc/app/(protected)/profile/_components/genre-section.tsxsrc/app/(protected)/profile/_components/playlists-section.tsxsrc/app/(protected)/profile/_components/tracks-section.tsxsrc/app/(protected)/profile/page.tsxsrc/app/api/lastfm/genre-breakdown/route.tssrc/app/api/spotify/playlists/route.tssrc/app/api/spotify/top-artists/route.tssrc/app/api/spotify/top-tracks/route.tssrc/app/globals.csssrc/app/layout.tsxsrc/components/layout/section-label.tsxsrc/components/providers.tsxsrc/components/sona/sona-voice.tsxsrc/hooks/use-genre-breakdown.tssrc/hooks/use-playlists.tssrc/hooks/use-top-artists.tssrc/hooks/use-top-tracks.tssrc/lib/lastfm/client.tssrc/lib/lastfm/genres.tssrc/lib/spotify/cache.tssrc/lib/spotify/client.tssrc/types/index.ts
- Gate useGenreBreakdown on useTopArtists success to prevent race condition - Add SPOTIFY_RATE_LIMITED handling to playlists route for consistency - Harden Last.fm fetchArtistTopTags with try/catch and API key guard
- Remove deprecated audio features endpoint - Refactor TIME_RANGES constant - Semantic cleanup in profile page and components
Summary by CodeRabbit
Release Notes