Time Zones Are Hard - https://tz.rita.moe

I now have something "working".

+7 -35
app.vue
···
<header>
<nav>
<NuxtLink to="/">Time Zones are hard</NuxtLink>
-
<span v-if="$colorMode.value === 'dark'" @click="$colorMode.preference = 'light'">
-
Light
-
</span>
-
<span v-else @click="$colorMode.preference = 'dark'">
-
Dark
-
</span>
+
<div class="nav-spacer"/>
+
<Icon
+
class="theme-toggle"
+
name="line-md:light-dark"
+
@click="$colorMode.value === 'dark' ? $colorMode.preference = 'light' : $colorMode.preference = 'dark'"
+
/>
</nav>
</header>
···
</script>
<style lang="scss">
-
header,
-
main,
-
footer {
-
margin: 1rem auto;
-
max-width: 900px;
-
}
-
-
header {
-
background: white;
-
border-radius: 10px;
-
padding: 1rem 2rem;
-
}
-
-
main {
-
padding: 1rem 2rem;
-
-
h1 {
-
text-align: center;
-
}
-
}
-
-
footer {
-
color: gray;
-
font-size: small;
-
font-style: italic;
-
text-align: center;
-
}
-
-
// @TODO: Add a dark colors
+
@import '@/styles/app';
</style>
+12 -7
error.vue
···
<template>
-
<h1>Error {{ props.error?.statusCode }}...</h1>
-
<h2>{{ props.error?.message }}</h2>
-
<p>
-
Did you try to type something manually?<br/>
-
<button @click="handleError">Go back home.</button>
-
</p>
+
<main>
+
<h1>Error {{ props.error?.statusCode }}...</h1>
+
<h2>{{ props.error?.message }}</h2>
+
<p>
+
<button type="button" @click="handleError">Go back home</button>
+
</p>
+
</main>
</template>
<script setup>
···
})
const handleError = () => clearError({ redirect: '/' })
-
</script>
+
</script>
+
+
<style lang="scss">
+
@import '@/styles/app';
+
</style>
+7 -1
nuxt.config.ts
···
'@nuxtjs/i18n',
'dayjs-nuxt',
'nuxt-icon'
-
]
+
],
+
dayjs: {
+
locales: ['en', 'fr'],
+
plugins: ['relativeTime', 'utc', 'timezone', 'localizedFormat', 'customParseFormat'],
+
defaultLocale: 'en',
+
defaultTimezone: 'UTC'
+
}
})
+2 -1
package.json
···
"name": "kdy-ts",
"version": "0.0.1",
"private": true,
+
"allowJs": true,
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
···
"@nuxt/devtools": "latest",
"@nuxtjs/color-mode": "^3.3.0",
"@nuxtjs/i18n": "^8.0.0-rc.2",
-
"@types/node": "^18.17.3",
+
"@types/node": "^20.4.10",
"dayjs-nuxt": "^1.1.2",
"nuxt": "^3.6.5",
"nuxt-icon": "^0.5.0",
+102 -18
pages/[id].vue
···
<template>
-
<h1>{{ localDateTime }}</h1>
-
<p>
-
The shared date and time was {{ origDateTime }}.
-
That's {{ tzDiff }} of difference from your time zone.
-
</p>
-
<h2>In other popular time zones</h2>
-
<div class="tz-table">
-
<div v-for="tz in tzList" :key="tz.code">
-
{{ tz.code }}<br>
-
{{ tz.dt }}
+
<template v-if="!origDateTime" class="loading">
+
<h1>Loading...</h1>
+
</template>
+
<template v-else>
+
<h1>
+
{{ localDateTime }}<br/>
+
<small>({{ relativeTime }})</small>
+
</h1>
+
<p v-if="tzDiff" class="tz-diff">
+
You have {{ tzDiff }} hours of difference with the original timezone.
+
</p>
+
+
<br/>
+
+
<h2>In other popular time zones</h2>
+
<div class="tz-table">
+
<div v-for="tz in tzList" :key="tz">
+
{{ tz.split('/')[1].replace('_', ' ') }}
+
<span v-html="toTZ(tz)"/>
+
</div>
</div>
-
</div>
+
</template>
</template>
<script setup>
···
import { ref, onMounted } from 'vue'
const route = useRoute()
+
const dayjs = useDayjs()
const localDateTime = ref('...')
-
const origDateTime = ref('...')
-
const tzDiff = ref('...')
-
const tzList = ref([])
+
const relativeTime = ref('...')
+
const origDateTime = ref(null)
+
const tzDiff = ref(null)
+
const tzList = ref([
+
'America/Los_Angeles',
+
'America/New_York',
+
'Europe/London',
+
'Europe/Paris',
+
'Asia/Dubai',
+
'Asia/Bangkok',
+
'Asia/Tokyo',
+
'Australia/Sydney'
+
])
+
+
const toTZ = (tz) => {
+
return origDateTime.value?.tz(tz).format('([UTC]Z) [<br>] L LT')
+
}
definePageMeta({
validate: async (route) => {
// Check if the id is made up of a date
-
return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+\d{2}:\d{2}$/.test(route.params.id)
+
return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}[\+|-]\d{4}$/.test(route.params.id)
}
})
onMounted(() => {
-
console.log('yo')
-
origDateTime.value = route.params.id
+
const dt = dayjs(route.params.id, 'YYYY-MM-DDTHH:mmZZ')
+
origDateTime.value = dt
+
+
if (process.client) {
+
const local = dt.local()
+
+
localDateTime.value = local.format('L LT')
+
+
// Sorry that this is horrible
+
const diff = (parseInt(local.format('ZZ')) - parseInt(route.params.id.slice(-5))).toString()
+
const h = diff.slice(0, diff.length - 2)
+
const m = diff.slice(-2)
+
+
if (h === '' && m === '0') {
+
tzDiff.value = null
+
} else if (h.startsWith('-') && h.length == 2) {
+
tzDiff.value = '-0' + h.slice(1) + ':' + m
+
} else if (h.length == 1) {
+
tzDiff.value = '+0' + h + ':' + m
+
} else if (h === '') {
+
tzDiff.value = '+00:' + m
+
} else if (!h.startsWith('-')) {
+
tzDiff.value = '+' + h + ':' + m
+
} else {
+
tzDiff.value = h + ':' + m
+
}
+
+
// They could have done something to detect to/from by itself...
+
if (local.toDate().getTime() > Date.now()) {
+
// For some reason, it acts like fromNow (in French at least)
+
relativeTime.value = local.toNow(true)
+
} else {
+
relativeTime.value = local.fromNow()
+
}
+
}
})
-
</script>
+
</script>
+
+
<style lang="scss">
+
.tz-diff {
+
text-align: center;
+
}
+
+
.tz-table {
+
display: grid;
+
grid-template-columns: repeat(1, 1fr);
+
row-gap: 1rem;
+
column-gap: 1rem;
+
text-align: center;
+
+
@media screen and (min-width: 440px) {
+
grid-template-columns: repeat(2, 1fr);
+
}
+
+
@media screen and (min-width: 630px) {
+
grid-template-columns: repeat(3, 1fr);
+
}
+
+
@media screen and (min-width: 820px) {
+
grid-template-columns: repeat(4, 1fr);
+
}
+
}
+
</style>
+132 -7
pages/index.vue
···
<template>
-
<h1>Time Zones are hard</h1>
<p>
Fill out this form to make a page that shows a set date and time in the visitor's time zone.
It will also show the time in other popular time zones.
</p>
-
<form>
-
<label for="datetime">Date and Time</label>
-
<input type="datetime-local" id="datetime" />
+
<form class="creation-form">
+
<label for="datetime">Date and Time:</label>&nbsp;
+
<input required type="datetime-local" id="datetime" v-model="selectedDT" />
+
<br/>
-
<button type="submit">Submit</button>
+
+
<label for="timezone">Time Zone:</label>&nbsp;
+
<select required id="timezone" v-model="selectedTZ">
+
<option selected :value="userTZ">Your time zone: {{ userTZ }}</option>
+
<optgroup label="General" v-if="tzList.general.length > 0">
+
<option
+
v-for="tz in tzList.general"
+
:key="tz"
+
:value="tz">
+
{{ tz }} ({{ getOffset(tz) }})</option>
+
</optgroup>
+
<optgroup label="GMT/UTC" v-if="tzList.gmt.length > 0">
+
<option
+
v-for="tz in tzList.gmt"
+
:key="tz"
+
:value="tz">
+
{{ tz.replace('Etc/', '').replace('UTC', 'GMT/UTC') }}</option>
+
</optgroup>
+
<optgroup label="Region">
+
<option
+
v-for="tz in tzList.region"
+
:key="tz"
+
:value="tz">
+
{{ tz }}</option>
+
</optgroup>
+
</select>
+
+
<br/><br/>
+
+
<button type="button" @click="goToResult">Submit</button>
</form>
</template>
-
<style lang="scss">
-
</style>
+
<script setup>
+
const dayjs = useDayjs()
+
+
const goToResult = () => {
+
if (document.querySelector('.creation-form').checkValidity()) {
+
const dt = dayjs.tz(selectedDT.value, selectedTZ.value).format('YYYY-MM-DDTHH:mmZZ')
+
+
// I have to do a whole window.location here because it wont take my param value
+
// into account with the client-side routing
+
window.location = `/${dt}`
+
} else {
+
alert('Please make sure you\'ve completed the form.')
+
}
+
}
+
+
const getOffset = (tz) => {
+
const offset = dayjs().tz(tz).utcOffset() / 60
+
return offset >= 0 ? 'UTC+' + offset : 'UTC' + offset
+
}
+
+
// Those two Arrays are not present in Chrome btw
+
// I've also added extra values, just in case
+
const generalTZ = [
+
'HST',
+
'PST',
+
'PDT',
+
'PST8PDT',
+
'MST',
+
'MST7MDT',
+
'CST',
+
'CDT',
+
'CST6CDT',
+
'EST',
+
'EST5EDT',
+
'WET',
+
'BST',
+
'CET',
+
'MET',
+
'EET',
+
'JST'
+
]
+
const gmtTZ = [
+
'Etc/GMT-14',
+
'Etc/GMT-13',
+
'Etc/GMT-12',
+
'Etc/GMT-11',
+
'Etc/GMT-10',
+
'Etc/GMT-9',
+
'Etc/GMT-8',
+
'Etc/GMT-7',
+
'Etc/GMT-6',
+
'Etc/GMT-5',
+
'Etc/GMT-4',
+
'Etc/GMT-3',
+
'Etc/GMT-2',
+
'Etc/GMT-1',
+
'UTC',
+
'Etc/GMT+1',
+
'Etc/GMT+2',
+
'Etc/GMT+3',
+
'Etc/GMT+4',
+
'Etc/GMT+5',
+
'Etc/GMT+6',
+
'Etc/GMT+7',
+
'Etc/GMT+8',
+
'Etc/GMT+9',
+
'Etc/GMT+10',
+
'Etc/GMT+11',
+
'Etc/GMT+12'
+
]
+
+
const userTZ = ref('...')
+
const tzList = ref({
+
general: [],
+
gmt: [],
+
region: []
+
})
+
+
const selectedDT = ref()
+
const selectedTZ = ref()
+
+
onMounted(() => {
+
if (process.client) {
+
const allTZ = Intl.supportedValuesOf('timeZone')
+
tzList.value.general = generalTZ.filter(
+
(tz) => allTZ.find(i => i === tz)
+
)
+
tzList.value.gmt = gmtTZ.filter(
+
(tz) => allTZ.find(i => i === tz)
+
)
+
tzList.value.region = allTZ.filter(
+
(tz) => tz.includes('/') && !tz.includes('Etc/')
+
)
+
+
userTZ.value = selectedTZ.value = dayjs.tz.guess()
+
}
+
})
+
</script>
+80
styles/app.scss
···
+
body {
+
background-color: #efefef;
+
color: #000;
+
font-family: 'Trebuchet MS', 'Lucida Sans Unicode', 'Lucida Grande', 'Lucida Sans', Arial, sans-serif;
+
margin: 0;
+
}
+
+
#__nuxt {
+
display: flex;
+
flex-direction: column;
+
margin: 0 auto;
+
max-width: 900px;
+
min-height: 100vh;
+
}
+
+
header {
+
background: white;
+
border-radius: 10px;
+
font-size: 1.5rem;
+
margin: 1rem 0.5rem 0;
+
padding: 1rem 1.5rem;
+
position: sticky;
+
top: 1rem;
+
z-index: 999;
+
+
@media screen and (min-width: 500px) {
+
font-size: 2rem;
+
}
+
+
a {
+
color: inherit;
+
text-decoration: none;
+
}
+
+
nav {
+
align-items: center;
+
display: flex;
+
+
.nav-spacer {
+
flex: 1;
+
}
+
+
.theme-toggle {
+
cursor: pointer;
+
}
+
}
+
+
}
+
+
main {
+
flex: 1;
+
padding: 1rem 2rem;
+
+
h1 {
+
margin: 1rem 0;
+
text-align: center;
+
}
+
+
button {
+
cursor: pointer;
+
}
+
}
+
+
footer {
+
color: gray;
+
font-size: 0.7rem;
+
padding: 1rem;
+
text-align: center;
+
}
+
+
html.dark-mode {
+
body {
+
background-color: #111;
+
color: #fff;
+
}
+
+
header {
+
background: #000;
+
}
+
}
+10 -15
yarn.lock
···
slash "^4.0.0"
"@rollup/plugin-commonjs@^25.0.2":
-
version "25.0.3"
-
resolved "https://registry.yarnpkg.com/@rollup/plugin-commonjs/-/plugin-commonjs-25.0.3.tgz#eb5217ebae43d63a172b516655be270ed258bdcc"
-
integrity sha512-uBdtWr/H3BVcgm97MUdq2oJmqBR23ny1hOrWe2PKo9FTbjsGqg32jfasJUKYAI5ouqacjRnj65mBB/S79F+GQA==
+
version "25.0.4"
+
resolved "https://registry.yarnpkg.com/@rollup/plugin-commonjs/-/plugin-commonjs-25.0.4.tgz#a7547a0c4ec3fa79818eb313e1de0023e548f4e6"
+
integrity sha512-L92Vz9WUZXDnlQQl3EwbypJR4+DM2EbsO+/KOcEkP4Mc6Ct453EeDB2uH9lgRwj4w5yflgNpq9pHOiY8aoUXBQ==
dependencies:
"@rollup/pluginutils" "^5.0.1"
commondir "^1.0.1"
···
dependencies:
"@types/node" "*"
-
"@types/node@*":
-
version "20.4.9"
-
resolved "https://registry.yarnpkg.com/@types/node/-/node-20.4.9.tgz#c7164e0f8d3f12dfae336af0b1f7fdec8c6b204f"
-
integrity sha512-8e2HYcg7ohnTUbHk8focoklEQYvemQmu9M/f43DZVx43kHn0tE3BY/6gSDxS7k0SprtS0NHvj+L80cGLnoOUcQ==
-
-
"@types/node@^18.17.3":
-
version "18.17.4"
-
resolved "https://registry.yarnpkg.com/@types/node/-/node-18.17.4.tgz#bf8ae9875528929cc9930dc3f066cd0481fe1231"
-
integrity sha512-ATL4WLgr7/W40+Sp1WnNTSKbgVn6Pvhc/2RHAdt8fl6NsQyp4oPCi2eKcGOvA494bwf1K/W6nGgZ9TwDqvpjdw==
+
"@types/node@*", "@types/node@^20.4.10":
+
version "20.4.10"
+
resolved "https://registry.yarnpkg.com/@types/node/-/node-20.4.10.tgz#73c9480791e3ddeb4887a660fc93a7f59353ad45"
+
integrity sha512-vwzFiiy8Rn6E0MtA13/Cxxgpan/N6UeNYR9oUu6kuJWxu6zCk98trcDp8CBhbtaeuq9SykCmXkFr2lWLoPcvLg==
"@types/resolve@1.20.2":
version "1.20.2"
···
vscode-uri "^3.0.2"
vite-plugin-inspect@^0.7.35:
-
version "0.7.36"
-
resolved "https://registry.yarnpkg.com/vite-plugin-inspect/-/vite-plugin-inspect-0.7.36.tgz#736be99c2b8830902b5bee06d2f14b62cb4067db"
-
integrity sha512-zdFTvLAU0Xb0C9B+JepUN353bZxBWqgkE71URe/9kfM38r6PtR5y2mo0CH1lBuX1DHNhKumLMUGXkvJ+z2OJ4w==
+
version "0.7.37"
+
resolved "https://registry.yarnpkg.com/vite-plugin-inspect/-/vite-plugin-inspect-0.7.37.tgz#80f4b0f661831300f880418b450f70c4963c7bc0"
+
integrity sha512-cRHzaE8g8/UUK0hA5DunAXiN3eJnq7Dpcu2bVf5dCRj/MYBKGeAv0Z27vYMhm2F/oeE5aG3+oYF4tkdhOlpXxg==
dependencies:
"@antfu/utils" "^0.7.5"
"@rollup/pluginutils" "^5.0.2"