1454 lines
55 KiB
Markdown
1454 lines
55 KiB
Markdown
# 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 5–174 of `public/app.js`) and locale helpers (lines 176–188). `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 254–289: 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 194–252 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 339–365, 291–296, 912–914 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 367–490 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 492–675 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||
}
|
||
|
||
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 677–908 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 910–1025 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"
|
||
```
|