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

55 KiB
Raw Permalink Blame 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.jsauth.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

mkdir -p /srv/http/zieltracker/public/js

Write public/js/state.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
node --check /srv/http/zieltracker/public/js/state.js

Expected: no output (clean).

  • Step 3: Commit
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:

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
node --check /srv/http/zieltracker/public/js/i18n.js

Expected: no output.

  • Step 3: Commit
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:

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
node --check /srv/http/zieltracker/public/js/api.js

Expected: no output.

  • Step 3: Commit
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 varconst/let.

  • Step 1: Write the file

Write public/js/goals.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
node --check /srv/http/zieltracker/public/js/goals.js

Expected: no output.

  • Step 3: Commit
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:

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
node --check /srv/http/zieltracker/public/js/ui.js

Expected: no output.

  • Step 3: Commit
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:

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
node --check /srv/http/zieltracker/public/js/auth.js

Expected: no output.

  • Step 3: Commit
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:

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
node --check /srv/http/zieltracker/public/js/sheets.js

Expected: no output.

  • Step 3: Commit
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:

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
node --check /srv/http/zieltracker/public/js/render.js

Expected: no output.

  • Step 3: Commit
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:

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
node --check /srv/http/zieltracker/public/js/app.js

Expected: no output.

  • Step 3: Commit
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

grep -n 'app.js' /srv/http/zieltracker/templates/app.html.twig

Note the line number.

  • Step 2: Replace the script tag

Change:

<script src="{{ asset('app.js') }}"></script>

To:

<script type="module" src="{{ asset('js/app.js') }}"></script>
  • Step 3: Clear Symfony cache
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

rm /srv/http/zieltracker/public/app.js
git add -A
git commit -m "Switch to ES module structure, remove monolithic app.js"