Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New bout page #730

Open
wants to merge 44 commits into
base: main
Choose a base branch
from
Open

New bout page #730

wants to merge 44 commits into from

Conversation

skanderm
Copy link
Contributor

@skanderm skanderm commented Dec 3, 2024

Summary by CodeRabbit

  • New Features

    • Introduced enhanced audio visualization tools including an interactive spectrogram timeline, frequency axis, timeline markers, and feed segments display.
    • Launched new bout management pages with integrated video playback and refined user interactions for managing audio feeds.
  • Chores / Refactor

    • Upgraded system configurations and job processing for improved performance and stability.
    • Streamlined error handling and rate limiting in key audio processing workflows.
    • Modernized UI layouts and updated dependencies for a more consistent user experience.

Copy link
Contributor

coderabbitai bot commented Dec 3, 2024

Walkthrough

The pull request introduces a variety of changes ranging from configuration updates, new function additions, dependency modifications, and schema migrations, to multiple new and updated React UI components. Back-end modules are updated to improve performance, error handling, and rate limiting—especially in audio image processing and GraphQL interactions—while front-end changes add new visual components and pages. The PR also refactors gettext integration and updates dependency declarations and code generation settings.

Changes

File(s) Change Summary
server/config/* (config.exs, dev.exs, prod.exs, runtime.exs, test.exs) Updated Oban job queues by adding an “audio_images” queue; added new environment config entries for dev, prod, and test; updated runtime host settings.
server/lib/orcasite/accounts/user.ex Removed the register_with_password function from the code interface.
server/lib/orcasite/global_setup.ex Added functions: populate_latest_feed_streams/2 and populate_feed_streams_range/3 for feed stream population.
server/lib/orcasite/notifications/workers/send_notification_email.ex Removed local continue?/0 and directly called Orcasite.RateLimiter.continue?/3 in the notification worker.
server/lib/orcasite/radio/*
(audio_image.ex, bout.ex, detection.ex, feed_segment.ex, feed_stream.ex, graphql_client.ex, workers/generate_spectrogram.ex, aws_client.ex)
Multiple updates: added new actions (read :for_feed, update :set_failed, create populate_with_segments); updated attribute constraints, relationships, and GraphQL queries; modified worker queue from :feeds to :audio_images with added attempt tracking and error handling; increased AWS Lambda retry attempts.
server/lib/orcasite/rate_limiter.ex Refactored continue?/3 to use the Hammer library and removed the GenServer-based recursive implementation.
server/lib/orcasite/repo.ex Added the min_pg_version/0 function returning a version struct.
server/lib/orcasite/types/audio_image_status.ex Updated enum values by adding :errored to the AudioImageStatus list.
server/lib/orcasite_web.ex, server/lib/orcasite_web/components/core_components.ex, server/lib/orcasite_web/gettext.ex Replaced importing of Gettext with use Gettext or use Gettext.Backend to integrate translation functionality.
server/mix.exs Updated the :ash_graphql dependency from a GitHub branch to version "~> 1.4.7" and added a new dependency on :recase.
server/priv/repo/migrations/* and server/priv/resource_snapshots/* Added migrations for installing ash-uuid v2 extension and enforcing non-null constraints in bouts; new JSON snapshots for bouts schema and updated extension version.
ui/package.json Added dependencies: date-fns and lodash, and added the dev dependency @types/lodash.
ui/src/components/Bouts/* Added multiple new components (BaseAudioWidthLayer, BoutPage, FeedSegmentsLayer, FrequencyAxisLayer, SpectrogramTimeline, TimelineMarker, TimelineTickerLayer) to enhance audio bout visualization.
ui/src/components/LoadingSpinner.tsx Introduced a new loading spinner component using Material-UI’s components.
ui/src/components/Player/BoutPlayer.tsx Added a new video playback component for bouts with enhanced state and error handling.
ui/src/components/layouts/SimpleLayout.tsx Renamed ReportsLayout to SimpleLayout and updated the export function accordingly.
ui/src/graphql/* Added new GraphQL fragments, mutations (createBout, updateBout), and queries (bout, getFeed, listDetections, listFeedStreams) for AudioImage, Bout, Feed, FeedSegment, and FeedStream.
ui/src/pages/bouts/* Added new pages: [boutId].tsx (bout detail), index.tsx, and new/[feedSlug].tsx (new bout creation) with updated layout assignments.
ui/src/pages/reports/* Updated layouts in [candidateId].tsx and index.tsx to use the new SimpleLayout.
ui/src/utils/time.ts Added a new function roundToNearest for rounding dates to a specified time unit.
ui/codegen.ts Changed GraphQL codegen configuration by replacing enumsAsTypes: true with enumsAsConst: true.

Sequence Diagram(s)

sequenceDiagram
    participant Oban as Oban Job
    participant Worker as GenerateSpectrogram Worker
    participant RateLimiter as RateLimiter
    participant DB as AudioImage Update

    Oban->>Worker: Pass job with audio_image_id & attempt
    Worker->>RateLimiter: Check rate limit (continue?/3)
    RateLimiter-->>Worker: :ok / denies then sleep and retry
    Worker->>DB: Process and update audio image
    alt Successful Processing
        DB-->>Worker: Return :ok
    else Error & Attempt>=3
        DB-->>Worker: Update audio image to failed
    end
    Worker-->>Oban: Return final status
Loading
sequenceDiagram
    participant Caller as Application Caller
    participant Client as GraphQL Client
    participant Finch as Finch HTTP Client
    participant API as GraphQL API

    Caller->>Client: get_feed(feed_slug)
    Client->>Client: Build GraphQL query
    Client->>Finch: submit(query)
    Finch->>API: Send HTTP POST request
    API-->>Finch: Respond with JSON data
    Finch-->>Client: Return response
    Client-->>Caller: Return decoded result
Loading

Suggested reviewers

  • paulcretu

Poem

Oh, what a hop in the code today,
With queues and queries in a splendid array,
New functions bloom and errors now mend,
Our modules and pages gracefully ascend,
I’m a rabbit with code so spry, ready to play!
🐇✨


📜 Recent review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 1b56c1f and e67238c.

⛔ Files ignored due to path filters (2)
  • ui/package-lock.json is excluded by !**/package-lock.json
  • ui/src/graphql/generated/index.ts is excluded by !**/generated/**
📒 Files selected for processing (1)
  • ui/package.json (2 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • ui/package.json
⏰ Context from checks skipped due to timeout of 90000ms (1)
  • GitHub Check: test

🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Generate unit testing code for this file.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query. Examples:
    • @coderabbitai generate unit testing code for this file.
    • @coderabbitai modularize this function.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read src/utils.ts and generate unit testing code.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.
    • @coderabbitai help me debug CodeRabbit configuration file.

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

CodeRabbit Commands (Invoked using PR comments)

  • @coderabbitai pause to pause the reviews on a PR.
  • @coderabbitai resume to resume the paused reviews.
  • @coderabbitai review to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
  • @coderabbitai full review to do a full review from scratch and review all the files again.
  • @coderabbitai summary to regenerate the summary of the PR.
  • @coderabbitai generate docstrings to generate docstrings for this PR.
  • @coderabbitai resolve resolve all the CodeRabbit review comments.
  • @coderabbitai configuration to show the current CodeRabbit configuration for the repository.
  • @coderabbitai help to get help.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Documentation and Community

  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

@skanderm skanderm temporarily deployed to orcasite-pr-730 December 6, 2024 20:06 Inactive
@paulcretu paulcretu marked this pull request as ready for review February 24, 2025 19:12
@paulcretu paulcretu self-requested a review as a code owner February 24, 2025 19:12
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 13

🧹 Nitpick comments (21)
server/lib/orcasite/repo.ex (1)

23-25: Add documentation explaining the PostgreSQL version requirement.

Please add @doc comments explaining why PostgreSQL 14.0.0 is required as the minimum version. This helps other developers understand the version constraints.

+  @doc """
+  Returns the minimum required PostgreSQL version.
+
+  PostgreSQL 14.0.0 is required because...
+  """
   def min_pg_version() do
     %Version{major: 14, minor: 0, patch: 0}
   end
ui/src/utils/time.ts (1)

16-35: Consider adding input validation and improving type safety.

The implementation is correct and well-documented. However, consider these improvements:

  1. Add input validation for timeUnitMs to ensure it's positive.
  2. Use TypeScript's literal union type for better type safety.
 export const roundToNearest = (
   date: Date,
   timeUnitMs: number,
-  roundMethod: "round" | "ceil" | "floor",
+  roundMethod: RoundMethod,
 ) => {
+  if (timeUnitMs <= 0) {
+    throw new Error("timeUnitMs must be positive");
+  }
+
   let roundFn;
   if (roundMethod === "round") {
     roundFn = Math.round;
   } else if (roundMethod === "ceil") {
     roundFn = Math.ceil;
   } else {
     roundFn = Math.floor;
   }

   return new Date(roundFn(date.getTime() / timeUnitMs) * timeUnitMs);
 };
+
+type RoundMethod = "round" | "ceil" | "floor";
server/lib/orcasite/radio/feed_stream.ex (1)

291-324: LGTM! Consider adding tests.

The new populate_with_segments action provides a clean way to upsert feed streams with their segments. The implementation is well-structured and handles all necessary attributes and relationships.

Would you like me to generate test cases for the new populate_with_segments action to ensure it handles various scenarios correctly?

ui/src/pages/bouts/new/[feedSlug].tsx (1)

18-21: Consider moving audioCategories to a constants file.

The audioCategories array could be moved to a shared constants file for reusability.

-  const audioCategories: AudioCategory[] = useMemo(
-    () => ["ANTHROPHONY", "BIOPHONY", "GEOPHONY"],
-    [],
-  );
+import { AUDIO_CATEGORIES } from '@/constants/audioCategories';
+  const audioCategories = useMemo(() => AUDIO_CATEGORIES, []);
ui/src/pages/bouts/[boutId].tsx (1)

29-39: Consider adding metadata for SEO.

While the page title is set, consider adding more metadata for better SEO.

Add more meta tags within the Head component:

 <Head>
   <title>Bout | Orcasound</title>
+  <meta name="description" content="View bout details and recordings" />
+  <meta property="og:title" content="Bout | Orcasound" />
+  <meta property="og:description" content="View bout details and recordings" />
 </Head>
server/priv/repo/migrations/20250127201038_install_ash-uuid_v2_extension.exs (2)

1-7: Consider enhancing module documentation.

While the documentation mentions this was auto-generated, it would be helpful to add information about the UUID v7 format and its benefits over other UUID versions.

 @moduledoc """
 Installs any extensions that are mentioned in the repo's `installed_extensions/0` callback

 This file was autogenerated with `mix ash_postgres.generate_migrations`
+
+ UUID v7 provides time-ordered UUIDs that are:
+ - Sortable by creation time
+ - Compatible with UUID v4 random format
+ - Microsecond precision timestamps
 """

49-53: Consider implementing a safer rollback strategy.

The current rollback strategy is commented out. Consider implementing a version that first checks if the functions are in use before dropping them.

 def down do
-  # Uncomment this if you actually want to uninstall the extensions
-  # when this migration is rolled back:
-  # execute("DROP FUNCTION IF EXISTS uuid_generate_v7()")
+  execute("""
+    DO $$
+    BEGIN
+      IF NOT EXISTS (
+        SELECT 1 FROM pg_depend d
+        JOIN pg_proc p ON p.oid = d.objid
+        WHERE p.proname = 'uuid_generate_v7'
+        AND d.deptype = 'n'
+      ) THEN
+        DROP FUNCTION IF EXISTS uuid_generate_v7();
+        DROP FUNCTION IF EXISTS timestamp_from_uuid_v7(uuid);
+      END IF;
+    END
+    $$;
+  """)
 end
ui/src/components/Bouts/TimelineMarker.tsx (2)

6-22: Consider adding accessibility props and documentation.

The component could benefit from additional accessibility props and JSDoc documentation.

+/**
+ * A component that renders a marker on a timeline with an icon and vertical line.
+ * @param time - The timestamp to display
+ * @param timelineStartTime - The start time of the timeline
+ * @param pixelsPerMinute - The number of pixels per minute for scaling
+ * @param zIndex - The z-index for layering
+ * @param Icon - The icon component to display
+ * @param iconProps - Additional props for the icon
+ * @param onClick - Click handler for the marker
+ */
 export function TimelineMarker({
   time,
   timelineStartTime,
   pixelsPerMinute,
   zIndex,
   Icon,
   iconProps,
   onClick,
+  ariaLabel,
 }: {
   time: Date;
   timelineStartTime: Date;
   pixelsPerMinute: number;
   zIndex: number;
   Icon: SvgIconComponent;
   iconProps?: { [key: string]: string };
   onClick?: () => void;
+  ariaLabel?: string;
 })

23-56: Add memoization for performance optimization.

Consider memoizing the component to prevent unnecessary re-renders when parent components update.

+import { memo } from "react";

-export function TimelineMarker({
+export const TimelineMarker = memo(function TimelineMarker({
   // ... props
-}) {
+}) {
   const offset = timeToOffset(time, timelineStartTime, pixelsPerMinute);
   return (
     <Box
       height={1}
       title={time.toLocaleTimeString(undefined, { timeZoneName: "short" })}
+      role="button"
+      aria-label={ariaLabel ?? time.toLocaleTimeString()}
       sx={{
         position: "absolute",
         left: `${offset}px`,
         zIndex: zIndex,
         cursor: "pointer",
       }}
       onClick={onClick}
     >
     // ... rest of the component
   );
-}
+});
ui/src/components/Bouts/BaseAudioWidthLayer.tsx (1)

22-41: Optimize rendering performance for large datasets.

The current implementation might cause performance issues with a large number of tiles. Consider using virtualization.

+import { FixedSizeList as List } from 'react-window';

+const TileItem = memo(({ index, style, data: { pixelsPerTile, zIndex, TICKER_HEIGHT } }) => (
+  <Box
+    key={index}
+    zIndex={zIndex}
+    bgcolor={"#bbb"}
+    display="flex"
+    alignItems="center"
+    justifyContent="center"
+    borderRight="1px solid #999"
+    sx={{
+      ...style,
+      minWidth: pixelsPerTile,
+      minHeight: `calc(100% - ${TICKER_HEIGHT}px)`,
+      top: TICKER_HEIGHT,
+    }}
+  />
+));

-      {Array(tiles)
-        .fill(0)
-        .map((_, idx) => (
-          <Box
-            key={idx}
-            zIndex={zIndex}
-            bgcolor={"#bbb"}
-            display="flex"
-            alignItems="center"
-            justifyContent="center"
-            borderRight="1px solid #999"
-            sx={{
-              minWidth: pixelsPerTile,
-              minHeight: `calc(100% - ${TICKER_HEIGHT}px)`,
-              position: "absolute",
-              left: idx * pixelsPerTile,
-              top: TICKER_HEIGHT,
-            }}
-          ></Box>
-        ))}
+      <List
+        height={window.innerHeight}
+        itemCount={tiles}
+        itemSize={pixelsPerTile}
+        width={tiles * pixelsPerTile}
+        itemData={{ pixelsPerTile, zIndex, TICKER_HEIGHT }}
+      >
+        {TileItem}
+      </List>
server/lib/orcasite/radio/workers/generate_spectrogram.ex (1)

12-39: Robust job processing flow.

The usage of attempt to handle retries up to 3 times is solid. Setting the resource to :set_failed on the final retry ensures a definitive status. The rate limit usage also helps mitigate resource overuse. If additional logging or alerting is desired on repeated failures, consider extending the final retry flow.

ui/src/components/Bouts/BoutPage.tsx (1)

70-579: Extensive bout management logic looks good.

The integration of feed queries, form state, and timeline visualization is cohesive. For maintainability, consider extracting some form or timeline logic into a custom hook or smaller components.

ui/src/components/Player/BoutPlayer.tsx (2)

52-52: Consider memoizing the now value and playerRef.

The now value and playerRef are recreated on every render. Consider using useMemo for now and useRef with an initial value for better performance.

-  const now = useMemo(() => new Date(), []);
-  const playerRef = useRef<VideoJSPlayer | null>(null);
+  const now = useMemo(() => new Date(), []);
+  const playerRef = useRef<VideoJSPlayer | null>(null);

Also applies to: 53-53


128-128: Use optional chaining for better readability.

The static analysis tool suggests using optional chaining for better readability.

-  setPlayerTimeRef && setPlayerTimeRef(time);
+  setPlayerTimeRef?.setPlayerTimeRef(time);

-  {playerDateTime !== undefined &&
-    playerDateTime.toLocaleTimeString()}
+  {playerDateTime?.toLocaleTimeString()}

-  {playerDateTime !== undefined &&
-    playerDateTime.toLocaleDateString()}
+  {playerDateTime?.toLocaleDateString()}

Also applies to: 232-232, 236-236

🧰 Tools
🪛 Biome (1.9.4)

[error] 128-128: Change to an optional chain.

Unsafe fix: Change to an optional chain.

(lint/complexity/useOptionalChain)

ui/src/components/Bouts/FeedSegmentsLayer.tsx (1)

38-44: Simplify null/undefined checks.

Multiple null/undefined checks can be simplified using optional chaining and nullish coalescing.

-        if (
-          feedSegment.startTime !== undefined &&
-          feedSegment.startTime !== null &&
-          feedSegment.endTime !== undefined &&
-          feedSegment.endTime !== null &&
-          typeof feedSegment.duration === "string"
-        ) {
+        if (feedSegment.startTime && feedSegment.endTime && typeof feedSegment.duration === "string") {
ui/src/components/Bouts/TimelineTickerLayer.tsx (2)

31-32: Extract magic numbers as named constants.

The values 20 and 8 for ticks and labels per window should be named constants for better maintainability.

+const TICKS_PER_WINDOW = 20;
+const LABELS_PER_WINDOW = 8;
-  const ticksPerWindow = 20;
-  const labelsPerWindow = 8;
+  const ticksPerWindow = TICKS_PER_WINDOW;
+  const labelsPerWindow = LABELS_PER_WINDOW;

36-67: Consider extracting scale arrays to a separate configuration file.

The tick and label scales are duplicated and could be maintained in a separate configuration file.

Create a new file timelineScales.ts:

export const TIMELINE_SCALES = [
  hoursToMilliseconds(24),
  hoursToMilliseconds(6),
  // ... rest of the scales
];
ui/src/components/Bouts/FrequencyAxisLayer.tsx (1)

25-45: Optimize tick calculation performance.

The tick calculation involves multiple array operations that could be optimized.

Consider memoizing the ticks calculation:

+  const ticks = useMemo(() => {
     return _.range(minScale, maxScale).flatMap((scale) => {
       // ... existing calculation
     });
+  }, [minScale, maxScale, adjustedMinFrequency, maxFrequency]);
server/lib/orcasite/radio/graphql_client.ex (2)

14-71: Extract or modularize the large inline query.

The query is lengthy and constructed inline, which can reduce readability. Consider extracting it into a separate function or template file to enhance maintainability and reuse.


73-87: Validate JSON decoding and handle potential errors.

When decoding the response with Jason.decode(body), consider handling decode failures more explicitly rather than returning the raw resp in the else clause. For example, you could return a structured error tuple or raise an exception to ensure the calling code can properly react to decode failures.

server/lib/orcasite/radio/bout.ex (1)

121-136: Possible duplication in create and update actions.

Both actions recalculate duration in a similar way. Factor out this logic into a shared change or helper function to reduce repetition and potential maintenance overhead.

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between bf6205f and 3360090.

⛔ Files ignored due to path filters (3)
  • server/mix.lock is excluded by !**/*.lock
  • ui/package-lock.json is excluded by !**/package-lock.json
  • ui/src/graphql/generated/index.ts is excluded by !**/generated/**
📒 Files selected for processing (55)
  • server/config/config.exs (1 hunks)
  • server/config/dev.exs (1 hunks)
  • server/config/prod.exs (1 hunks)
  • server/config/runtime.exs (2 hunks)
  • server/config/test.exs (1 hunks)
  • server/lib/orcasite/accounts/user.ex (0 hunks)
  • server/lib/orcasite/global_setup.ex (2 hunks)
  • server/lib/orcasite/notifications/workers/send_notification_email.ex (1 hunks)
  • server/lib/orcasite/radio/audio_image.ex (3 hunks)
  • server/lib/orcasite/radio/bout.ex (5 hunks)
  • server/lib/orcasite/radio/detection.ex (2 hunks)
  • server/lib/orcasite/radio/feed_segment.ex (1 hunks)
  • server/lib/orcasite/radio/feed_stream.ex (1 hunks)
  • server/lib/orcasite/radio/graphql_client.ex (1 hunks)
  • server/lib/orcasite/radio/workers/generate_spectrogram.ex (2 hunks)
  • server/lib/orcasite/rate_limiter.ex (1 hunks)
  • server/lib/orcasite/repo.ex (1 hunks)
  • server/lib/orcasite/types/audio_image_status.ex (1 hunks)
  • server/lib/orcasite_web.ex (2 hunks)
  • server/lib/orcasite_web/components/core_components.ex (1 hunks)
  • server/lib/orcasite_web/gettext.ex (1 hunks)
  • server/mix.exs (2 hunks)
  • server/priv/repo/migrations/20250127201038_install_ash-uuid_v2_extension.exs (1 hunks)
  • server/priv/repo/migrations/20250127201040_update_start_time_non_null_in_bouts.exs (1 hunks)
  • server/priv/resource_snapshots/repo/bouts/20250127201040.json (1 hunks)
  • server/priv/resource_snapshots/repo/extensions.json (1 hunks)
  • ui/package.json (2 hunks)
  • ui/src/components/Bouts/BaseAudioWidthLayer.tsx (1 hunks)
  • ui/src/components/Bouts/BoutPage.tsx (1 hunks)
  • ui/src/components/Bouts/FeedSegmentsLayer.tsx (1 hunks)
  • ui/src/components/Bouts/FrequencyAxisLayer.tsx (1 hunks)
  • ui/src/components/Bouts/SpectrogramTimeline.tsx (1 hunks)
  • ui/src/components/Bouts/TimelineMarker.tsx (1 hunks)
  • ui/src/components/Bouts/TimelineTickerLayer.tsx (1 hunks)
  • ui/src/components/LoadingSpinner.tsx (1 hunks)
  • ui/src/components/Player/BoutPlayer.tsx (1 hunks)
  • ui/src/components/layouts/SimpleLayout.tsx (2 hunks)
  • ui/src/graphql/fragments/AudioImageParts.graphql (1 hunks)
  • ui/src/graphql/fragments/BoutParts.graphql (1 hunks)
  • ui/src/graphql/fragments/FeedParts.graphql (1 hunks)
  • ui/src/graphql/fragments/FeedSegmentParts.graphql (1 hunks)
  • ui/src/graphql/fragments/FeedStreamParts.graphql (1 hunks)
  • ui/src/graphql/mutations/createBout.graphql (1 hunks)
  • ui/src/graphql/mutations/updateBout.graphql (1 hunks)
  • ui/src/graphql/queries/getBout.graphql (1 hunks)
  • ui/src/graphql/queries/getFeed.graphql (1 hunks)
  • ui/src/graphql/queries/listDetections.graphql (1 hunks)
  • ui/src/graphql/queries/listFeedStreams.graphql (1 hunks)
  • ui/src/hooks/useTimestampFetcher.ts (1 hunks)
  • ui/src/pages/bouts/[boutId].tsx (1 hunks)
  • ui/src/pages/bouts/index.tsx (2 hunks)
  • ui/src/pages/bouts/new/[feedSlug].tsx (1 hunks)
  • ui/src/pages/reports/[candidateId].tsx (2 hunks)
  • ui/src/pages/reports/index.tsx (2 hunks)
  • ui/src/utils/time.ts (1 hunks)
💤 Files with no reviewable changes (1)
  • server/lib/orcasite/accounts/user.ex
✅ Files skipped from review due to trivial changes (4)
  • ui/src/graphql/fragments/FeedSegmentParts.graphql
  • ui/src/components/layouts/SimpleLayout.tsx
  • server/priv/resource_snapshots/repo/extensions.json
  • server/priv/resource_snapshots/repo/bouts/20250127201040.json
🧰 Additional context used
🪛 GitHub Actions: CI
ui/src/hooks/useTimestampFetcher.ts

[warning] 7-7: exported declaration 'getBucketBase' not used within other modules import/no-unused-modules

ui/src/components/Bouts/BoutPage.tsx

[warning] 68-68: 'log' is assigned a value but never used. Allowed unused vars must match /^_/u. unused-imports/no-unused-vars

ui/src/components/Bouts/SpectrogramTimeline.tsx

[warning] 132-132: React Hook useCallback received a function whose dependencies are unknown. Pass an inline function instead. react-hooks/exhaustive-deps


[error] 409-409: Type error: Type '{ timelineStartTime: Date; timelineEndTime: Date; windowStartTime: Date; windowEndTime: Date; pixelsPerMinute: number; zIndex: number; }' is not assignable to type 'IntrinsicAttributes & { timelineStartTime: Date; windowStartTime: Date; windowEndTime: Date; pixelsPerMinute: number; zIndex: number; }'. Property 'timelineEndTime' does not exist on type 'IntrinsicAttributes & { timelineStartTime: Date; windowStartTime: Date; windowEndTime: Date; pixelsPerMinute: number; zIndex: number; }'.

ui/src/pages/bouts/[boutId].tsx

[warning] 11-11: 'targetTime' is assigned a value but never used. Allowed unused vars must match /^_/u. unused-imports/no-unused-vars

ui/src/components/Bouts/FrequencyAxisLayer.tsx

[warning] 12-12: 'scaling' is defined but never used. Allowed unused args must match /^_/u. unused-imports/no-unused-vars

🪛 Biome (1.9.4)
ui/src/components/Player/BoutPlayer.tsx

[error] 128-128: Change to an optional chain.

Unsafe fix: Change to an optional chain.

(lint/complexity/useOptionalChain)


[error] 232-233: Change to an optional chain.

Unsafe fix: Change to an optional chain.

(lint/complexity/useOptionalChain)


[error] 236-237: Change to an optional chain.

Unsafe fix: Change to an optional chain.

(lint/complexity/useOptionalChain)

🔇 Additional comments (63)
server/lib/orcasite_web/gettext.ex (1)

23-23: LGTM! Good improvement in Gettext configuration.

The change from use Gettext to use Gettext.Backend explicitly defines this module as a Gettext backend, which is more precise and follows best practices for internationalization in Phoenix applications.

server/lib/orcasite_web/components/core_components.ex (1)

20-20: LGTM! Consistent with the Gettext refactor.

The change to use Gettext, backend: OrcasiteWeb.Gettext aligns with the application-wide standardization of Gettext usage while maintaining the same translation functionality.

server/lib/orcasite_web.ex (2)

45-45: LGTM! Consistent Gettext configuration in controllers.

The change to use Gettext, backend: OrcasiteWeb.Gettext in the controller section maintains consistency with the application-wide Gettext refactor.


86-86: LGTM! Consistent Gettext configuration in HTML helpers.

The change to use Gettext, backend: OrcasiteWeb.Gettext in the html_helpers section completes the standardization of Gettext usage across the application.

server/config/test.exs (1)

55-55: LGTM!

The environment configuration is consistent with the pattern used in other environment files (dev.exs and prod.exs).

ui/src/graphql/fragments/AudioImageParts.graphql (1)

1-12: LGTM!

The fragment is well-structured and includes all necessary fields for audio images. It's appropriately used in the listFeedStreams query to retrieve complete audio images.

ui/src/graphql/fragments/FeedStreamParts.graphql (1)

1-12: LGTM! Well-structured fragment definition.

The fragment includes all necessary fields for stream metadata and playback information, organized logically.

server/config/dev.exs (1)

117-117: LGTM! Environment configuration is correctly set.

The environment is properly set to :dev which is consistent with this development configuration file.

ui/src/graphql/queries/getBout.graphql (1)

1-8:

✅ Verification successful

LGTM! Verify fragment dependencies.

The query is well-structured and properly uses fragments for code reuse.

Let's verify that the required fragments exist:


🏁 Script executed:

#!/bin/bash
# Description: Check for required fragment definitions

echo "Checking for BoutParts fragment..."
fd "BoutParts.graphql" ui/src/graphql/fragments

echo "Checking for FeedParts fragment..."
fd "FeedParts.graphql" ui/src/graphql/fragments

Length of output: 339


Action: Fragments Verified

The query is well-structured and all required fragment definitions exist:

  • BoutParts fragment: ui/src/graphql/fragments/BoutParts.graphql
  • FeedParts fragment: ui/src/graphql/fragments/FeedParts.graphql

LGTM!

ui/src/graphql/fragments/FeedParts.graphql (1)

1-15:

❓ Verification inconclusive

LGTM! Well-structured fragment definition.

The fragment includes all necessary fields for feed metadata, location information, and media URLs.

Let's verify the schema types for URL fields:


🏁 Script executed:

#!/bin/bash
# Description: Check schema types for URL fields

echo "Checking schema types for URL fields..."
ast-grep --pattern 'type Feed {
  $$$
  thumbUrl: $_
  imageUrl: $_
  mapUrl: $_
  $$$
}'

Length of output: 180


Action: Confirm correct schema types for URL fields

The GraphQL fragment itself is well-structured and includes all the expected feed properties. However, our attempt to locate the Feed type definition for the URL fields (thumbUrl, imageUrl, mapUrl) using the provided ast-grep query did not return any matches. Please double-check that the Feed type in the GraphQL schema defines these fields with the appropriate types (e.g., URL scalar or string) to ensure consistency.

ui/src/components/LoadingSpinner.tsx (1)

1-9: LGTM! Well-structured loading spinner component.

The component is well-implemented with proper TypeScript typing, reusable design, and centered layout. The use of Material-UI components and props spreading allows for good customization options.

server/config/prod.exs (1)

84-84: LGTM! Environment configuration added.

The addition of the environment configuration aligns with similar configurations in dev and test environments.

ui/src/graphql/queries/listDetections.graphql (1)

2-14: LGTM! Feed filtering capability added to detections query.

The addition of the feedId parameter enhances the query's filtering capabilities while maintaining proper GraphQL structure.

server/lib/orcasite/types/audio_image_status.ex (1)

2-2: Verify the distinction between :errored and :failed states.

The addition of the :errored state alongside the existing :failed state suggests different error scenarios. Please clarify the distinction between these states to ensure proper error handling.

Could you explain when an audio image should be marked as :errored versus :failed? This will help ensure consistent error handling across the application.

ui/src/graphql/queries/getFeed.graphql (1)

3-3: LGTM! Good use of GraphQL fragments.

Using the FeedParts fragment improves code reusability and maintainability by centralizing the common fields.

ui/package.json (1)

30-30:

❓ Verification inconclusive

Verify the compatibility of package versions.

The new dependencies look good, but let's verify they are using the latest stable versions and check for any known vulnerabilities.

Also applies to: 35-35, 58-58


🏁 Script executed:

#!/bin/bash
# Description: Check for latest versions and vulnerabilities in the new dependencies.

# Test: Check npm for latest versions
npm view date-fns version
npm view lodash version
npm view @types/lodash version

# Test: Check for known vulnerabilities
npm audit

Length of output: 469


Version and Vulnerability Check – Manual Verification Needed

The dependencies appear correctly set:

  • date-fns is at 4.1.0 (matches the latest).
  • lodash is at 4.17.21 (up-to-date).
  • @types/lodash is specified as ^4.17.13, while the latest available version is 4.17.15—you may consider bumping this to keep consistency if there’s no breaking change.

Note: The vulnerability check via npm audit failed because there isn’t a lockfile present. Please generate one (e.g., with npm i --package-lock-only) and re-run the audit to ensure there are no vulnerabilities.

ui/src/graphql/mutations/createBout.graphql (1)

1-26: LGTM! Well-structured mutation with comprehensive error handling.

The mutation is well-designed with:

  • Clear parameter definitions
  • Optional endTime parameter
  • Comprehensive error handling
  • Consistent use of the BoutParts fragment
ui/src/pages/bouts/index.tsx (2)

13-13: LGTM!

The layout change is consistent with similar changes in other pages, indicating an intentional refactoring of the layout system.


91-91: LGTM!

The layout assignment correctly matches the imported layout component.

ui/src/pages/reports/[candidateId].tsx (2)

6-6: LGTM!

The layout change aligns with the broader layout refactoring across the application.


72-72: LGTM!

The layout assignment correctly matches the imported layout component.

ui/src/graphql/mutations/updateBout.graphql (1)

1-22: LGTM!

The mutation is well-structured with:

  • Clear parameter definitions
  • Comprehensive error handling
  • Consistent data structure using BoutParts fragment
server/lib/orcasite/notifications/workers/send_notification_email.ex (1)

42-42: LGTM! Good refactoring.

Moving the rate limiting logic to a dedicated module improves code organization and ensures consistent rate limiting across the application.

ui/src/pages/reports/index.tsx (1)

17-17: LGTM! Layout change is consistent.

The change to use getSimpleLayout aligns with similar changes across other pages, maintaining consistency in the UI.

Also applies to: 166-166

server/mix.exs (2)

92-92: LGTM! Switching to a stable version.

Moving from a GitHub branch to a stable version of ash_graphql improves dependency stability.


112-113: LGTM! Good additions.

The new dependencies enhance the project's capabilities:

  • ecto_psql_extras provides useful PostgreSQL monitoring tools
  • recase adds string case conversion utilities
server/config/config.exs (1)

94-94: LGTM! Good queue configuration.

Adding the audio_images queue with a concurrency of 5 is appropriate for handling image processing tasks without overwhelming the system.

ui/src/pages/bouts/new/[feedSlug].tsx (2)

13-13: Review the hardcoded date.

The targetTime is hardcoded to a future date (2025-01-06). Consider making this dynamic or configurable.


39-40: LGTM! Good error handling and component structure.

The component properly handles loading states and error cases, and follows a clean structure with appropriate head tags and main content.

Also applies to: 42-57

server/lib/orcasite/radio/detection.ex (2)

54-57: LGTM! Improved relationship formatting.

The block syntax for the feed relationship improves code readability.


109-111: LGTM! Well-implemented feed filtering.

The feed_id argument and filter are properly implemented, allowing efficient filtering of detections by feed.

server/priv/repo/migrations/20250127201040_update_start_time_non_null_in_bouts.exs (2)

10-16: LGTM! The migration enforces data integrity for the bout page.

The changes ensure that bouts always have a category and start time, which is essential for proper display on the new bout page. The removal of the ongoing column suggests a shift in how bout status is tracked.


18-24: LGTM! Proper rollback functionality is implemented.

The down function correctly reverses all changes, making it safe to roll back if needed.

ui/src/pages/bouts/[boutId].tsx (1)

23-27: LGTM! Proper error handling is implemented.

The component handles all possible states appropriately:

  • Loading state with spinner
  • Missing bout error
  • Missing feed error
server/config/runtime.exs (1)

24-24: LGTM! Host configuration is properly standardized.

The changes consistently use "live.orcasound.net" as the default host across both configurations, with proper environment variable override support.

Also applies to: 54-54

ui/src/graphql/queries/listFeedStreams.graphql (2)

7-21: LGTM! Feed streams filtering is well-implemented.

The query effectively filters feed streams:

  • Handles both active and completed streams
  • Uses proper time range filtering
  • Includes pagination with sorting

26-40: LGTM! Nested data fetching is properly optimized.

The query efficiently fetches related data:

  • Feed segments are filtered by time range
  • Audio images are filtered by completion status
  • Proper use of fragments for field selection
server/priv/repo/migrations/20250127201038_install_ash-uuid_v2_extension.exs (2)

38-46: LGTM! Timestamp extraction is well-implemented.

The timestamp extraction function is correctly marked as IMMUTABLE, PARALLEL SAFE, STRICT, and LEAKPROOF, which are appropriate for this deterministic operation.


10-36:

❓ Verification inconclusive

Verify UUID v7 implementation against the latest draft RFC.

The implementation looks correct but should be verified against the latest UUID v7 specification.


🌐 Web query:

What is the latest RFC draft for UUID version 7?

💡 Result:

The latest specification for UUID version 7 is defined in RFC 9562, which supersedes RFC 4122 and was approved as a Proposed Standard by the Internet Engineering Steering Group (IESG) in November 2023[8][10]. This revision introduces UUIDv7 alongside other modern formats like UUIDv8.

Key Details:

  • UUIDv7 Structure: Combines a 48-bit Unix Epoch timestamp (millisecond precision) with 74 bits of randomness split into two blocks[3][4][5]. The layout ensures chronological sorting while maintaining high entropy:
    48 bits: Timestamp (ms since 1970-01-01)
     4 bits: Version (7)
    12 bits: Sub-millisecond data or randomness
     2 bits: Variant (RFC4122-compliant)
    62 bits: Randomness/counter
    
  • Advantages:
    • Time-sortable for database indexing efficiency[7]
    • Collision resistance comparable to UUIDv4[8]
    • No MAC address exposure (unlike UUIDv1)[4]

Implementation Status:

  • Officially published as RFC 9562 (formerly draft-ietf-uuidrev-rfc4122bis)[10]
  • Supported in libraries like Python's ramsey/uuid[4][9] and Rust's uuid crate[5]
  • Adopted by companies like Buildkite for distributed systems[7]

For developers, UUIDv7 is now stable and recommended over older versions like v1/v6 where possible[3][7].

Citations:


Review: UUIDv7 Function Verification Against RFC 9562

The implementation in server/priv/repo/migrations/20250127201038_install_ash-uuid_v2_extension.exs appears to align with RFC 9562, which defines UUIDv7 as follows:

  • 48 bits: Millisecond timestamp (ms since 1970-01-01)
  • 4 bits: Version (should be 7)
  • 12 bits: Sub-millisecond data (fractional component scaled to 12 bits)
  • Remaining bits: Variant and randomness

Implementation Breakdown:

  • Timestamp Overlay:
    The function extracts the millisecond timestamp via
    floor(extract(epoch FROM timestamp) * 1000)::BIGINT and uses int8send with a substring (starting at byte 3 for 6 bytes) to embed the 48‑bit timestamp. This ensures the upper 6 bytes contain the full timestamp.

  • Sub-millisecond & Version Injection:
    The remaining microseconds (i.e. the fractional part from the full microsecond value) are scaled by 4.096 to yield a 12‑bit value. Then, using PostgreSQL’s set_byte:

    • Byte at offset 6 (0‑indexed): Is set to a new value formed by concatenating the 4‑bit binary literal 0111 (representing version 7) with the upper 4 bits of the 12‑bit fraction.
    • Byte at offset 7 (0‑indexed): Is replaced with the lower 8 bits of the 12‑bit fractional value.
  • Randomness and Variant:
    The function calls gen_random_uuid() and applies uuid_send; since only the first 8 bytes are modified (the overlay and set_byte calls), the randomness and RFC‑compliant variant bits (embedded in the generated UUID) remain intact.

Action Items:

  • Indexing Verification: Please double-check that the usage of PostgreSQL’s 1‑indexed overlay (for replacing bytes 1–6) and the 0‑indexed set_byte (modifying bytes at offsets 6 and 7) is correctly coordinated so that the full 48‑bit timestamp remains preserved and the version and sub-millisecond fields are accurately positioned.
  • Conformance Testing: Ensure that tests confirm the resulting UUIDs adhere to RFC 9562’s structure (48‑bit timestamp, 4‑bit version, 12‑bit sub‑millisecond field, and preserved variant/random).

If these aspects have been manually verified or validated through tests, the implementation is compliant with the latest UUIDv7 draft (RFC 9562).

server/lib/orcasite/global_setup.ex (1)

39-43: LGTM! Clear and concise implementation.

The function provides a convenient way to populate recent feed streams with sensible defaults.

server/lib/orcasite/radio/audio_image.ex (4)

81-91: Looks good for a dedicated feed-based query.

The pagination configuration and filter expression are clear and straightforward. This read action nicely scopes audio image retrieval to a specific feed.


216-216: New :errored status is consistent.

Switching the status from :failed to :errored provides a more precise indication of error states. Ensure that all consuming code or downstream logic is updated accordingly.


230-233: Verify the distinction between :failed and :errored.

Two separate statuses (:errored vs. :failed) may be confusing unless there's a clear semantic difference. Confirm whether both are needed or if one can be consolidated.

Would you like to check all references to :failed and :errored in the codebase to ensure they are intentionally distinct?


238-238: GraphQL configuration for listing audio images by feed.

Declaring the new query :audio_images, :for_feed provides a convenient entry point for clients to retrieve feed-specific audio images. This looks good to me.

Also applies to: 240-242

server/lib/orcasite/radio/workers/generate_spectrogram.ex (1)

3-3: Queue renamed from :feeds to :audio_images.

This aligns well with the specialized queue introduced for audio images. Ensure the queue is correctly declared in the config and monitored in production.

ui/src/components/Bouts/BoutPage.tsx (2)

581-616: Category icon helper is clear and user-friendly.

Using icons for various categories is intuitive. The approach of returning different images based on the "BIOPHONY", "ANTHROPHONY", or "GEOPHONY" states is well-structured.


618-636: Tab panel component is well-implemented.

A straightforward pattern for conditionally rendering tab content. This promotes reusability across other parts of the UI if needed.

server/lib/orcasite/radio/graphql_client.ex (2)

1-12: Consider using variables instead of string interpolation for GraphQL queries.

String interpolation within the query (e.g., feed(slug: "#{feed_slug}")) may lead to potential injection vulnerabilities if feed_slug originates from user input. GraphQL typically supports using variables to avoid direct interpolation.


89-98: Confirm environment key naming.

This function references Application.get_env(:orcasite, :prod_host). Ensure that this environment configuration is always present and references the correct environment host, even in non-production environments. Otherwise, consider a fallback or environment-based logic.

ui/src/components/Bouts/SpectrogramTimeline.tsx (1)

213-231: Reassess the frequent setInterval approach.

Centering the window on the current time every 100ms can be resource-intensive. Consider alternatives, such as event-based updates from the player to reduce overhead and complexity.
[performance]

server/lib/orcasite/rate_limiter.ex (2)

2-6: Verify key conversion logic when using tuples and lists.

You convert tuples to lists and lists to joined strings (e.g., Enum.join(keys, ":")). Ensure the resulting key is unique enough to distinguish different tuples/lists, especially if “:” can appear in original data.


8-10: Check for potential naming conflicts with Atom keys.

When converting atoms to strings (Atom.to_string(key)), confirm that collisions with string-based keys won't occur. This is especially important if you rely on distinct naming.

server/lib/orcasite/radio/bout.ex (11)

23-23: Double-check non-nil constraints.

By disallowing nil for start_time and category, you ensure these fields must always be provided. Verify there's no valid scenario that requires them to be absent. If so, consider providing defaults or modifying business logic.

Also applies to: 29-29


39-41: Review public exposure of feed relationship.

Enabling public? on this belongs_to relationship can expose sensitive feed data. Confirm that the resulting GraphQL or JSON API responses won't inadvertently disclose confidential information.


70-70: Change in default actions.

Removing the :update action from the default set is aligned with your custom update action. Verify that any existing consumers of the default :update action aren’t affected.


81-81: Conditional filter usage.

Conditionally filtering by feed_id is straightforward and efficient. Approaches like this effectively limit results when feed_id is provided and return all records otherwise.


86-87: Clarity on accepted attributes during creation.

Accepting :category, :start_time, and :end_time during creation is consistent with your domain constraints (e.g., non-nil start_time). This change aligns well with the resource’s data requirements.


88-89: Mandatory feed_id argument.

Requiring a non-nil feed_id ensures that every bout is associated with a feed, enforcing referential integrity. This appears logically sound.


90-97: Ensure relationship validity.

Using Ash.Changeset.manage_relationship(:feed, …) to link the feed_id argument to the feed relationship is correct. Consider how the system handles invalid feed IDs—Ash will error out, but confirm this behavior is acceptable.


99-109: Duration calculation logic.

Computing duration with DateTime.diff(end_time, start_time, :millisecond) / 1000 is reasonable. However, if start_time is ever after end_time, this could yield negative values. Confirm whether that scenario is permissible or if further validation is needed.


141-141: GraphQL attribute types set to :id.

Declaring feed_id and feed_stream_id as ID types aligns with referencing external resources in your GraphQL schema. This remains consistent with existing conventions.


145-145: Added get :bout query.

Introducing a specific query for retrieving a single bout by ID is a common GraphQL best practice. This provides a clear and efficient access pattern.


150-150: Added update_bout mutation.

Publishing the custom update action as a GraphQL mutation neatly integrates with your resource’s security policies. Make sure to test it thoroughly for authorization checks and data validation.

Comment on lines 1 to 8
fragment BoutParts on Bout {
id
category
duration
endTime
startTime
endTime
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

Remove duplicate endTime field.

The endTime field appears twice in the fragment. Please remove the duplicate at line 7.

 fragment BoutParts on Bout {
   id
   category
   duration
   endTime
   startTime
-  endTime
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fragment BoutParts on Bout {
id
category
duration
endTime
startTime
endTime
}
fragment BoutParts on Bout {
id
category
duration
endTime
startTime
}

Comment on lines 7 to 8
export const getBucketBase = (bucket: string) =>
`https://${bucket}.s3.amazonaws.com`;
Copy link
Contributor

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

Verify the usage of the exported function.

The function is now exported but the linter indicates it's not being used by other modules. Please verify if this export is necessary for the new bout page components.

Run the following script to verify the function usage:


🏁 Script executed:

#!/bin/bash
# Description: Verify if getBucketBase is imported and used in other files.

# Test: Search for imports of getBucketBase. Expect: Multiple imports in Bouts components.
rg -A 5 $'import.*getBucketBase.*from.*useTimestampFetcher'

# Test: Search for usage of getBucketBase. Expect: Multiple usages in Bouts components.
rg -A 5 $'getBucketBase\('

Length of output: 914


Action Required: Verify and Adjust the Export of getBucketBase

Our investigation shows that getBucketBase is only being used within the same file (specifically in the construction of URLs for both latest.txt and the HLS stream) and is not imported elsewhere in the codebase. This is likely why the linter is flagging it as an unused export. Please confirm if this function is indeed intended for external use (for example, by new bout page components). If not, consider removing the export and making it a local helper function to resolve the linter warning.

🧰 Tools
🪛 GitHub Actions: CI

[warning] 7-7: exported declaration 'getBucketBase' not used within other modules import/no-unused-modules

Comment on lines 10 to 11
const BoutShowPage: NextPageWithLayout = () => {
const targetTime = new Date("2024-12-11 19:55:44.013Z");
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

Remove unused targetTime variable.

The targetTime variable is defined but never used, as flagged by the CI pipeline.

Apply this diff to fix the issue:

-const targetTime = new Date("2024-12-11 19:55:44.013Z");
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const BoutShowPage: NextPageWithLayout = () => {
const targetTime = new Date("2024-12-11 19:55:44.013Z");
const BoutShowPage: NextPageWithLayout = () => {
// The unused `targetTime` variable has been removed.
🧰 Tools
🪛 GitHub Actions: CI

[warning] 11-11: 'targetTime' is assigned a value but never used. Allowed unused vars must match /^_/u. unused-imports/no-unused-vars

Comment on lines 407 to 415
<TimelineTickerLayer
timelineStartTime={timelineStartTime}
timelineEndTime={timelineEndTime}
windowStartTime={windowStartTime}
windowEndTime={windowEndTime}
pixelsPerMinute={pixelsPerMinute}
zIndex={3}
/>
)}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

Remove or rename the unused prop timelineEndTime to avoid type errors.

The pipeline indicates a TypeScript error at line 409 regarding timelineEndTime. The TimelineTickerLayer component’s prop type does not accept a timelineEndTime. Either remove that prop if unused or update the child component’s prop definition accordingly.

Proposed fix:

-  timelineEndTime={timelineEndTime}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<TimelineTickerLayer
timelineStartTime={timelineStartTime}
timelineEndTime={timelineEndTime}
windowStartTime={windowStartTime}
windowEndTime={windowEndTime}
pixelsPerMinute={pixelsPerMinute}
zIndex={3}
/>
)}
<TimelineTickerLayer
timelineStartTime={timelineStartTime}
windowStartTime={windowStartTime}
windowEndTime={windowEndTime}
pixelsPerMinute={pixelsPerMinute}
zIndex={3}
/>
)}
🧰 Tools
🪛 GitHub Actions: CI

[error] 409-409: Type error: Type '{ timelineStartTime: Date; timelineEndTime: Date; windowStartTime: Date; windowEndTime: Date; pixelsPerMinute: number; zIndex: number; }' is not assignable to type 'IntrinsicAttributes & { timelineStartTime: Date; windowStartTime: Date; windowEndTime: Date; pixelsPerMinute: number; zIndex: number; }'. Property 'timelineEndTime' does not exist on type 'IntrinsicAttributes & { timelineStartTime: Date; windowStartTime: Date; windowEndTime: Date; pixelsPerMinute: number; zIndex: number; }'.

Comment on lines 132 to 135
const setWindowStartTime = useCallback(
_.throttle(setWindowStartTimeUnthrottled, 500, { trailing: false }),
[],
);
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

Include dependencies in the useCallback hook to avoid stale closures.

Your pipeline flagged a warning for react-hooks/exhaustive-deps regarding the useCallback usage for throttling. Make sure to list every variable or function used inside the callback in the dependency array to prevent potential bugs.

Consider adjusting as follows:

-const setWindowStartTime = useCallback(
-  _.throttle(setWindowStartTimeUnthrottled, 500, { trailing: false }),
-  [],
-);
+const setWindowStartTime = useCallback(() => {
+  return _.throttle(setWindowStartTimeUnthrottled, 500, { trailing: false })();
+}, [setWindowStartTimeUnthrottled]);

Adjust the approach as needed to ensure the throttle function is not recreated incorrectly or capturing stale data.

Committable suggestion skipped: line range outside the PR's diff.

🧰 Tools
🪛 GitHub Actions: CI

[warning] 132-132: React Hook useCallback received a function whose dependencies are unknown. Pass an inline function instead. react-hooks/exhaustive-deps

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

♻️ Duplicate comments (1)
ui/src/components/Bouts/SpectrogramTimeline.tsx (1)

132-146: ⚠️ Potential issue

Fix throttling implementation to prevent stale closures.

The current throttling implementation may lead to stale closures. The throttled functions should be memoized with their dependencies.

-const setWindowStartTime = useCallback(
-  _.throttle(setWindowStartTimeUnthrottled, 500, { trailing: false }),
-  [],
-);
+const setWindowStartTime = useCallback(() => {
+  return _.throttle(setWindowStartTimeUnthrottled, 500, { trailing: false })();
+}, [setWindowStartTimeUnthrottled]);

-const setWindowEndTime = useCallback(
-  _.throttle(setWindowEndTimeUnthrottled, 500, { trailing: false }),
-  [],
-);
+const setWindowEndTime = useCallback(() => {
+  return _.throttle(setWindowEndTimeUnthrottled, 500, { trailing: false })();
+}, [setWindowEndTimeUnthrottled]);
🧹 Nitpick comments (5)
ui/src/components/Bouts/BoutPage.tsx (2)

62-107: Consider adding prop validation for targetTime and targetAudioCategory.

The component accepts optional props but doesn't validate their values. Consider adding validation to ensure targetTime is a valid date and targetAudioCategory is one of the allowed values.

+const isValidDate = (date?: Date) => date instanceof Date && !isNaN(date.getTime());
+
 export default function BoutPage({
   isNew,
   feed,
   targetAudioCategory,
   targetTime,
   bout,
 }: {
   isNew: boolean;
   feed: FeedQuery["feed"];
   targetAudioCategory?: AudioCategory;
-  targetTime?: Date;
+  targetTime?: Date | null;
   bout?: BoutQuery["bout"];
 }) {
+  if (targetTime && !isValidDate(targetTime)) {
+    console.warn('Invalid targetTime provided to BoutPage');
+    targetTime = null;
+  }
+
+  if (targetAudioCategory && !audioCategories.includes(targetAudioCategory)) {
+    console.warn('Invalid targetAudioCategory provided to BoutPage');
+    targetAudioCategory = undefined;
+  }

638-693: Consider extracting CategoryIcon and TabPanel to separate files.

These helper components should be moved to their own files to improve code organization and maintainability.

Move these components to:

  • ui/src/components/Bouts/CategoryIcon.tsx
  • ui/src/components/Common/TabPanel.tsx
ui/src/components/Bouts/TimelineTickerLayer.tsx (2)

33-80: Consider extracting scale constants and calculations to a separate utility.

The scale calculations and constants could be moved to a separate utility file for better reusability and testing.

Create a new file ui/src/utils/timelineScales.ts:

export const SCALE_TYPES = {
  HOURS: {
    DAY: 24,
    QUARTER_DAY: 6,
    EIGHTH_DAY: 3,
    HOUR: 1
  },
  MINUTES: {
    TEN: 10,
    FIVE: 5,
    ONE: 1
  },
  SECONDS: {
    THIRTY: 30,
    TEN: 10,
    FIVE: 5,
    ONE: 1,
    TENTH: 0.1,
    HUNDREDTH: 0.01
  }
} as const;

export function calculateTimelineScales(windowRange: number, ticksPerWindow: number) {
  const tickSize = windowRange / ticksPerWindow;
  return {
    tickScales: generateScales(),
    labelScales: generateScales(),
    findMinScaleIndex: (scales: number[]) => 
      scales.findIndex(num => Math.floor(tickSize / num) > 0)
  };
}

81-118: Add memoization for time window calculations.

The time window calculations are being recomputed on every render. Consider memoizing these values.

+const useTimeWindowCalculations = (
+  windowStartTime: Date,
+  windowEndTime: Date,
+  windowRangeBufferFactor: number,
+  scale: number
+) => {
+  return useMemo(() => {
+    const tickStartTime = roundToNearest(
+      subMilliseconds(windowStartTime, windowRange * windowRangeBufferFactor),
+      scale,
+      "ceil"
+    );
+    const tickEndTime = roundToNearest(
+      addMilliseconds(windowEndTime, windowRange * windowRangeBufferFactor),
+      scale,
+      "ceil"
+    );
+    return { tickStartTime, tickEndTime };
+  }, [windowStartTime, windowEndTime, windowRangeBufferFactor, scale]);
+};
ui/src/components/Bouts/SpectrogramTimeline.tsx (1)

243-325: Consider extracting drag handling logic to a custom hook.

The touch and mouse event handling logic is complex and could be extracted to a custom hook for better reusability and testing.

Create a new file ui/src/hooks/useTimelineDrag.ts:

export function useTimelineDrag({
  spectrogramWindow,
  timelineStartTime,
  pixelsPerMinute,
  setWindowStartTime,
  setWindowEndTime,
  playerControls,
}) {
  const [isDragging, setIsDragging] = useState(false);
  const windowStartX = useRef(0);
  const windowScrollX = useRef(0);

  const handleDragStart = useCallback((pageX: number) => {
    setIsDragging(true);
    windowStartX.current = pageX - (spectrogramWindow.current?.offsetLeft ?? 0);
    windowScrollX.current = spectrogramWindow.current?.scrollLeft ?? 0;
    playerControls?.pause();
  }, [spectrogramWindow, playerControls]);

  // ... rest of the drag handling logic

  return {
    isDragging,
    handleTouchStart,
    handleTouchMove,
    handleTouchEnd,
    handleMouseDown,
    handleMouseMove,
    handleMouseUp,
    handleMouseLeave,
  };
}
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 3360090 and f5ae781.

📒 Files selected for processing (3)
  • ui/src/components/Bouts/BoutPage.tsx (1 hunks)
  • ui/src/components/Bouts/SpectrogramTimeline.tsx (1 hunks)
  • ui/src/components/Bouts/TimelineTickerLayer.tsx (1 hunks)
⏰ Context from checks skipped due to timeout of 90000ms (2)
  • GitHub Check: Analyze (javascript-typescript)
  • GitHub Check: test

Comment on lines 196 to 270
const [boutForm, setBoutForm] = useState<{
errors: Record<string, string>;
}>({
errors: {},
});
const createBoutMutation = useCreateBoutMutation({
onSuccess: ({ createBout: { errors, result } }) => {
if (errors && errors.length > 0) {
console.error(errors);
setBoutForm((form) => ({
...form,
errors: {
...form.errors,
...Object.fromEntries(
errors.map(({ code, message }) => [code, message] as const),
),
},
}));
} else if (result) {
router.push(`/bouts/${result.id}`);
}
},
});

const updateBoutMutation = useUpdateBoutMutation({
onSuccess: ({ updateBout: { errors } }) => {
if (errors && errors.length > 0) {
console.error(errors);
setBoutForm((form) => ({
...form,
errors: {
...form.errors,
...Object.fromEntries(
errors.map(({ code, message }) => [code, message] as const),
),
},
}));
} else {
setBoutSaved(true);
setTimeout(() => {
setBoutSaved(false);
}, 5000);
}
},
});

const saveBout = () => {
setBoutForm((form) => ({ ...form, errors: {} }));
if (audioCategory && boutStartTime) {
if (isNew) {
createBoutMutation.mutate({
feedId: feed.id,
startTime: boutStartTime,
endTime: boutEndTime,
category: audioCategory,
});
} else if (bout) {
updateBoutMutation.mutate({
id: bout.id,
startTime: boutStartTime,
endTime: boutEndTime,
category: audioCategory,
});
}
} else {
const errors: Record<string, string> = {};
if (!audioCategory) {
errors["audioCategory"] = "Audio category required";
}
if (!boutStartTime) {
errors["startTime"] = "Bout start time required";
}
setBoutForm((form) => ({ ...form, errors }));
}
};
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Enhance error handling in the saveBout function.

The error handling could be improved by:

  1. Adding type safety for error codes
  2. Handling network errors
  3. Adding loading state management
+type BoutFormErrors = {
+  audioCategory?: string;
+  startTime?: string;
+  network?: string;
+};
+
 const [boutForm, setBoutForm] = useState<{
-  errors: Record<string, string>;
+  errors: BoutFormErrors;
+  isLoading: boolean;
 }>({
   errors: {},
+  isLoading: false,
 });

 const saveBout = () => {
   setBoutForm((form) => ({ ...form, errors: {} }));
   if (audioCategory && boutStartTime) {
+    setBoutForm((form) => ({ ...form, isLoading: true }));
     if (isNew) {
       createBoutMutation.mutate({
         feedId: feed.id,
         startTime: boutStartTime,
         endTime: boutEndTime,
         category: audioCategory,
+      }, {
+        onError: (error) => {
+          setBoutForm((form) => ({
+            ...form,
+            isLoading: false,
+            errors: { network: error.message }
+          }));
+        }
       });
     } else if (bout) {
       // Similar changes for updateBoutMutation
     }
   } else {
-    const errors: Record<string, string> = {};
+    const errors: BoutFormErrors = {};
     if (!audioCategory) {
       errors["audioCategory"] = "Audio category required";
     }
     if (!boutStartTime) {
       errors["startTime"] = "Bout start time required";
     }
     setBoutForm((form) => ({ ...form, errors }));
   }
 };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const [boutForm, setBoutForm] = useState<{
errors: Record<string, string>;
}>({
errors: {},
});
const createBoutMutation = useCreateBoutMutation({
onSuccess: ({ createBout: { errors, result } }) => {
if (errors && errors.length > 0) {
console.error(errors);
setBoutForm((form) => ({
...form,
errors: {
...form.errors,
...Object.fromEntries(
errors.map(({ code, message }) => [code, message] as const),
),
},
}));
} else if (result) {
router.push(`/bouts/${result.id}`);
}
},
});
const updateBoutMutation = useUpdateBoutMutation({
onSuccess: ({ updateBout: { errors } }) => {
if (errors && errors.length > 0) {
console.error(errors);
setBoutForm((form) => ({
...form,
errors: {
...form.errors,
...Object.fromEntries(
errors.map(({ code, message }) => [code, message] as const),
),
},
}));
} else {
setBoutSaved(true);
setTimeout(() => {
setBoutSaved(false);
}, 5000);
}
},
});
const saveBout = () => {
setBoutForm((form) => ({ ...form, errors: {} }));
if (audioCategory && boutStartTime) {
if (isNew) {
createBoutMutation.mutate({
feedId: feed.id,
startTime: boutStartTime,
endTime: boutEndTime,
category: audioCategory,
});
} else if (bout) {
updateBoutMutation.mutate({
id: bout.id,
startTime: boutStartTime,
endTime: boutEndTime,
category: audioCategory,
});
}
} else {
const errors: Record<string, string> = {};
if (!audioCategory) {
errors["audioCategory"] = "Audio category required";
}
if (!boutStartTime) {
errors["startTime"] = "Bout start time required";
}
setBoutForm((form) => ({ ...form, errors }));
}
};
type BoutFormErrors = {
audioCategory?: string;
startTime?: string;
network?: string;
};
const [boutForm, setBoutForm] = useState<{
errors: BoutFormErrors;
isLoading: boolean;
}>({
errors: {},
isLoading: false,
});
const createBoutMutation = useCreateBoutMutation({
onSuccess: ({ createBout: { errors, result } }) => {
if (errors && errors.length > 0) {
console.error(errors);
setBoutForm((form) => ({
...form,
errors: {
...form.errors,
...Object.fromEntries(
errors.map(({ code, message }) => [code, message] as const),
),
},
}));
} else if (result) {
router.push(`/bouts/${result.id}`);
}
},
});
const updateBoutMutation = useUpdateBoutMutation({
onSuccess: ({ updateBout: { errors } }) => {
if (errors && errors.length > 0) {
console.error(errors);
setBoutForm((form) => ({
...form,
errors: {
...form.errors,
...Object.fromEntries(
errors.map(({ code, message }) => [code, message] as const),
),
},
}));
} else {
setBoutSaved(true);
setTimeout(() => {
setBoutSaved(false);
}, 5000);
}
},
});
const saveBout = () => {
setBoutForm((form) => ({ ...form, errors: {} }));
if (audioCategory && boutStartTime) {
setBoutForm((form) => ({ ...form, isLoading: true }));
if (isNew) {
createBoutMutation.mutate(
{
feedId: feed.id,
startTime: boutStartTime,
endTime: boutEndTime,
category: audioCategory,
},
{
onError: (error) => {
setBoutForm((form) => ({
...form,
isLoading: false,
errors: { network: error.message },
}));
},
}
);
} else if (bout) {
updateBoutMutation.mutate({
id: bout.id,
startTime: boutStartTime,
endTime: boutEndTime,
category: audioCategory,
});
}
} else {
const errors: BoutFormErrors = {};
if (!audioCategory) {
errors["audioCategory"] = "Audio category required";
}
if (!boutStartTime) {
errors["startTime"] = "Bout start time required";
}
setBoutForm((form) => ({ ...form, errors }));
}
};

Comment on lines +39 to +74
function centerWindow(
spectrogramWindow: MutableRefObject<HTMLDivElement | null>,
targetTime: Date,
timelineStartTime: Date,
pixelsPerMinute: number,
setWindowStartTime: (value: SetStateAction<Date | undefined>) => void,
setWindowEndTime: (value: SetStateAction<Date | undefined>) => void,
playerControls?: PlayerControls,
) {
if (spectrogramWindow.current) {
const offset = timeToOffset(targetTime, timelineStartTime, pixelsPerMinute);
spectrogramWindow.current.scrollLeft =
offset - spectrogramWindow.current.clientWidth / 2;

if (spectrogramWindow.current) {
setWindowStartTime(
offsetToTime(
spectrogramWindow.current.scrollLeft,
timelineStartTime,
pixelsPerMinute,
),
);
setWindowEndTime(
offsetToTime(
spectrogramWindow.current.scrollLeft +
spectrogramWindow.current.clientWidth,
timelineStartTime,
pixelsPerMinute,
),
);
}
if (playerControls?.setPlayerTime) {
playerControls.setPlayerTime(targetTime);
}
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Add error handling for invalid time values in centerWindow.

The centerWindow function should validate time values to prevent potential runtime errors.

 function centerWindow(
   spectrogramWindow: MutableRefObject<HTMLDivElement | null>,
   targetTime: Date,
   timelineStartTime: Date,
   pixelsPerMinute: number,
   setWindowStartTime: (value: SetStateAction<Date | undefined>) => void,
   setWindowEndTime: (value: SetStateAction<Date | undefined>) => void,
   playerControls?: PlayerControls,
 ) {
+  if (!(targetTime instanceof Date) || isNaN(targetTime.getTime())) {
+    console.error('Invalid targetTime provided to centerWindow');
+    return;
+  }
+
+  if (!(timelineStartTime instanceof Date) || isNaN(timelineStartTime.getTime())) {
+    console.error('Invalid timelineStartTime provided to centerWindow');
+    return;
+  }
+
   if (spectrogramWindow.current) {
     const offset = timeToOffset(targetTime, timelineStartTime, pixelsPerMinute);
     // ... rest of the function
   }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function centerWindow(
spectrogramWindow: MutableRefObject<HTMLDivElement | null>,
targetTime: Date,
timelineStartTime: Date,
pixelsPerMinute: number,
setWindowStartTime: (value: SetStateAction<Date | undefined>) => void,
setWindowEndTime: (value: SetStateAction<Date | undefined>) => void,
playerControls?: PlayerControls,
) {
if (spectrogramWindow.current) {
const offset = timeToOffset(targetTime, timelineStartTime, pixelsPerMinute);
spectrogramWindow.current.scrollLeft =
offset - spectrogramWindow.current.clientWidth / 2;
if (spectrogramWindow.current) {
setWindowStartTime(
offsetToTime(
spectrogramWindow.current.scrollLeft,
timelineStartTime,
pixelsPerMinute,
),
);
setWindowEndTime(
offsetToTime(
spectrogramWindow.current.scrollLeft +
spectrogramWindow.current.clientWidth,
timelineStartTime,
pixelsPerMinute,
),
);
}
if (playerControls?.setPlayerTime) {
playerControls.setPlayerTime(targetTime);
}
}
}
function centerWindow(
spectrogramWindow: MutableRefObject<HTMLDivElement | null>,
targetTime: Date,
timelineStartTime: Date,
pixelsPerMinute: number,
setWindowStartTime: (value: SetStateAction<Date | undefined>) => void,
setWindowEndTime: (value: SetStateAction<Date | undefined>) => void,
playerControls?: PlayerControls,
) {
if (!(targetTime instanceof Date) || isNaN(targetTime.getTime())) {
console.error('Invalid targetTime provided to centerWindow');
return;
}
if (!(timelineStartTime instanceof Date) || isNaN(timelineStartTime.getTime())) {
console.error('Invalid timelineStartTime provided to centerWindow');
return;
}
if (spectrogramWindow.current) {
const offset = timeToOffset(targetTime, timelineStartTime, pixelsPerMinute);
spectrogramWindow.current.scrollLeft =
offset - spectrogramWindow.current.clientWidth / 2;
if (spectrogramWindow.current) {
setWindowStartTime(
offsetToTime(
spectrogramWindow.current.scrollLeft,
timelineStartTime,
pixelsPerMinute,
),
);
setWindowEndTime(
offsetToTime(
spectrogramWindow.current.scrollLeft +
spectrogramWindow.current.clientWidth,
timelineStartTime,
pixelsPerMinute,
),
);
}
if (playerControls?.setPlayerTime) {
playerControls.setPlayerTime(targetTime);
}
}
}

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

♻️ Duplicate comments (1)
ui/src/components/Bouts/BoutPage.tsx (1)

197-271: 🛠️ Refactor suggestion

Previous error handling improvements are still needed.

The error handling in the saveBout function could be improved as suggested in the previous review.

Please refer to the previous review comment for the suggested improvements to error handling, including type safety for error codes, network error handling, and loading state management.

🧹 Nitpick comments (3)
ui/src/pages/bouts/new/[feedSlug].tsx (1)

17-31: Consider extracting audio categories to a shared constant.

The audio categories array is defined inline but appears to be used in multiple places (also in BoutPage.tsx).

Extract this to a shared constant to maintain consistency and reduce duplication:

+// In a shared constants file (e.g., src/constants/audio.ts)
+export const AUDIO_CATEGORIES = ["ANTHROPHONY", "BIOPHONY", "GEOPHONY"] as const;
+export type AudioCategory = typeof AUDIO_CATEGORIES[number];

-  const audioCategories: AudioCategory[] = [
-    "ANTHROPHONY",
-    "BIOPHONY",
-    "GEOPHONY",
-  ] as const;
+  const audioCategories = AUDIO_CATEGORIES;
ui/src/components/Bouts/BoutPage.tsx (2)

639-674: Add error boundary for image loading failures.

The CategoryIcon component doesn't handle image loading failures, which could affect the UI if the icon assets fail to load.

Add error handling for image loading:

function CategoryIcon({
  audioCategory,
  size,
  onError,
}: {
  audioCategory: AudioCategory;
  size?: number;
+ onError?: () => void;
}) {
  size = size ?? 15;
  if (audioCategory === "BIOPHONY")
    return (
      <Image
        src={whaleFlukeIconImage.src}
        width={size}
        height={size}
        alt="Whale fluke icon"
+       onError={onError}
+       fallback={<GraphicEq />}
      />
    );
  // Apply similar changes to other image components
}

676-694: Add prop types validation for TabPanel component.

The TabPanel component could benefit from prop types validation to ensure required props are provided.

Add prop types validation:

+import PropTypes from 'prop-types';

function TabPanel(props: {
  children?: React.ReactNode;
  index: number;
  value: number;
}) {
  const { children, value, index, ...other } = props;
  return (
    <div
      role="tabpanel"
      hidden={value !== index}
      id={`simple-tabpanel-${index}`}
      aria-labelledby={`simple-tab-${index}`}
      {...other}
    >
      {value === index && <Box sx={{ p: 3 }}>{children}</Box>}
    </div>
  );
}

+TabPanel.propTypes = {
+  children: PropTypes.node,
+  index: PropTypes.number.isRequired,
+  value: PropTypes.number.isRequired,
+};
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between f5ae781 and 7030ba0.

📒 Files selected for processing (2)
  • ui/src/components/Bouts/BoutPage.tsx (1 hunks)
  • ui/src/pages/bouts/new/[feedSlug].tsx (1 hunks)
⏰ Context from checks skipped due to timeout of 90000ms (2)
  • GitHub Check: test
  • GitHub Check: Analyze (javascript-typescript)
🔇 Additional comments (1)
ui/src/pages/bouts/new/[feedSlug].tsx (1)

12-12: Verify the hardcoded target time.

The component uses a hardcoded target time set to January 6, 2025. This might cause issues when the date passes.

Consider making this dynamic or documenting why this specific date is used.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (5)
ui/src/pages/bouts/new/[feedSlug].tsx (1)

14-27: Add validation for audio category mapping.

While the code properly validates if the category exists, it doesn't handle case sensitivity well. Consider using a more robust approach:

-  if (
-    categoryParam &&
-    audioCategories.includes(_.toUpper(categoryParam) as AudioCategory)
-  ) {
-    targetAudioCategory = categoryParam.toUpperCase() as AudioCategory;
+  if (categoryParam) {
+    const normalizedCategory = _.toUpper(categoryParam);
+    if (audioCategories.includes(normalizedCategory as AudioCategory)) {
+      targetAudioCategory = normalizedCategory as AudioCategory;
+    }
ui/src/components/Bouts/BoutPage.tsx (4)

105-124: Consider extracting timeline calculation logic to a utility function.

The timeline start/end calculation logic is complex and would be more maintainable if extracted to a separate utility function. This would make the component cleaner and the logic more testable.

// Add to /src/utils/time.ts or a similar file
+export function calculateTimelineRange(targetTime: Date, now: Date, timeBuffer: number, nearestMinutes: number) {
+  const startTime = roundToNearest(
+    subMinutes(targetTime, timeBuffer),
+    nearestMinutes * 60 * 1000,
+    "floor"
+  );
+  
+  const endTime = min([
+    now,
+    roundToNearest(
+      max([targetTime, addMinutes(targetTime, timeBuffer)]),
+      nearestMinutes * 60 * 1000,
+      "ceil"
+    ),
+  ]);
+  
+  return { startTime, endTime };
+}

// In your component
+const { startTime: timelineStartTime, endTime: timelineEndTime } = calculateTimelineRange(
+  targetTime, 
+  now, 
+  timeBuffer, 
+  nearestMinutes
+);
-const [timelineStartTime, setTimelineStartTime] = useState<Date>(
-  roundToNearest(
-    subMinutes(targetTime, timeBuffer),
-    nearestMinutes * 60 * 1000,
-    "floor",
-  ),
-);
-const [timelineEndTime, setTimelineEndTime] = useState<Date>(
-  min([
-    now,
-    roundToNearest(
-      max([targetTime, addMinutes(targetTime, timeBuffer)]),
-      nearestMinutes * 60 * 1000,
-      "ceil",
-    ),
-  ]),
-);
+const [timelineStartTime, setTimelineStartTime] = useState<Date>(startTime);
+const [timelineEndTime, setTimelineEndTime] = useState<Date>(endTime);

635-670: Consider using a map for CategoryIcon component.

The CategoryIcon component uses if-else logic for icon selection. Consider using an object map for cleaner code:

 function CategoryIcon({
   audioCategory,
   size,
 }: {
   audioCategory: AudioCategory;
   size?: number;
 }) {
   size = size ?? 15;
-  if (audioCategory === "BIOPHONY")
-    return (
-      <Image
-        src={whaleFlukeIconImage.src}
-        width={size}
-        height={size}
-        alt="Whale fluke icon"
-      />
-    );
-  if (audioCategory === "ANTHROPHONY")
-    return (
-      <Image
-        src={vesselIconImage.src}
-        width={size}
-        height={size}
-        alt="Vessel icon"
-      />
-    );
-  if (audioCategory === "GEOPHONY")
-    return (
-      <Image
-        src={wavesIconImage.src}
-        width={size}
-        height={size}
-        alt="Waves icon"
-      />
-    );
+  const iconMap = {
+    BIOPHONY: { src: whaleFlukeIconImage.src, alt: "Whale fluke icon" },
+    ANTHROPHONY: { src: vesselIconImage.src, alt: "Vessel icon" },
+    GEOPHONY: { src: wavesIconImage.src, alt: "Waves icon" },
+  };
+  
+  const icon = iconMap[audioCategory];
+  if (!icon) return null;
+  
+  return (
+    <Image
+      src={icon.src}
+      width={size}
+      height={size}
+      alt={icon.alt}
+    />
+  );
 }

591-592: Improve accessibility of detection IDs.

The detection IDs are displayed with small typography that may be difficult to read. Consider using a tooltip or a more accessible way to display these identifiers.

-<Typography variant="caption">{det.id}</Typography>
+<Tooltip title={`Detection ID: ${det.id}`}>
+  <Typography variant="caption" sx={{ cursor: 'pointer' }}>{det.id}</Typography>
+</Tooltip>

47-56: Group related imports.

Consider grouping related imports together for better readability. GraphQL imports could be organized by type (queries, mutations, fragments).

 import {
   AudioCategory,
   BoutQuery,
   FeedQuery,
+} from "@/graphql/generated";
+import {
   useCreateBoutMutation,
   useDetectionsQuery,
   useGetCurrentUserQuery,
   useListFeedStreamsQuery,
   useUpdateBoutMutation,
 } from "@/graphql/generated";
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 7030ba0 and 22ac3e0.

⛔ Files ignored due to path filters (1)
  • ui/src/graphql/generated/index.ts is excluded by !**/generated/**
📒 Files selected for processing (3)
  • ui/codegen.ts (1 hunks)
  • ui/src/components/Bouts/BoutPage.tsx (1 hunks)
  • ui/src/pages/bouts/new/[feedSlug].tsx (1 hunks)
⏰ Context from checks skipped due to timeout of 90000ms (2)
  • GitHub Check: test
  • GitHub Check: Analyze (javascript-typescript)
🔇 Additional comments (5)
ui/codegen.ts (1)

25-25: Good update to enum handling.

Changing from enumsAsTypes to enumsAsConst is a positive improvement. This configuration will generate TypeScript enums as const objects with string union types, providing better type safety and allowing use of enum values as discriminated unions in your components.

ui/src/pages/bouts/new/[feedSlug].tsx (2)

12-12: Verify the hardcoded future date.

The component uses a hardcoded date far in the future (2025). Is this intentional for development purposes, or should this be a dynamic value?


29-36: Add error handling for feed query failures.

The component only handles loading and "not found" states but doesn't handle query errors.

Add error handling:

-  if (!feedSlug || feedQueryResult.isLoading) return <LoadingSpinner mt={5} />;
-  if (!feed) return <p>Feed not found</p>;
+  if (!feedSlug || feedQueryResult.isLoading) return <LoadingSpinner mt={5} />;
+  if (feedQueryResult.error) return <p>Error loading feed: {feedQueryResult.error.message}</p>;
+  if (!feed) return <p>Feed not found</p>;
ui/src/components/Bouts/BoutPage.tsx (2)

193-197: Enhance error handling in the saveBout function.

The error handling could be improved by:

  1. Adding type safety for error codes
  2. Handling network errors
  3. Adding loading state management
+type BoutFormErrors = {
+  audioCategory?: string;
+  startTime?: string;
+  network?: string;
+};
+
 const [boutForm, setBoutForm] = useState<{
-  errors: Record<string, string>;
+  errors: BoutFormErrors;
+  isLoading: boolean;
 }>({
   errors: {},
+  isLoading: false,
 });

231-235: Replace setTimeout with useEffect for cleanup.

Using setTimeout without cleanup can lead to memory leaks if the component unmounts before the timeout completes.

Use useEffect for proper cleanup:

-        setBoutSaved(true);
-        setTimeout(() => {
-          setBoutSaved(false);
-        }, 5000);
+        setBoutSaved(true);
+        const timer = setTimeout(() => {
+          setBoutSaved(false);
+        }, 5000);
+        return () => clearTimeout(timer);

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (6)
ui/src/components/Bouts/BoutPage.tsx (3)

193-197: 🛠️ Refactor suggestion

Add loading state to the form state.

The form state doesn't track loading status, which would improve user experience by disabling the save button while a save operation is in progress.

const [boutForm, setBoutForm] = useState<{
  errors: Record<string, string>;
+  isLoading: boolean;
}>({
  errors: {},
+  isLoading: false,
});

231-235: ⚠️ Potential issue

Replace setTimeout with useEffect for proper cleanup.

Using setTimeout without cleanup can lead to memory leaks if the component unmounts before the timeout completes.

-      setBoutSaved(true);
-      setTimeout(() => {
-        setBoutSaved(false);
-      }, 5000);
+      setBoutSaved(true);
+      const timer = setTimeout(() => {
+        setBoutSaved(false);
+      }, 5000);
+      return () => clearTimeout(timer);

This should be wrapped in a useEffect hook:

+      useEffect(() => {
+        if (boutSaved) {
+          const timer = setTimeout(() => {
+            setBoutSaved(false);
+          }, 5000);
+          return () => clearTimeout(timer);
+        }
+      }, [boutSaved]);

239-267: 🛠️ Refactor suggestion

Enhance error handling and add loading state to saveBout function.

Improve the user experience by updating loading states during form submission and handling errors more robustly.

const saveBout = () => {
  setBoutForm((form) => ({ ...form, errors: {} }));
  if (audioCategory && boutStartTime) {
+    setBoutForm((form) => ({ ...form, isLoading: true }));
    if (isNew) {
      createBoutMutation.mutate({
        feedId: feed.id,
        startTime: boutStartTime,
        endTime: boutEndTime,
        category: audioCategory,
+      }, {
+        onSettled: () => {
+          setBoutForm((form) => ({ ...form, isLoading: false }));
+        }
      });
    } else if (bout) {
      updateBoutMutation.mutate({
        id: bout.id,
        startTime: boutStartTime,
        endTime: boutEndTime,
        category: audioCategory,
+      }, {
+        onSettled: () => {
+          setBoutForm((form) => ({ ...form, isLoading: false }));
+        }
      });
    }
  } else {
    const errors: Record<string, string> = {};
    if (!audioCategory) {
      errors["audioCategory"] = "Audio category required";
    }
    if (!boutStartTime) {
      errors["startTime"] = "Bout start time required";
    }
    setBoutForm((form) => ({ ...form, errors }));
  }
};
ui/src/components/Bouts/SpectrogramTimeline.tsx (3)

39-74: 🛠️ Refactor suggestion

Add error handling for invalid time values in centerWindow.

The centerWindow function should validate time values to prevent potential runtime errors.

function centerWindow(
  spectrogramWindow: MutableRefObject<HTMLDivElement | null>,
  targetTime: Date,
  timelineStartTime: Date,
  pixelsPerMinute: number,
  setWindowStartTime: (value: SetStateAction<Date | undefined>) => void,
  setWindowEndTime: (value: SetStateAction<Date | undefined>) => void,
  playerControls?: PlayerControls,
) {
+  if (!(targetTime instanceof Date) || isNaN(targetTime.getTime())) {
+    console.error('Invalid targetTime provided to centerWindow');
+    return;
+  }
+
+  if (!(timelineStartTime instanceof Date) || isNaN(timelineStartTime.getTime())) {
+    console.error('Invalid timelineStartTime provided to centerWindow');
+    return;
+  }
+
  if (spectrogramWindow.current) {
    const offset = timeToOffset(targetTime, timelineStartTime, pixelsPerMinute);
    spectrogramWindow.current.scrollLeft =
      offset - spectrogramWindow.current.clientWidth / 2;

    if (spectrogramWindow.current) {
      setWindowStartTime(
        offsetToTime(
          spectrogramWindow.current.scrollLeft,
          timelineStartTime,
          pixelsPerMinute,
        ),
      );
      setWindowEndTime(
        offsetToTime(
          spectrogramWindow.current.scrollLeft +
            spectrogramWindow.current.clientWidth,
          timelineStartTime,
          pixelsPerMinute,
        ),
      );
    }
    if (playerControls?.setPlayerTime) {
      playerControls.setPlayerTime(targetTime);
    }
  }
}

132-136: ⚠️ Potential issue

Include dependencies in the useCallback hook for throttled functions.

The ESLint warning is indicating that the useCallback hook is missing dependencies. The current implementation can cause stale closures.

-// eslint-disable-next-line react-hooks/exhaustive-deps
-const setWindowStartTime = useCallback(
-  _.throttle(setWindowStartTimeUnthrottled, 500, { trailing: false }),
-  [],
-);
+const setWindowStartTime = useCallback(
+  (value: SetStateAction<Date | undefined>) => {
+    const throttled = _.throttle(
+      (val: SetStateAction<Date | undefined>) => setWindowStartTimeUnthrottled(val),
+      500,
+      { trailing: false }
+    );
+    throttled(value);
+  },
+  [setWindowStartTimeUnthrottled],
+);

Apply similar changes to the setWindowEndTime function as well.


417-425: ⚠️ Potential issue

Remove the timelineEndTime prop from TimelineTickerLayer component.

The component receives a prop timelineEndTime that's not defined in its prop types, which will cause a TypeScript error.

{windowStartTime && windowEndTime && (
  <TimelineTickerLayer
    timelineStartTime={timelineStartTime}
-    timelineEndTime={timelineEndTime}
    windowStartTime={windowStartTime}
    windowEndTime={windowEndTime}
    pixelsPerMinute={pixelsPerMinute}
    zIndex={3}
  />
)}
🧹 Nitpick comments (4)
ui/src/components/Bouts/BoutPage.tsx (2)

297-304: Add disabled state to the save button based on loading state.

The save button should reflect the loading state to provide user feedback when an operation is in progress.

<Button
  variant="contained"
  size={isDesktop ? "large" : "small"}
  onClick={saveBout}
  sx={{ whiteSpace: "nowrap" }}
+  disabled={boutForm.isLoading}
>
-  {isNew ? "Create" : "Update"} bout
+  {boutForm.isLoading ? "Saving..." : (isNew ? "Create" : "Update") + " bout"}
</Button>

635-670: Add a default return value to the CategoryIcon component.

The CategoryIcon component should return a default fallback icon for cases where an unsupported category is provided.

function CategoryIcon({
  audioCategory,
  size,
}: {
  audioCategory: AudioCategory;
  size?: number;
}) {
  size = size ?? 15;
  if (audioCategory === "BIOPHONY")
    return (
      <Image
        src={whaleFlukeIconImage.src}
        width={size}
        height={size}
        alt="Whale fluke icon"
      />
    );
  if (audioCategory === "ANTHROPHONY")
    return (
      <Image
        src={vesselIconImage.src}
        width={size}
        height={size}
        alt="Vessel icon"
      />
    );
  if (audioCategory === "GEOPHONY")
    return (
      <Image
        src={wavesIconImage.src}
        width={size}
        height={size}
        alt="Waves icon"
      />
    );
+  // Return a default icon for unknown categories
+  return (
+    <GraphicEq 
+      sx={{ width: size, height: size }}
+    />
+  );
}
ui/src/components/Bouts/SpectrogramTimeline.tsx (2)

94-104: Add type check or assertion for Date objects in rangesOverlap.

The rangesOverlap function should verify that the provided date parameters are valid Date objects to prevent runtime errors.

export function rangesOverlap(
  startTime1?: Date,
  endTime1?: Date,
  startTime2?: Date,
  endTime2?: Date,
) {
-  if (startTime1 && endTime1 && startTime2 && endTime2) {
+  if (
+    startTime1 instanceof Date && !isNaN(startTime1.getTime()) &&
+    endTime1 instanceof Date && !isNaN(endTime1.getTime()) &&
+    startTime2 instanceof Date && !isNaN(startTime2.getTime()) &&
+    endTime2 instanceof Date && !isNaN(endTime2.getTime())
+  ) {
    return startTime1 <= endTime2 && endTime1 >= startTime2;
  }
  return false;
}

247-282: Extract touch handling logic to a separate function for better readability.

The touch handling code is quite complex and could be made more maintainable by extracting it to a separate function.

+const handleTouchDrag = (
+  containerCursorX: number,
+  windowWidth: number
+) => {
+  if (!spectrogramWindow.current) return;
+  
+  const move = containerCursorX - windowStartX.current;
+  const offset = windowScrollX.current - move;
+  
+  if (offset < 0 || offset + windowWidth > timelineWidth) return;
+  
+  spectrogramWindow.current.scrollLeft = offset;
+  const targetTime = offsetToTime(
+    offset + windowWidth / 2,
+    timelineStartTime,
+    pixelsPerMinute,
+  );
+  setPlayerTime(targetTime);
+  setWindowStartTime(
+    offsetToTime(windowScrollX.current, timelineStartTime, pixelsPerMinute),
+  );
+  setWindowEndTime(
+    offsetToTime(
+      windowScrollX.current + spectrogramWindow.current.offsetWidth,
+      timelineStartTime,
+      pixelsPerMinute,
+    ),
+  );
+};

const handleTouchMove = (e: React.TouchEvent) => {
  if (!isDragging || !spectrogramWindow.current) return;
  e.preventDefault();
  const containerCursorX =
    e.touches[0].pageX - spectrogramWindow.current.offsetLeft;
  const windowWidth = spectrogramWindow.current.offsetWidth;
-  const move = containerCursorX - windowStartX.current;
-  const offset = windowScrollX.current - move;
-  const windowWidth = spectrogramWindow.current.offsetWidth;
-  if (offset < 0) return;
-  if (offset + windowWidth > timelineWidth) return;
-  spectrogramWindow.current.scrollLeft = offset;
-  const targetTime = offsetToTime(
-    offset + windowWidth / 2,
-    timelineStartTime,
-    pixelsPerMinute,
-  );
-  setPlayerTime(targetTime);
-  setWindowStartTime(
-    offsetToTime(windowScrollX.current, timelineStartTime, pixelsPerMinute),
-  );
-  setWindowEndTime(
-    offsetToTime(
-      windowScrollX.current + spectrogramWindow.current.offsetWidth,
-      timelineStartTime,
-      pixelsPerMinute,
-    ),
-  );
+  handleTouchDrag(containerCursorX, windowWidth);
};

Apply a similar refactoring to the handleMouseMove function.

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 22ac3e0 and 98897fe.

📒 Files selected for processing (2)
  • ui/src/components/Bouts/BoutPage.tsx (1 hunks)
  • ui/src/components/Bouts/SpectrogramTimeline.tsx (1 hunks)
⏰ Context from checks skipped due to timeout of 90000ms (2)
  • GitHub Check: test
  • GitHub Check: Analyze (javascript-typescript)
🔇 Additional comments (1)
ui/src/components/Bouts/BoutPage.tsx (1)

38-38: Remove or rename the unused log variable.

The import of lodash is used only for a throttled log function that's not being used anywhere in the component.

-import _ from "lodash";
+import { startCase, toLower } from "lodash";

Then update lines 538 and 559 to use the imported functions:

-                      {_.startCase(_.toLower(category))}
+                      {startCase(toLower(category))}
-                <Typography>{_.startCase(_.toLower(bout.category))}</Typography>
+                <Typography>{startCase(toLower(bout.category))}</Typography>

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (7)
server/lib/orcasite/radio/aws_client.ex (1)

51-51: Improvement to Lambda invocation reliability.

Increasing the maximum retry attempts from 1 to 3 is a good enhancement that will improve the robustness of the spectrogram generation process when dealing with transient AWS service issues.

Consider also implementing a backoff strategy to complement the increased retry attempts. This would help prevent overwhelming the Lambda service during periods of instability.

|> ExAws.request(
  http_opts: [recv_timeout: :timer.minutes(2)],
- retries: [max_attempts: 3]
+ retries: [max_attempts: 3, base_backoff_in_ms: 100, max_backoff_in_ms: 10_000]
)
ui/src/components/Bouts/BoutPage.tsx (2)

217-237: Use a cleanup pattern for the setTimeout.
When the component unmounts, the setTimeout callback may still run, attempting to update an unmounted component and leading to potential memory leaks. Consider migrating to useEffect with a cleanup function to manage this timer.

Example refactor:

     } else {
       setBoutSaved(true);
-      setTimeout(() => {
-        setBoutSaved(false);
-      }, 5000);
+      const timerId = setTimeout(() => {
+        setBoutSaved(false);
+      }, 5000);

+      // using a cleanup effect
+      useEffect(() => {
+        return () => clearTimeout(timerId);
+      }, []);
     }

635-670: Refactor CategoryIcon using a switch statement or a mapping.
Using multiple if blocks is fine, but switching to a single switch statement or an object map may improve readability and make additions simpler.

Example refactor:

-function CategoryIcon({
-  audioCategory,
-  size,
-}: {
-  audioCategory: AudioCategory;
-  size?: number;
-}) {
-  size = size ?? 15;
-  if (audioCategory === "BIOPHONY")
-    return (
-      <Image src={whaleFlukeIconImage.src} width={size} height={size} alt="Whale fluke icon" />
-    );
-  if (audioCategory === "ANTHROPHONY")
-    return (
-      <Image src={vesselIconImage.src} width={size} height={size} alt="Vessel icon" />
-    );
-  if (audioCategory === "GEOPHONY")
-    return (
-      <Image src={wavesIconImage.src} width={size} height={size} alt="Waves icon" />
-    );
-}
+function CategoryIcon({ audioCategory, size = 15 }: { audioCategory: AudioCategory; size?: number }) {
+  switch (audioCategory) {
+    case "BIOPHONY":
+      return <Image src={whaleFlukeIconImage.src} alt="Whale fluke icon" width={size} height={size} />;
+    case "ANTHROPHONY":
+      return <Image src={vesselIconImage.src} alt="Vessel icon" width={size} height={size} />;
+    case "GEOPHONY":
+      return <Image src={wavesIconImage.src} alt="Waves icon" width={size} height={size} />;
+    default:
+      return null;
+  }
+}
ui/src/components/Player/BoutPlayer.tsx (2)

128-128: Leverage optional chaining for cleaner, safer function calls.
Instead of setPlayerTimeRef && setPlayerTimeRef(time), use an optional chain for improved readability.

-            setPlayerTimeRef && setPlayerTimeRef(time);
+            setPlayerTimeRef?.(time);
🧰 Tools
🪛 Biome (1.9.4)

[error] 128-128: Change to an optional chain.

Unsafe fix: Change to an optional chain.

(lint/complexity/useOptionalChain)


236-237: Use optional chaining to handle undefined playerDateTime gracefully.
Ensures cleaner conditional rendering of the date without needing explicit checks.

-  {playerDateTime !== undefined && playerDateTime.toLocaleDateString()}
+  {playerDateTime?.toLocaleDateString()}
🧰 Tools
🪛 Biome (1.9.4)

[error] 236-237: Change to an optional chain.

Unsafe fix: Change to an optional chain.

(lint/complexity/useOptionalChain)

server/lib/orcasite/radio/audio_image.ex (2)

185-216: Status handling improved in generate_spectrogram action.

The error handling flow now uses :errored instead of :failed, which provides better status differentiation. Good use of force_change_attribute to explicitly set the last_error to nil on success.

Consider adding more structured error information rather than just using inspect(error), which might make debugging easier in production.

-                   |> Ash.Changeset.force_change_attribute(:last_error, inspect(error))
+                   |> Ash.Changeset.force_change_attribute(:last_error, format_error(error))

You could add a private helper function:

defp format_error(error) do
  case error do
    %{message: message} -> message
    error when is_binary(error) -> error
    other -> inspect(other)
  end
end

218-220: New set_failed action provides explicit status control.

This explicit action to set status to :failed complements the automatic transition to :errored in the generate_spectrogram action.

Consider adding documentation to clarify when to use :failed vs :errored status to ensure consistent usage across the application.

    update :set_failed do
+     description "Explicitly mark an audio image as failed (vs. errored which is used for processing errors)"
      change set_attribute(:status, :failed)
    end
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 98897fe and f0ec5cb.

📒 Files selected for processing (5)
  • server/lib/orcasite/radio/audio_image.ex (2 hunks)
  • server/lib/orcasite/radio/aws_client.ex (1 hunks)
  • server/lib/orcasite/radio/workers/generate_spectrogram.ex (2 hunks)
  • ui/src/components/Bouts/BoutPage.tsx (1 hunks)
  • ui/src/components/Player/BoutPlayer.tsx (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • server/lib/orcasite/radio/workers/generate_spectrogram.ex
🧰 Additional context used
🪛 Biome (1.9.4)
ui/src/components/Player/BoutPlayer.tsx

[error] 128-128: Change to an optional chain.

Unsafe fix: Change to an optional chain.

(lint/complexity/useOptionalChain)


[error] 236-237: Change to an optional chain.

Unsafe fix: Change to an optional chain.

(lint/complexity/useOptionalChain)

⏰ Context from checks skipped due to timeout of 90000ms (2)
  • GitHub Check: Analyze (javascript-typescript)
  • GitHub Check: test
🔇 Additional comments (1)
server/lib/orcasite/radio/audio_image.ex (1)

225-229: GraphQL interface extended with feed filtering.

The attribute type syntax is simplified and the new query is properly exposed through GraphQL. This ensures the new feed filtering functionality is accessible to clients.

Comment on lines 239 to 267
const saveBout = () => {
setBoutForm((form) => ({ ...form, errors: {} }));
if (audioCategory && boutStartTime) {
if (isNew) {
createBoutMutation.mutate({
feedId: feed.id,
startTime: boutStartTime,
endTime: boutEndTime,
category: audioCategory,
});
} else if (bout) {
updateBoutMutation.mutate({
id: bout.id,
startTime: boutStartTime,
endTime: boutEndTime,
category: audioCategory,
});
}
} else {
const errors: Record<string, string> = {};
if (!audioCategory) {
errors["audioCategory"] = "Audio category required";
}
if (!boutStartTime) {
errors["startTime"] = "Bout start time required";
}
setBoutForm((form) => ({ ...form, errors }));
}
};
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Consider adding loading state management and preventing multiple submissions.
Currently, the UI does not show any loading indicator and allows multiple rapid clicks on “Create/Update bout.” Implement a loading state to disable the button while awaiting server response, improving user feedback and preventing duplicate requests.

Comment on lines 67 to 68
const intervalRef = useRef<NodeJS.Timeout>();

Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

Clear the interval on component unmount to avoid memory leaks.
Currently, the interval is cleared when the player pauses, but not when the component unmounts. Add a cleanup effect that clears any ongoing interval, ensuring no background tasks remain on unmount.

Example fix:

 useEffect(() => {
   return () => {
     clearInterval(intervalRef.current);
   };
 }, []);

Also applies to: 133-153

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

🧹 Nitpick comments (4)
ui/src/components/Bouts/BoutPage.tsx (1)

235-239: Consider replacing the setTimeout approach with a useEffect cleanup.

Currently, the setTimeout call to reset boutSaved might remain active if the user navigates away quickly. Using a useEffect hook and clearing the timer on unmount would help prevent potential memory leaks:

  else {
    setBoutSaved(true);
-   setTimeout(() => {
-     setBoutSaved(false);
-   }, 5000);
+   const timer = setTimeout(() => setBoutSaved(false), 5000);
+   return () => clearTimeout(timer);
  }
ui/src/components/Player/BoutPlayer.tsx (2)

129-135: Adopt optional chaining for cleaner code.

Instead of setPlayerTimeRef && setPlayerTimeRef(time);, consider using optional chaining for readability:

- setPlayerTimeRef && setPlayerTimeRef(time);
+ setPlayerTimeRef?.(time);
🧰 Tools
🪛 Biome (1.9.4)

[error] 133-133: Change to an optional chain.

Unsafe fix: Change to an optional chain.

(lint/complexity/useOptionalChain)


241-242: Use optional chaining for playerDateTime checks.

Short-circuiting with !== undefined can be replaced with an optional chain for succinctness and clarity:

- {playerDateTime !== undefined && playerDateTime.toLocaleDateString()}
+ {playerDateTime?.toLocaleDateString()}
🧰 Tools
🪛 Biome (1.9.4)

[error] 241-242: Change to an optional chain.

Unsafe fix: Change to an optional chain.

(lint/complexity/useOptionalChain)

ui/src/components/Bouts/SpectrogramTimeline.tsx (1)

221-246: Consider reducing the 100ms interval in useEffect for performance.

Re-centering the timeline every 100ms, especially on slower devices or large timelines, might be intensive. Implementing a more optimal throttling strategy or increasing the interval could improve performance and reduce CPU usage.

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between f0ec5cb and 0dd3349.

📒 Files selected for processing (6)
  • ui/src/components/Bouts/BaseAudioWidthLayer.tsx (1 hunks)
  • ui/src/components/Bouts/BoutPage.tsx (1 hunks)
  • ui/src/components/Bouts/SpectrogramTimeline.tsx (1 hunks)
  • ui/src/components/Player/BoutPlayer.tsx (1 hunks)
  • ui/src/graphql/fragments/BoutParts.graphql (1 hunks)
  • ui/src/pages/bouts/[boutId].tsx (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (3)
  • ui/src/graphql/fragments/BoutParts.graphql
  • ui/src/pages/bouts/[boutId].tsx
  • ui/src/components/Bouts/BaseAudioWidthLayer.tsx
🧰 Additional context used
🪛 Biome (1.9.4)
ui/src/components/Player/BoutPlayer.tsx

[error] 133-133: Change to an optional chain.

Unsafe fix: Change to an optional chain.

(lint/complexity/useOptionalChain)


[error] 241-242: Change to an optional chain.

Unsafe fix: Change to an optional chain.

(lint/complexity/useOptionalChain)

⏰ Context from checks skipped due to timeout of 90000ms (1)
  • GitHub Check: test
🔇 Additional comments (2)
ui/src/components/Bouts/BoutPage.tsx (1)

243-272: Nice unified error handling and loading state logic.

Your saveBout function correctly assigns and unassigns isSaving, updates form errors, and handles successful creation or update. This is a clean approach to error handling in React.

ui/src/components/Bouts/SpectrogramTimeline.tsx (1)

185-191: Move spectrogramControls assignment into a useEffect hook.

Directly assigning spectrogramControls.current in the component body can cause lifecycle issues. As previously noted, consider wrapping it in a useEffect for safer updates.

- if (spectrogramControls) {
-   spectrogramControls.current = {
-     goToTime,
-     zoomIn: () => setZoomLevel((zoom) => _.clamp(zoom * 2, minZoom, maxZoom)),
-     zoomOut: () => setZoomLevel((zoom) => _.clamp(zoom / 2, minZoom, maxZoom)),
-   };
- }
+ useEffect(() => {
+   if (spectrogramControls) {
+     spectrogramControls.current = {
+       goToTime,
+       zoomIn: () => setZoomLevel((zoom) => _.clamp(zoom * 2, minZoom, maxZoom)),
+       zoomOut: () => setZoomLevel((zoom) => _.clamp(zoom / 2, minZoom, maxZoom)),
+     };
+   }
+ }, [spectrogramControls, goToTime, minZoom, maxZoom]);

* first commit

* test commit

* toolpad core with error

* moderator ui dashboard layout with known nonbreaking TS errors

* moderator UI draft PR for Feb hackathon

* reverting file that didn't need to change

* backup before merging new bout

* pushing to skander/new-bout instead of main
@adrmac adrmac requested a review from a team as a code owner March 6, 2025 20:26
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (39)
ui/src/graphql/queries/list2Feeds.graphql (2)

2-17: Consider Fragment Reuse for Maintainability

The field selection within the feeds query is comprehensive and explicit. For consistency and ease of future modifications, consider reusing an existing fragment (e.g., FeedParts) if it already encapsulates these feed fields. This approach reduces code duplication and improves maintainability across similar queries.


18-18: Extraneous Trailing Line

There is a lone trailing line at the end of the file. While not causing any issues, removing unintentional whitespace can keep the file clean.

ui/src/graphql/queries/listDetections2.graphql (1)

1-6: Ensure Proper Variable Type Constraints.

The query parameters for detections2 are clearly defined and easy to follow. One suggestion is to consider whether any of the variables (for example, $limit) should be non-null (using Int!) if they are required to produce a valid result. This can help prevent runtime errors or unexpected behavior if a variable is unintentionally omitted.

ui/src/pages/moderator/bouts.tsx (1)

5-5: Consider renaming for consistency.

The component is named ModeratorLearnPage but is in a file named bouts.tsx. Consider renaming the component to ModeratorBoutsPage to match the file name, or rename the file to learn.tsx to match the component name.

ui/src/pages/moderator/reports.tsx (1)

3-5: Naming inconsistency between import and file path.

The component is imported as DetectionsPage from @/pages/reports. This naming inconsistency might be confusing. Consider either:

  1. Renaming the imported component to match the file path (ReportsPage)
  2. Updating the import path to match the component name (@/pages/detections)
ui/src/pages/moderator/index.tsx (2)

6-6: Consider renaming for clarity.

The component is named NewFeedsPage but renders a Candidates component. This naming might be confusing. Consider either:

  1. Renaming the component to ModeratorCandidatesPage to match what it renders
  2. Ensuring that the Candidates component is indeed related to new feeds

7-11: Unnecessary fragment wrapper.

Since there's only one child component, the fragment wrapper (<></>) is unnecessary. You can simplify this to:

-  return (
-    <>
-      <Candidates />
-    </>
-  );
+  return <Candidates />;
ui/src/pages/moderator/json.tsx (1)

6-18: Add error handling and improve JSON display.

The page correctly fetches and displays JSON data, but could benefit from:

  1. Error handling for the GraphQL query
  2. Improved styling or structure for better readability
  3. Possibly adding a toggle to show/hide each JSON section
const JSONPage: NextPageWithLayout = () => {
  const { combined } = useData();
  // get hydrophone feed list
  const feedsQueryResult = useFeedsQuery();
+ const loading = feedsQueryResult.loading;
+ const error = feedsQueryResult.error;
  const feeds = feedsQueryResult.data?.feeds ?? [];

  return (
    <>
+     {error && <div className="text-red-500">Error loading feeds: {error.message}</div>}
+     {loading && <div>Loading...</div>}
+     <h2 className="text-xl font-bold mb-2">Combined Data:</h2>
      <pre className="bg-gray-100 p-4 rounded mb-4 overflow-auto max-h-96">{JSON.stringify(combined, null, 2)}</pre>
+     <h2 className="text-xl font-bold mb-2">Feeds Data:</h2>
      <pre className="bg-gray-100 p-4 rounded overflow-auto max-h-96">{JSON.stringify(feeds, null, 2)}</pre>
    </>
  );
};
ui/src/components/layouts/ToolpadLayout.tsx (1)

22-24: Consider updating the branding title

The branding title "My Toolpad Core App" appears to be a placeholder. Consider updating it to match your application's actual name for consistency across the UI.

const BRANDING = {
-  title: "My Toolpad Core App",
+  title: "Application Name", // Replace with your actual application name
};
ui/src/components/FeedList.tsx (1)

18-18: Add loading and error states

The component currently returns null if sortedFeeds is falsy, which happens during loading or on error. Consider adding explicit loading and error states for a better user experience.

- if (!sortedFeeds) return null;
+ if (feedsQueryResult.isLoading) {
+   return (
+     <Container maxWidth="sm">
+       <Typography variant="body1">Loading feeds...</Typography>
+     </Container>
+   );
+ }
+ 
+ if (feedsQueryResult.isError) {
+   return (
+     <Container maxWidth="sm">
+       <Typography variant="body1" color="error">
+         Error loading feeds. Please try again.
+       </Typography>
+     </Container>
+   );
+ }
+ 
+ if (!sortedFeeds?.length) {
+   return (
+     <Container maxWidth="sm">
+       <Typography variant="body1">No feeds available.</Typography>
+     </Container>
+   );
+ }
ui/src/pages/moderator/hydrophones.tsx (1)

5-7: Consider more descriptive naming or extended logic.
This component is named hydrophones.tsx, yet it simply reuses FeedsPage from /listen. If distinct functionality or data is intended for hydrophones, consider adding a unique implementation or renaming the component/file for clarity.

ui/src/pages/moderator/playertest.tsx (2)

22-22: Use optional chaining for clarity.
Instead of referencing candidate && candidate.detections, consider simplifying to:

- const detections = candidate && candidate.detections;
+ const detections = candidate?.detections;
🧰 Tools
🪛 Biome (1.9.4)

[error] 22-22: Change to an optional chain.

Unsafe fix: Change to an optional chain.

(lint/complexity/useOptionalChain)


82-88: Combine multiple setState calls to reduce extra re-renders.
You currently call setPlayerProps twice in the same effect, which may cause additional re-render cycles. Combine them into a single call if feasible:

        setPlayerProps({
          feed: feed ? feed : feedsData[0],
          timestamp: startTimestamp,
          startOffset: startOffset,
          endOffset: endOffset,
          audioUri: lastDetection.audioUri || "",
        });
ui/src/components/ChartSelect.tsx (2)

31-31: Use a more descriptive "id" or remove it if not needed.
"id='demo-simple-select'" might be a leftover from an example. Rename or remove it for clarity and maintainability.

- id="demo-simple-select"
+ id="chart-select-dropdown"

7-13: Consider adding tests for this UI component.
This component is critical for user interaction. You could enhance reliability by adding unit tests that validate the dropdown list rendering and the onChange event behavior.

Also applies to: 15-45

ui/src/components/PlayBar.tsx (2)

82-87: Avoid multiple setState calls inside the same effect.
Currently, calling setPlayerProps multiple times can cause extra re-renders. For better performance, unify your logic into a single call:

setPlayerProps({
  feed: feed ? feed : feedsData[0],
  timestamp: startTimestamp,
  startOffset,
  endOffset,
  audioUri: lastDetection?.audioUri || ""
});

225-225: Use optional chaining in onClick handlers.
Replace onClick={() => item.onClick && item.onClick()} with onClick={() => item.onClick?.()} to simplify logic and improve readability.

-onClick={() => item.onClick && item.onClick()}
+onClick={() => item.onClick?.()}
-onClick={() => page.onClick && page.onClick()}
+onClick={() => page.onClick?.()}

Also applies to: 282-282

🧰 Tools
🪛 Biome (1.9.4)

[error] 225-225: Change to an optional chain.

Unsafe fix: Change to an optional chain.

(lint/complexity/useOptionalChain)

ui/src/types/DataTypes.ts (3)

6-12: Consider restricting the type field to defined options.

Right now, type: string in the HumanData interface is quite broad. You could use a string literal union (e.g., "human" | "ai") or an enum to reduce the risk of typos or invalid assignments.


9-9: Unify optional comments into a single optional property.

Instead of comments: string | null | undefined;, consider using a single optional property like comments?: string to simplify usage.

-  comments: string | null | undefined;
+  comments?: string;

37-44: Optional clarifications for dataset structure.

The Dataset interface includes separate arrays for human, ai, and combined. Ensure that combined doesn’t become stale if future code directly updates human or ai. Ideally, define a single source of truth or always reconstruct combined as needed.

ui/src/pages/moderator/[candidateId].tsx (3)

60-69: Combine repeated conditions.

Lines 63–66 and 66–69 check the same condition twice. You could unify them, improving readability:

- if (time >= startTime && time <= endTime) {
-   console.log("both true");
- }
- if (time >= startTime && time <= endTime) {
-   arr.push(d);
- }
+ if (time >= startTime && time <= endTime) {
+   console.log("both true"); // if you really need this log
+   arr.push(d);
+ }

35-35: Remove or replace console.log statements in production code.

Debugging logs like console.log("startTime:...") or "both true" spam logs in production. Consider a proper logging mechanism or remove them before deployment.

Also applies to: 64-64, 65-65, 75-75, 76-76


82-86: Check for undefined array elements before accessing them.

detections?.human[0]?.feedId may still cause issues if detections.human is empty. Wrap indexing in safety checks or use optional chaining consistently to avoid runtime errors.

-  const feed = feeds.filter((f) => f.id === detections?.human[0]?.feedId)[0];
+  const feed = feeds.find((f) => f.id === detections?.human[0]?.feedId) ?? null;
ui/src/components/layouts/DrawerLayout.tsx (1)

128-131: Use optional chaining for clarity.

In the navDiv function, you can simplify the div.children && div.children.map(...) pattern with optional chaining, e.g. div.children?.map(...).

-            {div.children &&
-              div.children.map((item) =>
-                listItem(item.title, item.path, item.icon),
-              )}
+            {div.children?.map((item) =>
+              listItem(item.title, item.path, item.icon)
+            )}
🧰 Tools
🪛 Biome (1.9.4)

[error] 128-131: Change to an optional chain.

Unsafe fix: Change to an optional chain.

(lint/complexity/useOptionalChain)

ui/src/components/layouts/ModeratorLayout.tsx (2)

101-114: Remove unreachable code after returns in standardizeFeedName.

The break; statements are unreachable because the return statement will exit the function. Removing them simplifies control flow.

    case "Beach Camp at Sunset Bay":
      return "Sunset Bay";
-     break;
    ...
    case "North SJC":
      return "North San Juan Channel";
-     break;
    ...
    default:
      return name;
-     break;
🧰 Tools
🪛 Biome (1.9.4)

[error] 104-104: This code is unreachable

... because this statement will return from the function beforehand

(lint/correctness/noUnreachable)


[error] 107-107: This code is unreachable

... because this statement will return from the function beforehand

(lint/correctness/noUnreachable)


[error] 110-110: This code is unreachable

... because this statement will return from the function beforehand

(lint/correctness/noUnreachable)


[error] 113-113: This code is unreachable

... because this statement will return from the function beforehand

(lint/correctness/noUnreachable)


258-261: Leverage optional chaining for safer array mapping.

You can replace div.children && div.children.map(...) with div.children?.map(...) to simplify the code and prevent potential runtime errors if children is undefined.

🧰 Tools
🪛 Biome (1.9.4)

[error] 258-261: Change to an optional chain.

Unsafe fix: Change to an optional chain.

(lint/complexity/useOptionalChain)

ui/src/components/Player/CandidateCardAIPlayer.tsx (1)

97-123: Use optional chaining for cleaner callback invocation.
Wherever you check a callback before calling, you can simplify the code with optional chaining:

- onPlayerInit && onPlayerInit(player);
+ onPlayerInit?.(player);

- onPlay && onPlay();
+ onPlay?.();

- onPlayerEnd && onPlayerEnd();
+ onPlayerEnd?.();
🧰 Tools
🪛 Biome (1.9.4)

[error] 97-97: Change to an optional chain.

Unsafe fix: Change to an optional chain.

(lint/complexity/useOptionalChain)


[error] 106-106: Change to an optional chain.

Unsafe fix: Change to an optional chain.

(lint/complexity/useOptionalChain)


[error] 123-123: Change to an optional chain.

Unsafe fix: Change to an optional chain.

(lint/complexity/useOptionalChain)

ui/src/components/Player/CandidateCardPlayer.tsx (3)

87-123: Use optional chaining for callback props.

The handleReady function has proper event handling for the video player, but there are places where optional chaining could be used for callback props to improve code safety.

According to static analysis, consider using optional chaining on these lines:

-      onPlayerInit && onPlayerInit(player);
+      onPlayerInit?.(player);

-      onPlay && onPlay();
+      onPlay?.();

-      onPlayerEnd && onPlayerEnd();
+      onPlayerEnd?.();
🧰 Tools
🪛 Biome (1.9.4)

[error] 90-90: Change to an optional chain.

Unsafe fix: Change to an optional chain.

(lint/complexity/useOptionalChain)


[error] 97-97: Change to an optional chain.

Unsafe fix: Change to an optional chain.

(lint/complexity/useOptionalChain)


[error] 112-112: Change to an optional chain.

Unsafe fix: Change to an optional chain.

(lint/complexity/useOptionalChain)


154-161: Consider separating development logging from cleanup effects.

The useEffect hook combines two unrelated operations: development logging and cleanup. It would be cleaner to separate these concerns.

  useEffect(() => {
    if (process.env.NODE_ENV === "development" && hlsURI) {
      console.log(`New stream instance: ${hlsURI}`);
    }
+  }, [hlsURI]);
+  
+  useEffect(() => {
    return () => {
      setPlayerStatus("idle");
    };
-  }, [hlsURI, feed.nodeName]);
+  }, []);

243-249: Time formatting utility could be more reusable.

The time formatting utility is well-implemented but could be moved to a common utilities file for reuse across the application.

Consider extracting the formattedSeconds function to a shared utilities file so it can be reused by other components that need to format time values.

ui/src/pages/moderator/candidates.tsx (9)

13-72: Consider using enums or constants for selection options.

The time range, increment, and category selections are defined as arrays of objects with labels and values. Consider using TypeScript enums or separate constant objects to better organize these configuration values.

+// Define enum or type for time ranges
+enum TimeRange {
+  SEVEN_DAYS = 7 * 24 * 60 * 60 * 1000,
+  THREE_DAYS = 3 * 24 * 60 * 60 * 1000,
+  ONE_DAY = 24 * 60 * 60 * 1000
+}
+
+// Use the enum values in your select options
 const timeRangeSelect = [
   {
     label: "Last 7 days",
-    value: sevenDays,
+    value: TimeRange.SEVEN_DAYS,
   },
   // ...
 ];

84-142: Consider performance optimization in createCandidates function.

The createCandidates function has nested loops and could potentially be optimized for better performance.

The findLastMatchingArray function is called for each element, which could be inefficient for large datasets. Consider using a map to keep track of the last array for each hydrophone:

 const createCandidates = (
   dataset: CombinedData[],
   interval: number,
 ): Candidate[] => {
   const candidates: Array<Array<CombinedData>> = [];
+  const lastArrayByHydrophone = new Map<string, Array<CombinedData>>();
   const sort = dataset.sort(
     (a, b) => Date.parse(b.timestampString) - Date.parse(a.timestampString),
   );
   sort.forEach((el: CombinedData) => {
     if (!candidates.length) {
       const firstArray = [];
       firstArray.push(el);
       candidates.push(firstArray);
+      lastArrayByHydrophone.set(el.hydrophone, firstArray);
     } else {
       const hydrophone = el.hydrophone;
-      const findLastMatchingArray = () => {
-        for (let i = candidates.length - 1; i >= 0; i--) {
-          if (candidates[i][0].hydrophone === hydrophone) {
-            return candidates[i];
-          }
-        }
-      };
-      const lastMatchingArray = findLastMatchingArray();
+      const lastMatchingArray = lastArrayByHydrophone.get(hydrophone);
       const lastTimestamp =
         lastMatchingArray &&
         lastMatchingArray[lastMatchingArray.length - 1].timestampString;
       // ... rest of the code
🧰 Tools
🪛 Biome (1.9.4)

[error] 108-109: Change to an optional chain.

Unsafe fix: Change to an optional chain.

(lint/complexity/useOptionalChain)


108-109: Use optional chaining for safer code.

The static analysis tool correctly identified a place where optional chaining would be safer.

-      const lastTimestamp =
-        lastMatchingArray &&
-        lastMatchingArray[lastMatchingArray.length - 1].timestampString;
+      const lastTimestamp = lastMatchingArray?.[lastMatchingArray.length - 1].timestampString;
🧰 Tools
🪛 Biome (1.9.4)

[error] 108-109: Change to an optional chain.

Unsafe fix: Change to an optional chain.

(lint/complexity/useOptionalChain)


182-193: Remove commented-out code.

There's a block of commented-out code that doesn't serve any documentation purpose. This should be removed for cleaner code.

-  // const [playing, setPlaying] = useState({
-  //   index: -1,
-  //   status: "ready",
-  // });
-
-  // const changeListState = (index: number, status: string) => {
-  //   setPlaying((prevState) => ({
-  //     ...prevState,
-  //     index: index,
-  //     status: status,
-  //   }));
-  // };

197-197: Consider typing the players ref.

The players ref is initialized with an empty object without a type. Consider adding proper typing.

-  const players = useRef({});
+  const players = useRef<Record<string, any>>({});

Or preferably, create a more specific interface for what's stored in the players object.


205-218: Consider extracting filter logic to a separate function.

The filtering logic is embedded in the component, making it harder to read and test. Consider extracting it to a separate function.

+  const filterData = (data: CombinedData[], filters: typeof filters) => {
+    return data.filter((el: CombinedData) => {
+      return (
+        (filters.hydrophone === "All hydrophones" ||
+          el.hydrophone === filters.hydrophone) &&
+        (filters.category === "All categories" ||
+          el.newCategory.toLowerCase() === filters.category)
+      );
+    });
+  };

-  const filteredData = combined.filter((el: CombinedData) => {
-    return (
-      // uncomment this to block Orcahello data
-      // el.type === "human" &&
-
-      // Disabling timerange filter for now because seed data is all from 2023
-      //            (Date.parse(el.timestamp) >= min) &&
-
-      (filters.hydrophone === "All hydrophones" ||
-        el.hydrophone === filters.hydrophone) &&
-      (filters.category === "All categories" ||
-        el.newCategory.toLowerCase() === filters.category)
-    );
-  });
+  const filteredData = filterData(combined, filters);

224-240: Sorting functions could be optimized.

The sorting functions sortDescending and sortAscending are almost identical except for the order of operands. Consider creating a single sort function with a direction parameter.

-  const sortDescending = (array: Candidate[]) => {
-    const sort = array.sort(
-      (a, b) =>
-        handledGetTime(b.array[0].timestamp) -
-        handledGetTime(a.array[0].timestamp),
-    );
-    return sort;
-  };
-
-  const sortAscending = (array: Candidate[]) => {
-    const sort = array.sort(
-      (a, b) =>
-        handledGetTime(a.array[0].timestamp) -
-        handledGetTime(b.array[0].timestamp),
-    );
-    return sort;
-  };
+  const sortCandidates = (array: Candidate[], direction: 'asc' | 'desc') => {
+    return [...array].sort((a, b) => {
+      const timeA = handledGetTime(a.array[0].timestamp);
+      const timeB = handledGetTime(b.array[0].timestamp);
+      return direction === 'asc' ? timeA - timeB : timeB - timeA;
+    });
+  };

Note that I've also changed to copying the array before sorting, as sorting in JavaScript mutates the array.


255-260: Simplify useEffect.

The useEffect has a redundant statement - it sets sortedCandidates to [...candidates] twice.

  useEffect(() => {
-    setSortedCandidates((v) => [...candidates]);
    if (isSuccess) {
      setSortedCandidates((v) => [...candidates]);
    }
  }, [isSuccess]);

278-290: Duplicate component rendering logic.

There's duplicate code for rendering CandidateCard components in candidateCards and sortedCandidateCards. Consider extracting a function to avoid duplication.

+  const renderCandidateCard = (candidate: Candidate, index: number) => (
+    <CandidateCard
+      candidate={candidate}
+      key={index}
+      index={index}
+      players={players}
+      playNext={playNext}
+    />
+  );

-  const candidateCards = candidates.map(
-    (candidate: Candidate, index: number) => (
-      <CandidateCard
-        candidate={candidate}
-        key={index}
-        index={index}
-        players={players}
-        playNext={playNext}
-      />
-    ),
-  );
+  const candidateCards = candidates.map(renderCandidateCard);

-  const sortedCandidateCards = sortedCandidates.map(
-    (candidate: Candidate, index: number) => (
-      <CandidateCard
-        candidate={candidate}
-        key={index}
-        index={index}
-        players={players}
-        playNext={playNext}
-      />
-    ),
-  );
+  const sortedCandidateCards = sortedCandidates.map(renderCandidateCard);
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 0dd3349 and 1b56c1f.

⛔ Files ignored due to path filters (4)
  • .DS_Store is excluded by !**/.DS_Store
  • package-lock.json is excluded by !**/package-lock.json
  • ui/package-lock.json is excluded by !**/package-lock.json
  • ui/src/graphql/generated/index.ts is excluded by !**/generated/**
📒 Files selected for processing (28)
  • package.json (1 hunks)
  • server/priv/repo/seeds.exs (1 hunks)
  • ui/package.json (2 hunks)
  • ui/src/components/CandidateCard.tsx (1 hunks)
  • ui/src/components/ChartSelect.tsx (1 hunks)
  • ui/src/components/FeedList.tsx (1 hunks)
  • ui/src/components/Header.tsx (1 hunks)
  • ui/src/components/PlayBar.tsx (1 hunks)
  • ui/src/components/Player/CandidateCardAIPlayer.tsx (1 hunks)
  • ui/src/components/Player/CandidateCardPlayer.tsx (1 hunks)
  • ui/src/components/ReportsBarChart.tsx (1 hunks)
  • ui/src/components/layouts/DrawerLayout.tsx (1 hunks)
  • ui/src/components/layouts/ModeratorLayout.tsx (1 hunks)
  • ui/src/components/layouts/ToolpadLayout.tsx (1 hunks)
  • ui/src/context/DataContext.tsx (1 hunks)
  • ui/src/graphql/queries/list2Feeds.graphql (1 hunks)
  • ui/src/graphql/queries/listDetections2.graphql (1 hunks)
  • ui/src/pages/_app.tsx (1 hunks)
  • ui/src/pages/moderator/[candidateId].tsx (1 hunks)
  • ui/src/pages/moderator/bouts.tsx (1 hunks)
  • ui/src/pages/moderator/candidates.tsx (1 hunks)
  • ui/src/pages/moderator/hydrophones.tsx (1 hunks)
  • ui/src/pages/moderator/index.tsx (1 hunks)
  • ui/src/pages/moderator/json.tsx (1 hunks)
  • ui/src/pages/moderator/learn.tsx (1 hunks)
  • ui/src/pages/moderator/playertest.tsx (1 hunks)
  • ui/src/pages/moderator/reports.tsx (1 hunks)
  • ui/src/types/DataTypes.ts (1 hunks)
✅ Files skipped from review due to trivial changes (2)
  • package.json
  • ui/src/pages/_app.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
  • ui/package.json
🧰 Additional context used
🪛 Biome (1.9.4)
ui/src/pages/moderator/playertest.tsx

[error] 22-22: Change to an optional chain.

Unsafe fix: Change to an optional chain.

(lint/complexity/useOptionalChain)

ui/src/components/PlayBar.tsx

[error] 225-225: Change to an optional chain.

Unsafe fix: Change to an optional chain.

(lint/complexity/useOptionalChain)


[error] 282-282: Change to an optional chain.

Unsafe fix: Change to an optional chain.

(lint/complexity/useOptionalChain)

ui/src/components/layouts/DrawerLayout.tsx

[error] 128-131: Change to an optional chain.

Unsafe fix: Change to an optional chain.

(lint/complexity/useOptionalChain)

ui/src/components/Player/CandidateCardAIPlayer.tsx

[error] 97-97: Change to an optional chain.

Unsafe fix: Change to an optional chain.

(lint/complexity/useOptionalChain)


[error] 106-106: Change to an optional chain.

Unsafe fix: Change to an optional chain.

(lint/complexity/useOptionalChain)


[error] 123-123: Change to an optional chain.

Unsafe fix: Change to an optional chain.

(lint/complexity/useOptionalChain)

ui/src/components/Player/CandidateCardPlayer.tsx

[error] 90-90: Change to an optional chain.

Unsafe fix: Change to an optional chain.

(lint/complexity/useOptionalChain)


[error] 97-97: Change to an optional chain.

Unsafe fix: Change to an optional chain.

(lint/complexity/useOptionalChain)


[error] 112-112: Change to an optional chain.

Unsafe fix: Change to an optional chain.

(lint/complexity/useOptionalChain)

ui/src/components/layouts/ModeratorLayout.tsx

[error] 104-104: This code is unreachable

... because this statement will return from the function beforehand

(lint/correctness/noUnreachable)


[error] 107-107: This code is unreachable

... because this statement will return from the function beforehand

(lint/correctness/noUnreachable)


[error] 110-110: This code is unreachable

... because this statement will return from the function beforehand

(lint/correctness/noUnreachable)


[error] 113-113: This code is unreachable

... because this statement will return from the function beforehand

(lint/correctness/noUnreachable)


[error] 258-261: Change to an optional chain.

Unsafe fix: Change to an optional chain.

(lint/complexity/useOptionalChain)

ui/src/pages/moderator/candidates.tsx

[error] 108-109: Change to an optional chain.

Unsafe fix: Change to an optional chain.

(lint/complexity/useOptionalChain)

⏰ Context from checks skipped due to timeout of 90000ms (2)
  • GitHub Check: Analyze (javascript-typescript)
  • GitHub Check: test
🔇 Additional comments (31)
ui/src/graphql/queries/list2Feeds.graphql (1)

1-1: GraphQL Query Declaration is Correct

The query declaration for feeds2 correctly defines an optional parameter $sort of type [FeedSortInput]. The overall syntax adheres to GraphQL standards.

ui/src/graphql/queries/listDetections2.graphql (1)

7-24: Verify Field Selections Against Schema.

The query correctly retrieves count, hasNextPage, and the detailed list of results with nested objects (feed and candidate). Please verify that these field names and their structure exactly match the GraphQL schema definitions on the server side. Also, if these fields are consistently used across different parts of the application, consider extracting them into a GraphQL fragment (e.g., the new AudioImageParts fragment) for improved reusability and maintainability.

server/priv/repo/seeds.exs (2)

16-23: Feed activation and bucket standardization looks good

You've activated the "Orcasound Lab (Haro Strait)" feed by uncommenting it and updated its bucket value to "audio-orcasound-net". This is consistent with the other bucket changes and aligns with what appears to be a standardization effort.


29-29: Consistent bucket naming standardization across all feeds

Good job standardizing all bucket values to "audio-orcasound-net" (including the commented-out feeds). This consistency will make maintenance easier and ensures all feeds use the same storage location when active.

Also applies to: 37-37, 45-45, 53-53, 61-61

ui/src/components/Header.tsx (1)

38-39: Good UX improvement with the sticky header!

Changing from static to sticky positioning allows the header to remain visible as users scroll down the page, which is especially helpful for navigating the new bout page. This ensures consistent access to navigation elements throughout the user experience.

ui/src/pages/moderator/bouts.tsx (1)

1-11: Component looks good!

The implementation correctly wraps the BoutsPage component with the moderator layout. This follows the pattern of reusing common functionality with different layouts for different user roles.

ui/src/pages/moderator/reports.tsx (1)

1-11: Implementation is clean and consistent.

The page correctly wraps the DetectionsPage component with the moderator layout, following the pattern established in other moderator pages.

ui/src/pages/moderator/index.tsx (1)

6-12: Component structure looks good.

The implementation follows the established pattern for moderator pages.

ui/src/pages/moderator/learn.tsx (1)

1-11: Clean implementation of a moderator-specific learn page

This looks good! The implementation follows Next.js patterns for creating pages with custom layouts. The ModeratorLearnPage component properly wraps the LearnPage with a moderator-specific layout using the getLayout pattern.

ui/src/components/layouts/ToolpadLayout.tsx (2)

10-20: Navigation structure is minimal

The current navigation only includes a Dashboard item. This may be intentional for now, but ensure it aligns with the planned functionality of the Toolpad interface. Consider whether additional navigation items will be needed.


26-34: Well-structured layout component

The ToolpadLayout component is cleanly implemented with appropriate wrapping of children in the required providers and containers.

ui/src/context/DataContext.tsx (1)

1-23: Clean implementation of the DataContext

The DataContext implementation follows React best practices for context creation and consumption. The default values, hook, and provider are all properly structured.

ui/src/components/FeedList.tsx (2)

11-16: Appropriate use of useMemo for sorting

Good use of useMemo to optimize the sorting operation, which will only recalculate when the data changes.


37-50: Proper implementation of getStaticProps

The getStaticProps function correctly prefetches data for server-side rendering and returns the dehydrated state as props.

ui/src/pages/moderator/hydrophones.tsx (1)

9-11: Looks good.
The assignment of ModeratorFeedsPage.getLayout = getModeratorLayout; is consistent with Next.js custom layout usage. No issues found in the default export.

ui/src/pages/moderator/playertest.tsx (1)

33-41: Verify empty array handling.
Be aware that Math.min(...detections.map(/*...*/)) and Math.max(...detections.map(/*...*/)) will produce Infinity or -Infinity if detections is an empty array. Verify that you handle the case of zero-length arrays to avoid unexpected behavior.

ui/src/components/PlayBar.tsx (1)

49-88: Guard against empty arrays when calling Math.min / Math.max.
candidate.array might be empty. Passing an empty array to Math.min(...[]) or Math.max(...[]) results in Infinity or -Infinity. Ensure you handle this scenario gracefully.

ui/src/types/DataTypes.ts (1)

35-35: Validate overlapping fields in multiple inheritance.

CombinedData extends both HumanData and AIData. If they share any properties with different types, it can lead to conflicts. Verify that fields, such as timestampString or type, don’t conflict across these interfaces.

ui/src/pages/moderator/[candidateId].tsx (2)

31-36: Validate the splitting logic for candidateId.

Using split("_") on candidateId assumes a consistent format ("start_end"). Ensure you gracefully handle unexpected or invalid tokens in real-world scenarios to avoid runtime errors.


172-186: Revisit fallback logic for selecting a player.

Currently, you’re checking if detections.human.length then show one player, otherwise check detections.ai.length, else show “no player found.” If both exist, only the human player is shown. Confirm this is the intended priority.

ui/src/components/CandidateCard.tsx (1)

26-174: Looks good!
The logic for conditionally rendering different players and linking candidate timestamps is clear and straightforward.

ui/src/components/Player/CandidateCardPlayer.tsx (7)

1-16: Dependencies and imports look appropriate.

The imports are well-organized, with external libraries first, followed by internal modules. The dynamic import of VideoJS is a good optimization for speeding up initial page load.


21-49: Well-structured component with clear and typed props.

The component has well-defined TypeScript interfaces for its props, making it clear what data it expects. The use of Pick type for the feed prop is a good practice to specify exactly which fields are needed.


50-57: State management is well-organized.

The component properly initializes its state variables with appropriate types. Using useRef for storing the player instance is the correct approach for maintaining references to imperative objects.


58-85: Memoize HLS URI configuration properly, but consider handling potential edge cases.

The useMemo hook correctly prevents unnecessary recalculations of player options. Using a dummy URI for error triggering is a clever approach.

Verify that the 404 path is properly handled in the error state. Make sure that your error handling logic in the component properly handles scenarios where the video source isn't available.


125-152: Error handling is comprehensive.

The play/pause handler has good error handling, properly checking for error states and handling the AbortError exception. The overall approach is robust.


163-180: Slider handlers are well-implemented.

The slider change handlers properly manage player state during seek operations. The use of optional chaining on playerRef?.current is good practice.


182-240: Well-structured UI layout with responsive considerations.

The component has a clean, responsive layout with proper styling for both desktop and mobile views. The use of Material-UI's Box component with theme-based styling is excellent.

ui/src/pages/moderator/candidates.tsx (3)

74-82: Interface definition looks good.

The Candidate interface is well-defined with clear properties for the data structure.


263-275: Consider using unique keys for CandidateCard components.

Using array indices as React keys can lead to unexpected behavior if the array items get reordered. Consider using a more stable unique identifier if available.

<CandidateCard
  candidate={candidate}
  key={index}  // Using index as key might cause issues if array items get reordered
  index={index}
  // ...
/>

Is there any unique identifier in the candidate object that could be used as key instead of the array index?


293-368: Overall component structure is well organized.

The component has a clear structure with filtering controls, sorting buttons, and a display of the filtered results. The Material-UI components are used consistently.

Comment on lines 94 to 100
if (dataset[i].newCategory.toLowerCase() === "whale") {
chartItem.whale += 1;
}
switch (dataset[i].newCategory.toLowerCase()) {
case "whale":
chartItem.whale += 1;
break;
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

Remove duplicated increment for "whale" category.
Lines 94-96 and 98-100 both increment chartItem.whale, which will double-count whale detections. Remove the redundant conditional to fix the overcounting.

-          if (dataset[i].newCategory.toLowerCase() === "whale") {
-            chartItem.whale += 1;
-          }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (dataset[i].newCategory.toLowerCase() === "whale") {
chartItem.whale += 1;
}
switch (dataset[i].newCategory.toLowerCase()) {
case "whale":
chartItem.whale += 1;
break;
switch (dataset[i].newCategory.toLowerCase()) {
case "whale":
chartItem.whale += 1;
break;

Comment on lines 144 to 166
export default function Candidates() {
// replace this with a direct react-query...
const {
combined,
isSuccess,
}: { combined: CombinedData[] | undefined; isSuccess: boolean } = useData(); // this uses a context provider to call data once and make it available to all children -- this may not be better than just using the query hooks, kind of does the same thing

// get hydrophone feed list
const feedsQueryResult = useFeedsQuery();
const feeds = feedsQueryResult.data?.feeds ?? [];

const [filters, setFilters] = useState({
timeRange: threeDays,
timeIncrement: 15,
hydrophone: "All hydrophones",
category: "All categories",
});

const [timeRange, setTimeRange] = useState(threeDays);
const [timeIncrement, setTimeIncrement] = useState(15);
const [hydrophone, setHydrophone] = useState("All hydrophones");
const [category, setCategory] = useState("All categories");

Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Unnecessary duplication of state.

There are duplicate state variables: filters and individual state variables for each filter property. This duplication can lead to synchronization issues.

Remove the individual state variables (timeRange, timeIncrement, etc.) and just use the filters object:

  const [filters, setFilters] = useState({
    timeRange: threeDays,
    timeIncrement: 15,
    hydrophone: "All hydrophones",
    category: "All categories",
  });

-  const [timeRange, setTimeRange] = useState(threeDays);
-  const [timeIncrement, setTimeIncrement] = useState(15);
-  const [hydrophone, setHydrophone] = useState("All hydrophones");
-  const [category, setCategory] = useState("All categories");

Then update any functions that use these individual variables to use filters instead.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export default function Candidates() {
// replace this with a direct react-query...
const {
combined,
isSuccess,
}: { combined: CombinedData[] | undefined; isSuccess: boolean } = useData(); // this uses a context provider to call data once and make it available to all children -- this may not be better than just using the query hooks, kind of does the same thing
// get hydrophone feed list
const feedsQueryResult = useFeedsQuery();
const feeds = feedsQueryResult.data?.feeds ?? [];
const [filters, setFilters] = useState({
timeRange: threeDays,
timeIncrement: 15,
hydrophone: "All hydrophones",
category: "All categories",
});
const [timeRange, setTimeRange] = useState(threeDays);
const [timeIncrement, setTimeIncrement] = useState(15);
const [hydrophone, setHydrophone] = useState("All hydrophones");
const [category, setCategory] = useState("All categories");
export default function Candidates() {
// replace this with a direct react-query...
const {
combined,
isSuccess,
}: { combined: CombinedData[] | undefined; isSuccess: boolean } = useData(); // this uses a context provider to call data once and make it available to all children -- this may not be better than just using the query hooks, kind of does the same thing
// get hydrophone feed list
const feedsQueryResult = useFeedsQuery();
const feeds = feedsQueryResult.data?.feeds ?? [];
const [filters, setFilters] = useState({
timeRange: threeDays,
timeIncrement: 15,
hydrophone: "All hydrophones",
category: "All categories",
});
// Update any function calls or references to use values directly from filters
// rather than the individual state variables.
}

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.

3 participants