fix: normalize transition timestamps across terminal
Some checks failed
Deploy Pounce (Auto) / deploy (push) Has been cancelled
Some checks failed
Deploy Pounce (Auto) / deploy (push) Has been cancelled
Convert timezone-aware datetimes to naive UTC before persisting (prevents Postgres 500s), add deletion_date migrations, and unify transition countdown + tracked-state across Drops, Watchlist, and Analyze panel.
This commit is contained in:
@ -1,6 +1,6 @@
|
|||||||
"""Domain management API (requires authentication)."""
|
"""Domain management API (requires authentication)."""
|
||||||
import json
|
import json
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime
|
||||||
from math import ceil
|
from math import ceil
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, status, Query
|
from fastapi import APIRouter, HTTPException, status, Query
|
||||||
@ -13,19 +13,11 @@ from app.models.subscription import TIER_CONFIG, SubscriptionTier
|
|||||||
from app.schemas.domain import DomainCreate, DomainResponse, DomainListResponse
|
from app.schemas.domain import DomainCreate, DomainResponse, DomainListResponse
|
||||||
from app.services.domain_checker import domain_checker
|
from app.services.domain_checker import domain_checker
|
||||||
from app.services.domain_health import get_health_checker, HealthStatus
|
from app.services.domain_health import get_health_checker, HealthStatus
|
||||||
|
from app.utils.datetime import to_naive_utc
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
def _to_naive_utc(dt: datetime | None) -> datetime | None:
|
|
||||||
"""Convert timezone-aware datetime to naive UTC datetime for PostgreSQL."""
|
|
||||||
if dt is None:
|
|
||||||
return None
|
|
||||||
if dt.tzinfo is not None:
|
|
||||||
# Convert to UTC and remove timezone info
|
|
||||||
return dt.astimezone(timezone.utc).replace(tzinfo=None)
|
|
||||||
return dt
|
|
||||||
|
|
||||||
def _safe_json_loads(value: str | None, default):
|
def _safe_json_loads(value: str | None, default):
|
||||||
if not value:
|
if not value:
|
||||||
return default
|
return default
|
||||||
@ -276,7 +268,7 @@ async def refresh_domain(
|
|||||||
domain.status = check_result.status
|
domain.status = check_result.status
|
||||||
domain.is_available = check_result.is_available
|
domain.is_available = check_result.is_available
|
||||||
domain.registrar = check_result.registrar
|
domain.registrar = check_result.registrar
|
||||||
domain.expiration_date = _to_naive_utc(check_result.expiration_date)
|
domain.expiration_date = to_naive_utc(check_result.expiration_date)
|
||||||
domain.last_checked = datetime.utcnow()
|
domain.last_checked = datetime.utcnow()
|
||||||
domain.last_check_method = check_result.check_method
|
domain.last_check_method = check_result.check_method
|
||||||
|
|
||||||
@ -354,7 +346,7 @@ async def refresh_all_domains(
|
|||||||
domain.status = check_result.status
|
domain.status = check_result.status
|
||||||
domain.is_available = check_result.is_available
|
domain.is_available = check_result.is_available
|
||||||
domain.registrar = check_result.registrar
|
domain.registrar = check_result.registrar
|
||||||
domain.expiration_date = _to_naive_utc(check_result.expiration_date)
|
domain.expiration_date = to_naive_utc(check_result.expiration_date)
|
||||||
domain.last_checked = datetime.utcnow()
|
domain.last_checked = datetime.utcnow()
|
||||||
domain.last_check_method = check_result.check_method
|
domain.last_check_method = check_result.check_method
|
||||||
|
|
||||||
|
|||||||
@ -17,6 +17,7 @@ from sqlalchemy import select, update
|
|||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.api.deps import get_current_user
|
from app.api.deps import get_current_user
|
||||||
from app.models.zone_file import DroppedDomain
|
from app.models.zone_file import DroppedDomain
|
||||||
|
from app.utils.datetime import to_iso_utc, to_naive_utc
|
||||||
from app.services.zone_file import (
|
from app.services.zone_file import (
|
||||||
ZoneFileService,
|
ZoneFileService,
|
||||||
get_dropped_domains,
|
get_dropped_domains,
|
||||||
@ -214,6 +215,8 @@ async def api_check_drop_status(
|
|||||||
# Check with dedicated drop status checker
|
# Check with dedicated drop status checker
|
||||||
status_result = await check_drop_status(full_domain)
|
status_result = await check_drop_status(full_domain)
|
||||||
|
|
||||||
|
persisted_deletion_date = to_naive_utc(status_result.deletion_date)
|
||||||
|
|
||||||
# Update the drop in DB
|
# Update the drop in DB
|
||||||
await db.execute(
|
await db.execute(
|
||||||
update(DroppedDomain)
|
update(DroppedDomain)
|
||||||
@ -222,7 +225,7 @@ async def api_check_drop_status(
|
|||||||
availability_status=status_result.status,
|
availability_status=status_result.status,
|
||||||
rdap_status=str(status_result.rdap_status) if status_result.rdap_status else None,
|
rdap_status=str(status_result.rdap_status) if status_result.rdap_status else None,
|
||||||
last_status_check=datetime.utcnow(),
|
last_status_check=datetime.utcnow(),
|
||||||
deletion_date=status_result.deletion_date,
|
deletion_date=persisted_deletion_date,
|
||||||
last_check_method=status_result.check_method,
|
last_check_method=status_result.check_method,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@ -236,8 +239,8 @@ async def api_check_drop_status(
|
|||||||
"can_register_now": status_result.can_register_now,
|
"can_register_now": status_result.can_register_now,
|
||||||
"should_track": status_result.should_monitor,
|
"should_track": status_result.should_monitor,
|
||||||
"message": status_result.message,
|
"message": status_result.message,
|
||||||
"deletion_date": status_result.deletion_date.isoformat() if status_result.deletion_date else None,
|
"deletion_date": to_iso_utc(persisted_deletion_date),
|
||||||
"status_checked_at": datetime.utcnow().isoformat(),
|
"status_checked_at": to_iso_utc(datetime.utcnow()),
|
||||||
"status_source": status_result.check_method,
|
"status_source": status_result.check_method,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -303,8 +306,10 @@ async def api_track_drop(
|
|||||||
name=full_domain,
|
name=full_domain,
|
||||||
status=domain_status,
|
status=domain_status,
|
||||||
is_available=drop.availability_status == 'available',
|
is_available=drop.availability_status == 'available',
|
||||||
deletion_date=drop.deletion_date, # Copy deletion date for countdown
|
deletion_date=to_naive_utc(drop.deletion_date), # Copy deletion date for countdown
|
||||||
notify_on_available=True, # Enable notification!
|
notify_on_available=True, # Enable notification!
|
||||||
|
last_checked=datetime.utcnow(),
|
||||||
|
last_check_method="zone_drop",
|
||||||
)
|
)
|
||||||
db.add(domain)
|
db.add(domain)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|||||||
@ -109,10 +109,15 @@ async def apply_migrations(conn: AsyncConnection) -> None:
|
|||||||
# 2b) domains indexes (watchlist list/sort/filter)
|
# 2b) domains indexes (watchlist list/sort/filter)
|
||||||
# ---------------------------------------------------------
|
# ---------------------------------------------------------
|
||||||
if await _table_exists(conn, "domains"):
|
if await _table_exists(conn, "domains"):
|
||||||
|
dt_type = "DATETIME" if dialect == "sqlite" else "TIMESTAMP"
|
||||||
|
|
||||||
# Canonical status metadata (optional)
|
# Canonical status metadata (optional)
|
||||||
if not await _has_column(conn, "domains", "last_check_method"):
|
if not await _has_column(conn, "domains", "last_check_method"):
|
||||||
logger.info("DB migrations: adding column domains.last_check_method")
|
logger.info("DB migrations: adding column domains.last_check_method")
|
||||||
await conn.execute(text("ALTER TABLE domains ADD COLUMN last_check_method VARCHAR(30)"))
|
await conn.execute(text("ALTER TABLE domains ADD COLUMN last_check_method VARCHAR(30)"))
|
||||||
|
if not await _has_column(conn, "domains", "deletion_date"):
|
||||||
|
logger.info("DB migrations: adding column domains.deletion_date")
|
||||||
|
await conn.execute(text(f"ALTER TABLE domains ADD COLUMN deletion_date {dt_type}"))
|
||||||
|
|
||||||
await conn.execute(text("CREATE INDEX IF NOT EXISTS ix_domains_user_id ON domains(user_id)"))
|
await conn.execute(text("CREATE INDEX IF NOT EXISTS ix_domains_user_id ON domains(user_id)"))
|
||||||
await conn.execute(text("CREATE INDEX IF NOT EXISTS ix_domains_status ON domains(status)"))
|
await conn.execute(text("CREATE INDEX IF NOT EXISTS ix_domains_status ON domains(status)"))
|
||||||
@ -135,9 +140,14 @@ async def apply_migrations(conn: AsyncConnection) -> None:
|
|||||||
# 2d) dropped_domains indexes + de-duplication
|
# 2d) dropped_domains indexes + de-duplication
|
||||||
# ---------------------------------------------------------
|
# ---------------------------------------------------------
|
||||||
if await _table_exists(conn, "dropped_domains"):
|
if await _table_exists(conn, "dropped_domains"):
|
||||||
|
dt_type = "DATETIME" if dialect == "sqlite" else "TIMESTAMP"
|
||||||
|
|
||||||
if not await _has_column(conn, "dropped_domains", "last_check_method"):
|
if not await _has_column(conn, "dropped_domains", "last_check_method"):
|
||||||
logger.info("DB migrations: adding column dropped_domains.last_check_method")
|
logger.info("DB migrations: adding column dropped_domains.last_check_method")
|
||||||
await conn.execute(text("ALTER TABLE dropped_domains ADD COLUMN last_check_method VARCHAR(30)"))
|
await conn.execute(text("ALTER TABLE dropped_domains ADD COLUMN last_check_method VARCHAR(30)"))
|
||||||
|
if not await _has_column(conn, "dropped_domains", "deletion_date"):
|
||||||
|
logger.info("DB migrations: adding column dropped_domains.deletion_date")
|
||||||
|
await conn.execute(text(f"ALTER TABLE dropped_domains ADD COLUMN deletion_date {dt_type}"))
|
||||||
|
|
||||||
# Query patterns:
|
# Query patterns:
|
||||||
# - by time window (dropped_date) + optional tld + keyword
|
# - by time window (dropped_date) + optional tld + keyword
|
||||||
|
|||||||
@ -792,24 +792,28 @@ async def check_all_domains(db):
|
|||||||
taken = 0
|
taken = 0
|
||||||
errors = 0
|
errors = 0
|
||||||
|
|
||||||
|
from app.utils.datetime import to_naive_utc
|
||||||
|
|
||||||
for domain_obj in domains:
|
for domain_obj in domains:
|
||||||
try:
|
try:
|
||||||
check_result = await domain_checker.check_domain(domain_obj.domain)
|
check_result = await domain_checker.check_domain(domain_obj.name)
|
||||||
|
|
||||||
# Update domain status
|
# Update domain status
|
||||||
domain_obj.status = check_result.status.value
|
domain_obj.status = check_result.status
|
||||||
domain_obj.is_available = check_result.is_available
|
domain_obj.is_available = check_result.is_available
|
||||||
domain_obj.last_checked = datetime.utcnow()
|
domain_obj.last_checked = datetime.utcnow()
|
||||||
|
domain_obj.last_check_method = check_result.check_method
|
||||||
|
|
||||||
if check_result.expiration_date:
|
if check_result.expiration_date:
|
||||||
domain_obj.expiration_date = check_result.expiration_date
|
domain_obj.expiration_date = to_naive_utc(check_result.expiration_date)
|
||||||
|
|
||||||
# Create check record
|
# Create check record
|
||||||
domain_check = DomainCheck(
|
domain_check = DomainCheck(
|
||||||
domain_id=domain_obj.id,
|
domain_id=domain_obj.id,
|
||||||
status=check_result.status.value,
|
status=check_result.status,
|
||||||
is_available=check_result.is_available,
|
is_available=check_result.is_available,
|
||||||
check_method=check_result.check_method,
|
response_data=str(check_result.to_dict()),
|
||||||
|
checked_at=datetime.utcnow(),
|
||||||
)
|
)
|
||||||
db.add(domain_check)
|
db.add(domain_check)
|
||||||
|
|
||||||
@ -819,10 +823,10 @@ async def check_all_domains(db):
|
|||||||
else:
|
else:
|
||||||
taken += 1
|
taken += 1
|
||||||
|
|
||||||
logger.info(f"Checked {domain_obj.domain}: {check_result.status.value}")
|
logger.info(f"Checked {domain_obj.name}: {check_result.status.value}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error checking {domain_obj.domain}: {e}")
|
logger.error(f"Error checking {domain_obj.name}: {e}")
|
||||||
errors += 1
|
errors += 1
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|||||||
@ -21,6 +21,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.models.zone_file import ZoneSnapshot, DroppedDomain
|
from app.models.zone_file import ZoneSnapshot, DroppedDomain
|
||||||
|
from app.utils.datetime import to_iso_utc, to_naive_utc
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -392,17 +393,17 @@ async def get_dropped_domains(
|
|||||||
"id": item.id,
|
"id": item.id,
|
||||||
"domain": item.domain,
|
"domain": item.domain,
|
||||||
"tld": item.tld,
|
"tld": item.tld,
|
||||||
"dropped_date": item.dropped_date.isoformat(),
|
"dropped_date": to_iso_utc(item.dropped_date),
|
||||||
"length": item.length,
|
"length": item.length,
|
||||||
"is_numeric": item.is_numeric,
|
"is_numeric": item.is_numeric,
|
||||||
"has_hyphen": item.has_hyphen,
|
"has_hyphen": item.has_hyphen,
|
||||||
# Canonical status fields (keep old key for backwards compat)
|
# Canonical status fields (keep old key for backwards compat)
|
||||||
"availability_status": getattr(item, "availability_status", "unknown") or "unknown",
|
"availability_status": getattr(item, "availability_status", "unknown") or "unknown",
|
||||||
"status": getattr(item, "availability_status", "unknown") or "unknown",
|
"status": getattr(item, "availability_status", "unknown") or "unknown",
|
||||||
"last_status_check": item.last_status_check.isoformat() if getattr(item, "last_status_check", None) else None,
|
"last_status_check": to_iso_utc(item.last_status_check),
|
||||||
"status_checked_at": item.last_status_check.isoformat() if getattr(item, "last_status_check", None) else None,
|
"status_checked_at": to_iso_utc(item.last_status_check),
|
||||||
"status_source": getattr(item, "last_check_method", None),
|
"status_source": getattr(item, "last_check_method", None),
|
||||||
"deletion_date": item.deletion_date.isoformat() if getattr(item, "deletion_date", None) else None,
|
"deletion_date": to_iso_utc(item.deletion_date),
|
||||||
}
|
}
|
||||||
for item in items
|
for item in items
|
||||||
]
|
]
|
||||||
@ -581,7 +582,7 @@ async def verify_drops_availability(
|
|||||||
"availability_status": status_result.status,
|
"availability_status": status_result.status,
|
||||||
"rdap_status": str(status_result.rdap_status)[:255] if status_result.rdap_status else None,
|
"rdap_status": str(status_result.rdap_status)[:255] if status_result.rdap_status else None,
|
||||||
"last_status_check": now,
|
"last_status_check": now,
|
||||||
"deletion_date": status_result.deletion_date,
|
"deletion_date": to_naive_utc(status_result.deletion_date),
|
||||||
"last_check_method": status_result.check_method,
|
"last_check_method": status_result.check_method,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
2
backend/app/utils/__init__.py
Normal file
2
backend/app/utils/__init__.py
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
"""Shared utility helpers (small, dependency-free)."""
|
||||||
|
|
||||||
34
backend/app/utils/datetime.py
Normal file
34
backend/app/utils/datetime.py
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
|
||||||
|
def to_naive_utc(dt: datetime | None) -> datetime | None:
|
||||||
|
"""
|
||||||
|
Convert a timezone-aware datetime to naive UTC (tzinfo removed).
|
||||||
|
|
||||||
|
Our DB columns are DateTime without timezone. Persisting timezone-aware
|
||||||
|
datetimes can cause runtime errors (especially on Postgres).
|
||||||
|
"""
|
||||||
|
if dt is None:
|
||||||
|
return None
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
return dt
|
||||||
|
return dt.astimezone(timezone.utc).replace(tzinfo=None)
|
||||||
|
|
||||||
|
|
||||||
|
def to_iso_utc(dt: datetime | None) -> str | None:
|
||||||
|
"""
|
||||||
|
Serialize a datetime as an ISO-8601 UTC string.
|
||||||
|
|
||||||
|
- If dt is timezone-aware: convert to UTC and use "Z".
|
||||||
|
- If dt is naive: treat it as UTC and use "Z".
|
||||||
|
"""
|
||||||
|
if dt is None:
|
||||||
|
return None
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
|
else:
|
||||||
|
dt = dt.astimezone(timezone.utc)
|
||||||
|
return dt.isoformat().replace("+00:00", "Z")
|
||||||
|
|
||||||
@ -42,6 +42,7 @@ import {
|
|||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
|
import { daysUntil, formatCountdown } from '@/lib/time'
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// ADD MODAL COMPONENT (like Portfolio)
|
// ADD MODAL COMPONENT (like Portfolio)
|
||||||
@ -119,14 +120,6 @@ function AddModal({
|
|||||||
// HELPERS
|
// HELPERS
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
function getDaysUntilExpiry(expirationDate: string | null): number | null {
|
|
||||||
if (!expirationDate) return null
|
|
||||||
const expDate = new Date(expirationDate)
|
|
||||||
const now = new Date()
|
|
||||||
const diffTime = expDate.getTime() - now.getTime()
|
|
||||||
return Math.ceil(diffTime / (1000 * 60 * 60 * 24))
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatExpiryDate(expirationDate: string | null): string {
|
function formatExpiryDate(expirationDate: string | null): string {
|
||||||
if (!expirationDate) return '—'
|
if (!expirationDate) return '—'
|
||||||
return new Date(expirationDate).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
return new Date(expirationDate).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||||
@ -161,7 +154,7 @@ export default function WatchlistPage() {
|
|||||||
}
|
}
|
||||||
openAnalyzePanel(domainData.name, {
|
openAnalyzePanel(domainData.name, {
|
||||||
status: statusMap[domainData.status] || (domainData.is_available ? 'available' : 'taken'),
|
status: statusMap[domainData.status] || (domainData.is_available ? 'available' : 'taken'),
|
||||||
deletion_date: domainData.deletion_date || domainData.expiration_date,
|
deletion_date: domainData.deletion_date || null,
|
||||||
is_drop: false,
|
is_drop: false,
|
||||||
})
|
})
|
||||||
}, [openAnalyzePanel])
|
}, [openAnalyzePanel])
|
||||||
@ -201,7 +194,7 @@ export default function WatchlistPage() {
|
|||||||
const available = domains?.filter(d => d.is_available) || []
|
const available = domains?.filter(d => d.is_available) || []
|
||||||
const expiringSoon = domains?.filter(d => {
|
const expiringSoon = domains?.filter(d => {
|
||||||
if (d.is_available || !d.expiration_date) return false
|
if (d.is_available || !d.expiration_date) return false
|
||||||
const days = getDaysUntilExpiry(d.expiration_date)
|
const days = daysUntil(d.expiration_date)
|
||||||
return days !== null && days <= 30 && days > 0
|
return days !== null && days <= 30 && days > 0
|
||||||
}) || []
|
}) || []
|
||||||
return { total: domains?.length || 0, available: available.length, expiring: expiringSoon.length }
|
return { total: domains?.length || 0, available: available.length, expiring: expiringSoon.length }
|
||||||
@ -213,7 +206,7 @@ export default function WatchlistPage() {
|
|||||||
let filtered = domains.filter(d => {
|
let filtered = domains.filter(d => {
|
||||||
if (filter === 'available') return d.is_available
|
if (filter === 'available') return d.is_available
|
||||||
if (filter === 'expiring') {
|
if (filter === 'expiring') {
|
||||||
const days = getDaysUntilExpiry(d.expiration_date)
|
const days = daysUntil(d.expiration_date)
|
||||||
return days !== null && days <= 30 && days > 0
|
return days !== null && days <= 30 && days > 0
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
@ -613,13 +606,14 @@ export default function WatchlistPage() {
|
|||||||
const health = healthReports[domain.id]
|
const health = healthReports[domain.id]
|
||||||
const healthStatus = health?.status || 'unknown'
|
const healthStatus = health?.status || 'unknown'
|
||||||
const config = healthConfig[healthStatus]
|
const config = healthConfig[healthStatus]
|
||||||
const days = getDaysUntilExpiry(domain.expiration_date)
|
const days = daysUntil(domain.expiration_date)
|
||||||
|
|
||||||
// Domain status display config (consistent with DropsTab)
|
// Domain status display config (consistent with DropsTab)
|
||||||
const domainStatus = domain.status || (domain.is_available ? 'available' : 'taken')
|
const domainStatus = domain.status || (domain.is_available ? 'available' : 'taken')
|
||||||
|
const transitionCountdown = domainStatus === 'dropping_soon' ? formatCountdown(domain.deletion_date ?? null) : null
|
||||||
const statusConfig = {
|
const statusConfig = {
|
||||||
available: { label: 'AVAIL', color: 'text-accent', bg: 'bg-accent/5 border-accent/20' },
|
available: { label: 'AVAIL', color: 'text-accent', bg: 'bg-accent/5 border-accent/20' },
|
||||||
dropping_soon: { label: 'TRANSITION', color: 'text-amber-400', bg: 'bg-amber-400/5 border-amber-400/20' },
|
dropping_soon: { label: transitionCountdown ? `TRANSITION • ${transitionCountdown}` : 'TRANSITION', color: 'text-amber-400', bg: 'bg-amber-400/5 border-amber-400/20' },
|
||||||
taken: { label: 'TAKEN', color: 'text-white/40', bg: 'bg-white/5 border-white/10' },
|
taken: { label: 'TAKEN', color: 'text-white/40', bg: 'bg-white/5 border-white/10' },
|
||||||
error: { label: 'ERROR', color: 'text-rose-400', bg: 'bg-rose-400/5 border-rose-400/20' },
|
error: { label: 'ERROR', color: 'text-rose-400', bg: 'bg-rose-400/5 border-rose-400/20' },
|
||||||
unknown: { label: 'CHECK', color: 'text-white/30', bg: 'bg-white/5 border-white/5' },
|
unknown: { label: 'CHECK', color: 'text-white/30', bg: 'bg-white/5 border-white/5' },
|
||||||
@ -642,9 +636,11 @@ export default function WatchlistPage() {
|
|||||||
</button>
|
</button>
|
||||||
<div className="flex items-center gap-2 mt-2 text-[10px] font-mono text-white/30 uppercase tracking-wider">
|
<div className="flex items-center gap-2 mt-2 text-[10px] font-mono text-white/30 uppercase tracking-wider">
|
||||||
<span className="bg-white/5 px-2 py-0.5 border border-white/5">{domain.registrar || 'Unknown'}</span>
|
<span className="bg-white/5 px-2 py-0.5 border border-white/5">{domain.registrar || 'Unknown'}</span>
|
||||||
{days !== null && days <= 30 && days > 0 && (
|
{domainStatus === 'dropping_soon' && transitionCountdown ? (
|
||||||
|
<span className="text-amber-400 font-bold">drops in {transitionCountdown}</span>
|
||||||
|
) : days !== null && days <= 30 && days > 0 ? (
|
||||||
<span className="text-orange-400 font-bold">{days}d left</span>
|
<span className="text-orange-400 font-bold">{days}d left</span>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -763,7 +759,9 @@ export default function WatchlistPage() {
|
|||||||
|
|
||||||
{/* Expires */}
|
{/* Expires */}
|
||||||
<div className="text-center text-sm font-mono">
|
<div className="text-center text-sm font-mono">
|
||||||
{days !== null && days <= 30 && days > 0 ? (
|
{domainStatus === 'dropping_soon' && transitionCountdown ? (
|
||||||
|
<span className="text-amber-400 font-bold">{transitionCountdown}</span>
|
||||||
|
) : days !== null && days <= 30 && days > 0 ? (
|
||||||
<span className="text-orange-400 font-bold">{days}d</span>
|
<span className="text-orange-400 font-bold">{days}d</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-white/50">{formatExpiryDate(domain.expiration_date)}</span>
|
<span className="text-white/50">{formatExpiryDate(domain.expiration_date)}</span>
|
||||||
|
|||||||
@ -22,6 +22,7 @@ import {
|
|||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { useAnalyzePanelStore } from '@/lib/analyze-store'
|
import { useAnalyzePanelStore } from '@/lib/analyze-store'
|
||||||
|
import { formatCountdown, parseIsoAsUtc } from '@/lib/time'
|
||||||
import type { AnalyzeResponse, AnalyzeSection, AnalyzeItem } from '@/components/analyze/types'
|
import type { AnalyzeResponse, AnalyzeSection, AnalyzeItem } from '@/components/analyze/types'
|
||||||
import { VisionSection } from '@/components/analyze/VisionSection'
|
import { VisionSection } from '@/components/analyze/VisionSection'
|
||||||
|
|
||||||
@ -278,6 +279,7 @@ export function AnalyzePanel() {
|
|||||||
}, [data])
|
}, [data])
|
||||||
|
|
||||||
const headerDomain = data?.domain || domain || ''
|
const headerDomain = data?.domain || domain || ''
|
||||||
|
const dropCountdown = useMemo(() => formatCountdown(dropStatus?.deletion_date ?? null), [dropStatus])
|
||||||
|
|
||||||
if (!isOpen) return null
|
if (!isOpen) return null
|
||||||
|
|
||||||
@ -410,7 +412,9 @@ export function AnalyzePanel() {
|
|||||||
</div>
|
</div>
|
||||||
{dropStatus.status === 'dropping_soon' && dropStatus.deletion_date && (
|
{dropStatus.status === 'dropping_soon' && dropStatus.deletion_date && (
|
||||||
<div className="text-xs font-mono text-amber-400/70">
|
<div className="text-xs font-mono text-amber-400/70">
|
||||||
Drops: {new Date(dropStatus.deletion_date).toLocaleDateString()}
|
{dropCountdown
|
||||||
|
? `Drops in ${dropCountdown} • ${parseIsoAsUtc(dropStatus.deletion_date).toLocaleDateString()}`
|
||||||
|
: `Drops: ${parseIsoAsUtc(dropStatus.deletion_date).toLocaleDateString()}`}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { useAnalyzePanelStore } from '@/lib/analyze-store'
|
import { useAnalyzePanelStore } from '@/lib/analyze-store'
|
||||||
|
import { formatCountdown } from '@/lib/time'
|
||||||
import {
|
import {
|
||||||
Globe,
|
Globe,
|
||||||
Loader2,
|
Loader2,
|
||||||
@ -117,7 +118,23 @@ export function DropsTab({ showToast }: DropsTabProps) {
|
|||||||
// Status Checking
|
// Status Checking
|
||||||
const [checkingStatus, setCheckingStatus] = useState<number | null>(null)
|
const [checkingStatus, setCheckingStatus] = useState<number | null>(null)
|
||||||
const [trackingDrop, setTrackingDrop] = useState<number | null>(null)
|
const [trackingDrop, setTrackingDrop] = useState<number | null>(null)
|
||||||
const [trackedDrops, setTrackedDrops] = useState<Set<number>>(new Set())
|
const [trackedDomains, setTrackedDomains] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
|
// Prefetch Watchlist domains (so Track button shows correct state)
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
const loadTracked = async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.getDomains(1, 200)
|
||||||
|
if (cancelled) return
|
||||||
|
setTrackedDomains(new Set(res.domains.map(d => d.name.toLowerCase())))
|
||||||
|
} catch {
|
||||||
|
// If unauthenticated, Drops list still renders; "Track" will prompt on action.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadTracked()
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Load Stats
|
// Load Stats
|
||||||
const loadStats = useCallback(async () => {
|
const loadStats = useCallback(async () => {
|
||||||
@ -206,29 +223,10 @@ export function DropsTab({ showToast }: DropsTabProps) {
|
|||||||
}
|
}
|
||||||
}, [checkingStatus, showToast])
|
}, [checkingStatus, showToast])
|
||||||
|
|
||||||
// Format countdown from deletion date
|
|
||||||
const formatCountdown = useCallback((deletionDate: string | null): string | null => {
|
|
||||||
if (!deletionDate) return null
|
|
||||||
|
|
||||||
const del = new Date(deletionDate)
|
|
||||||
const now = new Date()
|
|
||||||
const diff = del.getTime() - now.getTime()
|
|
||||||
|
|
||||||
if (diff <= 0) return 'Now'
|
|
||||||
|
|
||||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
|
|
||||||
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60))
|
|
||||||
const mins = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60))
|
|
||||||
|
|
||||||
if (days > 0) return `${days}d ${hours}h`
|
|
||||||
if (hours > 0) return `${hours}h ${mins}m`
|
|
||||||
return `${mins}m`
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Track a drop (add to watchlist)
|
// Track a drop (add to watchlist)
|
||||||
const trackDrop = useCallback(async (dropId: number, domain: string) => {
|
const trackDrop = useCallback(async (dropId: number, domain: string) => {
|
||||||
if (trackingDrop) return
|
if (trackingDrop) return
|
||||||
if (trackedDrops.has(dropId)) {
|
if (trackedDomains.has(domain.toLowerCase())) {
|
||||||
showToast(`${domain} is already in your Watchlist`, 'info')
|
showToast(`${domain} is already in your Watchlist`, 'info')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -237,7 +235,11 @@ export function DropsTab({ showToast }: DropsTabProps) {
|
|||||||
try {
|
try {
|
||||||
const result = await api.trackDrop(dropId)
|
const result = await api.trackDrop(dropId)
|
||||||
// Mark as tracked regardless of status
|
// Mark as tracked regardless of status
|
||||||
setTrackedDrops(prev => new Set(prev).add(dropId))
|
setTrackedDomains(prev => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
next.add(domain.toLowerCase())
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
if (result.status === 'already_tracking') {
|
if (result.status === 'already_tracking') {
|
||||||
showToast(`${domain} is already in your Watchlist`, 'info')
|
showToast(`${domain} is already in your Watchlist`, 'info')
|
||||||
@ -249,10 +251,10 @@ export function DropsTab({ showToast }: DropsTabProps) {
|
|||||||
} finally {
|
} finally {
|
||||||
setTrackingDrop(null)
|
setTrackingDrop(null)
|
||||||
}
|
}
|
||||||
}, [trackingDrop, trackedDrops, showToast])
|
}, [trackingDrop, trackedDomains, showToast])
|
||||||
|
|
||||||
// Check if a drop is already tracked
|
// Check if a drop is already tracked (domain-based, persists across sessions)
|
||||||
const isTracked = useCallback((dropId: number) => trackedDrops.has(dropId), [trackedDrops])
|
const isTracked = useCallback((fullDomain: string) => trackedDomains.has(fullDomain.toLowerCase()), [trackedDomains])
|
||||||
|
|
||||||
// Filtered and Sorted Items
|
// Filtered and Sorted Items
|
||||||
const sortedItems = useMemo(() => {
|
const sortedItems = useMemo(() => {
|
||||||
@ -582,7 +584,7 @@ export function DropsTab({ showToast }: DropsTabProps) {
|
|||||||
const fullDomain = `${item.domain}.${item.tld}`
|
const fullDomain = `${item.domain}.${item.tld}`
|
||||||
const isChecking = checkingStatus === item.id
|
const isChecking = checkingStatus === item.id
|
||||||
const isTrackingThis = trackingDrop === item.id
|
const isTrackingThis = trackingDrop === item.id
|
||||||
const alreadyTracked = isTracked(item.id)
|
const alreadyTracked = isTracked(fullDomain)
|
||||||
const status = item.availability_status || 'unknown'
|
const status = item.availability_status || 'unknown'
|
||||||
|
|
||||||
// Status display config with better labels
|
// Status display config with better labels
|
||||||
|
|||||||
@ -486,9 +486,12 @@ class ApiClient {
|
|||||||
is_available: boolean
|
is_available: boolean
|
||||||
registrar: string | null
|
registrar: string | null
|
||||||
expiration_date: string | null
|
expiration_date: string | null
|
||||||
|
deletion_date?: string | null
|
||||||
notify_on_available: boolean
|
notify_on_available: boolean
|
||||||
created_at: string
|
created_at: string
|
||||||
last_checked: string | null
|
last_checked: string | null
|
||||||
|
status_checked_at?: string | null
|
||||||
|
status_source?: string | null
|
||||||
}>
|
}>
|
||||||
total: number
|
total: number
|
||||||
page: number
|
page: number
|
||||||
|
|||||||
@ -19,9 +19,12 @@ interface Domain {
|
|||||||
is_available: boolean
|
is_available: boolean
|
||||||
registrar: string | null
|
registrar: string | null
|
||||||
expiration_date: string | null
|
expiration_date: string | null
|
||||||
|
deletion_date?: string | null
|
||||||
notify_on_available: boolean
|
notify_on_available: boolean
|
||||||
created_at: string
|
created_at: string
|
||||||
last_checked: string | null
|
last_checked: string | null
|
||||||
|
status_checked_at?: string | null
|
||||||
|
status_source?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Subscription {
|
interface Subscription {
|
||||||
|
|||||||
35
frontend/src/lib/time.ts
Normal file
35
frontend/src/lib/time.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
export function parseIsoAsUtc(value: string): Date {
|
||||||
|
// If the string already contains timezone info, keep it.
|
||||||
|
// Otherwise treat it as UTC (backend persists naive UTC timestamps).
|
||||||
|
const hasTimezone = /([zZ]|[+-]\d{2}:\d{2})$/.test(value)
|
||||||
|
return new Date(hasTimezone ? value : `${value}Z`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatCountdown(iso: string | null): string | null {
|
||||||
|
if (!iso) return null
|
||||||
|
|
||||||
|
const target = parseIsoAsUtc(iso)
|
||||||
|
const now = new Date()
|
||||||
|
const diff = target.getTime() - now.getTime()
|
||||||
|
|
||||||
|
if (Number.isNaN(diff)) return null
|
||||||
|
if (diff <= 0) return 'Now'
|
||||||
|
|
||||||
|
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
|
||||||
|
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60))
|
||||||
|
const mins = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60))
|
||||||
|
|
||||||
|
if (days > 0) return `${days}d ${hours}h`
|
||||||
|
if (hours > 0) return `${hours}h ${mins}m`
|
||||||
|
return `${mins}m`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function daysUntil(iso: string | null): number | null {
|
||||||
|
if (!iso) return null
|
||||||
|
const target = parseIsoAsUtc(iso)
|
||||||
|
const now = new Date()
|
||||||
|
const diff = target.getTime() - now.getTime()
|
||||||
|
if (Number.isNaN(diff)) return null
|
||||||
|
return Math.ceil(diff / (1000 * 60 * 60 * 24))
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user