feat(insights): add owner-facing post/story analytics (Gitea #30)
New insights router aggregates the reactionCount/commentCount counters (Post pre-existing, Story added in #29) into a small per-pet dashboard — total paws/comments received and which post/story is resonating most. Reachable from the owner's own pet profile ("Meine Tiere"), owner-only.
This commit is contained in:
1 parent
05c9abfb7a
commit
45448573ea
9 files changed
+470
No files matched your search
@@ -228,6 +228,26 @@
|
|||||||
"postDeleted": "Beitrag gelöscht",
|
"postDeleted": "Beitrag gelöscht",
|
||||||
"deleteError": "Beitrag konnte nicht gelöscht werden. Bitte versuche es erneut."
|
"deleteError": "Beitrag konnte nicht gelöscht werden. Bitte versuche es erneut."
|
||||||
},
|
},
|
||||||
|
"Insights": {
|
||||||
|
"pageTitle": "Statistik — {petName}",
|
||||||
|
"postsSection": "Beiträge",
|
||||||
|
"storiesSection": "Storys",
|
||||||
|
"statPostReactions": "Pfoten erhalten",
|
||||||
|
"statPostComments": "Kommentare erhalten",
|
||||||
|
"statStoryReactions": "Pfoten erhalten",
|
||||||
|
"statStoryComments": "Kommentare erhalten",
|
||||||
|
"mostLikedPost": "Meistgelikter Beitrag",
|
||||||
|
"mostCommentedPost": "Meistkommentierter Beitrag",
|
||||||
|
"mostLikedStory": "Meistgelikte Story",
|
||||||
|
"mostCommentedStory": "Meistkommentierte Story",
|
||||||
|
"pawsCountSingular": "1 Pfote",
|
||||||
|
"pawsCountPlural": "{count} Pfoten",
|
||||||
|
"commentsCountSingular": "1 Kommentar",
|
||||||
|
"commentsCountPlural": "{count} Kommentare",
|
||||||
|
"noPostsYet": "Noch keine Reaktionen",
|
||||||
|
"noActiveStoriesYet": "Keine aktive Story mit Reaktionen",
|
||||||
|
"storiesExpireHint": "Storys verschwinden nach 24 Stunden aus der Statistik."
|
||||||
|
},
|
||||||
"Comments": {
|
"Comments": {
|
||||||
"title": "Kommentare",
|
"title": "Kommentare",
|
||||||
"justNow": "jetzt",
|
"justNow": "jetzt",
|
||||||
@@ -581,6 +601,7 @@
|
|||||||
"PetProfile": {
|
"PetProfile": {
|
||||||
"editProfile": "Profil bearbeiten",
|
"editProfile": "Profil bearbeiten",
|
||||||
"health": "Gesundheit",
|
"health": "Gesundheit",
|
||||||
|
"insights": "Statistik",
|
||||||
"managedBy": "Verwaltet von @{username}",
|
"managedBy": "Verwaltet von @{username}",
|
||||||
"statsPosts": "Posts",
|
"statsPosts": "Posts",
|
||||||
"statsFollowers": "Follower",
|
"statsFollowers": "Follower",
|
||||||
|
|||||||
@@ -228,6 +228,26 @@
|
|||||||
"postDeleted": "Post deleted",
|
"postDeleted": "Post deleted",
|
||||||
"deleteError": "Couldn't delete post. Please try again."
|
"deleteError": "Couldn't delete post. Please try again."
|
||||||
},
|
},
|
||||||
|
"Insights": {
|
||||||
|
"pageTitle": "Insights — {petName}",
|
||||||
|
"postsSection": "Posts",
|
||||||
|
"storiesSection": "Stories",
|
||||||
|
"statPostReactions": "Paws received",
|
||||||
|
"statPostComments": "Comments received",
|
||||||
|
"statStoryReactions": "Paws received",
|
||||||
|
"statStoryComments": "Comments received",
|
||||||
|
"mostLikedPost": "Most liked post",
|
||||||
|
"mostCommentedPost": "Most commented post",
|
||||||
|
"mostLikedStory": "Most liked story",
|
||||||
|
"mostCommentedStory": "Most commented story",
|
||||||
|
"pawsCountSingular": "1 paw",
|
||||||
|
"pawsCountPlural": "{count} paws",
|
||||||
|
"commentsCountSingular": "1 comment",
|
||||||
|
"commentsCountPlural": "{count} comments",
|
||||||
|
"noPostsYet": "No reactions yet",
|
||||||
|
"noActiveStoriesYet": "No active story with reactions",
|
||||||
|
"storiesExpireHint": "Stories drop out of insights 24 hours after posting."
|
||||||
|
},
|
||||||
"Comments": {
|
"Comments": {
|
||||||
"title": "Comments",
|
"title": "Comments",
|
||||||
"justNow": "now",
|
"justNow": "now",
|
||||||
@@ -581,6 +601,7 @@
|
|||||||
"PetProfile": {
|
"PetProfile": {
|
||||||
"editProfile": "Edit profile",
|
"editProfile": "Edit profile",
|
||||||
"health": "Health",
|
"health": "Health",
|
||||||
|
"insights": "Insights",
|
||||||
"managedBy": "Managed by @{username}",
|
"managedBy": "Managed by @{username}",
|
||||||
"statsPosts": "posts",
|
"statsPosts": "posts",
|
||||||
"statsFollowers": "followers",
|
"statsFollowers": "followers",
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ interface MockPostClient {
|
|||||||
update: MockFn;
|
update: MockFn;
|
||||||
delete: MockFn;
|
delete: MockFn;
|
||||||
count: MockFn;
|
count: MockFn;
|
||||||
|
aggregate: MockFn;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MockPostImageClient {
|
interface MockPostImageClient {
|
||||||
@@ -95,10 +96,12 @@ interface MockStoryClient {
|
|||||||
create: MockFn;
|
create: MockFn;
|
||||||
findMany: MockFn;
|
findMany: MockFn;
|
||||||
findUnique: MockFn;
|
findUnique: MockFn;
|
||||||
|
findFirst: MockFn;
|
||||||
update: MockFn;
|
update: MockFn;
|
||||||
delete: MockFn;
|
delete: MockFn;
|
||||||
count: MockFn;
|
count: MockFn;
|
||||||
deleteMany: MockFn;
|
deleteMany: MockFn;
|
||||||
|
aggregate: MockFn;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MockStoryViewClient {
|
interface MockStoryViewClient {
|
||||||
@@ -392,6 +395,7 @@ export function createMockPrisma(): MockPrismaClient {
|
|||||||
update: vi.fn(),
|
update: vi.fn(),
|
||||||
delete: vi.fn(),
|
delete: vi.fn(),
|
||||||
count: vi.fn(),
|
count: vi.fn(),
|
||||||
|
aggregate: vi.fn(),
|
||||||
},
|
},
|
||||||
postImage: {
|
postImage: {
|
||||||
create: vi.fn(),
|
create: vi.fn(),
|
||||||
@@ -412,10 +416,12 @@ export function createMockPrisma(): MockPrismaClient {
|
|||||||
create: vi.fn(),
|
create: vi.fn(),
|
||||||
findMany: vi.fn(),
|
findMany: vi.fn(),
|
||||||
findUnique: vi.fn(),
|
findUnique: vi.fn(),
|
||||||
|
findFirst: vi.fn(),
|
||||||
update: vi.fn(),
|
update: vi.fn(),
|
||||||
delete: vi.fn(),
|
delete: vi.fn(),
|
||||||
count: vi.fn(),
|
count: vi.fn(),
|
||||||
deleteMany: vi.fn(),
|
deleteMany: vi.fn(),
|
||||||
|
aggregate: vi.fn(),
|
||||||
},
|
},
|
||||||
storyView: {
|
storyView: {
|
||||||
upsert: vi.fn(),
|
upsert: vi.fn(),
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
/**
|
||||||
|
* Insights Unit Tests — Gitea #30 ("Nutzer Daten erweitern")
|
||||||
|
*
|
||||||
|
* Covers: insights.getPetInsights
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("server-only", () => ({}));
|
||||||
|
|
||||||
|
vi.mock("@clerk/nextjs/server", () => ({
|
||||||
|
auth: vi.fn().mockResolvedValue({ userId: "owner-1" }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/prisma", () => ({
|
||||||
|
prisma: {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { insightsRouter } from "@/trpc/routers/insights";
|
||||||
|
import { createMockPrisma } from "@/__tests__/helpers/prisma-mock";
|
||||||
|
|
||||||
|
function createCaller(overrides?: {
|
||||||
|
userId?: string;
|
||||||
|
prisma?: ReturnType<typeof createMockPrisma>;
|
||||||
|
}) {
|
||||||
|
const mockPrisma = overrides?.prisma ?? createMockPrisma();
|
||||||
|
const ctx = {
|
||||||
|
userId: overrides?.userId ?? "owner-1",
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
prisma: mockPrisma as any,
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
return { caller: insightsRouter.createCaller(ctx as any), mockPrisma };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("insights.getPetInsights", () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
it("aggregates post/story totals and returns the top post/story", async () => {
|
||||||
|
const { caller, mockPrisma } = createCaller();
|
||||||
|
|
||||||
|
mockPrisma.pet.findUnique.mockResolvedValue({ id: "pet-1", ownerId: "owner-1" });
|
||||||
|
mockPrisma.post.aggregate.mockResolvedValue({
|
||||||
|
_sum: { reactionCount: 12, commentCount: 4 },
|
||||||
|
_count: { _all: 3 },
|
||||||
|
});
|
||||||
|
mockPrisma.story.aggregate.mockResolvedValue({
|
||||||
|
_sum: { reactionCount: 5, commentCount: 1 },
|
||||||
|
_count: { _all: 2 },
|
||||||
|
});
|
||||||
|
const topPostByReactions = { id: "post-1", reactionCount: 10, commentCount: 2, images: [] };
|
||||||
|
const topPostByComments = { id: "post-2", reactionCount: 2, commentCount: 4, images: [] };
|
||||||
|
const topStoryByReactions = { id: "story-1", reactionCount: 5, commentCount: 0 };
|
||||||
|
const topStoryByComments = { id: "story-2", reactionCount: 1, commentCount: 1 };
|
||||||
|
mockPrisma.post.findFirst
|
||||||
|
.mockResolvedValueOnce(topPostByReactions)
|
||||||
|
.mockResolvedValueOnce(topPostByComments);
|
||||||
|
mockPrisma.story.findFirst
|
||||||
|
.mockResolvedValueOnce(topStoryByReactions)
|
||||||
|
.mockResolvedValueOnce(topStoryByComments);
|
||||||
|
|
||||||
|
const result = await caller.getPetInsights({ petId: "pet-1" });
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
totalPosts: 3,
|
||||||
|
totalPostReactions: 12,
|
||||||
|
totalPostComments: 4,
|
||||||
|
totalActiveStories: 2,
|
||||||
|
totalStoryReactions: 5,
|
||||||
|
totalStoryComments: 1,
|
||||||
|
topPostByReactions,
|
||||||
|
topPostByComments,
|
||||||
|
topStoryByReactions,
|
||||||
|
topStoryByComments,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scopes story queries to non-expired stories only", async () => {
|
||||||
|
const { caller, mockPrisma } = createCaller();
|
||||||
|
|
||||||
|
mockPrisma.pet.findUnique.mockResolvedValue({ id: "pet-1", ownerId: "owner-1" });
|
||||||
|
mockPrisma.post.aggregate.mockResolvedValue({
|
||||||
|
_sum: { reactionCount: 0, commentCount: 0 },
|
||||||
|
_count: { _all: 0 },
|
||||||
|
});
|
||||||
|
mockPrisma.story.aggregate.mockResolvedValue({
|
||||||
|
_sum: { reactionCount: 0, commentCount: 0 },
|
||||||
|
_count: { _all: 0 },
|
||||||
|
});
|
||||||
|
mockPrisma.post.findFirst.mockResolvedValue(null);
|
||||||
|
mockPrisma.story.findFirst.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await caller.getPetInsights({ petId: "pet-1" });
|
||||||
|
|
||||||
|
expect(mockPrisma.story.aggregate).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: expect.objectContaining({
|
||||||
|
petId: "pet-1",
|
||||||
|
expiresAt: expect.objectContaining({ gt: expect.any(Date) }),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null spotlight fields when nothing has been reacted to or commented on", async () => {
|
||||||
|
const { caller, mockPrisma } = createCaller();
|
||||||
|
|
||||||
|
mockPrisma.pet.findUnique.mockResolvedValue({ id: "pet-1", ownerId: "owner-1" });
|
||||||
|
mockPrisma.post.aggregate.mockResolvedValue({
|
||||||
|
_sum: { reactionCount: null, commentCount: null },
|
||||||
|
_count: { _all: 0 },
|
||||||
|
});
|
||||||
|
mockPrisma.story.aggregate.mockResolvedValue({
|
||||||
|
_sum: { reactionCount: null, commentCount: null },
|
||||||
|
_count: { _all: 0 },
|
||||||
|
});
|
||||||
|
mockPrisma.post.findFirst.mockResolvedValue(null);
|
||||||
|
mockPrisma.story.findFirst.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const result = await caller.getPetInsights({ petId: "pet-1" });
|
||||||
|
|
||||||
|
expect(result.totalPostReactions).toBe(0);
|
||||||
|
expect(result.totalStoryComments).toBe(0);
|
||||||
|
expect(result.topPostByReactions).toBeNull();
|
||||||
|
expect(result.topStoryByComments).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws FORBIDDEN when the caller does not own the pet", async () => {
|
||||||
|
const { caller, mockPrisma } = createCaller({ userId: "owner-2" });
|
||||||
|
|
||||||
|
mockPrisma.pet.findUnique.mockResolvedValue({ id: "pet-1", ownerId: "owner-1" });
|
||||||
|
|
||||||
|
await expect(caller.getPetInsights({ petId: "pet-1" })).rejects.toMatchObject({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { notFound, redirect } from "next/navigation";
|
||||||
|
import { currentUser } from "@clerk/nextjs/server";
|
||||||
|
import { createTRPCCaller } from "@/trpc/server";
|
||||||
|
import { InsightsDashboard } from "@/components/insights/InsightsDashboard";
|
||||||
|
|
||||||
|
interface PageProps {
|
||||||
|
params: Promise<{ petId: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gitea #30 — owner-only pet insights ("hinterlegt bei Meine Tiere").
|
||||||
|
* Same ownership-gate shape as health/page.tsx.
|
||||||
|
*/
|
||||||
|
export default async function InsightsPage({ params }: PageProps) {
|
||||||
|
const { petId } = await params;
|
||||||
|
|
||||||
|
const [user, trpc] = await Promise.all([currentUser(), createTRPCCaller()]);
|
||||||
|
|
||||||
|
let pet;
|
||||||
|
try {
|
||||||
|
pet = await trpc.pets.getProfile({ petId });
|
||||||
|
} catch {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user || pet.ownerId !== user.id) {
|
||||||
|
redirect(`/pets/${petId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <InsightsDashboard petId={petId} petName={pet.name} />;
|
||||||
|
}
|
||||||
@@ -103,6 +103,12 @@ export default async function PetProfilePage({ params }: PageProps) {
|
|||||||
>
|
>
|
||||||
{t("health")}
|
{t("health")}
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
href={`/pets/${pet.id}/insights`}
|
||||||
|
className={buttonVariants({ variant: "outline" })}
|
||||||
|
>
|
||||||
|
{t("insights")}
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { PawPrint, MessageCircle, ImageOff, Loader2 } from "lucide-react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useTRPC } from "@/trpc/client";
|
||||||
|
import { getMediaUrl } from "@/lib/media-url";
|
||||||
|
import { BlurImage } from "@/components/ui/blur-image";
|
||||||
|
|
||||||
|
interface InsightsDashboardProps {
|
||||||
|
petId: string;
|
||||||
|
petName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatTile({ label, value }: { label: string; value: number }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center rounded-lg border bg-card px-4 py-3">
|
||||||
|
<span className="text-2xl font-semibold">{value}</span>
|
||||||
|
<span className="text-center text-xs text-muted-foreground">{label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SpotlightCardProps {
|
||||||
|
title: string;
|
||||||
|
thumbnailUrl: string | null;
|
||||||
|
thumbnailBlurhash: string | null;
|
||||||
|
subtitle: string | null;
|
||||||
|
emptyLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SpotlightCard({ title, thumbnailUrl, thumbnailBlurhash, subtitle, emptyLabel }: SpotlightCardProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 rounded-lg border bg-card p-3">
|
||||||
|
<div className="relative h-14 w-14 shrink-0 overflow-hidden rounded-md bg-muted">
|
||||||
|
{thumbnailUrl ? (
|
||||||
|
<BlurImage
|
||||||
|
src={thumbnailUrl}
|
||||||
|
blurhash={thumbnailBlurhash}
|
||||||
|
alt={title}
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full w-full items-center justify-center text-muted-foreground">
|
||||||
|
<ImageOff className="h-5 w-5" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">{title}</p>
|
||||||
|
{subtitle ? (
|
||||||
|
<p className="text-sm font-semibold truncate">{subtitle}</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">{emptyLabel}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gitea #30 — small owner-facing analytics tool: overview of paw-reactions
|
||||||
|
* and comments across a pet's posts + active stories, plus which post/story
|
||||||
|
* is resonating most. Reads insights.getPetInsights (owner-only).
|
||||||
|
*/
|
||||||
|
export function InsightsDashboard({ petId, petName }: InsightsDashboardProps) {
|
||||||
|
const t = useTranslations("Insights");
|
||||||
|
const trpc = useTRPC();
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery(trpc.insights.getPetInsights.queryOptions({ petId }));
|
||||||
|
|
||||||
|
if (isLoading || !data) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center py-24">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const topPostImage = data.topPostByReactions?.images[0] ?? null;
|
||||||
|
const topCommentedPostImage = data.topPostByComments?.images[0] ?? null;
|
||||||
|
|
||||||
|
const formatPaws = (count: number) =>
|
||||||
|
count === 1 ? t("pawsCountSingular") : t("pawsCountPlural", { count });
|
||||||
|
const formatComments = (count: number) =>
|
||||||
|
count === 1 ? t("commentsCountSingular") : t("commentsCountPlural", { count });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-[640px] mx-auto px-4 py-6">
|
||||||
|
<h1 className="mb-6 text-xl font-semibold">{t("pageTitle", { petName })}</h1>
|
||||||
|
|
||||||
|
<section className="mb-8">
|
||||||
|
<h2 className="mb-3 flex items-center gap-1.5 text-sm font-semibold text-muted-foreground">
|
||||||
|
<PawPrint className="h-4 w-4" aria-hidden="true" />
|
||||||
|
{t("postsSection")}
|
||||||
|
</h2>
|
||||||
|
<div className="mb-4 grid grid-cols-2 gap-3">
|
||||||
|
<StatTile label={t("statPostReactions")} value={data.totalPostReactions} />
|
||||||
|
<StatTile label={t("statPostComments")} value={data.totalPostComments} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<SpotlightCard
|
||||||
|
title={t("mostLikedPost")}
|
||||||
|
thumbnailUrl={topPostImage ? getMediaUrl(topPostImage.storageKey) : null}
|
||||||
|
thumbnailBlurhash={topPostImage?.blurhash ?? null}
|
||||||
|
subtitle={
|
||||||
|
data.topPostByReactions
|
||||||
|
? formatPaws(data.topPostByReactions.reactionCount)
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
emptyLabel={t("noPostsYet")}
|
||||||
|
/>
|
||||||
|
<SpotlightCard
|
||||||
|
title={t("mostCommentedPost")}
|
||||||
|
thumbnailUrl={topCommentedPostImage ? getMediaUrl(topCommentedPostImage.storageKey) : null}
|
||||||
|
thumbnailBlurhash={topCommentedPostImage?.blurhash ?? null}
|
||||||
|
subtitle={
|
||||||
|
data.topPostByComments
|
||||||
|
? formatComments(data.topPostByComments.commentCount)
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
emptyLabel={t("noPostsYet")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 className="mb-3 flex items-center gap-1.5 text-sm font-semibold text-muted-foreground">
|
||||||
|
<MessageCircle className="h-4 w-4" aria-hidden="true" />
|
||||||
|
{t("storiesSection")}
|
||||||
|
</h2>
|
||||||
|
<div className="mb-4 grid grid-cols-2 gap-3">
|
||||||
|
<StatTile label={t("statStoryReactions")} value={data.totalStoryReactions} />
|
||||||
|
<StatTile label={t("statStoryComments")} value={data.totalStoryComments} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<SpotlightCard
|
||||||
|
title={t("mostLikedStory")}
|
||||||
|
thumbnailUrl={
|
||||||
|
data.topStoryByReactions?.storageKey ? getMediaUrl(data.topStoryByReactions.storageKey) : null
|
||||||
|
}
|
||||||
|
thumbnailBlurhash={data.topStoryByReactions?.blurhash ?? null}
|
||||||
|
subtitle={
|
||||||
|
data.topStoryByReactions
|
||||||
|
? formatPaws(data.topStoryByReactions.reactionCount)
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
emptyLabel={t("noActiveStoriesYet")}
|
||||||
|
/>
|
||||||
|
<SpotlightCard
|
||||||
|
title={t("mostCommentedStory")}
|
||||||
|
thumbnailUrl={
|
||||||
|
data.topStoryByComments?.storageKey ? getMediaUrl(data.topStoryByComments.storageKey) : null
|
||||||
|
}
|
||||||
|
thumbnailBlurhash={data.topStoryByComments?.blurhash ?? null}
|
||||||
|
subtitle={
|
||||||
|
data.topStoryByComments
|
||||||
|
? formatComments(data.topStoryByComments.commentCount)
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
emptyLabel={t("noActiveStoriesYet")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-xs text-muted-foreground">{t("storiesExpireHint")}</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ import { ownerRouter } from "@/trpc/routers/owner";
|
|||||||
import { sponsorRouter } from "@/trpc/routers/sponsor";
|
import { sponsorRouter } from "@/trpc/routers/sponsor";
|
||||||
import { statsRouter } from "@/trpc/routers/stats";
|
import { statsRouter } from "@/trpc/routers/stats";
|
||||||
import { broadcastRouter } from "@/trpc/routers/broadcast";
|
import { broadcastRouter } from "@/trpc/routers/broadcast";
|
||||||
|
import { insightsRouter } from "@/trpc/routers/insights";
|
||||||
|
|
||||||
export const appRouter = router({
|
export const appRouter = router({
|
||||||
pets: petsRouter,
|
pets: petsRouter,
|
||||||
@@ -54,6 +55,7 @@ export const appRouter = router({
|
|||||||
sponsor: sponsorRouter,
|
sponsor: sponsorRouter,
|
||||||
stats: statsRouter,
|
stats: statsRouter,
|
||||||
broadcast: broadcastRouter,
|
broadcast: broadcastRouter,
|
||||||
|
insights: insightsRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Export the type for use in the client
|
// Export the type for use in the client
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { router, protectedProcedure } from "@/trpc/init";
|
||||||
|
import { assertPetOwnership } from "@/lib/assert-pet-ownership";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gitea #30 — small owner-facing analytics tool ("hinterlegt bei Meine
|
||||||
|
* Tiere"): how the pet's own posts/stories are resonating with the
|
||||||
|
* community. Owner-only, reads the denormalized reactionCount/commentCount
|
||||||
|
* columns (Post: pre-existing; Story: added in #29) instead of live
|
||||||
|
* aggregation over Reaction/Comment/StoryReaction/StoryComment rows.
|
||||||
|
*
|
||||||
|
* Stories are scoped to expiresAt > now — an expired story's media is no
|
||||||
|
* longer viewable anywhere else in the app, so surfacing it as "your top
|
||||||
|
* story" here would point at dead content.
|
||||||
|
*/
|
||||||
|
export const insightsRouter = router({
|
||||||
|
getPetInsights: protectedProcedure
|
||||||
|
.input(z.object({ petId: z.string().min(1) }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
await assertPetOwnership(ctx.prisma, input.petId, ctx.userId);
|
||||||
|
|
||||||
|
const postWhere = { petId: input.petId, hiddenAt: null };
|
||||||
|
const storyWhere = { petId: input.petId, expiresAt: { gt: new Date() } };
|
||||||
|
const thumbnailInclude = { images: { take: 1, orderBy: { position: "asc" as const } } };
|
||||||
|
|
||||||
|
const [
|
||||||
|
postTotals,
|
||||||
|
storyTotals,
|
||||||
|
topPostByReactions,
|
||||||
|
topPostByComments,
|
||||||
|
topStoryByReactions,
|
||||||
|
topStoryByComments,
|
||||||
|
] = await Promise.all([
|
||||||
|
ctx.prisma.post.aggregate({
|
||||||
|
where: postWhere,
|
||||||
|
_sum: { reactionCount: true, commentCount: true },
|
||||||
|
_count: { _all: true },
|
||||||
|
}),
|
||||||
|
ctx.prisma.story.aggregate({
|
||||||
|
where: storyWhere,
|
||||||
|
_sum: { reactionCount: true, commentCount: true },
|
||||||
|
_count: { _all: true },
|
||||||
|
}),
|
||||||
|
ctx.prisma.post.findFirst({
|
||||||
|
where: { ...postWhere, reactionCount: { gt: 0 } },
|
||||||
|
orderBy: { reactionCount: "desc" },
|
||||||
|
include: thumbnailInclude,
|
||||||
|
}),
|
||||||
|
ctx.prisma.post.findFirst({
|
||||||
|
where: { ...postWhere, commentCount: { gt: 0 } },
|
||||||
|
orderBy: { commentCount: "desc" },
|
||||||
|
include: thumbnailInclude,
|
||||||
|
}),
|
||||||
|
ctx.prisma.story.findFirst({
|
||||||
|
where: { ...storyWhere, reactionCount: { gt: 0 } },
|
||||||
|
orderBy: { reactionCount: "desc" },
|
||||||
|
}),
|
||||||
|
ctx.prisma.story.findFirst({
|
||||||
|
where: { ...storyWhere, commentCount: { gt: 0 } },
|
||||||
|
orderBy: { commentCount: "desc" },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalPosts: postTotals._count._all,
|
||||||
|
totalPostReactions: postTotals._sum.reactionCount ?? 0,
|
||||||
|
totalPostComments: postTotals._sum.commentCount ?? 0,
|
||||||
|
totalActiveStories: storyTotals._count._all,
|
||||||
|
totalStoryReactions: storyTotals._sum.reactionCount ?? 0,
|
||||||
|
totalStoryComments: storyTotals._sum.commentCount ?? 0,
|
||||||
|
topPostByReactions,
|
||||||
|
topPostByComments,
|
||||||
|
topStoryByReactions,
|
||||||
|
topStoryByComments,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
});
|
||||||
Reference in new issue
Block a user