dudi/docs/superpowers/plans/2026-05-08-js-module-split.md

1455 lines
55 KiB
Markdown
Raw Permalink Normal View History

# 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"
```