Compare commits

..

No commits in common. "61d677d8118b58842608212ba3ffdcc62864eba1" and "c026f54163678a03431582339aa24e5a58500c9f" have entirely different histories.

10 changed files with 1030 additions and 966 deletions

View file

@ -3,10 +3,7 @@ framework:
secret: '%env(APP_SECRET)%'
# Note that the session will be started ONLY if you read or write from it.
session:
gc_maxlifetime: 604800
cookie_lifetime: 604800
cookie_samesite: 'lax'
session: true
#esi: true
#fragments: true

View file

@ -1,426 +0,0 @@
# JS Unit Tests 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:** Add Node.js unit tests for `public/js/goals.js` and `public/js/i18n.js` using the built-in `node:test` runner — no npm, no build step.
**Architecture:** Two test files under `tests/js/`, run with `node --test tests/js/`. A root `package.json` with `{"type":"module"}` enables Node to parse all `.js` files as ESM. `test-i18n.js` uses static imports; `test-goals.js` uses top-level `await import()` so `globalThis.localStorage` is set before `state.js` evaluates. `state` is mutated per-test to control `TODAY` and other date-dependent logic.
**Tech Stack:** Node 25, `node:test`, `node:assert/strict`, ES modules.
---
## File Map
| File | Action | Purpose |
|---|---|---|
| `package.json` | Create | `{"type":"module"}` — lets Node parse `.js` as ESM |
| `tests/js/test-i18n.js` | Create | Tests for `tr()`, `ldoc()`, `setLocale()`, `applyLocale()` |
| `tests/js/test-goals.js` | Create | Tests for `heuteColor()`, `dTot()`, `o2d()`, `tOff()`, `calc()`, `dcls()`, `dlbl()` |
---
### Task 1: Add `package.json` and verify test runner
**Files:**
- Create: `package.json`
Without `{"type":"module"}` at the project root, Node parses `.js` as CommonJS and chokes on `import` statements. The deploy script uses only git and composer — this file is ignored by both.
- [ ] **Step 1: Create `package.json`**
```json
{ "type": "module" }
```
- [ ] **Step 2: Commit**
```bash
git add package.json
git commit -m "chore: add package.json to enable ES module parsing for Node test runner"
```
---
### Task 2: i18n.js tests
**Files:**
- Create: `tests/js/test-i18n.js`
`i18n.js` has no imports. It accesses `localStorage` only when functions are called (not at module evaluation time), so static imports work fine. A `before()` hook sets up the stubs; `beforeEach()` resets locale and clears the localStorage mock between tests.
Key behaviors under test:
- `tr(key)` — string lookup with locale fallback chain: current → de → key
- `ldoc()` — maps `LOCALE` to BCP-47 string
- `setLocale(lang, save)` — updates `LOCALE`, optionally persists to localStorage
- `applyLocale(userLocale)` — priority: explicit arg → localStorage → navigator.language
- [ ] **Step 1: Create `tests/js/test-i18n.js`**
```js
import { test, before, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { tr, ldoc, setLocale, applyLocale } from '../../public/js/i18n.js';
const lsMock = {
_data: {},
getItem(k) { return this._data[k] ?? null; },
setItem(k, v) { this._data[k] = v; },
};
before(() => {
globalThis.localStorage = lsMock;
globalThis.navigator = { language: 'de' };
});
beforeEach(() => {
setLocale('de', false);
lsMock._data = {};
});
// tr() — string lookup
test('tr() returns German string', () => {
assert.equal(tr('noch'), 'Noch');
});
test('tr() returns English string', () => {
setLocale('en', false);
assert.equal(tr('noch'), 'Left');
});
test('tr() returns Polish string', () => {
setLocale('pl', false);
assert.equal(tr('noch'), 'Jeszcze');
});
test('tr() falls back to German for unknown locale', () => {
setLocale('xx', false);
assert.equal(tr('noch'), 'Noch');
});
test('tr() returns key for completely unknown key', () => {
assert.equal(tr('__nonexistent__'), '__nonexistent__');
});
// ldoc() — BCP-47 locale string
test('ldoc() returns de-DE for German', () => {
assert.equal(ldoc(), 'de-DE');
});
test('ldoc() returns en-GB for English', () => {
setLocale('en', false);
assert.equal(ldoc(), 'en-GB');
});
test('ldoc() returns pl-PL for Polish', () => {
setLocale('pl', false);
assert.equal(ldoc(), 'pl-PL');
});
// setLocale()
test('setLocale() with save=false does not write to localStorage', () => {
setLocale('en', false);
assert.equal(lsMock._data.zt_locale, undefined);
assert.equal(ldoc(), 'en-GB');
});
test('setLocale() with save=true writes to localStorage', () => {
setLocale('pl', true);
assert.equal(lsMock._data.zt_locale, 'pl');
});
// applyLocale()
test('applyLocale() with explicit valid lang sets locale', () => {
applyLocale('pl');
assert.equal(ldoc(), 'pl-PL');
});
test('applyLocale() with unknown lang leaves locale unchanged', () => {
applyLocale('xx');
assert.equal(ldoc(), 'de-DE');
});
test('applyLocale() without arg reads localStorage', () => {
lsMock._data.zt_locale = 'en';
applyLocale(null);
assert.equal(ldoc(), 'en-GB');
});
test('applyLocale() without arg falls back to navigator.language', () => {
globalThis.navigator = { language: 'pl-PL' };
applyLocale(null); // localStorage empty, navigator → 'pl'
globalThis.navigator = { language: 'de' }; // restore
assert.equal(ldoc(), 'pl-PL');
});
```
- [ ] **Step 2: Run the tests**
```bash
node --test tests/js/test-i18n.js
```
Expected: all 14 tests pass (`✓` lines, `0 failing`).
If you see `SyntaxError: Cannot use import statement in a module` — verify `package.json` exists at the project root with `{"type":"module"}`.
If you see `ReferenceError: localStorage is not defined` — the `before()` hook hasn't run yet when the import resolves. This shouldn't happen with i18n.js (no localStorage at evaluation time), but if it does, move the `globalThis.localStorage = lsMock` assignment to before the import using a dynamic import:
```js
// Replace static import with:
globalThis.localStorage = lsMock;
const { tr, ldoc, setLocale, applyLocale } = await import('../../public/js/i18n.js');
```
- [ ] **Step 3: Commit**
```bash
git add tests/js/test-i18n.js
git commit -m "test: add unit tests for i18n.js (tr, ldoc, setLocale, applyLocale)"
```
---
### Task 3: goals.js tests
**Files:**
- Create: `tests/js/test-goals.js`
`goals.js` imports `state` from `./state.js`. `state.js` calls `loadPrefs()` at evaluation time — that function accesses `localStorage` inside a try/catch, so it survives without stubs, but setting up the stub first is cleaner. Use top-level `await import()` so `globalThis.localStorage` is set before `state.js` evaluates.
After import, `state.TODAY` is set in `beforeEach` to `new Date(2026, 4, 8, 0, 0, 0, 0)` (May 8, 2026, local midnight). The test goal uses `start: '2026-05-01 00:00:00'` (May 1) → `tOff = 7`.
`g.sets` format: keys are day offsets as strings (`'0'`, `'1'`…), values are arrays of `{amount: number}` objects.
Key `calc()` fields to understand:
- `t` = `tOff(g)` — days elapsed since start
- `past` = sum of all sets before today
- `tdone` = today's total
- `pd` = per-day target needed to finish on time
- `buf` = `(past - expectedPast) + max(0, tdone - daily)` — can be negative (deficit)
- `ok` = `tdone >= pd`
- [ ] **Step 1: Create `tests/js/test-goals.js`**
```js
import { test, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
globalThis.localStorage = {
_data: {},
getItem(k) { return this._data[k] ?? null; },
setItem(k, v) { this._data[k] = v; },
};
const { state } = await import('../../public/js/state.js');
const { tOff, o2d, dTot, heuteColor, calc, dcls, dlbl } = await import('../../public/js/goals.js');
// May 8, 2026 local midnight — used as state.TODAY in all tests
const FIXED_TODAY = new Date(2026, 4, 8, 0, 0, 0, 0);
// goal starting May 1, 2026 → tOff = 7 when TODAY = May 8
function makeGoal(overrides = {}) {
return { id: 1, start: '2026-05-01 00:00:00', daily: 10, days: 30, sets: {}, ...overrides };
}
beforeEach(() => {
state.TODAY = new Date(FIXED_TODAY);
state.goals = [];
state.collapsed = {};
state.selDay = {};
});
// heuteColor()
test('heuteColor() 0 done → red', () => {
assert.equal(heuteColor(0, 10), 'var(--red)');
});
test('heuteColor() partial → amber', () => {
assert.equal(heuteColor(5, 10), 'var(--amber)');
});
test('heuteColor() exact daily → green', () => {
assert.equal(heuteColor(10, 10), 'var(--green)');
});
test('heuteColor() over 110% → blue', () => {
assert.equal(heuteColor(11, 10), 'var(--blue)'); // 11 >= 10 * 1.1
});
// dTot()
test('dTot() sums amount fields', () => {
const g = { sets: { '0': [{ amount: 10 }, { amount: 5 }] } };
assert.equal(dTot(g, 0), 15);
});
test('dTot() empty array returns 0', () => {
const g = { sets: { '0': [] } };
assert.equal(dTot(g, 0), 0);
});
test('dTot() missing key returns 0', () => {
const g = { sets: {} };
assert.equal(dTot(g, 99), 0);
});
// o2d()
test('o2d() day 0 = start date at midnight', () => {
const g = makeGoal();
const d = o2d(g, 0);
assert.equal(d.getFullYear(), 2026);
assert.equal(d.getMonth(), 4); // 0-indexed: 4 = May
assert.equal(d.getDate(), 1);
assert.equal(d.getHours(), 0);
assert.equal(d.getMinutes(), 0);
});
test('o2d() day 1 = start + 1 day at midnight', () => {
const g = makeGoal();
const d = o2d(g, 1);
assert.equal(d.getDate(), 2); // May 2
assert.equal(d.getHours(), 0);
});
// tOff()
test('tOff() 7 days after start = 7', () => {
const g = makeGoal(); // start May 1, TODAY = May 8
assert.equal(tOff(g), 7);
});
// calc()
test('calc() day 0 nothing done: pd = daily, pct = 0, ok = false', () => {
// start = today → tOff = 0
const g = makeGoal({ start: '2026-05-08 00:00:00' });
const r = calc(g);
assert.equal(r.tot, 300); // 10 * 30
assert.equal(r.tOff, 0);
assert.equal(r.pd, 10); // ceil(300/30) = 10 = daily
assert.equal(r.done, 0);
assert.equal(r.pct, 0);
assert.equal(r.ok, false);
assert.equal(r.deficit, 0);
assert.equal(r.surplus, 0);
assert.equal(r.dailyDelta, 0);
});
test('calc() day 7 on track: no deficit, pd = daily', () => {
// days 0-6 done exactly 10 each, today nothing
const sets = {};
for (let i = 0; i < 7; i++) sets[String(i)] = [{ amount: 10 }];
const g = makeGoal({ sets });
const r = calc(g);
assert.equal(r.pd, 10); // ceil(230/23) = ceil(10) = 10
assert.equal(r.deficit, 0);
assert.equal(r.surplus, 0);
assert.equal(r.done, 70); // 7 * 10
});
test('calc() day 7 one missed day: pd > daily, deficit < 0', () => {
// days 0-5 done (10 each), day 6 missed, today nothing
const sets = {};
for (let i = 0; i < 6; i++) sets[String(i)] = [{ amount: 10 }];
const g = makeGoal({ sets });
const r = calc(g);
// past = 60, expectedPast = 70, buf = -10
assert.equal(r.deficit, -10);
assert.ok(r.pd > g.daily); // pd = ceil(240/23) = 11 > 10
assert.equal(r.surplus, 0);
});
test('calc() pct capped at 100', () => {
// start 30 days ago, all 30 days done → tOff = 30 = days
const sets = {};
for (let i = 0; i < 30; i++) sets[String(i)] = [{ amount: 10 }];
// April 8 + 30 days = May 8 → tOff = 30
const g = makeGoal({ start: '2026-04-08 00:00:00', sets });
const r = calc(g);
assert.equal(r.pct, 100);
});
// dcls()
test('dcls() future day → dot df', () => {
const g = makeGoal(); // tOff = 7
assert.equal(dcls(g, 8), 'dot df'); // i=8 > t=7
});
test('dcls() missed past day → dot dm dl', () => {
const g = makeGoal(); // no sets, day 0 < tOff 7, not editable
assert.equal(dcls(g, 0), 'dot dm dl');
});
test('dcls() today with partial done → dot dp de', () => {
const g = makeGoal({ sets: { '7': [{ amount: 5 }] } }); // 5 of 10 today
assert.equal(dcls(g, 7), 'dot dp de'); // partial + editable (i === t)
});
test('dcls() yesterday over 110% → dot db de', () => {
const g = makeGoal({ sets: { '6': [{ amount: 12 }] } }); // 12 >= 10*1.1=11
assert.equal(dcls(g, 6), 'dot db de'); // over + editable (i === t-1)
});
// dlbl()
test('dlbl() future day → day number string', () => {
const g = makeGoal(); // tOff = 7
assert.equal(dlbl(g, 8), '9'); // String(8+1)
});
test('dlbl() missed day → ✕', () => {
const g = makeGoal(); // no sets
assert.equal(dlbl(g, 0), '✕');
});
test('dlbl() exact → ✓', () => {
const g = makeGoal({ sets: { '0': [{ amount: 10 }] } });
assert.equal(dlbl(g, 0), '✓');
});
test('dlbl() partial → percentage', () => {
const g = makeGoal({ sets: { '0': [{ amount: 5 }] } }); // 5/10 = 50%
assert.equal(dlbl(g, 0), '50%');
});
test('dlbl() over 110% → +', () => {
const g = makeGoal({ sets: { '0': [{ amount: 12 }] } }); // 12 >= 11
assert.equal(dlbl(g, 0), '+');
});
```
- [ ] **Step 2: Run the tests**
```bash
node --test tests/js/test-goals.js
```
Expected: all 26 tests pass (`0 failing`).
Common failures and fixes:
**`tOff` returns wrong value**: `new Date('2026-05-01 00:00:00')` is parsed as local time in V8. If the test machine has a UTC offset that shifts midnight across a day boundary, the offset may be off by 1. Fix: change `start` in `makeGoal` to use `new Date(2026, 4, 1).toISOString().replace('T',' ').slice(0,19)` to generate a local-time string. But on standard setups in a UTC±12 range this is not an issue.
**`calc() day 7 on track pd = 10`**: Verify with: `Math.ceil(230/23) === 10``230/23 = 10.0` exactly. ✓ (300 - 70 = 230, dl = 22+1 = 23)
- [ ] **Step 3: Run all tests together**
```bash
node --test tests/js/
```
Expected: all tests from both files pass.
- [ ] **Step 4: Commit**
```bash
git add tests/js/test-goals.js
git commit -m "test: add unit tests for goals.js (calc, heuteColor, dTot, dcls, dlbl, tOff, o2d)"
```

View file

@ -1,125 +0,0 @@
# JS Unit Tests Design
## Goal
Add Node.js unit tests for `public/js/goals.js` (pure math functions) and `public/js/i18n.js` (translation/locale helpers) using the built-in `node:test` runner — no npm, no build step.
## Architecture
Two test files under `tests/js/`, run with `node --test tests/js/`.
```
tests/js/
test-i18n.js tests for tr(), ldoc(), setLocale(), applyLocale()
test-goals.js tests for heuteColor(), dTot(), o2d(), tOff(), calc(), dcls(), dlbl()
```
Both files use `node:test` and `node:assert/strict`. No test framework, no npm.
## Browser Global Stubs
`i18n.js` calls `localStorage.setItem` in `setLocale(..., true)` and `localStorage.getItem` in `applyLocale(undefined)`. `state.js` calls `localStorage.getItem` in `loadPrefs()` — but that function wraps the call in try/catch, so it survives without a stub. For clarity and to test all branches, both files add minimal stubs at the top:
```js
globalThis.localStorage = {
_data: {},
getItem(k) { return this._data[k] ?? null; },
setItem(k, v) { this._data[k] = v; },
};
globalThis.navigator = { language: 'de' };
```
## Import Strategy
`test-goals.js` must set up the globals **before** `state.js` is evaluated. Static `import` declarations hoist and run before any code in the file, so `test-goals.js` uses top-level `await import()` instead:
```js
// test-goals.js — top of file (no static imports for project modules)
globalThis.localStorage = { ... };
const { state } = await import('../../public/js/state.js');
const { calc, heuteColor, ... } = await import('../../public/js/goals.js');
```
`test-i18n.js` can use static imports because `i18n.js` has zero imports and accesses no globals at module evaluation time.
## State Control for goals.js
`tOff(g)` subtracts `g.start` from `state.TODAY`. All tests that depend on date arithmetic set `state.TODAY` explicitly:
```js
state.TODAY = new Date(2026, 4, 8, 0, 0, 0, 0); // May 8, 2026 local midnight
```
`state.collapsed` and `state.goals` are reset between tests that mutate them.
## Test Cases
### test-i18n.js (~15 cases)
| Function | Case |
|---|---|
| `tr()` | returns German string for 'de' |
| `tr()` | returns English string for 'en' |
| `tr()` | returns Polish string for 'pl' |
| `tr()` | falls back to German for unknown locale |
| `tr()` | returns key for completely unknown key |
| `ldoc()` | de → 'de-DE' |
| `ldoc()` | en → 'en-GB' |
| `ldoc()` | pl → 'pl-PL' |
| `setLocale('en', false)` | changes locale, no localStorage write |
| `setLocale('en', true)` | writes to localStorage |
| `applyLocale('pl')` | explicit valid lang sets locale |
| `applyLocale('xx')` | unknown lang leaves locale unchanged |
| `applyLocale(null)` | reads localStorage, falls back to navigator |
### test-goals.js (~20 cases)
| Function | Case |
|---|---|
| `heuteColor(0, 10)` | red (nothing done) |
| `heuteColor(5, 10)` | amber (partial) |
| `heuteColor(10, 10)` | green (exact) |
| `heuteColor(11, 10)` | blue (over by 10%) |
| `dTot(g, 0)` | sums amounts from sets objects |
| `dTot(g, 0)` | empty set returns 0 |
| `dTot(g, 99)` | missing key returns 0 |
| `o2d(g, 0)` | day 0 = start date at midnight |
| `o2d(g, 1)` | day 1 = start + 1 day at midnight |
| `tOff(g)` | 7 days after start = 7 |
| `calc(g)` | day 0, nothing done: pct=0, pd=daily, ok=false |
| `calc(g)` | day 7, all past days met, today partial: pd=daily, no deficit |
| `calc(g)` | day 7, one missed day: pd > daily, deficit < 0 |
| `calc(g)` | pct capped at 100 |
| `dcls(g, i)` | future day → 'dot df' |
| `dcls(g, i)` | missed day → 'dot dm dl' |
| `dcls(g, i)` | today (editable) with partial → contains ' de' |
| `dlbl(g, i)` | future day → day number as string |
| `dlbl(g, i)` | missed → '✕' |
| `dlbl(g, i)` | exact → '✓' |
| `dlbl(g, i)` | partial → percentage string |
| `dlbl(g, i)` | over 110% → '+' |
## sets Format
`g.sets` is keyed by **day offset as string** (not date string), values are arrays of `{amount: number}` objects:
```js
// 10 reps on day 0, 5 reps on day 1
sets: { '0': [{ amount: 10 }], '1': [{ amount: 5 }] }
```
`g.start` format from the API: `'YYYY-MM-DD HH:MM:SS'` (e.g. `'2026-05-01 00:00:00'`).
## Run Command
```bash
node --test tests/js/
```
All test files use ES module syntax (`import`). Node resolves `.js` files as CommonJS by default, so a root-level `package.json` must be added:
```json
{ "type": "module" }
```
This is a new file — the project has no existing `package.json`. It is safe to add: the deploy script uses only git and composer, and the browser ignores it. Without this, `state.js` would fail to parse as ESM when dynamically imported by the tests.

1025
public/app.js Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,106 +0,0 @@
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')); }
});
})();

View file

@ -1,289 +0,0 @@
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) 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), ed = editable(g, off);
const lbl = off === t ? tr('heute') : off === t - 1 ? tr('gestern') : null;
const k = g.id + '_' + off;
const el = tpl('tpl-panel');
el.querySelector('.dpanel-title').textContent = (lbl ? 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');
if (ed) { btn.dataset.g = g.id; btn.dataset.o = off; btn.dataset.i = i; } else { btn.remove(); }
body.appendChild(row);
}
} else {
body.appendChild(tpl('tpl-nosets'));
}
if (ed) {
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 && c.buf >= 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 && c.buf >= 0) { 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 (i <= t) { 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, .dl').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); };
});
}

View file

@ -65,7 +65,7 @@ export function openData() {
c.querySelector('.dm-cpw').onclick = () => { closeOv(); showChangePassword(); };
c.querySelector('.dm-lgout').onclick = () => {
api('POST', 'logout').finally(() => { state.goals = []; closeOv(); render(); showLogin(); });
api('POST', 'logout').then(() => { state.goals = []; closeOv(); render(); showLogin(); });
};
const adminBtn = c.querySelector('.dm-admin');

View file

@ -13,7 +13,7 @@ class AppController extends AbstractController
{
$public = $this->getParameter('kernel.project_dir') . '/public/';
return $this->render('app.html.twig', [
'jsv' => substr(md5_file($public . 'js/app.js'), 0, 8),
'jsv' => substr(md5_file($public . 'app.js'), 0, 8),
'cssv' => substr(md5_file($public . 'style.css'), 0, 8),
]);
}

View file

@ -84,19 +84,7 @@ class GoalController extends AbstractController
if (isset($data['unit'])) $goal->setUnit((string)$data['unit']);
if (isset($data['daily'])) $goal->setDaily((float)$data['daily']);
if (isset($data['days'])) $goal->setDays((int)$data['days']);
if (isset($data['sets'])) {
$newSets = (array)$data['sets'];
$existing = $goal->getSets();
$startTs = (clone $goal->getStart())->setTime(0, 0, 0)->getTimestamp();
$todayOffset = (int)round((mktime(0, 0, 0) - $startTs) / 86400);
foreach ($existing as $offset => $entries) {
if ((int)$offset < $todayOffset - 1) $newSets[(string)$offset] = $entries;
}
foreach (array_keys($newSets) as $offset) {
if ((int)$offset < $todayOffset - 1 && !array_key_exists((string)$offset, $existing)) unset($newSets[$offset]);
}
$goal->setSets($newSets);
}
if (isset($data['sets'])) $goal->setSets((array)$data['sets']);
$this->em->flush();
return new JsonResponse(['ok' => true]);

View file

@ -357,6 +357,6 @@
</div>
</template>
<script type="module" src="js/app.js?v={{ jsv }}"></script>
<script src="app.js?v={{ jsv }}"></script>
</body>
</html>