diff --git a/src/routes/home.tsx b/src/routes/home.tsx index 20f213a..89c99b9 100644 --- a/src/routes/home.tsx +++ b/src/routes/home.tsx @@ -7,24 +7,81 @@ function formatDateTime(iso?: string): string { if (!iso) return "Never"; const date = new Date(iso); if (Number.isNaN(date.getTime())) return "Never"; - return date.toISOString().replace("T", " ").replace(/\.\d+Z$/, " UTC"); + return date + .toISOString() + .replace("T", " ") + .replace(/\.\d+Z$/, " UTC"); } +function formatRelative(iso?: string): string { + if (!iso) return "Never"; + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return "Never"; + const diffMs = Date.now() - date.getTime(); + if (diffMs < 0) return "just now"; + const seconds = Math.floor(diffMs / 1000); + if (seconds < 60) return "just now"; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; +} + +function formatUptime(iso?: string): string { + if (!iso) return "—"; + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return "—"; + const diffMs = Date.now() - date.getTime(); + if (diffMs < 0) return "—"; + const minutes = Math.floor(diffMs / 60000); + if (minutes < 60) return `${minutes} min`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours} ${hours === 1 ? "hour" : "hours"}`; + const days = Math.floor(hours / 24); + return `${days} ${days === 1 ? "day" : "days"}`; +} + +type Tone = "success" | "danger"; + +type StatProps = { + label: string; + value: string | number; + tone?: Tone; + title?: string; + time?: boolean; +}; + +const Stat = ({ label, value, tone, title, time }: StatProps) => { + const valueClass = [ + "stat-value", + time ? "stat-value-time" : "", + tone ? `stat-value-${tone}` : "", + ] + .filter(Boolean) + .join(" "); + return ( +
+ {value} + {label} +
+ ); +}; + export async function handle(c: Context<{ Bindings: Env }>): Promise { const stats = await getStats(c.env); - const cards: Array<{ label: string; value: string | number; time?: boolean }> = - [ - { label: "Active feeds", value: stats.active_feeds }, - { label: "Feeds created", value: stats.feeds_created }, - { label: "Feeds deleted", value: stats.feeds_deleted }, - { label: "Emails received", value: stats.emails_received }, - { label: "Emails rejected", value: stats.emails_rejected }, - { label: "WebSub subscribers", value: stats.websub_subscriptions_active }, - { label: "Last email", value: formatDateTime(stats.last_email_at), time: true }, - { label: "Last feed created", value: formatDateTime(stats.last_feed_created_at), time: true }, - { label: "Online since", value: formatDateTime(stats.first_seen), time: true }, - ]; + const netFeeds = stats.feeds_created - stats.feeds_deleted; + const totalEmails = stats.emails_received + stats.emails_rejected; + const acceptanceRate = + totalEmails > 0 + ? `${Math.round((stats.emails_received / totalEmails) * 100)}%` + : "—"; + const avgEmailsPerFeed = + stats.feeds_created > 0 + ? (stats.emails_received / stats.feeds_created).toFixed(1) + : "—"; return c.html( @@ -41,16 +98,86 @@ export async function handle(c: Context<{ Bindings: Env }>): Promise { -
- {cards.map((card) => ( -
- - {card.value} - - {card.label} -
- ))} +
+
+ {stats.active_feeds} + Active feeds +
+
+ {stats.emails_received} + Emails received +
+ +
+

Feeds

+
+ + + + +
+
+ +
+

Emails

+
+ + + + + +
+
+ +
+

Distribution

+
+ +
+
+ +
+

Instance

+
+ + +
+
, ); diff --git a/src/styles/components.css b/src/styles/components.css index 76dc389..3ba0330 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -1016,7 +1016,7 @@ table.table code { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: var(--spacing-md); - margin-bottom: var(--spacing-lg); + margin-bottom: 0; } .stat-card { @@ -1039,7 +1039,100 @@ table.table code { color: var(--color-text-primary); } +.stat-value-success { + color: var(--color-success); +} + +.stat-value-danger { + color: var(--color-danger); +} + .stat-label { font-size: 0.85rem; color: var(--color-text-secondary); } + +/* Status page — hero featured metric */ +.stat-hero { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + justify-content: space-between; + gap: var(--spacing-lg); + margin-bottom: var(--spacing-xl); +} + +.stat-hero-main { + display: flex; + flex-direction: column; + gap: var(--spacing-xs); +} + +.stat-hero-value { + font-size: 3.25rem; + font-weight: var(--font-weight-bold); + line-height: 1; + color: var(--color-primary); +} + +.stat-hero-label { + font-size: var(--font-size-md); + font-weight: var(--font-weight-medium); + color: var(--color-text-secondary); +} + +.stat-hero-aside { + display: flex; + flex-direction: column; + gap: var(--spacing-xs); + text-align: right; +} + +.stat-hero-aside-value { + font-size: var(--font-size-xxl); + font-weight: var(--font-weight-semibold); + line-height: 1; + color: var(--color-success); +} + +.stat-hero-aside-label { + font-size: var(--font-size-sm); + color: var(--color-text-secondary); +} + +/* Status page — thematic sections */ +.stat-section { + margin-bottom: var(--spacing-xl); +} + +.stat-section-title { + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--color-text-tertiary); + margin: 0 0 var(--spacing-md); +} + +@media (max-width: 640px) { + .stat-hero-aside { + text-align: left; + } + + .stats-grid { + grid-template-columns: repeat(2, 1fr); + gap: var(--spacing-sm); + } + + .stat-card { + padding: var(--spacing-md); + } + + .stat-value { + font-size: 1.6rem; + } + + .stat-value-time { + font-size: 0.9rem; + } +}