diff --git a/README.md b/README.md
index bb1cb3e..44660ba 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@ Erika is a Next.js application for a creator landing page, Discord-authenticated
- Bun 1.3+
- PostgreSQL
-- Redis for visitor counts and server-sent events
+- Redis for follower-count caching and server-sent events
- Discord OAuth and bot credentials for authentication and role-based access
## Development
diff --git a/app/page.tsx b/app/page.tsx
index 3c883bb..4ace824 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -6,11 +6,8 @@ import Image from "next/image";
import Link from "next/link";
import { Finger_Paint } from "next/font/google";
import { getFollowerCounts } from "@/lib/followers";
-import { headers } from "next/headers";
-import { Share2, Trophy, Users } from "lucide-react";
-import { Kbd } from "@/components/ui/kbd";
+import { Share2, Trophy } from "lucide-react";
import Background from "@/public/background/space.webp";
-import { trackUniqueVisitor } from "@/lib/visitor/unique-visitors";
import {
formatCount,
getCountForLink,
@@ -106,16 +103,7 @@ function LinkList({
}
export default async function Home() {
- const visitorCountPromise = headers()
- .then(trackUniqueVisitor)
- .catch((error) => {
- console.error("Failed to track visitor:", error);
- return 0;
- });
- const [{ counts }, visitorCount] = await Promise.all([
- getFollowerCounts(),
- visitorCountPromise,
- ]);
+ const { counts } = await getFollowerCounts();
// Use configured profile data.
const profileName = config.profile.name;
@@ -166,14 +154,6 @@ export default async function Home() {
{profileName}
- {visitorCount > 0 && (
-
-
-
- {visitorCount} Unique Visitors
-
-
- )}
diff --git a/lib/config/server.ts b/lib/config/server.ts
index 847192d..6c5a539 100644
--- a/lib/config/server.ts
+++ b/lib/config/server.ts
@@ -14,14 +14,6 @@ export function getCanonicalUrl() {
return requiredEnvironmentUrl("NEXTAUTH_URL");
}
-export function getVisitorTrackingConfig() {
- return {
- secret: readFeatureEnvironment("Visitor tracking", [
- "NEXTAUTH_SECRET",
- ] as const).NEXTAUTH_SECRET,
- };
-}
-
export function getDiscordServerConfig() {
const config = readFeatureEnvironment("Discord", [
"DISCORD_BOT_TOKEN",
diff --git a/lib/followers/tiktok.ts b/lib/followers/tiktok.ts
index 11edac6..2dd3689 100644
--- a/lib/followers/tiktok.ts
+++ b/lib/followers/tiktok.ts
@@ -85,53 +85,3 @@ export async function getTiktokProfile(): Promise {
return empty;
}
}
-
-/** Convenience wrapper that just returns the follower count */
-export async function getTiktokFollowerCount(): Promise {
- const profile = await getTiktokProfile();
- return profile.followerCount;
-}
-
-export interface TiktokVideo {
- id: string;
- title: string;
- coverImageUrl: string;
- shareUrl: string;
- createTime: number;
-}
-
-export async function getLatestTiktokVideo(): Promise {
- const profile = await getTiktokProfile();
- if (!profile.username) {
- console.error("[TikTok API] Could not get username from profile to fetch TikWM.");
- return null;
- }
-
- try {
- const res = await fetch(`https://www.tikwm.com/api/user/posts?unique_id=@${profile.username}&count=1`, {
- cache: "no-store",
- signal: AbortSignal.timeout(5000),
- });
-
- if (!res.ok) return null;
-
- const data = await res.json();
- const videos = data.data?.videos;
-
- if (!videos || videos.length === 0) return null;
-
- const v = videos[0];
- const shareUrl = `https://www.tiktok.com/@${profile.username}/video/${v.video_id}`;
-
- return {
- id: v.video_id,
- title: v.title ?? "",
- coverImageUrl: v.cover ?? "",
- shareUrl: shareUrl,
- createTime: v.create_time ?? 0,
- };
- } catch (e) {
- console.error("[TikTok API] Error fetching latest video via TikWM:", e);
- return null;
- }
-}
diff --git a/lib/leaderboard/database.ts b/lib/leaderboard/database.ts
index 66d0ad9..76dad9f 100644
--- a/lib/leaderboard/database.ts
+++ b/lib/leaderboard/database.ts
@@ -1,5 +1,5 @@
import "server-only";
-import { and, eq, gte, inArray, sql } from "drizzle-orm";
+import { and, eq, gte, inArray } from "drizzle-orm";
import { drizzle } from "drizzle-orm/postgres-js";
import { integer, numeric, pgTable, text } from "drizzle-orm/pg-core";
import postgres from "postgres";
@@ -69,29 +69,6 @@ export async function getXpLeaderboardPage(
return buildXpLeaderboard(rows, requestedPage, searchQuery);
}
-export async function getMemberXpStats(userId: string) {
- const database = getLeaderboardDatabase();
- const [sourceRows, leaderboard] = await Promise.all([
- database
- .select({
- level: userLevels.level,
- totalXp: userLevels.xp,
- })
- .from(userLevels)
- .where(eq(userLevels.userId, userId))
- .limit(1),
- getXpLeaderboardPage(1, userId),
- ]);
- const rankedRow = leaderboard.rows.find((row) => row.userId === userId);
- const sourceRow = sourceRows[0];
-
- return {
- level: sourceRow?.level ?? 0,
- totalXp: sourceRow?.totalXp ?? "0",
- rank: rankedRow?.rank ?? null,
- };
-}
-
export async function getVoiceLeaderboardPage(
currentChannelIds: string[],
requestedPage: number,
@@ -131,28 +108,3 @@ export async function getVoiceLeaderboardPage(
searchQuery,
);
}
-
-export async function getMemberVoiceStats(
- userId: string,
- currentChannelIds: string[],
-) {
- const leaderboard = await getVoiceLeaderboardPage(
- currentChannelIds,
- 1,
- undefined,
- userId,
- );
- const rankedRow = leaderboard.rows.find((row) => row.userId === userId);
-
- return {
- totalSeconds: rankedRow?.totalSeconds ?? "0",
- rank: rankedRow?.rank ?? null,
- };
-}
-
-export async function getLeaderboardDatabaseReadOnlyStatus() {
- const result = await getLeaderboardDatabase().execute<{
- transaction_read_only: string;
- }>(sql`show transaction_read_only`);
- return result[0]?.transaction_read_only ?? null;
-}
diff --git a/lib/visitor/unique-visitors.test.ts b/lib/visitor/unique-visitors.test.ts
deleted file mode 100644
index 5f17e18..0000000
--- a/lib/visitor/unique-visitors.test.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import { describe, expect, test } from "bun:test";
-import {
- getClientAddress,
- hashClientAddress,
-} from "./visitor-identifiers";
-
-describe("privacy-preserving visitor tracking", () => {
- test("uses the first valid forwarded address", () => {
- const headers = new Headers({
- "x-forwarded-for": "203.0.113.9, 10.0.0.1",
- "x-real-ip": "192.0.2.2",
- });
-
- expect(getClientAddress(headers)).toBe("203.0.113.9");
- });
-
- test("rejects untrusted non-IP identifiers", () => {
- expect(
- getClientAddress(new Headers({ "x-forwarded-for": "unknown" })),
- ).toBeNull();
- });
-
- test("creates a stable identifier without retaining the address", () => {
- const address = "203.0.113.9";
- const identifier = hashClientAddress(address, "test-secret");
-
- expect(identifier).toBe(hashClientAddress(address, "test-secret"));
- expect(identifier).not.toContain(address);
- expect(identifier).toHaveLength(64);
- });
-});
diff --git a/lib/visitor/unique-visitors.ts b/lib/visitor/unique-visitors.ts
deleted file mode 100644
index f5e23a3..0000000
--- a/lib/visitor/unique-visitors.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-import "server-only";
-
-import { getVisitorTrackingConfig } from "@/lib/config/server";
-import { getRedisClient } from "@/lib/redis";
-import {
- getClientAddress,
- hashClientAddress,
-} from "@/lib/visitor/visitor-identifiers";
-
-const VISITOR_SET_KEY = "erika:unique_visitors:v2";
-const LEGACY_VISITOR_SET_KEY = "erika:unique_visitors";
-
-export async function trackUniqueVisitor(headers: Headers) {
- const client = await getRedisClient();
- const address = getClientAddress(headers);
-
- if (address) {
- const { secret } = getVisitorTrackingConfig();
- const identifier = hashClientAddress(address, secret);
- const [isLegacyVisitor, isPrivateVisitor] = await Promise.all([
- client.sismember(LEGACY_VISITOR_SET_KEY, address),
- client.sismember(VISITOR_SET_KEY, identifier),
- ]);
-
- if (isLegacyVisitor) {
- // Keep the legacy visitor in the old total without counting a revisit twice.
- if (isPrivateVisitor) await client.srem(VISITOR_SET_KEY, identifier);
- } else {
- await client.sadd(VISITOR_SET_KEY, identifier);
- }
- }
-
- const [legacyCount, privateCount] = await Promise.all([
- client.scard(LEGACY_VISITOR_SET_KEY),
- client.scard(VISITOR_SET_KEY),
- ]);
- return legacyCount + privateCount;
-}
diff --git a/lib/visitor/visitor-identifiers.ts b/lib/visitor/visitor-identifiers.ts
deleted file mode 100644
index cf3f67b..0000000
--- a/lib/visitor/visitor-identifiers.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-import { createHmac } from "node:crypto";
-import { isIP } from "node:net";
-
-export function getClientAddress(headers: Headers) {
- const forwardedAddress = headers
- .get("x-forwarded-for")
- ?.split(",", 1)[0]
- ?.trim();
- const address = forwardedAddress || headers.get("x-real-ip")?.trim();
- return address && isIP(address) ? address : null;
-}
-
-export function hashClientAddress(address: string, secret: string) {
- return createHmac("sha256", secret).update(address).digest("hex");
-}
diff --git a/skill/erika/references/behavior.md b/skill/erika/references/behavior.md
index 6a66ad1..c7145f6 100644
--- a/skill/erika/references/behavior.md
+++ b/skill/erika/references/behavior.md
@@ -16,6 +16,5 @@ Do not remove or bypass these without good reason:
1. `db/index.ts` uses a `globalThis` singleton and small pool size to prevent dev hot-reload connection leaks and reduce idle RAM.
2. The NextAuth session callback caches `discordId` lookups in memory with a TTL.
3. `lib/discord.ts` caches Discord API roles/profiles in memory for 60 seconds to avoid slow repeated external calls.
-4. `app/page.tsx` batches Redis unique visitor tracking and only counts when `sadd` adds a new IP.
-5. Docker Compose runs a single app replica; this is intentional for low RAM usage.
-6. Nginx must disable proxy buffering for the Next.js upstream so App Router streaming and Server Actions do not hang.
+4. Docker Compose runs a single app replica; this is intentional for low RAM usage.
+5. Nginx must disable proxy buffering for the Next.js upstream so App Router streaming and Server Actions do not hang.
diff --git a/skill/erika/references/libraries-and-data.md b/skill/erika/references/libraries-and-data.md
index e2564a7..e1c6a54 100644
--- a/skill/erika/references/libraries-and-data.md
+++ b/skill/erika/references/libraries-and-data.md
@@ -6,7 +6,7 @@
|---|---|
| `lib/config.ts` | Linktree config for `/`: profile, social links, and `config_p2` with Form link. This does not configure the form system. |
| `lib/icons.tsx` | SVG icon components for social platforms and Form |
-| `lib/redis.ts` | Redis singleton using Bun `RedisClient`; used for follower counts and unique visitors |
+| `lib/redis.ts` | Redis singleton using Bun `RedisClient`; used for follower counts and server-sent events |
| `lib/auth.ts` | Shared session, admin, and form role-access authorization helpers |
| `lib/discord.ts` | Discord API utilities for guild roles/member profiles with in-memory TTL caching |
| `lib/sanitize-html.ts` | Sanitizes admin-authored rich text before storage/rendering |
diff --git a/skill/erika/references/project.md b/skill/erika/references/project.md
index d0f0cea..1318628 100644
--- a/skill/erika/references/project.md
+++ b/skill/erika/references/project.md
@@ -23,7 +23,7 @@ Erika is a Next.js website for the content creator "Erika!". It has two main fea
| `DATABASE_URL` | PostgreSQL connection string |
| `NEXTAUTH_SECRET` | Secret for signing NextAuth sessions |
| `NEXTAUTH_URL` | Canonical URL of the site, such as `https://erika.sudloh.com` |
-| `REDIS_URL` | Redis connection string, used for follower-count caching and unique visitors |
+| `REDIS_URL` | Redis connection string, used for follower-count caching and server-sent events |
| `BASE_URL` | Public base URL, used in metadata/SEO and generated links |
| `DISCORD_CLIENT_ID` / `DISCORD_CLIENT_SECRET` | Discord OAuth app credentials for user login |
| `ADMIN_DISCORD_IDS` | Comma-separated Discord user IDs with admin access |
diff --git a/skill/erika/references/routes-and-actions.md b/skill/erika/references/routes-and-actions.md
index 69af49a..1dd8eac 100644
--- a/skill/erika/references/routes-and-actions.md
+++ b/skill/erika/references/routes-and-actions.md
@@ -5,7 +5,7 @@
| File | Purpose |
|---|---|
| `app/layout.tsx` | Root layout with fonts, theme provider, tooltip provider, toaster, page progress, and SEO metadata |
-| `app/page.tsx` | Linktree landing page with avatar, social links, follower counts, unique visitor count, and video background |
+| `app/page.tsx` | Linktree landing page with avatar, social links, follower counts, and video background |
| `app/globals.css` | Global CSS styles |
## `/form` User-Facing Forms