Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ A simple, community-maintained open-source task tracker. Add tasks, check them o
- Single-page, zero-build web app
- Tasks persist in `localStorage` by default, with optional GitHub sign-in sync
- GitHub OAuth uses PKCE in the browser and an HTTP-only session cookie
- English and Spanish UI copy with browser-language detection and a saved manual override
- Keyboard-friendly (more shortcuts coming, see #5)
- Light & dark themes (dark coming, see #1)
- MIT licensed
Expand Down
36 changes: 28 additions & 8 deletions index.html
Original file line number Diff line number Diff line change
@@ -1,23 +1,37 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TaskForge open-source task tracker</title>
<title>TaskForge - open-source task tracker</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<header class="app-header">
<div class="header-copy">
<h1>TaskForge</h1>
<p class="tagline">A tiny task list that lives in your browser.</p>
<p class="tagline" data-i18n="tagline">
A tiny task list that lives in your browser.
</p>
</div>
<label class="locale-control" for="locale-select">
<span data-i18n="languageLabel">Language</span>
<select id="locale-select" name="locale">
<option value="en">English</option>
<option value="es">Espanol</option>
</select>
</label>
<div class="auth-panel" aria-live="polite">
<img id="auth-avatar" class="auth-avatar" hidden />
<span id="auth-status" class="auth-status">
<span id="auth-status" class="auth-status" data-i18n="authLocal">
Tasks are stored on this device.
</span>
<button id="auth-action" class="auth-action" type="button">
<button
id="auth-action"
class="auth-action"
type="button"
data-i18n="signIn"
>
Sign in with GitHub
</button>
</div>
Expand All @@ -26,21 +40,27 @@ <h1>TaskForge</h1>
<main class="app-main">
<p id="auth-message" class="auth-message" hidden></p>

<form id="task-form" class="task-form" aria-label="Add a new task">
<form
id="task-form"
class="task-form"
aria-label="Add a new task"
data-i18n-aria-label="newTaskAria"
>
<input
id="task-input"
type="text"
name="title"
placeholder="What needs doing?"
data-i18n-placeholder="taskPlaceholder"
autocomplete="off"
required
/>
<button type="submit">Add</button>
<button type="submit" data-i18n="addButton">Add</button>
</form>

<ul id="task-list" class="task-list" aria-live="polite"></ul>

<p id="empty-state" class="empty-state" hidden>
<p id="empty-state" class="empty-state" data-i18n="emptyState" hidden>
No tasks yet. Add one above to get started.
</p>
</main>
Expand Down
44 changes: 38 additions & 6 deletions src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
logout,
startGitHubSignIn,
} from "./auth.js";
import { getInitialLocale, saveLocale, translate } from "./i18n.mjs";
import { load, loadLocal, save } from "./storage.js";
import { createTask, toggleTask, removeTask } from "./tasks.js";

Expand All @@ -15,11 +16,15 @@ const authStatus = document.getElementById("auth-status");
const authAction = document.getElementById("auth-action");
const authAvatar = document.getElementById("auth-avatar");
const authMessage = document.getElementById("auth-message");
const localeSelect = document.getElementById("locale-select");

let tasks = [];
let user = null;
let locale = getInitialLocale();

async function init() {
localeSelect.value = locale;
applyTranslations();
const localTasks = loadLocal();
let completedSignIn = false;
try {
Expand Down Expand Up @@ -72,8 +77,8 @@ function render() {
const del = document.createElement("button");
del.type = "button";
del.className = "task-delete";
del.textContent = "";
del.setAttribute("aria-label", `Delete task: ${task.title}`);
del.textContent = "x";
del.setAttribute("aria-label", t("deleteTaskAria", { title: task.title }));
del.addEventListener("click", async () => {
tasks = removeTask(tasks, task.id);
await save(tasks, user);
Expand Down Expand Up @@ -114,25 +119,52 @@ authAction.addEventListener("click", async () => {
}
});

localeSelect.addEventListener("change", () => {
locale = localeSelect.value;
saveLocale(locale);
applyTranslations();
renderAuth();
render();
});

function renderAuth() {
authAction.disabled = false;
if (!user) {
authAvatar.hidden = true;
authAvatar.removeAttribute("src");
authAvatar.removeAttribute("alt");
authStatus.textContent = "Tasks are stored on this device.";
authAction.textContent = "Sign in with GitHub";
authStatus.textContent = t("authLocal");
authAction.textContent = t("signIn");
return;
}

authAvatar.hidden = false;
authAvatar.src = user.avatarUrl;
authAvatar.alt = `${user.login}'s avatar`;
authStatus.textContent = `Signed in as ${user.login}`;
authAction.textContent = "Logout";
authStatus.textContent = t("signedInAs", { login: user.login });
authAction.textContent = t("logout");
setAuthMessage("");
}

function applyTranslations() {
document.documentElement.lang = locale;
document.title = t("documentTitle");

for (const element of document.querySelectorAll("[data-i18n]")) {
element.textContent = t(element.dataset.i18n);
}
for (const element of document.querySelectorAll("[data-i18n-placeholder]")) {
element.setAttribute("placeholder", t(element.dataset.i18nPlaceholder));
}
for (const element of document.querySelectorAll("[data-i18n-aria-label]")) {
element.setAttribute("aria-label", t(element.dataset.i18nAriaLabel));
}
}

function t(key, values) {
return translate(locale, key, values);
}

function setAuthMessage(message) {
authMessage.textContent = message;
authMessage.hidden = !message;
Expand Down
74 changes: 74 additions & 0 deletions src/i18n.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
const STORAGE_KEY = "taskforge.locale";

const messages = {
en: {
addButton: "Add",
authLocal: "Tasks are stored on this device.",
deleteTaskAria: "Delete task: {title}",
documentTitle: "TaskForge - open-source task tracker",
emptyState: "No tasks yet. Add one above to get started.",
languageLabel: "Language",
logout: "Logout",
newTaskAria: "Add a new task",
signedInAs: "Signed in as {login}",
signIn: "Sign in with GitHub",
tagline: "A tiny task list that lives in your browser.",
taskPlaceholder: "What needs doing?",
},
es: {
addButton: "Agregar",
authLocal: "Las tareas se guardan en este dispositivo.",
deleteTaskAria: "Eliminar tarea: {title}",
documentTitle: "TaskForge - gestor de tareas open source",
emptyState: "Todavia no hay tareas. Agrega una arriba para empezar.",
languageLabel: "Idioma",
logout: "Cerrar sesion",
newTaskAria: "Agregar una nueva tarea",
signedInAs: "Sesion iniciada como {login}",
signIn: "Iniciar sesion con GitHub",
tagline: "Una lista de tareas pequena que vive en tu navegador.",
taskPlaceholder: "Que hay que hacer?",
},
};

export const SUPPORTED_LOCALES = Object.freeze(Object.keys(messages));

export function getInitialLocale({
navigatorLanguage = globalThis.navigator?.language,
storage = globalThis.localStorage,
} = {}) {
const saved = readSavedLocale(storage);
if (saved) return saved;
return normalizeLocale(navigatorLanguage);
}

export function normalizeLocale(locale) {
const normalized = String(locale || "")
.trim()
.toLowerCase();
if (normalized.startsWith("es")) return "es";
return "en";
}

export function readSavedLocale(storage = globalThis.localStorage) {
try {
const saved = storage?.getItem(STORAGE_KEY);
return SUPPORTED_LOCALES.includes(saved) ? saved : "";
} catch {
return "";
}
}

export function saveLocale(locale, storage = globalThis.localStorage) {
if (!SUPPORTED_LOCALES.includes(locale)) return;
try {
storage?.setItem(STORAGE_KEY, locale);
} catch {
// Ignore storage failures so language switching remains non-blocking.
}
}

export function translate(locale, key, values = {}) {
const template = messages[locale]?.[key] || messages.en[key] || key;
return template.replace(/\{(\w+)\}/g, (_, name) => values[name] ?? "");
}
26 changes: 24 additions & 2 deletions styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@

body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial,
sans-serif;
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
background: var(--bg);
color: var(--fg);
line-height: 1.5;
Expand All @@ -43,6 +43,24 @@ body {
font-size: 0.95rem;
}

.locale-control {
display: flex;
align-items: center;
gap: 0.45rem;
color: var(--muted);
font-size: 0.9rem;
white-space: nowrap;
}

.locale-control select {
padding: 0.4rem 0.55rem;
font: inherit;
color: var(--fg);
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
}

.auth-panel {
display: flex;
align-items: center;
Expand Down Expand Up @@ -204,6 +222,10 @@ body {
width: 100%;
}

.locale-control {
width: 100%;
}

.auth-status {
flex: 1;
max-width: none;
Expand Down
53 changes: 53 additions & 0 deletions test/i18n.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import assert from "node:assert/strict";
import test from "node:test";

import {
getInitialLocale,
normalizeLocale,
saveLocale,
translate,
} from "../src/i18n.mjs";

test("normalizes supported browser languages", () => {
assert.equal(normalizeLocale("es-MX"), "es");
assert.equal(normalizeLocale("ES"), "es");
assert.equal(normalizeLocale("fr-FR"), "en");
assert.equal(normalizeLocale(""), "en");
});

test("saved locale overrides browser detection", () => {
const storage = memoryStorage({ "taskforge.locale": "es" });
assert.equal(getInitialLocale({ navigatorLanguage: "en-US", storage }), "es");
});

test("invalid saved locale falls back to navigator language", () => {
const storage = memoryStorage({ "taskforge.locale": "fr" });
assert.equal(getInitialLocale({ navigatorLanguage: "es-ES", storage }), "es");
});

test("translation interpolates dynamic values", () => {
assert.equal(
translate("es", "signedInAs", { login: "octo" }),
"Sesion iniciada como octo",
);
assert.equal(
translate("en", "deleteTaskAria", { title: "Ship" }),
"Delete task: Ship",
);
});

test("saveLocale ignores unsupported locales", () => {
const storage = memoryStorage();
saveLocale("fr", storage);
assert.equal(storage.getItem("taskforge.locale"), null);
saveLocale("es", storage);
assert.equal(storage.getItem("taskforge.locale"), "es");
});

function memoryStorage(initial = {}) {
const values = new Map(Object.entries(initial));
return {
getItem: (key) => values.get(key) ?? null,
setItem: (key, value) => values.set(key, String(value)),
};
}