This repository has no description
0

Configure Feed

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

add plumbing stats

+706 -4
+149
app/src/app/api/bluesky/stats/route.ts
··· 1 + import { NextRequest, NextResponse } from 'next/server'; 2 + import { createClient } from '@supabase/supabase-js'; 3 + 4 + // Supabase client - using environment variables 5 + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || ''; 6 + const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY || ''; 7 + 8 + export async function GET(request: NextRequest) { 9 + try { 10 + // If we have Supabase credentials, fetch stats 11 + if (supabaseUrl && supabaseKey) { 12 + const supabase = createClient(supabaseUrl, supabaseKey); 13 + 14 + // 1. Get total flush count 15 + const { count: totalCount, error: countError } = await supabase 16 + .from('flushing_records') 17 + .select('*', { count: 'exact', head: true }); 18 + 19 + if (countError) { 20 + throw new Error(`Failed to get total count: ${countError.message}`); 21 + } 22 + 23 + // 2. Get daily flush counts for the chart 24 + const { data: dailyData, error: dailyError } = await supabase 25 + .from('flushing_records') 26 + .select('created_at') 27 + .order('created_at', { ascending: true }); 28 + 29 + if (dailyError) { 30 + throw new Error(`Failed to get daily data: ${dailyError.message}`); 31 + } 32 + 33 + // Create a map of date -> count 34 + const dailyCounts = new Map<string, number>(); 35 + 36 + // Process each entry to get daily counts 37 + dailyData?.forEach(entry => { 38 + const date = new Date(entry.created_at); 39 + const dateKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; 40 + 41 + if (dailyCounts.has(dateKey)) { 42 + dailyCounts.set(dateKey, (dailyCounts.get(dateKey) || 0) + 1); 43 + } else { 44 + dailyCounts.set(dateKey, 1); 45 + } 46 + }); 47 + 48 + // Convert to array sorted by date 49 + const chartData = Array.from(dailyCounts.entries()) 50 + .map(([date, count]): {date: string, count: number} => ({ date, count })) 51 + .sort((a, b) => a.date.localeCompare(b.date)); 52 + 53 + // Calculate flushes per day 54 + let flushesPerDay = 0; 55 + if (chartData.length > 0) { 56 + // Calculate days between first and last flush 57 + const firstDate = new Date(chartData[0].date); 58 + const lastDate = new Date(chartData[chartData.length - 1].date); 59 + const daysDiff = Math.max(1, Math.ceil((lastDate.getTime() - firstDate.getTime()) / (1000 * 60 * 60 * 24))); 60 + flushesPerDay = parseFloat((totalCount / daysDiff).toFixed(1)); 61 + } 62 + 63 + // 3. Get top flushers (leaderboard) 64 + const { data: leaderboardData, error: leaderboardError } = await supabase 65 + .from('flushing_records') 66 + .select('did, count') 67 + .select('did') 68 + .order('created_at', { ascending: false }); 69 + 70 + if (leaderboardError) { 71 + throw new Error(`Failed to get leaderboard data: ${leaderboardError.message}`); 72 + } 73 + 74 + // Count flushes by DID 75 + const didCounts = new Map<string, number>(); 76 + leaderboardData?.forEach(entry => { 77 + didCounts.set(entry.did, (didCounts.get(entry.did) || 0) + 1); 78 + }); 79 + 80 + // Convert to array and sort by count 81 + const leaderboard = Array.from(didCounts.entries()) 82 + .map(([did, count]): {did: string, count: number} => ({ did, count })) 83 + .sort((a, b) => b.count - a.count) 84 + .slice(0, 10); // Get top 10 85 + 86 + // Return the data 87 + return NextResponse.json({ 88 + totalCount, 89 + flushesPerDay, 90 + chartData: chartData.slice(-30), // Last 30 days 91 + leaderboard 92 + }); 93 + } else { 94 + // If no Supabase credentials, return mock data 95 + return NextResponse.json({ 96 + totalCount: 42, 97 + flushesPerDay: 3.5, 98 + chartData: generateMockChartData(), 99 + leaderboard: generateMockLeaderboard() 100 + }); 101 + } 102 + } catch (error: any) { 103 + console.error('Error fetching stats:', error); 104 + return NextResponse.json( 105 + { error: 'Failed to fetch stats', message: error.message }, 106 + { status: 500 } 107 + ); 108 + } 109 + } 110 + 111 + // Generate mock chart data 112 + function generateMockChartData() { 113 + const chartData = []; 114 + const today = new Date(); 115 + 116 + for (let i = 29; i >= 0; i--) { 117 + const date = new Date(today); 118 + date.setDate(date.getDate() - i); 119 + const dateString = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; 120 + 121 + // Random count between 1 and 5 122 + const count = Math.floor(Math.random() * 5) + 1; 123 + 124 + chartData.push({ date: dateString, count }); 125 + } 126 + 127 + return chartData; 128 + } 129 + 130 + // Generate mock leaderboard 131 + function generateMockLeaderboard() { 132 + const mockDids = [ 133 + 'did:plc:mock1', 134 + 'did:plc:mock2', 135 + 'did:plc:mock3', 136 + 'did:plc:mock4', 137 + 'did:plc:mock5', 138 + 'did:plc:mock6', 139 + 'did:plc:mock7', 140 + 'did:plc:mock8', 141 + 'did:plc:mock9', 142 + 'did:plc:mock10' 143 + ]; 144 + 145 + return mockDids.map((did, index) => ({ 146 + did, 147 + count: 10 - index 148 + })); 149 + }
+8 -1
app/src/app/auth/login/login.module.css
··· 38 38 39 39 .loginForm h1 { 40 40 color: var(--primary-color); 41 - margin-bottom: 1rem; 41 + margin-bottom: 0.25rem; 42 + } 43 + 44 + .subtitle { 45 + color: #666; 46 + font-size: 1.1rem; 47 + margin: 0 0 1rem 0; 48 + font-style: italic; 42 49 } 43 50 44 51 .description {
+2 -1
app/src/app/auth/login/page.tsx
··· 135 135 ) : ( 136 136 <div className={styles.loginForm}> 137 137 <h1>Login with Bluesky</h1> 138 + <p className={styles.subtitle}>using your AT Protocol account</p> 138 139 <p className={styles.description}> 139 140 Enter your Bluesky handle to continue. This works with any Bluesky account, 140 141 including those on custom PDS servers. ··· 161 162 </button> 162 163 </div> 163 164 <p className={styles.helpText}> 164 - Examples: alice.bsky.social, bob.com, or any other Bluesky handle 165 + Examples: alice.bsky.social, bob.com, etc. 165 166 </p> 166 167 </form> 167 168
+17
app/src/app/page.module.css
··· 439 439 font-size: 0.9rem; 440 440 color: #666; 441 441 margin: 0; 442 + display: flex; 443 + flex-direction: column; 444 + gap: 0.5rem; 445 + } 446 + 447 + .statsLink { 448 + display: inline-block; 449 + color: var(--primary-color); 450 + font-weight: 500; 451 + text-decoration: none; 452 + transition: color 0.2s; 453 + margin-top: 0.25rem; 454 + } 455 + 456 + .statsLink:hover { 457 + text-decoration: underline; 458 + color: var(--secondary-color); 442 459 } 443 460 444 461 .refreshButton {
+4 -1
app/src/app/page.tsx
··· 357 357 <div className={styles.feedHeader}> 358 358 <div className={styles.feedHeaderLeft}> 359 359 <h2>Recent flushes</h2> 360 - <p className={styles.feedSubheader}>Click on a username to see their custom flushing profile.</p> 360 + <p className={styles.feedSubheader}> 361 + Click on a username to see their custom flushing profile. 362 + <Link href="/stats" className={styles.statsLink}>View Plumbing Stats 🪠</Link> 363 + </p> 361 364 </div> 362 365 <button 363 366 onClick={() => fetchLatestEntries(true)}
+1 -1
app/src/app/profile/[handle]/profile.module.css
··· 153 153 font-size: 1rem; 154 154 font-weight: 500; 155 155 cursor: pointer; 156 - margin: 1.5rem auto 0; 156 + margin-top: 1.5rem; 157 157 transition: all 0.2s; 158 158 } 159 159
+219
app/src/app/stats/page.tsx
··· 1 + 'use client'; 2 + 3 + import { useState, useEffect } from 'react'; 4 + import Link from 'next/link'; 5 + import styles from './stats.module.css'; 6 + import { formatRelativeTime } from '@/lib/time-utils'; 7 + 8 + interface StatsData { 9 + totalCount: number; 10 + flushesPerDay: number; 11 + chartData: { date: string; count: number }[]; 12 + leaderboard: { did: string; count: number; handle?: string }[]; 13 + } 14 + 15 + export default function StatsPage() { 16 + const [statsData, setStatsData] = useState<StatsData | null>(null); 17 + const [loading, setLoading] = useState(true); 18 + const [error, setError] = useState<string | null>(null); 19 + 20 + useEffect(() => { 21 + // Fetch stats data when the component mounts 22 + fetchStatsData(); 23 + }, []); 24 + 25 + // Function to fetch stats data 26 + const fetchStatsData = async () => { 27 + try { 28 + setLoading(true); 29 + setError(null); 30 + 31 + const response = await fetch('/api/bluesky/stats', { 32 + cache: 'no-store', 33 + headers: { 34 + 'Cache-Control': 'no-cache', 35 + 'Pragma': 'no-cache' 36 + } 37 + }); 38 + 39 + if (!response.ok) { 40 + throw new Error(`Failed to fetch stats: ${response.status}`); 41 + } 42 + 43 + const data = await response.json(); 44 + 45 + // Process leaderboard data - resolve handles when possible 46 + const leaderboardWithHandles = await Promise.all( 47 + data.leaderboard.map(async (item: { did: string; count: number }) => { 48 + // Try to resolve the DID to a handle 49 + try { 50 + const handleResponse = await fetch(`https://plc.directory/${item.did}/data`); 51 + if (handleResponse.ok) { 52 + const didDoc = await handleResponse.json(); 53 + // Extract handle from alsoKnownAs 54 + const handleUrl = didDoc.alsoKnownAs?.[0]; 55 + if (handleUrl && handleUrl.startsWith('at://')) { 56 + const handle = handleUrl.substring(5); // Remove 'at://' 57 + return { ...item, handle }; 58 + } 59 + } 60 + } catch (e) { 61 + console.error(`Failed to resolve handle for DID ${item.did}`, e); 62 + } 63 + // Return original item if handle resolution fails 64 + return item; 65 + }) 66 + ); 67 + 68 + setStatsData({ 69 + ...data, 70 + leaderboard: leaderboardWithHandles 71 + }); 72 + } catch (err: any) { 73 + console.error('Error fetching stats:', err); 74 + setError(err.message || 'Failed to load stats'); 75 + } finally { 76 + setLoading(false); 77 + } 78 + }; 79 + 80 + return ( 81 + <div className={styles.container}> 82 + <header className={styles.header}> 83 + <h1>Plumbing Stats 🪠</h1> 84 + <p className={styles.subtitle}> 85 + Global statistics for the im.flushing network 86 + </p> 87 + </header> 88 + 89 + <div className={styles.controls}> 90 + <button 91 + onClick={() => fetchStatsData()} 92 + className={styles.refreshButton} 93 + disabled={loading} 94 + > 95 + {loading ? 'Loading...' : 'Refresh Stats'} 96 + </button> 97 + <Link href="/" className={styles.homeLink}> 98 + Back to Dashboard 99 + </Link> 100 + </div> 101 + 102 + {error && ( 103 + <div className={styles.error}> 104 + Error: {error} 105 + </div> 106 + )} 107 + 108 + {loading ? ( 109 + <div className={styles.loadingContainer}> 110 + <div className={styles.loader}></div> 111 + <p>Loading stats...</p> 112 + </div> 113 + ) : statsData ? ( 114 + <div className={styles.statsContent}> 115 + {/* Overall Stats */} 116 + <section className={styles.overallStats}> 117 + <h2>Overall Flush Activity</h2> 118 + <div className={styles.statsGrid}> 119 + <div className={styles.statCard}> 120 + <div className={styles.statValue}>{statsData.totalCount}</div> 121 + <div className={styles.statLabel}>Total Flushes</div> 122 + </div> 123 + <div className={styles.statCard}> 124 + <div className={styles.statValue}>{statsData.flushesPerDay}</div> 125 + <div className={styles.statLabel}>Flushes Per Day</div> 126 + </div> 127 + </div> 128 + </section> 129 + 130 + {/* Activity Chart */} 131 + <section className={styles.chartSection}> 132 + <h2>Daily Activity</h2> 133 + {statsData.chartData.length > 0 ? ( 134 + <> 135 + <div className={styles.chartContainer}> 136 + {statsData.chartData.map((dataPoint, index) => { 137 + // Calculate height percentage (max of 100%) 138 + const maxCount = Math.max(...statsData.chartData.map(d => d.count)); 139 + const heightPercent = Math.max(10, Math.min(100, (dataPoint.count / maxCount) * 100)); 140 + 141 + return ( 142 + <div 143 + key={index} 144 + className={styles.chartBar} 145 + style={{ height: `${heightPercent}%` }} 146 + title={`${dataPoint.date}: ${dataPoint.count} flushes`} 147 + /> 148 + ); 149 + })} 150 + </div> 151 + 152 + <div className={styles.chartLegend}> 153 + <span className={styles.chartLegendItem}> 154 + {statsData.chartData.length > 0 ? new Date(statsData.chartData[0].date).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) : ''} 155 + </span> 156 + <span className={styles.chartLegendItem}> 157 + {statsData.chartData.length > 0 ? new Date(statsData.chartData[statsData.chartData.length - 1].date).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) : ''} 158 + </span> 159 + </div> 160 + </> 161 + ) : ( 162 + <p className={styles.noDataMessage}>Not enough data to display activity chart</p> 163 + )} 164 + </section> 165 + 166 + {/* Leaderboard */} 167 + <section className={styles.leaderboardSection}> 168 + <h2>Top Flushers</h2> 169 + {statsData.leaderboard.length > 0 ? ( 170 + <div className={styles.leaderboard}> 171 + <div className={styles.leaderboardHeader}> 172 + <span className={styles.rank}>Rank</span> 173 + <span className={styles.user}>User</span> 174 + <span className={styles.count}>Flushes</span> 175 + </div> 176 + {statsData.leaderboard.map((item, index) => ( 177 + <div key={index} className={`${styles.leaderboardItem} ${index === 0 ? styles.topRank : ''}`}> 178 + <span className={styles.rank}>#{index + 1}</span> 179 + <span className={styles.user}> 180 + {item.handle ? ( 181 + <Link href={`/profile/${item.handle}`}> 182 + @{item.handle} 183 + </Link> 184 + ) : ( 185 + <span className={styles.unknownUser}> 186 + {item.did.substring(0, 10)}... 187 + </span> 188 + )} 189 + </span> 190 + <span className={styles.count}>{item.count}</span> 191 + </div> 192 + ))} 193 + </div> 194 + ) : ( 195 + <p className={styles.noDataMessage}>No leaderboard data available</p> 196 + )} 197 + </section> 198 + 199 + <div className={styles.shareSection}> 200 + <button 201 + className={styles.shareButton} 202 + onClick={() => { 203 + // Generate share text 204 + const statsText = `There have been ${statsData.totalCount} flushes on @flushing.im! That's ${statsData.flushesPerDay} flushes per day. Check out the stats and leaderboard: https://flushing.im/stats`; 205 + window.open(`https://bsky.app/intent/compose?text=${encodeURIComponent(statsText)}`, '_blank'); 206 + }} 207 + > 208 + Share These Stats 209 + </button> 210 + </div> 211 + </div> 212 + ) : ( 213 + <div className={styles.emptyState}> 214 + <p>No stats data available</p> 215 + </div> 216 + )} 217 + </div> 218 + ); 219 + }
+306
app/src/app/stats/stats.module.css
··· 1 + .container { 2 + max-width: 800px; 3 + margin: 0 auto; 4 + padding: 2rem 1rem; 5 + } 6 + 7 + .header { 8 + text-align: center; 9 + margin-bottom: 2rem; 10 + } 11 + 12 + .header h1 { 13 + font-size: 2.5rem; 14 + margin-bottom: 0.5rem; 15 + color: var(--primary-color); 16 + } 17 + 18 + .subtitle { 19 + color: #666; 20 + font-size: 1.2rem; 21 + } 22 + 23 + .controls { 24 + display: flex; 25 + justify-content: center; 26 + gap: 1rem; 27 + margin-bottom: 2rem; 28 + } 29 + 30 + .refreshButton { 31 + background-color: var(--primary-color); 32 + color: white; 33 + border: none; 34 + border-radius: 4px; 35 + padding: 0.5rem 1rem; 36 + font-size: 1rem; 37 + cursor: pointer; 38 + transition: background-color 0.2s; 39 + } 40 + 41 + .refreshButton:hover:not(:disabled) { 42 + background-color: var(--secondary-color); 43 + } 44 + 45 + .refreshButton:disabled { 46 + background-color: #ccc; 47 + cursor: not-allowed; 48 + } 49 + 50 + .homeLink { 51 + display: inline-block; 52 + color: var(--primary-color); 53 + text-decoration: none; 54 + border: 1px solid var(--primary-color); 55 + border-radius: 4px; 56 + padding: 0.5rem 1rem; 57 + font-size: 1rem; 58 + transition: all 0.2s; 59 + } 60 + 61 + .homeLink:hover { 62 + background-color: rgba(91, 173, 240, 0.1); 63 + } 64 + 65 + .loadingContainer { 66 + display: flex; 67 + flex-direction: column; 68 + align-items: center; 69 + justify-content: center; 70 + padding: 3rem; 71 + text-align: center; 72 + } 73 + 74 + .loader { 75 + border: 4px solid #f3f3f3; 76 + border-top: 4px solid var(--primary-color); 77 + border-radius: 50%; 78 + width: 40px; 79 + height: 40px; 80 + animation: spin 1s linear infinite; 81 + margin-bottom: 1rem; 82 + } 83 + 84 + @keyframes spin { 85 + 0% { transform: rotate(0deg); } 86 + 100% { transform: rotate(360deg); } 87 + } 88 + 89 + .error { 90 + background-color: #ffebee; 91 + color: #c62828; 92 + padding: 1rem; 93 + border-radius: 4px; 94 + margin-bottom: 1rem; 95 + } 96 + 97 + .emptyState { 98 + text-align: center; 99 + padding: 3rem; 100 + color: #666; 101 + } 102 + 103 + /* Stats Content Sections */ 104 + .statsContent { 105 + display: flex; 106 + flex-direction: column; 107 + gap: 2rem; 108 + } 109 + 110 + .overallStats, .chartSection, .leaderboardSection { 111 + background: white; 112 + border-radius: 8px; 113 + padding: 1.5rem; 114 + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); 115 + } 116 + 117 + .overallStats h2, .chartSection h2, .leaderboardSection h2 { 118 + color: var(--primary-color); 119 + margin-bottom: 1.5rem; 120 + font-size: 1.5rem; 121 + text-align: center; 122 + } 123 + 124 + /* Stats Grid */ 125 + .statsGrid { 126 + display: grid; 127 + grid-template-columns: repeat(2, 1fr); 128 + gap: 1.5rem; 129 + } 130 + 131 + .statCard { 132 + background: #f8f9fa; 133 + padding: 1.5rem; 134 + border-radius: 8px; 135 + text-align: center; 136 + transition: transform 0.2s; 137 + } 138 + 139 + .statCard:hover { 140 + transform: translateY(-5px); 141 + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); 142 + } 143 + 144 + .statValue { 145 + font-size: 2.5rem; 146 + font-weight: bold; 147 + color: var(--primary-color); 148 + margin-bottom: 0.5rem; 149 + } 150 + 151 + .statLabel { 152 + color: #666; 153 + font-size: 1.1rem; 154 + } 155 + 156 + /* Chart Styles */ 157 + .chartContainer { 158 + height: 200px; 159 + display: flex; 160 + align-items: flex-end; 161 + gap: 2px; 162 + margin-bottom: 1rem; 163 + overflow-x: auto; 164 + padding-bottom: 0.5rem; 165 + } 166 + 167 + .chartBar { 168 + flex: 1; 169 + min-width: 10px; 170 + border-radius: 2px 2px 0 0; 171 + background-color: var(--primary-color); 172 + transition: height 0.5s ease; 173 + } 174 + 175 + .chartLegend { 176 + display: flex; 177 + justify-content: space-between; 178 + color: #666; 179 + font-size: 0.9rem; 180 + } 181 + 182 + .noDataMessage { 183 + text-align: center; 184 + color: #666; 185 + font-style: italic; 186 + padding: 2rem 0; 187 + } 188 + 189 + /* Leaderboard Styles */ 190 + .leaderboard { 191 + border: 1px solid #eee; 192 + border-radius: 8px; 193 + overflow: hidden; 194 + } 195 + 196 + .leaderboardHeader { 197 + display: grid; 198 + grid-template-columns: 80px 1fr 100px; 199 + padding: 1rem; 200 + background: #f5f5f5; 201 + font-weight: bold; 202 + color: #333; 203 + } 204 + 205 + .leaderboardItem { 206 + display: grid; 207 + grid-template-columns: 80px 1fr 100px; 208 + padding: 1rem; 209 + border-top: 1px solid #eee; 210 + transition: background-color 0.2s; 211 + } 212 + 213 + .leaderboardItem:hover { 214 + background-color: #f9f9f9; 215 + } 216 + 217 + .topRank { 218 + background-color: #fff8e1; 219 + } 220 + 221 + .rank { 222 + font-weight: bold; 223 + color: #666; 224 + } 225 + 226 + .user a { 227 + color: var(--primary-color); 228 + text-decoration: none; 229 + font-weight: 500; 230 + } 231 + 232 + .user a:hover { 233 + text-decoration: underline; 234 + } 235 + 236 + .unknownUser { 237 + color: #999; 238 + font-style: italic; 239 + } 240 + 241 + .count { 242 + font-weight: 500; 243 + text-align: right; 244 + } 245 + 246 + /* Share Button */ 247 + .shareSection { 248 + display: flex; 249 + justify-content: center; 250 + margin-top: 1rem; 251 + } 252 + 253 + .shareButton { 254 + background-color: var(--primary-color); 255 + color: white; 256 + border: none; 257 + border-radius: 4px; 258 + padding: 0.75rem 1.5rem; 259 + font-size: 1.1rem; 260 + cursor: pointer; 261 + transition: all 0.2s; 262 + } 263 + 264 + .shareButton:hover { 265 + background-color: var(--secondary-color); 266 + transform: translateY(-2px); 267 + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); 268 + } 269 + 270 + /* Responsive Adjustments */ 271 + @media (max-width: 600px) { 272 + .container { 273 + padding: 1rem; 274 + } 275 + 276 + .header h1 { 277 + font-size: 1.8rem; 278 + } 279 + 280 + .subtitle { 281 + font-size: 1rem; 282 + } 283 + 284 + .statsGrid { 285 + grid-template-columns: 1fr; 286 + gap: 1rem; 287 + } 288 + 289 + .statCard { 290 + padding: 1rem; 291 + } 292 + 293 + .statValue { 294 + font-size: 2rem; 295 + } 296 + 297 + .chartContainer { 298 + height: 150px; 299 + } 300 + 301 + .leaderboardHeader, .leaderboardItem { 302 + grid-template-columns: 60px 1fr 80px; 303 + padding: 0.75rem; 304 + font-size: 0.9rem; 305 + } 306 + }