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)."""
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime
|
||||
from math import ceil
|
||||
|
||||
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.services.domain_checker import domain_checker
|
||||
from app.services.domain_health import get_health_checker, HealthStatus
|
||||
from app.utils.datetime import to_naive_utc
|
||||
|
||||
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):
|
||||
if not value:
|
||||
return default
|
||||
@ -276,7 +268,7 @@ async def refresh_domain(
|
||||
domain.status = check_result.status
|
||||
domain.is_available = check_result.is_available
|
||||
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_check_method = check_result.check_method
|
||||
|
||||
@ -354,7 +346,7 @@ async def refresh_all_domains(
|
||||
domain.status = check_result.status
|
||||
domain.is_available = check_result.is_available
|
||||
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_check_method = check_result.check_method
|
||||
|
||||
|
||||
@ -17,6 +17,7 @@ from sqlalchemy import select, update
|
||||
from app.database import get_db
|
||||
from app.api.deps import get_current_user
|
||||
from app.models.zone_file import DroppedDomain
|
||||
from app.utils.datetime import to_iso_utc, to_naive_utc
|
||||
from app.services.zone_file import (
|
||||
ZoneFileService,
|
||||
get_dropped_domains,
|
||||
@ -214,6 +215,8 @@ async def api_check_drop_status(
|
||||
# Check with dedicated drop status checker
|
||||
status_result = await check_drop_status(full_domain)
|
||||
|
||||
persisted_deletion_date = to_naive_utc(status_result.deletion_date)
|
||||
|
||||
# Update the drop in DB
|
||||
await db.execute(
|
||||
update(DroppedDomain)
|
||||
@ -222,7 +225,7 @@ async def api_check_drop_status(
|
||||
availability_status=status_result.status,
|
||||
rdap_status=str(status_result.rdap_status) if status_result.rdap_status else None,
|
||||
last_status_check=datetime.utcnow(),
|
||||
deletion_date=status_result.deletion_date,
|
||||
deletion_date=persisted_deletion_date,
|
||||
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,
|
||||
"should_track": status_result.should_monitor,
|
||||
"message": status_result.message,
|
||||
"deletion_date": status_result.deletion_date.isoformat() if status_result.deletion_date else None,
|
||||
"status_checked_at": datetime.utcnow().isoformat(),
|
||||
"deletion_date": to_iso_utc(persisted_deletion_date),
|
||||
"status_checked_at": to_iso_utc(datetime.utcnow()),
|
||||
"status_source": status_result.check_method,
|
||||
}
|
||||
|
||||
@ -303,8 +306,10 @@ async def api_track_drop(
|
||||
name=full_domain,
|
||||
status=domain_status,
|
||||
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!
|
||||
last_checked=datetime.utcnow(),
|
||||
last_check_method="zone_drop",
|
||||
)
|
||||
db.add(domain)
|
||||
await db.commit()
|
||||
|
||||
@ -109,10 +109,15 @@ async def apply_migrations(conn: AsyncConnection) -> None:
|
||||
# 2b) domains indexes (watchlist list/sort/filter)
|
||||
# ---------------------------------------------------------
|
||||
if await _table_exists(conn, "domains"):
|
||||
dt_type = "DATETIME" if dialect == "sqlite" else "TIMESTAMP"
|
||||
|
||||
# Canonical status metadata (optional)
|
||||
if not await _has_column(conn, "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)"))
|
||||
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_status ON domains(status)"))
|
||||
@ -135,9 +140,14 @@ async def apply_migrations(conn: AsyncConnection) -> None:
|
||||
# 2d) dropped_domains indexes + de-duplication
|
||||
# ---------------------------------------------------------
|
||||
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"):
|
||||
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)"))
|
||||
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:
|
||||
# - by time window (dropped_date) + optional tld + keyword
|
||||
|
||||
@ -792,24 +792,28 @@ async def check_all_domains(db):
|
||||
taken = 0
|
||||
errors = 0
|
||||
|
||||
from app.utils.datetime import to_naive_utc
|
||||
|
||||
for domain_obj in domains:
|
||||
try:
|
||||
check_result = await domain_checker.check_domain(domain_obj.domain)
|
||||
check_result = await domain_checker.check_domain(domain_obj.name)
|
||||
|
||||
# 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.last_checked = datetime.utcnow()
|
||||
domain_obj.last_check_method = check_result.check_method
|
||||
|
||||
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
|
||||
domain_check = DomainCheck(
|
||||
domain_id=domain_obj.id,
|
||||
status=check_result.status.value,
|
||||
status=check_result.status,
|
||||
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)
|
||||
|
||||
@ -819,10 +823,10 @@ async def check_all_domains(db):
|
||||
else:
|
||||
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:
|
||||
logger.error(f"Error checking {domain_obj.domain}: {e}")
|
||||
logger.error(f"Error checking {domain_obj.name}: {e}")
|
||||
errors += 1
|
||||
|
||||
await db.commit()
|
||||
|
||||
@ -21,6 +21,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models.zone_file import ZoneSnapshot, DroppedDomain
|
||||
from app.utils.datetime import to_iso_utc, to_naive_utc
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -392,17 +393,17 @@ async def get_dropped_domains(
|
||||
"id": item.id,
|
||||
"domain": item.domain,
|
||||
"tld": item.tld,
|
||||
"dropped_date": item.dropped_date.isoformat(),
|
||||
"dropped_date": to_iso_utc(item.dropped_date),
|
||||
"length": item.length,
|
||||
"is_numeric": item.is_numeric,
|
||||
"has_hyphen": item.has_hyphen,
|
||||
# Canonical status fields (keep old key for backwards compat)
|
||||
"availability_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,
|
||||
"status_checked_at": 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": to_iso_utc(item.last_status_check),
|
||||
"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
|
||||
]
|
||||
@ -581,7 +582,7 @@ async def verify_drops_availability(
|
||||
"availability_status": status_result.status,
|
||||
"rdap_status": str(status_result.rdap_status)[:255] if status_result.rdap_status else None,
|
||||
"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,
|
||||
}
|
||||
)
|
||||
|
||||
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 Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
import { daysUntil, formatCountdown } from '@/lib/time'
|
||||
|
||||
// ============================================================================
|
||||
// ADD MODAL COMPONENT (like Portfolio)
|
||||
@ -119,14 +120,6 @@ function AddModal({
|
||||
// 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 {
|
||||
if (!expirationDate) return '—'
|
||||
return new Date(expirationDate).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
@ -161,7 +154,7 @@ export default function WatchlistPage() {
|
||||
}
|
||||
openAnalyzePanel(domainData.name, {
|
||||
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,
|
||||
})
|
||||
}, [openAnalyzePanel])
|
||||
@ -201,7 +194,7 @@ export default function WatchlistPage() {
|
||||
const available = domains?.filter(d => d.is_available) || []
|
||||
const expiringSoon = domains?.filter(d => {
|
||||
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 { total: domains?.length || 0, available: available.length, expiring: expiringSoon.length }
|
||||
@ -213,7 +206,7 @@ export default function WatchlistPage() {
|
||||
let filtered = domains.filter(d => {
|
||||
if (filter === 'available') return d.is_available
|
||||
if (filter === 'expiring') {
|
||||
const days = getDaysUntilExpiry(d.expiration_date)
|
||||
const days = daysUntil(d.expiration_date)
|
||||
return days !== null && days <= 30 && days > 0
|
||||
}
|
||||
return true
|
||||
@ -613,13 +606,14 @@ export default function WatchlistPage() {
|
||||
const health = healthReports[domain.id]
|
||||
const healthStatus = health?.status || 'unknown'
|
||||
const config = healthConfig[healthStatus]
|
||||
const days = getDaysUntilExpiry(domain.expiration_date)
|
||||
const days = daysUntil(domain.expiration_date)
|
||||
|
||||
// Domain status display config (consistent with DropsTab)
|
||||
const domainStatus = domain.status || (domain.is_available ? 'available' : 'taken')
|
||||
const transitionCountdown = domainStatus === 'dropping_soon' ? formatCountdown(domain.deletion_date ?? null) : null
|
||||
const statusConfig = {
|
||||
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' },
|
||||
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' },
|
||||
@ -642,9 +636,11 @@ export default function WatchlistPage() {
|
||||
</button>
|
||||
<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>
|
||||
{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>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -763,7 +759,9 @@ export default function WatchlistPage() {
|
||||
|
||||
{/* Expires */}
|
||||
<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-white/50">{formatExpiryDate(domain.expiration_date)}</span>
|
||||
|
||||
@ -22,6 +22,7 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { api } from '@/lib/api'
|
||||
import { useAnalyzePanelStore } from '@/lib/analyze-store'
|
||||
import { formatCountdown, parseIsoAsUtc } from '@/lib/time'
|
||||
import type { AnalyzeResponse, AnalyzeSection, AnalyzeItem } from '@/components/analyze/types'
|
||||
import { VisionSection } from '@/components/analyze/VisionSection'
|
||||
|
||||
@ -278,6 +279,7 @@ export function AnalyzePanel() {
|
||||
}, [data])
|
||||
|
||||
const headerDomain = data?.domain || domain || ''
|
||||
const dropCountdown = useMemo(() => formatCountdown(dropStatus?.deletion_date ?? null), [dropStatus])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
@ -410,7 +412,9 @@ export function AnalyzePanel() {
|
||||
</div>
|
||||
{dropStatus.status === 'dropping_soon' && dropStatus.deletion_date && (
|
||||
<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>
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { api } from '@/lib/api'
|
||||
import { useAnalyzePanelStore } from '@/lib/analyze-store'
|
||||
import { formatCountdown } from '@/lib/time'
|
||||
import {
|
||||
Globe,
|
||||
Loader2,
|
||||
@ -117,7 +118,23 @@ export function DropsTab({ showToast }: DropsTabProps) {
|
||||
// Status Checking
|
||||
const [checkingStatus, setCheckingStatus] = 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
|
||||
const loadStats = useCallback(async () => {
|
||||
@ -206,29 +223,10 @@ export function DropsTab({ showToast }: DropsTabProps) {
|
||||
}
|
||||
}, [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)
|
||||
const trackDrop = useCallback(async (dropId: number, domain: string) => {
|
||||
if (trackingDrop) return
|
||||
if (trackedDrops.has(dropId)) {
|
||||
if (trackedDomains.has(domain.toLowerCase())) {
|
||||
showToast(`${domain} is already in your Watchlist`, 'info')
|
||||
return
|
||||
}
|
||||
@ -237,7 +235,11 @@ export function DropsTab({ showToast }: DropsTabProps) {
|
||||
try {
|
||||
const result = await api.trackDrop(dropId)
|
||||
// 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') {
|
||||
showToast(`${domain} is already in your Watchlist`, 'info')
|
||||
@ -249,10 +251,10 @@ export function DropsTab({ showToast }: DropsTabProps) {
|
||||
} finally {
|
||||
setTrackingDrop(null)
|
||||
}
|
||||
}, [trackingDrop, trackedDrops, showToast])
|
||||
}, [trackingDrop, trackedDomains, showToast])
|
||||
|
||||
// Check if a drop is already tracked
|
||||
const isTracked = useCallback((dropId: number) => trackedDrops.has(dropId), [trackedDrops])
|
||||
// Check if a drop is already tracked (domain-based, persists across sessions)
|
||||
const isTracked = useCallback((fullDomain: string) => trackedDomains.has(fullDomain.toLowerCase()), [trackedDomains])
|
||||
|
||||
// Filtered and Sorted Items
|
||||
const sortedItems = useMemo(() => {
|
||||
@ -582,7 +584,7 @@ export function DropsTab({ showToast }: DropsTabProps) {
|
||||
const fullDomain = `${item.domain}.${item.tld}`
|
||||
const isChecking = checkingStatus === item.id
|
||||
const isTrackingThis = trackingDrop === item.id
|
||||
const alreadyTracked = isTracked(item.id)
|
||||
const alreadyTracked = isTracked(fullDomain)
|
||||
const status = item.availability_status || 'unknown'
|
||||
|
||||
// Status display config with better labels
|
||||
|
||||
@ -486,9 +486,12 @@ class ApiClient {
|
||||
is_available: boolean
|
||||
registrar: string | null
|
||||
expiration_date: string | null
|
||||
deletion_date?: string | null
|
||||
notify_on_available: boolean
|
||||
created_at: string
|
||||
last_checked: string | null
|
||||
status_checked_at?: string | null
|
||||
status_source?: string | null
|
||||
}>
|
||||
total: number
|
||||
page: number
|
||||
|
||||
@ -19,9 +19,12 @@ interface Domain {
|
||||
is_available: boolean
|
||||
registrar: string | null
|
||||
expiration_date: string | null
|
||||
deletion_date?: string | null
|
||||
notify_on_available: boolean
|
||||
created_at: string
|
||||
last_checked: string | null
|
||||
status_checked_at?: string | null
|
||||
status_source?: string | null
|
||||
}
|
||||
|
||||
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