Skip to content

fix: eliminate layout shift on tools pages - #84

Open
remcostoeten wants to merge 4 commits into
masterfrom
fix/tools-layout-shift
Open

fix: eliminate layout shift on tools pages#84
remcostoeten wants to merge 4 commits into
masterfrom
fix/tools-layout-shift

Conversation

@remcostoeten

@remcostoeten remcostoeten commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Summary

Speed Insights showed CLS of 0.11 on /tools/[slug] (and 0.09 on /tools). The shift came from three stacked layers, all fixed here:

  • Breadcrumbs rendered client-side. The whole nav sat behind a useSearchParams-forced <Suspense fallback={null}>, appearing only after hydration and pushing every page down. The Suspense boundary is now scoped to the individual links (only the ?lang param needs it), so the nav is in the server HTML. Also benefits /blog/[...slug] and /, which share the component.
  • Page-level fallback mismatch. The /tools/[slug] Suspense fallback now mirrors the real header geometry and renders the actual static ToolQuickNav, making the page chrome pixel-identical at every breakpoint.
  • One generic skeleton for 11 different tools. New tool-skeletons.tsx has a per-tool skeleton reproducing each tool's real first-paint layout (containers, borders, panel heights, responsive variants). Wired into the next/dynamic loaders, and reused by the internal hydration gates of find-replace, diff-checker, hemelsbreed and coordinate-marker so chunk-load → hydration → ready render one stable frame.

Verification

  • tsc --noEmit clean, next build passes with all tool routes prerendered
  • Compared skeleton vs hydrated frames in Chrome against the production build for diff-checker, find-replace, svg-converter and video-to-gif — geometry matches, no console errors

Summary by Sourcery

Improve tools pages stability and YouTube Music integration, adding richer diagnostics, persistent media sessions, and accessibility refinements while simplifying some marketing content.

New Features:

  • Add a YouTube Music diagnostics page with live signal checks, payload preview, and refresh controls.
  • Introduce per-tool skeleton components for all miscellaneous tools to match real layouts during loading.
  • Persist media tool sessions (input file, trim range, and output) across reloads using IndexedDB-backed hooks.

Bug Fixes:

  • Eliminate layout shift on tools pages by rendering breadcrumbs on the server, aligning tool page fallbacks with final layouts, and using tool-specific skeletons.
  • Improve activity calendar keyboard navigation and skip behavior, and make media trimming more resilient when autoplay fails.
  • Fix YouTube Music auth and error handling so rejected or unreachable sessions surface clear statuses instead of silent empty results.

Enhancements:

  • Refactor YouTube Music fetching into a structured result model with caching, timestamp stabilization, and a dedicated parser for Innertube responses.
  • Streamline tools hub UI by removing the recent-tools section and deferred search, keeping a simple category-filtered search experience.
  • Adjust media conversion UX with inline output previews, clearer status messages, and quick file clearing in the sendable-video tool.
  • Relax SEO/marketing content by removing inline FAQ blocks and FAQ structured data from tools and packages pages.
  • Tighten ffmpeg-based conversions by probing input codecs before remuxing and improving compatibility decisions for chat-friendly MP4 exports.
  • Refine animated icon and tool quick-nav styling to avoid overflow issues and align with the new skeletons.

Build:

  • Allow Next.js image optimization for additional YouTube-related hosts used by YouTube Music artwork.

Documentation:

  • Correct and clarify copy in a marketing blog post about arrow functions and hoisting.

Tests:

  • Add unit tests around YouTube Music Innertube parsing, cache behavior, and API status mapping, and update combined activity tests for the new YouTube Music fallback logic.

Chores:

  • Expose YouTube Music env vars through the server env helper and remove unused tool-usage tracking for tools hub and renderer.

@sourcery-ai

sourcery-ai Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Reviewer's Guide

Refactors YouTube Music integration to a richer, cache-aware API with diagnostics, stabilizes media tools via persistent sessions and smarter ffmpeg probing, replaces generic skeletons and usage tracking on tools pages with per-tool skeleton components and more accurate Suspense fallbacks, and improves accessibility and focus management across activity and tool UIs while removing FAQ content/structured data from tools and package pages.

Sequence diagram for media tool session restore and persistence

sequenceDiagram
  actor User
  participant SendableVideo
  participant Store as useSendableVideoStore
  participant MediaSession as useMediaSession
  participant Persistence as media_persistence
  participant IndexedDB

  SendableVideo->>Store: useSendableVideoStore()
  Store->>MediaSession: useMediaSession(STORAGE_KEY, onRestore)
  MediaSession->>Persistence: loadMediaSession(toolKey)
  Persistence->>IndexedDB: openDatabase / get(input, trim, output)
  IndexedDB-->>Persistence: TPersistedSession
  Persistence-->>MediaSession: TPersistedSession
  MediaSession-->>Store: onRestore(session)
  Store->>Store: setFile / setFileUrl / setOutput

  User->>SendableVideo: selectFile(File)
  SendableVideo->>Store: selectFile(File)
  Store->>MediaSession: persistFile(File)
  MediaSession->>Persistence: saveMediaFile(toolKey, File)
  Persistence->>IndexedDB: put(inputKey, File)

  User->>SendableVideo: adjustTrim
  SendableVideo->>Store: updateTrim(trim)
  Store->>MediaSession: persistTrim(trim)
  MediaSession->>Persistence: saveMediaTrim(toolKey, trim)

  User->>SendableVideo: exportMp4 / exportGif
  Store->>Store: setOutput
  Store->>MediaSession: persistOutput(output)
  MediaSession->>Persistence: saveMediaOutput(toolKey, output)
  Persistence->>IndexedDB: put(outputKey, blob)
Loading

File-Level Changes

Change Details Files
Refactor YouTube Music backend integration into a result-object API with caching, timestamp stabilization, and diagnostics, plus improved auth handling and HTTP status mapping.
  • Replace getYTMusicTracks unstable_cache with getYTMusicResult that returns a YTMusicResult envelope and a thin getYTMusicTracks delegator.
  • Extract DB cache access for YouTube Music into readYTMusicCache/writeYTMusicCache helper module and remove python-bridge/OAuth fallback logic.
  • Introduce parser utilities to normalize Innertube browse responses, infer played_at timestamps, detect unauthorized/sign-in responses, and stabilize timestamps against the cache.
  • Harden YTM auth: fetch live page config (apiKey/clientVersion/session IDs), simplify cookie parsing and SAPISIDHASH generation, add timeouts and better error messages, and reset cached state when YTM_COOKIE changes.
  • Update /api/ytmusic/recent route to use getYTMusicResult with bounded limit and optional refresh flag, return structured status+message, and map result.status to appropriate HTTP codes and cache headers.
  • Add YTMusic types for tracks, result status/source, and export them from the server index for reuse.
  • Add test coverage for parser behavior, tracks service behavior, and API route status mapping.
src/server/ytmusic/tracks.ts
src/server/ytmusic/auth.ts
src/app/api/ytmusic/recent/route.ts
src/features/ytmusic/types.ts
src/server/ytmusic/index.ts
src/server/ytmusic/cache.ts
src/server/ytmusic/parser.ts
__tests__/server/ytmusic/parser.test.ts
__tests__/server/ytmusic/tracks.test.ts
__tests__/app/api/ytmusic/recent/route.test.ts
Improve media tools (sendable video, video-to-gif, gif-to-video) UX with persistent sessions in IndexedDB, better trim-state restoration, smarter ffmpeg remux compatibility checks, and richer output previews.
  • Add useMediaSession hook and media-persistence utilities (IndexedDB) to restore prior file/trim/output per tool and to persist changes asynchronously.
  • Extend use-trim-state metadata handler to accept an optional initial trim range and maintain a history that respects restored ranges.
  • Wire sendable-video, video-to-gif, and gif-to-video stores to useMediaSession: restore previous file/trim/output on mount, persist file selection, trim updates (debounced), and outputs; clear persisted output on reset.
  • Add ffmpeg probeLogs helper and isRemuxCompatible to inspect source codecs via ffmpeg -i logs and choose between remuxArgs and encodeArgs for sendable-video, avoiding incompatible H.264 containers.
  • Adjust sendable-video/video-to-gif status copy, add clear-video button, preload metadata in MediaTrimPanel, and tweak trim summary messaging punctuation.
  • Enhance export panels to show inline previews (img for GIF, video element for MP4) alongside download buttons and persist output metadata.
  • Ensure cleanup of Blob URLs on output clearing, and propagate persistOutput dependencies through callbacks.
src/features/miscellaneous/sendable-video/hooks/use-sendable-video-store.ts
src/features/miscellaneous/video-to-gif/hooks/use-video-to-gif-store.ts
src/features/miscellaneous/gif-to-video/hooks/use-gif-to-video-store.ts
src/features/miscellaneous/sendable-video/components/export-panel.tsx
src/features/miscellaneous/sendable-video/index.tsx
src/features/miscellaneous/components/media-trim-panel.tsx
src/features/miscellaneous/utils/ffmpeg.ts
src/features/miscellaneous/sendable-video/utils/ffmpeg.ts
src/features/miscellaneous/hooks/use-media-session.ts
src/features/miscellaneous/utils/media-persistence.ts
Replace generic tool skeletons with per-tool skeleton components and align Suspense fallbacks and tool chrome to eliminate layout shift on tools pages.
  • Introduce tool-skeletons.tsx with tailored skeleton layouts for each tool (find-replace, diff-checker, link-extractor, JSON tool, SVG converter, hemelsbreed, coordinate-marker, my-location, sendable-video, gif-to-video, video-to-gif).
  • Update tool-renderer to accept skeleton functions per tool and pass them into next/dynamic loading options.
  • Swap ad-hoc hydration gates in diff-checker, find-replace, coordinate-marker, hemelsbreed, etc. with their dedicated skeleton components for pre-hydration rendering.
  • Revise ToolPageFallback to match the real header layout and include ToolQuickNav in the fallback so page chrome is stable pre-hydration.
  • Add ai-icon overflow-visible CSS and tweak ToolQuickNav button classes to avoid clipping animated icons while maintaining focus styles.
src/features/miscellaneous/components/tool-skeletons.tsx
src/features/miscellaneous/components/tool-renderer.tsx
src/features/miscellaneous/diff-checker/index.tsx
src/features/miscellaneous/find-replace/index.tsx
src/features/miscellaneous/coordinate-marker/index.tsx
src/features/miscellaneous/hemelsbreed/index.tsx
src/app/(tools)/tools/[slug]/page.tsx
src/features/miscellaneous/components/tool-quick-nav.tsx
src/features/miscellaneous/components/icons/animated-icons.css
Make breadcrumbs and tools hub render server-stable HTML, removing client-only hydration shifts and usage tracking for tools.
  • Refactor breadcrumbs to render synchronously on the server: remove the outer Suspense boundary, introduce a CrumbLink component that conditionally applies the lang search param via a small inner Suspense island per link, and keep nav markup in server HTML.
  • Ensure breadcrumb labels are lowercased consistently while maintaining focus-visible styles.
  • Simplify tools hub by removing recent-tools tracking/hook usage, dropping useDeferredValue, and using direct searchTools(query) filtered by category.
  • Adjust CategoryFilters markup for a11y (role group, labels) and keep counts per category; remove RecentTools section from ToolsHub.
src/components/layout/breadcrumbs.tsx
src/features/miscellaneous/components/tools-hub.tsx
src/features/miscellaneous/constants/tool-seo.ts
src/features/miscellaneous/hooks/use-tool-usage.ts
Remove FAQ content and structured data for tools and packages, simplifying SEO payloads and page content.
  • Drop TToolFaq type and faqs array from TToolSeoContent and TOOL_SEO_CONTENT entries, and remove FAQ rendering from ToolSeoContent.
  • Remove developerPackages.faqs from data.ts and delete FAQ section and FAQPage structured data from packages marketing page.
  • Delete FaqStructuredData usage from tools/[slug] page, keeping only breadcrumb and tool structured data.
  • Adjust blog post markdown minor content fix (arrow function copy).
src/features/miscellaneous/constants/tool-seo.ts
src/features/miscellaneous/components/tool-seo-content.tsx
src/features/packages/data.ts
src/app/(marketing)/packages/[slug]/page.tsx
src/app/(tools)/tools/[slug]/page.tsx
src/app/(marketing)/blog/posts/yappin/stop-using-arrow-fnc-and-HUGE-REVEAL.md
Improve accessibility and keyboard navigation for the activity calendar and feed section on the landing page.
  • Add roving tabindex and focus management for ActivityContributionGraph cells, including Home/End and arrow-key navigation that moves focus across the 7xN grid.
  • Track focused cell index via state, compute today cell index from weeks data, and use a Map of button refs to focus the next cell programmatically on key events.
  • Add keyboard handler on the grid to intercept navigation keys and prevent default scrolling behavior.
  • Expose a skip link to the activity feed, wrapping ActivityFeed in a focusable container with an id target, to allow skipping the calendar via keyboard.
src/components/landing/activity/contribution-graph.tsx
src/components/landing/activity/section.tsx
Update combined activity aggregation to prefer Spotify tracks but fall back to YouTube Music tracks when Spotify has no data, and adjust tests accordingly.
  • Change getCombinedActivity to always call getSpotifyTracks and getCachedGitHub* first, then use getYTMusicTracks only when Spotify returns no tracks; remove hasYTMusicCredentials from the path.
  • Update tests to mock getYTMusicTracks instead of hasYTMusicCredentials and verify fallback behavior when Spotify returns an empty array.
  • Adjust test names and expectations to reflect YouTube Music being used only when Spotify has no tracks.
src/app/api/activity/combined/combine.ts
__tests__/app/api/activity/combined/combine.test.ts
Tighten image and environment configuration for YouTube avatars/covers and YT Music credentials.
  • Extend next/image remotePatterns to allow i.ytimg.com alongside yt3.googleusercontent.com.
  • Add YTM_COOKIE and YTM_AUTH_USER to the validated server env schema and to the runtime env mapping.
  • Update .env.example (or related env docs) to include the new YT Music-related env variables.
next.config.ts
src/server/env.ts
.env.example

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@vercel

vercel Bot commented Aug 1, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
remcostoeten Ready Ready Preview Aug 1, 2026 9:18pm

@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@remcostoeten, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 39 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d80be06a-7d37-4f69-a969-a4faf76fe303

📥 Commits

Reviewing files that changed from the base of the PR and between fbfe8b3 and 6eb58bc.

📒 Files selected for processing (71)
  • .env.example
  • __tests__/app/(marketing)/rss/route.test.ts
  • __tests__/app/api/activity/combined/combine.test.ts
  • __tests__/app/api/activity/combined/route.test.ts
  • __tests__/app/api/github/contributions/route.test.ts
  • __tests__/app/api/github/events/route.test.ts
  • __tests__/app/api/spotify/auth-url/route.test.ts
  • __tests__/app/api/spotify/callback/route.test.ts
  • __tests__/app/api/spotify/dev-token/route.test.ts
  • __tests__/app/api/spotify/now-playing/route.test.ts
  • __tests__/app/api/spotify/recent/route.test.ts
  • __tests__/app/api/sync/route.test.ts
  • __tests__/app/api/ytmusic/recent/route.test.ts
  • __tests__/components/landing/activity/activity-section-client.test.ts
  • __tests__/components/projects/components/project-preview.test.tsx
  • __tests__/components/projects/server/github.test.ts
  • __tests__/components/providers/providers.test.ts
  • __tests__/features/miscellaneous/diff-checker/utils/diff.test.ts
  • __tests__/features/miscellaneous/find-replace/utils/search.test.ts
  • __tests__/features/miscellaneous/find-replace/utils/text-transforms.test.ts
  • __tests__/features/miscellaneous/json-tool/utils/json-tool.test.ts
  • __tests__/features/miscellaneous/link-extractor/utils/link-extractor.test.ts
  • __tests__/features/miscellaneous/svg-converter/utilities/svg-converter.test.ts
  • __tests__/server/github/service.test.ts
  • __tests__/server/spotify/tracks.test.ts
  • __tests__/server/ytmusic/parser.test.ts
  • __tests__/server/ytmusic/tracks.test.ts
  • next.config.ts
  • src/app/(marketing)/blog/posts/yappin/stop-using-arrow-fnc-and-HUGE-REVEAL.md
  • src/app/(marketing)/packages/[slug]/page.tsx
  • src/app/(marketing)/ytmusic/page.tsx
  • src/app/(tools)/tools/[slug]/page.tsx
  • src/app/api/activity/combined/combine.ts
  • src/app/api/ytmusic/recent/route.ts
  • src/components/landing/activity/contribution-graph.tsx
  • src/components/landing/activity/section.tsx
  • src/components/layout/breadcrumbs.tsx
  • src/features/miscellaneous/components/icons/animated-icons.css
  • src/features/miscellaneous/components/media-dropzone.tsx
  • src/features/miscellaneous/components/media-trim-panel.tsx
  • src/features/miscellaneous/components/tool-quick-nav.tsx
  • src/features/miscellaneous/components/tool-renderer.tsx
  • src/features/miscellaneous/components/tool-seo-content.tsx
  • src/features/miscellaneous/components/tool-skeletons.tsx
  • src/features/miscellaneous/components/tools-hub.tsx
  • src/features/miscellaneous/constants/tool-seo.ts
  • src/features/miscellaneous/coordinate-marker/index.tsx
  • src/features/miscellaneous/diff-checker/index.tsx
  • src/features/miscellaneous/find-replace/index.tsx
  • src/features/miscellaneous/gif-to-video/hooks/use-gif-to-video-store.ts
  • src/features/miscellaneous/hemelsbreed/index.tsx
  • src/features/miscellaneous/hooks/use-media-session.ts
  • src/features/miscellaneous/hooks/use-tool-usage.ts
  • src/features/miscellaneous/hooks/use-trim-state.ts
  • src/features/miscellaneous/sendable-video/components/export-panel.tsx
  • src/features/miscellaneous/sendable-video/hooks/use-sendable-video-store.ts
  • src/features/miscellaneous/sendable-video/index.tsx
  • src/features/miscellaneous/sendable-video/utils/ffmpeg.ts
  • src/features/miscellaneous/utils/ffmpeg.ts
  • src/features/miscellaneous/utils/media-persistence.ts
  • src/features/miscellaneous/video-to-gif/hooks/use-video-to-gif-store.ts
  • src/features/packages/data.ts
  • src/features/ytmusic/components/ytmusic-diagnostics.tsx
  • src/features/ytmusic/types.ts
  • src/server/env.ts
  • src/server/ytmusic/auth.ts
  • src/server/ytmusic/cache.ts
  • src/server/ytmusic/index.ts
  • src/server/ytmusic/parser.ts
  • src/server/ytmusic/python-bridge.ts
  • src/server/ytmusic/tracks.ts

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

- render breadcrumbs server-side by scoping the useSearchParams
  Suspense boundary to individual links instead of the whole nav
- make the /tools/[slug] Suspense fallback mirror the real page
  chrome, including the actual static ToolQuickNav
- replace the single generic tool skeleton with per-tool skeletons
  that reproduce each tool's first-paint layout at all breakpoints
- reuse those skeletons for the internal hydration gates of
  find-replace, diff-checker, hemelsbreed and coordinate-marker so
  chunk-load, hydration and ready states share one stable frame

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 2 issues, and left some high level feedback:

  • In ActivityContributionGraph, the keyboard navigation moves by 7 cells on horizontal arrows (treating columns as weeks), which may feel inverted compared to the visual grid; consider aligning ArrowUp/Down/Left/Right movement to the actual row/column layout so keyboard users traverse the calendar predictably.
  • The HTTP status mapping in src/app/api/ytmusic/recent/route.ts is string-based and non-exhaustive; consider switching on the YTMusicStatus union (e.g. a switch or helper with never handling) so adding new statuses cannot silently fall back to 200.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `ActivityContributionGraph`, the keyboard navigation moves by 7 cells on horizontal arrows (treating columns as weeks), which may feel inverted compared to the visual grid; consider aligning ArrowUp/Down/Left/Right movement to the actual row/column layout so keyboard users traverse the calendar predictably.
- The HTTP status mapping in `src/app/api/ytmusic/recent/route.ts` is string-based and non-exhaustive; consider switching on the `YTMusicStatus` union (e.g. a `switch` or helper with `never` handling) so adding new statuses cannot silently fall back to 200.

## Individual Comments

### Comment 1
<location path="src/components/landing/activity/contribution-graph.tsx" line_range="435-444" />
<code_context>
+	const activeCellIndex = focusedCellIndex ?? todayCellIndex
+
+	function handleGridKeyDown(event: React.KeyboardEvent) {
+		const totalCells = totalWeeks * 7
+		const moveByKey: Record<string, number> = {
+			ArrowRight: 7,
+			ArrowLeft: -7,
+			ArrowDown: 1,
+			ArrowUp: -1
+		}
+
+		let nextIndex: number
+		if (event.key === 'Home') {
+			nextIndex = 0
+		} else if (event.key === 'End') {
+			nextIndex = totalCells - 1
+		} else if (event.key in moveByKey) {
+			nextIndex = Math.min(
+				totalCells - 1,
+				Math.max(0, activeCellIndex + moveByKey[event.key])
+			)
+		} else {
+			return
+		}
+
+		event.preventDefault()
+		setFocusedCellIndex(nextIndex)
+		cellRefs.current.get(nextIndex)?.focus()
+	}
+
</code_context>
<issue_to_address>
**suggestion:** Keyboard navigation assumes a full `totalWeeks * 7` grid, which can desync from actual rendered cells.

Because `totalCells` is derived from `totalWeeks * 7`, navigation can target indices for which no button exists (e.g. weeks with leading/trailing blanks). In those cases `cellRefs.current.get(nextIndex)` is undefined while `tabIndex` still treats that index as focused, so keyboard focus has no real DOM target. Consider deriving bounds from `cellRefs.current.size` or the actual `weeks` data so keyboard navigation only lands on rendered cells.

Suggested implementation:

```typescript
	const activeCellIndex = focusedCellIndex ?? todayCellIndex

	function handleGridKeyDown(event: React.KeyboardEvent) {
		const totalCells = cellRefs.current.size

		if (totalCells === 0) return

		const moveByKey: Record<string, number> = {
			ArrowRight: 7,
			ArrowLeft: -7,
			ArrowDown: 1,
			ArrowUp: -1
		}

		let nextIndex: number

		if (event.key === 'Home') {
			nextIndex = 0
		} else if (event.key === 'End') {
			nextIndex = totalCells - 1
		} else if (event.key in moveByKey) {
			const currentIndex = Math.min(activeCellIndex, totalCells - 1)

			nextIndex = Math.min(
				totalCells - 1,
				Math.max(0, currentIndex + moveByKey[event.key])
			)
		} else {
			return
		}

		// Only move focus to indices that have a rendered cell
		if (!cellRefs.current.has(nextIndex)) {
			return
		}

		event.preventDefault()
		setFocusedCellIndex(nextIndex)
		cellRefs.current.get(nextIndex)?.focus()
	}

	const cellRefs = useRef<Map<number, HTMLButtonElement>>(new Map())
	const [focusedCellIndex, setFocusedCellIndex] = useState<number | null>(null)

	const todayCellIndex = useMemo(() => {
		const todayStr = new Date().toISOString().split('T')[0]
		for (let weekIndex = 0; weekIndex < weeks.length; weekIndex++) {
			const dayIndex = weeks[weekIndex].findIndex(
				day => day.date === todayStr
			)
			if (dayIndex !== -1) return weekIndex * 7 + dayIndex

```

If `totalWeeks` is now unused after this change, it can be safely removed to avoid dead code. Also ensure that any code creating `cellRefs` entries uses zero-based, contiguous indices so that `cellRefs.current.size` accurately reflects the highest valid index; if indices can be sparse, consider normalizing them or adjusting navigation to use an ordered list of valid keys instead of numeric ranges.
</issue_to_address>

### Comment 2
<location path="src/app/api/ytmusic/recent/route.ts" line_range="44-48" />
<code_context>
 	}
 }
+
+function getHttpStatus(status: string): number {
+	if (status === 'unauthorized') return 401
+	if (status === 'unconfigured') return 503
+	if (status === 'error') return 502
+	return 200
+}
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Using `string` instead of the `YTMusicStatus` type for `getHttpStatus` weakens type safety.

`status` comes from `YTMusicResult.status`, which is a discriminated union (`YTMusicStatus`). Accepting a plain `string` allows unsupported values to be passed without compile-time errors. Typing the parameter as `YTMusicStatus` (and using `assertNever` in the default case) would keep the mapping aligned with the domain model and prevent invalid statuses at the type level.

Suggested implementation:

```typescript
function getHttpStatus(status: YTMusicStatus): number {
	if (status === 'unauthorized') return 401
	if (status === 'unconfigured') return 503
	if (status === 'error') return 502
	if (status === 'ok') return 200

	return assertNever(status)
}

```

To fully implement this change, you will also need to:
1. Ensure `YTMusicStatus` is imported or available in this file (e.g., `import type { YTMusicStatus } from '...';`).
2. Ensure `assertNever` is imported from your shared utilities (e.g., `import { assertNever } from '...';`) or implement it if it does not yet exist:
   `export function assertNever(x: never): never { throw new Error(\`Unexpected value: \${x}\`) }`.
3. Confirm that `'ok'` (or the equivalent success status) is one of the union members of `YTMusicStatus`; adjust the success-case branch accordingly if your union uses a different literal for the successful state.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +435 to +444
const totalCells = totalWeeks * 7
const moveByKey: Record<string, number> = {
ArrowRight: 7,
ArrowLeft: -7,
ArrowDown: 1,
ArrowUp: -1
}

let nextIndex: number
if (event.key === 'Home') {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Keyboard navigation assumes a full totalWeeks * 7 grid, which can desync from actual rendered cells.

Because totalCells is derived from totalWeeks * 7, navigation can target indices for which no button exists (e.g. weeks with leading/trailing blanks). In those cases cellRefs.current.get(nextIndex) is undefined while tabIndex still treats that index as focused, so keyboard focus has no real DOM target. Consider deriving bounds from cellRefs.current.size or the actual weeks data so keyboard navigation only lands on rendered cells.

Suggested implementation:

	const activeCellIndex = focusedCellIndex ?? todayCellIndex

	function handleGridKeyDown(event: React.KeyboardEvent) {
		const totalCells = cellRefs.current.size

		if (totalCells === 0) return

		const moveByKey: Record<string, number> = {
			ArrowRight: 7,
			ArrowLeft: -7,
			ArrowDown: 1,
			ArrowUp: -1
		}

		let nextIndex: number

		if (event.key === 'Home') {
			nextIndex = 0
		} else if (event.key === 'End') {
			nextIndex = totalCells - 1
		} else if (event.key in moveByKey) {
			const currentIndex = Math.min(activeCellIndex, totalCells - 1)

			nextIndex = Math.min(
				totalCells - 1,
				Math.max(0, currentIndex + moveByKey[event.key])
			)
		} else {
			return
		}

		// Only move focus to indices that have a rendered cell
		if (!cellRefs.current.has(nextIndex)) {
			return
		}

		event.preventDefault()
		setFocusedCellIndex(nextIndex)
		cellRefs.current.get(nextIndex)?.focus()
	}

	const cellRefs = useRef<Map<number, HTMLButtonElement>>(new Map())
	const [focusedCellIndex, setFocusedCellIndex] = useState<number | null>(null)

	const todayCellIndex = useMemo(() => {
		const todayStr = new Date().toISOString().split('T')[0]
		for (let weekIndex = 0; weekIndex < weeks.length; weekIndex++) {
			const dayIndex = weeks[weekIndex].findIndex(
				day => day.date === todayStr
			)
			if (dayIndex !== -1) return weekIndex * 7 + dayIndex

If totalWeeks is now unused after this change, it can be safely removed to avoid dead code. Also ensure that any code creating cellRefs entries uses zero-based, contiguous indices so that cellRefs.current.size accurately reflects the highest valid index; if indices can be sparse, consider normalizing them or adjusting navigation to use an ordered list of valid keys instead of numeric ranges.

Comment on lines +44 to +48
function getHttpStatus(status: string): number {
if (status === 'unauthorized') return 401
if (status === 'unconfigured') return 503
if (status === 'error') return 502
return 200

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (bug_risk): Using string instead of the YTMusicStatus type for getHttpStatus weakens type safety.

status comes from YTMusicResult.status, which is a discriminated union (YTMusicStatus). Accepting a plain string allows unsupported values to be passed without compile-time errors. Typing the parameter as YTMusicStatus (and using assertNever in the default case) would keep the mapping aligned with the domain model and prevent invalid statuses at the type level.

Suggested implementation:

function getHttpStatus(status: YTMusicStatus): number {
	if (status === 'unauthorized') return 401
	if (status === 'unconfigured') return 503
	if (status === 'error') return 502
	if (status === 'ok') return 200

	return assertNever(status)
}

To fully implement this change, you will also need to:

  1. Ensure YTMusicStatus is imported or available in this file (e.g., import type { YTMusicStatus } from '...';).
  2. Ensure assertNever is imported from your shared utilities (e.g., import { assertNever } from '...';) or implement it if it does not yet exist:
    export function assertNever(x: never): never { throw new Error(\Unexpected value: ${x}`) }`.
  3. Confirm that 'ok' (or the equivalent success status) is one of the union members of YTMusicStatus; adjust the success-case branch accordingly if your union uses a different literal for the successful state.

@remcostoeten

Copy link
Copy Markdown
Owner Author

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant