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
+
+
+
+
+
+
+
+
+
,
);
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;
+ }
+}