Compare commits
No commits in common. "61d677d8118b58842608212ba3ffdcc62864eba1" and "c026f54163678a03431582339aa24e5a58500c9f" have entirely different histories.
61d677d811
...
c026f54163
10 changed files with 1030 additions and 966 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
```
|
||||
|
|
@ -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
1025
public/app.js
Normal file
File diff suppressed because it is too large
Load diff
106
public/js/app.js
106
public/js/app.js
|
|
@ -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')); }
|
||||
});
|
||||
})();
|
||||
|
|
@ -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); };
|
||||
});
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue