Skip to content

Conversation

@Roubean1
Copy link
Contributor

@Roubean1 Roubean1 commented Sep 12, 2025

Summary by CodeRabbit

  • New Features
    • Added a Voting Timeline chart showing cumulative votes over time with tooltips and localized time labels; displays “No votes yet” when applicable.
    • Introduced a Voting Speed card showing the average time to vote in seconds; falls back to “No votes yet” when unavailable.
    • Improved handling of missing timestamps; visuals render conditionally without errors.
    • Minor performance and UI polish on the poll detail view.

@Roubean1 Roubean1 linked an issue Sep 12, 2025 that may be closed by this pull request
@coderabbitai
Copy link

coderabbitai bot commented Sep 12, 2025

Walkthrough

Adds a voting timeline chart and average voting speed to PollDetail. Computes cumulative votes over time and average seconds-to-vote via useMemo, handles missing timestamps, conditionally renders a BarChart with Tooltip, and displays placeholders when no votes exist. No exported API changes.

Changes

Cohort / File(s) Summary of changes
PollDetail analytics and visualization
web/src/components/pollDetail.tsx
Added Tooltip import from recharts; introduced voteTimeline and averageVoteTime computed via useMemo; conditional BarChart rendering for voting timeline with tooltip; new “Voting Timeline” and “Voting Speed” cards; graceful handling for missing/empty vote data; minor formatting adjustments.

Sequence Diagram(s)

sequenceDiagram
    autonumber
    participant U as User
    participant PD as PollDetail (UI)
    participant HM as useMemo (calculations)
    participant RC as Recharts (BarChart/Tooltip)

    U->>PD: Open poll details
    PD->>HM: Compute voteTimeline (sorted cumulative)
    PD->>HM: Compute averageVoteTime (sec since createdAt)
    alt Votes exist
        HM-->>PD: voteTimeline[], averageVoteTime
        PD->>RC: Render BarChart + Tooltip
        note right of RC: Bars show cumulative votes over time
        PD->>U: Show "Voting Timeline" and "Voting Speed"
    else No votes or missing timestamps
        HM-->>PD: [] / null
        PD-->>U: Display "No votes yet"
    end
Loading

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Possibly related PRs

Pre-merge checks (2 passed, 1 inconclusive)

❌ Failed checks (1 inconclusive)
Check name Status Explanation Resolution
Title Check ❓ Inconclusive The title "feat: pollDetail" is related to the changeset because it targets the PollDetail component, but it is overly generic and does not summarize the primary user-facing changes (the voting timeline chart and average vote speed), so it doesn't clearly communicate the main purpose of the PR to reviewers or in the changelog. Please update the title to a concise, descriptive sentence such as "feat(pollDetail): add voting timeline chart and average vote speed" or "feat: show voting timeline and voting speed in PollDetail" so the main change is clear to reviewers and history.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Docstring Coverage ✅ Passed No functions found in the changes. Docstring coverage check skipped.

Poem

A nibble of time, a hop through the chart,
Bars grow like carrots, cumulus art.
Ticks in a timeline, seconds that sing,
Votes sprout swiftly—spring, spring, spring!
I thump with delight at each tooltip glow,
Data well-plotted—onward we go! 🥕📊

✨ Finishing touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch 43-enhance-poll-detail-view-2

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.

Copy link

@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 (3)
web/src/components/pollDetail.tsx (3)

11-11: Prefer consistent chart wrappers + responsive sizing (avoid raw Recharts Tooltip).

To keep styling consistent with the “Vote Distribution” chart and avoid hard‑coded sizes, wrap the timeline chart in ChartContainer + ResponsiveContainer and reuse ChartTooltip/ChartTooltipContent. Also drop the direct Recharts Tooltip import to prevent mixing tooltip systems.

Apply:

-import { Bar, BarChart, CartesianGrid, LabelList, Tooltip, XAxis, YAxis } from 'recharts';
+import { Bar, BarChart, CartesianGrid, LabelList, ResponsiveContainer, XAxis, YAxis } from 'recharts';

561-584: Make the timeline responsive and reuse ChartTooltip for consistent UX.

Hard-coded width/height will break on smaller viewports, and using Recharts Tooltip diverges from the rest of the UI. Wrap with ChartContainer + ResponsiveContainer and reuse ChartTooltip/ChartTooltipContent.

-            {voteTimeline.length > 0 ? (
-              <BarChart
-                data={voteTimeline}
-                margin={{ left: 120, right: 16 }}
-                width={670}
-                height={250}
-              >
-                <CartesianGrid strokeDasharray="3 3" />
-                <XAxis dataKey="time" />
-                <YAxis allowDecimals={false} />
-                <Tooltip />
-                <Bar dataKey="cumulativeVotes" fill="var(--color-desktop)" radius={[4, 4, 0, 0]} />
-              </BarChart>
-            ) : (
+            {voteTimeline.length > 0 ? (
+              <ChartContainer config={chartConfig}>
+                <ResponsiveContainer width="100%" height={250}>
+                  <BarChart data={voteTimeline} margin={{ left: 16, right: 16 }}>
+                    <CartesianGrid strokeDasharray="3 3" />
+                    <XAxis dataKey="time" />
+                    <YAxis allowDecimals={false} />
+                    <ChartTooltip cursor={false} content={<ChartTooltipContent indicator="line" />} />
+                    <Bar dataKey="cumulativeVotes" fill="var(--color-desktop)" radius={[4, 4, 0, 0]} />
+                  </BarChart>
+                </ResponsiveContainer>
+              </ChartContainer>
+            ) : (

585-598: Truthiness check hides 0s; use explicit null check.

If the average is exactly 0 seconds, the UI shows “No votes yet”. Compare against null instead.

-            {averageVoteTime ? (
+            {averageVoteTime !== null ? (
               <p>
                 Average time to vote: <strong>{Math.round(averageVoteTime)} seconds</strong>
               </p>
             ) : (
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 875ee5f and 83ab851.

📒 Files selected for processing (1)
  • web/src/components/pollDetail.tsx (3 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
web/src/components/pollDetail.tsx (2)
functions/src/services/pollService.ts (1)
  • vote (10-71)
web/src/components/pollList.tsx (1)
  • poll (108-142)

Comment on lines +257 to +274
const voteTimeline = useMemo(() => {
if (!poll?.votes || poll.votes.length === 0) return [];

const sortedVotes = [...poll.votes].sort((a, b) => {
const at = a.timestamp ? new Date(a.timestamp).getTime() : 0;
const bt = b.timestamp ? new Date(b.timestamp).getTime() : 0;
return at - bt;
});

const timeline: { time: string; cumulativeVotes: number }[] = [];
sortedVotes.forEach((vote, index) => {
const time = vote.timestamp ? new Date(vote.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : 'Unknown';
timeline.push({ time, cumulativeVotes: index + 1 });
});

return timeline;
}, [poll?.votes]);

Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

Defensive timestamp handling: current sort can return NaN → unstable ordering.

new Date(vote.timestamp) will be Invalid Date when timestamp is a Firestore Timestamp or malformed string. Subtracting NaN leads to a comparator that returns NaN, which makes Array.sort unstable and timeline rendering nondeterministic.

Patch the timeline to safely coerce timestamps and sort, falling back to the end for unknowns:

-  const voteTimeline = useMemo(() => {
-    if (!poll?.votes || poll.votes.length === 0) return [];
-
-    const sortedVotes = [...poll.votes].sort((a, b) => {
-      const at = a.timestamp ? new Date(a.timestamp).getTime() : 0;
-      const bt = b.timestamp ? new Date(b.timestamp).getTime() : 0;
-      return at - bt;
-    });
-
-    const timeline: { time: string; cumulativeVotes: number }[] = [];
-    sortedVotes.forEach((vote, index) => {
-      const time = vote.timestamp ? new Date(vote.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : 'Unknown';
-      timeline.push({ time, cumulativeVotes: index + 1 });
-    });
-
-    return timeline;
-  }, [poll?.votes]);
+  const voteTimeline = useMemo(() => {
+    if (!poll?.votes || poll.votes.length === 0) return [];
+
+    const toMs = (ts: unknown): number | null => {
+      if (!ts) return null;
+      if (typeof (ts as any)?.toDate === 'function') return (ts as any).toDate().getTime();
+      const d = ts instanceof Date ? ts : new Date(ts as any);
+      const ms = d.getTime();
+      return Number.isNaN(ms) ? null : ms;
+    };
+
+    const sorted = [...poll.votes]
+      .map((v) => ({ ms: toMs((v as any).timestamp) }))
+      .sort((a, b) => (a.ms ?? Number.POSITIVE_INFINITY) - (b.ms ?? Number.POSITIVE_INFINITY));
+
+    const timeline: { time: string; cumulativeVotes: number }[] = [];
+    sorted.forEach((item, index) => {
+      const time = item.ms !== null
+        ? new Date(item.ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
+        : 'Unknown';
+      timeline.push({ time, cumulativeVotes: index + 1 });
+    });
+    return timeline;
+  }, [poll?.votes]);

Additionally fix upstream normalization done in the snapshot (outside this hunk) to stop asserting to string and instead coerce to ms/Date:

-        const normalizedVotes = (data.votes ?? []).map((vote: BaseVote) => {
-          const ts = (vote?.timestamp ?? vote?.timestamp) as string | undefined;
-          return { ...vote, timestamp: ts };
-        });
+        const normalizedVotes = (data.votes ?? []).map((vote: BaseVote & { timestamp?: unknown }) => {
+          const raw = vote?.timestamp;
+          let ts: number | undefined;
+          if (raw && typeof (raw as any)?.toDate === 'function') {
+            ts = (raw as any).toDate().getTime();
+          } else if (raw instanceof Date) {
+            ts = raw.getTime();
+          } else if (raw != null) {
+            const d = new Date(raw as any);
+            ts = Number.isNaN(d.getTime()) ? undefined : d.getTime();
+          }
+          return { ...vote, timestamp: ts };
+        });

And update the local cast used in getVotersForOption so TS matches the new shape (outside this hunk):

-    const voters = (poll.votes as (BaseVote & { timestamp?: string })[])
+    const voters = (poll.votes as (BaseVote & { timestamp?: number | Date | string })[])
📝 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 voteTimeline = useMemo(() => {
if (!poll?.votes || poll.votes.length === 0) return [];
const sortedVotes = [...poll.votes].sort((a, b) => {
const at = a.timestamp ? new Date(a.timestamp).getTime() : 0;
const bt = b.timestamp ? new Date(b.timestamp).getTime() : 0;
return at - bt;
});
const timeline: { time: string; cumulativeVotes: number }[] = [];
sortedVotes.forEach((vote, index) => {
const time = vote.timestamp ? new Date(vote.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : 'Unknown';
timeline.push({ time, cumulativeVotes: index + 1 });
});
return timeline;
}, [poll?.votes]);
const voteTimeline = useMemo(() => {
if (!poll?.votes || poll.votes.length === 0) return [];
const toMs = (ts: unknown): number | null => {
if (!ts) return null;
if (typeof (ts as any)?.toDate === 'function') return (ts as any).toDate().getTime();
const d = ts instanceof Date ? ts : new Date(ts as any);
const ms = d.getTime();
return Number.isNaN(ms) ? null : ms;
};
const sorted = [...poll.votes]
.map((v) => ({ ms: toMs((v as any).timestamp) }))
.sort((a, b) => (a.ms ?? Number.POSITIVE_INFINITY) - (b.ms ?? Number.POSITIVE_INFINITY));
const timeline: { time: string; cumulativeVotes: number }[] = [];
sorted.forEach((item, index) => {
const time = item.ms !== null
? new Date(item.ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
: 'Unknown';
timeline.push({ time, cumulativeVotes: index + 1 });
});
return timeline;
}, [poll?.votes]);
🤖 Prompt for AI Agents
In web/src/components/pollDetail.tsx around lines 257-274, the comparator uses
new Date(vote.timestamp).getTime() which can yield NaN for Firestore Timestamps
or malformed values causing unstable sort; coerce timestamps to a numeric ms
value safely (e.g., if timestamp?.toMillis is a function call it, else try
Number(timestamp) / Date.parse/new Date(timestamp).getTime()), treat invalid
values as Infinity so unknown-times sort to the end, sort by these numeric ms
values, and use the numeric ms to build the display time (formatting with new
Date(ms) only when ms is finite, otherwise label as 'Unknown'); also update
upstream snapshot normalization to produce ms (not asserted string) and adjust
the local cast in getVotersForOption to match the new timestamp shape so
TypeScript types align.

Comment on lines +275 to 284
const averageVoteTime = useMemo(() => {
if (!poll?.votes || poll.votes.length === 0 || !poll.createdAt) return null;
const startTime = new Date(poll.createdAt).getTime();
const totalDiff = poll.votes.reduce((sum, vote) => {
const voteTime = vote.timestamp ? new Date(vote.timestamp).getTime() : startTime;
return sum + (voteTime - startTime);
}, 0);
return totalDiff / poll.votes.length / 1000;
}, [poll?.votes, poll?.createdAt]);

Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

Average time calc can be Invalid Date (Firestore Timestamp) and can go negative.

new Date(poll.createdAt) will be invalid if createdAt is a Firestore Timestamp; same for vote.timestamp. Result: NaN arithmetic and “NaN seconds”. Also guard against clock skews producing negative diffs.

Apply:

-  const averageVoteTime = useMemo(() => {
-    if (!poll?.votes || poll.votes.length === 0 || !poll.createdAt) return null;
-    const startTime = new Date(poll.createdAt).getTime();
-    const totalDiff = poll.votes.reduce((sum, vote) => {
-      const voteTime = vote.timestamp ? new Date(vote.timestamp).getTime() : startTime;
-      return sum + (voteTime - startTime);
-    }, 0);
-    return totalDiff / poll.votes.length / 1000;
-  }, [poll?.votes, poll?.createdAt]);
+  const averageVoteTime = useMemo(() => {
+    if (!poll?.votes || poll.votes.length === 0 || !poll.createdAt) return null;
+    const toMs = (ts: unknown): number | null => {
+      if (!ts) return null;
+      if (typeof (ts as any)?.toDate === 'function') return (ts as any).toDate().getTime();
+      const d = ts instanceof Date ? ts : new Date(ts as any);
+      const ms = d.getTime();
+      return Number.isNaN(ms) ? null : ms;
+    };
+    const startMs = toMs(poll.createdAt);
+    if (startMs === null) return null;
+    const totalDiff = poll.votes.reduce((sum, vote) => {
+      const voteMs = toMs((vote as any).timestamp) ?? startMs;
+      return sum + Math.max(0, voteMs - startMs);
+    }, 0);
+    return totalDiff / poll.votes.length / 1000;
+  }, [poll?.votes, poll?.createdAt]);
📝 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 averageVoteTime = useMemo(() => {
if (!poll?.votes || poll.votes.length === 0 || !poll.createdAt) return null;
const startTime = new Date(poll.createdAt).getTime();
const totalDiff = poll.votes.reduce((sum, vote) => {
const voteTime = vote.timestamp ? new Date(vote.timestamp).getTime() : startTime;
return sum + (voteTime - startTime);
}, 0);
return totalDiff / poll.votes.length / 1000;
}, [poll?.votes, poll?.createdAt]);
const averageVoteTime = useMemo(() => {
if (!poll?.votes || poll.votes.length === 0 || !poll.createdAt) return null;
const toMs = (ts: unknown): number | null => {
if (!ts) return null;
if (typeof (ts as any)?.toDate === 'function') return (ts as any).toDate().getTime();
const d = ts instanceof Date ? ts : new Date(ts as any);
const ms = d.getTime();
return Number.isNaN(ms) ? null : ms;
};
const startMs = toMs(poll.createdAt);
if (startMs === null) return null;
const totalDiff = poll.votes.reduce((sum, vote) => {
const voteMs = toMs((vote as any).timestamp) ?? startMs;
return sum + Math.max(0, voteMs - startMs);
}, 0);
return totalDiff / poll.votes.length / 1000;
}, [poll?.votes, poll?.createdAt]);
🤖 Prompt for AI Agents
In web/src/components/pollDetail.tsx around lines 275 to 284, the average vote
time computation uses new Date(...) on values that may be Firestore Timestamps
(causing Invalid Date/NaN) and does not guard against clock skew producing
negative diffs; update the logic to normalize timestamps safely: if
poll.createdAt or vote.timestamp are Firestore Timestamp objects call
.toDate().getTime() (or convert seconds*1000) and otherwise coerce
numbers/strings to milliseconds and validate with Number.isFinite; skip or treat
votes with invalid timestamps as occurring at startTime; when computing diff
clamp negative differences to 0 before summing; finally ensure you still divide
by the count of valid votes (or poll.votes.length if you choose to include
invalid as zero) and keep the same useMemo dependencies.

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.

Enhance Poll Detail View 2

2 participants