Skip to content
Merged
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
29 changes: 21 additions & 8 deletions apps/web/components/item-detail/item-stats-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,19 @@ import { useLocale, useTranslations } from 'next-intl';
import { Eye, ThumbsUp, Heart, MessageSquare, Star, Clock, BarChart3 } from 'lucide-react';
import type { ItemActivityDay, ItemEngagementMetrics } from '@/lib/db/queries/engagement.queries';

/**
* Cache-key prefix for the per-item activity payload. Exported so the vote
* hook (and any future mutator) can target the same cache via
* `queryClient.setQueriesData({ queryKey: [ITEM_ACTIVITY_QUERY_KEY, slug] })`
* to apply optimistic updates without restating the literal.
*/
export const ITEM_ACTIVITY_QUERY_KEY = 'item-activity' as const;

export interface ItemActivityPayload {
totals: ItemEngagementMetrics;
series: ItemActivityDay[];
}

interface ItemStatsSectionProps {
itemSlug: string;
publishedAt?: string;
Expand Down Expand Up @@ -157,11 +170,6 @@ function Sparkline({ series, metric }: SparklineProps) {
);
}

interface ActivityPayload {
totals: ItemEngagementMetrics;
series: ItemActivityDay[];
}

/**
* Compact sidebar Statistics card with an inline sparkline.
*
Expand All @@ -171,18 +179,23 @@ interface ActivityPayload {
* are clickable — selecting one highlights it with `theme-primary` and
* re-plots the sparkline below from `/api/items/[slug]/activity`. Rating
* and Listed rows are static.
*
* Data lives in the shared React Query cache under

@augmentcode augmentcode Bot Jun 17, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This is a user-visible behavior change (optimistic Upvotes total + sparkline updates and rollback semantics); if it’s not already covered, it likely needs a Playwright spec per (Rule: AGENTS.md). A targeted e2e that asserts “instant bump” and “no flicker on failed vote” would help prevent regressions.

Severity: low

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

* `[ITEM_ACTIVITY_QUERY_KEY, itemSlug, days]` so mutations (e.g. the upvote
* button via `useItemVote`) can patch it optimistically — the Upvotes total
* and today's sparkline point update on the same frame as the vote.
*/
export function ItemStatsSection({ itemSlug, publishedAt, days = 30 }: ItemStatsSectionProps) {
const t = useTranslations();
const locale = useLocale();
const [selected, setSelected] = useState<SeriesMetric>('views');

const { data, isSuccess: loaded } = useQuery<ActivityPayload>({
queryKey: ['item-activity', itemSlug, days],
const { data, isSuccess: loaded } = useQuery<ItemActivityPayload>({
queryKey: [ITEM_ACTIVITY_QUERY_KEY, itemSlug, days],
queryFn: async () => {
const res = await fetch(`/api/items/${encodeURIComponent(itemSlug)}/activity?days=${days}`);
if (!res.ok) throw new Error('Failed to fetch activity');
return res.json() as Promise<ActivityPayload>;
return res.json() as Promise<ItemActivityPayload>;
},
enabled: !!itemSlug,
staleTime: 1000 * 60 * 5,
Expand Down
130 changes: 93 additions & 37 deletions apps/web/hooks/use-item-vote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,48 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { useCurrentUser } from './use-current-user';
import { serverClient, apiUtils } from '@/lib/api/server-api-client';
import {
ITEM_ACTIVITY_QUERY_KEY,
type ItemActivityPayload
} from '@/components/item-detail/item-stats-section';

interface ItemVoteResponse {
count: number;
userVote: 'up' | 'down' | null;
}

/**
* Apply a signed delta to every cached activity payload for an item (the
* cache has one entry per `days` window). Bumps both `totals.votes` and
* today's sparkline point so the sidebar Statistics card visibly moves on
* the same frame as the vote button.
*/
function patchActivityForVoteDelta(
queryClient: ReturnType<typeof useQueryClient>,
itemId: string,
delta: number
) {
if (delta === 0) return;
queryClient.setQueriesData<ItemActivityPayload>(
{ queryKey: [ITEM_ACTIVITY_QUERY_KEY, itemId] },
(old) => {
if (!old) return old;
const lastIdx = old.series.length - 1;

@augmentcode augmentcode Bot Jun 17, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

patchActivityForVoteDelta always applies the vote delta to the last series entry, which assumes the server’s time-series attribution matches “today”. If a user is removing/changing a vote that was created on a prior day, the /api/items/[slug]/activity series (grouped by votes.createdAt) may shift a different day’s point after invalidateQueries, causing a noticeable jump.

Severity: medium

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

const newSeries =
lastIdx >= 0
? [
...old.series.slice(0, lastIdx),
{ ...old.series[lastIdx], votes: (old.series[lastIdx]?.votes ?? 0) + delta }
]
: old.series;
return {
totals: { ...old.totals, votes: (old.totals?.votes ?? 0) + delta },
series: newSeries
};
}
);
}

export function useItemVote(itemId: string) {
const { user } = useCurrentUser();
const loginModal = useLoginModal();
Expand Down Expand Up @@ -83,32 +119,42 @@ export function useItemVote(itemId: string) {
return;
}

await queryClient.cancelQueries({ queryKey: ['item-votes', itemId] });
// Cancel both query keys we're about to mutate so in-flight refetches
// can't overwrite the optimistic update after we've snapshotted.
await Promise.all([
queryClient.cancelQueries({ queryKey: ['item-votes', itemId] }),
queryClient.cancelQueries({ queryKey: [ITEM_ACTIVITY_QUERY_KEY, itemId] })
]);
const previousVotes = queryClient.getQueryData<ItemVoteResponse>(['item-votes', itemId]);
// Snapshot the activity cache so we can roll back on error without
// triggering a refetch flicker.
const previousActivity = queryClient.getQueriesData<ItemActivityPayload>({
queryKey: [ITEM_ACTIVITY_QUERY_KEY, itemId]
});

// Derive the signed delta from the SAME snapshot we used to feed the
// item-votes cache update — so the value patched into the activity
// cache stays consistent with what we wrote into item-votes.
let appliedDelta = 0;
queryClient.setQueryData<ItemVoteResponse>(['item-votes', itemId], (old) => {
if (!old) return { count: type === 'up' ? 1 : -1, userVote: type };

const countDiff = old.userVote === type ? -1 : old.userVote === null ? 1 : 2;
if (!old) {
const seedCount = type === 'up' ? 1 : -1;
appliedDelta = seedCount;
return { count: seedCount, userVote: type };
}
const oldUserVote = old.userVote;
const countDiff = oldUserVote === type ? -1 : oldUserVote === null ? 1 : 2;
const delta = type === 'up' ? countDiff : -countDiff;
appliedDelta = delta;
return {
count: old.count + (type === 'up' ? countDiff : -countDiff),
userVote: old.userVote === type ? null : type
count: old.count + delta,
userVote: oldUserVote === type ? null : type
};
});

// Optimistically update the Statistics card so upvotes appear instantly
const prevVote = previousVotes?.userVote ?? null;
const voteChange = prevVote === type ? -1 : prevVote === null ? 1 : 2;
const voteDelta = type === 'up' ? voteChange : -voteChange;
queryClient.setQueriesData<{ totals: { votes: number; [k: string]: any }; series: any[] }>(
{ queryKey: ['item-activity', itemId], exact: false },
(old) => {
if (!old) return old;
return { ...old, totals: { ...old.totals, votes: old.totals.votes + voteDelta } };
}
);
patchActivityForVoteDelta(queryClient, itemId, appliedDelta);

return { previousVotes };
return { previousVotes, previousActivity };
},
onSuccess: (data) => {
// Update cache with server data to ensure consistency
Expand All @@ -117,14 +163,17 @@ export function useItemVote(itemId: string) {
queryClient.setQueryData<ItemVoteResponse>(['item-votes', itemId], data);
}
// Sync the Statistics card with the authoritative server count
queryClient.invalidateQueries({ queryKey: ['item-activity', itemId] });
queryClient.invalidateQueries({ queryKey: [ITEM_ACTIVITY_QUERY_KEY, itemId] });
},
onError: (error, _, context) => {
if (context?.previousVotes) {
queryClient.setQueryData(['item-votes', itemId], context.previousVotes);
}
// Revert the optimistic Statistics update
queryClient.invalidateQueries({ queryKey: ['item-activity', itemId] });
if (context?.previousActivity) {
for (const [key, value] of context.previousActivity) {
queryClient.setQueryData(key, value);
}
}

// Don't show error toast if user is not logged in (handled by login modal)
if (!error.message.includes('sign in') && !error.message.includes('Authentication required')) {
Expand Down Expand Up @@ -165,29 +214,33 @@ export function useItemVote(itemId: string) {
return;
}

await queryClient.cancelQueries({ queryKey: ['item-votes', itemId] });
await Promise.all([
queryClient.cancelQueries({ queryKey: ['item-votes', itemId] }),
queryClient.cancelQueries({ queryKey: [ITEM_ACTIVITY_QUERY_KEY, itemId] })
]);
const previousVotes = queryClient.getQueryData<ItemVoteResponse>(['item-votes', itemId]);
const previousActivity = queryClient.getQueriesData<ItemActivityPayload>({
queryKey: [ITEM_ACTIVITY_QUERY_KEY, itemId]
});

// Derive the signed delta from the SAME `old` snapshot we hand to
// setQueryData so the activity patch stays consistent with the
// item-votes update.
let appliedDelta = 0;
queryClient.setQueryData<ItemVoteResponse>(['item-votes', itemId], (old) => {
if (!old) return { count: 0, userVote: null };
const oldUserVote = old.userVote;
const delta = oldUserVote === 'up' ? -1 : oldUserVote === 'down' ? 1 : 0;
appliedDelta = delta;
return {
count: old.count + (old.userVote === 'up' ? -1 : old.userVote === 'down' ? 1 : 0),
count: old.count + delta,
userVote: null
};
});

// Optimistically update the Statistics card
const prevVote = previousVotes?.userVote ?? null;
const voteDelta = prevVote === 'up' ? -1 : prevVote === 'down' ? 1 : 0;
queryClient.setQueriesData<{ totals: { votes: number; [k: string]: any }; series: any[] }>(
{ queryKey: ['item-activity', itemId], exact: false },
(old) => {
if (!old) return old;
return { ...old, totals: { ...old.totals, votes: old.totals.votes + voteDelta } };
}
);
patchActivityForVoteDelta(queryClient, itemId, appliedDelta);

return { previousVotes };
return { previousVotes, previousActivity };
},
onSuccess: (data) => {
// Update cache with server data to ensure consistency
Expand All @@ -196,14 +249,17 @@ export function useItemVote(itemId: string) {
queryClient.setQueryData<ItemVoteResponse>(['item-votes', itemId], data);
}
// Sync the Statistics card with the authoritative server count
queryClient.invalidateQueries({ queryKey: ['item-activity', itemId] });
queryClient.invalidateQueries({ queryKey: [ITEM_ACTIVITY_QUERY_KEY, itemId] });
},
onError: (error, _, context) => {
if (context?.previousVotes) {
queryClient.setQueryData(['item-votes', itemId], context.previousVotes);
}
// Revert the optimistic Statistics update
queryClient.invalidateQueries({ queryKey: ['item-activity', itemId] });
if (context?.previousActivity) {
for (const [key, value] of context.previousActivity) {
queryClient.setQueryData(key, value);
}
}
toast.error(error.message || 'An error occurred while removing your vote');
}
});
Expand Down
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@
"cross-env": "^10.1.0",
"drizzle-kit": "^0.31.7",
"drizzle-seed": "^0.3.1",
"esbuild": "^0.27.0",
"esbuild": "^0.28.1",
"esbuild-register": "^3.6.0",
"eslint": "^9",
"eslint-plugin-react": "^7.37.5",
Expand Down
21 changes: 21 additions & 0 deletions docs/log.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,27 @@ why** at a higher level than per-commit diffs.

---

## 2026-06-17 — Feat: instant sparkline bump + flicker-free rollback on upvote

- spec-037: the item-detail Statistics card already tracked total votes
optimistically via `useItemVote`'s `setQueryData(['item-votes', …])`, but
the sparkline (today's bar) and the activity totals lagged a full network
round-trip behind the vote button — and a failed mutation triggered a
cache refetch instead of a snapshot rollback, producing a brief flicker.
- Exported `ITEM_ACTIVITY_QUERY_KEY` + `ItemActivityPayload` from
`apps/web/components/item-detail/item-stats-section.tsx` so mutators can
surgically patch the activity cache without restating the cache key.
- `apps/web/hooks/use-item-vote.ts` now `cancelQueries` for both
`['item-votes', id]` and `[ITEM_ACTIVITY_QUERY_KEY, id]` before
snapshotting, derives the signed delta inside `setQueryData(old)` so the
count + userVote + activity bump stay consistent under concurrent
refetches, applies that same delta to the activity totals + today's
sparkline point on the same frame as the vote, and on error restores
the snapshot via `setQueryData(key, value)` to avoid the refetch flicker.
`onSuccess` still invalidates the activity query so the next render
reconciles with the authoritative server count.
- Credits @joel-kalema (rebased from #945 onto fresh develop). PR #961.

## 2026-06-16 — Fix: CI-safe git-CMS writes + favorite-toggle e2e race

- spec-039: the e2e suite's authenticated write-flow specs (admin create
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
"overrides": {
"@types/react": "19.2.7",
"@types/react-dom": "19.2.3",
"esbuild": "0.27.0",
"esbuild": "0.28.1",
"esbuild-register": "3.6.0",
"@opentelemetry/api": "1.9.0"
},
Expand Down
Loading
Loading