diff --git a/Timer.md b/Timer.md
new file mode 100644
index 0000000..5c8cb01
--- /dev/null
+++ b/Timer.md
@@ -0,0 +1,9 @@
+# Timer feature
+
+- Timer lives on its own page
+- Timer can be started, paused and stopped
+- When the timer is stopped a UI opens where the user has to select a project. After the project is selected the timer is destroyed and the time recorded with it is created as time entries assigned to the selected project
+- Timers are stored in the database and can be resumed after the application is closed and reopened or if the user signs out and back in.
+- For a user there exists one timer at a time. A timer can have zero or more start pause entries that record when the timer is started and paused. If a timer is running the last start pause entry has a start time but no pause time. If a timer is paused the last start pause entry has both a start and a pause time. When a timer is stopped the last start pause entry is paused if it was running. The user had to select a project to assign the stopped timer to and all the start pause entries are converted to time entries assigned to the selected project. The timer with all its start pause entries is then destroyed.
+- Don't use verbs in the endpoint paths as it is a RESTful API. Use nouns instead of verbs. For example you can post to `.../start` to create a start for a timer. The method should also be named `createTimerStart` instead of `startTimer`
+- If there is no start pause entry without a pause time the timer is paused. If there is a start pause entry without a pause time the timer is running.
diff --git a/client/package.json b/client/package.json
index b95bc5e..52f3cd5 100644
--- a/client/package.json
+++ b/client/package.json
@@ -14,6 +14,8 @@
},
"dependencies": {
"@claas.dev/material-tailwind": "^0.6.0",
+ "@fluent/bundle": "^0.19.1",
+ "@fluent/langneg": "^0.7.0",
"@tanstack/solid-query": "^5.101.0",
"@tanstack/solid-router": "^1.169.1",
"solid-js": "^1.9.11",
diff --git a/client/pnpm-lock.yaml b/client/pnpm-lock.yaml
index 6c76581..5aa4f4d 100644
--- a/client/pnpm-lock.yaml
+++ b/client/pnpm-lock.yaml
@@ -11,6 +11,12 @@ importers:
'@claas.dev/material-tailwind':
specifier: ^0.6.0
version: 0.6.0
+ '@fluent/bundle':
+ specifier: ^0.19.1
+ version: 0.19.1
+ '@fluent/langneg':
+ specifier: ^0.7.0
+ version: 0.7.0
'@tanstack/solid-query':
specifier: ^5.101.0
version: 5.101.0(solid-js@1.9.12)
@@ -153,6 +159,14 @@ packages:
'@emnapi/wasi-threads@1.2.0':
resolution: {integrity: sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==}
+ '@fluent/bundle@0.19.1':
+ resolution: {integrity: sha512-SWJLZrPamDPsJlFFOW1nkgN0j0rbPbmSdmK0XAoXlyqKieLtMVl4vzng3aR5pwKoUx0scug8+YY2oct3fdfy9A==}
+ engines: {node: '>=18.0.0', npm: '>=7.0.0'}
+
+ '@fluent/langneg@0.7.0':
+ resolution: {integrity: sha512-StAM0vgsD1QK+nFikaKs9Rxe3JGNipiXrpmemNGwM4gWERBXPe9gjzsBoKjgBgq1Vyiy+xy/C652QIWY+MPyYw==}
+ engines: {node: '>=14.0.0', npm: '>=7.0.0'}
+
'@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
@@ -231,56 +245,48 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
- libc: [glibc]
'@oxfmt/binding-linux-arm64-musl@0.46.0':
resolution: {integrity: sha512-aAUPBWJ1lGwwnxZUEDLJ94+Iy6MuwJwPxUgO4sCA5mEEyDk7b+cDQ+JpX1VR150Zoyd+D49gsrUzpUK5h587Eg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
- libc: [musl]
'@oxfmt/binding-linux-ppc64-gnu@0.46.0':
resolution: {integrity: sha512-ufBCJukyFX/UDrokP/r6BGDoTInnsDs7bxyzKAgMiZlt2Qu8GPJSJ6Zm6whIiJzKk0naxA8ilwmbO1LMw6Htxw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
- libc: [glibc]
'@oxfmt/binding-linux-riscv64-gnu@0.46.0':
resolution: {integrity: sha512-eqtlC2YmPqjun76R1gVfGLuKWx7NuEnLEAudZ7n6ipSKbCZTqIKSs1b5Y8K/JHZsRpLkeSmAAjig5HOIg8fQzQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
- libc: [glibc]
'@oxfmt/binding-linux-riscv64-musl@0.46.0':
resolution: {integrity: sha512-yccVOO2nMXkQLGgy0He3EQEwKD7NF0zEk+/OWmroznkqXyJdN6bfK0LtNnr6/14Bh3FjpYq7bP33l/VloCnxpA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
- libc: [musl]
'@oxfmt/binding-linux-s390x-gnu@0.46.0':
resolution: {integrity: sha512-aAf7fG23OQCey6VRPj9IeCraoYtpgtx0ZyJ1CXkPyT1wjzBE7c3xtuxHe/AdHaJfVVb/SXpSk8Gl1LzyQupSqw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
- libc: [glibc]
'@oxfmt/binding-linux-x64-gnu@0.46.0':
resolution: {integrity: sha512-q0JPsTMyJNjYrBvYFDz4WbVsafNZaPCZv4RnFypRotLqpKROtBZcEaXQW4eb9YmvLU3NckVemLJnzkSZSdmOxw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
- libc: [glibc]
'@oxfmt/binding-linux-x64-musl@0.46.0':
resolution: {integrity: sha512-7LsLY9Cw57GPkhSR+duI3mt9baRczK/DtHYSldQ4BEU92da9igBQNl4z7Vq5U9NNPsh1FmpKvv1q9WDtiUQR1A==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
- libc: [musl]
'@oxfmt/binding-openharmony-arm64@0.46.0':
resolution: {integrity: sha512-lHiBOz8Duaku7JtRNLlps3j++eOaICPZSd8FCVmTDM4DFOPT71Bjn7g6iar1z7StXlKRweUKxWUs4sA+zWGDXg==}
@@ -353,56 +359,48 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
- libc: [glibc]
'@oxlint/binding-linux-arm64-musl@1.71.0':
resolution: {integrity: sha512-fJZrs5sDZtTaPIOiemRQQmo82Ezy+vOGXemPc4Ok7iVVsYsFa7SlW6Z5XN819VfsqBHRm3NJ3rTdnR8+bJYJdQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
- libc: [musl]
'@oxlint/binding-linux-ppc64-gnu@1.71.0':
resolution: {integrity: sha512-cwl7VKGERIy9p+G+AvZdfy/06q0aHXaTt/mMRReC751iuNYJgqKjB7NydXSS30nBT9vtr2tunciOtrR4fD6FUA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
- libc: [glibc]
'@oxlint/binding-linux-riscv64-gnu@1.71.0':
resolution: {integrity: sha512-eZ8ieVXvzGi8jr7+ybQGPK2STw3mldfxZlgA2738iflfB/rzA69sE6m5rDRpQaxC7dpm745Enlh1Tod0QAk9Gg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
- libc: [glibc]
'@oxlint/binding-linux-riscv64-musl@1.71.0':
resolution: {integrity: sha512-puMDbQYe6+NXwfMusojoA7CXGn2b3utukmd23PQqc1E3XhVCwyZ+FueSMzDYeNgDV2dUfIVXAAKZBcFDeCL6sA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
- libc: [musl]
'@oxlint/binding-linux-s390x-gnu@1.71.0':
resolution: {integrity: sha512-4NJLxBs1ujISCt3L/1FcywLs73PWtJuw+piD6feK2V6h6OS6P7xu9/sWt1DTRLibe6QCzmfZzmM/2HPORoV/Lg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
- libc: [glibc]
'@oxlint/binding-linux-x64-gnu@1.71.0':
resolution: {integrity: sha512-cFDaiR8L3430qp88tfZnvFlt3KotFhR/DlbIL0nHOMMYiG/9Wy4l+6f7t8G8pTa9bd8Lt8+M0y/qjRQ/xcB74g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
- libc: [glibc]
'@oxlint/binding-linux-x64-musl@1.71.0':
resolution: {integrity: sha512-orfixdt76KlpNly9z0PkWBBNfwjKz+JFVLP/7wnVchlKNU9Dpt9InU/ZggeSej6fC7qwHmHNOGlhLnQXcYoGuA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
- libc: [musl]
'@oxlint/binding-openharmony-arm64@1.71.0':
resolution: {integrity: sha512-9emQu2lAp6yhPB3XuI+++vR+l/o6JR1X+EpxwcumPdQXBWXEPAsquPGL7l158EqU8SebQMXTUa/S5zN98juyHw==}
@@ -463,42 +461,36 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
- libc: [glibc]
'@rolldown/binding-linux-arm64-musl@1.0.0-rc.12':
resolution: {integrity: sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
- libc: [musl]
'@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12':
resolution: {integrity: sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
- libc: [glibc]
'@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12':
resolution: {integrity: sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
- libc: [glibc]
'@rolldown/binding-linux-x64-gnu@1.0.0-rc.12':
resolution: {integrity: sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
- libc: [glibc]
'@rolldown/binding-linux-x64-musl@1.0.0-rc.12':
resolution: {integrity: sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
- libc: [musl]
'@rolldown/binding-openharmony-arm64@1.0.0-rc.12':
resolution: {integrity: sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==}
@@ -639,28 +631,24 @@ packages:
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
- libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.2.3':
resolution: {integrity: sha512-MmIA32rNEOrjh6wnevlR3OjjlCuwgZ4JMJo7Vrhk4Fk56Vxi7EeF7cekSKwvlrnfcn/ERC1LdcG3sFneU8WdoA==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
- libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.2.3':
resolution: {integrity: sha512-BiCy1YV0IKO+xbD7gyZnENU4jdwDygeGQjncJoeIE5Kp4UqWHFsKUSJ3pp7vYURrqVzwJX2xD5gQeGnoXp4xPQ==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
- libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.2.3':
resolution: {integrity: sha512-venvyAu0AMKdr0c1Oz23IJJdZ72zSwKyHrLvqQV1cn49vPAJk3AuVtDkJ1ayk1sYI4M4j8Jv6ZGflpaP0QVSXQ==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
- libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.2.3':
resolution: {integrity: sha512-e3kColrZZCdtbwIOc07cNQ2zNf1sTPXTYLjjPlsgsaf+ttzAg/hOlDyEgHoOlBGxM88nPxeVaOGe9ThqVzPncg==}
@@ -971,28 +959,24 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
- libc: [glibc]
lightningcss-linux-arm64-musl@1.32.0:
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
- libc: [musl]
lightningcss-linux-x64-gnu@1.32.0:
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
- libc: [glibc]
lightningcss-linux-x64-musl@1.32.0:
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
- libc: [musl]
lightningcss-win32-arm64-msvc@1.32.0:
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
@@ -1368,6 +1352,10 @@ snapshots:
tslib: 2.8.1
optional: true
+ '@fluent/bundle@0.19.1': {}
+
+ '@fluent/langneg@0.7.0': {}
+
'@jridgewell/gen-mapping@0.3.13':
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
diff --git a/client/src/ErrorDetails.tsx b/client/src/ErrorDetails.tsx
index 74d4c63..6c7ce13 100644
--- a/client/src/ErrorDetails.tsx
+++ b/client/src/ErrorDetails.tsx
@@ -1,11 +1,14 @@
import type { ErrorComponentProps } from "@tanstack/solid-router";
import { Show } from "solid-js";
+import { useI18n } from "./i18n";
+
export function ErrorDetails(
properties: ErrorComponentProps & { title: string; explainer: string },
) {
// Log the error so we can view it in the console and it doesn't get swallowed
console.error("ErrorDetails", properties.error);
+ const { t } = useI18n();
return (
<>
@@ -14,7 +17,7 @@ export function ErrorDetails(
- Technical details
+ {t("error-technical-details")}
{properties.error.stack}
@@ -29,7 +32,7 @@ export function ErrorDetails(
onClick={properties.reset}
class="text-label-lg text-on-error-container col-start-2"
>
- Retry
+ {t("error-retry")}
>
diff --git a/client/src/Navigation.tsx b/client/src/Navigation.tsx
index d5dab90..c9e0df1 100644
--- a/client/src/Navigation.tsx
+++ b/client/src/Navigation.tsx
@@ -4,9 +4,11 @@ import type { JSX, VoidProps } from "solid-js";
import { useFloatingActionButton } from "@/FloatingActionButton";
import Icon from "@/Icon";
+import { useI18n } from "@/i18n";
-export default function Navigation(properties: VoidProps>) {
+export default function Navigation(_properties: VoidProps>) {
const floatingActionButton = useFloatingActionButton();
+ const { t } = useI18n();
return (
@@ -44,7 +46,7 @@ export default function Navigation(properties: VoidProps
- Times
+ {t("nav-times")}
@@ -53,7 +55,7 @@ export default function Navigation(properties: VoidProps
- Timer
+ {t("nav-timer")}
@@ -69,7 +71,7 @@ export default function Navigation(properties: VoidProps
- Projects
+ {t("nav-projects")}
@@ -84,7 +86,7 @@ export default function Navigation(properties: VoidProps
- Organizations
+ {t("nav-organizations")}
diff --git a/client/src/i18n.tsx b/client/src/i18n.tsx
new file mode 100644
index 0000000..6ab704e
--- /dev/null
+++ b/client/src/i18n.tsx
@@ -0,0 +1,60 @@
+import { FluentBundle, FluentResource, type FluentVariable } from "@fluent/bundle";
+import { negotiateLanguages } from "@fluent/langneg";
+import { createContext, createMemo, createSignal, useContext, type ParentProps } from "solid-js";
+
+// Included in bundle even if translation is not not used. This is fine for now but needs to be changed in the future.
+import deFtl from "./locales/de.ftl?raw";
+import enFtl from "./locales/en.ftl?raw";
+
+const MESSAGES: Record = {
+ en: enFtl,
+ de: deFtl,
+};
+
+const SUPPORTED_LOCALES = ["en", "de"] as const;
+const DEFAULT_LOCALE = "en";
+
+function createBundle(locale: string): FluentBundle {
+ const bundle = new FluentBundle(locale);
+ const resource = new FluentResource(MESSAGES[locale] ?? MESSAGES[DEFAULT_LOCALE]);
+ bundle.addResource(resource);
+ return bundle;
+}
+
+export type TranslateArgs = Record;
+
+const I18nContext = createContext<{
+ t: (id: string, args?: TranslateArgs) => string;
+ locale: () => string;
+ setLocale: (locale: string) => void;
+}>();
+
+export function I18nProvider(props: ParentProps) {
+ const userLocales = Array.from(
+ navigator.languages?.length ? navigator.languages : [navigator.language ?? DEFAULT_LOCALE],
+ );
+ const negotiated = negotiateLanguages(userLocales, SUPPORTED_LOCALES, {
+ defaultLocale: DEFAULT_LOCALE,
+ });
+ const [locale, setLocale] = createSignal(negotiated[0] ?? DEFAULT_LOCALE);
+
+ const bundle = createMemo(() => createBundle(locale()));
+
+ function t(id: string, args?: TranslateArgs): string {
+ const b = bundle();
+ const message = b.getMessage(id);
+ if (!message?.value) return id;
+ const errors: Error[] = [];
+ return b.formatPattern(message.value, args, errors);
+ }
+
+ return (
+ {props.children}
+ );
+}
+
+export function useI18n() {
+ const ctx = useContext(I18nContext);
+ if (!ctx) throw new Error("useI18n must be used within I18nProvider");
+ return ctx;
+}
diff --git a/client/src/index.tsx b/client/src/index.tsx
index b3c161f..23ac84d 100644
--- a/client/src/index.tsx
+++ b/client/src/index.tsx
@@ -6,6 +6,7 @@ import { render } from "solid-js/web";
import { routeTree } from "./routeTree.gen";
import { FloatingActionButtonProvider } from "./FloatingActionButton";
+import { I18nProvider } from "./i18n";
import { TitleProvider } from "./Title";
import { idQuery } from "./user";
@@ -42,13 +43,15 @@ declare module "@tanstack/solid-router" {
render(
() => (
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
),
document.body,
);
diff --git a/client/src/locales/de.ftl b/client/src/locales/de.ftl
new file mode 100644
index 0000000..99e389a
--- /dev/null
+++ b/client/src/locales/de.ftl
@@ -0,0 +1,135 @@
+# Navigation
+nav-times = Zeiten
+nav-timer = Timer
+nav-projects = Projekte
+nav-organizations = Organisationen
+
+# Top app bar
+app-sign-out = Abmelden
+
+# Sign in
+sign-in-welcome = Willkommen
+sign-in-username-label = Benutzername
+sign-in-password-label = Passwort
+sign-in-submit = Anmelden
+sign-in-go-sign-up = Konto erstellen
+sign-in-error-server = Ein Fehler bei der Verbindung zum Server ist aufgetreten. Bitte später erneut versuchen.
+sign-in-error-invalid-credentials = Ungültiger Benutzername oder Passwort
+sign-in-error-unknown = Ein Fehler beim Anmelden ist aufgetreten. Bitte erneut versuchen.
+
+# Sign up
+sign-up-create-account = Konto erstellen
+sign-up-name-label = Name
+sign-up-username-label = Benutzername
+sign-up-password-label = Neues Passwort
+sign-up-submit = Registrieren
+sign-up-go-sign-in = Bereits ein Konto? Anmelden
+sign-up-error-server = Ein Fehler bei der Verbindung zum Server ist aufgetreten. Bitte später erneut versuchen.
+sign-up-error-username-taken = Benutzername ist bereits vergeben
+sign-up-error-unknown = Ein Fehler bei der Registrierung ist aufgetreten. Bitte später erneut versuchen.
+
+# Timer
+timer-title = Timer
+timer-running-status = Läuft
+timer-delete-entry = Eintrag löschen
+timer-pause = Pausieren
+timer-resume = Fortsetzen
+timer-start = Starten
+timer-stop = Stoppen
+timer-select-project-title = Projekt auswählen
+timer-select-project-body = { $duration } werden als Zeiteintrag gespeichert.
+timer-loading-projects = Projekte werden geladen …
+timer-discard = Verwerfen
+
+# Times
+times-title = Zeiten
+times-log-time = Zeit erfassen
+times-edit = Bearbeiten
+times-save = Speichern
+times-cancel = Abbrechen
+times-start-time-label = Startzeit
+times-end-time-label = Endzeit
+times-delete = Löschen
+
+# Projects
+projects-title = Projekte
+projects-create = Projekt erstellen
+projects-loading = Projekte werden geladen …
+projects-edit = Bearbeiten
+projects-delete = Löschen
+
+# Organizations
+organizations-title = Organisationen
+organizations-create = Organisation erstellen
+organizations-loading = Organisationen werden geladen …
+organizations-open = Öffnen
+organizations-edit = Bearbeiten
+organizations-delete = Löschen
+
+# New organization
+org-new-title = Neue Organisation
+org-new-name-label = Name
+org-new-cancel = Abbrechen
+org-new-create = Erstellen
+org-new-discard = Verwerfen
+
+# Edit organization
+org-edit-title = Organisation bearbeiten
+org-edit-name-label = Name
+org-edit-cancel = Abbrechen
+org-edit-save = Speichern
+org-edit-discard = Verwerfen
+
+# Organization members
+org-members-back = Zurück
+org-members-error-page-title = Fehler
+org-members-error-title = Fehler beim Laden der Organisation
+org-members-error-body = Entschuldigung, beim Laden der Organisation ist ein Fehler aufgetreten. Bitte später erneut versuchen.
+org-members-users-heading = Benutzer
+org-members-delete = Löschen
+org-members-add-member = Mitglied hinzufügen
+
+# Add member
+org-add-member-title = Mitglied hinzufügen
+org-add-member-username-label = Benutzername
+org-add-member-cancel = Abbrechen
+org-add-member-add = Hinzufügen
+org-add-member-discard = Verwerfen
+
+# New project
+project-new-title = Neues Projekt
+project-new-name-label = Name
+project-new-organization-label = Organisation
+project-new-loading = Wird geladen …
+project-new-cancel = Abbrechen
+project-new-create = Erstellen
+project-new-discard = Verwerfen
+
+# Edit project
+project-edit-title = Projekt bearbeiten
+project-edit-name-label = Name
+project-edit-organization-label = Organisation
+project-edit-loading = Wird geladen …
+project-edit-cancel = Abbrechen
+project-edit-save = Speichern
+project-edit-discard = Verwerfen
+
+# Log time
+time-new-title = Zeit erfassen
+time-new-date-label = Datum
+time-new-start-label = Start
+time-new-end-label = Ende
+time-new-project-label = Projekt
+time-new-loading-projects = Projekte werden geladen …
+time-new-cancel = Abbrechen
+time-new-save = Speichern
+time-new-discard = Verwerfen
+time-new-validation-start-before-end = Die Startzeit muss vor der Endzeit liegen.
+
+# Error component
+error-technical-details = Technische Details
+error-retry = Wiederholen
+
+# Generic error page
+error-title = Fehler
+error-body = Entschuldigung, ein unerwarteter Fehler ist aufgetreten. Bitte später erneut versuchen.
diff --git a/client/src/locales/en.ftl b/client/src/locales/en.ftl
new file mode 100644
index 0000000..f009090
--- /dev/null
+++ b/client/src/locales/en.ftl
@@ -0,0 +1,135 @@
+# Navigation
+nav-times = Times
+nav-timer = Timer
+nav-projects = Projects
+nav-organizations = Organizations
+
+# Top app bar
+app-sign-out = Sign out
+
+# Sign in
+sign-in-welcome = Welcome
+sign-in-username-label = Username
+sign-in-password-label = Password
+sign-in-submit = Sign in
+sign-in-go-sign-up = Sign up for an account
+sign-in-error-server = An error occurred connecting to the server. Please try again later.
+sign-in-error-invalid-credentials = Invalid username or password
+sign-in-error-unknown = An error occurred signing in. Please try again.
+
+# Sign up
+sign-up-create-account = Create an account
+sign-up-name-label = Name
+sign-up-username-label = Username
+sign-up-password-label = New password
+sign-up-submit = Sign up
+sign-up-go-sign-in = Already have an account? Sign in
+sign-up-error-server = An error occurred connecting to the server. Please try again later.
+sign-up-error-username-taken = Username is already taken
+sign-up-error-unknown = An error occurred during sign up. Please try again later.
+
+# Timer
+timer-title = Timer
+timer-running-status = Running
+timer-delete-entry = Delete entry
+timer-pause = Pause
+timer-resume = Resume
+timer-start = Start
+timer-stop = Stop
+timer-select-project-title = Select a project
+timer-select-project-body = { $duration } will be saved as a time entry.
+timer-loading-projects = Loading projects...
+timer-discard = Discard
+
+# Times
+times-title = Times
+times-log-time = Log time
+times-edit = Edit
+times-save = Save
+times-cancel = Cancel
+times-start-time-label = Start time
+times-end-time-label = End time
+times-delete = Delete
+
+# Projects
+projects-title = Projects
+projects-create = Create project
+projects-loading = Loading projects...
+projects-edit = Edit
+projects-delete = Delete
+
+# Organizations
+organizations-title = Organizations
+organizations-create = Create organization
+organizations-loading = Loading organizations...
+organizations-open = Open
+organizations-edit = Edit
+organizations-delete = Delete
+
+# New organization
+org-new-title = New Organization
+org-new-name-label = Name
+org-new-cancel = Cancel
+org-new-create = Create
+org-new-discard = Discard
+
+# Edit organization
+org-edit-title = Edit Organization
+org-edit-name-label = Name
+org-edit-cancel = Cancel
+org-edit-save = Save
+org-edit-discard = Discard
+
+# Organization members
+org-members-back = Back
+org-members-error-page-title = Error
+org-members-error-title = Error loading organization
+org-members-error-body = Sorry, an error occurred while loading the organization. Please try again later.
+org-members-users-heading = Users
+org-members-delete = Delete
+org-members-add-member = Add Member
+
+# Add member
+org-add-member-title = Add member
+org-add-member-username-label = Username
+org-add-member-cancel = Cancel
+org-add-member-add = Add
+org-add-member-discard = Discard
+
+# New project
+project-new-title = New Project
+project-new-name-label = Name
+project-new-organization-label = Organization
+project-new-loading = Loading...
+project-new-cancel = Cancel
+project-new-create = Create
+project-new-discard = Discard
+
+# Edit project
+project-edit-title = Edit Project
+project-edit-name-label = Name
+project-edit-organization-label = Organization
+project-edit-loading = Loading...
+project-edit-cancel = Cancel
+project-edit-save = Save
+project-edit-discard = Discard
+
+# Log time
+time-new-title = Log new time
+time-new-date-label = Date
+time-new-start-label = Start
+time-new-end-label = End
+time-new-project-label = Project
+time-new-loading-projects = Loading projects...
+time-new-cancel = Cancel
+time-new-save = Save
+time-new-discard = Discard
+time-new-validation-start-before-end = Start time must be before end time.
+
+# Error component
+error-technical-details = Technical details
+error-retry = Retry
+
+# Generic error page
+error-title = Error
+error-body = Sorry, an unexpected error occurred. Please try again later.
diff --git a/client/src/routes/__root.tsx b/client/src/routes/__root.tsx
index 896b442..70b5a7e 100644
--- a/client/src/routes/__root.tsx
+++ b/client/src/routes/__root.tsx
@@ -2,6 +2,7 @@ import type { QueryClient } from "@tanstack/solid-query";
import { createRootRouteWithContext, redirect } from "@tanstack/solid-router";
import { ErrorDetails } from "../ErrorDetails";
+import { useI18n } from "../i18n";
import { idQuery, UnauthenticatedError } from "../user";
import { Route as SignInRoute } from "./sign-in";
import { Route as SignUpRoute } from "./sign-up";
@@ -42,11 +43,14 @@ export const Route = createRootRoute({
});
}
},
- errorComponent: (properties) => (
-
- ),
+ errorComponent: (properties) => {
+ const { t } = useI18n();
+ return (
+
+ );
+ },
});
diff --git a/client/src/routes/_app/organizations/index.tsx b/client/src/routes/_app/organizations/index.tsx
index ac88b64..40f9aad 100644
--- a/client/src/routes/_app/organizations/index.tsx
+++ b/client/src/routes/_app/organizations/index.tsx
@@ -4,6 +4,7 @@ import { For, Show } from "solid-js";
import { FloatingActionButton } from "../../../FloatingActionButton";
import Icon from "../../../Icon";
+import { useI18n } from "../../../i18n";
import {
deleteOrganization,
isOrganization,
@@ -26,6 +27,7 @@ export const Route = createFileRoute("/_app/organizations/")({
function Organizations() {
const routeData = Route.useLoaderData();
const userId = () => routeData().userId;
+ const { t } = useI18n();
const options = () => query(userId());
const organizationsQuery = useQuery(options);
@@ -51,12 +53,16 @@ function Organizations() {
return (
<>
-
-
+
+
{/* TODO overflow, pagination, scrolling */}
- Loading organizations...
}>
+ {t("organizations-loading")}}>
{(organization) => {
return (
@@ -72,7 +78,7 @@ function Organizations() {
data-variant="standard"
class="icon-button"
>
- Open
+ {t("organizations-open")}
@@ -83,7 +89,7 @@ function Organizations() {
data-variant="standard"
class="icon-button"
>
- Edit
+ {t("organizations-edit")}
>
diff --git a/client/src/routes/_app/projects.tsx b/client/src/routes/_app/projects.tsx
index d2b189b..6f5e702 100644
--- a/client/src/routes/_app/projects.tsx
+++ b/client/src/routes/_app/projects.tsx
@@ -1,10 +1,11 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/solid-query";
import { createFileRoute, Link } from "@tanstack/solid-router";
-import { For, Show, type VoidProps } from "solid-js";
+import { For, Show } from "solid-js";
import { FloatingActionButton } from "../../FloatingActionButton";
import Icon from "../../Icon";
-import { deleteProject, isProject, query, type Id as ProjectId, type Project } from "../../project";
+import { useI18n } from "../../i18n";
+import { deleteProject, isProject, query, type Id as ProjectId } from "../../project";
import { Title } from "../../Title";
import { idQuery } from "../../user";
@@ -21,6 +22,7 @@ export const Route = createFileRoute("/_app/projects")({
function Projects() {
const routeData = Route.useLoaderData();
const userId = () => routeData().userId;
+ const { t } = useI18n();
const options = () => query(userId());
const projectsQuery = useQuery(options);
@@ -45,12 +47,12 @@ function Projects() {
return (
<>
-
-
+
+
{/* TODO overflow, pagination, scrolling */}
- Loading projects...}>
+ {t("projects-loading")}}>
{(project) => {
return (
@@ -67,7 +69,7 @@ function Projects() {
data-variant="standard"
class="icon-button"
>
- Edit
+ {t("projects-edit")}
>
@@ -92,3 +94,4 @@ function Projects() {
>
);
}
+
diff --git a/client/src/routes/_app/route.tsx b/client/src/routes/_app/route.tsx
index 751d59d..7fa6431 100644
--- a/client/src/routes/_app/route.tsx
+++ b/client/src/routes/_app/route.tsx
@@ -2,6 +2,7 @@ import { useQueryClient } from "@tanstack/solid-query";
import { createFileRoute, Outlet } from "@tanstack/solid-router";
import Body from "../../Body";
+import { useI18n } from "../../i18n";
import Navigation from "../../Navigation";
import { useTitle } from "../../Title";
import { ActionButton, TopAppBar } from "../../TopAppBar";
@@ -14,6 +15,7 @@ export const Route = createFileRoute("/_app")({
function RouteComponent() {
const queryClient = useQueryClient();
const navigate = Route.useNavigate();
+ const { t } = useI18n();
async function handleSignOut() {
const response = await fetch("/api/sign-outs", {
@@ -40,7 +42,7 @@ function RouteComponent() {
trailingAction={
}
diff --git a/client/src/routes/_app/timer.tsx b/client/src/routes/_app/timer.tsx
index 29b798c..cb634e6 100644
--- a/client/src/routes/_app/timer.tsx
+++ b/client/src/routes/_app/timer.tsx
@@ -4,6 +4,7 @@ import { createEffect, createMemo, createSignal, For, onCleanup, Show, Suspense
import { FloatingActionButtonAction } from "@/FloatingActionButton";
import Icon from "@/Icon";
+import { useI18n } from "@/i18n";
import { isProject, query as projectQuery, type Id as ProjectId } from "@/project";
import { query as timesQuery } from "@/time";
import {
@@ -51,6 +52,7 @@ function EntryRow(properties: {
onDelete: () => void;
isDeleting: boolean;
}) {
+ const { t } = useI18n();
const startedAt = () => Temporal.Instant.from(properties.entry.startedAt);
const pausedAt = () =>
@@ -72,7 +74,7 @@ function EntryRow(properties: {
when={pausedAt()}
fallback={
- Running
+ {t("timer-running-status")}
}
>
@@ -96,7 +98,7 @@ function EntryRow(properties: {
disabled={properties.isDeleting}
class="icon-button fill-on-surface-variant disabled:opacity-50"
>
- Delete entry
+ {t("timer-delete-entry")}
@@ -106,6 +108,7 @@ function EntryRow(properties: {
function TimerPage() {
const { userId } = Route.useLoaderData()();
const queryClient = useQueryClient();
+ const { t } = useI18n();
const timer = useQuery(() => timerQuery(userId));
@@ -187,15 +190,14 @@ function TimerPage() {
const entries = () => timer.data?.entries;
const floatingActionButtonLabel = createMemo(() => {
- if (timer.data?.status === "RUNNING") return "Pause";
- if (timer.data?.status === "PAUSED") return "Resume";
-
- return "Start";
+ if (timer.data?.status === "RUNNING") return t("timer-pause");
+ if (timer.data?.status === "PAUSED") return t("timer-resume");
+ return t("timer-start");
});
return (
<>
-
+
- Start
+ {t("timer-start")}
@@ -234,7 +236,7 @@ function TimerPage() {
class="button gap-2"
>
- Pause
+ {t("timer-pause")}
@@ -246,7 +248,7 @@ function TimerPage() {
class="button gap-2"
>
- Resume
+ {t("timer-resume")}
@@ -279,13 +281,16 @@ function TimerPage() {
-
Select a project
+
{t("timer-select-project-title")}
- will
- be saved as a time entry.
+ {t("timer-select-project-body", {
+ duration: durationFormatter.format(elapsed()),
+ })}
Loading projects...}
+ fallback={
+ {t("timer-loading-projects")}
+ }
>
@@ -309,7 +314,7 @@ function TimerPage() {
data-variant="outlined"
class="button mt-6 w-full"
>
- Discard
+ {t("timer-discard")}
diff --git a/client/src/routes/_app/times.tsx b/client/src/routes/_app/times.tsx
index fdcc191..92f617f 100644
--- a/client/src/routes/_app/times.tsx
+++ b/client/src/routes/_app/times.tsx
@@ -4,6 +4,7 @@ import { createSignal, For, type VoidProps } from "solid-js";
import { FloatingActionButton } from "@/FloatingActionButton";
import Icon from "@/Icon";
+import { useI18n } from "@/i18n";
import { deleteTimes, query, updateTimes, type Time, type TimeId } from "@/time";
import { Title } from "@/Title";
import { idQuery, type UserId } from "@/user";
@@ -69,6 +70,7 @@ const durationFormatter = new Intl.DurationFormat(undefined, {
function Day(properties: VoidProps) {
// Could do this with a custom styled checkbox and no JS
const [isEdit, setIsEdit] = createSignal(false);
+ const { t } = useI18n();
function cancelEdit() {
setIsEdit(false);
@@ -162,7 +164,7 @@ function Day(properties: VoidProps) {
onClick={() => setIsEdit(!isEdit())}
class="icon-button fill-on-surface group-data-is-edit:hidden"
>
- Edit
+ {t("times-edit")}
@@ -172,7 +174,7 @@ function Day(properties: VoidProps) {
disabled={updateMutation.isPending}
class="icon-button fill-on-surface hidden group-data-is-edit:block"
>
- Save
+ {t("times-save")}
@@ -181,7 +183,7 @@ function Day(properties: VoidProps) {
disabled={updateMutation.isPending}
class="icon-button fill-on-surface hidden group-data-is-edit:block"
>
- Cancel
+ {t("times-cancel")}
@@ -245,7 +247,7 @@ function Day(properties: VoidProps) {
>
) {
/>
) {
{formattedNewDuration()}