Skip to content

feat: add species cards and hourly heatmap views to detections page#7

Merged
tphakala merged 3 commits intomainfrom
feat/multi-view-detections
Feb 10, 2026
Merged

feat: add species cards and hourly heatmap views to detections page#7
tphakala merged 3 commits intomainfrom
feat/multi-view-detections

Conversation

@tphakala
Copy link
Copy Markdown
Owner

@tphakala tphakala commented Feb 10, 2026

Summary

  • Three switchable views on the detections page: Table (existing), Species (card grid), and Grid (hourly heatmap)
  • Species cards view shows per-species aggregation with detection count, avg/max confidence, and first/last detected timestamps
  • Hourly heatmap view renders a species × hour-of-day matrix with BirdNET-Go-style blue/teal color palette, supporting both light and dark themes
  • New IPC handlers (catalog:get-run-species, catalog:get-hourly-detections) with database queries using extracted buildWhereClause helper
  • All shared filters (species search, confidence threshold, species list) work across all three views

Files changed

Area Files
Types shared/types.tsRunSpeciesAggregation, HourlyDetectionCell
Backend src/main/db/detections.ts — extracted buildWhereClause, added getRunSpeciesAggregation, getDetectionsForGrid
IPC src/main/ipc/catalog.ts — two new handlers with label enrichment and AudioMoth filename hour parsing
Preload src/preload/index.ts — whitelisted new channels
Renderer src/renderer/src/lib/utils/ipc.ts — two new IPC wrappers
Components src/renderer/src/lib/components/SpeciesCards.svelte (new), DetectionHeatmap.svelte (new)
Page src/renderer/src/pages/DetectionsPage.svelte — header/filter bar split, view state, switching logic
i18n messages/en.json — 16 new keys for views, cards, and heatmap

Test plan

  • Select a run with detections — Table view should look identical to current behavior
  • Click Species toggle — cards show with correct counts, confidence, timestamps
  • Click Grid toggle — heatmap renders species × hours 0-23 with colored cells
  • Adjust filters (search, confidence slider, species list) — all views update
  • Switch between runs — views reload with new data
  • Toggle light/dark theme — heatmap palette adapts (blue scale / teal-cyan scale)
  • Verify contextual header count updates per view (detections count vs species count)

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added species and grid (hourly heatmap) view modes alongside the table view.
    • Species view: sortable cards showing detection counts, avg/max confidence, and first/last seen.
    • Grid view: 24-hour heatmap with per-species hourly counts, legend and tooltips.
    • New UI endpoints to retrieve aggregated species and hourly detection data.
    • Expanded English localization strings for views, cards, grid, and pagination.

Split the detections page into three switchable views: Table (existing),
Species (card grid with aggregated stats), and Grid (hourly detection
heatmap). Adds new IPC handlers, database queries, and shared types to
support per-species aggregation and hourly detection grouping. The heatmap
includes dark/light theme-aware color palettes matching BirdNET-Go style.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Feb 10, 2026

Walkthrough

Adds species-aggregation and hourly-detection features: new DB query helpers, IPC handlers, shared types, renderer IPC clients, localization keys, two UI components (SpeciesCards, DetectionHeatmap), and refactors DetectionsPage to support table/species/grid views with per-view loading and filtering.

Changes

Cohort / File(s) Summary
Localization
messages/en.json
Added ~19 i18n keys for view toggles, species-card labels, grid labels, and pagination; minor formatting tweak to species_loading.
Types
shared/types.ts
Added RunSpeciesAggregation and HourlyDetectionCell interfaces for aggregation and hourly-cell payloads.
Database
src/main/db/detections.ts
Added buildWhereClause(filter) and two query helpers: getRunSpeciesAggregation(filter) and getDetectionsForGrid(filter); added raw internal types and validated sort columns.
Main IPC / Catalog
src/main/ipc/catalog.ts
Added handlers catalog:get-run-species and catalog:get-hourly-detections; helpers to resolve species filters and compute detection hours; enriches rows with common names.
Preload
src/preload/index.ts
Exposed IPC channels catalog:get-run-species and catalog:get-hourly-detections for renderer invoke.
Renderer IPC client
src/renderer/src/lib/utils/ipc.ts
Added getRunSpecies(filter) and getHourlyDetections(filter) wrappers and updated type imports.
UI Components
src/renderer/src/lib/components/SpeciesCards.svelte, src/renderer/src/lib/components/DetectionHeatmap.svelte
Added SpeciesCards (sortable grid with counts/confidence/timestamps) and DetectionHeatmap (24-hour heatmap with theme-aware palette, tooltips, legend).
Page integration
src/renderer/src/pages/DetectionsPage.svelte
Refactored to multi-view (table, species, grid) with shared filter builder, per-view loaders (loadSpeciesView, loadGridView), view toggles, header counts, and per-view state management.

Sequence Diagram

sequenceDiagram
    participant User as User/UI
    participant Renderer as Renderer
    participant Preload as Preload / IPC Bridge
    participant Main as Main Process
    participant DB as Database
    participant Catalog as Label Service

    User->>Renderer: switch to "Species" view
    Renderer->>Renderer: buildBaseFilter(...)
    Renderer->>Preload: invoke('catalog:get-run-species', filter)
    Preload->>Main: catalog:get-run-species
    Main->>DB: getRunSpeciesAggregation(filter)
    DB-->>Main: RawRunSpeciesAggregation[]
    Main->>Catalog: resolveAll(scientific_names)
    Catalog-->>Main: common_name mappings
    Main-->>Preload: RunSpeciesAggregation[]
    Preload-->>Renderer: aggregated species data
    Renderer->>Renderer: render SpeciesCards

    User->>Renderer: switch to "Grid" view
    Renderer->>Preload: invoke('catalog:get-hourly-detections', filter)
    Preload->>Main: catalog:get-hourly-detections
    Main->>DB: getDetectionsForGrid(filter)
    DB-->>Main: RawGridDetection[]
    Main->>Main: computeDetectionHour(...) & aggregate by (species,hour)
    Main->>Catalog: resolveAll(scientific_names)
    Catalog-->>Main: common_name mappings
    Main-->>Preload: HourlyDetectionCell[]
    Preload-->>Renderer: hourly detection cells
    Renderer->>Renderer: render DetectionHeatmap
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 12.50% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: add species cards and hourly heatmap views to detections page' clearly and accurately summarizes the main change: introducing two new UI views (species cards and hourly heatmap) to the detections page.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/multi-view-detections

No actionable comments were generated in the recent review. 🎉

🧹 Recent nitpick comments
src/main/ipc/catalog.ts (2)

105-132: Consider a structured composite key instead of null-byte delimited strings.

Using \0 as a map key separator works but is unconventional and requires splitting on read. A Map<string, Map<number, number>> (species → hour → count) would be more explicit and avoid the string encode/decode round-trip.

♻️ Suggested alternative using nested maps
-    const counters = new Map<string, number>();
-    const speciesNames = new Set<string>();
-
-    for (const row of rows) {
-      const hour = computeDetectionHour(row.source_file, row.start_time);
-      const key = `${row.scientific_name}\0${hour}`;
-      counters.set(key, (counters.get(key) ?? 0) + 1);
-      speciesNames.add(row.scientific_name);
-    }
-
-    const nameMap = resolveAll([...speciesNames]);
-    const result: HourlyDetectionCell[] = [];
-    for (const [key, count] of counters) {
-      const [sci, hourStr] = key.split('\0');
-      result.push({
-        scientific_name: sci,
-        common_name: nameMap.get(sci) ?? sci,
-        hour: parseInt(hourStr, 10),
-        detection_count: count,
-      });
-    }
+    const counters = new Map<string, Map<number, number>>();
+
+    for (const row of rows) {
+      const hour = computeDetectionHour(row.source_file, row.start_time);
+      let hourMap = counters.get(row.scientific_name);
+      if (!hourMap) {
+        hourMap = new Map();
+        counters.set(row.scientific_name, hourMap);
+      }
+      hourMap.set(hour, (hourMap.get(hour) ?? 0) + 1);
+    }
+
+    const nameMap = resolveAll([...counters.keys()]);
+    const result: HourlyDetectionCell[] = [];
+    for (const [sci, hourMap] of counters) {
+      const commonName = nameMap.get(sci) ?? sci;
+      for (const [hour, count] of hourMap) {
+        result.push({
+          scientific_name: sci,
+          common_name: commonName,
+          hour,
+          detection_count: count,
+        });
+      }
+    }

94-103: Name enrichment pattern is repeated across handlers — consider extracting.

The resolve-all + map-with-fallback pattern appears here, in enrichDetections, enrichSpeciesSummaries, and the hourly handler. A small generic helper like enrichWithCommonName<T>(rows, getScientificName) could DRY this up.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@gemini-code-assist
Copy link
Copy Markdown

Summary of Changes

Hello @tphakala, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request significantly enhances the user experience on the detections page by introducing new ways to visualize and interact with detection data. Users can now switch between a detailed table, an aggregated species card view, and an hourly heatmap, providing more comprehensive insights into their analysis runs. The changes involve substantial additions to the data layer for efficient aggregation and a complete overhaul of the detections page UI to support these new interactive views, all while maintaining compatibility with existing filtering capabilities.

Highlights

  • New Detections Page Views: Introduced three switchable views on the detections page: the existing Table view, a new Species card grid view, and a new Hourly heatmap view, allowing users to visualize detection data in different aggregated formats.
  • Species Cards View: Added a 'Species' view that displays aggregated data per species, including detection count, average and maximum confidence, and first/last detected timestamps, presented in a card grid layout.
  • Hourly Heatmap View: Implemented a 'Grid' view featuring an hourly heatmap that visualizes species detections across hours of the day, using a BirdNET-Go-style color palette that adapts to light and dark themes.
  • Backend Data Aggregation: Developed new backend database queries and IPC handlers (catalog:get-run-species, catalog:get-hourly-detections) to efficiently retrieve aggregated species and hourly detection data, including a helper to build WHERE clauses.
  • Integrated Filtering: Ensured that all existing shared filters, such as species search, confidence threshold, and species lists, function consistently across all three new detection views.
Changelog
  • messages/en.json
    • Added 16 new internationalization keys for the new view types, species cards, and heatmap components.
  • shared/types.ts
    • Defined new TypeScript interfaces RunSpeciesAggregation for species-level data and HourlyDetectionCell for hourly heatmap data.
  • src/main/db/detections.ts
    • Introduced RawRunSpeciesAggregation and RawGridDetection interfaces for raw database query results.
    • Extracted common filtering logic into a reusable buildWhereClause function.
    • Added getRunSpeciesAggregation function to retrieve aggregated species data for the cards view.
    • Added getDetectionsForGrid function to fetch detection data necessary for the hourly heatmap.
  • src/main/ipc/catalog.ts
    • Imported new types and database functions to support new data views.
    • Implemented computeDetectionHour to accurately determine the wall-clock hour from AudioMoth filenames and detection offsets.
    • Registered new IPC handlers catalog:get-run-species and catalog:get-hourly-detections to expose aggregated data to the renderer process.
  • src/preload/index.ts
    • Whitelisted the new IPC channels catalog:get-run-species and catalog:get-hourly-detections for secure communication.
  • src/renderer/src/lib/components/DetectionHeatmap.svelte
    • Added a new Svelte component DetectionHeatmap to render the hourly species detection grid, including dynamic color palettes for light/dark themes and tooltips.
  • src/renderer/src/lib/components/SpeciesCards.svelte
    • Added a new Svelte component SpeciesCards to display species aggregations in a sortable card grid format.
  • src/renderer/src/lib/utils/ipc.ts
    • Added new IPC wrapper functions getRunSpecies and getHourlyDetections for frontend interaction with the new backend handlers.
  • src/renderer/src/pages/DetectionsPage.svelte
    • Introduced activeView state to manage the currently displayed detection view (table, species, grid).
    • Added state variables (speciesData, speciesLoading, speciesSortBy, gridData, gridLoading) to manage data and loading states for the new views.
    • Refactored filter application into a buildBaseFilter function for consistency across views.
    • Implemented loadSpeciesView and loadGridView functions to fetch data for the new views.
    • Created loadActiveView and switchView functions to handle view transitions and data loading.
    • Updated filter handlers (handleSearch, clearSpeciesFilter, minConfidence reactivity, speciesListFilterId reactivity) to trigger loadActiveView.
    • Modified the header to include view toggle buttons and a contextual detection/species count based on the active view.
    • Integrated the new SpeciesCards and DetectionHeatmap components, rendering them conditionally based on activeView.
Activity
  • No specific human activity (comments, reviews, progress updates) has been recorded for this pull request yet.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces two new views on the detections page: a species card grid and an hourly detection heatmap. The changes include new database queries, IPC handlers, and Svelte components for the new views. The code is well-structured, with good extraction of reusable logic like buildWhereClause. I've found a few issues: a potential timezone bug in the heatmap data calculation, some code duplication in the IPC handlers, and a data-loading bug in the main page component that can lead to stale data being displayed. My review includes suggestions to fix these issues.

const match = /^(\d{4})(\d{2})(\d{2})_(\d{2})(\d{2})(\d{2})$/.exec(base);
if (match) {
const [, y, mo, d, h, mi, s] = match;
const date = new Date(+y, +mo - 1, +d, +h, +mi, +s);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

The current implementation of computeDetectionHour assumes the timestamp in the AudioMoth filename is in the system's local timezone. These timestamps are often in UTC. This can lead to incorrect hour calculations for the heatmap if the user's system is not in the UTC timezone. To ensure correctness, you should parse the timestamp as UTC.

Suggested change
const date = new Date(+y, +mo - 1, +d, +h, +mi, +s);
const date = new Date(Date.UTC(+y, +mo - 1, +d, +h, +mi, +s));

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in c391e8f. Changed to Date.UTC() + getUTCHours() so AudioMoth timestamps are correctly parsed as UTC.

Comment on lines +157 to +168
function loadActiveView() {
void loadRunDetections();
if (activeView === 'species') void loadSpeciesView();
if (activeView === 'grid') void loadGridView();
}

function switchView(view: DetectionView) {
if (view === activeView) return;
activeView = view;
if (view === 'species' && speciesData.length === 0) void loadSpeciesView();
if (view === 'grid' && gridData.length === 0) void loadGridView();
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

The data loading logic across the different views has a potential bug and is confusing.

  1. Bug: When filters are changed while on one view (e.g., 'Table'), data for other views is not updated. Switching to another view (e.g., 'Species') will then display stale, unfiltered data because switchView only reloads data if it's empty.
  2. Clarity: The function loadActiveView is misleadingly named. It always reloads data for the 'Table' view, regardless of which view is active, and then conditionally loads for other views.

I suggest refactoring this to a clearer and more robust approach where changing filters or switching views reliably loads fresh data for the active view. You would also need to replace all calls to loadActiveView() with loadActiveViewData() throughout the component.

  function loadActiveViewData() {
    if (!appState.selectedRunId) return;
    switch (activeView) {
      case 'table':
        void loadRunDetections();
        break;
      case 'species':
        void loadSpeciesView();
        break;
      case 'grid':
        void loadGridView();
        break;
    }
  }

  function switchView(view: DetectionView) {
    if (view === activeView) return;
    activeView = view;
    loadActiveViewData();
  }

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in c391e8f. Refactored both loadActiveView and switchView to use a switch statement that always reloads data for the active view, eliminating stale data after filter changes.

Comment on lines +85 to +91
ipcMain.handle('catalog:get-run-species', (_event, filter: DetectionFilter): RunSpeciesAggregation[] => {
if (filter.species) {
const matchingScientific = searchByCommonName(filter.species);
if (matchingScientific.length > 0) {
filter = { ...filter, scientific_names: matchingScientific };
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

This logic for resolving a species filter query to a list of scientific names is duplicated across catalog:get-detections, catalog:get-run-species, and catalog:get-hourly-detections. To improve maintainability and reduce code duplication, consider extracting this into a shared helper function.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in c391e8f. Extracted resolveSpeciesFilter() helper and replaced the duplicated pattern in all three handlers.

Comment on lines +41 to +51
function computeDetectionHour(sourceFile: string, startTime: number): number {
const base = sourceFile.replace(/^.*[\\/]/, '').replace(/\.[^.]+$/, '');
const match = /^(\d{4})(\d{2})(\d{2})_(\d{2})(\d{2})(\d{2})$/.exec(base);
if (match) {
const [, y, mo, d, h, mi, s] = match;
const date = new Date(+y, +mo - 1, +d, +h, +mi, +s);
const actual = new Date(date.getTime() + startTime * 1000);
return actual.getHours();
}
// Fallback: treat start_time as offset from midnight (hour within recording)
return Math.floor(startTime / 3600) % 24;

This comment was marked as outdated.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in c391e8f. Changed to new Date(Date.UTC(...)) and getUTCHours().

Copy link
Copy Markdown

@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

🤖 Fix all issues with AI agents
In `@src/main/ipc/catalog.ts`:
- Around line 41-52: computeDetectionHour currently falls back to
Math.floor(startTime/3600)%24 when the filename doesn't match the
YYYYMMDD_HHMMSS pattern, which can produce a misleading wall-clock hour; change
computeDetectionHour to return null (or undefined) on unmatched filenames
instead of a computed hour, update its signature/usage to allow null, and adjust
callers (e.g., heatmap generation or any code that consumes
computeDetectionHour) to treat null as "unknown/skip" or bucket into an
"unknown" column, and add a brief comment in computeDetectionHour explaining
that accurate hours require AudioMoth-style filenames.

In `@src/renderer/src/pages/DetectionsPage.svelte`:
- Around line 157-168: The bug is stale species/grid data after filter changes
because switchView skips reload when speciesData/gridData are non-empty; change
switchView (and adjust loadActiveView) so that switchView always calls
loadSpeciesView() when view === 'species' and always calls loadGridView() when
view === 'grid' (do not gate on speciesData.length or gridData.length), and
update loadActiveView to only load table data when activeView === 'table'
(remove eager loadRunDetections() when not on table) so views are reloaded with
current filters and redundant DB calls are avoided.
🧹 Nitpick comments (5)
src/main/db/detections.ts (1)

135-142: Unbounded result set may cause memory pressure on large runs.

getDetectionsForGrid fetches every matching detection row into memory so the IPC handler can compute hourly buckets in JS. For runs with tens or hundreds of thousands of detections this is fine, but very large datasets could cause noticeable memory spikes.

Consider adding a SQL-side aggregation (e.g., grouping by scientific_name and extracting the hour via substr(source_file, …) for the common AudioMoth path) or at least documenting the expected upper bound. Not urgent if current dataset sizes are modest.

src/main/ipc/catalog.ts (2)

85-107: Extract the repeated species-filter resolution into a helper.

The pattern of resolving filter.species via searchByCommonName and attaching scientific_names is now duplicated in three handlers (lines 74-79, 86-91, 102-107). A small helper would reduce repetition:

♻️ Suggested helper extraction
+function resolveSpeciesFilter(filter: DetectionFilter): DetectionFilter {
+  if (filter.species) {
+    const matchingScientific = searchByCommonName(filter.species);
+    if (matchingScientific.length > 0) {
+      return { ...filter, scientific_names: matchingScientific };
+    }
+  }
+  return filter;
+}

Then each handler becomes:

filter = resolveSpeciesFilter(filter);

123-131: parseInt without explicit radix.

Nitpick: parseInt(hourStr) on line 128 works correctly here since hour strings are "0""23", but passing radix 10 explicitly is a common best practice.

♻️ Suggested fix
-        hour: parseInt(hourStr),
+        hour: parseInt(hourStr, 10),
src/renderer/src/lib/components/DetectionHeatmap.svelte (1)

73-85: SvelteMap is unnecessary inside $derived.by() — plain Map suffices.

Since these maps are recreated from scratch every time cells changes (the $derived.by block re-runs entirely), there's no need for Svelte's reactive SvelteMap. A regular Map avoids the reactive-proxy overhead without any loss of reactivity.

♻️ Suggested change
   const speciesList = $derived.by(() => {
-    const seen = new SvelteMap<string, string>();
+    const seen = new Map<string, string>();
     for (const c of cells) seen.set(c.scientific_name, c.common_name);
     return [...seen.entries()]
       .sort((a, b) => a[1].localeCompare(b[1]))
       .map(([scientific_name, common_name]) => ({ scientific_name, common_name }));
   });
 
   const cellMap = $derived.by(() => {
-    const lookup = new SvelteMap<string, number>();
+    const lookup = new Map<string, number>();
     for (const c of cells) lookup.set(`${c.scientific_name}:${c.hour}`, c.detection_count);
     return lookup;
   });
src/renderer/src/lib/components/SpeciesCards.svelte (1)

21-25: Sort option labels are computed once and won't update on runtime language change.

sortOptions is a plain const initialized at component creation, so m.species_card_sortCount() etc. are evaluated once. If the UI language can change at runtime without remounting this component, these labels will be stale. Converting to a $derived would fix this, though it's a non-issue if language changes trigger a full page reload.

♻️ Reactive alternative
-  const sortOptions: { id: SortOption; label: string }[] = [
-    { id: 'count', label: m.species_card_sortCount() },
-    { id: 'name', label: m.species_card_sortName() },
-    { id: 'confidence', label: m.species_card_sortConfidence() },
-  ];
+  const sortOptions = $derived<{ id: SortOption; label: string }[]>([
+    { id: 'count', label: m.species_card_sortCount() },
+    { id: 'name', label: m.species_card_sortName() },
+    { id: 'confidence', label: m.species_card_sortConfidence() },
+  ]);

- Fix stale data bug: loadActiveView/switchView now always reload
  the active view's data instead of skipping when data exists
- Fix UTC timezone bug: computeDetectionHour uses Date.UTC and
  getUTCHours for correct AudioMoth timestamp parsing
- Extract resolveSpeciesFilter helper to deduplicate species name
  resolution across three IPC handlers
- Add explicit radix to parseInt call

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@tphakala
Copy link
Copy Markdown
Owner Author

/gemini review

Copy link
Copy Markdown

@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

🤖 Fix all issues with AI agents
In `@src/renderer/src/pages/DetectionsPage.svelte`:
- Around line 67-81: The species/grid branches of the headerCount derived value
always call m.pagination_speciesCount and never handle the singular case; update
the species and grid cases (inside the headerCount const derived block) to
detect when the count is exactly 1 (use speciesData.length for 'species' and the
uniqueSpecies count for 'grid') and call a singular i18n key (e.g.,
m.pagination_speciesCountSingular) when count === 1, otherwise call
m.pagination_speciesCount; alternatively, use your existing pluralization helper
if available to produce the correct singular/plural string for species.
🧹 Nitpick comments (3)
src/main/ipc/catalog.ts (1)

101-128: In-memory aggregation of all raw detections may not scale for large runs.

getDetectionsForGrid fetches every detection row for the run, then aggregates in JS. For runs with hundreds of thousands of detections, this could cause significant memory pressure and latency. The filename-based hour derivation prevents pushing this to SQL, so the approach is understandable, but consider adding a LIMIT safety cap or a note about expected dataset sizes.

The aggregation logic itself (null-char key, split, parseInt) is correct and clean.

src/renderer/src/pages/DetectionsPage.svelte (2)

83-91: run_id: appState.selectedRunId ?? 0 silently defaults to an invalid run ID.

All callers guard with if (!appState.selectedRunId) return before reaching buildBaseFilter, so run_id will never actually be 0 at runtime. However, defaulting to 0 instead of throwing or returning undefined masks potential misuse if a future caller omits the guard. Consider making this explicit:

Suggested tightening
  function buildBaseFilter() {
+   const runId = appState.selectedRunId;
+   if (!runId) throw new Error('buildBaseFilter called without a selected run');
    return {
-     run_id: appState.selectedRunId ?? 0,
+     run_id: runId,
      min_confidence: ignoreConfidence ? undefined : appState.minConfidence,
      species: speciesQuery || undefined,
      species_list_id: speciesListFilterId || undefined,
    };
  }

284-297: Species-list cross-tab effect: loadActiveView() call is not void-prefixed like other fire-and-forget sites.

Other call sites (e.g., lines 160, 176, 212) use void loadRunDetections() or void loadSpeciesView() to explicitly discard the promise. Here, loadActiveView() (which itself uses void internally) is called bare. Consistency-wise, consider prefixing with void or — since loadActiveView is synchronous and returns undefined — this is technically fine but differs from the surrounding pattern.

Comment on lines +67 to +81
// --- Contextual header count ---
const headerCount = $derived.by(() => {
switch (activeView) {
case 'table':
return total === 1
? m.pagination_detectionCountSingular({ count: formatNumber(total) })
: m.pagination_detectionCount({ count: formatNumber(total) });
case 'species':
return m.pagination_speciesCount({ count: formatNumber(speciesData.length) });
case 'grid': {
const uniqueSpecies = new Set(gridData.map((c) => c.scientific_name)).size;
return m.pagination_speciesCount({ count: formatNumber(uniqueSpecies) });
}
}
});
Copy link
Copy Markdown

@coderabbitai coderabbitai bot Feb 10, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

No singular form for species count in species/grid views.

The table view distinguishes singular vs plural (pagination_detectionCountSingular / pagination_detectionCount), but both species and grid cases always use pagination_speciesCount even when the count is 1. If the i18n key doesn't handle pluralization internally, the header could read "1 species" (correct in English) or awkwardly in other locales.

This is a very minor i18n nit — only matters when exactly one species is present.

🤖 Prompt for AI Agents
In `@src/renderer/src/pages/DetectionsPage.svelte` around lines 67 - 81, The
species/grid branches of the headerCount derived value always call
m.pagination_speciesCount and never handle the singular case; update the species
and grid cases (inside the headerCount const derived block) to detect when the
count is exactly 1 (use speciesData.length for 'species' and the uniqueSpecies
count for 'grid') and call a singular i18n key (e.g.,
m.pagination_speciesCountSingular) when count === 1, otherwise call
m.pagination_speciesCount; alternatively, use your existing pluralization helper
if available to produce the correct singular/plural string for species.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Valid point. Currently English only and "1 species" reads correctly, but noted for when we add more locales.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@tphakala, sounds good! You're absolutely right that "1 species" is grammatically correct in English since "species" is one of those words that's both singular and plural. Deferring the singular/plural handling for other locales until you actually add them is a pragmatic approach—no need to over-engineer for hypothetical cases.

Thanks for noting it for future i18n work! 👍

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces significant new functionality by adding 'Species' and 'Grid' (heatmap) views to the detections page, complementing the existing table view. The implementation is well-structured, with good separation of concerns between the backend (database queries, IPC handlers) and the frontend (new Svelte components). The use of Svelte 5's new reactivity model is also well done.

My review includes a correction for a potential bug in date parsing within an IPC handler that could lead to incorrect data in the heatmap view, and a suggestion to refactor a small piece of duplicated code in the main page component for better maintainability. Overall, this is a great addition to the application.

Comment on lines +42 to +53
function computeDetectionHour(sourceFile: string, startTime: number): number {
const base = sourceFile.replace(/^.*[\\/]/, '').replace(/\.[^.]+$/, '');
const match = /^(\d{4})(\d{2})(\d{2})_(\d{2})(\d{2})(\d{2})$/.exec(base);
if (match) {
const [, y, mo, d, h, mi, s] = match;
const date = new Date(Date.UTC(+y, +mo - 1, +d, +h, +mi, +s));
const actual = new Date(date.getTime() + startTime * 1000);
return actual.getUTCHours();
}
// Fallback: treat start_time as offset from midnight (hour within recording)
return Math.floor(startTime / 3600) % 24;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

The computeDetectionHour function parses a date from the filename but doesn't validate if the parsed date components (year, month, day) are valid. The Date constructor can silently accept invalid values (e.g., month 13) and adjust the date, which could lead to incorrect hour calculations. This is inconsistent with parseRecordingStart in the renderer code, which does perform this validation. It's recommended to add validation to handle potentially malformed filenames gracefully, for example by using the fallback logic.

function computeDetectionHour(sourceFile: string, startTime: number): number {
  const base = sourceFile.replace(/^.*[\\/]/, '').replace(/\.[^.]+$/, '');
  const match = /^(\d{4})(\d{2})(\d{2})_(\d{2})(\d{2})(\d{2})$/.exec(base);
  if (match) {
    const [, y, mo, d, h, mi, s] = match;
    const date = new Date(Date.UTC(+y, +mo - 1, +d, +h, +mi, +s));
    // Validate that the parsed date is not an automatic correction of invalid parts
    if (date.getUTCFullYear() !== +y || date.getUTCMonth() !== +mo - 1 || date.getUTCDate() !== +d) {
      // Malformed date in filename, use fallback
      return Math.floor(startTime / 3600) % 24;
    }
    const actual = new Date(date.getTime() + startTime * 1000);
    return actual.getUTCHours();
  }
  // Fallback: treat start_time as offset from midnight (hour within recording)
  return Math.floor(startTime / 3600) % 24;
}

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 71d4fe4. Added date component validation - if the parsed date doesn't match the input components (e.g. month 13 silently becoming next year), we fall back to the start_time-based calculation.

Comment on lines +171 to +185
function switchView(view: DetectionView) {
if (view === activeView) return;
activeView = view;
switch (view) {
case 'table':
void loadRunDetections();
break;
case 'species':
void loadSpeciesView();
break;
case 'grid':
void loadGridView();
break;
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The switch statement inside switchView duplicates the logic from loadActiveView. You can simplify this by calling loadActiveView from within switchView. This will make the code more maintainable and reduce redundancy.

  function switchView(view: DetectionView) {
    if (view === activeView) return;
    activeView = view;
    loadActiveView();
  }

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 71d4fe4. switchView now delegates to loadActiveView() instead of duplicating the switch.

- switchView now delegates to loadActiveView instead of duplicating
  the switch statement
- Add date component validation in computeDetectionHour to handle
  malformed filenames (e.g. month 13) that Date silently corrects

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@tphakala tphakala merged commit b7bf45a into main Feb 10, 2026
5 checks passed
@tphakala tphakala deleted the feat/multi-view-detections branch February 10, 2026 17:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant