Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 369 lines 11 kB view raw view rendered
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*