First code commit

This commit is contained in:
Elias Wendland
2026-07-07 23:49:51 +02:00
parent 24dd54f684
commit 193a7348a0
6 changed files with 775 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
target/
Cargo.lock
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "time-converter"
version = "0.1.0"
edition = "2024"
[dependencies]
axum = "0.8.9"
chrono = "0.4.45"
chrono-tz = "0.10.4"
serde_json = "1.0.150"
tokio = { version = "1.52.3", features = ["full"] }
+1
View File
@@ -0,0 +1 @@
pub mod v1;
+241
View File
@@ -0,0 +1,241 @@
use axum::extract::Path;
use serde_json::json;
use chrono::TimeZone;
use chrono::Offset;
pub async fn root() -> String {
let endpoints = vec![
"/timeconvert/with-time/{from_timezone}/{to_timezone}/{time}",
"/timeconvert/with-date-time/{from_timezone}/{to_timezone}/{datetime}",
"/time/live/{timezone}",
"/time/live/{timezone}/{offset_seconds}"
];
let data = json!({
"available_endpoints": endpoints
});
build_response("OK".to_string(), data, "".to_string())
}
pub async fn timeconvert_with_time(Path((from_timezone, to_timezone, time)): Path<(String, String, String)>) -> String {
let time_diff_result = calculate_time_difference_in_minutes(from_timezone, to_timezone);
if let Err(err) = time_diff_result {
return build_response("ERROR".to_string(), json!({}), err);
}
let parsed_time_result = parse_time_to_three_part(time);
if let Err(err) = parsed_time_result {
return build_response("ERROR".to_string(), json!({}), err);
}
let time_diff_in_minutes = time_diff_result.unwrap();
let (h, m, s) = parsed_time_result.unwrap();
let (converted_hours, converted_minutes, converted_seconds, day_diff) = apply_time_difference_to_three_part_time(h, m, s, time_diff_in_minutes);
let final_time_string = build_time_string_from_three_part(converted_hours, converted_minutes, converted_seconds);
let data = json!({
"converted_time": final_time_string,
"day_diff": day_diff
});
build_response("OK".to_string(), data, "".to_string())
}
pub async fn timeconvert_with_date_time(Path((from_timezone, to_timezone, datetime)): Path<(String, String, String)>) -> String {
let time_diff_result = calculate_time_difference_in_minutes(from_timezone, to_timezone);
if let Err(err) = time_diff_result {
return build_response("ERROR".to_string(), json!({}), err);
}
let parsed_datetime_result = parse_datetime_string(datetime);
if let Err(err) = parsed_datetime_result {
return build_response("ERROR".to_string(), json!({}), err);
}
let dt = parsed_datetime_result.unwrap();
let time_diff_in_minutes = time_diff_result.unwrap();
let converted_dt = apply_minute_difference_to_datetime(dt, time_diff_in_minutes);
let data = json!({
"converted_datetime": format_datetime_to_string(converted_dt)
});
build_response("OK".to_string(), data, "".to_string())
}
pub async fn time_live(Path(timezone): Path<String>) -> String {
let tz_result = parse_timezone(timezone);
if let Err(err) = tz_result {
return build_response("ERROR".to_string(), json!({}), err);
}
let tz = tz_result.unwrap();
let now = get_current_time_in_timezone(tz);
let data = build_time_and_datetime_json_data(now);
build_response("OK".to_string(), data, "".to_string())
}
pub async fn time_live_with_offset(Path((timezone, offset_seconds)): Path<(String, String)>) -> String {
let tz_result = parse_timezone(timezone);
if let Err(err) = tz_result {
return build_response("ERROR".to_string(), json!({}), err);
}
let offset_result = parse_offset_seconds(offset_seconds);
if let Err(err) = offset_result {
return build_response("ERROR".to_string(), json!({}), err);
}
let tz = tz_result.unwrap();
let offset_secs = offset_result.unwrap();
let now = get_current_time_in_timezone(tz);
let now_with_offset = apply_seconds_difference_to_datetime(now, offset_secs);
let data = build_time_and_datetime_json_data(now_with_offset);
build_response("OK".to_string(), data, "".to_string())
}
fn build_response(status: String, data: serde_json::Value, error_msg: String) -> String {
let json: serde_json::Value;
if error_msg.is_empty() {
json = json!({
"status": status,
"data": data
})
} else {
json = json!({
"status": status,
"error_msg": error_msg
})
}
serde_json::to_string(&json).unwrap()
}
fn calculate_time_difference_in_minutes(from_timezone: String, to_timezone: String) -> Result<i16, String> {
let from_offset = convert_timezone_to_utc_offset(from_timezone);
let to_offset = convert_timezone_to_utc_offset(to_timezone);
if from_offset.is_err() || to_offset.is_err() {
return Err(format!("{}", from_offset.err().unwrap_or_else(|| to_offset.unwrap_err())));
}
Ok(to_offset.unwrap() - from_offset.unwrap())
}
fn convert_timezone_to_utc_offset(timezone: String) -> Result<i16, String> {
let tz = parse_timezone(timezone)?;
let now = chrono::Utc::now().naive_utc();
let offset = tz.offset_from_utc_datetime(&now);
let offset_in_minutes = (offset.fix().local_minus_utc() / 60) as i16;
Ok(offset_in_minutes)
}
fn parse_timezone(timezone: String) -> Result<chrono_tz::Tz, String> {
let mut clean_tz = timezone.trim_matches(|c| c == '"' || c == '\'').to_string();
if clean_tz.starts_with("UTC+") {
let hours = &clean_tz[4..];
clean_tz = format!("Etc/GMT-{}", hours);
} else if clean_tz.starts_with("UTC-") {
let hours = &clean_tz[4..];
clean_tz = format!("Etc/GMT+{}", hours);
}
clean_tz.parse().map_err(|_| String::from("Could not parse timezone"))
}
fn parse_time_to_three_part(time: String) -> Result<(i16, i16, i16), String> {
let clean_time = time.trim_matches(|c| c == '"' || c == '\'');
let parts: Vec<&str> = clean_time.split(':').collect();
if parts.len() != 3 {
return Err(String::from("Invalid time format. Expected HH:MM:SS format"));
}
let hours = parts[0].parse::<i16>().map_err(|_| String::from("Invalid hours"))?;
let minutes = parts[1].parse::<i16>().map_err(|_| String::from("Invalid minutes"))?;
let seconds = parts[2].parse::<i16>().map_err(|_| String::from("Invalid seconds"))?;
if hours > 23 || minutes > 59 || seconds > 59 {
return Err(String::from("Invalid time value. Ensure hours are less than 24, minutes less than 60, and seconds less than 60"));
}
Ok((hours, minutes, seconds))
}
fn apply_time_difference_to_three_part_time(h: i16, m: i16, s: i16, time_diff_in_minutes: i16) -> (i16, i16, i16, i16) {
let mut converted_hours = h + time_diff_in_minutes / 60;
let mut converted_minutes = m + time_diff_in_minutes % 60;
let converted_seconds = s;
if converted_minutes >= 60 {
converted_hours += 1;
converted_minutes -= 60;
} else if converted_minutes < 0 {
converted_hours -= 1;
converted_minutes += 60;
}
let mut day_diff = 0;
while converted_hours >= 24 {
day_diff += 1;
converted_hours -= 24;
}
while converted_hours < 0 {
day_diff -= 1;
converted_hours += 24;
}
(converted_hours, converted_minutes, converted_seconds, day_diff)
}
fn build_time_string_from_three_part(h: i16, m: i16, s: i16) -> String {
format!("{:02}:{:02}:{:02}", h, m, s)
}
fn parse_datetime_string(datetime: String) -> Result<chrono::NaiveDateTime, String> {
let clean_datetime = datetime.trim_matches(|c| c == '"' || c == '\'');
chrono::NaiveDateTime::parse_from_str(clean_datetime, "%Y-%m-%dT%H:%M:%S")
.map_err(|_| String::from("Invalid datetime format. Expected YYYY-MM-DDTHH:MM:SS"))
}
fn apply_minute_difference_to_datetime(mut dt: chrono::NaiveDateTime, time_diff_in_minutes: i16) -> chrono::NaiveDateTime {
dt += chrono::TimeDelta::try_minutes(time_diff_in_minutes as i64).unwrap();
dt
}
fn format_datetime_to_string(dt: chrono::NaiveDateTime) -> String {
dt.format("%Y-%m-%dT%H:%M:%S").to_string()
}
fn get_current_time_in_timezone(tz: chrono_tz::Tz) -> chrono::DateTime<chrono_tz::Tz> {
chrono::Utc::now().with_timezone(&tz)
}
fn parse_offset_seconds(offset_seconds: String) -> Result<i64, String> {
let clean_offset = offset_seconds.trim_matches(|c| c == '"' || c == '\'');
clean_offset.parse::<i64>().map_err(|_| String::from("Invalid offset_seconds format"))
}
fn apply_seconds_difference_to_datetime(mut dt: chrono::DateTime<chrono_tz::Tz>, offset_secs: i64) -> chrono::DateTime<chrono_tz::Tz> {
dt += chrono::TimeDelta::try_seconds(offset_secs).unwrap();
dt
}
fn build_time_and_datetime_json_data(dt: chrono::DateTime<chrono_tz::Tz>) -> serde_json::Value {
json!({
"time": dt.format("%H:%M:%S").to_string(),
"datetime": dt.format("%Y-%m-%dT%H:%M:%S").to_string()
})
}
+482
View File
@@ -0,0 +1,482 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Time</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
background: #fff;
color: #111;
font-family: Georgia, "Times New Roman", serif;
height: 100vh;
overflow: hidden;
}
/* ── Clock view ── */
#clock-view {
display: flex;
flex-direction: column;
height: 100vh;
padding: 3vw 4vw;
}
.clock-main {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
}
.time {
font-size: clamp(4rem, 22vw, 28rem);
font-weight: normal;
letter-spacing: -0.04em;
line-height: 0.9;
font-variant-numeric: tabular-nums;
}
.date {
font-size: clamp(1rem, 2.2vw, 2rem);
color: #888;
font-style: italic;
margin-top: 1.5vw;
}
.clock-footer {
display: flex;
justify-content: flex-end;
}
/* ── Converter view ── */
#converter-view {
display: none;
height: 100vh;
padding: 3vw 4vw;
flex-direction: column;
}
.section-head {
border-bottom: 1px solid #111;
padding-bottom: 0.75rem;
margin-bottom: 2rem;
display: flex;
align-items: baseline;
gap: 1.5rem;
}
.section-head h1 {
font-size: clamp(1.25rem, 3vw, 2rem);
font-weight: normal;
}
.form-body {
display: flex;
flex-direction: column;
gap: 1.75rem;
max-width: 480px;
}
.field label {
display: block;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.12em;
color: #888;
margin-bottom: 0.4rem;
}
input[type="text"],
input[type="time"] {
width: 100%;
font-family: inherit;
font-size: 1.1rem;
padding: 0.4rem 0;
border: none;
border-bottom: 1px solid #ccc;
outline: none;
background: transparent;
color: #111;
}
input:focus {
border-bottom-color: #111;
}
input::placeholder { color: #bbb; }
.row {
display: flex;
gap: 0.75rem;
margin-top: 2rem;
}
.btn {
font-family: inherit;
font-size: 0.8rem;
padding: 0.6rem 1.4rem;
cursor: pointer;
border: 1px solid #111;
background: #111;
color: #fff;
letter-spacing: 0.07em;
text-transform: uppercase;
}
.btn:hover { background: #333; border-color: #333; }
.btn-ghost {
background: #fff;
color: #111;
}
.btn-ghost:hover { background: #f0f0f0; }
/* ── Result ── */
.result {
display: none;
margin-top: 2.5rem;
padding-top: 1.75rem;
border-top: 1px solid #111;
max-width: 480px;
}
.result-label {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.12em;
color: #888;
margin-bottom: 0.4rem;
}
.result-time {
font-size: clamp(3rem, 8vw, 6rem);
font-weight: normal;
letter-spacing: -0.03em;
line-height: 1;
font-variant-numeric: tabular-nums;
}
.result-sub {
font-size: 0.9rem;
color: #888;
font-style: italic;
margin-top: 0.4rem;
}
.error {
display: none;
margin-top: 1.5rem;
padding: 0.75rem;
border: 1px solid #111;
font-size: 0.875rem;
background: #f5f5f5;
max-width: 480px;
}
/* ── Share view ── */
#share-view {
display: none;
height: 100vh;
padding: 3vw 4vw;
flex-direction: column;
}
.share-main {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
max-width: 600px;
}
.share-from {
font-size: clamp(1rem, 2vw, 1.5rem);
color: #888;
font-style: italic;
margin-bottom: 0.5rem;
}
.share-time-in {
font-size: clamp(3rem, 12vw, 10rem);
font-weight: normal;
letter-spacing: -0.04em;
line-height: 0.9;
font-variant-numeric: tabular-nums;
margin-bottom: 1.5rem;
}
.share-arrow {
font-size: clamp(1rem, 2vw, 1.5rem);
color: #888;
margin-bottom: 0.5rem;
}
.share-to-label {
font-size: clamp(1rem, 2vw, 1.5rem);
color: #888;
font-style: italic;
margin-bottom: 0.5rem;
}
.share-time-out {
font-size: clamp(3rem, 12vw, 10rem);
font-weight: normal;
letter-spacing: -0.04em;
line-height: 0.9;
font-variant-numeric: tabular-nums;
}
.share-day {
font-size: clamp(0.9rem, 1.5vw, 1.25rem);
color: #888;
font-style: italic;
margin-top: 0.75rem;
}
.share-loading {
font-size: clamp(1.5rem, 5vw, 3rem);
color: #888;
font-style: italic;
}
</style>
</head>
<body>
<!-- Clock -->
<div id="clock-view">
<div class="clock-main">
<div class="time" id="live-time">00:00:00</div>
<div class="date" id="live-date"></div>
</div>
<div class="clock-footer">
<button class="btn" onclick="showView('converter-view')">Convert &rarr;</button>
</div>
</div>
<!-- Converter -->
<div id="converter-view">
<div class="section-head">
<h1>Time Converter</h1>
</div>
<form id="conv-form" onsubmit="doConvert(event)" class="form-body">
<div class="field">
<label>From timezone</label>
<input type="text" id="from-tz" placeholder="UTC, America/New_York, PST&hellip;" required>
</div>
<div class="field">
<label>To timezone</label>
<input type="text" id="to-tz" placeholder="Europe/Paris, Asia/Tokyo&hellip;" required>
</div>
<div class="field">
<label>Time</label>
<input type="time" id="time-in" step="1" required>
</div>
<div class="row">
<button type="submit" class="btn">Convert</button>
<button type="button" class="btn btn-ghost" onclick="goHome()">Back</button>
</div>
</form>
<div class="error" id="conv-error"></div>
<div class="result" id="conv-result">
<div class="result-label">Result</div>
<div class="result-time" id="result-time"></div>
<div class="result-sub" id="result-day"></div>
<div class="row">
<button class="btn btn-ghost" onclick="copyShare()" id="share-btn" style="margin-top:0">Copy share link</button>
</div>
</div>
</div>
<!-- Share / Deeplink view -->
<div id="share-view">
<div class="section-head">
<h1>Time Converter</h1>
<button class="btn btn-ghost" onclick="goHome()">Home</button>
</div>
<div class="share-main">
<div id="share-loading" class="share-loading">Converting&hellip;</div>
<div id="share-content" style="display:none">
<div class="share-from" id="share-from-label"></div>
<div class="share-time-in" id="share-time-in"></div>
<div class="share-arrow">&#8595; in your timezone</div>
<div class="share-to-label" id="share-to-label"></div>
<div class="share-time-out" id="share-time-out"></div>
<div class="share-day" id="share-day-diff"></div>
</div>
<div id="share-error" class="error" style="margin-top:2rem"></div>
</div>
</div>
<script>
// ── Clock ──
function pad(n) { return n < 10 ? '0' + n : '' + n; }
function tick() {
var d = new Date();
document.getElementById('live-time').textContent =
pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds());
document.getElementById('live-date').textContent =
d.toLocaleDateString('en-US', { weekday:'long', year:'numeric', month:'long', day:'numeric' });
}
tick();
setInterval(tick, 1000);
// ── View routing ──
function showView(id) {
var ids = ['clock-view', 'converter-view', 'share-view'];
for (var i = 0; i < ids.length; i++) {
document.getElementById(ids[i]).style.display = ids[i] === id ? 'flex' : 'none';
}
}
function goHome() {
document.getElementById('conv-error').style.display = 'none';
document.getElementById('conv-result').style.display = 'none';
document.getElementById('conv-form').reset();
history.pushState(null, '', '/');
showView('clock-view');
}
// ── Converter ──
var _lastFrom = '', _lastTime = '';
function doConvert(e) {
e.preventDefault();
var fromTz = document.getElementById('from-tz').value.trim();
var toTz = document.getElementById('to-tz').value.trim();
var t = document.getElementById('time-in').value.trim();
if (t.split(':').length === 2) t += ':00';
_lastFrom = fromTz;
_lastTime = t.substring(0, 5); // HH:MM for share link
callConvert(fromTz, toTz, t, function(converted, dayDiff, errMsg) {
var errEl = document.getElementById('conv-error');
var resEl = document.getElementById('conv-result');
if (errMsg) {
resEl.style.display = 'none';
errEl.textContent = errMsg;
errEl.style.display = 'block';
} else {
errEl.style.display = 'none';
document.getElementById('result-time').textContent = converted;
var d = dayDiff;
document.getElementById('result-day').textContent =
d === 0 ? 'Same day' : (d > 0 ? '+' + d : d) + ' day(s)';
resEl.style.display = 'block';
}
});
}
function callConvert(fromTz, toTz, t, cb) {
var url = '/api/v1/timeconvert/with-time/' +
encodeURIComponent(fromTz) + '/' +
encodeURIComponent(toTz) + '/' +
encodeURIComponent(t);
var xhr = new XMLHttpRequest();
xhr.open('GET', url);
xhr.onload = function() {
try {
var j = JSON.parse(xhr.responseText);
if (j.status === 'OK') {
cb(j.data.converted_time, j.data.day_diff, null);
} else {
cb(null, 0, j.error_msg || 'Conversion failed.');
}
} catch(_) { cb(null, 0, 'Invalid server response.'); }
};
xhr.onerror = function() { cb(null, 0, 'Network error.'); };
xhr.send();
}
// ── Share link ──
function copyShare() {
var t = _lastTime.replace(':', '%3A');
var url = window.location.origin + '/share?tz=' +
encodeURIComponent(_lastFrom) + '&t=' + t;
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(url);
} else {
var ta = document.createElement('textarea');
ta.value = url;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
}
var btn = document.getElementById('share-btn');
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy share link'; }, 2000);
}
// ── Share / deeplink landing ──
function loadShareView() {
var params = new URLSearchParams(window.location.search);
var fromTz = params.get('tz') || '';
var time = params.get('t') || '';
if (!fromTz || !time) {
document.getElementById('share-loading').style.display = 'none';
var errEl = document.getElementById('share-error');
errEl.textContent = 'Invalid share link.';
errEl.style.display = 'block';
return;
}
if (time.split(':').length === 2) time += ':00';
var localTz = (Intl && Intl.DateTimeFormat
? Intl.DateTimeFormat().resolvedOptions().timeZone
: 'UTC');
callConvert(fromTz, localTz, time, function(converted, dayDiff, errMsg) {
document.getElementById('share-loading').style.display = 'none';
if (errMsg) {
var errEl = document.getElementById('share-error');
errEl.textContent = errMsg;
errEl.style.display = 'block';
} else {
document.getElementById('share-from-label').textContent = fromTz;
document.getElementById('share-time-in').textContent = time.substring(0, 5);
document.getElementById('share-to-label').textContent = localTz;
document.getElementById('share-time-out').textContent = converted.substring(0, 5);
var d = dayDiff;
document.getElementById('share-day-diff').textContent =
d === 0 ? '' : (d > 0 ? '+' + d : d) + ' day(s)';
document.getElementById('share-content').style.display = 'block';
}
});
}
// ── Init: decide which view to show ──
if (window.location.pathname === '/share') {
showView('share-view');
loadShareView();
} else {
showView('clock-view');
}
</script>
</body>
</html>
+38
View File
@@ -0,0 +1,38 @@
use axum::{
routing::get,
response::Html,
Router,
};
use tokio::net::TcpListener;
mod api;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Build our application with a router
let app = Router::new()
.route("/", get(root))
.route("/share", get(root))
.route("/api", get(get_api_root))
.route("/api/v1", get(api::v1::root))
.route("/api/v1/timeconvert/with-time/{from_timezone}/{to_timezone}/{time}", get(api::v1::timeconvert_with_time))
.route("/api/v1/timeconvert/with-date-time/{from_timezone}/{to_timezone}/{datetime}", get(api::v1::timeconvert_with_date_time))
.route("/api/v1/time/live/{timezone}", get(api::v1::time_live))
.route("/api/v1/time/live/{timezone}/{offset_seconds}", get(api::v1::time_live_with_offset));
// Run our app, listening on port 8080
let listener = TcpListener::bind("127.0.0.1:8080").await?;
println!("Server running on http://127.0.0.1:8080");
axum::serve(listener, app).await?;
Ok(())
}
async fn root() -> Html<&'static str> {
Html(include_str!("index.html"))
}
async fn get_api_root() -> String {
format!("Available API versions: v1")
}