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}
+
+ >
+ )}
+
+ {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 (
+
+
+
+ );
+}
\ 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 (
+
+ );
+ }
+
+ 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