fix: normalize transition timestamps across terminal
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:
2025-12-21 18:14:25 +01:00
parent 719f4c0724
commit 7c08e90a56
13 changed files with 164 additions and 71 deletions

View File

@ -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

View File

@ -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()

View File

@ -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

View File

@ -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()

View File

@ -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,
} }
) )

View File

@ -0,0 +1,2 @@
"""Shared utility helpers (small, dependency-free)."""

View 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")

View File

@ -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>

View File

@ -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>

View File

@ -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

View File

@ -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

View File

@ -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
View 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))
}