piclets / src /lib /components /Pages /Activity.svelte
Fraser's picture
RESET TO MONSTER DISCOVERY SYSTEM
565e57b
<script lang="ts">
import { onMount } from 'svelte';
import { fade, fly } from 'svelte/transition';
import type { ActivityEntry, PicletInstance, LeaderboardEntry } from '$lib/db/schema';
import { db } from '$lib/db';
import PullToRefresh from '../UI/PullToRefresh.svelte';
import { CanonicalService } from '$lib/services/canonicalService';
let activities: ActivityEntry[] = $state([]);
let leaderboard: LeaderboardEntry[] = $state([]);
let isLoading = $state(true);
let isRefreshing = $state(false);
let activeView: 'recent' | 'leaderboard' = $state('recent');
// Mock data for development (will be replaced with server data)
const mockActivities: ActivityEntry[] = [
{
id: 1,
type: 'discovery',
title: 'New Discovery!',
description: 'Fraser discovered the first "Pillow" Piclet',
picletTypeId: 'pillow_001',
discovererName: 'Fraser',
rarity: 'legendary',
createdAt: new Date(Date.now() - 1000 * 60 * 5) // 5 minutes ago
},
{
id: 2,
type: 'variation',
title: 'Variation Found',
description: 'Alex found a "Velvet Pillow" variation',
picletTypeId: 'pillow_002',
discovererName: 'Alex',
rarity: 'rare',
createdAt: new Date(Date.now() - 1000 * 60 * 30) // 30 minutes ago
},
{
id: 3,
type: 'milestone',
title: 'Milestone Reached!',
description: 'Sam collected 100 unique Piclets!',
picletTypeId: 'trophy',
discovererName: 'Sam',
rarity: 'epic',
createdAt: new Date(Date.now() - 1000 * 60 * 60) // 1 hour ago
}
];
const mockLeaderboard: LeaderboardEntry[] = [
{ username: 'Fraser', totalDiscoveries: 156, uniqueDiscoveries: 45, rarityScore: 2340, rank: 1 },
{ username: 'Alex', totalDiscoveries: 134, uniqueDiscoveries: 38, rarityScore: 1890, rank: 2 },
{ username: 'Sam', totalDiscoveries: 98, uniqueDiscoveries: 31, rarityScore: 1560, rank: 3 },
{ username: 'Jordan', totalDiscoveries: 87, uniqueDiscoveries: 28, rarityScore: 1230, rank: 4 },
{ username: 'Casey', totalDiscoveries: 76, uniqueDiscoveries: 22, rarityScore: 980, rank: 5 }
];
onMount(async () => {
await loadActivityData();
});
async function loadActivityData() {
isLoading = true;
try {
// Check if player has any discoveries
const playerPiclets = await db.picletInstances.toArray();
if (playerPiclets.length === 0) {
// No discoveries yet - show empty state
activities = [];
leaderboard = [];
} else {
// TODO: Fetch from server once backend is ready
// For now, use mock data
activities = mockActivities;
leaderboard = mockLeaderboard;
// Load recent local discoveries
const recentLocal = playerPiclets
.filter(p => p.collectedAt)
.sort((a, b) => (b.collectedAt?.getTime() || 0) - (a.collectedAt?.getTime() || 0))
.slice(0, 5)
.map((p, index) => ({
id: 1000 + index,
type: 'discovery' as const,
title: p.isCanonical ? 'New Discovery!' : 'Variation Found',
description: `You ${p.isCanonical ? 'discovered' : 'found a variation of'} "${p.objectName || p.typeId}"`,
picletTypeId: p.typeId,
discovererName: 'You',
rarity: CanonicalService.calculateRarity(p.scanCount) as any,
createdAt: p.collectedAt || new Date()
}));
// Merge with mock activities
activities = [...recentLocal, ...activities].slice(0, 10);
}
} catch (error) {
console.error('Error loading activity data:', error);
}
isLoading = false;
}
async function handleRefresh() {
isRefreshing = true;
try {
await loadActivityData();
} catch (error) {
console.error('Error refreshing activity:', error);
}
isRefreshing = false;
}
function formatTime(date: Date): string {
const now = Date.now();
const diff = now - date.getTime();
const minutes = Math.floor(diff / (1000 * 60));
const hours = Math.floor(diff / (1000 * 60 * 60));
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (minutes < 1) return 'just now';
if (minutes < 60) return `${minutes}m ago`;
if (hours < 24) return `${hours}h ago`;
if (days < 7) return `${days}d ago`;
return date.toLocaleDateString();
}
function getRarityColor(rarity: string): string {
switch (rarity) {
case 'legendary': return '#FFD700';
case 'epic': return '#9B59B6';
case 'rare': return '#3498DB';
case 'uncommon': return '#2ECC71';
default: return '#95A5A6';
}
}
function getActivityIcon(type: string): string {
switch (type) {
case 'discovery': return '✨';
case 'variation': return '🔄';
case 'milestone': return '🏆';
default: return '📍';
}
}
</script>
<div class="activity-page">
<div class="view-selector">
<button
class="view-tab"
class:active={activeView === 'recent'}
onclick={() => activeView = 'recent'}
>
Recent Activity
</button>
<button
class="view-tab"
class:active={activeView === 'leaderboard'}
onclick={() => activeView = 'leaderboard'}
>
Leaderboard
</button>
</div>
<PullToRefresh onRefresh={handleRefresh}>
{#if isLoading}
<div class="loading">
<div class="spinner"></div>
<p>Loading activity...</p>
</div>
{:else if activeView === 'recent'}
{#if activities.length === 0}
<div class="empty-state">
<div class="empty-icon">🔍</div>
<h2>No Discoveries Yet</h2>
<p>Start scanning objects to discover Piclets!</p>
<p class="hint">Every object in the world has a unique Piclet waiting to be discovered.</p>
</div>
{:else}
<div class="activity-list">
{#each activities as activity, index (activity.id)}
<div
class="activity-card"
in:fly={{ y: 20, delay: index * 50 }}
>
<div class="activity-icon">
<span class="type-icon">{getActivityIcon(activity.type)}</span>
</div>
<div class="activity-info">
<h3>{activity.title}</h3>
<p>{activity.description}</p>
<div class="activity-meta">
<span
class="rarity-badge"
style="background-color: {getRarityColor(activity.rarity)}"
>
{activity.rarity}
</span>
<span class="time">{formatTime(activity.createdAt)}</span>
</div>
</div>
</div>
{/each}
</div>
{/if}
{:else}
{#if leaderboard.length === 0}
<div class="empty-state">
<div class="empty-icon">🏆</div>
<h2>No Rankings Yet</h2>
<p>Be the first to discover Piclets!</p>
</div>
{:else}
<div class="leaderboard-list">
{#each leaderboard as entry, index (entry.username)}
<div
class="leaderboard-card"
in:fly={{ y: 20, delay: index * 50 }}
>
<div class="rank-badge" class:gold={entry.rank === 1} class:silver={entry.rank === 2} class:bronze={entry.rank === 3}>
#{entry.rank}
</div>
<div class="player-info">
<h3>{entry.username}</h3>
<div class="stats">
<span>🔍 {entry.uniqueDiscoveries} unique</span>
<span>📊 {entry.totalDiscoveries} total</span>
<span>⭐ {entry.rarityScore} points</span>
</div>
</div>
</div>
{/each}
</div>
{/if}
{/if}
</PullToRefresh>
</div>
<style>
.activity-page {
height: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
}
.view-selector {
display: flex;
gap: 0.5rem;
padding: 1rem;
background: #fff;
border-bottom: 1px solid #e0e0e0;
}
.view-tab {
flex: 1;
padding: 0.75rem;
border: 1px solid #e0e0e0;
border-radius: 8px;
background: #f5f5f5;
color: #666;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.view-tab.active {
background: #007bff;
color: white;
border-color: #007bff;
}
.loading, .empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 60vh;
text-align: center;
padding: 1rem;
}
.spinner {
width: 48px;
height: 48px;
border: 4px solid #f0f0f0;
border-top-color: #007bff;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 1rem;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.empty-icon {
font-size: 4rem;
margin-bottom: 1rem;
}
.empty-state h2 {
margin: 0 0 0.5rem;
font-size: 1.25rem;
color: #333;
}
.empty-state p {
color: #666;
font-size: 0.9rem;
margin: 0.25rem 0;
}
.hint {
font-style: italic;
color: #999;
margin-top: 1rem;
}
.activity-list, .leaderboard-list {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
padding-bottom: 5rem;
}
.activity-card, .leaderboard-card {
display: flex;
align-items: center;
gap: 1rem;
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 12px;
padding: 1rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
transition: all 0.2s ease;
}
.activity-card:hover, .leaderboard-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
}
.activity-icon {
width: 48px;
height: 48px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
background: #f0f7ff;
border-radius: 50%;
}
.type-icon {
font-size: 1.5rem;
}
.activity-info, .player-info {
flex: 1;
}
.activity-info h3, .player-info h3 {
margin: 0 0 0.25rem;
font-size: 1rem;
font-weight: 600;
color: #1a1a1a;
}
.activity-info p {
margin: 0 0 0.5rem;
font-size: 0.875rem;
color: #666;
}
.activity-meta {
display: flex;
align-items: center;
gap: 0.5rem;
}
.rarity-badge {
padding: 0.125rem 0.5rem;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
color: white;
text-transform: uppercase;
}
.time {
font-size: 0.75rem;
color: #999;
}
.rank-badge {
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: #f0f0f0;
font-weight: bold;
font-size: 1.25rem;
flex-shrink: 0;
}
.rank-badge.gold {
background: linear-gradient(135deg, #FFD700, #FFA500);
color: white;
}
.rank-badge.silver {
background: linear-gradient(135deg, #C0C0C0, #808080);
color: white;
}
.rank-badge.bronze {
background: linear-gradient(135deg, #CD7F32, #8B4513);
color: white;
}
.stats {
display: flex;
gap: 1rem;
margin-top: 0.5rem;
font-size: 0.875rem;
color: #666;
}
.stats span {
display: flex;
align-items: center;
gap: 0.25rem;
}
</style>