Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 1 addition & 13 deletions ui/src/components/Bouts/BoutItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
Feed,
useDetectionsCountQuery,
} from "@/graphql/generated";
import { durationString } from "@/utils/time";

import CategoryIcon from "./CategoryIcon";

Expand Down Expand Up @@ -145,16 +146,3 @@ export default function BoutItem({
</Link>
);
}

function durationString(durationMs: number | null | undefined) {
if (typeof durationMs !== "number") return "";
const duration = intervalToDuration({ start: 0, end: durationMs });
const hours = Math.floor(durationMs / 1000 / 60 / 60);

const zeroPad = (num: number) => String(num).padStart(2, "0");
const formatted = [hours, duration.minutes ?? 0, duration.seconds ?? 0]
.map(zeroPad)
.join(":");

return formatted;
}
17 changes: 15 additions & 2 deletions ui/src/components/DetectionsTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
useNotifyConfirmedCandidateMutation,
useSetDetectionVisibleMutation,
} from "@/graphql/generated";
import { ReportBoutList } from "@/modules/reports/ReportBoutList";
import { analytics } from "@/utils/analytics";
import { formatTimestamp } from "@/utils/time";

Expand All @@ -53,8 +54,11 @@ export default function DetectionsTable({
| "sourceIp"
| "description"
>[];
feed: Pick<Feed, "slug" | "nodeName" | "bucket">;
candidate: Pick<Candidate, "id" | "visible">;
feed: Pick<Feed, "id" | "slug" | "name" | "nodeName" | "bucket">;
candidate: Pick<
Candidate,
"id" | "visible" | "minTime" | "maxTime" | "category"
>;
onDetectionUpdate: () => void;
}) {
const offsetPadding = 15;
Expand Down Expand Up @@ -182,6 +186,15 @@ export default function DetectionsTable({
</TableBody>
</Table>

{candidate?.category && (
<ReportBoutList
feed={feed}
minTime={candidate.minTime}
maxTime={candidate.maxTime}
category={candidate.category}
/>
)}

{currentUser?.moderator && (
<Box sx={{ marginTop: 10 }}>
<Box
Expand Down
177 changes: 177 additions & 0 deletions ui/src/modules/reports/ReportBoutList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import {
Box,
Button,
Chip,
Link,
Skeleton,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
Typography,
} from "@mui/material";
import {
differenceInMilliseconds,
format,
formatDuration,
intervalToDuration,
} from "date-fns";
import _ from "lodash";
import { useEffect, useState } from "react";

import {
BoutQuery,
DetectionCategory,
Feed,
useBoutsQuery,
} from "@/graphql/generated";
import { detectionCategoryToAudioCategory } from "@/utils/enum";
import { durationString } from "@/utils/time";

type BoutResult = NonNullable<BoutQuery["bout"]>;

export function ReportBoutList({
feed,
minTime,
maxTime,
category,
}: {
feed: Pick<Feed, "id" | "name">;
minTime: Date;
maxTime: Date;
category: DetectionCategory;
}) {
console.log("feed id?", feed.id);
const { data: boutData, isLoading } = useBoutsQuery({
feedId: feed.id,
filter: {
category: { eq: detectionCategoryToAudioCategory(category) },
startTime: { lessThanOrEqual: maxTime },
endTime: { greaterThanOrEqual: minTime },
},
});

const bouts = boutData?.bouts;

return (
<Box sx={{ marginTop: 10 }}>
<h3>Bouts</h3>
<Table sx={{ marginTop: 2 }}>
<TableHead>
<TableRow>
<TableCell>#</TableCell>
<TableCell>ID</TableCell>
<TableCell>Name</TableCell>
<TableCell>Category</TableCell>
<TableCell>Start time</TableCell>
<TableCell>End time</TableCell>
<TableCell>Duration</TableCell>
<TableCell>Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{isLoading && (
<TableRow>
{_.range(8).map((_, idx) => (
<TableCell key={idx}>
<Skeleton animation="wave" variant="text" />
</TableCell>
))}
</TableRow>
)}
{!isLoading &&
(bouts?.count || 0) > 0 &&
bouts?.results?.map((bout, idx) => (
<BoutItem bout={bout} index={idx} key={bout.id} feed={feed} />
))}
{!isLoading && (bouts?.count || 0) === 0 && (
<TableRow>
<TableCell colSpan={8} sx={{ textAlign: "center" }}>
No bouts for this candidate
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</Box>
);
}

function BoutItem({
bout,
feed,
index,
}: {
bout: BoutResult;
feed: Pick<Feed, "name">;
index: number;
}) {
const [duration, setDuration] = useState(
bout.duration && bout.duration * 1000,
);
const isLive = bout.endTime === undefined || bout.endTime === null;
useEffect(() => {
let interval: NodeJS.Timeout | undefined;
if (isLive) {
setDuration(
differenceInMilliseconds(new Date(), new Date(bout.startTime)),
);
interval = setInterval(() => {
setDuration(
differenceInMilliseconds(new Date(), new Date(bout.startTime)),
);
}, 1000);
}

return () => {
clearInterval(interval);
};
}, [bout.startTime, isLive]);

return (
<TableRow key={bout.id}>
<TableCell>{index + 1}</TableCell>
<TableCell>{bout.id}</TableCell>
<TableCell>{bout.name || feed.name}</TableCell>
<TableCell>
<Chip label={bout.category} />
</TableCell>
<TableCell>
{format(new Date(bout.startTime), "h:mm:ss a O")}
<Typography fontSize={"small"}>
{new Date(bout.startTime).toLocaleDateString()}
</Typography>
</TableCell>
<TableCell>
{bout.endTime && (
<>
{format(new Date(bout.endTime), "h:mm:ss a O")}
<Typography fontSize={"small"}>
{new Date(bout.endTime).toLocaleDateString()}
</Typography>
</>
)}
</TableCell>
<TableCell>
{duration && (
<Box
sx={{ mt: { sm: "auto" }, ml: { xs: "auto", sm: 0 } }}
title={formatDuration(
intervalToDuration({ start: 0, end: duration }),
)}
>
<Typography variant="monospace" textAlign="right">
{durationString(duration)}
</Typography>
</Box>
)}
</TableCell>
Comment on lines +156 to +169

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

Duration not shown for 0ms and invalid Typography variant.

  • The truthy check hides “00:00:00” when duration is 0.
  • Typography variant "monospace" is not a valid MUI variant; use a standard variant and set fontFamily.

Apply this diff:

-      <TableCell>
-        {duration && (
+      <TableCell>
+        {duration != null && (
           <Box
             sx={{ mt: { sm: "auto" }, ml: { xs: "auto", sm: 0 } }}
             title={formatDuration(
               intervalToDuration({ start: 0, end: duration }),
             )}
           >
-            <Typography variant="monospace" textAlign="right">
+            <Typography variant="body2" sx={{ fontFamily: "monospace" }} textAlign="right">
               {durationString(duration)}
             </Typography>
           </Box>
         )}
       </TableCell>
📝 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
<TableCell>
{duration && (
<Box
sx={{ mt: { sm: "auto" }, ml: { xs: "auto", sm: 0 } }}
title={formatDuration(
intervalToDuration({ start: 0, end: duration }),
)}
>
<Typography variant="monospace" textAlign="right">
{durationString(duration)}
</Typography>
</Box>
)}
</TableCell>
<TableCell>
{duration != null && (
<Box
sx={{ mt: { sm: "auto" }, ml: { xs: "auto", sm: 0 } }}
title={formatDuration(
intervalToDuration({ start: 0, end: duration }),
)}
>
<Typography
variant="body2"
sx={{ fontFamily: "monospace" }}
textAlign="right"
>
{durationString(duration)}
</Typography>
</Box>
)}
</TableCell>
🤖 Prompt for AI Agents
In ui/src/modules/reports/ReportBoutList.tsx around lines 156 to 169, the
current truthy check hides a 0ms duration and the Typography uses an invalid MUI
variant "monospace"; change the conditional to explicitly check for
null/undefined (e.g., duration !== null && duration !== undefined) so 0 is
rendered, and replace the Typography variant with a valid variant such as
"body2" (or "caption") while applying sx={{ fontFamily: 'monospace' }} (or
theme.typography.monospace) to get monospaced styling; keep the existing title
formatting logic.

<TableCell>
<Link href={`/bouts/${bout.id}`} title={`/bouts/${bout.id}`}>
<Button size="small">View</Button>
</Link>
</TableCell>
</TableRow>
);
}
1 change: 1 addition & 0 deletions ui/src/pages/reports/[candidateId].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ const CandidatePage: NextPageWithLayout = () => {
)}
</Box>
</Box>

{candidate && (
<DetectionsTable
detections={candidate.detections}
Expand Down
21 changes: 21 additions & 0 deletions ui/src/utils/enum.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { AudioCategory, DetectionCategory } from "@/graphql/generated";

export function detectionCategoryToAudioCategory(
detectionCategory: DetectionCategory,
): AudioCategory {
return {
WHALE: AudioCategory.Biophony,
VESSEL: AudioCategory.Anthrophony,
OTHER: AudioCategory.Geophony,
}[detectionCategory];
}

export function audioCategoryToDetectionCategory(
audioCategory: AudioCategory,
): DetectionCategory {
return {
BIOPHONY: DetectionCategory.Whale,
ANTHROPHONY: DetectionCategory.Vessel,
GEOPHONY: DetectionCategory.Other,
}[audioCategory];
}
15 changes: 15 additions & 0 deletions ui/src/utils/time.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { intervalToDuration } from "date-fns";

export const formatTimestamp = (timestamp: string | Date) => {
const date = new Date(timestamp);

Expand Down Expand Up @@ -34,3 +36,16 @@ export const roundToNearest = (

return new Date(roundedTimestamp);
};

export function durationString(durationMs: number | null | undefined) {
if (typeof durationMs !== "number") return "";
const duration = intervalToDuration({ start: 0, end: durationMs });
const hours = Math.floor(durationMs / 1000 / 60 / 60);

const zeroPad = (num: number) => String(num).padStart(2, "0");
const formatted = [hours, duration.minutes ?? 0, duration.seconds ?? 0]
.map(zeroPad)
.join(":");

return formatted;
}
Loading