Add per account overridable timezone setting

To make it possible to render the timetable in the user's local time we
need to know the timezone to render it in on the server.  Otherwise
there will be hydration errors and paint flashing as the client renders
a different timezone.

Add a server global default timezone that can be overriden on a
per-account bases to prepare for timezone handling the timetable.
This commit is contained in:
Hornwitser 2025-03-09 15:53:51 +01:00
parent 264c97b586
commit c4a6f6b3f9
6 changed files with 83 additions and 7 deletions

View file

@ -6,6 +6,7 @@ export default defineNuxtConfig({
cookieSecretKey: "", cookieSecretKey: "",
vapidPrivateKey: "", vapidPrivateKey: "",
public: { public: {
defaultTimezone: "Europe/Oslo",
vapidPublicKey: "", vapidPublicKey: "",
} }
} }

View file

@ -10,6 +10,7 @@
"postinstall": "nuxt prepare" "postinstall": "nuxt prepare"
}, },
"dependencies": { "dependencies": {
"luxon": "^3.5.0",
"nuxt": "^3.15.4", "nuxt": "^3.15.4",
"vue": "latest", "vue": "latest",
"vue-router": "latest", "vue-router": "latest",
@ -23,6 +24,7 @@
] ]
}, },
"devDependencies": { "devDependencies": {
"@types/luxon": "^3.4.2",
"@types/web-push": "^3.6.4" "@types/web-push": "^3.6.4"
} }
} }

View file

@ -5,6 +5,15 @@
Name: {{ session?.account.name }} Name: {{ session?.account.name }}
</p> </p>
<p>Access: {{ session?.account.type }}</p> <p>Access: {{ session?.account.type }}</p>
<form @submit.prevent="changeSettings">
<label>
Timezone
<input type="text" v-model="timezone">
</label>
<button type="submit">
Save
</button>
</form>
<p> <p>
<PushNotification /> <PushNotification />
</p> </p>
@ -26,6 +35,22 @@ definePageMeta({
const { data: session } = useAccountSession(); const { data: session } = useAccountSession();
const { refresh: sessionRefresh } = useAccountSession(); const { refresh: sessionRefresh } = useAccountSession();
const timezone = ref(session.value?.account.timezone ?? "");
async function changeSettings() {
try {
await $fetch("/api/account", {
method: "patch",
body: {
timezone: timezone.value,
}
});
await sessionRefresh();
} catch (err: any) {
alert(err.data.message);
}
}
async function deleteAccount() { async function deleteAccount() {
try { try {
await $fetch.raw("/api/account", { await $fetch.raw("/api/account", {

17
pnpm-lock.yaml generated
View file

@ -8,6 +8,9 @@ importers:
.: .:
dependencies: dependencies:
luxon:
specifier: ^3.5.0
version: 3.5.0
nuxt: nuxt:
specifier: ^3.15.4 specifier: ^3.15.4
version: 3.15.4(@parcel/watcher@2.5.1)(@types/node@22.13.8)(db0@0.2.4)(ioredis@5.5.0)(magicast@0.3.5)(rollup@4.34.9)(terser@5.39.0)(typescript@5.8.2)(vite@6.2.0(@types/node@22.13.8)(jiti@2.4.2)(terser@5.39.0)(yaml@2.7.0))(yaml@2.7.0) version: 3.15.4(@parcel/watcher@2.5.1)(@types/node@22.13.8)(db0@0.2.4)(ioredis@5.5.0)(magicast@0.3.5)(rollup@4.34.9)(terser@5.39.0)(typescript@5.8.2)(vite@6.2.0(@types/node@22.13.8)(jiti@2.4.2)(terser@5.39.0)(yaml@2.7.0))(yaml@2.7.0)
@ -21,6 +24,9 @@ importers:
specifier: ^3.6.7 specifier: ^3.6.7
version: 3.6.7 version: 3.6.7
devDependencies: devDependencies:
'@types/luxon':
specifier: ^3.4.2
version: 3.4.2
'@types/web-push': '@types/web-push':
specifier: ^3.6.4 specifier: ^3.6.4
version: 3.6.4 version: 3.6.4
@ -873,6 +879,9 @@ packages:
'@types/http-proxy@1.17.16': '@types/http-proxy@1.17.16':
resolution: {integrity: sha512-sdWoUajOB1cd0A8cRRQ1cfyWNbmFKLAqBB89Y8x5iYyG/mkJHc0YUH8pdWBy2omi9qtCpiIgGjuwO0dQST2l5w==} resolution: {integrity: sha512-sdWoUajOB1cd0A8cRRQ1cfyWNbmFKLAqBB89Y8x5iYyG/mkJHc0YUH8pdWBy2omi9qtCpiIgGjuwO0dQST2l5w==}
'@types/luxon@3.4.2':
resolution: {integrity: sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==}
'@types/node@22.13.8': '@types/node@22.13.8':
resolution: {integrity: sha512-G3EfaZS+iOGYWLLRCEAXdWK9my08oHNZ+FHluRiggIYJPOXzhOiDgpVCUHaUvyIC5/fj7C/p637jdzC666AOKQ==} resolution: {integrity: sha512-G3EfaZS+iOGYWLLRCEAXdWK9my08oHNZ+FHluRiggIYJPOXzhOiDgpVCUHaUvyIC5/fj7C/p637jdzC666AOKQ==}
@ -1943,6 +1952,10 @@ packages:
lru-cache@5.1.1: lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
luxon@3.5.0:
resolution: {integrity: sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==}
engines: {node: '>=12'}
magic-string-ast@0.7.0: magic-string-ast@0.7.0:
resolution: {integrity: sha512-686fgAHaJY7wLTFEq7nnKqeQrhqmXB19d1HnqT35Ci7BN6hbAYLZUezTQ062uUHM7ggZEQlqJ94Ftls+KDXU8Q==} resolution: {integrity: sha512-686fgAHaJY7wLTFEq7nnKqeQrhqmXB19d1HnqT35Ci7BN6hbAYLZUezTQ062uUHM7ggZEQlqJ94Ftls+KDXU8Q==}
engines: {node: '>=16.14.0'} engines: {node: '>=16.14.0'}
@ -4036,6 +4049,8 @@ snapshots:
dependencies: dependencies:
'@types/node': 22.13.8 '@types/node': 22.13.8
'@types/luxon@3.4.2': {}
'@types/node@22.13.8': '@types/node@22.13.8':
dependencies: dependencies:
undici-types: 6.20.0 undici-types: 6.20.0
@ -5210,6 +5225,8 @@ snapshots:
dependencies: dependencies:
yallist: 3.1.1 yallist: 3.1.1
luxon@3.5.0: {}
magic-string-ast@0.7.0: magic-string-ast@0.7.0:
dependencies: dependencies:
magic-string: 0.30.17 magic-string: 0.30.17

View file

@ -1,12 +1,16 @@
import { Account } from "~/shared/types/account"; import { Account } from "~/shared/types/account";
import { readAccounts, writeAccounts } from "~/server/database"; import { readAccounts, writeAccounts } from "~/server/database";
import { DateTime } from "luxon";
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const session = await requireAccountSession(event); const session = await requireAccountSession(event);
const body: Pick<Account, "interestedIds"> = await readBody(event); const body: Pick<Account, "interestedIds" | "timezone"> = await readBody(event);
if ( if (
body.interestedIds !== undefined
&& !(
!(body.interestedIds instanceof Array) !(body.interestedIds instanceof Array)
|| !body.interestedIds.every(id => typeof id === "string") || !body.interestedIds.every(id => typeof id === "string")
)
) { ) {
throw createError({ throw createError({
status: 400, status: 400,
@ -14,17 +18,43 @@ export default defineEventHandler(async (event) => {
}); });
} }
if (body.timezone !== undefined) {
if (typeof body.timezone !== "string") {
throw createError({
status: 400,
message: "Invalid timezone",
});
}
if (body.timezone.length) {
const zonedTime = DateTime.local().setZone(body.timezone);
if (!zonedTime.isValid) {
throw createError({
status: 400,
message: "Invalid timezone: " + zonedTime.invalidExplanation,
});
}
}
}
const accounts = await readAccounts(); const accounts = await readAccounts();
const sessionAccount = accounts.find(account => account.id === session.accountId); const sessionAccount = accounts.find(account => account.id === session.accountId);
if (!sessionAccount) { if (!sessionAccount) {
throw Error("Account does not exist"); throw Error("Account does not exist");
} }
if (body.interestedIds !== undefined) {
if (body.interestedIds.length) { if (body.interestedIds.length) {
sessionAccount.interestedIds = body.interestedIds; sessionAccount.interestedIds = body.interestedIds;
} else { } else {
delete sessionAccount.interestedIds; delete sessionAccount.interestedIds;
} }
}
if (body.timezone !== undefined) {
if (body.timezone)
sessionAccount.timezone = body.timezone;
else
delete sessionAccount.timezone;
}
await writeAccounts(accounts); await writeAccounts(accounts);
// Update Schedule counts. // Update Schedule counts.

View file

@ -4,6 +4,7 @@ export interface Account {
/** Name of the account. Not present on anonymous accounts */ /** Name of the account. Not present on anonymous accounts */
name?: string, name?: string,
interestedIds?: string[], interestedIds?: string[],
timezone?: string,
} }
export interface Subscription { export interface Subscription {