Monorepo for Aesthetic.Computer
aesthetic.computer
1# Piece Analytics Plan
2
3## Current State
4
5- **MongoDB Atlas** is already set up and used for:
6 - `@handles` - user handles
7 - `pieces` - user-uploaded pieces (via track-media.mjs)
8 - `paintings` - user paintings
9 - `tapes` - recorded tapes
10 - `moods` - user moods
11 - `logs` - system logs
12 - `chat-system` - chat messages
13
14- **No tracking exists** for system piece hits (the built-in pieces like `prompt`, `line`, `colors`, etc.)
15
16## Proposed Schema
17
18### Collection: `piece-hits` (Aggregate Stats)
19
20```javascript
21{
22 _id: ObjectId,
23 piece: "colors", // piece name (slug)
24 type: "system" | "user", // system pieces vs @handle/piece
25 hits: 12345, // total hit count
26 uniqueUsers: 8234, // unique user count
27 firstHit: Date, // first recorded hit
28 lastHit: Date, // most recent hit
29
30 // Rolling daily aggregation (last 30 days)
31 daily: {
32 "2025-12-31": { hits: 45, unique: 32 },
33 "2025-12-30": { hits: 52, unique: 41 },
34 }
35}
36```
37
38### Collection: `piece-user-hits` (Per-User Stats)
39
40```javascript
41{
42 _id: ObjectId,
43 piece: "colors", // piece name
44 user: "auth0|abc123", // user sub (permanent ID)
45 hits: 42, // how many times this user hit this piece
46 firstHit: Date,
47 lastHit: Date,
48}
49// Compound unique index: { piece: 1, user: 1 }
50// Note: handles resolved at query time from @handles collection
51```
52
53### Collection: `piece-hit-log` (Optional Raw Event Log)
54
55```javascript
56{
57 _id: ObjectId,
58 piece: "colors",
59 user: "auth0|abc123" | null,
60 timestamp: Date,
61 referrer: "prompt", // previous piece
62 params: ["dark"], // piece parameters
63 sessionId: "xyz789", // to group session activity
64}
65// TTL index: auto-delete after 90 days
66```
67```
68
69### Collection: `piece-hit-events` (Optional, for detailed analytics)
70
71```javascript
72{
73 _id: ObjectId,
74 piece: "colors",
75 user: "sub_123..." | null, // null for anonymous
76 timestamp: Date,
77 referrer: "prompt" | null, // which piece they came from
78 params: ["param1"], // piece parameters used
79 duration: 45000, // ms spent in piece (optional, tracked on leave)
80 device: "mobile" | "desktop"
81}
82```
83
84## Implementation Options
85
86### Option A: Client-Side Tracking (Lightweight)
87**Where**: `disk.mjs` or `bios.mjs` (client-side)
88
89```javascript
90// In boot() when piece loads
91async function trackPieceHit(pieceName) {
92 try {
93 await fetch('/api/piece-hit', {
94 method: 'POST',
95 headers: { 'Content-Type': 'application/json' },
96 body: JSON.stringify({
97 piece: pieceName,
98 referrer: document.referrer
99 })
100 });
101 } catch (e) { /* silent fail */ }
102}
103```
104
105**Pros**: Accurate user-initiated loads, can track duration
106**Cons**: Can be blocked, adds latency to piece load
107
108### Option B: Server-Side Tracking (index.mjs)
109**Where**: `system/netlify/functions/index.mjs`
110
111Track every page render request:
112
113```javascript
114// In the handler, after parsing slug
115if (statusCode === 200 && slug && !previewOrIcon) {
116 // Fire-and-forget hit tracking (don't await)
117 trackHit(slug, parsed).catch(e => console.error('Hit tracking failed:', e));
118}
119```
120
121**Pros**: More reliable, no client-side blocking, catches all loads
122**Cons**: Includes bots/crawlers, no duration tracking
123
124### Option C: Hybrid (Recommended)
125- **Server-side** for page view counts (simple increment)
126- **Client-side** for engagement metrics (duration, interactions)
127
128## New API Endpoint: `/api/piece-hit`
129
130**File**: `system/netlify/functions/piece-hit.mjs`
131
132```javascript
133// piece-hit.mjs
134import { authorize } from "../../backend/authorization.mjs";
135import { connect } from "../../backend/database.mjs";
136import { respond } from "../../backend/http.mjs";
137
138export async function handler(event) {
139 const database = await connect();
140
141 // GET: Return stats for a piece or all pieces
142 if (event.httpMethod === "GET") {
143 const { piece, top, users } = event.queryStringParameters || {};
144 const hitsCol = database.db.collection("piece-hits");
145 const userHitsCol = database.db.collection("piece-user-hits");
146 const handlesCol = database.db.collection("@handles");
147
148 if (piece) {
149 const stats = await hitsCol.findOne({ piece });
150
151 // Optionally include top users for this piece
152 let topUsers = [];
153 if (users) {
154 const userStats = await userHitsCol
155 .find({ piece, user: { $ne: "anonymous" } })
156 .sort({ hits: -1 })
157 .limit(10)
158 .toArray();
159
160 // Resolve handles from subs
161 for (const u of userStats) {
162 const handleDoc = await handlesCol.findOne({ user: u.user });
163 topUsers.push({
164 handle: handleDoc?._id || null,
165 hits: u.hits,
166 lastHit: u.lastHit
167 });
168 }
169 }
170
171 return respond(200, {
172 ...stats,
173 topUsers: users ? topUsers : undefined
174 });
175 }
176
177 // Return top pieces overall
178 const pieces = await hitsCol
179 .find({})
180 .sort({ hits: -1 })
181 .limit(parseInt(top) || 50)
182 .toArray();
183 return respond(200, { pieces });
184 }
185
186 // POST: Record a hit
187 if (event.httpMethod === "POST") {
188 const { piece, type = "system", referrer, params } = JSON.parse(event.body || "{}");
189 if (!piece) return respond(400, { error: "piece required" });
190
191 // Try to get user from auth header
192 let user = null;
193 try {
194 user = await authorize(event.headers);
195 } catch (e) { /* anonymous hit */ }
196
197 const now = new Date();
198 const today = now.toISOString().split("T")[0];
199 const hitsCol = database.db.collection("piece-hits");
200 const userHitsCol = database.db.collection("piece-user-hits");
201
202 // 1. Update aggregate stats
203 const updateOps = {
204 $inc: {
205 hits: 1,
206 [`daily.${today}.hits`]: 1
207 },
208 $set: { lastHit: now, type },
209 $setOnInsert: { firstHit: now, uniqueUsers: 0 }
210 };
211
212 await hitsCol.updateOne({ piece }, updateOps, { upsert: true });
213
214 // 2. Update per-user stats (only sub, no handle)
215 const userKey = user?.sub || "anonymous";
216 const userResult = await userHitsCol.updateOne(
217 { piece, user: userKey },
218 {
219 $inc: { hits: 1 },
220 $set: { lastHit: now },
221 $setOnInsert: { firstHit: now }
222 },
223 { upsert: true }
224 );
225
226 // If this was a new user for this piece, increment uniqueUsers
227 if (userResult.upsertedCount > 0 && userKey !== "anonymous") {
228 await hitsCol.updateOne(
229 { piece },
230 { $inc: { uniqueUsers: 1, [`daily.${today}.unique`]: 1 } }
231 );
232 }
233
234 await database.disconnect();
235 return respond(200, { success: true });
236 }
237
238 return respond(405, { error: "Method not allowed" });
239}
240```
241
242## New API Endpoint: `/api/piece-fans`
243
244**File**: `system/netlify/functions/piece-fans.mjs`
245
246Get top users ("fans") of a piece:
247
248```javascript
249// piece-fans.mjs - Get users who love a specific piece
250import { connect } from "../../backend/database.mjs";
251import { respond } from "../../backend/http.mjs";
252
253export async function handler(event) {
254 if (event.httpMethod !== "GET") return respond(405);
255
256 const { piece, limit = 20 } = event.queryStringParameters || {};
257 if (!piece) return respond(400, { error: "piece required" });
258
259 const database = await connect();
260 const userHitsCol = database.db.collection("piece-user-hits");
261 const handlesCol = database.db.collection("@handles");
262
263 // Get top users by hits (excluding anonymous)
264 const userStats = await userHitsCol
265 .find({ piece, user: { $ne: "anonymous" } })
266 .sort({ hits: -1 })
267 .limit(parseInt(limit))
268 .toArray();
269
270 // Resolve handles from subs at query time
271 const fans = [];
272 for (const u of userStats) {
273 const handleDoc = await handlesCol.findOne({ user: u.user });
274 if (handleDoc) { // Only include users with handles
275 fans.push({
276 handle: handleDoc._id,
277 hits: u.hits,
278 firstHit: u.firstHit,
279 lastHit: u.lastHit
280 });
281 }
282 }
283
284 await database.disconnect();
285 return respond(200, { piece, fans });
286}
287```
288
289## Using Hit Data in list.mjs
290
291```javascript
292// In boot(), fetch popular pieces
293const hitStats = await fetch('/api/piece-hit').then(r => r.json());
294const hitMap = new Map(hitStats.pieces?.map(p => [p.piece, p.hits]) || []);
295
296// Add "🔥 Popular" category sorted by hits
297const popularPieces = allItems
298 .filter(item => hitMap.get(item.name) > 100)
299 .sort((a, b) => (hitMap.get(b.name) || 0) - (hitMap.get(a.name) || 0))
300 .slice(0, 20);
301```
302
303## Implementation Steps
304
3051. **Create endpoint**: `piece-hit.mjs` with GET/POST + user tracking
3062. **Create endpoint**: `piece-fans.mjs` to query top users per piece
3073. **Add server-side tracking**: In `index.mjs`, fire-and-forget POST on each page load
3084. **Create indexes**:
309 - `piece-hits`: `{ piece: 1 }` unique, `{ hits: -1 }` for sorting
310 - `piece-user-hits`: `{ piece: 1, user: 1 }` unique compound, `{ piece: 1, hits: -1 }` for fan queries
311 - `piece-hit-log`: `{ timestamp: 1 }` TTL 90 days (optional)
3125. **Update list.mjs**: Add "🔥 Popular" category using hit data
3136. **Add to metrics.mjs**: Include piece hit stats in `/api/metrics`
3147. **Create piece detail view**: Show fans/top users for each piece
315
316## Example Queries
317
318```javascript
319// Top 10 most visited pieces
320db.collection("piece-hits").find().sort({ hits: -1 }).limit(10)
321
322// Top fans of "colors" piece
323db.collection("piece-user-hits")
324 .find({ piece: "colors", user: { $ne: "anonymous" } })
325 .sort({ hits: -1 })
326 .limit(10)
327
328// User's favorite pieces (most visited by a specific user)
329db.collection("piece-user-hits")
330 .find({ user: "auth0|abc123" })
331 .sort({ hits: -1 })
332 .limit(10)
333
334// Pieces a user has never visited
335const visited = await db.collection("piece-user-hits")
336 .find({ user: "auth0|abc123" })
337 .project({ piece: 1 }).toArray();
338const visitedSet = new Set(visited.map(v => v.piece));
339// Then filter allPieces - visitedSet
340```
341
342## Privacy Considerations
343
344- Don't store IP addresses
345- User IDs only if logged in (optional)
346- No personal data in hit events
347- Consider GDPR compliance for EU users
348- Add rate limiting to prevent abuse
349
350## Estimated Effort
351
352- **Endpoint + Server tracking**: 1-2 hours
353- **Client-side duration tracking**: 2-3 hours
354- **list.mjs integration**: 1 hour
355- **Indexes + testing**: 1 hour
356
357**Total**: ~5-7 hours for full implementation
358
359## Questions to Decide
360
3611. **Track duration?** Requires client-side tracking on piece leave
3622. **Track user vs anonymous separately?** More complex queries
3633. **Rolling daily stats?** Need cleanup job for old data
3644. **Bot filtering?** Could use User-Agent checking
3655. **Rate limiting?** Prevent spam hits from single client
366
367---
368
369*Created: 2025.12.31*