Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found
Select Git revision
Loading items

Target

Select target project
  • creations/timezoneDB
1 result
Select Git revision
Loading items
Show changes
Commits on Source (5)
FROM rustlang/rust:nightly AS builder
RUN apt-get update && apt-get install -y \
pkg-config \
libssl-dev \
libpq-dev \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo "fn main() {}" > src/main.rs
RUN cargo build --release && rm -rf src
COPY src ./src
COPY public ./public
COPY migrations ./migrations
RUN cargo build --release
RUN touch src/main.rs && cargo build --release
FROM debian:bookworm-slim
FROM debian:bookworm-slim AS runtime
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y \
ca-certificates \
libpq5 \
libssl3 \
curl \
&& rm -rf /var/lib/apt/lists/*
RUN groupadd -r appuser && useradd -r -g appuser appuser
WORKDIR /app
COPY --from=builder /app/target/release/timezone-db /usr/local/bin/app
COPY --from=builder /app/public ./public
COPY --from=builder /app/migrations ./migrations
COPY --from=builder /app/target/release/timezone-db /usr/local/bin/timezone-db
COPY --from=builder --chown=appuser:appuser /app/public ./public
COPY --from=builder --chown=appuser:appuser /app/migrations ./migrations
RUN chown -R appuser:appuser /app
USER appuser
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost:${PORT:-3000}/health || exit 1
CMD ["/usr/local/bin/app"]
CMD ["/usr/local/bin/timezone-db"]
BSD 3-Clause License
Copyright (c) 2025, creations
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
......@@ -45,8 +45,8 @@ h2 {
}
#avatar {
width: 80px;
height: 80px;
width: 120px;
height: 120px;
border-radius: 50%;
border: 2px solid var(--border);
margin-bottom: 0.5rem;
......@@ -119,6 +119,7 @@ button.danger {
button.danger:hover {
background: #e63838;
cursor: pointer;
}
.ts-wrapper.single .ts-control {
......@@ -187,3 +188,209 @@ button.danger:hover {
.hidden {
display: none;
}
/* Enhanced features styles */
.time-display {
text-align: center;
margin: 1.5rem 0;
padding: 1rem;
background: linear-gradient(135deg, var(--card) 0%, rgba(78, 163, 255, 0.1) 100%);
border-radius: 0.75rem;
border: 1px solid var(--border);
}
.time-display h3 {
margin: 0 0 0.5rem 0;
color: var(--accent);
font-size: 1rem;
}
#current-time-value {
font-size: 2rem;
font-weight: bold;
color: var(--accent);
margin-bottom: 0.25rem;
font-family: 'Courier New', monospace;
}
#current-date-value {
font-size: 0.9rem;
color: var(--fg);
opacity: 0.8;
}
.info-cards {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin: 1rem 0;
}
.info-card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 0.5rem;
padding: 1rem;
text-align: center;
}
.info-card h4 {
margin: 0 0 0.5rem 0;
color: var(--accent);
font-size: 0.9rem;
}
.info-card .value {
font-size: 1.1rem;
font-weight: bold;
color: var(--fg);
}
.timezone-info {
background: var(--card);
border: 1px solid var(--border);
border-radius: 0.5rem;
padding: 1rem;
margin: 1rem 0;
}
.timezone-info h4 {
margin: 0 0 0.5rem 0;
color: var(--accent);
font-size: 0.9rem;
}
.timezone-info .offset {
font-size: 1.1rem;
color: var(--fg);
margin-bottom: 0.5rem;
}
.timezone-info .description {
font-size: 0.8rem;
color: var(--fg);
opacity: 0.7;
}
.about-section {
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid var(--border);
}
.about-section h3 {
color: var(--accent);
font-size: 1rem;
margin-bottom: 0.5rem;
}
.about-section p {
font-size: 0.9rem;
color: var(--fg);
opacity: 0.8;
line-height: 1.4;
margin-bottom: 0.5rem;
}
.feature-list {
font-size: 0.85rem;
color: var(--fg);
opacity: 0.7;
margin-top: 1rem;
padding-left: 1rem;
}
.feature-list li {
margin-bottom: 0.5rem;
}
.feature-list code {
background: var(--bg);
padding: 0.2rem 0.4rem;
border-radius: 0.25rem;
font-family: 'Courier New', monospace;
color: var(--accent);
}
@media (max-width: 480px) {
.info-cards {
grid-template-columns: 1fr;
}
#current-time-value {
font-size: 1.5rem;
}
}
.stats-grid {
display: grid;
grid-template-columns: calc(50% - 0.5rem) calc(50% - 0.5rem);
gap: 1rem;
margin: 1rem 0 1.5rem;
}
.stat-card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 0.5rem;
padding: 1rem;
text-align: center;
transition: transform 0.2s ease, border-color 0.2s ease;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
}
.stat-card:hover {
transform: translateY(-2px);
border-color: var(--accent);
}
.stat-value {
font-size: 1.5rem;
font-weight: bold;
color: var(--accent);
margin-bottom: 0.3rem;
box-sizing: border-box;
word-break: break-word;
}
.stat-label {
font-size: 0.8rem;
color: var(--fg);
opacity: 0.8;
}
#stat-top-timezone {
font-size: 0.9rem;
}
@media (max-width: 480px) {
.info-cards {
grid-template-columns: 1fr;
}
#current-time-value {
font-size: 1.5rem;
}
.stats-grid {
grid-template-columns: 1fr;
}
.stat-card {
max-width: 100%;
}
#stats-section {
margin-top: 2rem;
padding-top: .5rem;
border-top: 1px solid var(--border);
}
#stats-section h3 {
text-align: center;
}
}
public/favicon.ico

172 KiB

......@@ -3,7 +3,8 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Timezone DB</title>
<title>Timezone DB - Track Your Time</title>
<meta name="description" content="A simple timezone tracker for Discord users. Store and share your timezone with friends.">
<link
href="https://cdn.jsdelivr.net/npm/tom-select/dist/css/tom-select.css"
......@@ -36,9 +37,44 @@
<main>
<section id="login-section" class="hidden">
<h1>Timezone DB</h1>
<a href="/auth/discord?redirect=/" id="login-btn"
>Log in with Discord</a
>
<p style="text-align: center; margin-bottom: 1.5rem; opacity: 0.8;">
Track and share your timezone with friends
</p>
<a href="/auth/discord?redirect=/" id="login-btn">
Log in with Discord
</a>
<div id="stats-section" class="hidden">
<h3>Community Stats</h3>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value" id="stat-total-users">-</div>
<div class="stat-label">Total Users</div>
</div>
<div class="stat-card">
<div class="stat-value" id="stat-unique-timezones">-</div>
<div class="stat-label">Unique Timezones</div>
</div>
<div class="stat-card">
<div class="stat-value" id="stat-recent-users">-</div>
<div class="stat-label">New This Week</div>
</div>
<div class="stat-card">
<div class="stat-value" id="stat-top-timezone">-</div>
<div class="stat-label">Most Popular</div>
</div>
</div>
</div>
<div class="about-section">
<h3>What is Timezone DB?</h3>
<p>A simple service to store and retrieve user timezones. Perfect for Discord communities and remote teams.</p>
<ul class="feature-list">
<li>Store your timezone with Discord OAuth</li>
<li>API endpoints for integrations</li>
</ul>
</div>
</section>
<section id="timezone-section" class="hidden">
......@@ -53,16 +89,48 @@
<h2 id="auth-status">Logged in</h2>
</div>
<div class="timezone-info hidden" id="timezone-info">
<h4>Timezone Information</h4>
<div class="offset" id="timezone-offset">UTC+0</div>
<div class="description" id="timezone-description">Your current timezone</div>
</div>
<div class="info-cards hidden" id="info-cards">
<div class="info-card">
<h4>UTC Offset</h4>
<div class="value" id="utc-offset">--</div>
</div>
<div class="info-card">
<h4>Time Format</h4>
<div class="value" id="time-format">12h</div>
</div>
</div>
<label for="timezone-select">Select your timezone:</label>
<select id="timezone-select">
<option value="">Select timezone</option>
</select>
<button id="set-timezone">Save</button>
<button id="set-timezone">Save Timezone</button>
<button id="delete-timezone" class="danger hidden">
Delete Timezone
</button>
<button id="logout" class="danger hidden">
Logout
</button>
<p id="status-msg"></p>
<div class="about-section">
<h3>API Usage</h3>
<ul class="feature-list">
<li><code>GET /auth/discord</code> - Authenticate with Discord</li>
<li><code>GET /logout</code> - Logout</li>
<li><code>GET /get?id=DISCORD_ID</code> - Get timezone data</li>
<li><code>GET /list</code> - List all stored timezones</li>
<li><code>POST /set</code> - Update timezone (requires auth)</li>
<li><code>GET /stats</code> - Get statistics</li>
</ul>
</div>
</section>
</main>
</body>
......
......@@ -22,6 +22,160 @@ const ts = new TomSelect("#timezone-select", {
maxOptions: 1000,
});
let timeInterval;
let userPreferred24Hour = null;
function detectBrowserTimeFormat() {
const testFormatter = new Intl.DateTimeFormat(navigator.language, {
hour: 'numeric',
minute: '2-digit'
});
const testTime = testFormatter.format(new Date('2023-01-01 13:00:00'));
return !(testTime.includes('PM') || testTime.includes('AM') ||
testTime.includes('pm') || testTime.includes('am'));
}
function getCurrentTimeFormat() {
if (userPreferred24Hour === null) {
return detectBrowserTimeFormat();
}
return userPreferred24Hour;
}
function saveTimeFormatPreference(is24Hour) {
userPreferred24Hour = is24Hour;
localStorage.setItem('timeFormat24Hour', is24Hour.toString());
}
function loadTimeFormatPreference() {
const saved = localStorage.getItem('timeFormat24Hour');
if (saved !== null) {
userPreferred24Hour = saved === 'true';
}
}
function createTimeDisplay() {
if (document.getElementById('time-display')) return;
const timeContainer = document.createElement('div');
timeContainer.id = 'time-display';
timeContainer.className = 'time-display hidden';
timeContainer.innerHTML = `
<div class="current-time">
<h3>Your Current Time</h3>
<div id="current-time-value">--:--:--</div>
<div id="current-date-value">----</div>
</div>
`;
const profileSection = document.querySelector('.profile');
profileSection.after(timeContainer);
}
function updateTime(timezone) {
if (!timezone) return;
const timeDisplay = document.getElementById('time-display');
const timeValue = document.getElementById('current-time-value');
const dateValue = document.getElementById('current-date-value');
if (!timeDisplay || !timeValue || !dateValue) return;
const now = new Date();
const is24Hour = getCurrentTimeFormat();
const formatter = new Intl.DateTimeFormat(navigator.language, {
timeZone: timezone,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: !is24Hour
});
const dateFormatter = new Intl.DateTimeFormat(navigator.language, {
timeZone: timezone,
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
});
timeValue.textContent = formatter.format(now);
dateValue.textContent = dateFormatter.format(now);
timeDisplay.classList.remove('hidden');
}
function updateTimezoneInfo(timezone) {
if (!timezone) return;
const now = new Date();
const offsetFormatter = new Intl.DateTimeFormat(navigator.language, {
timeZone: timezone,
timeZoneName: 'longOffset'
});
const offsetParts = offsetFormatter.formatToParts(now);
const offset = offsetParts.find(part => part.type === 'timeZoneName')?.value || 'UTC';
const infoSection = document.getElementById('timezone-info');
const infoCards = document.getElementById('info-cards');
const offsetEl = document.getElementById('timezone-offset');
const descriptionEl = document.getElementById('timezone-description');
const utcOffsetEl = document.getElementById('utc-offset');
const timeFormatEl = document.getElementById('time-format');
if (infoSection && offsetEl && descriptionEl) {
offsetEl.textContent = offset;
descriptionEl.textContent = timezone.replace(/_/g, ' ');
utcOffsetEl.textContent = offset;
const is24Hour = getCurrentTimeFormat();
timeFormatEl.textContent = is24Hour ? '24h' : '12h';
timeFormatEl.style.cursor = 'pointer';
timeFormatEl.title = 'Click to toggle between 12h and 24h format';
timeFormatEl.onclick = () => {
const newFormat = !getCurrentTimeFormat();
saveTimeFormatPreference(newFormat);
updateTimezoneInfo(timezone);
updateTime(timezone);
};
infoSection.classList.remove('hidden');
infoCards.classList.remove('hidden');
}
}
async function fetchStats() {
try {
const response = await fetch('/stats', { credentials: 'include' });
if (!response.ok) throw new Error();
const json = await response.json();
const section = document.getElementById("stats-section");
const statsElements = {
totalUsers: document.getElementById('stat-total-users'),
uniqueTimezones: document.getElementById('stat-unique-timezones'),
recentUsers: document.getElementById('stat-recent-users'),
topTimezones: document.getElementById('stat-top-timezone')
}
if (json) {
statsElements.totalUsers.textContent = json.total_users;
statsElements.uniqueTimezones.textContent = json.unique_timezones;
statsElements.recentUsers.textContent = json.recent_registrations;
statsElements.topTimezones.textContent = json.top_timezone;
section.classList.remove('hidden');
}
}
catch (error) {
console.error(error);
}
};
async function fetchUserInfo() {
try {
const res = await fetch("/me", { credentials: "include" });
......@@ -42,13 +196,29 @@ async function fetchUserInfo() {
timezoneSection.classList.remove("hidden");
const deleteBtn = document.getElementById("delete-timezone");
const logoutBtn = document.getElementById("logout");
if (tz) {
ts.setValue(tz);
deleteBtn.classList.remove("hidden");
logoutBtn.classList.remove("hidden");
if (timeInterval) clearInterval(timeInterval);
createTimeDisplay();
updateTime(tz);
updateTimezoneInfo(tz);
timeInterval = setInterval(() => updateTime(tz), 1000);
} else {
ts.clear();
deleteBtn.classList.add("hidden");
if (timeInterval) clearInterval(timeInterval);
const timeDisplay = document.getElementById('time-display');
const timezoneInfo = document.getElementById('timezone-info');
const infoCards = document.getElementById('info-cards');
if (timeDisplay) timeDisplay.classList.add('hidden');
if (timezoneInfo) timezoneInfo.classList.add('hidden');
if (infoCards) infoCards.classList.add('hidden');
}
deleteBtn.addEventListener("click", async () => {
......@@ -63,13 +233,45 @@ async function fetchUserInfo() {
ts.clear();
statusMsg.textContent = "Timezone deleted.";
deleteBtn.classList.add("hidden");
if (timeInterval) clearInterval(timeInterval);
const timeDisplay = document.getElementById('time-display');
const timezoneInfo = document.getElementById('timezone-info');
const infoCards = document.getElementById('info-cards');
if (timeDisplay) timeDisplay.classList.add('hidden');
if (timezoneInfo) timezoneInfo.classList.add('hidden');
if (infoCards) infoCards.classList.add('hidden');
} catch {
statusMsg.textContent = "Failed to delete timezone.";
}
});
logoutBtn.addEventListener("click", async () => {
try {
const res = await fetch("/logout", {
method: "GET",
credentials: "include",
});
if (!res.ok) throw new Error();
statusMsg.textContent = "Logged out.";
loginSection.classList.remove("hidden");
timezoneSection.classList.add("hidden");
} catch {
statusMsg.textContent = "Failed to log out.";
}
});
} catch {
loginSection.classList.remove("hidden");
timezoneSection.classList.add("hidden");
try {
await fetchStats();
} catch (error) {
console.error(error);
}
}
}
......@@ -101,12 +303,20 @@ setBtn.addEventListener("click", async () => {
statusMsg.textContent = "Timezone updated!";
document.getElementById("delete-timezone").classList.remove("hidden");
if (timeInterval) clearInterval(timeInterval);
createTimeDisplay();
updateTime(timezone);
updateTimezoneInfo(timezone);
timeInterval = setInterval(() => updateTime(timezone), 1000);
} catch (error) {
statusMsg.textContent = error.message;
} finally {
setBtn.disabled = false;
setBtn.textContent = "Save";
setBtn.textContent = "Save Timezone";
}
});
loadTimeFormatPreference();
fetchUserInfo();
......@@ -337,3 +337,61 @@ pub async fn me(State(state): State<AppState>, headers: HeaderMap) -> impl IntoR
Err(err) => err.into_response(),
}
}
#[instrument(skip(state))]
pub async fn logout(State(state): State<AppState>, headers: HeaderMap) -> impl IntoResponse {
let Some(cookie_header) = headers.typed_get::<Cookie>() else {
return (
StatusCode::OK,
Json(JsonMessage {
message: "Already logged out".into(),
}),
)
.into_response();
};
let Some(session_id) = cookie_header.get("session") else {
return (
StatusCode::OK,
Json(JsonMessage {
message: "Already logged out".into(),
}),
)
.into_response();
};
let mut redis_conn = match state.redis.get_connection().await {
Ok(conn) => conn,
Err(e) => {
error!("Failed to get Redis connection: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(JsonMessage {
message: "Database connection error".into(),
}),
)
.into_response();
}
};
let key = format!("session:{}", session_id);
let _: redis::RedisResult<()> = redis_conn.as_mut().del(&key).await;
let mut headers = HeaderMap::new();
headers.insert(
"Set-Cookie",
"session=; Max-Age=0; Path=/; SameSite=None; Secure; HttpOnly"
.parse()
.unwrap(),
);
info!("User logged out successfully");
(
StatusCode::OK,
headers,
Json(JsonMessage {
message: "Logged out successfully".into(),
}),
)
.into_response()
}
use axum::body::Body;
use axum::http::{header, StatusCode};
use axum::response::Response;
use std::fs;
pub async fn favicon() -> Response {
match fs::read("public/favicon.ico") {
Ok(content) => Response::builder()
.header(header::CONTENT_TYPE, "image/x-icon")
.body(Body::from(content))
.unwrap(),
Err(_) => Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Body::from("404 Not Found"))
.unwrap(),
}
}
......@@ -9,7 +9,9 @@ use std::fs;
use tower_http::services::ServeDir;
mod auth;
mod favicon;
mod health;
mod stats;
mod timezone;
async fn preflight_handler() -> Response {
......@@ -45,15 +47,18 @@ async fn index_page() -> Html<String> {
pub fn all() -> Router<AppState> {
Router::new()
.route("/favicon.ico", get(favicon::favicon))
.route("/", get(index_page))
.route("/get", get(timezone::get_timezone))
.route("/set", post(timezone::set_timezone))
.route("/set", options(preflight_handler))
.route("/stats", get(stats::get_stats))
.route("/delete", delete(timezone::delete_timezone))
.route("/list", get(timezone::list_timezones))
.route("/auth/discord", get(auth::start_oauth))
.route("/auth/discord/callback", get(auth::handle_callback))
.route("/me", get(auth::me))
.route("/logout", get(auth::logout))
.route("/health", get(health::health_check))
.nest_service("/public", ServeDir::new("public"))
.fallback(get(index_page))
......
use crate::db::AppState;
use crate::types::JsonMessage;
use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
use serde::Serialize;
use sqlx::Row;
use std::collections::HashMap;
use tracing::error;
#[derive(Serialize)]
pub struct StatsResponse {
pub total_users: i64,
pub total_timezones: i64,
pub timezone_distribution: HashMap<String, i64>,
pub top_timezones: Vec<TimezoneCount>,
pub unique_timezones: i64,
pub top_timezone: String,
pub recent_registrations: i64,
}
#[derive(Serialize)]
pub struct TimezoneCount {
pub timezone: String,
pub count: i64,
}
#[derive(Serialize)]
pub struct BasicStats {
pub total_users: i64,
pub total_timezones: i64,
pub top_timezone: Option<String>,
}
pub async fn get_stats(State(state): State<AppState>) -> impl IntoResponse {
let total_users_result = sqlx::query("SELECT COUNT(*) as count FROM timezones")
.fetch_one(&state.db)
.await;
let total_users = match total_users_result {
Ok(row) => row.get::<i64, _>("count"),
Err(e) => {
error!("Failed to get total users: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(JsonMessage {
message: "Failed to fetch stats".into(),
}),
)
.into_response();
}
};
let timezone_dist_result = sqlx::query(
"SELECT timezone, COUNT(*) as count FROM timezones GROUP BY timezone ORDER BY count DESC",
)
.fetch_all(&state.db)
.await;
let (timezone_distribution, top_timezones) = match timezone_dist_result {
Ok(rows) => {
let mut distribution = HashMap::new();
let mut top_zones = Vec::new();
for row in rows {
let timezone: String = row.get("timezone");
let count: i64 = row.get("count");
distribution.insert(timezone.clone(), count);
if top_zones.len() < 10 {
top_zones.push(TimezoneCount { timezone, count });
}
}
(distribution, top_zones)
}
Err(e) => {
error!("Failed to get timezone distribution: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(JsonMessage {
message: "Failed to fetch timezone distribution".into(),
}),
)
.into_response();
}
};
let recent_registrations_result = sqlx::query(
"SELECT COUNT(*) as count FROM timezones WHERE created_at > NOW() - INTERVAL '7 days'",
)
.fetch_one(&state.db)
.await;
let recent_registrations = match recent_registrations_result {
Ok(row) => row.get::<i64, _>("count"),
Err(e) => {
error!("Failed to get recent registrations: {}", e);
0
}
};
let total_timezones = timezone_distribution.len() as i64;
let unique_timezones = timezone_distribution.len() as i64;
let top_timezone = top_timezones
.first()
.map(|tz| tz.timezone.clone())
.unwrap_or_default();
let stats = StatsResponse {
total_users,
total_timezones,
timezone_distribution,
top_timezones,
unique_timezones,
top_timezone,
recent_registrations,
};
(StatusCode::OK, Json(stats)).into_response()
}