Skip to content

Commit 597275c

Browse files
committed
feat: optimistic updates
1 parent 32ef939 commit 597275c

4 files changed

Lines changed: 109 additions & 21 deletions

File tree

front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/key_factor_strength_item.tsx

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import cn from "@/utils/core/cn";
2020
import { KeyFactorImpactDirectionLabel } from "../item_creation/driver/impact_direction_label";
2121
import { convertNumericImpactToDirectionCategory } from "../utils";
2222
import ThumbVoteButtons, { ThumbVoteSelection } from "./thumb_vote_buttons";
23+
import { useOptimisticVote } from "./use_optimistic_vote";
2324

2425
type Props = PropsWithChildren<{
2526
keyFactor: KeyFactor;
@@ -70,14 +71,21 @@ const KeyFactorStrengthItem: FC<Props> = ({
7071

7172
const isCompactConsumer = mode === "consumer" && isCompact;
7273

73-
const userVote = aggregate?.user_vote ?? null;
74-
const { upCount, downCount } = useMemo(() => {
75-
const arr = aggregate?.aggregated_data ?? [];
76-
return {
77-
upCount: arr.find((a) => a.score === upScore)?.count ?? 0,
78-
downCount: arr.find((a) => a.score === downScore)?.count ?? 0,
79-
};
80-
}, [aggregate, upScore, downScore]);
74+
const aggregatedData = aggregate?.aggregated_data ?? [];
75+
const {
76+
vote: userVote,
77+
upCount,
78+
downCount,
79+
setOptimistic,
80+
clearOptimistic,
81+
} = useOptimisticVote({
82+
serverVote: aggregate?.user_vote ?? null,
83+
serverUpCount: aggregatedData.find((a) => a.score === upScore)?.count ?? 0,
84+
serverDownCount:
85+
aggregatedData.find((a) => a.score === downScore)?.count ?? 0,
86+
upValue: upScore,
87+
downValue: downScore,
88+
});
8189

8290
const selection: ThumbVoteSelection =
8391
userVote === upScore ? "up" : userVote === downScore ? "down" : null;
@@ -89,6 +97,7 @@ const KeyFactorStrengthItem: FC<Props> = ({
8997
}
9098
if (user.is_bot || submitting) return;
9199
setSubmitting(true);
100+
setOptimistic(next);
92101

93102
try {
94103
const resp = await voteKeyFactor({
@@ -102,6 +111,7 @@ const KeyFactorStrengthItem: FC<Props> = ({
102111
setKeyFactorVote(keyFactor.id, updated);
103112
}
104113
} finally {
114+
clearOptimistic();
105115
setSubmitting(false);
106116
}
107117
};

front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/question_link/question_link_agree_voter.tsx

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { Question } from "@/types/question";
2121
import cn from "@/utils/core/cn";
2222

2323
import ThumbVoteButtons, { ThumbVoteSelection } from "../thumb_vote_buttons";
24+
import { useOptimisticVote } from "../use_optimistic_vote";
2425

2526
type Props = {
2627
aggregationId?: number;
@@ -70,11 +71,25 @@ const QuestionLinkAgreeVoter: FC<Props> = ({
7071
return {
7172
agree: votes?.aggregated_data?.find((x) => x.score === 1)?.count ?? 0,
7273
disagree: votes?.aggregated_data?.find((x) => x.score === -1)?.count ?? 0,
73-
selected: mapUserVoteToSelection(votes?.user_vote),
74+
userVote: (votes?.user_vote ?? null) as 1 | -1 | null,
7475
};
7576
}, [aggregateCoherenceLinks, aggregationId]);
7677

77-
const { agree, disagree, selected } = contextVotes;
78+
const {
79+
vote: currentVote,
80+
upCount: agree,
81+
downCount: disagree,
82+
setOptimistic,
83+
clearOptimistic,
84+
} = useOptimisticVote<1 | -1 | null>({
85+
serverVote: contextVotes.userVote,
86+
serverUpCount: contextVotes.agree,
87+
serverDownCount: contextVotes.disagree,
88+
upValue: 1,
89+
downValue: -1,
90+
});
91+
92+
const selected = mapUserVoteToSelection(currentVote);
7893

7994
const { user } = useAuth();
8095
const [showCopyHint, setShowCopyHint] = useState(false);
@@ -107,6 +122,7 @@ const QuestionLinkAgreeVoter: FC<Props> = ({
107122
next === "agree" ? 1 : next === "disagree" ? -1 : null;
108123

109124
setSubmitting(true);
125+
setOptimistic(vote);
110126
try {
111127
const res = await voteAggregateCoherenceLink(aggregationId, vote);
112128
if ("errors" in res) return;
@@ -121,6 +137,7 @@ const QuestionLinkAgreeVoter: FC<Props> = ({
121137
} catch (e) {
122138
console.error("Failed to vote aggregate coherence link", e);
123139
} finally {
140+
clearOptimistic();
124141
setSubmitting(false);
125142
}
126143
};
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { useMemo, useState } from "react";
2+
3+
type OptimisticVoteResult<V extends number | null> = {
4+
vote: V | null;
5+
upCount: number;
6+
downCount: number;
7+
setOptimistic: (value: V | null) => void;
8+
clearOptimistic: () => void;
9+
};
10+
11+
/**
12+
* Manages optimistic UI updates for up/down vote interactions.
13+
* While an API call is in flight, the vote selection and counts
14+
* reflect the expected outcome rather than server state.
15+
*/
16+
export function useOptimisticVote<V extends number | null>({
17+
serverVote,
18+
serverUpCount,
19+
serverDownCount,
20+
upValue,
21+
downValue,
22+
}: {
23+
serverVote: V | null;
24+
serverUpCount: number;
25+
serverDownCount: number;
26+
upValue: V;
27+
downValue: V;
28+
}): OptimisticVoteResult<V> {
29+
const [optimistic, setOptimisticRaw] = useState<V | null | undefined>(
30+
undefined
31+
);
32+
33+
const vote = optimistic !== undefined ? optimistic : serverVote;
34+
35+
const { upCount, downCount } = useMemo(() => {
36+
let up = serverUpCount;
37+
let down = serverDownCount;
38+
39+
if (optimistic !== undefined) {
40+
const wasUp = serverVote === upValue;
41+
const wasDown = serverVote === downValue;
42+
const isUp = optimistic === upValue;
43+
const isDown = optimistic === downValue;
44+
45+
if (wasUp && !isUp) up = Math.max(0, up - 1);
46+
if (!wasUp && isUp) up += 1;
47+
if (wasDown && !isDown) down = Math.max(0, down - 1);
48+
if (!wasDown && isDown) down += 1;
49+
}
50+
51+
return { upCount: up, downCount: down };
52+
}, [
53+
serverUpCount,
54+
serverDownCount,
55+
serverVote,
56+
upValue,
57+
downValue,
58+
optimistic,
59+
]);
60+
61+
const setOptimistic = (value: V | null) => setOptimisticRaw(value);
62+
const clearOptimistic = () => setOptimisticRaw(undefined);
63+
64+
return { vote, upCount, downCount, setOptimistic, clearOptimistic };
65+
}

front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/vote_panel.tsx

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { faXmark } from "@fortawesome/free-solid-svg-icons";
44
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
5-
import { FC, ReactNode, RefObject, useEffect, useState } from "react";
5+
import { FC, ReactNode, RefObject, useMemo } from "react";
66
import { createPortal } from "react-dom";
77

88
import cn from "@/utils/core/cn";
@@ -34,22 +34,18 @@ function VotePanelInner<T extends string>({
3434
renderLabel,
3535
footer,
3636
}: Props<T>) {
37-
const [style, setStyle] = useState<React.CSSProperties>({
38-
position: "fixed",
39-
opacity: 0,
40-
});
41-
42-
useEffect(() => {
43-
if (!anchorRef.current) return;
37+
const style = useMemo<React.CSSProperties>(() => {
38+
if (!anchorRef.current) {
39+
return { position: "fixed", opacity: 0 };
40+
}
4441
const rect = anchorRef.current.getBoundingClientRect();
45-
setStyle({
42+
return {
4643
position: "fixed",
4744
top: rect.bottom + 4,
4845
left: rect.left,
4946
width: rect.width,
5047
zIndex: 50,
51-
opacity: 1,
52-
});
48+
};
5349
}, [anchorRef]);
5450

5551
const panel = (

0 commit comments

Comments
 (0)