dudi/docs/superpowers/plans/2026-05-08-js-module-split.md
Simon Kühn eb7cfad6bc Add implementation plan for JS module split
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 11:32:50 +02:00

1454 lines
55 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# JS Module Split Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Split `public/app.js` (1025 lines, single file) into 9 ES modules under `public/js/` with no build step, converting `var` to `const`/`let` throughout.
**Architecture:** Shared mutable state lives in a single exported `state` object in `state.js`; all modules import and mutate it directly. The one circular dependency (`api.js` → `auth.js`) is broken via a `session-expired` custom DOM event. The HTML `<script>` tag is switched to `type="module"` only after all modules are ready; the old `public/app.js` stays untouched until then.
**Tech Stack:** Vanilla ES Modules (no bundler), Symfony 8 backend, MariaDB.
---
### Task 1: Create state.js
**Files:**
- Create: `public/js/state.js`
- [ ] **Step 1: Create the directory and file**
```bash
mkdir -p /srv/http/zieltracker/public/js
```
Write `public/js/state.js`:
```js
function loadPrefs() {
try { return JSON.parse(localStorage.getItem('zt_p') || '{}'); } catch (e) { return {}; }
}
export const state = {
TODAY: (() => { const d = new Date(); d.setHours(0,0,0,0); return d; })(),
goals: [],
prefs: loadPrefs(),
selDay: {},
addAmt: {},
renamingId: null,
renameVal: '',
collapsed: {},
userName: '',
isAdmin: false,
};
export function savePrefs() {
localStorage.setItem('zt_p', JSON.stringify(state.prefs));
}
```
- [ ] **Step 2: Syntax check**
```bash
node --check /srv/http/zieltracker/public/js/state.js
```
Expected: no output (clean).
- [ ] **Step 3: Commit**
```bash
git add public/js/state.js
git commit -m "Add state.js module"
```
---
### Task 2: Create i18n.js
**Files:**
- Create: `public/js/i18n.js`
- [ ] **Step 1: Write the file**
Extract STRINGS (lines 5174 of `public/app.js`) and locale helpers (lines 176188). `setLocale` no longer calls `render()`/`updateHeader()` — callers handle re-rendering. The api call for saving locale is also removed from here and handled at the call site in sheets.js.
Write `public/js/i18n.js`:
```js
const STRINGS = {
de: {
hint:'Menü → "Zum Startbildschirm" für App-Icon',
emptyLine1:'Noch keine Ziele.',emptyLine2:'Tippe auf + um zu starten.',
noEntry:'Noch kein Eintrag',log:'+',
noch:'Noch',dAbbr:'T',endet:'endet',todayShort:'heute',total:'total',
todayHeading:'Heute',done2:'Gemacht',dailyGoal:'Tagesziel',remaining:'Noch',
history:'Verlauf — heute & gestern bearbeitbar',
legBuf:'Puffer',legDone:'Erreicht',legPartial:'Teilweise',legMissed:'Verpasst',
delGoal:'Ziel löschen',
qbLabel:'Quick-Buchen',openLabel:'Offen',doneToday:'Heute erledigt',
hello:'Hallo {n}!',
confirmDelete:'Ziel wirklich löschen?',errDelete:'Fehler beim Löschen',errSave:'Speicherfehler',
loginTitle:'Anmelden',emailLabel:'E-Mail',passwordLabel:'Passwort',loginBtn:'Anmelden',
forgotPw:'Passwort vergessen?',
loginErrEmpty:'Bitte E-Mail und Passwort eingeben',
loginErrWrong:'Falsche E-Mail oder Passwort',loginErrRate:'Zu viele Versuche',loginErrConn:'Verbindungsfehler',
forgotTitle:'Passwort vergessen',forgotSub:'Wir schicken dir einen Reset-Link',
sendLink:'Link senden',back:'Zurück',
emailSentTitle:'E-Mail gesendet',
emailSentSub:'Falls die Adresse bekannt ist, erhältst du in Kürze einen Reset-Link.',
ok:'OK',
resetTitle:'Neues Passwort',newPwLabel:'Neues Passwort',setPw:'Passwort setzen',min8:'mind. 8 Zeichen',
pwChangedTitle:'Passwort geändert',pwChangedSub:'Du kannst dich jetzt anmelden.',
changeNameTitle:'Name ändern',yourName:'Dein Name',save:'Speichern',cancel:'Abbrechen',
errNameEmpty:'Name darf nicht leer sein',errNameSave:'Fehler beim Speichern',nameSaved:'Name gespeichert',
changePwTitle:'Passwort ändern',currentPwLabel:'Aktuelles Passwort',
newPwConfLabel:'Neues Passwort bestätigen',changePwBtn:'Ändern',
errPwMismatch:'Die neuen Passwörter stimmen nicht überein',pwChanged:'Passwort geändert',
registerTitle:'Konto erstellen',registerSub:'Du wurdest eingeladen',
namePlaceholder:'Wie sollen wir dich nennen?',pwConfLabel:'Passwort bestätigen',
pwPlaceholder:'Passwort wiederholen',registerBtn:'Registrieren',
errPwMismatch2:'Passwörter stimmen nicht überein',errFillAll:'Bitte alle Felder ausfüllen',
newGoalTitle:'Neues Ziel',exerciseLabel:'Übung / Gewohnheit',exercisePlaceholder:'Liegestütz, Plank …',
unitLabel:'Einheit',unitDefault:'Stück',daysLabel:'Dauer in Tagen',
dailyLabel:'Tagesziel',weeklyLabel:'Wochenziel',startGoal:'Ziel starten',errCreate:'Fehler beim Erstellen',
dataMenuTitle:'Daten verwalten',dataMenuSub:'Export, Import und Backup',
exportLabel:'Exportieren',exportSub:'Alle Ziele als JSON-Datei speichern',
importLabel:'Importieren',importSub:'Backup laden oder zusammenführen',
inviteLabel:'Freund einladen',inviteSub:'Einladungslink generieren',
inviteListLabel:'Meine Einladungen',inviteListSub:'Status aller gesendeten Einladungen',
changeName:'Name ändern',changePw:'Passwort ändern',logout:'Abmelden',close:'Schließen',
clearAll:'Alle Daten löschen',clearAllSub:'Kann nicht rückgängig gemacht werden',
confirmClear:'Alle Daten löschen?',
inviteFormTitle:'Freund einladen',inviteFormSub:'Link gilt 7 Tage und kann nur einmal verwendet werden',
inviteNameLabel:'Name (für deine Übersicht)',inviteNamePlaceholder:'z.B. Max',
generateLink:'Link generieren',inviteLinkTitle:'Einladungslink',
copyLink:'Link kopieren',linkCopied:'Link kopiert!',errGenerate:'Fehler beim Generieren',
noInvites:'Noch keine Einladungen verschickt',
statusPending:'Ausstehend',statusUsed:'Angenommen',statusExpired:'Abgelaufen',
errLoad:'Fehler beim Laden',
confirmImport:'{n} Ziel(e) importieren?',importDone:'{n} Ziel(e) importiert.',invalidFormat:'Ungültiges Format',
linkLabel:'Link',doneLabel:'gemacht',ofLabel:'von',
gestern:'Gestern',heute:'Heute',
expiresAt:'läuft ab:',acceptedBy:'→',
adminLabel:'Nutzer',adminColName:'Name',adminColEmail:'E-Mail',adminColRegistered:'Registriert',
},
en: {
hint:'Menu → "Add to Home Screen" for app icon',
emptyLine1:'No goals yet.',emptyLine2:'Tap + to get started.',
noEntry:'No entries yet',log:'+',
noch:'Left',dAbbr:'d',endet:'ends',todayShort:'today',total:'total',
todayHeading:'Today',done2:'Done',dailyGoal:'Daily goal',remaining:'Left',
history:'History — today & yesterday editable',
legBuf:'Buffer',legDone:'Reached',legPartial:'Partial',legMissed:'Missed',
delGoal:'Delete goal',
qbLabel:'Quick-log',openLabel:'Open',doneToday:'Done today',
hello:'Hello {n}!',
confirmDelete:'Really delete this goal?',errDelete:'Delete failed',errSave:'Save failed',
loginTitle:'Sign in',emailLabel:'E-Mail',passwordLabel:'Password',loginBtn:'Sign in',
forgotPw:'Forgot password?',
loginErrEmpty:'Please enter email and password',
loginErrWrong:'Wrong email or password',loginErrRate:'Too many attempts',loginErrConn:'Connection error',
forgotTitle:'Forgot password',forgotSub:"We'll send you a reset link",
sendLink:'Send link',back:'Back',
emailSentTitle:'Email sent',
emailSentSub:"If the address is registered, you'll receive a reset link shortly.",
ok:'OK',
resetTitle:'New password',newPwLabel:'New password',setPw:'Set password',min8:'at least 8 characters',
pwChangedTitle:'Password changed',pwChangedSub:'You can now sign in.',
changeNameTitle:'Change name',yourName:'Your name',save:'Save',cancel:'Cancel',
errNameEmpty:'Name cannot be empty',errNameSave:'Save failed',nameSaved:'Name saved',
changePwTitle:'Change password',currentPwLabel:'Current password',
newPwConfLabel:'Confirm new password',changePwBtn:'Change',
errPwMismatch:"The new passwords don't match",pwChanged:'Password changed',
registerTitle:'Create account',registerSub:"You've been invited",
namePlaceholder:'What should we call you?',pwConfLabel:'Confirm password',
pwPlaceholder:'Repeat password',registerBtn:'Register',
errPwMismatch2:"Passwords don't match",errFillAll:'Please fill in all fields',
newGoalTitle:'New goal',exerciseLabel:'Exercise / Habit',exercisePlaceholder:'Push-ups, Plank …',
unitLabel:'Unit',unitDefault:'reps',daysLabel:'Duration (days)',
dailyLabel:'Daily goal',weeklyLabel:'Weekly goal',startGoal:'Start goal',errCreate:'Create failed',
dataMenuTitle:'Manage data',dataMenuSub:'Export, Import and Backup',
exportLabel:'Export',exportSub:'Save all goals as JSON file',
importLabel:'Import',importSub:'Load or merge backup',
inviteLabel:'Invite friend',inviteSub:'Generate invite link',
inviteListLabel:'My invitations',inviteListSub:'Status of all sent invitations',
changeName:'Change name',changePw:'Change password',logout:'Sign out',close:'Close',
clearAll:'Delete all data',clearAllSub:'Cannot be undone',
confirmClear:'Delete all data?',
inviteFormTitle:'Invite friend',inviteFormSub:'Link valid 7 days, single use',
inviteNameLabel:'Name (for your reference)',inviteNamePlaceholder:'e.g. Max',
generateLink:'Generate link',inviteLinkTitle:'Invite link',
copyLink:'Copy link',linkCopied:'Link copied!',errGenerate:'Generate failed',
noInvites:'No invitations sent yet',
statusPending:'Pending',statusUsed:'Accepted',statusExpired:'Expired',
errLoad:'Load failed',
confirmImport:'Import {n} goal(s)?',importDone:'{n} goal(s) imported.',invalidFormat:'Invalid format',
linkLabel:'Link',doneLabel:'done',ofLabel:'of',
gestern:'Yesterday',heute:'Today',
expiresAt:'expires:',acceptedBy:'→',
adminLabel:'Users',adminColName:'Name',adminColEmail:'Email',adminColRegistered:'Registered',
},
pl: {
hint:'Menu → "Dodaj do ekranu głównego" aby zainstalować',
emptyLine1:'Brak celów.',emptyLine2:'Dotknij +, aby zacząć.',
noEntry:'Brak wpisów',log:'+',
noch:'Jeszcze',dAbbr:'d',endet:'kończy',todayShort:'dziś',total:'łącznie',
todayHeading:'Dziś',done2:'Wykonano',dailyGoal:'Cel dzienny',remaining:'Pozostało',
history:'Historia — dziś i wczoraj edytowalne',
legBuf:'Zapas',legDone:'Osiągnięto',legPartial:'Częściowo',legMissed:'Pominięto',
delGoal:'Usuń cel',
qbLabel:'Szybki wpis',openLabel:'Otwarte',doneToday:'Dziś ukończone',
hello:'Cześć {n}!',
confirmDelete:'Na pewno usunąć cel?',errDelete:'Błąd podczas usuwania',errSave:'Błąd zapisu',
loginTitle:'Zaloguj się',emailLabel:'E-Mail',passwordLabel:'Hasło',loginBtn:'Zaloguj',
forgotPw:'Zapomniałeś hasła?',
loginErrEmpty:'Podaj e-mail i hasło',
loginErrWrong:'Błędny e-mail lub hasło',loginErrRate:'Zbyt wiele prób',loginErrConn:'Błąd połączenia',
forgotTitle:'Zapomniane hasło',forgotSub:'Wyślemy Ci link do resetu',
sendLink:'Wyślij link',back:'Wróć',
emailSentTitle:'E-mail wysłany',
emailSentSub:'Jeśli adres jest zarejestrowany, wkrótce otrzymasz link resetujący.',
ok:'OK',
resetTitle:'Nowe hasło',newPwLabel:'Nowe hasło',setPw:'Ustaw hasło',min8:'min. 8 znaków',
pwChangedTitle:'Hasło zmienione',pwChangedSub:'Możesz się teraz zalogować.',
changeNameTitle:'Zmień nazwę',yourName:'Twoje imię',save:'Zapisz',cancel:'Anuluj',
errNameEmpty:'Imię nie może być puste',errNameSave:'Błąd zapisu',nameSaved:'Imię zapisano',
changePwTitle:'Zmień hasło',currentPwLabel:'Aktualne hasło',
newPwConfLabel:'Potwierdź nowe hasło',changePwBtn:'Zmień',
errPwMismatch:'Nowe hasła się nie zgadzają',pwChanged:'Hasło zmienione',
registerTitle:'Utwórz konto',registerSub:'Zostałeś zaproszony',
namePlaceholder:'Jak mamy Cię nazywać?',pwConfLabel:'Potwierdź hasło',
pwPlaceholder:'Powtórz hasło',registerBtn:'Zarejestruj',
errPwMismatch2:'Hasła się nie zgadzają',errFillAll:'Wypełnij wszystkie pola',
newGoalTitle:'Nowy cel',exerciseLabel:'Ćwiczenie / Nawyk',exercisePlaceholder:'Pompki, Plank …',
unitLabel:'Jednostka',unitDefault:'szt.',daysLabel:'Czas trwania (dni)',
dailyLabel:'Cel dzienny',weeklyLabel:'Cel tygodniowy',startGoal:'Rozpocznij cel',errCreate:'Błąd tworzenia',
dataMenuTitle:'Zarządzaj danymi',dataMenuSub:'Eksport, import i kopia zapasowa',
exportLabel:'Eksportuj',exportSub:'Zapisz wszystkie cele jako plik JSON',
importLabel:'Importuj',importSub:'Załaduj lub połącz kopię zapasową',
inviteLabel:'Zaproś znajomego',inviteSub:'Wygeneruj link zaproszenia',
inviteListLabel:'Moje zaproszenia',inviteListSub:'Status wszystkich wysłanych zaproszeń',
changeName:'Zmień nazwę',changePw:'Zmień hasło',logout:'Wyloguj',close:'Zamknij',
clearAll:'Usuń wszystkie dane',clearAllSub:'Nie można cofnąć',
confirmClear:'Usunąć wszystkie dane?',
inviteFormTitle:'Zaproś znajomego',inviteFormSub:'Link ważny 7 dni, jednorazowy',
inviteNameLabel:'Nazwa (dla Twojej ewidencji)',inviteNamePlaceholder:'np. Max',
generateLink:'Wygeneruj link',inviteLinkTitle:'Link zaproszenia',
copyLink:'Kopiuj link',linkCopied:'Link skopiowany!',errGenerate:'Błąd generowania',
noInvites:'Brak wysłanych zaproszeń',
statusPending:'Oczekujący',statusUsed:'Zaakceptowany',statusExpired:'Wygasły',
errLoad:'Błąd ładowania',
confirmImport:'Importować {n} cel(e)?',importDone:'Zaimportowano {n} cel(e).',invalidFormat:'Nieprawidłowy format',
linkLabel:'Link',doneLabel:'wykonano',ofLabel:'z',
gestern:'Wczoraj',heute:'Dziś',
expiresAt:'wygasa:',acceptedBy:'→',
adminLabel:'Użytkownicy',adminColName:'Nazwa',adminColEmail:'E-mail',adminColRegistered:'Rejestracja',
},
};
export let LOCALE = 'de';
export function tr(key) {
return (STRINGS[LOCALE] || STRINGS.de)[key] || STRINGS.de[key] || key;
}
export function ldoc() {
return LOCALE === 'de' ? 'de-DE' : LOCALE === 'pl' ? 'pl-PL' : 'en-GB';
}
export function setLocale(lang, save) {
LOCALE = lang;
if (save) localStorage.setItem('zt_locale', lang);
}
export function applyLocale(userLocale) {
const lang = userLocale || localStorage.getItem('zt_locale');
if (!lang) {
const nav = (navigator.language || '').slice(0, 2).toLowerCase();
if (STRINGS[nav]) { LOCALE = nav; return; }
}
if (lang && STRINGS[lang]) LOCALE = lang;
}
```
- [ ] **Step 2: Syntax check**
```bash
node --check /srv/http/zieltracker/public/js/i18n.js
```
Expected: no output.
- [ ] **Step 3: Commit**
```bash
git add public/js/i18n.js
git commit -m "Add i18n.js module"
```
---
### Task 3: Create api.js
**Files:**
- Create: `public/js/api.js`
Key change from `public/app.js` lines 254289: the `showLogin()` call on 401 is replaced with `document.dispatchEvent(new CustomEvent('session-expired'))`. `saveGoal` is moved to `render.js` where it's used. `loadGoals` becomes a thin wrapper.
- [ ] **Step 1: Write the file**
Write `public/js/api.js`:
```js
let _apiPending = 0;
function _apiBar() {
document.getElementById('api-bar').classList.toggle('loading', _apiPending > 0);
}
export function api(method, path, body) {
const opts = { method, credentials: 'include', headers: { 'Content-Type': 'application/json' } };
if (body) opts.body = JSON.stringify(body);
_apiPending++; _apiBar();
return fetch('api/' + path, opts)
.then(res => res.json().then(data => {
if (!res.ok) { const e = new Error(data.error || 'Fehler'); e.status = res.status; throw e; }
return data;
}).catch(e => {
if (e.status) throw e;
const ne = new Error('Fehler'); ne.status = res.status; throw ne;
}))
.catch(e => {
if (e.status === 401 && path !== 'login') {
document.dispatchEvent(new CustomEvent('session-expired'));
}
throw e;
})
.finally(() => { _apiPending--; _apiBar(); });
}
export function loadGoals() {
return api('GET', 'goals');
}
```
- [ ] **Step 2: Syntax check**
```bash
node --check /srv/http/zieltracker/public/js/api.js
```
Expected: no output.
- [ ] **Step 3: Commit**
```bash
git add public/js/api.js
git commit -m "Add api.js module"
```
---
### Task 4: Create goals.js
**Files:**
- Create: `public/js/goals.js`
Contains all pure goal calculations (lines 194252 of `public/app.js`). `toggleCollapse` no longer calls `render()` — callers in `wire()` handle re-rendering explicitly. All `var``const`/`let`.
- [ ] **Step 1: Write the file**
Write `public/js/goals.js`:
```js
import { state } from './state.js';
import { ldoc } from './i18n.js';
export function tOff(g) {
return Math.round((state.TODAY - new Date(g.start)) / 86400000);
}
export function o2d(g, i) {
const d = new Date(new Date(g.start).getTime() + i * 86400000);
d.setHours(0,0,0,0);
return d;
}
export function dTot(g, o) {
return (g.sets[String(o)] || []).reduce((a, b) => a + b.amount, 0);
}
export function fd(d) {
return d.toLocaleDateString(ldoc(), { weekday: 'short', day: 'numeric', month: 'short' });
}
export function fs(d) {
return d.toLocaleDateString(ldoc(), { day: 'numeric', month: 'short' });
}
export function editable(g, o) {
const t = tOff(g);
return o === t || o === t - 1;
}
export function now() {
const n = new Date();
return String(n.getHours()).padStart(2, '0') + ':' + String(n.getMinutes()).padStart(2, '0');
}
export function heuteColor(tdone, daily) {
if (tdone === 0) return 'var(--red)';
if (tdone >= daily * 1.1) return 'var(--blue)';
if (tdone >= daily) return 'var(--green)';
return 'var(--amber)';
}
export function isCollapsed(id) {
return state.collapsed[id] !== false;
}
export function toggleCollapse(id) {
const wasCollapsed = isCollapsed(id);
state.collapsed[id] = !wasCollapsed;
if (wasCollapsed) {
const g = state.goals.find(x => x.id === id);
if (g) state.selDay[id] = tOff(g);
}
}
export function calc(g) {
const t = tOff(g), tot = g.daily * g.days;
const dr = Math.max(0, g.days - t - 1);
const sd = new Date(g.start); sd.setHours(0,0,0,0);
const end = new Date(sd.getTime() + g.days * 86400000);
let past = 0;
for (let i = 0; i < Math.min(t, g.days); i++) past += dTot(g, i);
const tdone = dTot(g, t), tot2 = past + tdone;
const dl = dr + 1;
const remaining = Math.max(0, tot - past);
const pd = Math.ceil(remaining / Math.max(1, dl));
const st = Math.max(0, pd - tdone);
const expectedPast = Math.min(t, g.days) * g.daily;
const buf = Math.floor((past - expectedPast) + Math.max(0, tdone - g.daily));
const deficit = Math.min(0, buf);
const surplus = Math.max(0, buf);
const dailyDelta = pd - g.daily;
const pct = Math.min(100, Math.round((tot2 / tot) * 100));
return { tot, tOff: t, end, dr, done: tot2, tdone, pd, st, buf, deficit, surplus, dailyDelta, net: tdone - pd, pct, ok: tdone >= pd };
}
export function dcls(g, i) {
const t = tOff(g);
if (i > t) return 'dot df';
const v = dTot(g, i);
const c = v === 0 ? 'dot dm' : v >= g.daily * 1.1 ? 'dot db' : v >= g.daily ? 'dot dd' : 'dot dp';
return c + (editable(g, i) ? ' de' : ' dl');
}
export function dlbl(g, i) {
const t = tOff(g);
if (i > t) return String(i + 1);
const v = dTot(g, i);
if (v === 0) return '✕';
if (v >= g.daily * 1.1) return '+';
if (v >= g.daily) return '✓';
return Math.round(v / g.daily * 100) + '%';
}
```
- [ ] **Step 2: Syntax check**
```bash
node --check /srv/http/zieltracker/public/js/goals.js
```
Expected: no output.
- [ ] **Step 3: Commit**
```bash
git add public/js/goals.js
git commit -m "Add goals.js module"
```
---
### Task 5: Create ui.js
**Files:**
- Create: `public/js/ui.js`
Contains `tpl`, `showToast`, `closeOv`, `showSheet`, `updateHeader` (lines 339365, 291296, 912914 of `public/app.js`).
- [ ] **Step 1: Write the file**
Write `public/js/ui.js`:
```js
import { tr, ldoc } from './i18n.js';
import { state } from './state.js';
export function tpl(id) {
const el = document.getElementById(id).content.cloneNode(true).firstElementChild;
el.querySelectorAll('[data-t]').forEach(n => { n.textContent = tr(n.dataset.t); });
el.querySelectorAll('[data-ph]').forEach(n => { n.placeholder = tr(n.dataset.ph); });
el.querySelectorAll('[data-val]').forEach(n => { n.value = tr(n.dataset.val); });
return el;
}
export function showToast(msg) {
const t = document.createElement('div');
t.className = 'toast';
t.textContent = msg;
document.body.appendChild(t);
setTimeout(() => t.remove(), 3000);
}
const OV_CSS = 'display:flex;position:fixed;inset:0;z-index:200;background:rgba(0,0,0,.5);align-items:flex-end;justify-content:center;animation:fi .2s ease';
export function closeOv() {
const o = document.getElementById('ov');
o.style.display = 'none';
o.innerHTML = '';
}
export function showSheet(content, dismissable) {
const o = document.getElementById('ov');
o.style.cssText = OV_CSS;
const sheet = tpl('tpl-sheet');
sheet.appendChild(content);
o.innerHTML = '';
o.appendChild(sheet);
o.onclick = dismissable !== false ? e => { if (e.target === o) closeOv(); } : null;
}
export function updateHeader() {
document.getElementById('tlbl').textContent = state.TODAY.toLocaleDateString(
ldoc(), { weekday: 'long', day: 'numeric', month: 'long' }
);
}
```
- [ ] **Step 2: Syntax check**
```bash
node --check /srv/http/zieltracker/public/js/ui.js
```
Expected: no output.
- [ ] **Step 3: Commit**
```bash
git add public/js/ui.js
git commit -m "Add ui.js module"
```
---
### Task 6: Create auth.js
**Files:**
- Create: `public/js/auth.js`
Contains all auth screens (lines 367490 of `public/app.js`). Imports `render` from `render.js` — no circular: `render.js` does not import `auth.js`.
- [ ] **Step 1: Write the file**
Write `public/js/auth.js`:
```js
import { api, loadGoals } from './api.js';
import { tr } from './i18n.js';
import { state } from './state.js';
import { tpl, showSheet, closeOv, showToast, updateHeader } from './ui.js';
import { render } from './render.js';
export function showLogin(err) {
const c = tpl('tpl-login');
if (err) { const e = c.querySelector('.login-err'); e.textContent = err; e.style.display = ''; }
showSheet(c, false);
const email = c.querySelector('.lf-email'), pass = c.querySelector('.lf-pass'), sub = c.querySelector('.lf-sub');
setTimeout(() => email.focus(), 50);
email.onkeydown = e => { if (e.key === 'Enter') pass.focus(); };
pass.onkeydown = e => { if (e.key === 'Enter') sub.click(); };
c.querySelector('.lf-fgt').onclick = () => showForgotPassword();
sub.onclick = () => {
const ev = email.value.trim(), pv = pass.value;
if (!ev || !pv) {
const errEl = c.querySelector('.login-err');
errEl.textContent = tr('loginErrEmpty'); errEl.style.display = '';
return;
}
sub.disabled = true; sub.textContent = '…';
api('POST', 'login', { email: ev, password: pv })
.then(() => loadGoals())
.then(g => { state.goals = g; closeOv(); render(); })
.catch(err => {
sub.disabled = false; sub.textContent = tr('loginBtn');
showLogin(err.status === 401 ? tr('loginErrWrong') : err.status === 429 ? tr('loginErrRate') : tr('loginErrConn'));
});
};
}
export function showForgotPassword() {
const c = tpl('tpl-forgot-pw');
showSheet(c, false);
const email = c.querySelector('.fp-email'), errEl = c.querySelector('.login-err'), sub = c.querySelector('.fp-sub');
setTimeout(() => email.focus(), 50);
c.querySelector('.fp-back').onclick = () => showLogin();
sub.onclick = () => {
const ev = email.value.trim(); if (!ev) return;
sub.disabled = true; sub.textContent = '…';
api('POST', 'reset-request', { email: ev })
.then(() => {
const conf = tpl('tpl-email-sent');
conf.querySelector('.es-ok').onclick = () => showLogin();
showSheet(conf, false);
})
.catch(err => {
sub.disabled = false; sub.textContent = tr('sendLink');
errEl.textContent = err.message || 'Fehler'; errEl.style.display = '';
});
};
}
export function showResetPassword(selector, token) {
const c = tpl('tpl-reset-pw');
showSheet(c, false);
const pass = c.querySelector('.rp-pass'), errEl = c.querySelector('.login-err'), sub = c.querySelector('.rp-sub');
setTimeout(() => pass.focus(), 50);
sub.onclick = () => {
const pv = pass.value; if (!pv) return;
sub.disabled = true; sub.textContent = '…';
api('POST', 'reset-password', { selector, token, password: pv })
.then(() => {
const conf = tpl('tpl-pw-changed');
conf.querySelector('.pc-ok').onclick = () => showLogin();
showSheet(conf, false);
})
.catch(err => {
sub.disabled = false; sub.textContent = tr('setPw');
errEl.textContent = err.message || 'Fehler'; errEl.style.display = '';
});
};
}
export function showChangePassword() {
const c = tpl('tpl-change-pw');
showSheet(c, true);
const oldP = c.querySelector('.cp-old'), newP = c.querySelector('.cp-new'), newP2 = c.querySelector('.cp-new2');
const errEl = c.querySelector('.login-err'), sub = c.querySelector('.cp-sub');
setTimeout(() => oldP.focus(), 50);
c.querySelector('.cp-can').onclick = closeOv;
sub.onclick = () => {
const o = oldP.value, n = newP.value, n2 = newP2.value;
if (!o || !n || !n2) return;
if (n !== n2) { errEl.textContent = tr('errPwMismatch'); errEl.style.display = ''; return; }
sub.disabled = true; sub.textContent = '…';
api('POST', 'change-password', { old_password: o, new_password: n })
.then(() => { showToast(tr('pwChanged')); closeOv(); })
.catch(err => {
sub.disabled = false; sub.textContent = tr('changePwBtn');
errEl.textContent = err.message || 'Fehler'; errEl.style.display = '';
});
};
}
export function showRegister(token) {
const c = tpl('tpl-register');
showSheet(c, false);
const nameInp = c.querySelector('.rg-name'), email = c.querySelector('.rg-email');
const pass = c.querySelector('.rg-pass'), pass2 = c.querySelector('.rg-pass2');
const errEl = c.querySelector('.login-err'), sub = c.querySelector('.rg-sub');
setTimeout(() => nameInp.focus(), 50);
nameInp.onkeydown = e => { if (e.key === 'Enter') email.focus(); };
email.onkeydown = e => { if (e.key === 'Enter') pass.focus(); };
pass.onkeydown = e => { if (e.key === 'Enter') pass2.focus(); };
pass2.onkeydown = e => { if (e.key === 'Enter') sub.click(); };
const checkMatch = () => {
if (pass2.value && pass.value !== pass2.value) { errEl.textContent = tr('errPwMismatch2'); errEl.style.display = ''; }
else errEl.style.display = 'none';
};
pass.oninput = checkMatch; pass2.oninput = checkMatch;
sub.onclick = () => {
const nv = nameInp.value.trim(), ev = email.value.trim(), pv = pass.value;
if (!nv || !ev || !pv) { errEl.textContent = tr('errFillAll'); errEl.style.display = ''; return; }
if (pv !== pass2.value) { errEl.textContent = tr('errPwMismatch2'); errEl.style.display = ''; return; }
sub.disabled = true; sub.textContent = '…';
api('POST', 'register', { name: nv, email: ev, password: pv, token })
.then(r => { state.userName = r.name || ''; return loadGoals(); })
.then(g => { state.goals = g; closeOv(); updateHeader(); render(); })
.catch(err => {
sub.disabled = false; sub.textContent = tr('registerBtn');
errEl.textContent = err.message || 'Fehler'; errEl.style.display = '';
});
};
}
```
- [ ] **Step 2: Syntax check**
```bash
node --check /srv/http/zieltracker/public/js/auth.js
```
Expected: no output.
- [ ] **Step 3: Commit**
```bash
git add public/js/auth.js
git commit -m "Add auth.js module"
```
---
### Task 7: Create sheets.js
**Files:**
- Create: `public/js/sheets.js`
Contains `openNew`, `openData`, `openAdmin`, `escHtml` (lines 492675 of `public/app.js`). Note: `LOCALE` is a live binding — reading it always returns the current value set by `setLocale`. The locale API save (`PATCH me`) is done here at the call site instead of inside `setLocale`.
- [ ] **Step 1: Write the file**
Write `public/js/sheets.js`:
```js
import { api } from './api.js';
import { tr, setLocale, LOCALE, ldoc } from './i18n.js';
import { state } from './state.js';
import { tpl, showSheet, closeOv, showToast, updateHeader } from './ui.js';
import { render } from './render.js';
import { showChangePassword, showLogin } from './auth.js';
export function escHtml(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
export function openNew() {
const c = tpl('tpl-new-goal');
showSheet(c, true);
const name = c.querySelector('.ng-name'), unit = c.querySelector('.ng-unit');
const daily = c.querySelector('.ng-daily'), weekly = c.querySelector('.ng-weekly');
const days = c.querySelector('.ng-days'), sub = c.querySelector('.ng-sub');
daily.addEventListener('input', () => {
if (daily.value) weekly.value = Math.round(parseFloat(daily.value) * 7 * 100) / 100;
});
weekly.addEventListener('input', () => {
if (weekly.value) daily.value = Math.round(parseFloat(weekly.value) / 7 * 100) / 100;
});
setTimeout(() => name.focus(), 50);
c.querySelector('.ng-can').onclick = closeOv;
sub.onclick = () => {
const nv = (name.value || '').trim(), uv = (unit.value || '').trim() || tr('unitDefault');
const dv = parseFloat(daily.value) || 1, dyv = parseInt(days.value, 10) || 30;
if (!nv) { name.focus(); return; }
sub.disabled = true;
api('POST', 'goals', { name: nv, unit: uv, daily: dv, days: dyv, start: state.TODAY.toISOString() })
.then(r => {
state.goals.push({ id: r.id, name: r.name, unit: r.unit, daily: r.daily, days: r.days, start: r.start, sets: r.sets || {} });
closeOv(); render();
})
.catch(e => {
sub.disabled = false;
if (e.status !== 401) showToast(tr('errCreate'));
});
};
}
export function openData() {
const c = tpl('tpl-data-menu');
showSheet(c, true);
c.querySelector('.dm-cls').onclick = closeOv;
c.querySelector('.dm-name').onclick = () => {
const nc = tpl('tpl-change-name');
showSheet(nc, true);
const inp = nc.querySelector('.cn-name'), errEl = nc.querySelector('.login-err'), sub = nc.querySelector('.cn-sub');
inp.value = state.userName;
setTimeout(() => { inp.focus(); inp.select(); }, 50);
nc.querySelector('.cn-can').onclick = closeOv;
sub.onclick = () => {
const nv = inp.value.trim();
if (!nv) { errEl.textContent = tr('errNameEmpty'); errEl.style.display = ''; return; }
sub.disabled = true; sub.textContent = '…';
api('PATCH', 'me', { name: nv })
.then(r => { state.userName = r.name; closeOv(); render(); showToast(tr('nameSaved')); })
.catch(() => { sub.disabled = false; sub.textContent = tr('save'); showToast(tr('errNameSave')); });
};
};
c.querySelector('.dm-cpw').onclick = () => { closeOv(); showChangePassword(); };
c.querySelector('.dm-lgout').onclick = () => {
api('POST', 'logout').then(() => { state.goals = []; closeOv(); render(); showLogin(); });
};
const adminBtn = c.querySelector('.dm-admin');
if (state.isAdmin) {
adminBtn.style.display = '';
adminBtn.onclick = () => { closeOv(); openAdmin(); };
}
c.querySelector('.dm-inv').onclick = () => {
const ic = tpl('tpl-invite-form');
showSheet(ic, true);
const invName = ic.querySelector('.inv-name');
setTimeout(() => invName.focus(), 50);
ic.querySelector('.inv-cancel').onclick = closeOv;
ic.querySelector('.inv-gen').onclick = function() {
const note = invName.value.trim(), btn = this;
btn.disabled = true; btn.textContent = '…';
api('POST', 'invite', { note })
.then(res => {
const lc = tpl('tpl-invite-link');
lc.querySelector('.stitle').textContent = tr('inviteLinkTitle') + (note ? ' — ' + note : '');
const urlInp = lc.querySelector('.il-url');
urlInp.value = res.url;
showSheet(lc, true);
lc.querySelector('.il-close').onclick = closeOv;
lc.querySelector('.il-copy').onclick = () => {
navigator.clipboard.writeText(res.url).then(() => { showToast(tr('linkCopied')); closeOv(); });
};
setTimeout(() => urlInp.select(), 50);
})
.catch(err => {
btn.disabled = false; btn.textContent = tr('generateLink');
showToast(err.message || tr('errGenerate'));
});
};
};
c.querySelector('.dm-invlist').onclick = () => {
api('GET', 'invites').then(list => {
const statusLabel = { pending: tr('statusPending'), used: tr('statusUsed'), expired: tr('statusExpired') };
const statusColor = { pending: 'var(--amber)', used: 'var(--green)', expired: 'var(--red)' };
const lc = tpl('tpl-invite-list');
const body = lc.querySelector('.dpanel-body');
if (!list.length) {
const empty = document.createElement('div');
empty.className = 'nosets'; empty.style.padding = '16px';
empty.textContent = tr('noInvites');
body.appendChild(empty);
} else {
for (const inv of list) {
const label = inv.note || new Date(inv.created_at).toLocaleDateString(ldoc(), { day: 'numeric', month: 'short', year: 'numeric' });
const detail = inv.used_by_email
? (tr('acceptedBy') + ' ' + inv.used_by_email)
: (inv.status === 'pending' ? tr('expiresAt') + ' ' + new Date(inv.expires_at).toLocaleDateString(ldoc(), { day: 'numeric', month: 'short' }) : '');
const row = tpl('tpl-invite-row');
row.querySelector('.ir-label').textContent = label;
if (detail) row.querySelector('.ir-detail').textContent = ' ' + detail;
const st = row.querySelector('.ir-status');
st.textContent = statusLabel[inv.status]; st.style.color = statusColor[inv.status];
if (inv.url) {
const cp = row.querySelector('.ir-copy'); cp.style.display = '';
cp.onclick = () => { navigator.clipboard.writeText(inv.url).then(() => { showToast(tr('linkCopied')); }); };
}
body.appendChild(row);
}
}
showSheet(lc, true);
lc.querySelector('.il-close').onclick = closeOv;
}).catch(() => showToast(tr('errLoad')));
};
c.querySelector('.dm-exp').onclick = () => {
const blob = new Blob(
[JSON.stringify({ goals: state.goals, at: new Date().toISOString() }, null, 2)],
{ type: 'application/json' }
);
const url = URL.createObjectURL(blob), a = document.createElement('a');
a.href = url; a.download = 'dudi-backup.json'; a.click(); URL.revokeObjectURL(url); closeOv();
};
c.querySelector('.dm-imp').onclick = () => {
const inp = document.createElement('input'); inp.type = 'file'; inp.accept = '.json';
inp.onchange = e => {
const f = e.target.files[0]; if (!f) return;
const r = new FileReader();
r.onload = ev => {
try {
const p = JSON.parse(ev.target.result);
if (!p.goals || !Array.isArray(p.goals)) throw new Error(tr('invalidFormat'));
if (!confirm(tr('confirmImport').replace('{n}', p.goals.length))) return;
const promises = p.goals.map(g =>
api('POST', 'goals', { name: g.name, unit: g.unit, daily: g.daily, days: g.days, start: g.start, sets: g.sets || {} })
.then(r => { state.goals.push({ id: r.id, name: r.name, unit: r.unit, daily: r.daily, days: r.days, start: r.start, sets: r.sets || {} }); })
);
Promise.all(promises).then(() => { closeOv(); render(); alert(tr('importDone').replace('{n}', p.goals.length)); });
} catch (err) { alert(err.message); }
};
r.readAsText(f);
};
inp.click();
};
c.querySelectorAll('.btn-lang').forEach(b => {
if (b.dataset.lang === LOCALE) b.classList.add('active');
b.onclick = function() {
const lang = this.dataset.lang;
setLocale(lang, true);
api('PATCH', 'me', { locale: lang }).catch(() => {});
render(); updateHeader(); closeOv();
};
});
c.querySelector('.dm-clr').onclick = () => {
if (!confirm(tr('confirmClear'))) return;
const ids = state.goals.map(g => g.id);
state.goals = []; render();
Promise.all(ids.map(id => api('DELETE', 'goals/' + id))).catch(() => showToast(tr('errDelete')));
closeOv();
};
}
export function openAdmin() {
api('GET', 'admin/users').then(rows => {
const c = tpl('tpl-admin-users');
const body = c.querySelector('.au-body');
rows.forEach(u => {
const row = document.createElement('tr');
row.style.borderBottom = '1px solid var(--border)';
const name = u.username || '—';
const date = new Date(u.registered * 1000).toLocaleDateString(ldoc(), { day: 'numeric', month: 'short', year: 'numeric' });
row.innerHTML = '<td style="padding:7px 12px">' + escHtml(name) + '</td>'
+ '<td style="padding:7px 12px;color:var(--text2)">' + escHtml(u.email) + '</td>'
+ '<td style="padding:7px 12px;color:var(--text2);font-size:.85em">' + date + '</td>';
body.appendChild(row);
});
showSheet(c, true);
c.querySelector('.au-close').onclick = closeOv;
}).catch(() => showToast(tr('errLoad')));
}
```
- [ ] **Step 2: Syntax check**
```bash
node --check /srv/http/zieltracker/public/js/sheets.js
```
Expected: no output.
- [ ] **Step 3: Commit**
```bash
git add public/js/sheets.js
git commit -m "Add sheets.js module"
```
---
### Task 8: Create render.js
**Files:**
- Create: `public/js/render.js`
Contains all card builders, render(), wire(), and goal action functions (lines 677908 of `public/app.js`). `saveGoal` is defined locally here. `toggleCollapse` no longer calls render internally — `wire()` calls `render()` explicitly after it. Does NOT import `auth.js` or `sheets.js` (no circular dependency).
- [ ] **Step 1: Write the file**
Write `public/js/render.js`:
```js
import { state, savePrefs } from './state.js';
import { tr } from './i18n.js';
import { api } from './api.js';
import { tOff, o2d, dTot, fd, fs, editable, now, heuteColor, isCollapsed, toggleCollapse, calc, dcls, dlbl } from './goals.js';
import { tpl, showToast } from './ui.js';
function saveGoal(g) {
api('PATCH', 'goals/' + g.id, {
name: g.name, unit: g.unit, daily: g.daily, days: g.days, start: g.start, sets: g.sets,
}).catch(e => {
if (e.status !== 401) showToast(tr('errSave'));
});
}
function addSet(gid, off) {
const g = state.goals.find(x => x.id === gid);
if (!g || !editable(g, off)) return;
const k = gid + '_' + off, amt = parseInt(state.addAmt[k] || '0', 10);
if (amt <= 0) return;
if (!g.sets[String(off)]) g.sets[String(off)] = [];
g.sets[String(off)].push({ amount: amt, time: off === tOff(g) ? now() : '—' });
state.addAmt[k] = ''; saveGoal(g); render();
}
function remSet(gid, off, idx) {
const g = state.goals.find(x => x.id === gid);
if (!g || !editable(g, off)) return;
g.sets[String(off)].splice(idx, 1); saveGoal(g); render();
}
function delGoal(id) {
if (!confirm(tr('confirmDelete'))) return;
state.goals = state.goals.filter(g => g.id !== id);
render();
api('DELETE', 'goals/' + id).catch(() => showToast(tr('errDelete')));
}
function selD(gid, off) {
const g = state.goals.find(x => x.id === gid);
if (!g || !editable(g, off)) return;
state.selDay[gid] = state.selDay[gid] === off ? null : off;
render();
}
function startRen(id) {
const g = state.goals.find(x => x.id === id); if (!g) return;
state.renamingId = id; state.renameVal = g.name; render();
setTimeout(() => { const el = document.getElementById('ri' + id); if (el) { el.focus(); el.select(); } }, 50);
}
function commitRen(id) {
const g = state.goals.find(x => x.id === id);
if (g && state.renameVal.trim()) { g.name = state.renameVal.trim(); saveGoal(g); }
state.renamingId = null; render();
}
function cancelRen() { state.renamingId = null; render(); }
function buildNameWrap(g) {
if (state.renamingId === g.id) {
const el = tpl('tpl-name-edit');
const inp = el.querySelector('.ren-input');
inp.id = 'ri' + g.id; inp.value = g.name; inp.dataset.g = g.id;
return el;
}
const el = tpl('tpl-name-view');
el.querySelector('.goal-name').textContent = g.name;
el.querySelector('.btn-ren').dataset.g = g.id;
return el;
}
function buildPanel(g, off) {
const t = tOff(g), sets = g.sets[String(off)] || [], tot = dTot(g, off);
const lbl = off === t ? tr('heute') : tr('gestern'), k = g.id + '_' + off;
const el = tpl('tpl-panel');
el.querySelector('.dpanel-title').textContent = lbl + ' — ' + fd(o2d(g, off));
el.querySelector('.dpanel-sub').textContent = tot + ' / ' + g.daily + ' ' + g.unit;
const body = el.querySelector('.dpanel-body');
if (sets.length) {
for (let i = 0; i < sets.length; i++) {
const s = sets[i], row = tpl('tpl-set-row'), span = row.querySelector('span');
if (s.time !== '—') {
const st = document.createElement('span'); st.className = 'stime'; st.textContent = s.time + ' ·';
span.appendChild(st); span.appendChild(document.createTextNode(' '));
}
const strong = document.createElement('strong'); strong.textContent = s.amount;
span.appendChild(strong); span.appendChild(document.createTextNode(' ' + g.unit));
const btn = row.querySelector('.sdel');
btn.dataset.g = g.id; btn.dataset.o = off; btn.dataset.i = i;
body.appendChild(row);
}
} else {
body.appendChild(tpl('tpl-nosets'));
}
const addRow = tpl('tpl-add-row');
const inp = addRow.querySelector('.num-in');
inp.placeholder = g.daily; inp.value = state.addAmt[k] || ''; inp.dataset.k = k; inp.dataset.g = g.id; inp.dataset.o = off;
const abtn = addRow.querySelector('.btn-as');
abtn.dataset.g = g.id; abtn.dataset.o = off;
addRow.querySelector('.ulbl').textContent = g.unit;
body.appendChild(addRow);
return el;
}
function buildCard(g) {
const c = calc(g), t = c.tOff;
const fc = c.surplus > 0 ? 'var(--blue)' : c.st === 0 ? 'var(--green)' : c.dailyDelta <= 0 ? 'var(--green)' : c.dailyDelta <= g.daily * .2 ? 'var(--amber)' : 'var(--red)';
let bc, bt;
const bufStr = (c.buf > 0 ? '+' : '') + c.buf;
if (c.ok && c.surplus > 0) { bc = 'b-buf'; bt = bufStr; }
else if (c.ok) { bc = 'b-done'; bt = bufStr; }
else if (c.dailyDelta <= 0) { bc = 'b-ok'; bt = bufStr; }
else if (c.dailyDelta <= g.daily * .2) { bc = 'b-warn'; bt = bufStr; }
else { bc = 'b-danger'; bt = bufStr; }
let el;
if (isCollapsed(g.id)) {
el = tpl('tpl-card-collapsed');
if (c.ok) el.classList.add('done');
el.querySelector('.card-hdr').dataset.g = g.id;
const bd = el.querySelector('.card-bd');
bd.insertBefore(buildNameWrap(g), bd.firstElementChild);
const hc = heuteColor(c.tdone, g.daily);
el.querySelector('.m-dr').textContent = c.dr;
el.querySelector('.m-end').textContent = fs(c.end);
const mH = el.querySelector('.m-heute'); mH.textContent = c.tdone + '/' + g.daily; mH.style.color = hc;
el.querySelector('.m-total').textContent = c.done + '/' + c.tot;
const badge = el.querySelector('.badge'); badge.className = 'badge ' + bc; badge.textContent = bt;
const fill = el.querySelector('.prog-fill'); fill.style.width = c.pct + '%'; fill.style.background = fc;
return el;
}
el = tpl('tpl-card-expanded');
if (c.ok) el.classList.add('done');
el.querySelector('.card-hdr').dataset.g = g.id;
const bd = el.querySelector('.card-bd');
bd.insertBefore(buildNameWrap(g), bd.firstElementChild);
el.querySelector('.m-dr').textContent = c.dr;
el.querySelector('.m-end').textContent = fs(c.end);
const badge = el.querySelector('.badge'); badge.className = 'badge ' + bc; badge.textContent = bt;
const fill = el.querySelector('.prog-fill'); fill.style.width = c.pct + '%'; fill.style.background = fc;
el.querySelector('.pr-done').textContent = c.done + ' ' + g.unit + ' ' + tr('doneLabel');
el.querySelector('.pr-pct').textContent = c.pct + '% ' + tr('ofLabel') + ' ' + c.tot;
el.querySelector('.sv-tdone').textContent = c.tdone;
el.querySelector('.sv-daily').textContent = g.daily;
el.querySelector('.sv-st').textContent = c.st;
el.querySelector('.sv-noch').style.color = heuteColor(c.tdone, g.daily);
el.querySelectorAll('.sunit').forEach(u => { u.textContent = g.unit; });
const sel = state.selDay[g.id] != null ? state.selDay[g.id] : t;
const dotsWrap = el.querySelector('.dots-wrap');
for (let i = 0; i < g.days; i++) {
const it = i === t, iy = i === t - 1, is = sel === i, ed = editable(g, i);
const dot = tpl('tpl-dot');
dot.className = dcls(g, i) + (is ? ' rs' : it ? ' rt' : iy && t > 0 ? ' ry' : '');
if (ed) { dot.dataset.g = g.id; dot.dataset.d = i; }
dot.textContent = dlbl(g, i);
dotsWrap.appendChild(dot);
}
if (sel != null) el.insertBefore(buildPanel(g, sel), el.querySelector('.card-foot'));
el.querySelector('.btn-del').dataset.g = g.id;
return el;
}
function buildQuickBook() {
const active = state.goals.filter(g => {
const c = calc(g);
return tOff(g) < g.days && (c.buf < 0 || (c.tdone < g.daily && c.buf < g.daily));
});
if (!active.length) return null;
const frag = document.createDocumentFragment();
const lbl = document.createElement('div'); lbl.className = 'sec-lbl'; lbl.textContent = tr('qbLabel');
frag.appendChild(lbl);
const card = document.createElement('div'); card.className = 'card qb-card';
for (const g of active) {
const c = calc(g), k = g.id + '_' + c.tOff;
const row = tpl('tpl-qb-row');
row.querySelector('.qb-name').textContent = g.name;
const stat = row.querySelector('.qb-stat'); stat.textContent = c.tdone + '/' + g.daily; stat.style.color = heuteColor(c.tdone, g.daily);
const inp = row.querySelector('.num-in');
inp.placeholder = g.daily; inp.value = state.addAmt[k] || ''; inp.dataset.k = k; inp.dataset.g = g.id; inp.dataset.o = c.tOff;
const btn = row.querySelector('.btn-as'); btn.dataset.g = g.id; btn.dataset.o = c.tOff;
card.appendChild(row);
}
frag.appendChild(card);
return frag;
}
function calcAwards() {
let units = 0;
for (const g of state.goals) {
if (tOff(g) >= g.days) units += Math.floor(g.days / 30);
}
const gold = Math.floor(units / 25); units %= 25;
const silver = Math.floor(units / 5); const bronze = units % 5;
return { gold, silver, bronze };
}
export function render() {
const m = document.getElementById('main');
const frag = document.createDocumentFragment();
if (!state.prefs.hd) {
const hint = tpl('tpl-hint');
hint.querySelector('.hclose').onclick = () => { state.prefs.hd = 1; savePrefs(); hint.remove(); };
frag.appendChild(hint);
}
const aw = calcAwards();
if (aw.gold || aw.silver || aw.bronze) {
const awards = document.createElement('div'); awards.className = 'awards';
for (const [emoji, count] of [['🥇', aw.gold], ['🥈', aw.silver], ['🥉', aw.bronze]]) {
for (let i = 0; i < count; i++) {
const sp = document.createElement('span'); sp.className = 'aw'; sp.textContent = emoji;
awards.appendChild(sp);
}
}
frag.appendChild(awards);
}
if (!state.goals.length) {
frag.appendChild(tpl('tpl-empty'));
m.innerHTML = ''; m.appendChild(frag); wire(); return;
}
if (state.userName) {
const gr = document.createElement('div'); gr.className = 'greeting';
gr.textContent = tr('hello').replace('{n}', state.userName);
frag.appendChild(gr);
}
const qb = buildQuickBook(); if (qb) frag.appendChild(qb);
const open = [], done = [];
for (const g of state.goals) {
if (calc(g).ok) done.push(g); else open.push(g);
}
if (open.length) {
const sl = document.createElement('div'); sl.className = 'sec-lbl'; sl.textContent = tr('openLabel');
frag.appendChild(sl);
for (const g of open) frag.appendChild(buildCard(g));
}
if (done.length) {
const sl2 = document.createElement('div'); sl2.className = 'sec-lbl'; sl2.textContent = tr('doneToday');
frag.appendChild(sl2);
for (const g of done) frag.appendChild(buildCard(g));
}
m.innerHTML = ''; m.appendChild(frag); wire();
}
function wire() {
document.querySelectorAll('.card-hdr[data-g]').forEach(el => {
el.onclick = function(e) {
if (e.target.classList.contains('btn-ren') || e.target.classList.contains('ren-input')) return;
toggleCollapse(this.dataset.g); render();
};
});
document.querySelectorAll('.btn-ren').forEach(b => {
b.onclick = function(e) { e.stopPropagation(); startRen(this.dataset.g); };
});
document.querySelectorAll('.ren-input').forEach(inp => {
const gid = inp.dataset.g;
inp.oninput = function() { state.renameVal = this.value; };
inp.onkeydown = function(e) { if (e.key === 'Enter') commitRen(gid); if (e.key === 'Escape') cancelRen(); };
inp.onblur = function() { commitRen(gid); };
});
document.querySelectorAll('.de').forEach(d => {
d.onclick = function(e) { e.stopPropagation(); selD(this.dataset.g, parseInt(this.dataset.d, 10)); };
});
document.querySelectorAll('.btn-as').forEach(b => {
b.onclick = function() { addSet(this.dataset.g, parseInt(this.dataset.o, 10)); };
});
document.querySelectorAll('.num-in').forEach(inp => {
const k = inp.dataset.k, g = inp.dataset.g, o = parseInt(inp.dataset.o, 10);
inp.oninput = function() { state.addAmt[k] = this.value; };
inp.onkeydown = function(e) { if (e.key === 'Enter') addSet(g, o); };
});
document.querySelectorAll('.sdel').forEach(b => {
b.onclick = function() { remSet(this.dataset.g, parseInt(this.dataset.o, 10), parseInt(this.dataset.i, 10)); };
});
document.querySelectorAll('.btn-del').forEach(b => {
b.onclick = function() { delGoal(this.dataset.g); };
});
}
```
- [ ] **Step 2: Syntax check**
```bash
node --check /srv/http/zieltracker/public/js/render.js
```
Expected: no output.
- [ ] **Step 3: Commit**
```bash
git add public/js/render.js
git commit -m "Add render.js module"
```
---
### Task 9: Create app.js entry point
**Files:**
- Create: `public/js/app.js`
Entry point: init, URL param handling, midnight timer, visibility change, stopwatch IIFE (lines 9101025 of `public/app.js`). Registers the `session-expired` listener that connects `api.js` to `auth.js` without a circular import. The stopwatch uses `swState` as the local variable name to avoid shadowing the imported `state`.
- [ ] **Step 1: Write the file**
Write `public/js/app.js`:
```js
import { state } from './state.js';
import { applyLocale } from './i18n.js';
import { api, loadGoals } from './api.js';
import { updateHeader } from './ui.js';
import { showLogin, showRegister, showResetPassword } from './auth.js';
import { openNew, openData } from './sheets.js';
import { render } from './render.js';
document.addEventListener('session-expired', () => showLogin());
document.getElementById('btnNew').onclick = openNew;
document.getElementById('btnData').onclick = openData;
document.querySelector('.hdr-logo').onclick = () => {
loadGoals().then(g => { state.goals = g; render(); }).catch(() => {});
};
updateHeader();
const _qs = new URLSearchParams(window.location.search);
const inviteToken = _qs.get('invite');
const resetSelector = _qs.get('reset_selector');
const resetToken = _qs.get('reset_token');
if (inviteToken || resetSelector) history.replaceState(null, '', location.pathname);
if (resetSelector && resetToken) {
applyLocale(null); render(); showResetPassword(resetSelector, resetToken);
} else {
api('GET', 'me')
.then(r => {
state.userName = r.name || ''; state.isAdmin = r.is_admin || false;
applyLocale(r.locale); updateHeader();
return loadGoals();
})
.then(g => { state.goals = g; render(); })
.catch(() => {
applyLocale(null); render();
if (inviteToken) showRegister(inviteToken);
else showLogin();
});
}
function scheduleMidnight() {
const n = new Date();
const ms = new Date(n.getFullYear(), n.getMonth(), n.getDate() + 1, 0, 0, 5).getTime() - n.getTime();
setTimeout(() => {
state.TODAY = new Date(); state.TODAY.setHours(0, 0, 0, 0);
state.selDay = {}; state.collapsed = {};
updateHeader(); render(); scheduleMidnight();
}, ms);
}
scheduleMidnight();
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
const n = new Date(); n.setHours(0, 0, 0, 0);
if (n.getTime() !== state.TODAY.getTime()) {
state.TODAY = n; state.selDay = {}; state.collapsed = {};
render(); scheduleMidnight();
}
loadGoals().then(g => { state.goals = g; render(); }).catch(() => {});
}
});
(function() {
const swEl = document.getElementById('sw');
let swState = 0, start = 0, elapsed = 0, raf = null, wakeLock = null;
function acquireWakeLock() {
if (!('wakeLock' in navigator)) return;
navigator.wakeLock.request('screen').then(s => { wakeLock = s; }).catch(() => {});
}
function releaseWakeLock() {
if (wakeLock) { wakeLock.release(); wakeLock = null; }
}
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible' && swState === 1) acquireWakeLock();
});
function getMs() { return swState === 1 ? elapsed + (Date.now() - start) : elapsed; }
function updateFillBtns() {
const show = getMs() >= 1000;
document.querySelectorAll('.btn-sw-fill').forEach(b => { b.style.display = show ? '' : 'none'; });
}
function fmt(ms) { return (ms / 1000).toFixed(2) + 's'; }
function tick() { swEl.textContent = fmt(Date.now() - start + elapsed); updateFillBtns(); raf = requestAnimationFrame(tick); }
swEl.addEventListener('click', () => {
if (swState === 0) {
start = Date.now(); elapsed = 0; swEl.classList.add('running');
swState = 1; tick(); acquireWakeLock();
} else if (swState === 1) {
cancelAnimationFrame(raf); elapsed += Date.now() - start;
swEl.textContent = fmt(elapsed); swEl.classList.remove('running');
swState = 2; updateFillBtns(); releaseWakeLock();
} else {
cancelAnimationFrame(raf); elapsed = 0; swEl.textContent = '0.00s';
swEl.classList.remove('running'); swState = 0; updateFillBtns(); releaseWakeLock();
}
});
document.addEventListener('click', e => {
if (!e.target.classList.contains('btn-sw-fill')) return;
const inp = e.target.closest('.add-row, .qb-row').querySelector('.num-in');
if (inp) { inp.value = Math.floor(getMs() / 1000); inp.dispatchEvent(new Event('input')); }
});
})();
```
- [ ] **Step 2: Syntax check**
```bash
node --check /srv/http/zieltracker/public/js/app.js
```
Expected: no output.
- [ ] **Step 3: Commit**
```bash
git add public/js/app.js
git commit -m "Add app.js entry point module"
```
---
### Task 10: Switch HTML, verify, clean up
**Files:**
- Modify: `templates/app.html.twig`
- Delete: `public/app.js`
- [ ] **Step 1: Find the script tag in app.html.twig**
```bash
grep -n 'app.js' /srv/http/zieltracker/templates/app.html.twig
```
Note the line number.
- [ ] **Step 2: Replace the script tag**
Change:
```html
<script src="{{ asset('app.js') }}"></script>
```
To:
```html
<script type="module" src="{{ asset('js/app.js') }}"></script>
```
- [ ] **Step 3: Clear Symfony cache**
```bash
cd /srv/http/zieltracker && php bin/console cache:clear
```
- [ ] **Step 4: Open browser and run through the checklist**
Open http://dudi.local/ and verify each of these works without console errors:
- [ ] App loads, goals are displayed
- [ ] Login form appears when logged out; login works
- [ ] "+" button opens new goal sheet; creating a goal works
- [ ] Logging a set (entering a number, pressing Enter or tapping +) works
- [ ] Collapsing/expanding a goal card works
- [ ] Renaming a goal (pencil icon) works
- [ ] Data menu opens (gear icon); locale switch (DE/EN/PL) re-renders UI correctly
- [ ] Stopwatch ticks; "fill" button inserts time into the nearest input
- [ ] Password change flow works
- [ ] Invite generation works (if admin invite button visible)
- [ ] Reloading the page re-fetches goals
- [ ] No errors in browser console
- [ ] **Step 5: Delete old file and commit**
```bash
rm /srv/http/zieltracker/public/app.js
git add -A
git commit -m "Switch to ES module structure, remove monolithic app.js"
```