Skip to content

Commit 46f57ae

Browse files
authored
feat(votes): integrate vote results api and enhance results drawer (#242)
LFXV2-1089 - Add GET /votes/:uid/results backend endpoint with controller, service, and route - Refactor vote results drawer to fetch real results from API - Rename vote_uid to uid in Vote interface to match API response - Add VoteResultsResponse, PollQuestionResult, PollCommentResult interfaces - Display voter comments section in results drawer - Render vote descriptions as HTML content - Remove deprecated generic_choice_votes from Vote interface Generated with [Claude Code](https://claude.ai/code) Signed-off-by: Asitha de Silva <asithade@gmail.com>
1 parent a4b5db1 commit 46f57ae

File tree

12 files changed

+357
-85
lines changed

12 files changed

+357
-85
lines changed

apps/lfx-one/src/app/modules/votes/components/vote-results-drawer/vote-results-drawer.component.html

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ <h1 class="text-lg sm:text-xl font-semibold text-slate-900 flex-1 min-w-0 trunca
5454

5555
<!-- Content Section -->
5656
<div class="flex flex-col gap-6" data-testid="vote-results-drawer-content">
57-
@if (loading()) {
57+
@if (isLoading()) {
5858
<!-- Loading Skeleton -->
5959
<div class="flex flex-col gap-6">
6060
<!-- Participation Stats Skeleton -->
@@ -104,7 +104,8 @@ <h2 class="text-xl font-semibold text-slate-900 mb-3">Advanced Voting Method</h2
104104
@if (voteData.description) {
105105
<div class="w-full mt-8 pt-6 border-t border-slate-200 text-left" data-testid="vote-results-details">
106106
<h3 class="text-base font-semibold text-slate-900 mb-2">Vote Details</h3>
107-
<p class="text-sm text-slate-700 leading-relaxed"><span class="font-medium">Description:</span> {{ voteData.description }}</p>
107+
<p class="text-sm text-slate-700 leading-relaxed"><span class="font-medium">Description:</span></p>
108+
<div class="vote-description text-sm text-slate-700 leading-relaxed" [innerHTML]="voteData.description"></div>
108109
</div>
109110
}
110111
</div>
@@ -115,7 +116,8 @@ <h3 class="text-base font-semibold text-slate-900 mb-2">Vote Details</h3>
115116
@if (voteData.description) {
116117
<div class="flex flex-col gap-4" data-testid="vote-results-details">
117118
<h2 class="text-lg font-semibold text-slate-900">Vote Details</h2>
118-
<p class="text-sm text-slate-700 leading-relaxed"><span class="font-medium">Description:</span> {{ voteData.description }}</p>
119+
<p class="text-sm text-slate-700 leading-relaxed"><span class="font-medium">Description:</span></p>
120+
<div class="vote-description text-sm text-slate-700 leading-relaxed" [innerHTML]="voteData.description"></div>
119121
</div>
120122
}
121123

@@ -187,6 +189,23 @@ <h3 class="text-base font-semibold text-slate-900">Q{{ idx + 1 }}. {{ question.q
187189
</p>
188190
</div>
189191
</div>
192+
193+
<!-- Comments Section -->
194+
@if (commentResults().length > 0) {
195+
<div class="flex flex-col gap-4 pt-4 border-t border-slate-200" data-testid="vote-results-comments">
196+
<h2 class="text-lg font-semibold text-slate-900">Comments</h2>
197+
@for (commentResult of commentResults(); track commentResult.prompt) {
198+
<div class="flex flex-col gap-2">
199+
<h3 class="text-sm font-medium text-slate-700">{{ commentResult.prompt }}</h3>
200+
@for (comment of commentResult.comments; track $index) {
201+
<div class="p-3 bg-slate-50 rounded-lg border border-slate-200">
202+
<p class="text-sm text-slate-700">{{ comment }}</p>
203+
</div>
204+
}
205+
</div>
206+
}
207+
</div>
208+
}
190209
</div>
191210
} @else {
192211
<!-- Closed Vote - Final Results Layout -->
@@ -298,12 +317,30 @@ <h3 class="text-base font-semibold text-slate-900">
298317
</div>
299318
</div>
300319

320+
<!-- Comments Section -->
321+
@if (commentResults().length > 0) {
322+
<div class="flex flex-col gap-4 pt-4 border-t border-slate-200" data-testid="vote-results-comments">
323+
<h2 class="text-lg font-semibold text-slate-900">Comments</h2>
324+
@for (commentResult of commentResults(); track commentResult.prompt) {
325+
<div class="flex flex-col gap-2">
326+
<h3 class="text-sm font-medium text-slate-700">{{ commentResult.prompt }}</h3>
327+
@for (comment of commentResult.comments; track $index) {
328+
<div class="p-3 bg-slate-50 rounded-lg border border-slate-200">
329+
<p class="text-sm text-slate-700">{{ comment }}</p>
330+
</div>
331+
}
332+
</div>
333+
}
334+
</div>
335+
}
336+
301337
<!-- Vote Details Section (at bottom for closed votes) -->
302338
@if (voteData.description) {
303339
<div class="flex flex-col gap-4 pt-4 border-t border-slate-200" data-testid="vote-results-details">
304340
<h2 class="text-lg font-semibold text-slate-900">Vote Details</h2>
305341

306-
<p class="text-sm text-slate-700 leading-relaxed"><span class="font-medium">Description:</span> {{ voteData.description }}</p>
342+
<p class="text-sm text-slate-700 leading-relaxed"><span class="font-medium">Description:</span></p>
343+
<div class="vote-description text-sm text-slate-700 leading-relaxed" [innerHTML]="voteData.description"></div>
307344
</div>
308345
}
309346
}

apps/lfx-one/src/app/modules/votes/components/vote-results-drawer/vote-results-drawer.component.ts

Lines changed: 107 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,19 @@
22
// SPDX-License-Identifier: MIT
33

44
import { DatePipe } from '@angular/common';
5-
import { Component, computed, effect, input, model, signal, Signal } from '@angular/core';
5+
import { Component, computed, inject, input, model, signal, Signal } from '@angular/core';
6+
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
67
import { ButtonComponent } from '@components/button/button.component';
78
import { TagComponent } from '@components/tag/tag.component';
89
import { environment } from '@environments/environment';
910
import { PollStatus, PollType } from '@lfx-one/shared';
10-
import { Vote, VoteParticipationStats, VoteResultsOption, VoteResultsQuestion } from '@lfx-one/shared/interfaces';
11+
import { PollCommentResult, Vote, VoteParticipationStats, VoteResultsOption, VoteResultsQuestion, VoteResultsResponse } from '@lfx-one/shared/interfaces';
1112
import { PollStatusLabelPipe } from '@pipes/poll-status-label.pipe';
1213
import { PollStatusSeverityPipe } from '@pipes/poll-status-severity.pipe';
14+
import { VoteService } from '@services/vote.service';
1315
import { DrawerModule } from 'primeng/drawer';
1416
import { SkeletonModule } from 'primeng/skeleton';
17+
import { catchError, finalize, of, shareReplay, startWith, switchMap } from 'rxjs';
1518

1619
@Component({
1720
selector: 'lfx-vote-results-drawer',
@@ -20,42 +23,86 @@ import { SkeletonModule } from 'primeng/skeleton';
2023
styleUrl: './vote-results-drawer.component.scss',
2124
})
2225
export class VoteResultsDrawerComponent {
26+
// === Services ===
27+
private readonly voteService = inject(VoteService);
28+
2329
// === Inputs ===
24-
public readonly vote = input<Vote | null>(null);
30+
public readonly voteId = input<string | null>(null);
31+
public readonly listVote = input<Vote | null>(null);
2532

2633
// === Model Signals (two-way binding) ===
2734
public readonly visible = model<boolean>(false);
2835

2936
// === Writable Signals ===
30-
protected readonly loading = signal<boolean>(false);
37+
protected readonly loadingVoteDetails = signal<boolean>(false);
38+
protected readonly loadingVoteResults = signal<boolean>(false);
39+
40+
// === Shared Observables ===
41+
private readonly voteId$ = toObservable(this.voteId).pipe(shareReplay({ bufferSize: 1, refCount: true }));
42+
43+
// === Derived Signals (from API) ===
44+
protected readonly vote: Signal<Vote | null> = this.initVote();
45+
protected readonly voteResults: Signal<VoteResultsResponse | null> = this.initVoteResults();
3146

3247
// === Computed Signals ===
3348
protected readonly isGenericPoll: Signal<boolean> = this.initIsGenericPoll();
3449
protected readonly pccVotingUrl: Signal<string> = this.initPccVotingUrl();
3550
protected readonly isVoteClosed: Signal<boolean> = this.initIsVoteClosed();
51+
protected readonly isLoading: Signal<boolean> = computed(() => this.loadingVoteDetails() || this.loadingVoteResults());
3652
protected readonly participationStats: Signal<VoteParticipationStats> = this.initParticipationStats();
3753
protected readonly questionsWithResults: Signal<VoteResultsQuestion[]> = this.initQuestionsWithResults();
54+
protected readonly commentResults: Signal<PollCommentResult[]> = this.initCommentResults();
3855
protected readonly votingMethodText: Signal<string> = this.initVotingMethodText();
3956

40-
// === Constructor ===
41-
public constructor() {
42-
// Simulate loading when vote changes
43-
effect(() => {
44-
const v = this.vote();
45-
if (v && this.visible()) {
46-
this.loading.set(true);
47-
// Simulate API fetch delay
48-
setTimeout(() => this.loading.set(false), 500);
49-
}
50-
});
51-
}
52-
5357
// === Protected Methods ===
5458
protected onClose(): void {
5559
this.visible.set(false);
5660
}
5761

5862
// === Private Initializers ===
63+
private initVote(): Signal<Vote | null> {
64+
return toSignal(
65+
this.voteId$.pipe(
66+
switchMap((id) => {
67+
if (!id) {
68+
this.loadingVoteDetails.set(false);
69+
return of(null);
70+
}
71+
72+
this.loadingVoteDetails.set(true);
73+
const listVote = this.listVote();
74+
75+
return this.voteService.getVote(id).pipe(
76+
catchError(() => of(listVote)),
77+
finalize(() => this.loadingVoteDetails.set(false)),
78+
startWith(listVote)
79+
);
80+
})
81+
),
82+
{ initialValue: null }
83+
);
84+
}
85+
86+
private initVoteResults(): Signal<VoteResultsResponse | null> {
87+
return toSignal(
88+
this.voteId$.pipe(
89+
switchMap((id) => {
90+
if (!id) {
91+
this.loadingVoteResults.set(false);
92+
return of(null);
93+
}
94+
95+
this.loadingVoteResults.set(true);
96+
return this.voteService.getVoteResults(id).pipe(
97+
catchError(() => of(null)),
98+
finalize(() => this.loadingVoteResults.set(false))
99+
);
100+
})
101+
),
102+
{ initialValue: null }
103+
);
104+
}
105+
59106
private initIsGenericPoll(): Signal<boolean> {
60107
return computed(() => {
61108
const v = this.vote();
@@ -84,13 +131,13 @@ export class VoteResultsDrawerComponent {
84131

85132
private initParticipationStats(): Signal<VoteParticipationStats> {
86133
return computed(() => {
87-
const v = this.vote();
88-
if (!v) {
134+
const results = this.voteResults();
135+
if (!results) {
89136
return { eligibleVoters: 0, totalResponses: 0, participationRate: 0 };
90137
}
91138

92-
const eligibleVoters = v.total_voting_request_invitations || 0;
93-
const totalResponses = v.num_response_received || 0;
139+
const eligibleVoters = results.num_recipients || 0;
140+
const totalResponses = results.num_votes_cast || 0;
94141
const participationRate = eligibleVoters > 0 ? Math.round((totalResponses / eligibleVoters) * 100) : 0;
95142

96143
return { eligibleVoters, totalResponses, participationRate };
@@ -99,59 +146,69 @@ export class VoteResultsDrawerComponent {
99146

100147
private initQuestionsWithResults(): Signal<VoteResultsQuestion[]> {
101148
return computed(() => {
102-
const v = this.vote();
149+
const results = this.voteResults();
103150
const isClosed = this.isVoteClosed();
104151

105-
if (!v?.poll_questions?.length) {
152+
if (!results?.poll_results?.length) {
106153
return [];
107154
}
108155

109-
return v.poll_questions.map((question) => {
110-
// Get vote counts from generic_choice_votes or default to 0
111-
const choiceVotes = v.generic_choice_votes || {};
112-
113-
// Calculate total votes for this question
114-
let totalVotes = 0;
115-
const optionsWithCounts: VoteResultsOption[] = question.choices.map((choice) => {
116-
const voteCount = choiceVotes[choice.choice_id] || 0;
117-
totalVotes += voteCount;
118-
return {
119-
choiceId: choice.choice_id,
120-
text: choice.choice_text,
121-
voteCount,
122-
percentage: 0, // Will calculate after we have total
123-
isWinner: false,
124-
isTied: false,
125-
isLeading: false, // Will calculate after we have max votes
126-
};
127-
});
128-
129-
// Calculate percentages and determine winner/ties
156+
return results.poll_results.map((pollResult) => {
157+
const choiceVotes = pollResult.generic_choice_votes || [];
158+
159+
// Compute total votes first for percentage calculation
160+
const totalVotes = choiceVotes.reduce((sum, cv) => sum + cv.vote_count, 0);
161+
162+
// Build options with vote counts from the results API
163+
const optionsWithCounts: VoteResultsOption[] = choiceVotes.map((cv) => ({
164+
choiceId: cv.choice_id,
165+
text: pollResult.question.choices.find((c) => c.choice_id === cv.choice_id)?.choice_text || cv.choice_id,
166+
voteCount: cv.vote_count,
167+
percentage: this.computePercentage(cv.percentage, cv.vote_count, totalVotes),
168+
isWinner: false,
169+
isTied: false,
170+
isLeading: false,
171+
}));
172+
173+
// Determine winner/ties based on max votes
130174
const maxVotes = Math.max(...optionsWithCounts.map((o) => o.voteCount), 0);
131175
const optionsWithMaxVotes = optionsWithCounts.filter((o) => o.voteCount === maxVotes);
132176
const isTied = optionsWithMaxVotes.length > 1 && maxVotes > 0;
133177

134178
const processedOptions = optionsWithCounts.map((option) => ({
135179
...option,
136-
percentage: totalVotes > 0 ? Math.round((option.voteCount / totalVotes) * 100) : 0,
137-
// Only show winner if vote is closed and there's no tie
138180
isWinner: isClosed && !isTied && option.voteCount === maxVotes && maxVotes > 0,
139-
// Show tied status for all options with max votes (only for closed votes)
140181
isTied: isClosed && isTied && option.voteCount === maxVotes,
141-
// Show leading status for live votes (options with max votes)
142182
isLeading: option.voteCount === maxVotes && maxVotes > 0,
143183
}));
144184

145185
return {
146-
questionId: question.question_id,
147-
question: question.prompt,
186+
questionId: pollResult.question.question_id,
187+
question: pollResult.question.prompt,
148188
options: processedOptions,
149189
totalVotes,
150190
};
151191
});
152192
});
153193
}
154194

195+
private initCommentResults(): Signal<PollCommentResult[]> {
196+
return computed(() => {
197+
const results = this.voteResults();
198+
if (!results?.comment_results?.length) {
199+
return [];
200+
}
201+
202+
return results.comment_results.filter((cr) => cr.comments.length > 0);
203+
});
204+
}
205+
206+
private computePercentage(apiPercentage: number, voteCount: number, totalVotes: number): number {
207+
if (apiPercentage > 0) return apiPercentage;
208+
if (totalVotes <= 0) return 0;
209+
return Math.round((voteCount / totalVotes) * 100);
210+
}
211+
155212
private initVotingMethodText(): Signal<string> {
156213
return computed(() => {
157214
const v = this.vote();

0 commit comments

Comments
 (0)