diff --git a/BACKEND_API_REQUIREMENTS.md b/BACKEND_API_REQUIREMENTS.md new file mode 100644 index 0000000..44671c1 --- /dev/null +++ b/BACKEND_API_REQUIREMENTS.md @@ -0,0 +1,218 @@ +# Backend API Requirements for Tile History + +## Overview +The frontend tile history feature is complete and currently uses mock data in development. To display real blockchain data, the following API endpoints need to be implemented in the backend. + +## Required API Endpoints + +### 1. GET `/api/tile/{id}/history` +Returns complete history for a specific tile. + +**Response Structure:** +```json +{ + "tile_id": 1234, + "purchases": [...], + "transfers": [...], + "changes": [...], + "wrapped_events": [...] +} +``` + +### 2. GET `/api/tile/{id}/purchases` +Returns all purchase/sale events for a tile. + +**Response Structure:** +```json +{ + "purchases": [ + { + "id": 1, + "timestamp": "2023-01-15T10:30:00Z", + "block_number": 16400000, + "tx": "0xabc123...", + "log_index": 45, + "sold_by": "0x1234...", + "sold_by_ens": "seller.eth", + "purchased_by": "0x5678...", + "purchased_by_ens": "buyer.eth", + "price": "5000000000000000000", // Wei + "eth_price_usd": 1500.50, // ETH price at time of sale + "sale_price_usd": 7502.50 + } + ] +} +``` + +### 3. GET `/api/tile/{id}/transfers` +Returns all transfer events (including wrapping/unwrapping). + +**Response Structure:** +```json +{ + "transfers": [ + { + "id": 1, + "timestamp": "2023-01-15T10:30:00Z", + "block_number": 16400000, + "tx": "0xabc123...", + "log_index": 45, + "from": "0x1234...", + "from_ens": "sender.eth", + "to": "0x5678...", + "to_ens": "receiver.eth", + "transfer_type": "transfer", // "wrap", "unwrap", "transfer", "gift" + "is_wrapper_contract": false + } + ] +} +``` + +### 4. GET `/api/tile/{id}/changes` +Returns all data changes (image, URL, price updates). + +**Response Structure:** +```json +{ + "changes": [ + { + "id": 1, + "timestamp": "2023-01-15T10:30:00Z", + "block_number": 16400000, + "tx": "0xabc123...", + "log_index": 45, + "change_type": "image", // "image", "url", "price", "multiple" + "previous_image": "FF0FF0...", // If image change + "new_image": "00F00F...", + "previous_url": "https://old.com", // If URL change + "new_url": "https://new.com", + "previous_price": "1000000000000000000", // If price change + "new_price": "2000000000000000000", + "updated_by": "0x1234...", + "updated_by_ens": "updater.eth" + } + ] +} +``` + +### 5. GET `/api/tile/{id}/wrapping` +Returns wrapping/unwrapping history. + +**Response Structure:** +```json +{ + "wrapping_events": [ + { + "id": 1, + "timestamp": "2023-01-15T10:30:00Z", + "block_number": 16400000, + "tx": "0xabc123...", + "log_index": 45, + "wrapped": true, // true for wrap, false for unwrap + "updated_by": "0x1234...", + "updated_by_ens": "wrapper.eth" + } + ] +} +``` + +## Database Tables Already Available + +The backend already has these tables that can be queried: + +1. **purchase_histories** + - tile_id, sold_by, purchased_by, price, tx, timestamp, block_number, log_index + +2. **transfer_histories** + - tile_id, transferred_from, transferred_to, tx, timestamp, block_number, log_index + +3. **data_histories** + - tile_id, image, url, price, updated_by, tx, timestamp, block_number, log_index + +4. **wrapping_histories** + - tile_id, wrapped, updated_by, tx, timestamp, block_number, log_index + +## Implementation Notes + +### SQL Queries Needed + +Example for purchase history: +```sql +SELECT + ph.*, + ens_from.name as sold_by_ens, + ens_to.name as purchased_by_ens +FROM purchase_histories ph +LEFT JOIN ens_lookup ens_from ON ph.sold_by = ens_from.address +LEFT JOIN ens_lookup ens_to ON ph.purchased_by = ens_to.address +WHERE ph.tile_id = $1 +ORDER BY ph.timestamp DESC, ph.log_index DESC; +``` + +### Wrapper Contract Detection +The PixelMap wrapper contract address is: `0x2A46f3e77E2d9BFF52b83B7aDD41081Ab2c6Aaac` + +When detecting wrap/unwrap events: +- **Wrap**: transfer TO the wrapper contract +- **Unwrap**: transfer FROM the wrapper contract + +### ENS Resolution +ENS names should be resolved and cached for better UX. Consider implementing a batch ENS resolver to handle multiple addresses efficiently. + +### Historical ETH Prices +Consider integrating with a price oracle API (like CoinGecko or Chainlink) to get historical ETH prices for showing USD values at the time of transactions. + +## Frontend Integration + +The frontend is already set up to consume these endpoints. Once implemented, update: + +1. Remove mock data usage by setting `NEXT_PUBLIC_USE_MOCK_HISTORY=false` +2. Update `fetchTileHistory` in `utils/tileHistory.ts` to call the real endpoints +3. The TileHistory component will automatically use the real data + +## Priority + +High priority endpoints (implement first): +1. `/api/tile/{id}/purchases` - Most important for users +2. `/api/tile/{id}/changes` - Shows tile evolution +3. `/api/tile/{id}/transfers` - Shows ownership changes + +Medium priority: +4. `/api/tile/{id}/wrapping` - Important for NFT traders +5. `/api/tile/{id}/history` - Convenience endpoint combining all data + +## Testing + +Test with known active tiles: +- Tile #1826 - Likely has multiple sales +- Tile #1984 - Popular tile +- Tile #0 - Corner tile, likely valuable + +## Performance Considerations + +1. Add database indexes on tile_id for all history tables +2. Consider caching frequently accessed tile histories +3. Implement pagination for tiles with extensive history +4. Use database views for complex joins + +## Example Backend Route (Go/Gin) + +```go +func GetTilePurchaseHistory(c *gin.Context) { + tileID := c.Param("id") + + purchases, err := db.GetPurchaseHistoryByTileId(ctx, tileID) + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + + // Add ENS resolution + for i, purchase := range purchases { + purchases[i].SoldByEns = ensResolver.Resolve(purchase.SoldBy) + purchases[i].PurchasedByEns = ensResolver.Resolve(purchase.PurchasedBy) + } + + c.JSON(200, gin.H{"purchases": purchases}) +} +``` \ No newline at end of file diff --git a/backend/cmd/regenerate-tiles/main.go b/backend/cmd/regenerate-tiles/main.go new file mode 100644 index 0000000..13547be --- /dev/null +++ b/backend/cmd/regenerate-tiles/main.go @@ -0,0 +1,107 @@ +package main + +import ( + "context" + "database/sql" + "log" + "os" + "path/filepath" + "bufio" + "strings" + + _ "github.com/lib/pq" + "pixelmap.io/backend/internal/db" + "pixelmap.io/backend/internal/ingestor" +) + +func loadEnv() { + // Try to load .env file from backend directory + envPath := ".env" + if _, err := os.Stat(envPath); os.IsNotExist(err) { + envPath = filepath.Join("..", ".env") + } + + file, err := os.Open(envPath) + if err != nil { + log.Printf("Warning: Could not load .env file: %v", err) + return + } + defer file.Close() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := scanner.Text() + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + parts := strings.SplitN(line, "=", 2) + if len(parts) == 2 { + key := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + // Remove quotes if present + value = strings.Trim(value, `"'`) + os.Setenv(key, value) + } + } +} + +func main() { + // Load environment variables from .env file + loadEnv() + + // Connect to database + dbURL := os.Getenv("DATABASE_URL") + if dbURL == "" { + log.Fatal("DATABASE_URL environment variable is not set. Please check your .env file") + } + + conn, err := sql.Open("postgres", dbURL) + if err != nil { + log.Fatal("Failed to connect to database:", err) + } + defer conn.Close() + + queries := db.New(conn) + ctx := context.Background() + + log.Println("Starting tile JSON regeneration...") + + // Get all tiles (PixelMap has 3969 tiles) + tiles, err := queries.ListTiles(ctx, db.ListTilesParams{ + Limit: 4000, + Offset: 0, + }) + if err != nil { + log.Fatal("Failed to get tiles:", err) + } + + log.Printf("Found %d tiles to regenerate", len(tiles)) + + // Regenerate metadata for each tile + for i, tile := range tiles { + if i%100 == 0 { + log.Printf("Progress: %d/%d tiles processed", i, len(tiles)) + } + + // Get data history for the tile + dataHistory, err := queries.GetDataHistoryByTileId(ctx, tile.ID) + if err != nil { + log.Printf("Error fetching data history for tile %d: %v", tile.ID, err) + continue + } + + // Update metadata (this creates the JSON file) + if err := ingestor.UpdateTileMetadata(tile, dataHistory, queries, ctx); err != nil { + log.Printf("Error updating metadata for tile %d: %v", tile.ID, err) + continue + } + } + + log.Println("Regeneration complete!") + + // Optionally trigger S3 sync here + // You can add S3 sync code if needed + + log.Println("Don't forget to sync to S3!") +} \ No newline at end of file diff --git a/backend/cmd/sync-s3/main.go b/backend/cmd/sync-s3/main.go new file mode 100644 index 0000000..ee5f369 --- /dev/null +++ b/backend/cmd/sync-s3/main.go @@ -0,0 +1,68 @@ +package main + +import ( + "context" + "log" + "os" + "path/filepath" + "bufio" + "strings" + + "go.uber.org/zap" + "pixelmap.io/backend/internal/ingestor" +) + +func loadEnv() { + envPath := ".env" + if _, err := os.Stat(envPath); os.IsNotExist(err) { + envPath = filepath.Join("..", ".env") + } + + file, err := os.Open(envPath) + if err != nil { + log.Printf("Warning: Could not load .env file: %v", err) + return + } + defer file.Close() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := scanner.Text() + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + parts := strings.SplitN(line, "=", 2) + if len(parts) == 2 { + key := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + value = strings.Trim(value, `"'`) + os.Setenv(key, value) + } + } +} + +func main() { + // Load environment variables + loadEnv() + + // Create logger + logger, _ := zap.NewProduction() + defer logger.Sync() + + // Create S3 syncer + s3Syncer, err := ingestor.NewS3Syncer(logger, "cache") + if err != nil { + log.Fatal("Failed to create S3 syncer:", err) + } + + log.Println("Starting S3 sync...") + + // Sync to S3 + ctx := context.Background() + if err := s3Syncer.SyncWithS3(ctx); err != nil { + log.Fatal("Failed to sync to S3:", err) + } + + log.Println("S3 sync complete!") +} \ No newline at end of file diff --git a/backend/internal/db/queries/queries.sql b/backend/internal/db/queries/queries.sql index 9ba72dd..1e6f07b 100644 --- a/backend/internal/db/queries/queries.sql +++ b/backend/internal/db/queries/queries.sql @@ -187,6 +187,16 @@ SELECT * FROM purchase_histories WHERE tile_id = $1 ORDER BY time_stamp DESC, log_index DESC; +-- name: GetTransferHistoryByTileId :many +SELECT * FROM transfer_histories +WHERE tile_id = $1 +ORDER BY time_stamp DESC, log_index DESC; + +-- name: GetWrappingHistoryByTileId :many +SELECT * FROM wrapping_histories +WHERE tile_id = $1 +ORDER BY time_stamp DESC, log_index DESC; + -- name: GetLatestPurchaseHistoryByTileId :one SELECT * FROM purchase_histories WHERE tile_id = $1 diff --git a/backend/internal/ingestor/ingestor.go b/backend/internal/ingestor/ingestor.go index a3417fa..29a362b 100644 --- a/backend/internal/ingestor/ingestor.go +++ b/backend/internal/ingestor/ingestor.go @@ -687,7 +687,7 @@ func (i *Ingestor) processDataHistory(ctx context.Context) error { if err != nil { return fmt.Errorf("failed to get data history: %w", err) } - if err := updateTileMetadata(tile, dataHistory); err != nil { + if err := UpdateTileMetadata(tile, dataHistory, i.queries, ctx); err != nil { return fmt.Errorf("failed to update metadata: %w", err) } diff --git a/backend/internal/ingestor/metadata.go b/backend/internal/ingestor/metadata.go index ddf1465..4e03fe3 100644 --- a/backend/internal/ingestor/metadata.go +++ b/backend/internal/ingestor/metadata.go @@ -17,15 +17,58 @@ import ( var logger = log.New(os.Stdout, "metadata", log.LstdFlags) type MetadataPixelMapTile struct { - ID int `json:"id"` - Image string `json:"image"` - URL string `json:"url"` - Price string `json:"price"` - Owner string `json:"owner"` - Wrapped bool `json:"wrapped"` - OpenseaPrice string `json:"opensea_price"` - Ens string `json:"ens"` - HistoricalImages []PixelMapImage `json:"historical_images"` + ID int `json:"id"` + Image string `json:"image"` + URL string `json:"url"` + Price string `json:"price"` + Owner string `json:"owner"` + Wrapped bool `json:"wrapped"` + OpenseaPrice string `json:"opensea_price"` + Ens string `json:"ens"` + HistoricalImages []PixelMapImage `json:"historical_images"` + PurchaseHistory []PurchaseHistoryItem `json:"purchase_history"` + TransferHistory []TransferHistoryItem `json:"transfer_history"` + WrappingHistory []WrappingHistoryItem `json:"wrapping_history"` + DataHistory []DataHistoryItem `json:"data_history"` +} + +type PurchaseHistoryItem struct { + ID int32 `json:"id"` + Timestamp time.Time `json:"timestamp"` + BlockNumber int64 `json:"block_number"` + Tx string `json:"tx"` + SoldBy string `json:"sold_by"` + PurchasedBy string `json:"purchased_by"` + Price string `json:"price"` +} + +type TransferHistoryItem struct { + ID int32 `json:"id"` + Timestamp time.Time `json:"timestamp"` + BlockNumber int64 `json:"block_number"` + Tx string `json:"tx"` + TransferredFrom string `json:"transferred_from"` + TransferredTo string `json:"transferred_to"` +} + +type WrappingHistoryItem struct { + ID int32 `json:"id"` + Timestamp time.Time `json:"timestamp"` + BlockNumber int64 `json:"block_number"` + Tx string `json:"tx"` + Wrapped bool `json:"wrapped"` + UpdatedBy string `json:"updated_by"` +} + +type DataHistoryItem struct { + ID int32 `json:"id"` + Timestamp time.Time `json:"timestamp"` + BlockNumber int64 `json:"block_number"` + Tx string `json:"tx"` + Image string `json:"image,omitempty"` + URL string `json:"url,omitempty"` + Price string `json:"price,omitempty"` + UpdatedBy string `json:"updated_by"` } // GenerateTiledataJSON generates the tiledata.json file @@ -82,8 +125,8 @@ func GenerateTiledataJSON(tiles []db.Tile, queries *db.Queries, ctx context.Cont return nil } -// updateTileMetadata updates the metadata for a given tile -func updateTileMetadata(tile db.Tile, dataHistory []db.DataHistory) error { +// UpdateTileMetadata updates the metadata for a given tile (exported for regeneration scripts) +func UpdateTileMetadata(tile db.Tile, dataHistory []db.DataHistory, queries *db.Queries, ctx context.Context) error { tileMetaData := map[string]interface{}{ "description": "Official PixelMap Wrapped Tile. Created in 2016, PixelMap is considered the second oldest NFT, the " + "oldest verified collection on OpenSea, and provides the ability to create, display, and immortalize artwork " + @@ -149,6 +192,60 @@ func updateTileMetadata(tile db.Tile, dataHistory []db.DataHistory) error { } historicalImages := GetHistoricalImages(tile, dataHistory) + + // Initialize empty slices + purchaseItems := []PurchaseHistoryItem{} + transferItems := []TransferHistoryItem{} + wrappingItems := []WrappingHistoryItem{} + dataItems := []DataHistoryItem{} + + // Only fetch history if queries is not nil (for testing) + if queries != nil { + // Fetch purchase history + purchaseHistory, err := queries.GetPurchaseHistoryByTileId(ctx, tile.ID) + if err != nil { + logger.Printf("Error fetching purchase history for tile %d: %v", tile.ID, err) + purchaseHistory = []db.PurchaseHistory{} + } + + // Convert purchase history to API format + purchaseItems = make([]PurchaseHistoryItem, len(purchaseHistory)) + for i, p := range purchaseHistory { + purchaseItems[i] = PurchaseHistoryItem{ + ID: p.ID, + Timestamp: p.TimeStamp, + BlockNumber: p.BlockNumber, + Tx: p.Tx, + SoldBy: p.SoldBy, + PurchasedBy: p.PurchasedBy, + Price: p.Price, + } + } + + // Note: Transfer and Wrapping history queries don't exist yet in the database + // We'll leave them empty for now until the queries are added + // TODO: Add GetTransferHistoryByTileId and GetWrappingHistoryByTileId queries + } + + // Convert data history to API format + dataItems = make([]DataHistoryItem, len(dataHistory)) + for i, d := range dataHistory { + price := "" + if d.Price.Valid { + price = d.Price.String + } + dataItems[i] = DataHistoryItem{ + ID: d.ID, + Timestamp: d.TimeStamp, + BlockNumber: d.BlockNumber, + Tx: d.Tx, + Image: d.Image, + URL: d.Url, + Price: price, + UpdatedBy: d.UpdatedBy, + } + } + // Create PixelMapTile API data pixelMapTile := MetadataPixelMapTile{ ID: int(tile.ID), @@ -160,6 +257,10 @@ func updateTileMetadata(tile db.Tile, dataHistory []db.DataHistory) error { OpenseaPrice: tile.OpenseaPrice, Ens: tile.Ens, HistoricalImages: historicalImages, + PurchaseHistory: purchaseItems, + TransferHistory: transferItems, + WrappingHistory: wrappingItems, + DataHistory: dataItems, } if err := os.MkdirAll(filepath.Dir("cache/metadata/"), os.ModePerm); err != nil { diff --git a/backend/internal/ingestor/metadata_test.go b/backend/internal/ingestor/metadata_test.go index 94451e2..c887e65 100644 --- a/backend/internal/ingestor/metadata_test.go +++ b/backend/internal/ingestor/metadata_test.go @@ -1,6 +1,7 @@ package ingestor import ( + "context" "encoding/json" "os" "testing" @@ -37,7 +38,7 @@ func TestUpdateTileMetadataForTileZero(t *testing.T) { } // Execute - err := updateTileMetadata(tile, dataHistory) + err := UpdateTileMetadata(tile, dataHistory, nil, context.Background()) // Assert assert.NoError(t, err) @@ -92,7 +93,7 @@ func TestUpdateTileMetadataForTileCenter(t *testing.T) { } // Execute - err := updateTileMetadata(tile, dataHistory) + err := UpdateTileMetadata(tile, dataHistory, nil, context.Background()) // Assert assert.NoError(t, err) diff --git a/backend/regenerate-tiles.sh b/backend/regenerate-tiles.sh new file mode 100755 index 0000000..ac3035d --- /dev/null +++ b/backend/regenerate-tiles.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +# Load environment variables from .env file +if [ -f .env ]; then + export $(cat .env | grep -v '^#' | xargs) +fi + +# Run the regeneration script +go run cmd/regenerate-tiles/main.go + +echo "Done! Don't forget to sync to S3" \ No newline at end of file diff --git a/backend/sync-to-s3.sh b/backend/sync-to-s3.sh new file mode 100755 index 0000000..c104869 --- /dev/null +++ b/backend/sync-to-s3.sh @@ -0,0 +1,24 @@ +#!/bin/bash + +# Load AWS credentials from .env if needed +if [ -f .env ]; then + export $(cat .env | grep -v '^#' | xargs) +fi + +# S3 bucket name (from the Go code we saw) +BUCKET="pixelmap.art" + +echo "Syncing cache directory to S3 bucket: $BUCKET" + +# Sync the entire cache directory to S3 +# --delete removes files from S3 that don't exist locally +# Remove --delete if you want to keep old files +aws s3 sync cache/ s3://$BUCKET/ \ + --exclude ".DS_Store" \ + --exclude "*.log" + +echo "Sync complete!" + +# Optionally, you can sync just specific directories: +# aws s3 sync cache/tile/ s3://$BUCKET/tile/ +# aws s3 cp cache/tiledata.json s3://$BUCKET/tiledata.json \ No newline at end of file diff --git a/frontend/TILE_HISTORY_FEATURES.md b/frontend/TILE_HISTORY_FEATURES.md new file mode 100644 index 0000000..143cdd2 --- /dev/null +++ b/frontend/TILE_HISTORY_FEATURES.md @@ -0,0 +1,146 @@ +# Tile History Features Implementation + +## Overview +We've created a comprehensive tile history viewing system that makes exploring each tile's complete history fascinating and engaging. When you click on a tile, you can now see everything about its journey through time. + +## Components Created + +### 1. TileHistory Component (`components/TileHistory.tsx`) +The main history viewer with three viewing modes: + +#### Timeline Mode +- Chronological event timeline showing all tile changes +- Visual icons for different event types (💰 purchases, 🔄 transfers, 🎨 image changes, etc.) +- Expandable event cards with detailed information +- Direct links to Etherscan transactions +- Color-coded events for easy scanning +- Shows time elapsed and block numbers + +#### Gallery Mode (via TileImageComparison) +- Interactive image comparison slider +- Timeline playback animation +- Side-by-side image comparison +- Pixel difference calculations +- Image evolution grid view + +#### Stats Mode +- Total image changes counter +- Sales history metrics +- Unique owners count +- Price analytics (highest, lowest, average) +- Total trading volume +- Ownership duration tracking + +### 2. TileImageComparison Component (`components/TileImageComparison.tsx`) +Advanced image analysis tool featuring: + +- **Comparison Slider**: Drag to compare any two images from the tile's history +- **Timeline Playback**: Animated playback of all image changes over time +- **Speed Controls**: Adjustable playback speed (0.25s to 2s per frame) +- **Pixel Difference Analysis**: Shows exact number and percentage of pixels changed +- **Image Selection**: Choose any two images to compare from dropdowns +- **Visual Grid**: All historical images displayed in a grid for quick selection +- **Metadata Display**: Block numbers and timestamps for each image + +### 3. TileHistory Utilities (`utils/tileHistory.ts`) +Comprehensive data structures and utilities: + +- Event type definitions (PurchaseEvent, TransferEvent, DataChangeEvent) +- Price history tracking with USD conversions +- Ownership timeline with profit/loss calculations +- Tile statistics calculator +- Pattern detection (trading activity, price trends) +- Historical data fetching utilities + +## Data Displayed + +### For Each Tile, Users Can See: + +1. **Complete Ownership History** + - Every purchase with price and parties involved + - Transfer events (wrapping, gifting, etc.) + - ENS names when available + - Time between ownership changes + +2. **Image Evolution** + - Every image ever set on the tile + - When each image was created (block number and date) + - Visual comparison between any two images + - Percentage of pixels changed between versions + - Animated timeline of all changes + +3. **Price History** + - Original purchase price (2 ETH for unminted tiles) + - All resale prices + - Current listing price + - Price trends and appreciation + +4. **Activity Metrics** + - Total number of owners + - Number of image changes + - URL updates + - Days since last activity + - Trading frequency + +5. **Transaction Details** + - Direct links to Etherscan for every transaction + - Gas costs (when available) + - Block numbers for blockchain verification + - Timestamps for all events + +## User Experience Features + +### Interactive Elements +- Click timeline events to expand details +- Drag slider to compare images +- Play/pause image evolution animation +- Switch between timeline, gallery, and stats views +- Hover effects on all interactive elements + +### Visual Design +- Color-coded event types for quick scanning +- Gradient backgrounds for stat cards +- Smooth transitions and animations +- Responsive design for mobile and desktop +- Clean, modern interface matching PixelMap aesthetic + +## Future Enhancement Opportunities + +### Backend API Endpoints Needed +To fully realize this feature, the backend should expose: +- `/api/tile/{id}/history` - Complete history data +- `/api/tile/{id}/purchases` - Purchase history +- `/api/tile/{id}/transfers` - Transfer history +- `/api/tile/{id}/changes` - Data change history + +### Additional Features to Consider +1. **USD Price Conversion**: Show historical USD values using ETH price at time of sale +2. **Rarity Scoring**: Calculate rarity based on trading frequency and hold time +3. **Social Features**: Comments on historical events +4. **Download Options**: Export history as PDF or CSV +5. **Comparison Tools**: Compare multiple tiles' histories +6. **Achievement Badges**: "Diamond Hands", "OG Owner", etc. + +## Integration + +The history viewer is integrated into the tile detail page (`pages/tile/[id].tsx`) and appears below the main tile card. It automatically loads when viewing any tile and uses the existing `historical_images` data from the API. + +## Technical Notes + +- Uses `date-fns` for date formatting +- Fully TypeScript typed for reliability +- Responsive design using Tailwind CSS +- Optimized for performance with React hooks +- Ready for additional data when backend APIs are expanded + +## Usage + +When users click on any tile, they now see: +1. The standard tile card with current information +2. Below that, a comprehensive history viewer with: + - Timeline of all events + - Interactive image comparison tools + - Statistical analysis + - Complete transaction history + +This transforms each tile from a static piece of art into a living historical document, making PixelMap's history tangible and explorable. \ No newline at end of file diff --git a/frontend/components/TileCard.tsx b/frontend/components/TileCard.tsx index c3eec70..4b60eb2 100644 --- a/frontend/components/TileCard.tsx +++ b/frontend/components/TileCard.tsx @@ -97,23 +97,34 @@ export default function TileCard({ tile, large }: TileCardProps) { tileExtended.historical_images && Array.isArray(tileExtended.historical_images) && tileExtended.historical_images.length > 0 && ( - <> -

Previous Images:

- {sortedHistoricalImages?.map( - (image: PixelMapImage, idx: number) => ( - { - setTileImage(image.image); - }} - key={idx} - src={image.image_url} - height={16} - width={16} - alt={`Historical image ${idx}`} - /> - ) - )} - +
+

History:

+ {tileExtended.historical_images.length} image changes +
+ {sortedHistoricalImages?.slice(0, 5).map( + (image: PixelMapImage, idx: number) => ( + { + setTileImage(image.image); + }} + onMouseLeave={() => { + setTileImage(tile.image); + }} + key={idx} + src={image.image_url} + height={16} + width={16} + className="cursor-pointer hover:ring-2 hover:ring-blue-400" + title={`Block #${image.blockNumber}`} + alt={`Historical image ${idx}`} + /> + ) + )} + {tileExtended.historical_images.length > 5 && ( + +{tileExtended.historical_images.length - 5} more + )} +
+
)}
diff --git a/frontend/components/TileHistory.tsx b/frontend/components/TileHistory.tsx new file mode 100644 index 0000000..265b18f --- /dev/null +++ b/frontend/components/TileHistory.tsx @@ -0,0 +1,559 @@ +import React, { useState, useEffect, useMemo } from 'react'; +import { formatDistanceToNow } from 'date-fns'; +import { PixelMapTile } from '@pixelmap/common/types/PixelMapTile'; +import { PixelMapImage } from '@pixelmap/common/types/PixelMapImage'; +import TileImage from './TileImage'; +import TileImageComparison from './TileImageComparison'; +import { formatPrice, shortenIfHex } from '../utils/misc'; +import { generateMockTileHistory, shouldUseMockData } from '../utils/mockTileHistory'; + +interface TileHistoryProps { + tile: PixelMapTile; + historicalImages?: PixelMapImage[]; + purchaseHistory?: PurchaseEvent[]; + transferHistory?: TransferEvent[]; + dataHistory?: DataChangeEvent[]; +} + +interface PurchaseEvent { + id: number; + timestamp: Date; + blockNumber: number; + tx: string; + soldBy: string; + purchasedBy: string; + price: string; + soldByEns?: string; + purchasedByEns?: string; +} + +interface TransferEvent { + id: number; + timestamp: Date; + blockNumber: number; + tx: string; + from: string; + to: string; + fromEns?: string; + toEns?: string; +} + +interface DataChangeEvent { + id: number; + timestamp: Date; + blockNumber: number; + tx: string; + image?: string; + url?: string; + price?: string; + updatedBy: string; + updatedByEns?: string; +} + +type HistoryEvent = { + type: 'purchase' | 'transfer' | 'image' | 'url' | 'price' | 'genesis'; + timestamp: Date; + blockNumber: number; + tx: string; + data: any; +}; + +export default function TileHistory({ + tile, + historicalImages = [], + purchaseHistory = [], + transferHistory = [], + dataHistory = [] +}: TileHistoryProps) { + // Check if tile has the new history data from backend + const backendPurchaseHistory = (tile as any).purchase_history || []; + const backendTransferHistory = (tile as any).transfer_history || []; + const backendWrappingHistory = (tile as any).wrapping_history || []; + const backendDataHistory = (tile as any).data_history || []; + + // Debug logging + console.log('Tile data:', tile); + console.log('Backend purchase history:', backendPurchaseHistory); + console.log('Backend transfer history:', backendTransferHistory); + console.log('Backend data history:', backendDataHistory); + + // Use backend data if available, otherwise use props + const actualPurchaseHistory = backendPurchaseHistory.length > 0 ? + backendPurchaseHistory.map((p: any) => ({ + id: p.id, + timestamp: new Date(p.timestamp), + blockNumber: p.block_number, + tx: p.tx, + soldBy: p.sold_by, + purchasedBy: p.purchased_by, + price: p.price + })) : purchaseHistory; + + const actualTransferHistory = backendTransferHistory.length > 0 ? + backendTransferHistory.map((t: any) => ({ + id: t.id, + timestamp: new Date(t.timestamp), + blockNumber: t.block_number, + tx: t.tx, + from: t.transferred_from, + to: t.transferred_to + })) : transferHistory; + const [viewMode, setViewMode] = useState<'timeline' | 'gallery' | 'stats'>('timeline'); + const [expandedEvent, setExpandedEvent] = useState(null); + const [events, setEvents] = useState([]); + const [isUsingMockData, setIsUsingMockData] = useState(false); + + // Memoize mock data so it doesn't regenerate on every render + const mockData = useMemo(() => { + if (shouldUseMockData() && actualPurchaseHistory.length === 0 && actualTransferHistory.length === 0 && dataHistory.length === 0) { + return generateMockTileHistory(tile.id || 0, tile.owner); + } + return null; + }, [tile.id, tile.owner, actualPurchaseHistory.length, actualTransferHistory.length, dataHistory.length]); + + useEffect(() => { + const allEvents: HistoryEvent[] = []; + + // Use real data from backend, mock data, or passed props + let finalPurchaseHistory = actualPurchaseHistory; + let finalTransferHistory = actualTransferHistory; + let finalDataHistory = backendDataHistory.length > 0 ? + backendDataHistory.map((d: any) => ({ + id: d.id, + timestamp: new Date(d.timestamp), + blockNumber: d.block_number, + tx: d.tx, + image: d.image || undefined, + url: d.url || undefined, + price: d.price || undefined, + updatedBy: d.updated_by + })) : dataHistory; + + if (mockData && actualPurchaseHistory.length === 0 && backendDataHistory.length === 0) { + finalPurchaseHistory = mockData.purchases; + finalTransferHistory = mockData.transfers; + finalDataHistory = mockData.changes; + setIsUsingMockData(true); + } else { + setIsUsingMockData(false); + } + + // Add genesis event + allEvents.push({ + type: 'genesis', + timestamp: new Date('2016-11-17'), // PixelMap launch date + blockNumber: 2624959, // Genesis block + tx: '0x0', + data: { message: 'PixelMap Genesis - Tile Created' } + }); + + // Add purchase events + finalPurchaseHistory.forEach(p => { + allEvents.push({ + type: 'purchase', + timestamp: p.timestamp, + blockNumber: p.blockNumber, + tx: p.tx, + data: p + }); + }); + + // Add transfer events + finalTransferHistory.forEach(t => { + // Check if it's a wrap/unwrap event + const isWrap = t.to === '0x2A46f3e77E2d9BFF52b83B7aDD41081Ab2c6Aaac' || t.wrapped === true; + const isUnwrap = t.from === '0x2A46f3e77E2d9BFF52b83B7aDD41081Ab2c6Aaac' || t.wrapped === false; + + allEvents.push({ + type: 'transfer', + timestamp: t.timestamp, + blockNumber: t.blockNumber, + tx: t.tx, + data: { + ...t, + transferType: isWrap ? 'Wrapped to ERC721' : isUnwrap ? 'Unwrapped from ERC721' : 'Transferred' + } + }); + }); + + // Add wrapping events from backend + if (backendWrappingHistory.length > 0) { + backendWrappingHistory.forEach((w: any) => { + allEvents.push({ + type: 'transfer', + timestamp: new Date(w.timestamp), + blockNumber: w.block_number, + tx: w.tx, + data: { + transferType: w.wrapped ? 'Wrapped to ERC721' : 'Unwrapped from ERC721', + from: w.wrapped ? w.updated_by : '0x2A46f3e77E2d9BFF52b83B7aDD41081Ab2c6Aaac', + to: w.wrapped ? '0x2A46f3e77E2d9BFF52b83B7aDD41081Ab2c6Aaac' : w.updated_by + } + }); + }); + } + + // Add data change events + finalDataHistory.forEach(d => { + if (d.image) { + allEvents.push({ + type: 'image', + timestamp: d.timestamp, + blockNumber: d.blockNumber, + tx: d.tx, + data: d + }); + } + if (d.url) { + allEvents.push({ + type: 'url', + timestamp: d.timestamp, + blockNumber: d.blockNumber, + tx: d.tx, + data: d + }); + } + if (d.price) { + allEvents.push({ + type: 'price', + timestamp: d.timestamp, + blockNumber: d.blockNumber, + tx: d.tx, + data: d + }); + } + }); + + // Sort by timestamp descending (newest first) + allEvents.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()); + setEvents(allEvents); + }, [mockData, actualPurchaseHistory, actualTransferHistory, dataHistory, backendWrappingHistory, backendDataHistory]); + + const getEventIcon = (type: string) => { + switch (type) { + case 'purchase': return '💰'; + case 'transfer': return '🔄'; + case 'image': return '🎨'; + case 'url': return '🔗'; + case 'price': return '💵'; + case 'genesis': return '⚡'; + default: return '📝'; + } + }; + + const getEventColor = (type: string) => { + switch (type) { + case 'purchase': return 'bg-green-100 border-green-400'; + case 'transfer': return 'bg-blue-100 border-blue-400'; + case 'image': return 'bg-purple-100 border-purple-400'; + case 'url': return 'bg-yellow-100 border-yellow-400'; + case 'price': return 'bg-orange-100 border-orange-400'; + case 'genesis': return 'bg-gray-100 border-gray-400'; + default: return 'bg-gray-100 border-gray-400'; + } + }; + + const formatEthPrice = (price: string) => { + try { + // Check if price is already in ETH (has decimal point and is small) + const numPrice = parseFloat(price); + if (price.includes('.') || numPrice < 1000) { + // Already in ETH + return `${numPrice.toFixed(4)} ETH`; + } else { + // In Wei, convert to ETH + const eth = numPrice / 1e18; + return `${eth.toFixed(4)} ETH`; + } + } catch { + return price; + } + }; + + const calculateStats = () => { + const totalChanges = historicalImages?.length || 0; + const totalSales = actualPurchaseHistory?.length || 0; + const totalTransfers = (actualTransferHistory?.length || 0) + (backendWrappingHistory?.length || 0); + + let highestPrice = 0; + let lowestPrice = Infinity; + let totalVolume = 0; + + if (actualPurchaseHistory && actualPurchaseHistory.length > 0) { + actualPurchaseHistory.forEach(p => { + // Handle price whether it's in ETH or Wei + let price = parseFloat(p.price); + if (p.price.includes('.') || price < 1000) { + // Already in ETH, use as is + } else { + // In Wei, convert to ETH + price = price / 1e18; + } + if (price > highestPrice) highestPrice = price; + if (price < lowestPrice) lowestPrice = price; + totalVolume += price; + }); + } + + const firstOwner = actualPurchaseHistory?.[actualPurchaseHistory.length - 1]?.purchasedBy || tile.owner; + const holdDuration = tile.owner === firstOwner ? + formatDistanceToNow(new Date('2016-11-17')) : + 'Multiple Owners'; + + return { + totalChanges, + totalSales, + totalTransfers, + highestPrice: highestPrice === 0 ? 'N/A' : `${highestPrice.toFixed(2)} ETH`, + lowestPrice: lowestPrice === Infinity ? 'N/A' : `${lowestPrice.toFixed(2)} ETH`, + totalVolume: `${totalVolume.toFixed(2)} ETH`, + holdDuration, + uniqueOwners: new Set([...(actualPurchaseHistory?.map(p => p.purchasedBy) || []), tile.owner]).size + }; + }; + + const stats = calculateStats(); + + return ( +
+

Tile #{tile.id} History

+ + {/* Wrapped/Unwrapped Status */} +
+ {tile.wrapped ? ( +
+

+ 🎁 WRAPPED - ERC721 Token +

+
+ ) : ( +
+

+ 📦 UNWRAPPED - Original PixelMap Format +

+
+ )} +
+ + {isUsingMockData && ( +
+

+ 📊 Demo Mode: Showing sample history data. Real blockchain data will be available when backend APIs are connected. +

+
+ )} +
+
+ + + +
+
+ + {viewMode === 'timeline' && ( +
+
+
+ + {events.map((event, index) => ( +
+
+ {getEventIcon(event.type)} +
+ +
setExpandedEvent(expandedEvent === index ? null : index)}> + +
+
+

+ {event.type === 'purchase' && `Purchased for ${formatEthPrice(event.data.price)}`} + {event.type === 'transfer' && (event.data.transferType || 'Transferred')} + {event.type === 'image' && 'Image Updated'} + {event.type === 'url' && 'URL Changed'} + {event.type === 'price' && 'Price Updated'} + {event.type === 'genesis' && '⚡ GENESIS - Tile Created'} +

+ +

+ {formatDistanceToNow(event.timestamp, { addSuffix: true })} + {' • '} + Block #{event.blockNumber.toLocaleString()} +

+ + {expandedEvent === index && ( +
+ {event.type === 'purchase' && ( + <> +

From: {event.data.soldByEns || event.data.soldBy}

+

To: {event.data.purchasedByEns || event.data.purchasedBy}

+

Price: {formatEthPrice(event.data.price)}

+ + )} + + {event.type === 'transfer' && ( + <> +

From: {event.data.fromEns || event.data.from}

+

To: {event.data.toEns || event.data.to}

+ + )} + + {event.type === 'image' && event.data.image && ( + <> +

Updated by: {event.data.updatedByEns || event.data.updatedBy}

+
+
+

New Image:

+ +
+
+ + )} + + {event.type === 'url' && ( + <> +

Updated by: {event.data.updatedByEns || event.data.updatedBy}

+

New URL: {event.data.url}

+ + )} + + {event.type === 'price' && ( + <> +

Updated by: {event.data.updatedByEns || event.data.updatedBy}

+

New Price: {formatEthPrice(event.data.price)}

+ + )} + +

+ + → View on Etherscan + +

+
+ )} +
+ + {event.type === 'image' && ( +
+ +
+ )} +
+
+
+ ))} +
+
+ )} + + {viewMode === 'gallery' && ( + historicalImages && historicalImages.length > 0 ? ( + + ) : ( +
+

📷 No historical images available for this tile

+

Image history will appear here once the tile's image is updated

+
+ ) + )} + + {viewMode === 'stats' && ( +
+
+
+
+

Image Changes

+

{stats.totalChanges}

+
+ 🎨 +
+
+ +
+
+
+

Total Sales

+

{stats.totalSales}

+
+ 💰 +
+
+ +
+
+
+

Unique Owners

+

{stats.uniqueOwners}

+
+ 👥 +
+
+ +
+
+
+

Highest Sale

+

{stats.highestPrice}

+
+ 📈 +
+
+ +
+
+
+

Total Volume

+

{stats.totalVolume}

+
+ 💎 +
+
+ +
+
+
+

Hold Duration

+

{stats.holdDuration}

+
+ +
+
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/frontend/components/TileHistoryModal.tsx b/frontend/components/TileHistoryModal.tsx new file mode 100644 index 0000000..83aebd1 --- /dev/null +++ b/frontend/components/TileHistoryModal.tsx @@ -0,0 +1,122 @@ +import React, { useEffect, useState } from 'react'; +import { Dialog, Transition } from '@headlessui/react'; +import { Fragment } from 'react'; +import { XIcon } from '@heroicons/react/outline'; +import { PixelMapTile } from '@pixelmap/common/types/PixelMapTile'; +import { fetchSingleTile } from '../utils/api'; +import TileHistory from './TileHistory'; +import TileCard from './TileCard'; +import Loader from './Loader'; + +interface TileHistoryModalProps { + isOpen: boolean; + onClose: () => void; + tileId: number | null; + initialTile?: PixelMapTile; +} + +export default function TileHistoryModal({ isOpen, onClose, tileId, initialTile }: TileHistoryModalProps) { + const [tile, setTile] = useState(initialTile); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (tileId !== null && isOpen) { + setLoading(true); + fetchSingleTile(tileId.toString()).then((fetchedTile) => { + if (fetchedTile) { + setTile(fetchedTile); + } + setLoading(false); + }); + } + }, [tileId, isOpen]); + + return ( + + + +
+ + +
+
+ + +
+ + {tile ? `Tile #${tile.id} - Complete History` : 'Loading Tile...'} + + +
+ +
+ {loading ? ( +
+ +
+ ) : tile ? ( +
+
+ +
+ + +
+ ) : ( +
+ No tile data available +
+ )} +
+ + +
+
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/components/TileImageComparison.tsx b/frontend/components/TileImageComparison.tsx new file mode 100644 index 0000000..463c3d6 --- /dev/null +++ b/frontend/components/TileImageComparison.tsx @@ -0,0 +1,294 @@ +import React, { useState, useRef, useEffect } from 'react'; +import TileImage from './TileImage'; +import { PixelMapImage } from '@pixelmap/common/types/PixelMapImage'; +import { formatDistanceToNow } from 'date-fns'; + +interface TileImageComparisonProps { + images: PixelMapImage[]; + currentImage?: string; +} + +export default function TileImageComparison({ images, currentImage }: TileImageComparisonProps) { + // Handle empty or null images array + const safeImages = images || []; + + const [leftIndex, setLeftIndex] = useState(Math.max(0, safeImages.length - 1)); + const [rightIndex, setRightIndex] = useState(0); + const [sliderPosition, setSliderPosition] = useState(50); + const [isDragging, setIsDragging] = useState(false); + const [playbackSpeed, setPlaybackSpeed] = useState(1000); + const [isPlaying, setIsPlaying] = useState(false); + const [currentFrame, setCurrentFrame] = useState(0); + const containerRef = useRef(null); + const playbackInterval = useRef(); + + const allImages = currentImage ? + [{ image: currentImage, image_url: '', blockNumber: 0, date: new Date() }, ...safeImages] : + safeImages; + + useEffect(() => { + if (isPlaying && allImages.length > 0) { + playbackInterval.current = setInterval(() => { + setCurrentFrame(prev => (prev + 1) % allImages.length); + }, playbackSpeed); + } else { + if (playbackInterval.current) { + clearInterval(playbackInterval.current); + } + } + + return () => { + if (playbackInterval.current) { + clearInterval(playbackInterval.current); + } + }; + }, [isPlaying, playbackSpeed, allImages.length]); + + const handleMouseMove = (e: React.MouseEvent | React.TouchEvent) => { + if (!isDragging || !containerRef.current) return; + + const rect = containerRef.current.getBoundingClientRect(); + const x = 'touches' in e ? e.touches[0].clientX : e.clientX; + const position = ((x - rect.left) / rect.width) * 100; + setSliderPosition(Math.max(0, Math.min(100, position))); + }; + + const handleMouseDown = () => setIsDragging(true); + const handleMouseUp = () => setIsDragging(false); + + const getPixelDifferences = () => { + if (leftIndex === rightIndex) return 0; + + const leftImage = allImages[leftIndex].image; + const rightImage = allImages[rightIndex].image; + + if (!leftImage || !rightImage) return 0; + + let differences = 0; + const minLength = Math.min(leftImage.length, rightImage.length); + + for (let i = 0; i < minLength; i += 3) { + const leftPixel = leftImage.substr(i, 3); + const rightPixel = rightImage.substr(i, 3); + if (leftPixel !== rightPixel) differences++; + } + + return differences; + }; + + const pixelDifferences = getPixelDifferences(); + const percentChanged = ((pixelDifferences / 256) * 100).toFixed(1); + + // Handle case where there are no images + if (allImages.length === 0) { + return ( +
+

No images available

+
+ ); + } + + return ( +
+
+

🎮 Image Evolution Viewer

+ +
+ + + +
+ + {isPlaying ? ( +
+
+ +
+

Frame {currentFrame + 1} of {allImages.length}

+

+ {formatDistanceToNow(allImages[currentFrame].date, { addSuffix: true })} +

+

+ Block #{allImages[currentFrame].blockNumber.toLocaleString()} +

+
+
+ +
+
+
+
+ ) : ( + <> +
+
+ + +
+ Block #{allImages[leftIndex].blockNumber.toLocaleString()} +
+
+ +
+ + +
+ Block #{allImages[rightIndex].blockNumber.toLocaleString()} +
+
+
+ +
+
+ +
+ +
+ +
+ +
+
+ + + +
+
+
+ + {leftIndex !== rightIndex && ( +
+

📊 COMPARISON STATS

+
+
+

Pixels

+

{pixelDifferences}

+
+
+

Changed

+

{percentChanged}%

+
+
+

Time Gap

+

+ {formatDistanceToNow(allImages[leftIndex].date, { + addSuffix: false + })} +

+
+
+

Blocks

+

+ {Math.abs(allImages[rightIndex].blockNumber - allImages[leftIndex].blockNumber).toLocaleString()} +

+
+
+
+ )} + + )} +
+ +
+

🖼️ Image History Gallery

+
+ {allImages.map((img, idx) => ( +
{ + if (idx < allImages.length / 2) { + setLeftIndex(idx); + } else { + setRightIndex(idx); + } + }} + > + +
+

+ {idx === 0 && currentImage ? 'NOW' : formatDistanceToNow(img.date, { addSuffix: true })} +

+
+ {(idx === leftIndex || idx === rightIndex) && ( +
+ {idx === leftIndex ? 'L' : 'R'} +
+ )} +
+ ))} +
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/components/TilePopover.tsx b/frontend/components/TilePopover.tsx index e42168d..db51d20 100644 --- a/frontend/components/TilePopover.tsx +++ b/frontend/components/TilePopover.tsx @@ -2,11 +2,14 @@ import React, { useEffect, useState } from "react"; import { usePopperTooltip } from 'react-popper-tooltip'; import { shortenIfHex, formatPrice, openseaLink, cleanUrl } from "../utils/misc"; import TileCard from './TileCard'; +import TileHistoryModal from './TileHistoryModal'; import { XIcon } from '@heroicons/react/outline' +import { ClockIcon } from '@heroicons/react/outline' export default function TilePopover({tile, referenceElement}) { const [controlledVisible, setControlledVisible] = useState(false); + const [showHistoryModal, setShowHistoryModal] = useState(false); const { getTooltipProps, @@ -41,9 +44,29 @@ export default function TilePopover({tile, referenceElement}) { + +
+ +
)} + + setShowHistoryModal(false)} + tileId={tile?.id || null} + initialTile={tile} + /> ); } diff --git a/frontend/package.json b/frontend/package.json index 3f19ee5..1b3d19e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -27,6 +27,7 @@ "@web3-react/core": "^6.1.9", "@web3-react/injected-connector": "^6.0.7", "@web3-react/network-connector": "^6.1.9", + "date-fns": "^4.1.0", "detect-collisions": "^6.0.2", "next": "^13.5.6", "node-base91": "^0.3.4", diff --git a/frontend/pages/tile/[id].tsx b/frontend/pages/tile/[id].tsx index 8cdd4f3..7439117 100644 --- a/frontend/pages/tile/[id].tsx +++ b/frontend/pages/tile/[id].tsx @@ -6,6 +6,7 @@ import { fetchSingleTile } from "../../utils/api"; import Loader from "../../components/Loader"; import TileCard from "../../components/TileCard"; +import TileHistory from "../../components/TileHistory"; import Layout from "../../components/Layout"; import { PixelMapTile } from "@pixelmap/common/types/PixelMapTile"; @@ -52,6 +53,12 @@ const Tile = () => {
+
+ +
diff --git a/frontend/utils/mockTileHistory.ts b/frontend/utils/mockTileHistory.ts new file mode 100644 index 0000000..0e0366c --- /dev/null +++ b/frontend/utils/mockTileHistory.ts @@ -0,0 +1,231 @@ +// Mock data generator for development - replace with real API calls when backend is ready + +export interface PurchaseEvent { + id: number; + timestamp: Date; + blockNumber: number; + tx: string; + soldBy: string; + purchasedBy: string; + price: string; + soldByEns?: string; + purchasedByEns?: string; +} + +export interface TransferEvent { + id: number; + timestamp: Date; + blockNumber: number; + tx: string; + from: string; + to: string; + fromEns?: string; + toEns?: string; + wrapped?: boolean; +} + +export interface DataChangeEvent { + id: number; + timestamp: Date; + blockNumber: number; + tx: string; + image?: string; + url?: string; + price?: string; + updatedBy: string; + updatedByEns?: string; +} + +const sampleAddresses = [ + { address: '0x1234567890123456789012345678901234567890', ens: 'vitalik.eth' }, + { address: '0x2345678901234567890123456789012345678901', ens: 'punk6529.eth' }, + { address: '0x3456789012345678901234567890123456789012', ens: null }, + { address: '0x4567890123456789012345678901234567890123', ens: 'pixelcollector.eth' }, + { address: '0x5678901234567890123456789012345678901234', ens: null }, +]; + +const sampleImages = [ + 'FF0FF0FF0FF0FF0FF0FF0FF0FF0FF0FF0FF0FF0FF0FF0FF0FF0FF0FF0FF0FF0FF0FF0FF0FF0FF0FF0FF0FF0FF0FF0FF0FF0FF0FF0FF0FF0FF0FF0FF0FF0FF0FF0FF0FF0FF0FF0FF0FF0FF0FF0FF0FF0FF0FF0FF0FF0FF0FF0FF0FF0FF0FF0FF0FF0FF0FF0FF0FF0FF0FF0FF0FF0FF0FF0FF0FF0FF0FF0FF0FF0FF0FF0FF0FF0FF0FF0FF0FF0', + '00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F', + 'F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00F00', +]; + +// Seeded random number generator for consistent mock data +function seededRandom(seed: number) { + let x = Math.sin(seed) * 10000; + return x - Math.floor(x); +} + +export function generateMockTileHistory(tileId: number, currentOwner?: string) { + const purchases: PurchaseEvent[] = []; + const transfers: TransferEvent[] = []; + const changes: DataChangeEvent[] = []; + + // Use tileId as seed for consistent randomness per tile + let seed = tileId || 1; + const getRandom = () => { + seed += 1; + return seededRandom(seed); + }; + + // Start from genesis + let currentBlock = 2624959; // PixelMap genesis block + let currentDate = new Date('2016-11-17'); + + // Initial purchase (minting) + const firstBuyer = sampleAddresses[0]; + purchases.push({ + id: 1, + timestamp: new Date(currentDate), + blockNumber: currentBlock, + tx: `0x${Math.floor(getRandom() * 1e16).toString(16).padStart(64, '0')}`, + soldBy: '0x0000000000000000000000000000000000000000', + purchasedBy: firstBuyer.address, + price: '2000000000000000000', // 2 ETH in wei + purchasedByEns: firstBuyer.ens || undefined, + }); + + // Generate 3-8 random events over the years + const numEvents = 3 + Math.floor(getRandom() * 6); + let lastOwner = firstBuyer; + let isWrapped = false; + + for (let i = 0; i < numEvents; i++) { + // Advance time randomly (30-365 days) + const daysToAdd = 30 + Math.floor(getRandom() * 335); + currentDate = new Date(currentDate.getTime() + daysToAdd * 24 * 60 * 60 * 1000); + currentBlock += daysToAdd * 6400; // ~6400 blocks per day + + // Random event type + const eventType = getRandom(); + + if (eventType < 0.3 && !isWrapped) { + // Wrap event + transfers.push({ + id: transfers.length + 1, + timestamp: new Date(currentDate), + blockNumber: currentBlock, + tx: `0x${Math.floor(getRandom() * 1e16).toString(16).padStart(64, '0')}`, + from: lastOwner.address, + to: '0x2A46f3e77E2d9BFF52b83B7aDD41081Ab2c6Aaac', // Wrapper contract + fromEns: lastOwner.ens || undefined, + wrapped: true, + }); + isWrapped = true; + + } else if (eventType < 0.5) { + // Sale event + const newOwner = sampleAddresses[Math.floor(getRandom() * sampleAddresses.length)]; + const price = (2 + getRandom() * 8) * 1e18; // 2-10 ETH + + purchases.push({ + id: purchases.length + 1, + timestamp: new Date(currentDate), + blockNumber: currentBlock, + tx: `0x${Math.floor(getRandom() * 1e16).toString(16).padStart(64, '0')}`, + soldBy: lastOwner.address, + purchasedBy: newOwner.address, + price: price.toString(), + soldByEns: lastOwner.ens || undefined, + purchasedByEns: newOwner.ens || undefined, + }); + + // If wrapped, also create transfer event + if (isWrapped) { + transfers.push({ + id: transfers.length + 1, + timestamp: new Date(currentDate), + blockNumber: currentBlock, + tx: `0x${Math.floor(getRandom() * 1e16).toString(16).padStart(64, '0')}`, + from: lastOwner.address, + to: newOwner.address, + fromEns: lastOwner.ens || undefined, + toEns: newOwner.ens || undefined, + }); + } + + lastOwner = newOwner; + + } else if (eventType < 0.7) { + // Image change + const newImage = sampleImages[Math.floor(getRandom() * sampleImages.length)]; + changes.push({ + id: changes.length + 1, + timestamp: new Date(currentDate), + blockNumber: currentBlock, + tx: `0x${Math.floor(getRandom() * 1e16).toString(16).padStart(64, '0')}`, + image: newImage, + updatedBy: lastOwner.address, + updatedByEns: lastOwner.ens || undefined, + }); + + } else if (eventType < 0.85) { + // URL change + changes.push({ + id: changes.length + 1, + timestamp: new Date(currentDate), + blockNumber: currentBlock, + tx: `0x${Math.floor(getRandom() * 1e16).toString(16).padStart(64, '0')}`, + url: `https://example${Math.floor(getRandom() * 100)}.com`, + updatedBy: lastOwner.address, + updatedByEns: lastOwner.ens || undefined, + }); + + } else { + // Price change + const newPrice = (1 + getRandom() * 20) * 1e18; // 1-20 ETH + changes.push({ + id: changes.length + 1, + timestamp: new Date(currentDate), + blockNumber: currentBlock, + tx: `0x${Math.floor(getRandom() * 1e16).toString(16).padStart(64, '0')}`, + price: newPrice.toString(), + updatedBy: lastOwner.address, + updatedByEns: lastOwner.ens || undefined, + }); + } + } + + // Add recent unwrap if currently wrapped + if (isWrapped && getRandom() > 0.5) { + currentDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); // 30 days ago + currentBlock = 18500000 + Math.floor(getRandom() * 100000); + + transfers.push({ + id: transfers.length + 1, + timestamp: currentDate, + blockNumber: currentBlock, + tx: `0x${Math.floor(getRandom() * 1e16).toString(16).padStart(64, '0')}`, + from: '0x2A46f3e77E2d9BFF52b83B7aDD41081Ab2c6Aaac', // Wrapper contract + to: lastOwner.address, + toEns: lastOwner.ens || undefined, + wrapped: false, + }); + isWrapped = false; + } + + // Add a very recent image change + const recentDate = new Date(Date.now() - Math.floor(getRandom() * 7) * 24 * 60 * 60 * 1000); + changes.push({ + id: changes.length + 1, + timestamp: recentDate, + blockNumber: 18600000 + Math.floor(getRandom() * 10000), + tx: `0x${Math.floor(getRandom() * 1e16).toString(16).padStart(64, '0')}`, + image: sampleImages[Math.floor(getRandom() * sampleImages.length)], + updatedBy: lastOwner.address, + updatedByEns: lastOwner.ens || undefined, + }); + + return { + purchases, + transfers, + changes, + }; +} + +// Helper to check if we should use mock data (development mode) +export function shouldUseMockData(): boolean { + // Only use mock data if explicitly enabled + // We're disabling it by default now that backend data is available + return process.env.NEXT_PUBLIC_USE_MOCK_HISTORY === 'true'; +} \ No newline at end of file diff --git a/frontend/utils/tileHistory.ts b/frontend/utils/tileHistory.ts new file mode 100644 index 0000000..7a51dc1 --- /dev/null +++ b/frontend/utils/tileHistory.ts @@ -0,0 +1,258 @@ +export interface TileHistoryData { + purchaseHistory: PurchaseEvent[]; + transferHistory: TransferEvent[]; + dataHistory: DataChangeEvent[]; + priceHistory: PricePoint[]; + ownershipTimeline: OwnershipPeriod[]; +} + +export interface PurchaseEvent { + id: number; + timestamp: Date; + blockNumber: number; + tx: string; + soldBy: string; + purchasedBy: string; + price: string; + soldByEns?: string; + purchasedByEns?: string; + ethPriceUSD?: number; + salePriceUSD?: number; + gasUsed?: string; + gasPriceGwei?: number; +} + +export interface TransferEvent { + id: number; + timestamp: Date; + blockNumber: number; + tx: string; + from: string; + to: string; + fromEns?: string; + toEns?: string; + transferType: 'wrap' | 'unwrap' | 'transfer' | 'gift'; +} + +export interface DataChangeEvent { + id: number; + timestamp: Date; + blockNumber: number; + tx: string; + changeType: 'image' | 'url' | 'price' | 'multiple'; + previousImage?: string; + newImage?: string; + previousUrl?: string; + newUrl?: string; + previousPrice?: string; + newPrice?: string; + updatedBy: string; + updatedByEns?: string; +} + +export interface PricePoint { + timestamp: Date; + blockNumber: number; + price: string; + ethPriceUSD?: number; + priceUSD?: number; +} + +export interface OwnershipPeriod { + owner: string; + ownerEns?: string; + startDate: Date; + endDate?: Date; + startBlock: number; + endBlock?: number; + durationDays: number; + acquisitionPrice?: string; + salePrice?: string; + profitLoss?: string; + profitLossPercent?: number; + changesMade: number; +} + +export interface TileStats { + totalOwners: number; + totalSales: number; + totalTransfers: number; + totalImageChanges: number; + totalUrlChanges: number; + totalPriceChanges: number; + highestSalePrice: string; + lowestSalePrice: string; + averageSalePrice: string; + totalVolume: string; + averageHoldTime: number; + longestHoldTime: number; + shortestHoldTime: number; + mostActiveOwner: string; + firstOwner: string; + firstPurchaseDate: Date; + lastActivityDate: Date; + daysSinceLastActivity: number; + isOriginalOwner: boolean; + hasNeverBeenSold: boolean; + rarity: 'common' | 'uncommon' | 'rare' | 'legendary'; +} + +export async function fetchTileHistory(tileId: number): Promise { + try { + const response = await fetch(`/api/tile/${tileId}/history`); + if (!response.ok) { + console.error('Failed to fetch tile history'); + return null; + } + const data = await response.json(); + + return { + purchaseHistory: data.purchases || [], + transferHistory: data.transfers || [], + dataHistory: data.changes || [], + priceHistory: data.prices || [], + ownershipTimeline: data.ownership || [] + }; + } catch (error) { + console.error('Error fetching tile history:', error); + return null; + } +} + +export function calculateTileStats( + history: TileHistoryData, + currentOwner: string, + currentPrice: string +): TileStats { + const uniqueOwners = new Set(); + const ownerChanges: Map = new Map(); + + history.purchaseHistory.forEach(p => { + uniqueOwners.add(p.purchasedBy); + uniqueOwners.add(p.soldBy); + }); + + history.dataHistory.forEach(d => { + const count = ownerChanges.get(d.updatedBy) || 0; + ownerChanges.set(d.updatedBy, count + 1); + }); + + const mostActiveOwner = Array.from(ownerChanges.entries()) + .sort((a, b) => b[1] - a[1])[0]?.[0] || currentOwner; + + const prices = history.purchaseHistory.map(p => parseFloat(p.price)); + const highestPrice = Math.max(...prices, 0); + const lowestPrice = Math.min(...prices, Infinity); + const avgPrice = prices.length > 0 ? prices.reduce((a, b) => a + b, 0) / prices.length : 0; + const totalVolume = prices.reduce((a, b) => a + b, 0); + + const holdTimes = history.ownershipTimeline.map(o => o.durationDays); + const avgHoldTime = holdTimes.length > 0 ? holdTimes.reduce((a, b) => a + b, 0) / holdTimes.length : 0; + const longestHold = Math.max(...holdTimes, 0); + const shortestHold = Math.min(...holdTimes, Infinity); + + const imageChanges = history.dataHistory.filter(d => d.changeType === 'image').length; + const urlChanges = history.dataHistory.filter(d => d.changeType === 'url').length; + const priceChanges = history.dataHistory.filter(d => d.changeType === 'price').length; + + const firstOwner = history.purchaseHistory[history.purchaseHistory.length - 1]?.purchasedBy || currentOwner; + const firstPurchase = history.purchaseHistory[history.purchaseHistory.length - 1]?.timestamp || new Date('2016-11-17'); + + const lastActivity = new Date(Math.max( + ...history.purchaseHistory.map(p => p.timestamp.getTime()), + ...history.dataHistory.map(d => d.timestamp.getTime()), + 0 + )); + + const daysSinceLastActivity = Math.floor((Date.now() - lastActivity.getTime()) / (1000 * 60 * 60 * 24)); + + let rarity: 'common' | 'uncommon' | 'rare' | 'legendary' = 'common'; + if (history.purchaseHistory.length === 0) rarity = 'legendary'; + else if (history.purchaseHistory.length === 1) rarity = 'rare'; + else if (history.purchaseHistory.length <= 3) rarity = 'uncommon'; + + return { + totalOwners: uniqueOwners.size, + totalSales: history.purchaseHistory.length, + totalTransfers: history.transferHistory.length, + totalImageChanges: imageChanges, + totalUrlChanges: urlChanges, + totalPriceChanges: priceChanges, + highestSalePrice: highestPrice.toString(), + lowestSalePrice: lowestPrice === Infinity ? '0' : lowestPrice.toString(), + averageSalePrice: avgPrice.toString(), + totalVolume: totalVolume.toString(), + averageHoldTime: avgHoldTime, + longestHoldTime: longestHold, + shortestHoldTime: shortestHold === Infinity ? 0 : shortestHold, + mostActiveOwner, + firstOwner, + firstPurchaseDate: firstPurchase, + lastActivityDate: lastActivity, + daysSinceLastActivity, + isOriginalOwner: currentOwner === firstOwner, + hasNeverBeenSold: history.purchaseHistory.length === 0, + rarity + }; +} + +export function formatHistoricalPrice( + weiPrice: string, + ethPriceUSD?: number +): { eth: string; usd?: string } { + const eth = parseFloat(weiPrice) / 1e18; + const result: { eth: string; usd?: string } = { + eth: `${eth.toFixed(4)} ETH` + }; + + if (ethPriceUSD) { + result.usd = `$${(eth * ethPriceUSD).toFixed(2)}`; + } + + return result; +} + +export function groupEventsByDate(events: any[]): Map { + const grouped = new Map(); + + events.forEach(event => { + const date = new Date(event.timestamp).toLocaleDateString(); + const existing = grouped.get(date) || []; + existing.push(event); + grouped.set(date, existing); + }); + + return grouped; +} + +export function detectPatterns(history: TileHistoryData): string[] { + const patterns: string[] = []; + + if (history.purchaseHistory.length === 0) { + patterns.push('Never Sold - Original Owner'); + } + + if (history.purchaseHistory.length > 10) { + patterns.push('High Trading Activity'); + } + + const recentActivity = history.dataHistory.filter(d => { + const daysSince = (Date.now() - d.timestamp.getTime()) / (1000 * 60 * 60 * 24); + return daysSince < 30; + }); + + if (recentActivity.length > 5) { + patterns.push('Recently Active'); + } + + const priceIncreases = history.priceHistory.filter((p, i) => { + if (i === 0) return false; + return parseFloat(p.price) > parseFloat(history.priceHistory[i - 1].price); + }); + + if (priceIncreases.length > history.priceHistory.length / 2) { + patterns.push('Price Appreciation Trend'); + } + + return patterns; +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b89074c..0b50167 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,6 +59,9 @@ importers: '@web3-react/network-connector': specifier: ^6.1.9 version: 6.2.9 + date-fns: + specifier: ^4.1.0 + version: 4.1.0 detect-collisions: specifier: ^6.0.2 version: 6.8.1 @@ -1532,6 +1535,9 @@ packages: resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} engines: {node: '>= 0.4'} + date-fns@4.1.0: + resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} + debug@3.2.7: resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} peerDependencies: @@ -5205,6 +5211,8 @@ snapshots: es-errors: 1.3.0 is-data-view: 1.0.2 + date-fns@4.1.0: {} + debug@3.2.7: dependencies: ms: 2.1.3 @@ -5468,7 +5476,7 @@ snapshots: eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(eslint@8.57.1))(eslint@8.57.1) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1) eslint-plugin-react: 7.37.5(eslint@8.57.1) eslint-plugin-react-hooks: 5.0.0-canary-7118f5dd7-20230705(eslint@8.57.1) @@ -5498,7 +5506,7 @@ snapshots: tinyglobby: 0.2.14 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) transitivePeerDependencies: - supports-color @@ -5513,7 +5521,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9