Skip to content
This repository was archived by the owner on Jul 7, 2026. It is now read-only.

Commit f598394

Browse files
ChanMeng666claude
andcommitted
feat(story): track and display per-story view counts
Phase 2 PR-2A. Adds Story.viewCount with session-deduped tracking and surfaces it everywhere a story is shown. Schema: - New Story.viewCount Int @default(0) - Migration 20260421083000_add_story_view_count already applied to the production Neon DB before this commit Tracking: - recordStoryView server action: simple increment, no auth required (anonymous reads count too) - New ViewTracker client component fires once per session per story via sessionStorage dedupe — refreshing the same tab won't re-count, a new tab/session will. Failures silently roll back the dedupe key so a transient network error doesn't permanently mute counting. - Mounted at the bottom of StoryReader Display: - Story detail header now shows reading time estimate (300 字/min) and view count alongside chapter / word count - StoryCard footer shows views next to word/chapter counts - Feed, profile (own + drafts + liked), and public author page all thread viewCount through their card mappers Reading-time estimate is intentionally simple (wordCount / 300) and clamps to a minimum of 1 minute. Will refine if user feedback says it's off for English-heavy stories. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 30df8fe commit f598394

10 files changed

Lines changed: 78 additions & 4 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- AlterTable
2+
ALTER TABLE "Story" ADD COLUMN "viewCount" INTEGER NOT NULL DEFAULT 0;

prisma/schema.prisma

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ model Story {
7777
rating Rating @default(GENERAL)
7878
status StoryStatus @default(DRAFT)
7979
wordCount Int @default(0)
80+
viewCount Int @default(0)
8081
coverImageUrl String?
8182
8283
authorId String

src/app/(main)/(protected)/profile/profile-client.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ interface Story {
7070
rating: string;
7171
status: string;
7272
wordCount: number;
73+
viewCount?: number;
7374
coverImageUrl: string | null;
7475
publishedAt: string | null;
7576
createdAt: string;
@@ -132,6 +133,7 @@ function toCardData(story: Story, fallbackAuthor: { id: string; username: string
132133
chapterCount: story._count.chapters,
133134
likes: story._count.likes,
134135
comments: story._count.comments,
136+
views: story.viewCount ?? 0,
135137
coverUrl: story.coverImageUrl ?? undefined,
136138
author: {
137139
id: author.id,

src/app/(main)/feed/page.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ function FeedPageContent() {
107107
chapterCount: story._count?.chapters || 0,
108108
likes: story._count?.likes || 0,
109109
comments: story._count?.comments || 0,
110+
views: story.viewCount,
110111
coverUrl: story.coverImageUrl || undefined,
111112
author: {
112113
id: story.author.id,

src/app/(main)/users/[username]/page.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ export default async function PublicProfilePage({ params }: PublicProfilePagePro
9191
chapterCount: story._count.chapters,
9292
likes: story._count.likes,
9393
comments: story._count.comments,
94+
views: story.viewCount,
9495
coverUrl: story.coverImageUrl ?? undefined,
9596
author: {
9697
id: profile.id,

src/components/feed/StoryCard.tsx

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

33
import Link from "next/link";
44
import Image from "next/image";
5-
import { Heart, MessageSquare, BookOpen } from "lucide-react";
5+
import { Heart, MessageSquare, BookOpen, Eye } from "lucide-react";
66
import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card";
77
import { Badge } from "@/components/ui/badge";
88
import { Button } from "@/components/ui/button";
@@ -22,6 +22,7 @@ export interface StoryCardData {
2222
chapterCount: number;
2323
likes: number;
2424
comments: number;
25+
views?: number;
2526
coverUrl?: string;
2627
author: {
2728
id: string;
@@ -177,12 +178,18 @@ export function StoryCard({ story, onLike }: StoryCardProps) {
177178
</CardContent>
178179

179180
<CardFooter className="pt-2 border-t border-border flex items-center justify-between">
180-
<div className="flex items-center gap-4 text-sm text-muted-foreground">
181+
<div className="flex items-center gap-3 text-sm text-muted-foreground">
181182
<span className="flex items-center gap-1">
182183
<BookOpen className="size-3.5" />
183184
{formatNumber(story.wordCount)}
184185
</span>
185186
<span>{story.chapterCount}</span>
187+
{typeof story.views === "number" && (
188+
<span className="flex items-center gap-1">
189+
<Eye className="size-3.5" />
190+
{formatNumber(story.views)}
191+
</span>
192+
)}
186193
</div>
187194
<div className="relative z-20 flex items-center gap-1">
188195
<Button

src/components/story/StoryReader.tsx

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"use client";
22

33
import Link from "next/link";
4-
import { Heart, BookOpen, Calendar, User, Tag, MessageSquare, Pencil, Sparkles } from "lucide-react";
4+
import { Heart, BookOpen, Calendar, User, Tag, MessageSquare, Pencil, Sparkles, Eye } from "lucide-react";
55
import { Button } from "@/components/ui/button";
66
import { Badge } from "@/components/ui/badge";
77
import { Separator } from "@/components/ui/separator";
@@ -10,6 +10,7 @@ import { toggleLike } from "@/lib/actions/story";
1010
import { toast } from "sonner";
1111
import { CommentsSection } from "./CommentsSection";
1212
import { ContinueChapterDialog } from "./ContinueChapterDialog";
13+
import { ViewTracker } from "./ViewTracker";
1314

1415
interface StoryReaderProps {
1516
story: {
@@ -21,6 +22,7 @@ interface StoryReaderProps {
2122
tags: string[];
2223
rating: string;
2324
wordCount: number;
25+
viewCount: number;
2426
publishedAt: Date | null;
2527
createdAt: Date;
2628
author: {
@@ -42,6 +44,17 @@ interface StoryReaderProps {
4244
isOwner?: boolean;
4345
}
4446

47+
function formatCount(n: number): string {
48+
if (n < 1000) return n.toString();
49+
if (n < 10000) return (n / 1000).toFixed(1) + "k";
50+
return Math.floor(n / 1000) + "k";
51+
}
52+
53+
// Average reading speed: ~300 Chinese chars / minute
54+
function readingMinutes(wordCount: number): number {
55+
return Math.max(1, Math.round(wordCount / 300));
56+
}
57+
4558
const ratingLabels: Record<string, string> = {
4659
GENERAL: "全年龄",
4760
TEEN: "青少年",
@@ -136,7 +149,11 @@ export function StoryReader({
136149
</span>
137150
<span className="flex items-center gap-1.5">
138151
<BookOpen className="size-3.5" />
139-
全文 {story.wordCount.toLocaleString()} 字 · 共 {chapterCount}
152+
全文 {story.wordCount.toLocaleString()} 字 · 共 {chapterCount} 章 · 约 {readingMinutes(story.wordCount)} 分钟
153+
</span>
154+
<span className="flex items-center gap-1.5">
155+
<Eye className="size-3.5" />
156+
{formatCount(story.viewCount)} 阅读
140157
</span>
141158
</div>
142159

@@ -238,6 +255,8 @@ export function StoryReader({
238255
onOpenChange={setContinueOpen}
239256
/>
240257
)}
258+
259+
<ViewTracker storyId={story.id} />
241260
</article>
242261
);
243262
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
"use client";
2+
3+
import { useEffect } from "react";
4+
import { recordStoryView } from "@/lib/actions/story";
5+
6+
interface ViewTrackerProps {
7+
storyId: string;
8+
}
9+
10+
/**
11+
* Fires recordStoryView once per session per story. Uses sessionStorage so
12+
* a refresh in the same tab doesn't double-count, but a new tab/session
13+
* will count again — that matches what most readers expect from a "view".
14+
*/
15+
export function ViewTracker({ storyId }: ViewTrackerProps) {
16+
useEffect(() => {
17+
if (typeof window === "undefined") return;
18+
const key = `story-viewed:${storyId}`;
19+
if (sessionStorage.getItem(key)) return;
20+
sessionStorage.setItem(key, "1");
21+
recordStoryView(storyId).catch(() => {
22+
// Silent: a failed view increment shouldn't surface to the user.
23+
sessionStorage.removeItem(key);
24+
});
25+
}, [storyId]);
26+
27+
return null;
28+
}

src/lib/actions/story.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -475,6 +475,18 @@ export async function toggleLike(storyId: string) {
475475
}
476476
}
477477

478+
export async function recordStoryView(storyId: string) {
479+
// Fire-and-forget. No auth required (anonymous reads count too).
480+
// Client dedupes per session via sessionStorage; this server action
481+
// is intentionally kept minimal — no rate limiting beyond that.
482+
await prisma.story.update({
483+
where: { id: storyId },
484+
data: { viewCount: { increment: 1 } },
485+
select: { id: true },
486+
});
487+
return { ok: true };
488+
}
489+
478490
export async function getLikedStories() {
479491
const user = await getCurrentUser();
480492

src/lib/hooks/useStory.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ interface Story {
2323
rating: Rating;
2424
status: StoryStatus;
2525
wordCount: number;
26+
viewCount: number;
2627
coverImageUrl: string | null;
2728
authorId: string;
2829
publishedAt: Date | null;

0 commit comments

Comments
 (0)