Initial commit: Dudi habit tracker

Symfony 8 SPA with Doctrine ORM, Symfony Security, vanilla JS frontend.
Migrated from plain PHP (delight-im/auth + raw SQL) to full Symfony stack.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Simon Kühn 2026-04-29 15:40:57 +02:00
commit fd473f00af
64 changed files with 12287 additions and 0 deletions

18
.env Normal file
View file

@ -0,0 +1,18 @@
###> symfony/framework-bundle ###
APP_ENV=dev
APP_SECRET=change_me
###< symfony/framework-bundle ###
###> doctrine/doctrine-bundle ###
DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=10.11-MariaDB&charset=utf8mb4"
###< doctrine/doctrine-bundle ###
###> symfony/mailer ###
MAILER_DSN=null://null
MAILER_FROM=noreply@example.com
###< symfony/mailer ###
###> app ###
APP_URL=http://localhost
DEFAULT_URI=http://localhost/dd
###< app ###

18
.gitignore vendored Normal file
View file

@ -0,0 +1,18 @@
###> symfony ###
/.env.local
/.env.*.local
/var/
/vendor/
/public/bundles/
###< symfony ###
###> legacy (pre-symfony) ###
/include/config.php
###< legacy ###
###> tools ###
/.claude/
/.superpowers/
/.aider*
/docs/private/
###< tools ###

15
.htaccess Normal file
View file

@ -0,0 +1,15 @@
RewriteEngine On
RewriteBase /dd/
# Composer-Dateien nicht ausliefern
<FilesMatch "^(composer\.(json|lock))$">
Require all denied
</FilesMatch>
# API-Requests an api.php weiterleiten, Pfad als Query-Parameter
RewriteRule ^api/(.+)$ api.php?_path=$1 [L,QSA]
RewriteRule ^api/?$ api.php [L,QSA]
# Alles andere an index.php (SPA)
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.php [L]

48
CLAUDE.md Normal file
View file

@ -0,0 +1,48 @@
# Dudi Claude Context
## What this is
Habit & goal tracking PWA. Users set goals (e.g. "50 push-ups/day for 30 days"), log sets daily, and see progress. Invite-only registration. German UI.
## Stack
- Symfony 8 + Doctrine ORM (src/)
- Vanilla JS SPA — no build step, no npm (public/app.js)
- HTML `<template>` elements in templates/app.html.twig (rendered once, JS clones them)
- MariaDB, session auth + remember-me 24h
## Local dev
- URL: http://dudi.local/
- DB: zieltracker / zieltracker (see .env.local, gitignored)
- `php bin/console cache:clear` after config changes
- `public/app.js` is the authoritative JS file — also copy to `public/` after edits to `app.js` at root (root copy is legacy, will be removed)
## Server
- `ssh -p 30183 root@miniweb.kuehn.home`
- App: /var/www/tracker/, DB: dd/dd
- Deploy: `bash deploy.sh` (rsync + remote composer install + cache:clear)
- Remember-me table must exist: `rememberme_token` (already created)
## Key files
- `src/Controller/AuthController.php` — login, register, password reset, name change
- `src/Controller/GoalController.php` — goals CRUD
- `src/Controller/InviteController.php` — invite creation + listing
- `src/Security/JsonLoginAuthenticator.php` — JSON POST /api/login
- `config/packages/security.yaml` — firewall, remember-me, access control
- `templates/app.html.twig` — SPA shell + all tpl-* templates
- `public/app.js` — all frontend logic
## Data model
- `goals.sets` is a JSON object: `{"2026-04-01": [20, 30], "2026-04-02": [50]}` — keys are dates, values are arrays of logged amounts per session
- Users table schema is compatible with the old delight-im/auth library (bcrypt hashes work unchanged)
- Password resets use `users_resets` table directly (raw SQL) — Symfony doesn't manage this table
## Conventions
- API responses: `{ ok: true, ... }` on success, `{ error: "message" }` on failure
- All API routes prefixed `/api/`, catch-all route in AppController renders SPA
- No Twig cache (cache: false in index.php, now handled by Symfony kernel in prod)
- JS uses `tpl(id)` helper to clone `<template>` elements, fills via querySelector with semantic class names
- No comments in JS unless non-obvious; no trailing summaries in responses
## Pending cleanup
- `api.php`, `index.php`, `include/`, `app.js`, `style.css` at project root are legacy pre-Symfony files — safe to delete once miniweb is migrated to Symfony
- Legacy DB tables that can be dropped: `users_throttling`, `users_confirmations`, `schema_migrations`
- miniweb still runs old non-Symfony code — Symfony deploy + Apache config update pending

48
README.md Normal file
View file

@ -0,0 +1,48 @@
# Dudi Durchzieh-Dienst
A lightweight habit & goal tracker PWA built with Symfony 8, Doctrine ORM, and vanilla JS.
## Stack
- **Backend:** Symfony 8, Doctrine ORM, Symfony Security, Symfony Mailer
- **Frontend:** Vanilla JS SPA, HTML `<template>` elements, no build step
- **Database:** MariaDB
- **Auth:** Symfony Security with bcrypt, session + remember-me (24h)
## Local Setup
```bash
composer install
cp .env .env.local # then fill in real values
php bin/console cache:clear
```
Configure Apache to point to `public/` as DocumentRoot (see `docs/deployment.md`).
## Development
The app runs at `http://dudi.local/` locally. An Apache VirtualHost and `/etc/hosts` entry are required.
```
127.0.0.1 dudi.local
```
Apache VirtualHost:
```apache
<VirtualHost *:80>
ServerName dudi.local
DocumentRoot "/path/to/dudi/public"
<Directory "/path/to/dudi/public">
AllowOverride All
Require all granted
</Directory>
</VirtualHost>
```
## Deploy
```bash
bash deploy.sh
```
See `docs/deployment.md` for first-time server setup.

294
api.php Normal file
View file

@ -0,0 +1,294 @@
<?php
error_reporting(E_ALL);
ini_set('display_errors', 0);
ini_set('log_errors', 1);
ini_set('error_log', '/tmp/zt.log');
set_exception_handler(function(\Throwable $e) {
http_response_code(500);
echo json_encode(['error' => $e->getMessage(), 'file' => basename($e->getFile()), 'line' => $e->getLine()]);
exit;
});
require_once __DIR__ . '/include/db.php';
require_once __DIR__ . '/include/mailer.php';
header('Content-Type: application/json; charset=utf-8');
// CORS für gleiche Domain nicht nötig; bei separaten Dev-Domains ggf. ergänzen
$method = $_SERVER['REQUEST_METHOD'];
$segments = explode('/', trim($_GET['_path'] ?? '', '/'));
$resource = $segments[0] ?? '';
$resourceId = isset($segments[1]) && $segments[1] !== '' ? (int)$segments[1] : null;
$body = json_decode(file_get_contents('php://input'), true) ?? [];
function json_out(array $data, int $status = 200): never {
http_response_code($status);
echo json_encode($data);
exit;
}
function require_auth(\Delight\Auth\Auth $auth): int {
if (!$auth->isLoggedIn()) {
json_out(['error' => 'Unauthorized'], 401);
}
return $auth->getUserId();
}
// ── POST /api/login ──────────────────────────────────────────────────────────
if ($method === 'POST' && $resource === 'login') {
$email = trim($body['email'] ?? '');
$pass = $body['password'] ?? '';
try {
$auth->login($email, $pass, 86400);
json_out(['ok' => true, 'email' => $auth->getEmail()]);
} catch (\Delight\Auth\InvalidEmailException
| \Delight\Auth\InvalidPasswordException
| \Delight\Auth\UserNotFound $e) {
json_out(['error' => 'Falsche E-Mail oder Passwort'], 401);
} catch (\Delight\Auth\TooManyRequestsException $e) {
json_out(['error' => 'Zu viele Versuche, bitte warten'], 429);
}
}
// ── POST /api/logout ─────────────────────────────────────────────────────────
if ($method === 'POST' && $resource === 'logout') {
$auth->logOut();
json_out(['ok' => true]);
}
// ── GET /api/me ───────────────────────────────────────────────────────────────
if ($method === 'GET' && $resource === 'me') {
if ($auth->isLoggedIn()) {
$name = $conn->fetchOne('SELECT username FROM users WHERE id = ?', [$auth->getUserId()]);
json_out(['ok' => true, 'email' => $auth->getEmail(), 'id' => $auth->getUserId(), 'name' => $name ?: '']);
}
json_out(['ok' => false], 401);
}
// ── PATCH /api/me ─────────────────────────────────────────────────────────────
if ($method === 'PATCH' && $resource === 'me') {
$uid = require_auth($auth);
$name = trim($body['name'] ?? '');
if (!$name) json_out(['error' => 'Name fehlt'], 400);
$conn->executeStatement('UPDATE users SET username = ? WHERE id = ?', [$name, $uid]);
json_out(['ok' => true, 'name' => $name]);
}
// ── POST /api/register ────────────────────────────────────────────────────────
if ($method === 'POST' && $resource === 'register') {
$email = trim($body['email'] ?? '');
$pass = $body['password'] ?? '';
$token = trim($body['token'] ?? '');
if (!$email || !$pass || !$token) {
json_out(['error' => 'Fehlende Felder'], 400);
}
// Token validieren
$invite = $conn->fetchAssociative(
'SELECT * FROM invites WHERE token = ? AND used_by IS NULL AND expires_at > NOW()',
[$token]
);
if (!$invite) {
json_out(['error' => 'Ungültiger oder abgelaufener Einladungslink'], 400);
}
$name = trim($body['name'] ?? '');
try {
$userId = $auth->register($email, $pass);
} catch (\Delight\Auth\InvalidEmailException $e) {
json_out(['error' => 'Ungültige E-Mail'], 400);
} catch (\Delight\Auth\InvalidPasswordException $e) {
json_out(['error' => 'Passwort zu kurz (min. 8 Zeichen)'], 400);
} catch (\Delight\Auth\UserAlreadyExistsException $e) {
json_out(['error' => 'E-Mail bereits registriert'], 409);
}
// Token als verwendet markieren
$conn->executeStatement(
'UPDATE invites SET used_by = ?, used_at = NOW() WHERE token = ?',
[$userId, $token]
);
if ($name) {
$conn->executeStatement('UPDATE users SET username = ? WHERE id = ?', [$name, $userId]);
}
// Direkt einloggen
$auth->login($email, $pass, 86400);
json_out(['ok' => true, 'email' => $auth->getEmail(), 'name' => $name]);
}
// ── POST /api/reset-request ──────────────────────────────────────────────────
if ($method === 'POST' && $resource === 'reset-request') {
$email = trim($body['email'] ?? '');
if (!$email) json_out(['error' => 'E-Mail fehlt'], 400);
try {
$auth->requestPasswordReset($email, function (string $selector, string $token) use ($email) {
$url = APP_URL . '/?reset_selector=' . rawurlencode($selector) . '&reset_token=' . rawurlencode($token);
$text = "Hallo,\n\nklicke auf den folgenden Link um dein Passwort zurückzusetzen (gültig 24h):\n\n$url\n\nFalls du das nicht angefragt hast, ignoriere diese E-Mail.";
sendMail($email, 'Durchzieh-Dienst Passwort zurücksetzen', $text);
});
} catch (\Delight\Auth\InvalidEmailException $e) {
// Keinen Hinweis geben ob die E-Mail existiert
} catch (\Exception $e) {
json_out(['error' => 'Mail konnte nicht gesendet werden'], 500);
}
json_out(['ok' => true]); // immer ok, auch wenn E-Mail unbekannt
}
// ── POST /api/reset-password ──────────────────────────────────────────────────
if ($method === 'POST' && $resource === 'reset-password') {
$selector = $body['selector'] ?? '';
$token = $body['token'] ?? '';
$pass = $body['password'] ?? '';
if (!$selector || !$token || !$pass) json_out(['error' => 'Fehlende Felder'], 400);
try {
$auth->resetPassword($selector, $token, $pass);
json_out(['ok' => true]);
} catch (\Delight\Auth\InvalidSelectorTokenPairException $e) {
json_out(['error' => 'Ungültiger oder abgelaufener Reset-Link'], 400);
} catch (\Delight\Auth\TokenExpiredException $e) {
json_out(['error' => 'Reset-Link abgelaufen, bitte neu anfordern'], 400);
} catch (\Delight\Auth\InvalidPasswordException $e) {
json_out(['error' => 'Passwort zu kurz (min. 8 Zeichen)'], 400);
}
}
// ── POST /api/change-password ─────────────────────────────────────────────────
if ($method === 'POST' && $resource === 'change-password') {
$uid = require_auth($auth);
$oldPass = $body['old_password'] ?? '';
$newPass = $body['new_password'] ?? '';
if (!$oldPass || !$newPass) json_out(['error' => 'Fehlende Felder'], 400);
try {
$auth->changePassword($oldPass, $newPass);
json_out(['ok' => true]);
} catch (\Delight\Auth\NotLoggedInException $e) {
json_out(['error' => 'Nicht eingeloggt'], 401);
} catch (\Delight\Auth\InvalidPasswordException $e) {
json_out(['error' => 'Neues Passwort zu kurz (min. 8 Zeichen)'], 400);
} catch (\Delight\Auth\TooManyRequestsException $e) {
json_out(['error' => 'Zu viele Versuche'], 429);
}
}
// ── POST /api/invite ─────────────────────────────────────────────────────────
if ($method === 'POST' && $resource === 'invite') {
$uid = require_auth($auth);
$token = bin2hex(random_bytes(32));
$note = trim($body['note'] ?? '');
$conn->executeStatement(
'INSERT INTO invites (token, note, created_by, expires_at) VALUES (?, ?, ?, DATE_ADD(NOW(), INTERVAL 7 DAY))',
[$token, $note ?: null, $uid]
);
json_out(['url' => APP_URL . '/?invite=' . $token]);
}
// ── GET /api/invites ──────────────────────────────────────────────────────────
if ($method === 'GET' && $resource === 'invites') {
$uid = require_auth($auth);
$rows = $conn->fetchAllAssociative(
'SELECT i.token, i.created_at, i.expires_at, i.used_at, i.note, u.email AS used_by_email
FROM invites i
LEFT JOIN users u ON u.id = i.used_by
WHERE i.created_by = ?
ORDER BY i.created_at DESC',
[$uid]
);
$result = array_map(function ($r) {
if ($r['used_by_email'] !== null) {
$status = 'used';
} elseif (strtotime($r['expires_at']) < time()) {
$status = 'expired';
} else {
$status = 'pending';
}
return [
'url' => $status === 'pending' ? APP_URL . '/?invite=' . $r['token'] : null,
'created_at' => $r['created_at'],
'expires_at' => $r['expires_at'],
'used_at' => $r['used_at'],
'note' => $r['note'],
'used_by_email' => $r['used_by_email'],
'status' => $status,
];
}, $rows);
json_out($result);
}
// ── GET /api/goals ────────────────────────────────────────────────────────────
if ($method === 'GET' && $resource === 'goals') {
$uid = require_auth($auth);
$rows = $conn->fetchAllAssociative(
'SELECT id, name, unit, daily, days, start, sets FROM goals WHERE user_id = ? ORDER BY created_at ASC',
[$uid]
);
$goals = array_map(function ($r) {
$r['sets'] = json_decode($r['sets'], true) ?: [];
$r['id'] = (string)$r['id'];
return $r;
}, $rows);
json_out($goals);
}
// ── POST /api/goals ───────────────────────────────────────────────────────────
if ($method === 'POST' && $resource === 'goals') {
$uid = require_auth($auth);
$name = trim($body['name'] ?? '');
$unit = trim($body['unit'] ?? 'Stück');
$daily = (float)($body['daily'] ?? 1);
$days = (int) ($body['days'] ?? 30);
try { $start = (new DateTime($body['start'] ?? 'now'))->format('Y-m-d H:i:s'); }
catch (\Exception $e) { $start = date('Y-m-d H:i:s'); }
$setsJson = isset($body['sets']) && is_array($body['sets']) ? json_encode((object)$body['sets']) : '{}';
if (!$name) json_out(['error' => 'Name fehlt'], 400);
$conn->executeStatement(
'INSERT INTO goals (user_id, name, unit, daily, days, start, sets) VALUES (?, ?, ?, ?, ?, ?, ?)',
[$uid, $name, $unit, $daily, $days, $start, $setsJson]
);
$id = (string)$conn->lastInsertId();
json_out(['id' => $id, 'name' => $name, 'unit' => $unit, 'daily' => $daily, 'days' => $days, 'start' => $start, 'sets' => json_decode($setsJson, true)]);
}
// ── PATCH /api/goals/{id} ─────────────────────────────────────────────────────
if ($method === 'PATCH' && $resource === 'goals' && $resourceId !== null) {
$uid = require_auth($auth);
// Sicherstellen dass das Ziel dem User gehört
$existing = $conn->fetchOne('SELECT id FROM goals WHERE id = ? AND user_id = ?', [$resourceId, $uid]);
if (!$existing) json_out(['error' => 'Nicht gefunden'], 404);
$fields = [];
$params = [];
foreach (['name', 'unit'] as $f) {
if (isset($body[$f])) { $fields[] = "$f = ?"; $params[] = (string)$body[$f]; }
}
foreach (['daily'] as $f) {
if (isset($body[$f])) { $fields[] = "$f = ?"; $params[] = (float)$body[$f]; }
}
foreach (['days'] as $f) {
if (isset($body[$f])) { $fields[] = "$f = ?"; $params[] = (int)$body[$f]; }
}
if (isset($body['sets'])) {
$fields[] = 'sets = ?';
$params[] = json_encode($body['sets']);
}
if (!$fields) json_out(['ok' => true]);
$params[] = $resourceId;
$params[] = $uid;
$conn->executeStatement('UPDATE goals SET ' . implode(', ', $fields) . ' WHERE id = ? AND user_id = ?', $params);
json_out(['ok' => true]);
}
// ── DELETE /api/goals/{id} ────────────────────────────────────────────────────
if ($method === 'DELETE' && $resource === 'goals' && $resourceId !== null) {
$uid = require_auth($auth);
$conn->executeStatement('DELETE FROM goals WHERE id = ? AND user_id = ?', [$resourceId, $uid]);
json_out(['ok' => true]);
}
json_out(['error' => 'Not found'], 404);

716
app.js Normal file
View file

@ -0,0 +1,716 @@
var TODAY = new Date(); TODAY.setHours(0,0,0,0);
var goals = [], prefs, selDay = {}, addAmt = {}, renamingId = null, renameVal = '', collapsed = {};
var userName = '';
function loadPref(k,def){ try{ return JSON.parse(localStorage.getItem(k)||def); }catch(e){ return JSON.parse(def); } }
function saveP(){ localStorage.setItem('zt_p',JSON.stringify(prefs)); }
prefs = loadPref('zt_p','{}');
function tOff(g){ return Math.round((TODAY - new Date(g.start))/86400000); }
function o2d(g,i){ var d=new Date(new Date(g.start).getTime()+i*86400000); d.setHours(0,0,0,0); return d; }
function dTot(g,o){ return (g.sets[String(o)]||[]).reduce(function(a,b){return a+b.amount;},0); }
function fd(d){ return d.toLocaleDateString('de-DE',{weekday:'short',day:'numeric',month:'short'}); }
function fs(d){ return d.toLocaleDateString('de-DE',{day:'numeric',month:'short'}); }
function editable(g,o){ var t=tOff(g); return o===t||o===t-1; }
function now(){ var n=new Date(); return String(n.getHours()).padStart(2,'0')+':'+String(n.getMinutes()).padStart(2,'0'); }
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)';
}
function isCollapsed(id){ return collapsed[id]!==false; }
function toggleCollapse(id){
var wasCollapsed=isCollapsed(id);
collapsed[id]=!wasCollapsed;
if(wasCollapsed){
var g=goals.filter(function(x){return x.id===id;})[0];
if(g) selDay[id]=tOff(g);
}
render();
}
function calc(g){
var t=tOff(g), tot=g.daily*g.days;
var dr=Math.max(0,g.days-t-1);
var sd=new Date(g.start); sd.setHours(0,0,0,0);
var end=new Date(sd.getTime()+g.days*86400000);
var past=0;
for(var i=0;i<Math.min(t,g.days);i++) past+=dTot(g,i);
var tdone=dTot(g,t), tot2=past+tdone;
var dl=dr+1;
var remaining=Math.max(0,tot-past);
var pd=Math.ceil(remaining/Math.max(1,dl));
var st=Math.max(0,pd-tdone);
var expectedPast=Math.min(t,g.days)*g.daily;
var buf=(past-expectedPast)+Math.max(0,tdone-g.daily);
var deficit=Math.min(0,buf);
var surplus=Math.max(0,buf);
var dailyDelta=pd-g.daily;
var pct=Math.min(100,Math.round((tot2/tot)*100));
return{tot:tot,tOff:t,end:end,dr:dr,done:tot2,tdone:tdone,pd:pd,st:st,buf:buf,deficit:deficit,surplus:surplus,dailyDelta:dailyDelta,net:tdone-pd,pct:pct,ok:tdone>=pd};
}
function dcls(g,i){
var t=tOff(g); if(i>t) return 'dot df';
var v=dTot(g,i);
var 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');
}
function dlbl(g,i){
var t=tOff(g); if(i>t) return String(i+1);
var 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)+'%';
}
// ── API ──────────────────────────────────────────────────────────────────────
function api(method, path, body){
var opts = {method:method, credentials:'include', headers:{'Content-Type':'application/json'}};
if(body) opts.body = JSON.stringify(body);
return fetch('api/' + path, opts).then(function(res){
return res.json().then(function(data){
if(!res.ok){ var e=new Error(data.error||'Fehler'); e.status=res.status; throw e; }
return data;
});
});
}
function loadGoals(){
return api('GET','goals').then(function(data){ return data; });
}
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(function(e){
if(e.status===401){ showLogin(); }
else showToast('Speicherfehler');
});
}
// ── Toast ─────────────────────────────────────────────────────────────────────
function showToast(msg){
var t=document.createElement('div'); t.className='toast'; t.textContent=msg;
document.body.appendChild(t); setTimeout(function(){t.remove();},3000);
}
// ── Goal-Aktionen ─────────────────────────────────────────────────────────────
function addSet(gid,off){
var g=goals.filter(function(x){return x.id===gid;})[0];
if(!g||!editable(g,off)) return;
var k=gid+'_'+off, amt=parseInt(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():'—'});
addAmt[k]=''; saveGoal(g); render();
}
function remSet(gid,off,idx){
var g=goals.filter(function(x){return x.id===gid;})[0];
if(!g||!editable(g,off)) return;
g.sets[String(off)].splice(idx,1); saveGoal(g); render();
}
function delGoal(id){
if(!confirm('Ziel wirklich löschen?')) return;
goals=goals.filter(function(g){return g.id!==id;});
render();
api('DELETE','goals/'+id).catch(function(){ showToast('Fehler beim Löschen'); });
}
function selD(gid,off){
var g=goals.filter(function(x){return x.id===gid;})[0];
if(!g||!editable(g,off)) return;
selDay[gid]=selDay[gid]===off?null:off; render();
}
function startRen(id){
var g=goals.filter(function(x){return x.id===id;})[0]; if(!g) return;
renamingId=id; renameVal=g.name; render();
setTimeout(function(){ var el=document.getElementById('ri'+id); if(el){el.focus();el.select();} },50);
}
function commitRen(id){
var g=goals.filter(function(x){return x.id===id;})[0];
if(g&&renameVal.trim()){g.name=renameVal.trim(); saveGoal(g);}
renamingId=null; render();
}
function cancelRen(){ renamingId=null; render(); }
// ── Template-Helper ───────────────────────────────────────────────────────────
function tpl(id){
return document.getElementById(id).content.cloneNode(true).firstElementChild;
}
// ── Overlays ──────────────────────────────────────────────────────────────────
var 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';
function closeOv(){
var o=document.getElementById('ov');
o.style.display='none';
o.innerHTML='';
}
function showSheet(content, dismissable){
var o=document.getElementById('ov');
o.style.cssText=OV_CSS;
var sheet=tpl('tpl-sheet');
sheet.appendChild(content);
o.innerHTML='';
o.appendChild(sheet);
o.onclick=dismissable!==false?function(e){if(e.target===o)closeOv();}:null;
}
// ── Login ─────────────────────────────────────────────────────────────────────
function showLogin(err){
var c=tpl('tpl-login');
if(err){ var e=c.querySelector('.login-err'); e.textContent=err; e.style.display=''; }
showSheet(c,false);
var email=c.querySelector('.lf-email'), pass=c.querySelector('.lf-pass'), sub=c.querySelector('.lf-sub');
setTimeout(function(){email.focus();},50);
email.onkeydown=function(e){if(e.key==='Enter')pass.focus();};
pass.onkeydown=function(e){if(e.key==='Enter')sub.click();};
c.querySelector('.lf-fgt').onclick=function(){showForgotPassword();};
sub.onclick=function(){
var ev=email.value.trim(), pv=pass.value;
if(!ev||!pv){ var errEl=c.querySelector('.login-err'); errEl.textContent='Bitte E-Mail und Passwort eingeben'; errEl.style.display=''; return; }
sub.disabled=true; sub.textContent='…';
api('POST','login',{email:ev,password:pv})
.then(function(){ return loadGoals(); })
.then(function(g){ goals=g; closeOv(); render(); })
.catch(function(err){
sub.disabled=false; sub.textContent='Anmelden';
showLogin(err.status===401?'Falsche E-Mail oder Passwort':err.status===429?'Zu viele Versuche':'Verbindungsfehler');
});
};
}
// ── Passwort vergessen ────────────────────────────────────────────────────────
function showForgotPassword(){
var c=tpl('tpl-forgot-pw');
showSheet(c,false);
var email=c.querySelector('.fp-email'), errEl=c.querySelector('.login-err'), sub=c.querySelector('.fp-sub');
setTimeout(function(){email.focus();},50);
c.querySelector('.fp-back').onclick=function(){showLogin();};
sub.onclick=function(){
var ev=email.value.trim(); if(!ev) return;
sub.disabled=true; sub.textContent='…';
api('POST','reset-request',{email:ev})
.then(function(){
var conf=tpl('tpl-email-sent');
conf.querySelector('.es-ok').onclick=function(){showLogin();};
showSheet(conf,false);
})
.catch(function(err){
sub.disabled=false; sub.textContent='Link senden';
errEl.textContent=err.message||'Fehler'; errEl.style.display='';
});
};
}
// ── Passwort zurücksetzen ─────────────────────────────────────────────────────
function showResetPassword(selector,token){
var c=tpl('tpl-reset-pw');
showSheet(c,false);
var pass=c.querySelector('.rp-pass'), errEl=c.querySelector('.login-err'), sub=c.querySelector('.rp-sub');
setTimeout(function(){pass.focus();},50);
sub.onclick=function(){
var pv=pass.value; if(!pv) return;
sub.disabled=true; sub.textContent='…';
api('POST','reset-password',{selector:selector,token:token,password:pv})
.then(function(){
var conf=tpl('tpl-pw-changed');
conf.querySelector('.pc-ok').onclick=function(){showLogin();};
showSheet(conf,false);
})
.catch(function(err){
sub.disabled=false; sub.textContent='Passwort setzen';
errEl.textContent=err.message||'Fehler'; errEl.style.display='';
});
};
}
// ── Passwort ändern ───────────────────────────────────────────────────────────
function showChangePassword(){
var c=tpl('tpl-change-pw');
showSheet(c,true);
var oldP=c.querySelector('.cp-old'), newP=c.querySelector('.cp-new'), newP2=c.querySelector('.cp-new2');
var errEl=c.querySelector('.login-err'), sub=c.querySelector('.cp-sub');
setTimeout(function(){oldP.focus();},50);
c.querySelector('.cp-can').onclick=closeOv;
sub.onclick=function(){
var o=oldP.value, n=newP.value, n2=newP2.value;
if(!o||!n||!n2) return;
if(n!==n2){ errEl.textContent='Die neuen Passwörter stimmen nicht überein'; errEl.style.display=''; return; }
sub.disabled=true; sub.textContent='…';
api('POST','change-password',{old_password:o,new_password:n})
.then(function(){ showToast('Passwort geändert'); closeOv(); })
.catch(function(err){
sub.disabled=false; sub.textContent='Ändern';
errEl.textContent=err.message||'Fehler'; errEl.style.display='';
});
};
}
// ── Registrierung ─────────────────────────────────────────────────────────────
function showRegister(token){
var c=tpl('tpl-register');
showSheet(c,false);
var nameInp=c.querySelector('.rg-name'), email=c.querySelector('.rg-email');
var pass=c.querySelector('.rg-pass'), pass2=c.querySelector('.rg-pass2');
var errEl=c.querySelector('.login-err'), sub=c.querySelector('.rg-sub');
setTimeout(function(){nameInp.focus();},50);
nameInp.onkeydown=function(e){if(e.key==='Enter')email.focus();};
email.onkeydown=function(e){if(e.key==='Enter')pass.focus();};
pass.onkeydown=function(e){if(e.key==='Enter')pass2.focus();};
pass2.onkeydown=function(e){if(e.key==='Enter')sub.click();};
function checkMatch(){ if(pass2.value&&pass.value!==pass2.value){ errEl.textContent='Passwörter stimmen nicht überein'; errEl.style.display=''; } else { errEl.style.display='none'; } }
pass.oninput=checkMatch; pass2.oninput=checkMatch;
sub.onclick=function(){
var nv=nameInp.value.trim(), ev=email.value.trim(), pv=pass.value;
if(!nv||!ev||!pv){ errEl.textContent='Bitte alle Felder ausfüllen'; errEl.style.display=''; return; }
if(pv!==pass2.value){ errEl.textContent='Passwörter stimmen nicht überein'; errEl.style.display=''; return; }
sub.disabled=true; sub.textContent='…';
api('POST','register',{name:nv,email:ev,password:pv,token:token})
.then(function(r){ userName=r.name||''; return loadGoals(); })
.then(function(g){ goals=g; closeOv(); updateHeader(); render(); })
.catch(function(err){
sub.disabled=false; sub.textContent='Registrieren';
errEl.textContent=err.message||'Fehler'; errEl.style.display='';
});
};
}
// ── Neues Ziel ────────────────────────────────────────────────────────────────
function openNew(){
var c=tpl('tpl-new-goal');
showSheet(c,true);
var name=c.querySelector('.ng-name'), unit=c.querySelector('.ng-unit');
var daily=c.querySelector('.ng-daily'), days=c.querySelector('.ng-days'), sub=c.querySelector('.ng-sub');
setTimeout(function(){name.focus();},50);
c.querySelector('.ng-can').onclick=closeOv;
sub.onclick=function(){
var nv=(name.value||'').trim(), uv=(unit.value||'').trim()||'Stück';
var dv=parseInt(daily.value,10)||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:TODAY.toISOString()})
.then(function(r){
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(function(e){
sub.disabled=false;
if(e.status===401){ closeOv(); showLogin(); }
else showToast('Fehler beim Erstellen');
});
};
}
// ── Daten-Menü ────────────────────────────────────────────────────────────────
function openData(){
var c=tpl('tpl-data-menu');
showSheet(c,true);
c.querySelector('.dm-cls').onclick=closeOv;
c.querySelector('.dm-name').onclick=function(){
var nc=tpl('tpl-change-name');
showSheet(nc,true);
var inp=nc.querySelector('.cn-name'), errEl=nc.querySelector('.login-err'), sub=nc.querySelector('.cn-sub');
inp.value=userName;
setTimeout(function(){inp.focus();inp.select();},50);
nc.querySelector('.cn-can').onclick=closeOv;
sub.onclick=function(){
var nv=inp.value.trim();
if(!nv){ errEl.textContent='Name darf nicht leer sein'; errEl.style.display=''; return; }
sub.disabled=true; sub.textContent='…';
api('PATCH','me',{name:nv})
.then(function(r){ userName=r.name; closeOv(); render(); showToast('Name gespeichert'); })
.catch(function(){ sub.disabled=false; sub.textContent='Speichern'; showToast('Fehler beim Speichern'); });
};
};
c.querySelector('.dm-cpw').onclick=function(){ closeOv(); showChangePassword(); };
c.querySelector('.dm-lgout').onclick=function(){
api('POST','logout').then(function(){ goals=[]; closeOv(); render(); showLogin(); });
};
c.querySelector('.dm-inv').onclick=function(){
var ic=tpl('tpl-invite-form');
showSheet(ic,true);
var invName=ic.querySelector('.inv-name');
setTimeout(function(){invName.focus();},50);
ic.querySelector('.inv-cancel').onclick=closeOv;
ic.querySelector('.inv-gen').onclick=function(){
var note=invName.value.trim(), btn=this;
btn.disabled=true; btn.textContent='…';
api('POST','invite',{note:note}).then(function(res){
var lc=tpl('tpl-invite-link');
lc.querySelector('.stitle').textContent='Einladungslink'+(note?' für '+note:'');
var urlInp=lc.querySelector('.il-url');
urlInp.value=res.url;
showSheet(lc,true);
lc.querySelector('.il-close').onclick=closeOv;
lc.querySelector('.il-copy').onclick=function(){
navigator.clipboard.writeText(res.url).then(function(){ showToast('Link kopiert!'); closeOv(); });
};
setTimeout(function(){urlInp.select();},50);
}).catch(function(){
btn.disabled=false; btn.textContent='Link generieren';
showToast('Fehler beim Generieren');
});
};
};
c.querySelector('.dm-invlist').onclick=function(){
api('GET','invites').then(function(list){
var statusLabel={'pending':'Ausstehend','used':'Angenommen','expired':'Abgelaufen'};
var statusColor={'pending':'var(--amber)','used':'var(--green)','expired':'var(--red)'};
var lc=tpl('tpl-invite-list');
var body=lc.querySelector('.dpanel-body');
if(!list.length){
var empty=document.createElement('div');
empty.className='nosets'; empty.style.padding='16px';
empty.textContent='Noch keine Einladungen verschickt';
body.appendChild(empty);
} else {
for(var i=0;i<list.length;i++){
var inv=list[i];
var label=inv.note||new Date(inv.created_at).toLocaleDateString('de-DE',{day:'numeric',month:'short',year:'numeric'});
var detail=inv.used_by_email?('→ '+inv.used_by_email):(inv.status==='pending'?'läuft ab: '+new Date(inv.expires_at).toLocaleDateString('de-DE',{day:'numeric',month:'short'}):'');
var row=tpl('tpl-invite-row');
row.querySelector('.ir-label').textContent=label;
if(detail) row.querySelector('.ir-detail').textContent=' '+detail;
var st=row.querySelector('.ir-status');
st.textContent=statusLabel[inv.status]; st.style.color=statusColor[inv.status];
if(inv.url){
var cp=row.querySelector('.ir-copy'); cp.style.display='';
cp.onclick=function(url){ return function(){ navigator.clipboard.writeText(url).then(function(){ showToast('Link kopiert!'); }); }; }(inv.url);
}
body.appendChild(row);
}
}
showSheet(lc,true);
lc.querySelector('.il-close').onclick=closeOv;
}).catch(function(){ showToast('Fehler beim Laden'); });
};
c.querySelector('.dm-exp').onclick=function(){
var blob=new Blob([JSON.stringify({goals:goals,at:new Date().toISOString()},null,2)],{type:'application/json'});
var 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=function(){
var inp=document.createElement('input'); inp.type='file'; inp.accept='.json';
inp.onchange=function(e){
var f=e.target.files[0]; if(!f) return;
var r=new FileReader(); r.onload=function(ev){
try{
var p=JSON.parse(ev.target.result);
if(!p.goals||!Array.isArray(p.goals)) throw new Error('Ungültiges Format');
if(!confirm(p.goals.length+' Ziel(e) importieren?')) return;
var promises=p.goals.map(function(g){
return api('POST','goals',{name:g.name,unit:g.unit,daily:g.daily,days:g.days,start:g.start,sets:g.sets||{}})
.then(function(r){ 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(function(){ closeOv(); render(); alert(p.goals.length+' Ziel(e) importiert.'); });
}catch(err){ alert('Fehler: '+err.message); }
}; r.readAsText(f);
}; inp.click();
};
c.querySelector('.dm-clr').onclick=function(){
if(!confirm('Alle Daten löschen?')) return;
var ids=goals.map(function(g){return g.id;}); goals=[]; render();
Promise.all(ids.map(function(id){return api('DELETE','goals/'+id);}))
.catch(function(){ showToast('Fehler beim Löschen'); });
closeOv();
};
}
// ── Card-Bausteine ────────────────────────────────────────────────────────────
function buildNameWrap(g){
if(renamingId===g.id){
var el=tpl('tpl-name-edit');
var inp=el.querySelector('.ren-input');
inp.id='ri'+g.id; inp.value=g.name; inp.dataset.g=g.id;
return el;
}
var 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){
var t=tOff(g), sets=g.sets[String(off)]||[], tot=dTot(g,off);
var lbl=off===t?'Heute':'Gestern', k=g.id+'_'+off;
var el=tpl('tpl-panel');
el.querySelector('.dpanel-title').textContent=lbl+' — '+fd(o2d(g,off));
el.querySelector('.dpanel-sub').textContent=tot+' / '+g.daily+' '+g.unit;
var body=el.querySelector('.dpanel-body');
if(sets.length){
for(var i=0;i<sets.length;i++){
var s=sets[i], row=tpl('tpl-set-row'), span=row.querySelector('span');
if(s.time!=='—'){
var st=document.createElement('span'); st.className='stime'; st.textContent=s.time+' ·';
span.appendChild(st); span.appendChild(document.createTextNode(' '));
}
var strong=document.createElement('strong'); strong.textContent=s.amount;
span.appendChild(strong); span.appendChild(document.createTextNode(' '+g.unit));
var 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'));
}
var addRow=tpl('tpl-add-row');
var inp=addRow.querySelector('.num-in');
inp.placeholder=g.daily; inp.value=addAmt[k]||''; inp.dataset.k=k; inp.dataset.g=g.id; inp.dataset.o=off;
var 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){
var c=calc(g), t=c.tOff;
var 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)';
var bc,bt,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;}
var 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;
var bd=el.querySelector('.card-bd');
bd.insertBefore(buildNameWrap(g),bd.firstElementChild);
var hc=heuteColor(c.tdone,g.daily);
el.querySelector('.m-dr').textContent=c.dr;
el.querySelector('.m-end').textContent=fs(c.end);
var mH=el.querySelector('.m-heute'); mH.textContent=c.tdone+'/'+g.daily; mH.style.color=hc;
el.querySelector('.m-total').textContent=c.done+'/'+c.tot;
var badge=el.querySelector('.badge'); badge.className='badge '+bc; badge.textContent=bt;
var 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;
var 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);
var badge=el.querySelector('.badge'); badge.className='badge '+bc; badge.textContent=bt;
var fill=el.querySelector('.prog-fill'); fill.style.width=c.pct+'%'; fill.style.background=fc;
el.querySelector('.pr-done').textContent=c.done+' '+g.unit+' gemacht';
el.querySelector('.pr-pct').textContent=c.pct+'% von '+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(function(u){ u.textContent=g.unit; });
var sel=selDay[g.id]!=null?selDay[g.id]:t;
var dotsWrap=el.querySelector('.dots-wrap');
for(var i=0;i<g.days;i++){
var it=i===t, iy=i===t-1, is=sel===i, ed=editable(g,i);
var 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;
}
// ── Quick-Buchen ──────────────────────────────────────────────────────────────
function buildQuickBook(){
var active=goals.filter(function(g){ var c=calc(g); return tOff(g)<g.days&&!c.ok; });
if(!active.length) return null;
var frag=document.createDocumentFragment();
var lbl=document.createElement('div'); lbl.className='sec-lbl'; lbl.textContent='Quick-Buchen';
frag.appendChild(lbl);
var card=document.createElement('div'); card.className='card qb-card';
for(var i=0;i<active.length;i++){
var g=active[i], c=calc(g), k=g.id+'_'+c.tOff;
var row=tpl('tpl-qb-row');
row.querySelector('.qb-name').textContent=g.name;
var stat=row.querySelector('.qb-stat'); stat.textContent=c.tdone+'/'+g.daily; stat.style.color=heuteColor(c.tdone,g.daily);
var inp=row.querySelector('.num-in');
inp.placeholder=g.daily; inp.value=addAmt[k]||''; inp.dataset.k=k; inp.dataset.g=g.id; inp.dataset.o=c.tOff;
var btn=row.querySelector('.btn-as'); btn.dataset.g=g.id; btn.dataset.o=c.tOff;
card.appendChild(row);
}
frag.appendChild(card);
return frag;
}
// ── Render ────────────────────────────────────────────────────────────────────
function calcAwards(){
var units=0;
for(var i=0;i<goals.length;i++){
var g=goals[i];
if(tOff(g)>=g.days) units+=Math.floor(g.days/30);
}
var gold=Math.floor(units/25); units%=25;
var silver=Math.floor(units/5); var bronze=units%5;
return{gold:gold,silver:silver,bronze:bronze};
}
function render(){
var m=document.getElementById('main');
var frag=document.createDocumentFragment();
if(!prefs.hd){
var hint=tpl('tpl-hint');
hint.querySelector('.hclose').onclick=function(){ prefs.hd=1; saveP(); hint.remove(); };
frag.appendChild(hint);
}
var aw=calcAwards();
if(aw.gold||aw.silver||aw.bronze){
var awards=document.createElement('div'); awards.className='awards';
var medals=[['🥇',aw.gold],['🥈',aw.silver],['🥉',aw.bronze]];
for(var mi=0;mi<medals.length;mi++){
for(var ai=0;ai<medals[mi][1];ai++){
var sp=document.createElement('span'); sp.className='aw'; sp.textContent=medals[mi][0];
awards.appendChild(sp);
}
}
frag.appendChild(awards);
}
if(!goals.length){
frag.appendChild(tpl('tpl-empty'));
m.innerHTML=''; m.appendChild(frag); wire(); return;
}
if(userName){
var gr=document.createElement('div'); gr.className='greeting'; gr.textContent='Hallo '+userName+'!';
frag.appendChild(gr);
}
var qb=buildQuickBook(); if(qb) frag.appendChild(qb);
var open=[],done=[];
for(var gi=0;gi<goals.length;gi++){
var g=goals[gi], c=calc(g);
if(c.ok) done.push(g); else open.push(g);
}
if(open.length){
var sl=document.createElement('div'); sl.className='sec-lbl'; sl.textContent='Offen';
frag.appendChild(sl);
for(var i=0;i<open.length;i++) frag.appendChild(buildCard(open[i]));
}
if(done.length){
var sl2=document.createElement('div'); sl2.className='sec-lbl'; sl2.textContent='Heute erledigt';
frag.appendChild(sl2);
for(var j=0;j<done.length;j++) frag.appendChild(buildCard(done[j]));
}
m.innerHTML=''; m.appendChild(frag); wire();
}
function wire(){
document.querySelectorAll('.card-hdr[data-g]').forEach(function(el){
el.onclick=function(e){
if(e.target.classList.contains('btn-ren')||e.target.classList.contains('ren-input')) return;
toggleCollapse(this.dataset.g);
};
});
document.querySelectorAll('.btn-ren').forEach(function(b){
b.onclick=function(e){e.stopPropagation();startRen(this.dataset.g);};
});
document.querySelectorAll('.ren-input').forEach(function(inp){
var gid=inp.dataset.g;
inp.oninput=function(){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(function(d){
d.onclick=function(e){e.stopPropagation();selD(this.dataset.g,parseInt(this.dataset.d,10));};
});
document.querySelectorAll('.btn-as').forEach(function(b){
b.onclick=function(){addSet(this.dataset.g,parseInt(this.dataset.o,10));};
});
document.querySelectorAll('.num-in').forEach(function(inp){
var k=inp.dataset.k, g=inp.dataset.g, o=parseInt(inp.dataset.o,10);
inp.oninput=function(){addAmt[k]=this.value;};
inp.onkeydown=function(e){if(e.key==='Enter')addSet(g,o);};
});
document.querySelectorAll('.sdel').forEach(function(b){
b.onclick=function(){remSet(this.dataset.g,parseInt(this.dataset.o,10),parseInt(this.dataset.i,10));};
});
document.querySelectorAll('.btn-del').forEach(function(b){
b.onclick=function(){delGoal(this.dataset.g);};
});
}
// ── Start ─────────────────────────────────────────────────────────────────────
function updateHeader(){
document.getElementById('tlbl').textContent=TODAY.toLocaleDateString('de-DE',{weekday:'long',day:'numeric',month:'long'});
}
document.getElementById('btnNew').onclick=openNew;
document.getElementById('btnData').onclick=openData;
updateHeader();
var _qs=new URLSearchParams(window.location.search);
var inviteToken=_qs.get('invite');
var resetSelector=_qs.get('reset_selector');
var resetToken=_qs.get('reset_token');
if(inviteToken||resetSelector) history.replaceState(null,'',location.pathname);
if(resetSelector&&resetToken){
render(); showResetPassword(resetSelector,resetToken);
} else {
api('GET','me')
.then(function(r){ userName=r.name||''; updateHeader(); return loadGoals(); })
.then(function(g){ goals=g; render(); })
.catch(function(){
render();
if(inviteToken){ showRegister(inviteToken); }
else { showLogin(); }
});
}
function scheduleMidnight(){
var n=new Date();
var ms=new Date(n.getFullYear(),n.getMonth(),n.getDate()+1,0,0,5).getTime()-n.getTime();
setTimeout(function(){
TODAY=new Date();TODAY.setHours(0,0,0,0);selDay={};collapsed={};
updateHeader();render();scheduleMidnight();
},ms);
}
scheduleMidnight();
document.addEventListener('visibilitychange',function(){
if(document.visibilityState==='visible'){
var n=new Date();n.setHours(0,0,0,0);
if(n.getTime()!==TODAY.getTime()){TODAY=n;selDay={};collapsed={};render();scheduleMidnight();}
loadGoals().then(function(g){goals=g;render();}).catch(function(){});
}
});

21
bin/console Executable file
View file

@ -0,0 +1,21 @@
#!/usr/bin/env php
<?php
use App\Kernel;
use Symfony\Bundle\FrameworkBundle\Console\Application;
if (!is_dir(dirname(__DIR__).'/vendor')) {
throw new LogicException('Dependencies are missing. Try running "composer install".');
}
if (!is_file(dirname(__DIR__).'/vendor/autoload_runtime.php')) {
throw new LogicException('Symfony Runtime is missing. Try running "composer require symfony/runtime".');
}
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
return function (array $context) {
$kernel = new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
return new Application($kernel);
};

77
composer.json Normal file
View file

@ -0,0 +1,77 @@
{
"type": "project",
"license": "proprietary",
"minimum-stability": "stable",
"prefer-stable": true,
"require": {
"php": ">=8.4",
"ext-ctype": "*",
"doctrine/doctrine-bundle": "^3.2",
"doctrine/doctrine-migrations-bundle": "^4.0",
"doctrine/orm": "^3.6",
"symfony/console": "8.0.*",
"symfony/doctrine-messenger": "8.0.*",
"symfony/dotenv": "8.0.*",
"symfony/flex": "^2",
"symfony/framework-bundle": "8.0.*",
"symfony/mailer": "8.0.*",
"symfony/password-hasher": "8.0.*",
"symfony/runtime": "8.0.*",
"symfony/security-bundle": "8.0.*",
"symfony/twig-bundle": "8.0.*",
"symfony/validator": "8.0.*",
"symfony/yaml": "8.0.*"
},
"config": {
"allow-plugins": {
"php-http/discovery": true,
"symfony/flex": true,
"symfony/runtime": true
},
"optimize-autoloader": true,
"sort-packages": true
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"App\\Tests\\": "tests/"
}
},
"replace": {
"symfony/polyfill-ctype": "*",
"symfony/polyfill-iconv": "*",
"symfony/polyfill-php72": "*",
"symfony/polyfill-php73": "*",
"symfony/polyfill-php74": "*",
"symfony/polyfill-php80": "*",
"symfony/polyfill-php81": "*",
"symfony/polyfill-php82": "*",
"symfony/polyfill-php83": "*",
"symfony/polyfill-php84": "*"
},
"scripts": {
"auto-scripts": {
"cache:clear": "symfony-cmd",
"assets:install %PUBLIC_DIR%": "symfony-cmd"
},
"post-install-cmd": [
"@auto-scripts"
],
"post-update-cmd": [
"@auto-scripts"
]
},
"conflict": {
"symfony/symfony": "*"
},
"extra": {
"symfony": {
"allow-contrib": false,
"require": "8.0.*"
}
}
}

5486
composer.lock generated Normal file

File diff suppressed because it is too large Load diff

9
config/bundles.php Normal file
View file

@ -0,0 +1,9 @@
<?php
return [
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true],
Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true],
Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true],
Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
];

View file

@ -0,0 +1,19 @@
framework:
cache:
# Unique name of your app: used to compute stable namespaces for cache keys.
#prefix_seed: your_vendor_name/app_name
# The "app" cache stores to the filesystem by default.
# The data in this cache should persist between deploys.
# Other options include:
# Redis
#app: cache.adapter.redis
#default_redis_provider: redis://localhost
# APCu (not recommended with heavy random-write workloads as memory fragmentation can cause perf issues)
#app: cache.adapter.apcu
# Namespaced pools use the above "app" backend by default
#pools:
#my.dedicated.cache: null

View file

@ -0,0 +1,44 @@
doctrine:
dbal:
url: '%env(resolve:DATABASE_URL)%'
# IMPORTANT: You MUST configure your server version,
# either here or in the DATABASE_URL env var (see .env file)
#server_version: '16'
profiling_collect_backtrace: '%kernel.debug%'
orm:
validate_xml_mapping: true
naming_strategy: doctrine.orm.naming_strategy.underscore
auto_mapping: true
mappings:
App:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Entity'
prefix: 'App\Entity'
alias: App
when@test:
doctrine:
dbal:
# "TEST_TOKEN" is typically set by ParaTest
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
when@prod:
doctrine:
orm:
query_cache_driver:
type: pool
pool: doctrine.system_cache_pool
result_cache_driver:
type: pool
pool: doctrine.result_cache_pool
framework:
cache:
pools:
doctrine.result_cache_pool:
adapter: cache.app
doctrine.system_cache_pool:
adapter: cache.system

View file

@ -0,0 +1,6 @@
doctrine_migrations:
migrations_paths:
# namespace is arbitrary but should be different from App\Migrations
# as migrations classes should NOT be autoloaded
'DoctrineMigrations': '%kernel.project_dir%/migrations'
enable_profiler: false

View file

@ -0,0 +1,15 @@
# see https://symfony.com/doc/current/reference/configuration/framework.html
framework:
secret: '%env(APP_SECRET)%'
# Note that the session will be started ONLY if you read or write from it.
session: true
#esi: true
#fragments: true
when@test:
framework:
test: true
session:
storage_factory_id: session.storage.factory.mock_file

View file

@ -0,0 +1,3 @@
framework:
mailer:
dsn: '%env(MAILER_DSN)%'

View file

@ -0,0 +1,22 @@
framework:
messenger:
# Uncomment this (and the failed transport below) to send failed messages to this transport for later handling.
# failure_transport: failed
transports:
# https://symfony.com/doc/current/messenger.html#transport-configuration
# async: '%env(MESSENGER_TRANSPORT_DSN)%'
# failed: 'doctrine://default?queue_name=failed'
sync: 'sync://'
routing:
# Route your messages to the transports
# 'App\Message\YourMessage': async
# when@test:
# framework:
# messenger:
# transports:
# # replace with your transport name here (e.g., my_transport: 'in-memory://')
# # For more Messenger testing tools, see https://github.com/zenstruck/messenger-test
# async: 'in-memory://'

View file

@ -0,0 +1,3 @@
framework:
property_info:
with_constructor_extractor: true

View file

@ -0,0 +1,10 @@
framework:
router:
# Configure how to generate URLs in non-HTTP contexts, such as CLI commands.
# See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
default_uri: '%env(DEFAULT_URI)%'
when@prod:
framework:
router:
strict_requirements: null

View file

@ -0,0 +1,45 @@
security:
password_hashers:
App\Entity\User:
algorithm: bcrypt
providers:
app_user_provider:
entity:
class: App\Entity\User
property: email
firewalls:
dev:
pattern: ^/(_profiler|_wdt|assets|build)/
security: false
main:
lazy: true
provider: app_user_provider
custom_authenticators:
- App\Security\JsonLoginAuthenticator
logout:
path: app_auth_logout
invalidate_session: true
remember_me:
secret: '%kernel.secret%'
lifetime: 86400
token_provider:
doctrine: true
access_control:
- { path: ^/api/login, roles: PUBLIC_ACCESS }
- { path: ^/api/logout, roles: PUBLIC_ACCESS }
- { path: ^/api/register, roles: PUBLIC_ACCESS }
- { path: ^/api/reset-request, roles: PUBLIC_ACCESS }
- { path: ^/api/reset-password, roles: PUBLIC_ACCESS }
- { path: ^/api/me, roles: PUBLIC_ACCESS }
- { path: ^/api, roles: ROLE_USER }
- { path: ^/, roles: PUBLIC_ACCESS }
when@test:
security:
password_hashers:
App\Entity\User:
algorithm: auto
cost: 4

View file

@ -0,0 +1,6 @@
twig:
file_name_pattern: '*.twig'
when@test:
twig:
strict_variables: true

View file

@ -0,0 +1,11 @@
framework:
validation:
# Enables validator auto-mapping support.
# For instance, basic validation constraints will be inferred from Doctrine's metadata.
#auto_mapping:
# App\Entity\: []
when@test:
framework:
validation:
not_compromised_password: false

5
config/preload.php Normal file
View file

@ -0,0 +1,5 @@
<?php
if (file_exists(dirname(__DIR__).'/var/cache/prod/App_KernelProdContainer.preload.php')) {
require dirname(__DIR__).'/var/cache/prod/App_KernelProdContainer.preload.php';
}

1372
config/reference.php Normal file

File diff suppressed because it is too large Load diff

11
config/routes.yaml Normal file
View file

@ -0,0 +1,11 @@
# yaml-language-server: $schema=../vendor/symfony/routing/Loader/schema/routing.schema.json
# This file is the entry point to configure the routes of your app.
# Methods with the #[Route] attribute are automatically imported.
# See also https://symfony.com/doc/current/routing.html
# To list all registered routes, run the following command:
# bin/console debug:router
controllers:
resource: routing.controllers

View file

@ -0,0 +1,4 @@
when@dev:
_errors:
resource: '@FrameworkBundle/Resources/config/routing/errors.php'
prefix: /_error

View file

@ -0,0 +1,3 @@
_security_logout:
resource: security.route_loader.logout
type: service

23
config/services.yaml Normal file
View file

@ -0,0 +1,23 @@
# yaml-language-server: $schema=../vendor/symfony/dependency-injection/Loader/schema/services.schema.json
# This file is the entry point to configure your own services.
# Files in the packages/ subdirectory configure your dependencies.
# See also https://symfony.com/doc/current/service_container/import.html
# Put parameters here that don't need to change on each machine where the app is deployed
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
parameters:
services:
# default configuration for services in *this* file
_defaults:
autowire: true # Automatically injects dependencies in your services.
autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
# makes classes in src/ available to be used as services
# this creates a service per class whose id is the fully-qualified class name
App\:
resource: '../src/'
# add more service definitions when explicit configuration is needed
# please note that last definitions always *replace* previous ones

29
deploy.sh Executable file
View file

@ -0,0 +1,29 @@
#!/bin/bash
set -e
REMOTE="root@miniweb.kuehn.home"
REMOTE_DIR="/var/www/tracker"
SSH="ssh -p 30183"
rsync -av \
-e "$SSH" \
--exclude='.claude/' \
--exclude='.superpowers/' \
--exclude='.aider*' \
--exclude='vendor/' \
--exclude='var/' \
--exclude='docs/' \
--exclude='.env.local' \
--exclude='deploy.sh' \
--exclude='include/' \
--exclude='api.php' \
--exclude='index.php' \
/srv/http/zieltracker/ $REMOTE:$REMOTE_DIR/
# composer install auf dem Server
$SSH $REMOTE "cd $REMOTE_DIR && composer install --no-dev --no-interaction --optimize-autoloader 2>&1 | tail -5"
# Cache leeren
$SSH $REMOTE "cd $REMOTE_DIR && php bin/console cache:clear --env=prod --no-warmup 2>&1"
echo "Deploy fertig."

102
docs/api.md Normal file
View file

@ -0,0 +1,102 @@
# API Reference
All endpoints are under `/api/`. JSON in, JSON out. Auth via session cookie + remember-me.
## Auth
### `POST /api/login`
```json
{ "email": "...", "password": "..." }
```
Returns `{ ok, email, name }` or `401`.
### `POST /api/logout`
Invalidates session. Returns `{ ok: true }`.
### `GET /api/me`
Returns `{ ok, email, id, name }` or `{ ok: false }` (401) if not logged in.
### `PATCH /api/me`
```json
{ "name": "Max" }
```
Updates display name. Returns `{ ok, name }`.
### `POST /api/register`
```json
{ "email": "...", "password": "...", "token": "<invite-token>", "name": "..." }
```
Requires a valid pending invite token. Registers + auto-logs in. Returns `{ ok, email, name }`.
### `POST /api/reset-request`
```json
{ "email": "..." }
```
Sends password reset mail. Always returns `{ ok: true }` (no email enumeration).
### `POST /api/reset-password`
```json
{ "selector": "...", "token": "...", "password": "..." }
```
### `POST /api/change-password`
```json
{ "old_password": "...", "new_password": "..." }
```
---
## Goals
### `GET /api/goals`
Returns array of goal objects for the authenticated user.
```json
[{
"id": "1",
"name": "Liegestütz",
"unit": "Stück",
"daily": 50,
"days": 30,
"start": "2026-04-01 00:00:00",
"sets": { "2026-04-01": [20, 30], "2026-04-02": [50] }
}]
```
`sets` is a JSON object keyed by date (`YYYY-MM-DD`), values are arrays of logged amounts.
### `POST /api/goals`
```json
{ "name": "...", "unit": "Stück", "daily": 50, "days": 30, "start": "2026-04-01" }
```
### `PATCH /api/goals/{id}`
Partial update — send only fields to change: `name`, `unit`, `daily`, `days`, `sets`.
### `DELETE /api/goals/{id}`
Deletes goal. Only owner can delete.
---
## Invites
### `POST /api/invite`
```json
{ "note": "Max" }
```
Creates a 7-day single-use invite token. Returns `{ url: "http://dudi.local/?invite=<token>" }`.
### `GET /api/invites`
Returns all invites created by the authenticated user.
```json
[{
"url": "http://dudi.local/?invite=...", // null if used or expired
"note": "Max",
"status": "pending", // "pending" | "used" | "expired"
"created_at": "...",
"expires_at": "...",
"used_at": null,
"used_by_email": null
}]
```

69
docs/deployment.md Normal file
View file

@ -0,0 +1,69 @@
# Deployment
## Server: miniweb.kuehn.home
- SSH: `ssh -p 30183 root@miniweb.kuehn.home`
- App dir: `/var/www/tracker/`
- Apache DocumentRoot: `/var/www/tracker/public/`
- Database: MariaDB, DB name `dd`, user `dd`
- URL: `http://miniweb.kuehn.home/dd/`
## First-time Setup (already done)
```bash
cd /var/www/tracker
composer install --no-dev --optimize-autoloader
# create .env.local with real credentials (see below)
php bin/console cache:clear --env=prod
```
### `.env.local` on server
```
APP_ENV=prod
APP_SECRET=<random 32 hex chars>
DATABASE_URL="mysql://dd:<password>@127.0.0.1:3306/dd?serverVersion=mariadb-8.3.0&charset=utf8mb4"
APP_URL=http://miniweb.kuehn.home/dd
DEFAULT_URI=http://miniweb.kuehn.home/dd
MAILER_DSN=smtp://user:pass@smtp.example.com:587
MAILER_FROM=noreply@example.com
```
### Apache VirtualHost (or adjust existing)
```apache
<VirtualHost *:80>
ServerName miniweb.kuehn.home
DocumentRoot /var/www/tracker/public
<Directory /var/www/tracker/public>
AllowOverride All
Require all granted
</Directory>
</VirtualHost>
```
### Database remember-me table
```sql
CREATE TABLE IF NOT EXISTS rememberme_token (
series VARCHAR(88) NOT NULL,
value VARCHAR(88) NOT NULL,
lastUsed DATETIME NOT NULL,
class VARCHAR(100) DEFAULT '' NOT NULL,
username VARCHAR(200) NOT NULL,
PRIMARY KEY (series)
) DEFAULT CHARACTER SET utf8mb4;
```
## Deploying Updates
```bash
bash deploy.sh
```
This rsync's the project (excluding `vendor/`, `var/`, `.env.local`), runs `composer install --no-dev` and `cache:clear --env=prod` remotely.
## Local Development
- URL: `http://dudi.local/`
- Apache VirtualHost with DocumentRoot `/srv/http/zieltracker/public`
- `/etc/hosts`: `127.0.0.1 dudi.local`
- DB: MariaDB, db `zieltracker`, user `zieltracker`
- `APP_ENV=dev` (Symfony debug bar active)

66
docs/structure.md Normal file
View file

@ -0,0 +1,66 @@
# Project Structure
```
dudi/
├── bin/
│ └── console # Symfony CLI
├── config/
│ ├── packages/
│ │ ├── doctrine.yaml # ORM + DBAL config
│ │ ├── security.yaml # Firewall, providers, access control
│ │ ├── mailer.yaml
│ │ └── ...
│ └── routes.yaml # Auto-discovers #[Route] attributes
├── migrations/ # Legacy SQL migrations (pre-Symfony)
├── public/ # Apache DocumentRoot
│ ├── index.php # Symfony kernel entry point
│ ├── .htaccess # RewriteRule → index.php
│ ├── app.js # Frontend SPA (vanilla JS)
│ ├── style.css
│ └── logo.png
├── src/
│ ├── Controller/
│ │ ├── AppController.php # Catch-all → renders app.html.twig
│ │ ├── AuthController.php # /api/me, /api/register, /api/reset-*, /api/change-password
│ │ ├── GoalController.php # /api/goals CRUD
│ │ └── InviteController.php # /api/invite, /api/invites
│ ├── Entity/
│ │ ├── User.php # Maps to users table (delight-im/auth compatible schema)
│ │ ├── Goal.php
│ │ └── Invite.php
│ ├── Repository/
│ │ ├── UserRepository.php # implements PasswordUpgraderInterface
│ │ ├── GoalRepository.php
│ │ └── InviteRepository.php
│ ├── Security/
│ │ └── JsonLoginAuthenticator.php # Handles POST /api/login with JSON body
│ └── Kernel.php
├── templates/
│ └── app.html.twig # Full SPA shell + all <template> elements
├── .env # Generic defaults (committed)
├── .env.local # Local credentials (gitignored)
├── deploy.sh # rsync + remote composer install + cache:clear
└── symfony.lock
```
## Frontend Architecture
The frontend is a single-page app with no build step:
- `public/app.js` — all logic, ~700 lines vanilla JS
- `templates/app.html.twig` — rendered once server-side; contains all `<template id="tpl-*">` elements
- JS clones templates with `tpl(id)` helper and fills in dynamic values via `querySelector`
- No framework, no bundler, no npm
## Database Tables
| Table | Managed by | Purpose |
|---|---|---|
| `goals` | Doctrine ORM | User goals with JSON `sets` column |
| `invites` | Doctrine ORM | Invite tokens |
| `users` | Doctrine ORM | User accounts (bcrypt passwords) |
| `rememberme_token` | Symfony Security | 24h remember-me tokens |
| `users_resets` | Raw SQL in AuthController | Password reset tokens |
| `users_throttling` | Legacy (delight-im/auth) | Can be removed |
| `users_confirmations` | Legacy (delight-im/auth) | Can be removed |
| `schema_migrations` | Legacy | Can be removed |

View file

@ -0,0 +1,562 @@
# Zieltracker Refactor & Collapsible Cards 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 index.html into three files, add collapsible cards with compact meta-line, sort by daily completion status, replace 4-block stats with a "Heute" group, and constrain desktop width to 480px.
**Architecture:** Pure HTML/CSS/JS — no build step. Logic stays in `app.js`, styles in `style.css`, HTML skeleton in `index.html`. All changes are in-place; localStorage data format is unchanged.
**Tech Stack:** Vanilla JS (ES5), CSS custom properties, localStorage
---
## File Map
| File | Action | Responsibility |
|------|--------|---------------|
| `index.html` | Modify | HTML skeleton only — links to style.css + app.js |
| `style.css` | Create | All styles including new classes |
| `app.js` | Create | All logic including new collapse/sort/render |
---
### Task 1: Create style.css
**Files:**
- Create: `style.css`
- [ ] **Step 1: Create style.css** — extract the full `<style>` block from index.html (lines 12103) and add new classes at the bottom:
```css
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600&family=DM+Mono:wght@400;500&display=swap');
:root{--bg:#f5f4f0;--bg2:#fff;--bg3:#f0eeea;--border:rgba(0,0,0,.07);--border2:rgba(0,0,0,.12);--text:#1a1a1a;--text2:#666;--text3:#aaa;--green:#16a34a;--green-bg:rgba(22,163,74,.08);--blue:#2563eb;--blue-bg:rgba(37,99,235,.08);--amber:#d97706;--amber-bg:rgba(217,119,6,.08);--red:#dc2626;--red-bg:rgba(220,38,38,.08);--r:14px;--rs:8px}
*{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent}
body{font-family:'DM Sans',sans-serif;background:var(--bg);color:var(--text);min-height:100dvh;padding-bottom:80px}
.main-wrap{max-width:480px;margin:0 auto}
.hdr{position:sticky;top:0;z-index:100;background:rgba(245,244,240,.92);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border-bottom:1px solid var(--border);padding:16px 20px 14px;display:flex;align-items:center;justify-content:space-between}
.hdr-title{font-size:18px;font-weight:600;letter-spacing:-.3px}
.hdr-sub{font-size:12px;color:var(--text3);margin-top:1px;font-family:'DM Mono',monospace}
.hdr-btns{display:flex;gap:8px;align-items:center}
.btn-add{width:38px;height:38px;border-radius:50%;background:var(--text);color:var(--bg);border:none;cursor:pointer;font-size:22px;display:flex;align-items:center;justify-content:center;font-weight:300;transition:transform .15s}
.btn-add:active{transform:scale(.92)}
.btn-menu{width:38px;height:38px;border-radius:50%;background:var(--bg3);color:var(--text2);border:1px solid var(--border);cursor:pointer;font-size:20px;display:flex;align-items:center;justify-content:center;transition:transform .15s}
.btn-menu:active{transform:scale(.92)}
.main{padding:16px 16px 0}
.sec-lbl{font-size:10px;color:var(--text3);text-transform:uppercase;letter-spacing:.6px;font-family:'DM Mono',monospace;margin-bottom:6px;margin-top:4px;padding:0 2px}
.empty{text-align:center;padding:60px 20px;color:var(--text3);font-size:14px;line-height:1.8}
.card{background:var(--bg2);border:1px solid var(--border);border-radius:var(--r);margin-bottom:8px;overflow:hidden}
.card.done{opacity:.7}
.card-hdr{padding:14px 16px 12px;display:flex;align-items:center;justify-content:space-between;gap:10px;cursor:pointer;user-select:none}
.name-wrap{display:flex;align-items:center;gap:6px}
.goal-name{font-size:16px;font-weight:600;letter-spacing:-.2px}
.btn-ren{background:none;border:none;color:var(--text3);cursor:pointer;font-size:14px;padding:2px 4px;line-height:1;flex-shrink:0}
.btn-ren:active{color:var(--blue)}
.ren-input{font-family:'DM Sans',sans-serif;font-size:16px;font-weight:600;background:var(--bg3);border:1px solid var(--blue);border-radius:6px;color:var(--text);padding:2px 8px;width:100%;letter-spacing:-.2px}
.ren-input:focus{outline:none}
.goal-meta{font-size:11px;color:var(--text3);margin-top:3px;font-family:'DM Mono',monospace;line-height:1.5}
.chevron{font-size:13px;color:var(--text3);margin-left:4px;flex-shrink:0}
.badge{font-size:10px;font-weight:600;padding:3px 8px;border-radius:99px;white-space:nowrap;flex-shrink:0;letter-spacing:.3px;text-transform:uppercase}
.b-ok{background:var(--green-bg);color:var(--green)}.b-done{background:var(--green-bg);color:var(--green)}.b-warn{background:var(--amber-bg);color:var(--amber)}.b-danger{background:var(--red-bg);color:var(--red)}.b-buf{background:var(--blue-bg);color:var(--blue)}
.prog-wrap{padding:0 16px 12px}
.prog-track{height:4px;background:var(--bg3);border-radius:99px;overflow:hidden}
.prog-fill{height:100%;border-radius:99px;transition:width .4s ease}
.prog-row{display:flex;justify-content:space-between;font-size:11px;color:var(--text3);margin-top:5px;font-family:'DM Mono',monospace}
.heute-stats{padding:0 16px 14px}
.heute-group{background:var(--bg3);border-radius:var(--rs);padding:8px 8px 6px}
.heute-lbl{font-size:9px;color:var(--text3);text-transform:uppercase;letter-spacing:.5px;text-align:center;margin-bottom:6px;font-family:'DM Mono',monospace}
.heute-inner{display:grid;grid-template-columns:1fr 1fr 1fr;gap:5px}
.stat{background:var(--bg2);border-radius:6px;padding:7px 6px 5px;text-align:center}
.slbl{font-size:9px;color:var(--text3);margin-bottom:2px}
.sval{font-size:14px;font-weight:600;font-family:'DM Mono',monospace}
.sunit{font-size:8px;color:var(--text3)}
.dots-sec{padding:0 16px 12px}
.dots-lbl{font-size:11px;color:var(--text3);margin-bottom:6px}
.dots-wrap{display:flex;gap:3px;flex-wrap:wrap}
.dot{width:26px;height:26px;border-radius:5px;display:flex;align-items:center;justify-content:center;font-size:8px;font-weight:600;font-family:'DM Mono',monospace;transition:opacity .15s}
.df{background:var(--bg3);color:var(--text3)}
.dm{background:rgba(220,38,38,.12);color:var(--red)}
.dp{background:rgba(217,119,6,.12);color:var(--amber)}
.dd{background:rgba(22,163,74,.12);color:var(--green)}
.db{background:rgba(37,99,235,.12);color:var(--blue)}
.de{cursor:pointer}.de:active{opacity:.6;transform:scale(.9)}.dl{opacity:.6;cursor:default}
.rt{box-shadow:0 0 0 2px var(--text2)}.ry{box-shadow:0 0 0 2px var(--text3)}.rs{box-shadow:0 0 0 2px var(--blue) !important}
.legend{display:flex;gap:10px;flex-wrap:wrap;margin-top:8px}
.leg{display:flex;align-items:center;gap:4px;font-size:10px;color:var(--text3)}
.ldot{width:8px;height:8px;border-radius:2px}
.dpanel{margin:0 16px 14px;background:var(--bg3);border-radius:var(--rs);overflow:hidden;border:1px solid var(--border2)}
.dpanel-hdr{padding:10px 12px;display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid var(--border)}
.dpanel-title{font-size:13px;font-weight:600}
.dpanel-sub{font-size:11px;color:var(--text2);font-family:'DM Mono',monospace}
.dpanel-body{padding:10px 12px}
.set-row{display:flex;align-items:center;justify-content:space-between;padding:5px 0;border-bottom:1px solid var(--border);font-size:13px}
.set-row:last-of-type{border-bottom:none}
.stime{font-size:10px;color:var(--text3);font-family:'DM Mono',monospace;margin-right:4px}
.sdel{background:none;border:none;color:var(--text3);cursor:pointer;font-size:16px;padding:2px 6px;border-radius:4px}
.sdel:active{color:var(--red)}
.add-row{display:flex;gap:8px;align-items:center;margin-top:10px}
.num-in{font-family:'DM Mono',monospace;font-size:15px;padding:8px 10px;border-radius:var(--rs);border:1px solid var(--border2);background:var(--bg2);color:var(--text);width:90px;text-align:center}
.num-in:focus{outline:none;border-color:var(--blue)}
.ulbl{font-size:12px;color:var(--text3)}
.btn-as{flex:1;padding:8px;border-radius:var(--rs);background:var(--blue-bg);color:var(--blue);border:1px solid rgba(37,99,235,.2);cursor:pointer;font-family:'DM Sans',sans-serif;font-size:13px;font-weight:600}
.btn-as:active{opacity:.7}
.nosets{font-size:12px;color:var(--text3);padding:4px 0 8px}
.card-foot{padding:8px 16px 12px;display:flex;justify-content:flex-end}
.btn-del{background:none;border:none;color:var(--text3);font-size:12px;cursor:pointer;padding:4px 0;font-family:'DM Sans',sans-serif}
.btn-del:active{color:var(--red)}
.ov{position:fixed;inset:0;z-index:200;background:rgba(0,0,0,.5);display:flex;align-items:flex-end;animation:fi .2s ease}
@keyframes fi{from{opacity:0}to{opacity:1}}
.sheet{width:100%;background:var(--bg2);border-radius:20px 20px 0 0;border:1px solid var(--border2);padding:20px 20px 40px;animation:su .25s ease;max-height:90dvh;overflow-y:auto}
@keyframes su{from{transform:translateY(100%)}to{transform:translateY(0)}}
.shandle{width:36px;height:4px;border-radius:2px;background:var(--border2);margin:0 auto 20px}
.stitle{font-size:17px;font-weight:600;margin-bottom:4px}
.ssub{font-size:12px;color:var(--text3);margin-bottom:16px}
.fgrid{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:10px}
.ff{margin-bottom:10px}
.ff label{font-size:11px;color:var(--text3);display:block;margin-bottom:4px;text-transform:uppercase;letter-spacing:.5px}
.fi{width:100%;font-family:'DM Sans',sans-serif;font-size:15px;padding:10px 12px;border-radius:var(--rs);border:1px solid var(--border2);background:var(--bg3);color:var(--text)}
.fi:focus{outline:none;border-color:var(--blue)}
.factions{display:flex;gap:8px;margin-top:16px}
.btn-p{flex:1;padding:13px;border-radius:var(--rs);background:var(--text);color:var(--bg);border:none;cursor:pointer;font-family:'DM Sans',sans-serif;font-size:15px;font-weight:600}
.btn-p:active{opacity:.8}
.btn-c{padding:13px 20px;border-radius:var(--rs);background:var(--bg3);color:var(--text2);border:1px solid var(--border);cursor:pointer;font-family:'DM Sans',sans-serif;font-size:15px}
.dbtn{width:100%;padding:13px 16px;border-radius:var(--rs);background:var(--bg3);border:1px solid var(--border);color:var(--text);font-family:'DM Sans',sans-serif;font-size:15px;text-align:left;cursor:pointer;margin-bottom:8px;display:flex;align-items:center;gap:12px}
.dbtn:active{background:var(--border2)}
.dico{font-size:18px;width:24px;text-align:center;flex-shrink:0}
.dlbl{flex:1}.dsub{font-size:11px;color:var(--text3);display:block;margin-top:1px}
.ddanger{color:var(--red)}
.ddiv{height:1px;background:var(--border);margin:12px 0}
.hint{margin:0 16px 12px;background:var(--blue-bg);border:1px solid rgba(37,99,235,.15);border-radius:var(--rs);padding:10px 12px;font-size:12px;color:var(--blue);line-height:1.5;display:flex;justify-content:space-between;align-items:center;gap:8px}
.hclose{background:none;border:none;color:var(--blue);cursor:pointer;font-size:16px;padding:0 2px}
```
- [ ] **Step 2: Verify file exists**
```bash
ls -la /srv/http/zieltracker/style.css
```
Expected: file exists, ~4KB
---
### Task 2: Create app.js
**Files:**
- Create: `app.js`
- [ ] **Step 1: Create app.js** with the full logic — extracted from index.html plus all new functionality:
```js
var TODAY = new Date(); TODAY.setHours(0,0,0,0);
var goals, prefs, selDay={}, addAmt={}, renamingId=null, renameVal='', collapsed={};
function load(k,def){ try{ return JSON.parse(localStorage.getItem(k)||def); }catch(e){ return JSON.parse(def); } }
function save(){ localStorage.setItem('zt_g',JSON.stringify(goals)); }
function saveP(){ localStorage.setItem('zt_p',JSON.stringify(prefs)); }
goals = load('zt_g','[]');
prefs = load('zt_p','{}');
function tOff(g){ return Math.round((TODAY - new Date(g.start))/86400000); }
function o2d(g,i){ var d=new Date(new Date(g.start).getTime()+i*86400000); d.setHours(0,0,0,0); return d; }
function dTot(g,o){ return (g.sets[String(o)]||[]).reduce(function(a,b){return a+b.amount;},0); }
function fd(d){ return d.toLocaleDateString('de-DE',{weekday:'short',day:'numeric',month:'short'}); }
function fs(d){ return d.toLocaleDateString('de-DE',{day:'numeric',month:'short'}); }
function editable(g,o){ var t=tOff(g); return o===t||o===t-1; }
function now(){ var n=new Date(); return String(n.getHours()).padStart(2,'0')+':'+String(n.getMinutes()).padStart(2,'0'); }
// Returns CSS color for heute: X/Y display
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)';
}
// collapsed defaults to true — only false when explicitly opened
function isCollapsed(id){ return collapsed[id]!==false; }
function toggleCollapse(id){ collapsed[id]=!isCollapsed(id); render(); }
function calc(g){
var t=tOff(g), tot=g.daily*g.days;
var dr=Math.max(0,g.days-t-1);
var sd=new Date(g.start); sd.setHours(0,0,0,0);
var end=new Date(sd.getTime()+g.days*86400000);
var past=0;
for(var i=0;i<Math.min(t,g.days);i++) past+=dTot(g,i);
var tdone=dTot(g,t), tot2=past+tdone;
var dl=dr+1;
var remaining=Math.max(0,tot-past);
var pd=Math.ceil(remaining/Math.max(1,dl));
var st=Math.max(0,pd-tdone);
var expectedPast=Math.min(t,g.days)*g.daily;
var buf=(past-expectedPast)+Math.max(0,tdone-g.daily);
var deficit=Math.min(0,buf);
var surplus=Math.max(0,buf);
var dailyDelta=pd-g.daily;
var pct=Math.min(100,Math.round((tot2/tot)*100));
return{tot:tot,tOff:t,end:end,dr:dr,done:tot2,tdone:tdone,pd:pd,st:st,buf:buf,deficit:deficit,surplus:surplus,dailyDelta:dailyDelta,net:tdone-pd,pct:pct,ok:tdone>=pd};
}
function dcls(g,i){
var t=tOff(g); if(i>t) return 'dot df';
var v=dTot(g,i);
var 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');
}
function dlbl(g,i){
var t=tOff(g); if(i>t) return String(i+1);
var 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)+'%';
}
function addSet(gid,off){
var g=goals.filter(function(x){return x.id===gid;})[0];
if(!g||!editable(g,off)) return;
var k=gid+'_'+off, amt=parseInt(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():'—'});
addAmt[k]=''; save(); render();
}
function remSet(gid,off,idx){
var g=goals.filter(function(x){return x.id===gid;})[0];
if(!g||!editable(g,off)) return;
g.sets[String(off)].splice(idx,1); save(); render();
}
function delGoal(id){
if(!confirm('Ziel wirklich löschen?')) return;
goals=goals.filter(function(g){return g.id!==id;}); save(); render();
}
function selD(gid,off){
var g=goals.filter(function(x){return x.id===gid;})[0];
if(!g||!editable(g,off)) return;
selDay[gid]=selDay[gid]===off?null:off; render();
}
function startRen(id){
var g=goals.filter(function(x){return x.id===id;})[0]; if(!g) return;
renamingId=id; renameVal=g.name; render();
setTimeout(function(){ var el=document.getElementById('ri'+id); if(el){el.focus();el.select();} },50);
}
function commitRen(id){
var g=goals.filter(function(x){return x.id===id;})[0];
if(g&&renameVal.trim()) g.name=renameVal.trim();
renamingId=null; save(); render();
}
function cancelRen(){ renamingId=null; render(); }
function closeOv(){ var o=document.getElementById('ov'); o.style.display='none'; o.innerHTML=''; }
function showSheet(html){
var o=document.getElementById('ov');
o.style.cssText='display:flex;position:fixed;inset:0;z-index:200;background:rgba(0,0,0,.5);align-items:flex-end;animation:fi .2s ease';
o.innerHTML='<div class="sheet">'+html+'</div>';
o.onclick=function(e){if(e.target===o)closeOv();};
}
function openNew(){
showSheet(
'<div class="shandle"></div>'+
'<div class="stitle">Neues Ziel</div>'+
'<div class="ff"><label>Übung / Gewohnheit</label><input class="fi" id="fn" type="text" placeholder="Liegestütz, Plank …"/></div>'+
'<div class="fgrid">'+
'<div class="ff"><label>Einheit</label><input class="fi" id="fu" type="text" value="Stück"/></div>'+
'<div class="ff"><label>Tagesziel</label><input class="fi" id="fd" type="number" min="1" value="50"/></div>'+
'</div>'+
'<div class="ff"><label>Dauer in Tagen</label><input class="fi" id="fdy" type="number" min="7" max="365" value="30"/></div>'+
'<div class="factions"><button class="btn-p" id="fsub">Ziel starten</button><button class="btn-c" id="fcan">Abbrechen</button></div>'
);
document.getElementById('fn').focus();
document.getElementById('fsub').onclick=function(){
var name=(document.getElementById('fn').value||'').trim();
var unit=(document.getElementById('fu').value||'').trim()||'Stück';
var daily=parseInt(document.getElementById('fd').value,10)||1;
var days=parseInt(document.getElementById('fdy').value,10)||30;
if(!name){document.getElementById('fn').focus();return;}
goals.push({id:Date.now(),name:name,unit:unit,daily:daily,days:days,start:TODAY.toISOString(),sets:{}});
save(); closeOv(); render();
};
document.getElementById('fcan').onclick=closeOv;
}
function openData(){
showSheet(
'<div class="shandle"></div>'+
'<div class="stitle">Daten verwalten</div>'+
'<div class="ssub">Export, Import und Backup</div>'+
'<button class="dbtn" id="dexp"><span class="dico"></span><span class="dlbl">Exportieren<span class="dsub">Alle Ziele als JSON-Datei speichern</span></span></button>'+
'<button class="dbtn" id="dimp"><span class="dico"></span><span class="dlbl">Importieren<span class="dsub">Backup laden oder zusammenführen</span></span></button>'+
'<div class="ddiv"></div>'+
'<button class="dbtn ddanger" id="dclr"><span class="dico"></span><span class="dlbl">Alle Daten löschen<span class="dsub">Kann nicht rückgängig gemacht werden</span></span></button>'+
'<button class="btn-c" id="dcls" style="width:100%;margin-top:4px;text-align:center">Schließen</button>'
);
document.getElementById('dcls').onclick=closeOv;
document.getElementById('dexp').onclick=function(){
var blob=new Blob([JSON.stringify({goals:goals,at:new Date().toISOString()},null,2)],{type:'application/json'});
var url=URL.createObjectURL(blob), a=document.createElement('a');
a.href=url; a.download='zieltracker-backup.json'; a.click(); URL.revokeObjectURL(url); closeOv();
};
document.getElementById('dimp').onclick=function(){
var inp=document.createElement('input'); inp.type='file'; inp.accept='.json';
inp.onchange=function(e){
var f=e.target.files[0]; if(!f) return;
var r=new FileReader(); r.onload=function(ev){
try{
var p=JSON.parse(ev.target.result);
if(!p.goals||!Array.isArray(p.goals)) throw new Error('Ungültiges Format');
if(!confirm(p.goals.length+' Ziel(e) importieren? Bestehende Daten bleiben erhalten.')) return;
var ex={}; goals.forEach(function(g){ex[g.id]=1;});
var add=0; p.goals.forEach(function(g){if(!ex[g.id]){goals.push(g);add++;}});
save(); closeOv(); render(); alert(add+' Ziel(e) importiert.');
}catch(err){alert('Fehler: '+err.message);}
}; r.readAsText(f);
}; inp.click();
};
document.getElementById('dclr').onclick=function(){
if(!confirm('Alle Daten löschen?')) return;
goals=[]; save(); closeOv(); render();
};
}
function panel(g,off){
var t=tOff(g), sets=g.sets[String(off)]||[], tot=dTot(g,off);
var lbl=off===t?'Heute':'Gestern', k=g.id+'_'+off;
var rows='';
for(var i=0;i<sets.length;i++){
var s=sets[i];
rows+='<div class="set-row"><span>'+(s.time!=='—'?'<span class="stime">'+s.time+' ·</span>':'')+' <strong>'+s.amount+'</strong> '+g.unit+'</span>'+
'<button class="sdel" data-g="'+g.id+'" data-o="'+off+'" data-i="'+i+'">×</button></div>';
}
return '<div class="dpanel">'+
'<div class="dpanel-hdr"><span class="dpanel-title">'+lbl+' — '+fd(o2d(g,off))+'</span><span class="dpanel-sub">'+tot+' / '+g.daily+' '+g.unit+'</span></div>'+
'<div class="dpanel-body">'+
(sets.length?rows:'<div class="nosets">Noch kein Eintrag</div>')+
'<div class="add-row">'+
'<input class="num-in" type="number" min="1" placeholder="'+g.daily+'" value="'+(addAmt[k]||'')+'" data-k="'+k+'" data-g="'+g.id+'" data-o="'+off+'"/>'+
'<span class="ulbl">'+g.unit+'</span>'+
'<button class="btn-as" data-g="'+g.id+'" data-o="'+off+'">+ Satz</button>'+
'</div>'+
'</div></div>';
}
function cardHtml(g){
var c=calc(g), t=c.tOff;
var 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)';
var bc,bt;
if(c.ok&&c.surplus>0){bc='b-buf';bt='+'+c.surplus+' Puffer';}
else if(c.ok){bc='b-done';bt='Heute ✓';}
else if(c.dailyDelta<=0){bc='b-ok';bt='Im Plan';}
else if(c.dailyDelta<=g.daily*.2){bc='b-warn';bt='Leicht mehr';}
else{bc='b-danger';bt='-'+Math.abs(c.deficit)+' Rückstand';}
var nh=renamingId===g.id
?'<div class="name-wrap"><input class="ren-input" id="ri'+g.id+'" value="'+g.name.replace(/"/g,'&quot;')+'" data-g="'+g.id+'"/></div>'
:'<div class="name-wrap"><div class="goal-name">'+g.name+'</div><button class="btn-ren" data-g="'+g.id+'"></button></div>';
var doneCls=c.ok?' done':'';
if(isCollapsed(g.id)){
var hc=heuteColor(c.tdone,g.daily);
var metaC='Noch '+c.dr+'T · endet '+fs(c.end)+' · heute: <span style="color:'+hc+'">'+c.tdone+'/'+g.daily+'</span> · total: '+c.done+'/'+c.tot;
return '<div class="card'+doneCls+'">'+
'<div class="card-hdr" data-g="'+g.id+'">'+
'<div style="flex:1;min-width:0">'+nh+'<div class="goal-meta">'+metaC+'</div></div>'+
'<span class="badge '+bc+'">'+bt+'</span>'+
'<span class="chevron"></span>'+
'</div>'+
'<div style="padding:0 16px 12px"><div class="prog-track"><div class="prog-fill" style="width:'+c.pct+'%;background:'+fc+'"></div></div></div>'+
'</div>';
}
// Expanded
var sel=selDay[g.id];
var dots='';
for(var i=0;i<g.days;i++){
var it=i===t, iy=i===t-1, is=sel===i, ed=editable(g,i);
var cls=dcls(g,i)+(is?' rs':it?' rt':iy&&t>0?' ry':'');
dots+='<div class="'+cls+'"'+(ed?' data-g="'+g.id+'" data-d="'+i+'"':'')+'>'+dlbl(g,i)+'</div>';
}
var nc=heuteColor(c.tdone,g.daily);
return '<div class="card'+doneCls+'">'+
'<div class="card-hdr" data-g="'+g.id+'">'+
'<div style="flex:1;min-width:0">'+nh+'<div class="goal-meta">Noch '+c.dr+'T · endet '+fs(c.end)+'</div></div>'+
'<span class="badge '+bc+'">'+bt+'</span>'+
'<span class="chevron"></span>'+
'</div>'+
'<div class="prog-wrap"><div class="prog-track"><div class="prog-fill" style="width:'+c.pct+'%;background:'+fc+'"></div></div>'+
'<div class="prog-row"><span>'+c.done+' '+g.unit+' gemacht</span><span>'+c.pct+'% von '+c.tot+'</span></div></div>'+
'<div class="heute-stats"><div class="heute-group">'+
'<div class="heute-lbl">Heute</div>'+
'<div class="heute-inner">'+
'<div class="stat"><div class="slbl">Gemacht</div><div class="sval">'+c.tdone+'<div class="sunit">'+g.unit+'</div></div></div>'+
'<div class="stat"><div class="slbl">Tagesziel</div><div class="sval">'+g.daily+'<div class="sunit">'+g.unit+'</div></div></div>'+
'<div class="stat"><div class="slbl">Noch</div><div class="sval" style="color:'+nc+'">'+c.st+'<div class="sunit">'+g.unit+'</div></div></div>'+
'</div>'+
'</div></div>'+
'<div class="dots-sec"><div class="dots-lbl">Verlauf — heute &amp; gestern bearbeitbar</div>'+
'<div class="dots-wrap">'+dots+'</div>'+
'<div class="legend">'+
'<span class="leg"><span class="ldot" style="background:rgba(37,99,235,.3)"></span>Puffer</span>'+
'<span class="leg"><span class="ldot" style="background:rgba(22,163,74,.3)"></span>Erreicht</span>'+
'<span class="leg"><span class="ldot" style="background:rgba(217,119,6,.3)"></span>Teilweise</span>'+
'<span class="leg"><span class="ldot" style="background:rgba(220,38,38,.3)"></span>Verpasst</span>'+
'</div></div>'+
(sel!=null?panel(g,sel):'')+
'<div class="card-foot"><button class="btn-del" data-g="'+g.id+'">Ziel löschen</button></div>'+
'</div>';
}
function render(){
var m=document.getElementById('main'), h='';
if(!prefs.hd) h+='<div class="hint">Menü → "Zum Startbildschirm" für App-Icon<button class="hclose" id="hc">×</button></div>';
if(!goals.length){
h+='<div class="empty"><div style="font-size:40px;opacity:.4;margin-bottom:12px">🎯</div>Noch keine Ziele.<br>Tippe auf <strong>+</strong> um zu starten.</div>';
m.innerHTML=h; wire(); return;
}
// Sort: open first, done today at bottom
var open=[], done=[];
for(var gi=0;gi<goals.length;gi++){
var g=goals[gi], c=calc(g);
if(c.ok) done.push(g); else open.push(g);
}
if(open.length){
h+='<div class="sec-lbl">Offen</div>';
for(var i=0;i<open.length;i++) h+=cardHtml(open[i]);
}
if(done.length){
h+='<div class="sec-lbl">Heute erledigt</div>';
for(var j=0;j<done.length;j++) h+=cardHtml(done[j]);
}
m.innerHTML=h; wire();
}
function wire(){
var hc=document.getElementById('hc');
if(hc) hc.onclick=function(){prefs.hd=1;saveP();this.closest('.hint').remove();};
document.querySelectorAll('.card-hdr[data-g]').forEach(function(el){
el.onclick=function(e){
if(e.target.classList.contains('btn-ren')||e.target.classList.contains('ren-input')) return;
toggleCollapse(parseInt(this.dataset.g,10));
};
});
document.querySelectorAll('.btn-ren').forEach(function(b){
b.onclick=function(e){e.stopPropagation();startRen(parseInt(this.dataset.g,10));};
});
document.querySelectorAll('.ren-input').forEach(function(inp){
var gid=parseInt(inp.dataset.g,10);
inp.oninput=function(){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(function(d){
d.onclick=function(e){e.stopPropagation();selD(parseInt(this.dataset.g,10),parseInt(this.dataset.d,10));};
});
document.querySelectorAll('.btn-as').forEach(function(b){
b.onclick=function(){addSet(parseInt(this.dataset.g,10),parseInt(this.dataset.o,10));};
});
document.querySelectorAll('.num-in').forEach(function(inp){
var k=inp.dataset.k, g=parseInt(inp.dataset.g,10), o=parseInt(inp.dataset.o,10);
inp.oninput=function(){addAmt[k]=this.value;};
inp.onkeydown=function(e){if(e.key==='Enter')addSet(g,o);};
});
document.querySelectorAll('.sdel').forEach(function(b){
b.onclick=function(){remSet(parseInt(this.dataset.g,10),parseInt(this.dataset.o,10),parseInt(this.dataset.i,10));};
});
document.querySelectorAll('.btn-del').forEach(function(b){
b.onclick=function(){delGoal(parseInt(this.dataset.g,10));};
});
}
document.getElementById('btnNew').onclick=openNew;
document.getElementById('btnData').onclick=openData;
document.getElementById('tlbl').textContent=TODAY.toLocaleDateString('de-DE',{weekday:'long',day:'numeric',month:'long'});
render();
function scheduleMidnight(){
var n=new Date();
var ms=new Date(n.getFullYear(),n.getMonth(),n.getDate()+1,0,0,5).getTime()-n.getTime();
setTimeout(function(){
TODAY=new Date();TODAY.setHours(0,0,0,0);selDay={};collapsed={};
document.getElementById('tlbl').textContent=TODAY.toLocaleDateString('de-DE',{weekday:'long',day:'numeric',month:'long'});
render();scheduleMidnight();
},ms);
}
scheduleMidnight();
document.addEventListener('visibilitychange',function(){
if(document.visibilityState==='visible'){
var n=new Date();n.setHours(0,0,0,0);
if(n.getTime()!==TODAY.getTime()){TODAY=n;selDay={};collapsed={};render();scheduleMidnight();}
}
});
```
- [ ] **Step 2: Verify file exists**
```bash
ls -la /srv/http/zieltracker/app.js
```
Expected: file exists, ~8KB
---
### Task 3: Rewrite index.html as clean skeleton
**Files:**
- Modify: `index.html`
- [ ] **Step 1: Replace index.html** with a clean skeleton that references the new files:
```html
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0"/>
<meta name="theme-color" content="#f5f4f0"/>
<meta name="mobile-web-app-capable" content="yes"/>
<meta name="apple-mobile-web-app-capable" content="yes"/>
<title>Zieltracker</title>
<link rel="stylesheet" href="style.css"/>
</head>
<body>
<div class="main-wrap">
<div class="hdr">
<div>
<div class="hdr-title">Zieltracker</div>
<div class="hdr-sub" id="tlbl"></div>
</div>
<div class="hdr-btns">
<button class="btn-menu" id="btnData"></button>
<button class="btn-add" id="btnNew">+</button>
</div>
</div>
<div class="main" id="main"></div>
</div>
<div id="ov" style="display:none"></div>
<script src="app.js"></script>
</body>
</html>
```
- [ ] **Step 2: Open in browser and verify**
Open `http://localhost/zieltracker/` (or wherever it's served).
Check:
- Page loads without console errors
- Header and cards are centered on a wide screen (max ~480px content width)
- Existing goals show up as collapsed with meta-line `Noch XT · endet DD. Mon · heute: X/Y · total: X/Y`
- Clicking a card header expands it, showing Heute-Stats group + Dots + Entry panel
- Clicking again collapses it
- Goals with `ok=true` (today's quota met) appear under "Heute erledigt" with reduced opacity
- Rename pencil still works (click pencil → edit inline)
- Dot click still opens entry panel
- `+ Satz` still saves a set
- [ ] **Step 3: Commit**
```bash
cd /srv/http/zieltracker
git add index.html style.css app.js
git commit -m "refactor: split into files, collapsible cards, heute stats, desktop max-width"
```

View file

@ -0,0 +1,578 @@
# Registration & Invite System 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 invite-only registration, referral tracking, and password reset to Zieltracker.
**Architecture:** Vanilla JS single-page app. A new `config.js` externalizes server URLs. PocketBase handles auth, email verification, and password reset natively via SMTP. A new `invites` collection tracks tokens with labels and referral state. All new UI flows (register tab, password reset, invite management) are added to `app.js` following the existing sheet pattern.
**Tech Stack:** Vanilla JS, PocketBase REST API, Brevo SMTP (configured in PocketBase admin)
> **Note:** This app has no test framework. Each task uses browser-based manual verification instead of automated tests.
---
## File Map
| File | Action | Responsibility |
|---|---|---|
| `config.js` | Create | `PB_URL` and `APP_URL` globals |
| `index.html` | Modify | Add `<script src="config.js">` before `app.js` |
| `app.js` | Modify | Remove hardcoded `PB_URL`; add `inviteRecord`/`loginTab` globals; refactor `showLogin`; add password reset, registration, and invite management functions; update startup block |
| `style.css` | Modify | Add tab styles, password-reset link style, invite list/link styles |
---
### Task 1: Create config.js and wire it up
**Files:**
- Create: `config.js`
- Modify: `index.html`
- Modify: `app.js` (remove line 4)
- [ ] **Step 1: Create `config.js`**
```js
var PB_URL = 'http://miniweb.kuehn.home:8090';
var APP_URL = 'http://miniweb.kuehn.home';
```
- [ ] **Step 2: Add script tag to `index.html`**
In `index.html`, before `<script src="app.js"></script>`, add:
```html
<script src="config.js"></script>
```
- [ ] **Step 3: Remove hardcoded `PB_URL` from `app.js`**
Delete line 4 of `app.js`:
```js
var PB_URL = 'http://miniweb.kuehn.home:8090';
```
- [ ] **Step 4: Verify in browser**
Open the app. Goals load, login/logout works, no console errors.
- [ ] **Step 5: Commit**
```bash
git add config.js index.html app.js
git commit -m "feat: externalize server URLs into config.js"
```
---
### Task 2: PocketBase — create `invites` collection (manual)
**Files:** None — PocketBase admin UI only
- [ ] **Step 1: Open PocketBase admin**
Navigate to `http://miniweb.kuehn.home:8090/_/` and log in.
- [ ] **Step 2: Create `invites` collection**
Collections → New collection → Name: `invites`
Add fields:
| Name | Type | Options |
|---|---|---|
| `token` | Text | Required |
| `label` | Text | Required |
| `created_by` | Relation | Collection: `users`, Required |
| `used_by` | Relation | Collection: `users`, Optional |
| `used_at` | Date | Optional |
- [ ] **Step 3: Set API rules**
In the `invites` collection → API rules:
| Rule | Value |
|---|---|
| List / View | *(empty — public)* |
| Create | `@request.auth.id != ""` |
| Update | `used_by = ""` |
| Delete | `@request.auth.id = created_by` |
- [ ] **Step 4: Verify**
In PocketBase admin, create a test invite record manually with any token string. Then open in browser:
`http://miniweb.kuehn.home:8090/api/collections/invites/records`
Should return the record as JSON.
---
### Task 3: PocketBase — configure SMTP (manual)
**Files:** None — PocketBase admin UI only
- [ ] **Step 1: Get Brevo SMTP credentials**
Sign up at brevo.com (free tier). Go to:
SMTP & API → SMTP → Generate SMTP key
Note: host `smtp-relay.brevo.com`, port `587`, your Brevo login email, and the generated key.
- [ ] **Step 2: Configure PocketBase SMTP**
PocketBase admin → Settings → Mail settings:
| Field | Value |
|---|---|
| Sender name | ZielTracker |
| Sender address | your-email@example.com |
| SMTP host | smtp-relay.brevo.com |
| SMTP port | 587 |
| Username | your-brevo-login@example.com |
| Password | (Brevo SMTP key) |
| TLS | enabled |
- [ ] **Step 3: Send test email**
PocketBase admin → Settings → Mail settings → "Send test email". Confirm arrival in inbox.
---
### Task 4: Add CSS for new UI elements
**Files:**
- Modify: `style.css`
- [ ] **Step 1: Append styles to `style.css`**
```css
.ltabs{display:flex;gap:0;margin:0 0 16px;border-bottom:2px solid var(--border)}
.ltab{flex:1;padding:10px;background:none;border:none;font-size:15px;color:var(--text3);cursor:pointer;border-bottom:2px solid transparent;margin-bottom:-2px;transition:color .15s;font-family:'DM Sans',sans-serif}
.ltab.active{color:var(--text);border-bottom-color:var(--text)}
.pw-link{background:none;border:none;color:var(--text3);font-size:13px;padding:8px 0 0;text-decoration:underline;cursor:pointer;display:block;text-align:center;font-family:'DM Sans',sans-serif;width:100%}
.inv-row{display:flex;justify-content:space-between;align-items:center;padding:10px 0;border-bottom:1px solid var(--border)}
.inv-lbl{font-weight:500;font-size:15px}
.inv-link{background:var(--bg3);border:1px solid var(--border2);border-radius:var(--rs);padding:12px;font-size:13px;word-break:break-all;margin:8px 0 16px;font-family:'DM Mono',monospace;line-height:1.5}
```
- [ ] **Step 2: Verify**
Open app in browser, confirm no visual regressions.
- [ ] **Step 3: Commit**
```bash
git add style.css
git commit -m "feat: add CSS for login tabs, password-reset link, and invite rows"
```
---
### Task 5: Add globals and refactor startup block
**Files:**
- Modify: `app.js`
- [ ] **Step 1: Add globals near top of `app.js`**
After line 3 (`var authToken = '', authUserId = '';`), insert:
```js
var inviteRecord = null;
var loginTab = 'login';
```
- [ ] **Step 2: Replace the startup block at the bottom of `app.js`**
The current startup block (after `scheduleMidnight` and `visibilitychange`) is:
```js
authToken=localStorage.getItem('zt_token')||'';
authUserId=localStorage.getItem('zt_uid')||'';
if(!authToken){
render(); showLogin();
} else {
pbGoals()
.then(function(g){ goals=g; render(); })
.catch(function(){ clearAuth(); render(); showLogin(); });
}
```
Replace with:
```js
authToken=localStorage.getItem('zt_token')||'';
authUserId=localStorage.getItem('zt_uid')||'';
function init(){
if(!authToken){ render(); showLogin(); }
else{
pbGoals()
.then(function(g){ goals=g; render(); })
.catch(function(){ clearAuth(); render(); showLogin(); });
}
}
var urlInviteToken=(new URLSearchParams(window.location.search)).get('invite');
if(urlInviteToken){
api('GET','/api/collections/invites/records?filter='+encodeURIComponent('(token="'+urlInviteToken+'"&&used_by="")'))
.then(function(res){
if(res.items&&res.items.length>0) inviteRecord=res.items[0];
})
.catch(function(){})
.then(function(){ init(); });
}else{
init();
}
```
- [ ] **Step 3: Verify**
Open app normally (no `?invite` param). Works as before, no console errors.
- [ ] **Step 4: Commit**
```bash
git add app.js
git commit -m "feat: add invite globals and refactored startup with URL token check"
```
---
### Task 6: Password reset UI
**Files:**
- Modify: `app.js`
- [ ] **Step 1: Add `showPasswordReset` function after `showLogin` in `app.js`**
```js
function showPasswordReset(){
var o=document.getElementById('ov');
o.innerHTML='<div class="sheet">'+
'<div class="shandle"></div>'+
'<div class="stitle">Passwort zurücksetzen</div>'+
'<div class="ssub">Wir senden dir einen Reset-Link per E-Mail</div>'+
'<div class="ff"><label>E-Mail</label><input class="fi" id="premail" type="email" autocomplete="email"/></div>'+
'<div class="factions">'+
'<button class="btn-p" id="prsub">Link senden</button>'+
'<button class="btn-c" id="prback">Zurück</button>'+
'</div>'+
'</div>';
o.onclick=null;
setTimeout(function(){ var el=document.getElementById('premail'); if(el) el.focus(); },50);
document.getElementById('prback').onclick=function(){ loginTab='login'; showLogin(); };
document.getElementById('prsub').onclick=function(){
var email=document.getElementById('premail').value.trim();
if(!email) return;
var btn=this; btn.disabled=true; btn.textContent='…';
api('POST','/api/collections/users/request-password-reset',{email:email})
.then(function(){
document.querySelector('#ov .sheet').innerHTML=
'<div class="shandle"></div>'+
'<div class="stitle">E-Mail gesendet</div>'+
'<div class="ssub">Falls ein Konto mit dieser Adresse existiert, hast du einen Link erhalten.</div>'+
'<div class="factions"><button class="btn-p" id="prdone">Zum Login</button></div>';
document.getElementById('prdone').onclick=function(){ loginTab='login'; showLogin(); };
})
.catch(function(){
btn.disabled=false; btn.textContent='Link senden';
showToast('Fehler beim Senden');
});
};
}
```
- [ ] **Step 2: Verify**
Open app. The login sheet still works. (The "Passwort vergessen?" link is wired in the next task.)
- [ ] **Step 3: Commit**
```bash
git add app.js
git commit -m "feat: add password reset sheet"
```
---
### Task 7: Login sheet refactor + registration form
> All six functions in this task must be added together in one commit — `showLogin` calls `renderRegisterForm` and `wireRegisterForm` which are defined here.
**Files:**
- Modify: `app.js`
- [ ] **Step 1: Replace the entire `showLogin` function and add all six helper functions**
Replace the existing `showLogin` function in `app.js` with the following block. This is a complete replacement — remove the old function and insert all six functions in its place:
```js
function showLogin(err){
var o=document.getElementById('ov');
o.style.cssText='display:flex;position:fixed;inset:0;z-index:200;background:rgba(0,0,0,.5);align-items:flex-end;animation:fi .2s ease';
var hasInvite=!!inviteRecord;
var tabsHtml=hasInvite
?'<div class="ltabs">'+
'<button class="ltab'+(loginTab==='login'?' active':'')+'" id="ltab-login">Anmelden</button>'+
'<button class="ltab'+(loginTab==='register'?' active':'')+'" id="ltab-reg">Registrieren</button>'+
'</div>'
:'';
var formHtml=(hasInvite&&loginTab==='register')?renderRegisterForm(err):renderLoginForm(err);
o.innerHTML='<div class="sheet">'+
'<div class="shandle"></div>'+
'<div class="stitle">Anmelden</div>'+
'<div class="ssub">ZielTracker</div>'+
tabsHtml+formHtml+
'</div>';
o.onclick=null;
if(hasInvite){
document.getElementById('ltab-login').onclick=function(){loginTab='login';showLogin();};
document.getElementById('ltab-reg').onclick=function(){loginTab='register';showLogin();};
}
if(hasInvite&&loginTab==='register') wireRegisterForm();
else wireLoginForm();
}
function renderLoginForm(err){
return (err?'<div class="login-err">'+err+'</div>':'')+
'<div class="ff"><label>E-Mail</label><input class="fi" id="lemail" type="email" autocomplete="email"/></div>'+
'<div class="ff"><label>Passwort</label><input class="fi" id="lpass" type="password" autocomplete="current-password"/></div>'+
'<div class="factions"><button class="btn-p" id="lsub">Anmelden</button></div>'+
'<button class="pw-link" id="lpw">Passwort vergessen?</button>';
}
function wireLoginForm(){
setTimeout(function(){
var le=document.getElementById('lemail'),lp=document.getElementById('lpass');
if(le){le.focus();le.onkeydown=function(e){if(e.key==='Enter')lp.focus();};}
if(lp) lp.onkeydown=function(e){if(e.key==='Enter')document.getElementById('lsub').click();};
var lpw=document.getElementById('lpw');
if(lpw) lpw.onclick=showPasswordReset;
},50);
document.getElementById('lsub').onclick=function(){
var email=document.getElementById('lemail').value.trim();
var pass=document.getElementById('lpass').value;
if(!email||!pass) return;
var btn=this; btn.disabled=true; btn.textContent='…';
api('POST','/api/collections/users/auth-with-password',{identity:email,password:pass})
.then(function(res){
authToken=res.token; authUserId=res.record.id;
localStorage.setItem('zt_token',authToken);
localStorage.setItem('zt_uid',authUserId);
return pbGoals();
})
.then(function(g){ goals=g; closeOv(); render(); })
.catch(function(e){
btn.disabled=false; btn.textContent='Anmelden';
if(authToken){ closeOv(); render(); }
else{ showLogin(e.status===400||e.status===401?'Falsche E-Mail oder Passwort':'Verbindungsfehler'); }
});
};
}
function renderRegisterForm(err){
return (err?'<div class="login-err">'+err+'</div>':'')+
'<div class="ssub" style="margin-bottom:8px">Eingeladen als: <strong>'+inviteRecord.label+'</strong></div>'+
'<div class="ff"><label>E-Mail</label><input class="fi" id="remail" type="email" autocomplete="email"/></div>'+
'<div class="ff"><label>Passwort</label><input class="fi" id="rpass" type="password" autocomplete="new-password"/></div>'+
'<div class="ff"><label>Passwort wiederholen</label><input class="fi" id="rpass2" type="password" autocomplete="new-password"/></div>'+
'<div class="factions"><button class="btn-p" id="rsub">Registrieren</button></div>';
}
function wireRegisterForm(){
setTimeout(function(){ var el=document.getElementById('remail'); if(el) el.focus(); },50);
document.getElementById('rsub').onclick=function(){
var email=document.getElementById('remail').value.trim();
var pass=document.getElementById('rpass').value;
var pass2=document.getElementById('rpass2').value;
if(!email||!pass||!pass2) return;
if(pass!==pass2){showLogin('Passwörter stimmen nicht überein');return;}
if(pass.length<8){showLogin('Passwort muss mindestens 8 Zeichen haben');return;}
var btn=this; btn.disabled=true; btn.textContent='…';
var savedInviteId=inviteRecord.id;
api('POST','/api/collections/users/records',{email:email,password:pass,passwordConfirm:pass2})
.then(function(){
return api('POST','/api/collections/users/auth-with-password',{identity:email,password:pass});
})
.then(function(res){
authToken=res.token; authUserId=res.record.id;
localStorage.setItem('zt_token',authToken);
localStorage.setItem('zt_uid',authUserId);
return api('PATCH','/api/collections/invites/records/'+savedInviteId,{used_by:authUserId,used_at:new Date().toISOString()});
})
.then(function(){
inviteRecord=null;
return pbGoals();
})
.then(function(g){ goals=g; closeOv(); render(); })
.catch(function(e){
btn.disabled=false; btn.textContent='Registrieren';
showLogin(e.status===400?'E-Mail bereits vergeben oder ungültig':'Registrierung fehlgeschlagen');
});
};
}
```
- [ ] **Step 2: Verify login still works**
Open app normally (no `?invite` param). Login works as before. "Passwort vergessen?" link appears and opens the reset sheet.
- [ ] **Step 3: Verify registration flow**
1. In PocketBase admin, create a test invite: token = `testtoken123`, label = `Testkind`, used_by = empty.
2. Open `http://miniweb.kuehn.home/?invite=testtoken123` in a private window.
3. Login sheet shows two tabs: "Anmelden" / "Registrieren".
4. Click "Registrieren" → form shows "Eingeladen als: **Testkind**".
5. Enter mismatched passwords → error "Passwörter stimmen nicht überein".
6. Enter a 7-char password → error "Passwort muss mindestens 8 Zeichen haben".
7. Enter valid email + matching passwords (8+ chars) → user created, logged in, goals list shown.
8. In PocketBase admin, confirm invite record has `used_by` and `used_at` set.
9. Open the same link again in a private window → only "Anmelden" tab visible (token is used).
- [ ] **Step 4: Commit**
```bash
git add app.js
git commit -m "feat: refactor login sheet with tabs, registration form, and password-reset link"
```
---
### Task 8: Invite management UI
**Files:**
- Modify: `app.js`
- Modify: `style.css`
- [ ] **Step 1: Add `genToken` utility function to `app.js`**
Add after the `now()` function (around line 16):
```js
function genToken(){
var c='ABCDEFGHJKMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789',t='';
for(var i=0;i<12;i++) t+=c[Math.floor(Math.random()*c.length)];
return t;
}
```
- [ ] **Step 2: Add `showInvites` and `showNewInvite` functions after `openData` in `app.js`**
```js
function showInvites(){
api('GET','/api/collections/invites/records?filter='+encodeURIComponent('(created_by="'+authUserId+'")&sort=-created&expand=used_by'))
.then(function(res){
var items=res.items||[];
var rows='';
for(var i=0;i<items.length;i++){
var inv=items[i];
var usedEmail=inv.expand&&inv.expand.used_by?inv.expand.used_by.email:'registriert';
var status=inv.used_by
?'<span style="color:var(--green)">✓ '+usedEmail+'</span>'
:'<span style="color:var(--text3)">Ausstehend</span>';
rows+='<div class="inv-row"><span class="inv-lbl">'+inv.label+'</span>'+status+'</div>';
}
showSheet(
'<div class="shandle"></div>'+
'<div class="stitle">Einladungen</div>'+
(rows||'<div style="color:var(--text3);padding:12px 0;font-size:14px">Noch keine Einladungen verschickt.</div>')+
'<div class="ddiv"></div>'+
'<button class="btn-p" id="inew" style="width:100%">+ Neue Einladung</button>'+
'<button class="btn-c" id="icls" style="width:100%;margin-top:4px;text-align:center">Schließen</button>'
);
document.getElementById('icls').onclick=closeOv;
document.getElementById('inew').onclick=showNewInvite;
})
.catch(function(){ showToast('Fehler beim Laden'); });
}
function showNewInvite(){
showSheet(
'<div class="shandle"></div>'+
'<div class="stitle">Neue Einladung</div>'+
'<div class="ff"><label>Name / Für wen?</label><input class="fi" id="ilabel" type="text" placeholder="z.B. Mama"/></div>'+
'<div class="factions">'+
'<button class="btn-p" id="isub">Einladung erstellen</button>'+
'<button class="btn-c" id="iback">Zurück</button>'+
'</div>'
);
setTimeout(function(){ var el=document.getElementById('ilabel'); if(el) el.focus(); },50);
document.getElementById('iback').onclick=showInvites;
document.getElementById('isub').onclick=function(){
var label=(document.getElementById('ilabel').value||'').trim();
if(!label) return;
var btn=this; btn.disabled=true; btn.textContent='…';
var token=genToken();
api('POST','/api/collections/invites/records',{token:token,label:label,created_by:authUserId})
.then(function(){
var link=APP_URL+'/?invite='+token;
showSheet(
'<div class="shandle"></div>'+
'<div class="stitle">Einladung erstellt</div>'+
'<div class="ssub">Teile diesen Link mit '+label+':</div>'+
'<div class="inv-link" id="ilink">'+link+'</div>'+
'<div class="factions">'+
'<button class="btn-p" id="icopy">Link kopieren</button>'+
'<button class="btn-c" id="idone">Fertig</button>'+
'</div>'
);
document.getElementById('idone').onclick=closeOv;
document.getElementById('icopy').onclick=function(){
navigator.clipboard.writeText(link).then(function(){ showToast('Link kopiert!'); });
};
})
.catch(function(){ btn.disabled=false; btn.textContent='Einladung erstellen'; showToast('Fehler'); });
};
}
```
- [ ] **Step 3: Add "Einladungen" button to `openData`**
In `openData`, the sheet HTML starts with the export button. Add a new button and divider at the very beginning of the sheet content (before the export button):
Replace the beginning of `openData`'s sheet HTML:
```js
'<button class="dbtn" id="dexp"><span class="dico"></span><span class="dlbl">Exportieren<span class="dsub">Alle Ziele als JSON-Datei speichern</span></span></button>'+
```
With:
```js
'<button class="dbtn" id="dinv"><span class="dico"></span><span class="dlbl">Einladungen<span class="dsub">Freunde und Familie einladen</span></span></button>'+
'<div class="ddiv"></div>'+
'<button class="dbtn" id="dexp"><span class="dico"></span><span class="dlbl">Exportieren<span class="dsub">Alle Ziele als JSON-Datei speichern</span></span></button>'+
```
Add the event binding after `document.getElementById('dcls').onclick=closeOv;`:
```js
document.getElementById('dinv').onclick=function(){ closeOv(); showInvites(); };
```
- [ ] **Step 4: Verify invite management**
1. Log in, open ⋯ menu → "Einladungen" button appears at the top.
2. Click → invite list sheet opens (empty).
3. "Neue Einladung" → enter "Papa" → "Einladung erstellen".
4. Result sheet shows invite link with "Link kopieren" button.
5. "Link kopieren" → toast "Link kopiert!" appears, clipboard contains the URL.
6. "Fertig" → sheet closes.
7. Open ⋯ → Einladungen → "Papa" listed as "Ausstehend".
8. Open the invite link in a private window and register.
9. Back in original session: ⋯ → Einladungen → "Papa" shows "✓ [email]".
- [ ] **Step 5: Commit**
```bash
git add app.js style.css
git commit -m "feat: add invite management UI with create, list, and referral tracking"
```
---
## Done
After Task 8, the full feature set is live:
- `config.js` holds server URLs (update for production deployment)
- Invite links are the only way to register
- Password reset works via email (requires PocketBase SMTP configured in Task 3)
- Invite list shows referral status per person

View file

@ -0,0 +1,107 @@
# Zieltracker — Refactor & Design-Update
**Datum:** 2026-04-17
## Überblick
Refactoring der Single-File-App in separate Dateien, neue einklappbare Karten, überarbeitete Stats, Desktop-Layout und Sortierung nach Tagesstatus.
---
## 1. Dateistruktur
Aus `index.html` (alles inline) werden drei Dateien:
- `index.html` — nur HTML-Gerüst + `<link>` + `<script>`
- `style.css` — alle Styles
- `app.js` — gesamte Logik
---
## 2. Layout — Desktop max-width
```css
.main-wrap {
max-width: 480px;
margin: 0 auto;
}
```
- Header und Content-Bereich beide im `max-width`-Container
- Außerhalb: `background: var(--bg)` bleibt sichtbar als grauer Rand
- Auf Handy ändert sich nichts (412px Breite des OnePlus 9 Pro passt rein)
---
## 3. Einklappbare Karten
### Standardverhalten
- Alle Karten starten **eingeklappt** beim Laden
- Kein Persist in localStorage — jeder Reload beginnt komplett eingeklappt
- Klick auf Karte (Header-Bereich) klappt auf/zu
- Pfeil: `▸` eingeklappt, `▴` ausgeklappt
### Sortierung
1. **Offen** (heute noch nicht erledigt) — oben, normale Opazität
2. **Heute erledigt** — unten, leicht abgedunkelt (`opacity: 0.7`)
Trennlabels zwischen den Gruppen: `OFFEN` / `HEUTE ERLEDIGT` (10px, monospace, uppercase, grau)
### Eingeklappt zeigt
```
[Name] [✎]
[Noch XT · endet DD. Mon · heute: GEMACHT/TAGESZIEL · total: DONE/TOTAL]
[Badge] [▸]
[Fortschrittsbalken]
```
- `heute: X/Y` farbkodiert: rot (0), amber (teilweise), grün (erreicht), blau (über Ziel)
- Fortschrittsbalken ohne Beschriftung darunter
### Ausgeklappt zeigt
```
[Name] [✎]
[Noch XT · endet DD. Mon]
[Badge] [▴]
[Fortschrittsbalken]
[X Stück gemacht 62% von 500] ← prog-row
[Heute-Stats-Block]
[Dots + Legende]
[Eingabe-Panel (wenn Tag auswählbar)]
[Ziel löschen]
```
---
## 4. Stats — Umbau
### Alt (4 Blöcke, immer sichtbar)
Heute noch · Gemacht · Puffer · Täglich
### Neu (1 Gruppe "Heute", 3 Blöcke, nur ausgeklappt)
| Gemacht | Tagesziel | Noch |
|---------|-----------|------|
| 30 Stk | 50 Stk | 20 Stk (farbig) |
- Gruppe hat Label "HEUTE" (9px monospace uppercase)
- `Noch` farbkodiert identisch zu `heute:` in der Meta-Zeile
- Puffer und Täglich entfallen als eigene Blöcke (Badge zeigt Puffer/Rückstand bereits)
---
## 5. Eingabe
Bleibt unverändert: Freie Zahleingabe + `+ Satz` Button.
---
## 6. Zusammenfassung der Änderungen
| Was | Vorher | Nachher |
|-----|--------|---------|
| Dateistruktur | 1 HTML-Datei | index.html + style.css + app.js |
| Desktop-Breite | volle Breite | max-width 480px, zentriert |
| Karten | immer ausgeklappt | eingeklappt, aufklappbar |
| Sortierung | Erstellungsreihenfolge | Offen zuerst, Erledigt unten |
| Stats | 4 Blöcke (Heute noch/Gemacht/Puffer/Täglich) | 3 Blöcke Gruppe "Heute" |
| Meta-Zeile (eingeklappt) | Noch XT · endet DD | + heute: X/Y · total: X/Y |

View file

@ -0,0 +1,98 @@
# Design: Registrierung & Einladungssystem
**Datum:** 2026-04-21
**Status:** Approved
## Ziel
Zieltracker für Freunde und Familie öffnen: Registrierung nur per Einladungslink, Passwort-Reset per E-Mail, Referral-Tracking pro User.
---
## 1. Konfiguration (`config.js`)
Neue Datei `config.js`, die vor `app.js` eingebunden wird und die zwei globalen Variablen definiert:
```js
var PB_URL = 'https://pb.kuehn.example.com';
var APP_URL = 'https://zieltracker.kuehn.example.com';
```
- `PB_URL` ersetzt die bisherige Hardcodierung in `app.js`
- `APP_URL` wird verwendet, um Einladungslinks zu bauen (`APP_URL + '/?invite=' + token`)
- Wird einmalig auf dem Server angepasst — kein UI
---
## 2. PocketBase: `invites`-Collection
| Feld | Typ | Constraints |
|---|---|---|
| `token` | text | unique, required |
| `label` | text | required (z.B. "Für Mama") |
| `created_by` | relation → users | required |
| `used_by` | relation → users | optional |
| `used_at` | date | optional |
**API-Regeln:**
- Lesen (einzelner Token via Filter): public — zum Validieren beim Registrieren
- Erstellen: nur authentifizierte User (`@request.auth.id != ""`)
- Update (`used_by`, `used_at`): public — wird beim Registrieren gesetzt
- Löschen: nur der Ersteller
---
## 3. PocketBase: E-Mail / SMTP
PocketBase Admin → Settings → Mail settings:
- Provider: Brevo oder Resend (externer SMTP-Dienst, kostenloser Tier)
- PocketBase schickt nativ: E-Mail-Verifikation nach Registrierung, Passwort-Reset-Link
- Kein eigener Code nötig
---
## 4. UI-Flows
### 4.1 Einladung erstellen (eingeloggter User)
Im "Daten verwalten"-Sheet (⋯-Menü):
- Neuer Button **"Einladungen"**
- Öffnet Sheet mit Liste aller eigenen Einladungen:
- Label + Status ("Ausstehend" oder "✓ Registriert")
- Button **"Neue Einladung"**:
1. Eingabe: Label (z.B. "Für Papa")
2. Generiert Token (12 zufällige alphanumerische Zeichen)
3. Schreibt Token in `invites`-Collection via PocketBase API
4. Zeigt fertigen Link zum Kopieren: `APP_URL/?invite=TOKEN`
### 4.2 Registrierung (Eingeladener)
- User öffnet Einladungslink: `https://zieltracker.../?invite=TOKEN`
- App liest `?invite`-Parameter aus URL
- App prüft Token gegen PocketBase (`invites`-Collection, Filter: `token=TOKEN && used_by=""`)
- Ist Token gültig und ungenutzt: Login-Sheet zeigt zusätzlichen Tab **"Registrieren"**
- Ist Token ungültig/bereits genutzt: nur Login, kein Registrierungs-Tab
- Registrierungsformular: E-Mail, Passwort, Passwort wiederholen
- Nach Erfolg:
1. PocketBase-User wird angelegt (Standard-Auth-Flow)
2. Token wird als `used_by = neuer User`, `used_at = jetzt` markiert
3. User ist eingeloggt, Ziele werden geladen
### 4.3 Passwort vergessen
Im Login-Sheet:
- Kleiner Link **"Passwort vergessen?"** unter dem Anmelden-Button
- Öffnet Mini-Sheet mit E-Mail-Eingabe
- Button **"Reset-Link senden"**
- Ruft PocketBase-Endpoint auf: `POST /api/collections/users/request-password-reset`
- Zeigt Bestätigung: "E-Mail wurde gesendet" (unabhängig ob Adresse existiert)
- PocketBase verschickt den Reset-Link — kein eigener Template-Code nötig
---
## 5. Nicht im Scope
- Admin-Funktion zum Deaktivieren von Accounts
- Invite-Limit pro User
- Ablaufdatum für Einladungslinks

60
include/db.php Normal file
View file

@ -0,0 +1,60 @@
<?php
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/config.php';
$conn = \Doctrine\DBAL\DriverManager::getConnection([
'driver' => 'pdo_mysql',
'host' => DB_HOST,
'dbname' => DB_NAME,
'user' => DB_USER,
'password' => DB_PASS,
'charset' => 'utf8mb4',
]);
// PDO für delight-im/auth
$pdo = $conn->getNativeConnection();
$auth = new \Delight\Auth\Auth($pdo);
// Tabellen anlegen falls nicht vorhanden
$conn->executeStatement('
CREATE TABLE IF NOT EXISTS goals (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
name VARCHAR(255) NOT NULL,
unit VARCHAR(50) NOT NULL DEFAULT \'Stück\',
daily FLOAT NOT NULL DEFAULT 1,
days INT NOT NULL DEFAULT 30,
start DATETIME NOT NULL,
sets JSON NOT NULL DEFAULT (\'[]\'),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
');
$conn->executeStatement('
CREATE TABLE IF NOT EXISTS schema_migrations (
version VARCHAR(64) NOT NULL PRIMARY KEY,
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
');
$conn->executeStatement('
CREATE TABLE IF NOT EXISTS invites (
id INT AUTO_INCREMENT PRIMARY KEY,
token VARCHAR(64) NOT NULL UNIQUE,
note VARCHAR(255) DEFAULT NULL,
created_by INT NOT NULL,
used_by INT DEFAULT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP NOT NULL,
used_at TIMESTAMP DEFAULT NULL
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
');
// Migrations ausführen
foreach (glob(__DIR__ . '/../migrations/*.sql') as $file) {
$version = basename($file, '.sql');
if (!$conn->fetchOne('SELECT 1 FROM schema_migrations WHERE version = ?', [$version])) {
$conn->executeStatement(file_get_contents($file));
$conn->executeStatement('INSERT INTO schema_migrations (version) VALUES (?)', [$version]);
}
}

19
include/mailer.php Normal file
View file

@ -0,0 +1,19 @@
<?php
use PHPMailer\PHPMailer\PHPMailer;
function sendMail(string $to, string $subject, string $body): void {
$mail = new PHPMailer(true);
$mail->isSMTP();
$mail->Host = SMTP_HOST;
$mail->SMTPAuth = true;
$mail->Username = SMTP_USER;
$mail->Password = SMTP_PASS;
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
$mail->Port = SMTP_PORT;
$mail->CharSet = 'UTF-8';
$mail->setFrom(SMTP_FROM, SMTP_FROM_NAME);
$mail->addAddress($to);
$mail->Subject = $subject;
$mail->Body = $body;
$mail->send();
}

8
index.php Normal file
View file

@ -0,0 +1,8 @@
<?php
require_once __DIR__ . '/vendor/autoload.php';
$twig = new \Twig\Environment(
new \Twig\Loader\FilesystemLoader(__DIR__ . '/templates'),
['cache' => false]
);
echo $twig->render('app.html.twig');

BIN
logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

View file

@ -0,0 +1 @@
ALTER TABLE invites ADD COLUMN IF NOT EXISTS note VARCHAR(255) DEFAULT NULL AFTER token;

17
public/.htaccess Normal file
View file

@ -0,0 +1,17 @@
DirectoryIndex index.php
<IfModule mod_negotiation.c>
Options -MultiViews
</IfModule>
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /
# Composer-Dateien sperren (liegen außerhalb public/, trotzdem sicher)
RewriteRule ^(composer\.(json|lock))$ - [F,L]
# Static files direkt ausliefern
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.php [L]
</IfModule>

716
public/app.js Normal file
View file

@ -0,0 +1,716 @@
var TODAY = new Date(); TODAY.setHours(0,0,0,0);
var goals = [], prefs, selDay = {}, addAmt = {}, renamingId = null, renameVal = '', collapsed = {};
var userName = '';
function loadPref(k,def){ try{ return JSON.parse(localStorage.getItem(k)||def); }catch(e){ return JSON.parse(def); } }
function saveP(){ localStorage.setItem('zt_p',JSON.stringify(prefs)); }
prefs = loadPref('zt_p','{}');
function tOff(g){ return Math.round((TODAY - new Date(g.start))/86400000); }
function o2d(g,i){ var d=new Date(new Date(g.start).getTime()+i*86400000); d.setHours(0,0,0,0); return d; }
function dTot(g,o){ return (g.sets[String(o)]||[]).reduce(function(a,b){return a+b.amount;},0); }
function fd(d){ return d.toLocaleDateString('de-DE',{weekday:'short',day:'numeric',month:'short'}); }
function fs(d){ return d.toLocaleDateString('de-DE',{day:'numeric',month:'short'}); }
function editable(g,o){ var t=tOff(g); return o===t||o===t-1; }
function now(){ var n=new Date(); return String(n.getHours()).padStart(2,'0')+':'+String(n.getMinutes()).padStart(2,'0'); }
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)';
}
function isCollapsed(id){ return collapsed[id]!==false; }
function toggleCollapse(id){
var wasCollapsed=isCollapsed(id);
collapsed[id]=!wasCollapsed;
if(wasCollapsed){
var g=goals.filter(function(x){return x.id===id;})[0];
if(g) selDay[id]=tOff(g);
}
render();
}
function calc(g){
var t=tOff(g), tot=g.daily*g.days;
var dr=Math.max(0,g.days-t-1);
var sd=new Date(g.start); sd.setHours(0,0,0,0);
var end=new Date(sd.getTime()+g.days*86400000);
var past=0;
for(var i=0;i<Math.min(t,g.days);i++) past+=dTot(g,i);
var tdone=dTot(g,t), tot2=past+tdone;
var dl=dr+1;
var remaining=Math.max(0,tot-past);
var pd=Math.ceil(remaining/Math.max(1,dl));
var st=Math.max(0,pd-tdone);
var expectedPast=Math.min(t,g.days)*g.daily;
var buf=(past-expectedPast)+Math.max(0,tdone-g.daily);
var deficit=Math.min(0,buf);
var surplus=Math.max(0,buf);
var dailyDelta=pd-g.daily;
var pct=Math.min(100,Math.round((tot2/tot)*100));
return{tot:tot,tOff:t,end:end,dr:dr,done:tot2,tdone:tdone,pd:pd,st:st,buf:buf,deficit:deficit,surplus:surplus,dailyDelta:dailyDelta,net:tdone-pd,pct:pct,ok:tdone>=pd};
}
function dcls(g,i){
var t=tOff(g); if(i>t) return 'dot df';
var v=dTot(g,i);
var 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');
}
function dlbl(g,i){
var t=tOff(g); if(i>t) return String(i+1);
var 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)+'%';
}
// ── API ──────────────────────────────────────────────────────────────────────
function api(method, path, body){
var opts = {method:method, credentials:'include', headers:{'Content-Type':'application/json'}};
if(body) opts.body = JSON.stringify(body);
return fetch('api/' + path, opts).then(function(res){
return res.json().then(function(data){
if(!res.ok){ var e=new Error(data.error||'Fehler'); e.status=res.status; throw e; }
return data;
});
});
}
function loadGoals(){
return api('GET','goals').then(function(data){ return data; });
}
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(function(e){
if(e.status===401){ showLogin(); }
else showToast('Speicherfehler');
});
}
// ── Toast ─────────────────────────────────────────────────────────────────────
function showToast(msg){
var t=document.createElement('div'); t.className='toast'; t.textContent=msg;
document.body.appendChild(t); setTimeout(function(){t.remove();},3000);
}
// ── Goal-Aktionen ─────────────────────────────────────────────────────────────
function addSet(gid,off){
var g=goals.filter(function(x){return x.id===gid;})[0];
if(!g||!editable(g,off)) return;
var k=gid+'_'+off, amt=parseInt(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():'—'});
addAmt[k]=''; saveGoal(g); render();
}
function remSet(gid,off,idx){
var g=goals.filter(function(x){return x.id===gid;})[0];
if(!g||!editable(g,off)) return;
g.sets[String(off)].splice(idx,1); saveGoal(g); render();
}
function delGoal(id){
if(!confirm('Ziel wirklich löschen?')) return;
goals=goals.filter(function(g){return g.id!==id;});
render();
api('DELETE','goals/'+id).catch(function(){ showToast('Fehler beim Löschen'); });
}
function selD(gid,off){
var g=goals.filter(function(x){return x.id===gid;})[0];
if(!g||!editable(g,off)) return;
selDay[gid]=selDay[gid]===off?null:off; render();
}
function startRen(id){
var g=goals.filter(function(x){return x.id===id;})[0]; if(!g) return;
renamingId=id; renameVal=g.name; render();
setTimeout(function(){ var el=document.getElementById('ri'+id); if(el){el.focus();el.select();} },50);
}
function commitRen(id){
var g=goals.filter(function(x){return x.id===id;})[0];
if(g&&renameVal.trim()){g.name=renameVal.trim(); saveGoal(g);}
renamingId=null; render();
}
function cancelRen(){ renamingId=null; render(); }
// ── Template-Helper ───────────────────────────────────────────────────────────
function tpl(id){
return document.getElementById(id).content.cloneNode(true).firstElementChild;
}
// ── Overlays ──────────────────────────────────────────────────────────────────
var 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';
function closeOv(){
var o=document.getElementById('ov');
o.style.display='none';
o.innerHTML='';
}
function showSheet(content, dismissable){
var o=document.getElementById('ov');
o.style.cssText=OV_CSS;
var sheet=tpl('tpl-sheet');
sheet.appendChild(content);
o.innerHTML='';
o.appendChild(sheet);
o.onclick=dismissable!==false?function(e){if(e.target===o)closeOv();}:null;
}
// ── Login ─────────────────────────────────────────────────────────────────────
function showLogin(err){
var c=tpl('tpl-login');
if(err){ var e=c.querySelector('.login-err'); e.textContent=err; e.style.display=''; }
showSheet(c,false);
var email=c.querySelector('.lf-email'), pass=c.querySelector('.lf-pass'), sub=c.querySelector('.lf-sub');
setTimeout(function(){email.focus();},50);
email.onkeydown=function(e){if(e.key==='Enter')pass.focus();};
pass.onkeydown=function(e){if(e.key==='Enter')sub.click();};
c.querySelector('.lf-fgt').onclick=function(){showForgotPassword();};
sub.onclick=function(){
var ev=email.value.trim(), pv=pass.value;
if(!ev||!pv){ var errEl=c.querySelector('.login-err'); errEl.textContent='Bitte E-Mail und Passwort eingeben'; errEl.style.display=''; return; }
sub.disabled=true; sub.textContent='…';
api('POST','login',{email:ev,password:pv})
.then(function(){ return loadGoals(); })
.then(function(g){ goals=g; closeOv(); render(); })
.catch(function(err){
sub.disabled=false; sub.textContent='Anmelden';
showLogin(err.status===401?'Falsche E-Mail oder Passwort':err.status===429?'Zu viele Versuche':'Verbindungsfehler');
});
};
}
// ── Passwort vergessen ────────────────────────────────────────────────────────
function showForgotPassword(){
var c=tpl('tpl-forgot-pw');
showSheet(c,false);
var email=c.querySelector('.fp-email'), errEl=c.querySelector('.login-err'), sub=c.querySelector('.fp-sub');
setTimeout(function(){email.focus();},50);
c.querySelector('.fp-back').onclick=function(){showLogin();};
sub.onclick=function(){
var ev=email.value.trim(); if(!ev) return;
sub.disabled=true; sub.textContent='…';
api('POST','reset-request',{email:ev})
.then(function(){
var conf=tpl('tpl-email-sent');
conf.querySelector('.es-ok').onclick=function(){showLogin();};
showSheet(conf,false);
})
.catch(function(err){
sub.disabled=false; sub.textContent='Link senden';
errEl.textContent=err.message||'Fehler'; errEl.style.display='';
});
};
}
// ── Passwort zurücksetzen ─────────────────────────────────────────────────────
function showResetPassword(selector,token){
var c=tpl('tpl-reset-pw');
showSheet(c,false);
var pass=c.querySelector('.rp-pass'), errEl=c.querySelector('.login-err'), sub=c.querySelector('.rp-sub');
setTimeout(function(){pass.focus();},50);
sub.onclick=function(){
var pv=pass.value; if(!pv) return;
sub.disabled=true; sub.textContent='…';
api('POST','reset-password',{selector:selector,token:token,password:pv})
.then(function(){
var conf=tpl('tpl-pw-changed');
conf.querySelector('.pc-ok').onclick=function(){showLogin();};
showSheet(conf,false);
})
.catch(function(err){
sub.disabled=false; sub.textContent='Passwort setzen';
errEl.textContent=err.message||'Fehler'; errEl.style.display='';
});
};
}
// ── Passwort ändern ───────────────────────────────────────────────────────────
function showChangePassword(){
var c=tpl('tpl-change-pw');
showSheet(c,true);
var oldP=c.querySelector('.cp-old'), newP=c.querySelector('.cp-new'), newP2=c.querySelector('.cp-new2');
var errEl=c.querySelector('.login-err'), sub=c.querySelector('.cp-sub');
setTimeout(function(){oldP.focus();},50);
c.querySelector('.cp-can').onclick=closeOv;
sub.onclick=function(){
var o=oldP.value, n=newP.value, n2=newP2.value;
if(!o||!n||!n2) return;
if(n!==n2){ errEl.textContent='Die neuen Passwörter stimmen nicht überein'; errEl.style.display=''; return; }
sub.disabled=true; sub.textContent='…';
api('POST','change-password',{old_password:o,new_password:n})
.then(function(){ showToast('Passwort geändert'); closeOv(); })
.catch(function(err){
sub.disabled=false; sub.textContent='Ändern';
errEl.textContent=err.message||'Fehler'; errEl.style.display='';
});
};
}
// ── Registrierung ─────────────────────────────────────────────────────────────
function showRegister(token){
var c=tpl('tpl-register');
showSheet(c,false);
var nameInp=c.querySelector('.rg-name'), email=c.querySelector('.rg-email');
var pass=c.querySelector('.rg-pass'), pass2=c.querySelector('.rg-pass2');
var errEl=c.querySelector('.login-err'), sub=c.querySelector('.rg-sub');
setTimeout(function(){nameInp.focus();},50);
nameInp.onkeydown=function(e){if(e.key==='Enter')email.focus();};
email.onkeydown=function(e){if(e.key==='Enter')pass.focus();};
pass.onkeydown=function(e){if(e.key==='Enter')pass2.focus();};
pass2.onkeydown=function(e){if(e.key==='Enter')sub.click();};
function checkMatch(){ if(pass2.value&&pass.value!==pass2.value){ errEl.textContent='Passwörter stimmen nicht überein'; errEl.style.display=''; } else { errEl.style.display='none'; } }
pass.oninput=checkMatch; pass2.oninput=checkMatch;
sub.onclick=function(){
var nv=nameInp.value.trim(), ev=email.value.trim(), pv=pass.value;
if(!nv||!ev||!pv){ errEl.textContent='Bitte alle Felder ausfüllen'; errEl.style.display=''; return; }
if(pv!==pass2.value){ errEl.textContent='Passwörter stimmen nicht überein'; errEl.style.display=''; return; }
sub.disabled=true; sub.textContent='…';
api('POST','register',{name:nv,email:ev,password:pv,token:token})
.then(function(r){ userName=r.name||''; return loadGoals(); })
.then(function(g){ goals=g; closeOv(); updateHeader(); render(); })
.catch(function(err){
sub.disabled=false; sub.textContent='Registrieren';
errEl.textContent=err.message||'Fehler'; errEl.style.display='';
});
};
}
// ── Neues Ziel ────────────────────────────────────────────────────────────────
function openNew(){
var c=tpl('tpl-new-goal');
showSheet(c,true);
var name=c.querySelector('.ng-name'), unit=c.querySelector('.ng-unit');
var daily=c.querySelector('.ng-daily'), days=c.querySelector('.ng-days'), sub=c.querySelector('.ng-sub');
setTimeout(function(){name.focus();},50);
c.querySelector('.ng-can').onclick=closeOv;
sub.onclick=function(){
var nv=(name.value||'').trim(), uv=(unit.value||'').trim()||'Stück';
var dv=parseInt(daily.value,10)||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:TODAY.toISOString()})
.then(function(r){
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(function(e){
sub.disabled=false;
if(e.status===401){ closeOv(); showLogin(); }
else showToast('Fehler beim Erstellen');
});
};
}
// ── Daten-Menü ────────────────────────────────────────────────────────────────
function openData(){
var c=tpl('tpl-data-menu');
showSheet(c,true);
c.querySelector('.dm-cls').onclick=closeOv;
c.querySelector('.dm-name').onclick=function(){
var nc=tpl('tpl-change-name');
showSheet(nc,true);
var inp=nc.querySelector('.cn-name'), errEl=nc.querySelector('.login-err'), sub=nc.querySelector('.cn-sub');
inp.value=userName;
setTimeout(function(){inp.focus();inp.select();},50);
nc.querySelector('.cn-can').onclick=closeOv;
sub.onclick=function(){
var nv=inp.value.trim();
if(!nv){ errEl.textContent='Name darf nicht leer sein'; errEl.style.display=''; return; }
sub.disabled=true; sub.textContent='…';
api('PATCH','me',{name:nv})
.then(function(r){ userName=r.name; closeOv(); render(); showToast('Name gespeichert'); })
.catch(function(){ sub.disabled=false; sub.textContent='Speichern'; showToast('Fehler beim Speichern'); });
};
};
c.querySelector('.dm-cpw').onclick=function(){ closeOv(); showChangePassword(); };
c.querySelector('.dm-lgout').onclick=function(){
api('POST','logout').then(function(){ goals=[]; closeOv(); render(); showLogin(); });
};
c.querySelector('.dm-inv').onclick=function(){
var ic=tpl('tpl-invite-form');
showSheet(ic,true);
var invName=ic.querySelector('.inv-name');
setTimeout(function(){invName.focus();},50);
ic.querySelector('.inv-cancel').onclick=closeOv;
ic.querySelector('.inv-gen').onclick=function(){
var note=invName.value.trim(), btn=this;
btn.disabled=true; btn.textContent='…';
api('POST','invite',{note:note}).then(function(res){
var lc=tpl('tpl-invite-link');
lc.querySelector('.stitle').textContent='Einladungslink'+(note?' für '+note:'');
var urlInp=lc.querySelector('.il-url');
urlInp.value=res.url;
showSheet(lc,true);
lc.querySelector('.il-close').onclick=closeOv;
lc.querySelector('.il-copy').onclick=function(){
navigator.clipboard.writeText(res.url).then(function(){ showToast('Link kopiert!'); closeOv(); });
};
setTimeout(function(){urlInp.select();},50);
}).catch(function(){
btn.disabled=false; btn.textContent='Link generieren';
showToast('Fehler beim Generieren');
});
};
};
c.querySelector('.dm-invlist').onclick=function(){
api('GET','invites').then(function(list){
var statusLabel={'pending':'Ausstehend','used':'Angenommen','expired':'Abgelaufen'};
var statusColor={'pending':'var(--amber)','used':'var(--green)','expired':'var(--red)'};
var lc=tpl('tpl-invite-list');
var body=lc.querySelector('.dpanel-body');
if(!list.length){
var empty=document.createElement('div');
empty.className='nosets'; empty.style.padding='16px';
empty.textContent='Noch keine Einladungen verschickt';
body.appendChild(empty);
} else {
for(var i=0;i<list.length;i++){
var inv=list[i];
var label=inv.note||new Date(inv.created_at).toLocaleDateString('de-DE',{day:'numeric',month:'short',year:'numeric'});
var detail=inv.used_by_email?('→ '+inv.used_by_email):(inv.status==='pending'?'läuft ab: '+new Date(inv.expires_at).toLocaleDateString('de-DE',{day:'numeric',month:'short'}):'');
var row=tpl('tpl-invite-row');
row.querySelector('.ir-label').textContent=label;
if(detail) row.querySelector('.ir-detail').textContent=' '+detail;
var st=row.querySelector('.ir-status');
st.textContent=statusLabel[inv.status]; st.style.color=statusColor[inv.status];
if(inv.url){
var cp=row.querySelector('.ir-copy'); cp.style.display='';
cp.onclick=function(url){ return function(){ navigator.clipboard.writeText(url).then(function(){ showToast('Link kopiert!'); }); }; }(inv.url);
}
body.appendChild(row);
}
}
showSheet(lc,true);
lc.querySelector('.il-close').onclick=closeOv;
}).catch(function(){ showToast('Fehler beim Laden'); });
};
c.querySelector('.dm-exp').onclick=function(){
var blob=new Blob([JSON.stringify({goals:goals,at:new Date().toISOString()},null,2)],{type:'application/json'});
var 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=function(){
var inp=document.createElement('input'); inp.type='file'; inp.accept='.json';
inp.onchange=function(e){
var f=e.target.files[0]; if(!f) return;
var r=new FileReader(); r.onload=function(ev){
try{
var p=JSON.parse(ev.target.result);
if(!p.goals||!Array.isArray(p.goals)) throw new Error('Ungültiges Format');
if(!confirm(p.goals.length+' Ziel(e) importieren?')) return;
var promises=p.goals.map(function(g){
return api('POST','goals',{name:g.name,unit:g.unit,daily:g.daily,days:g.days,start:g.start,sets:g.sets||{}})
.then(function(r){ 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(function(){ closeOv(); render(); alert(p.goals.length+' Ziel(e) importiert.'); });
}catch(err){ alert('Fehler: '+err.message); }
}; r.readAsText(f);
}; inp.click();
};
c.querySelector('.dm-clr').onclick=function(){
if(!confirm('Alle Daten löschen?')) return;
var ids=goals.map(function(g){return g.id;}); goals=[]; render();
Promise.all(ids.map(function(id){return api('DELETE','goals/'+id);}))
.catch(function(){ showToast('Fehler beim Löschen'); });
closeOv();
};
}
// ── Card-Bausteine ────────────────────────────────────────────────────────────
function buildNameWrap(g){
if(renamingId===g.id){
var el=tpl('tpl-name-edit');
var inp=el.querySelector('.ren-input');
inp.id='ri'+g.id; inp.value=g.name; inp.dataset.g=g.id;
return el;
}
var 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){
var t=tOff(g), sets=g.sets[String(off)]||[], tot=dTot(g,off);
var lbl=off===t?'Heute':'Gestern', k=g.id+'_'+off;
var el=tpl('tpl-panel');
el.querySelector('.dpanel-title').textContent=lbl+' — '+fd(o2d(g,off));
el.querySelector('.dpanel-sub').textContent=tot+' / '+g.daily+' '+g.unit;
var body=el.querySelector('.dpanel-body');
if(sets.length){
for(var i=0;i<sets.length;i++){
var s=sets[i], row=tpl('tpl-set-row'), span=row.querySelector('span');
if(s.time!=='—'){
var st=document.createElement('span'); st.className='stime'; st.textContent=s.time+' ·';
span.appendChild(st); span.appendChild(document.createTextNode(' '));
}
var strong=document.createElement('strong'); strong.textContent=s.amount;
span.appendChild(strong); span.appendChild(document.createTextNode(' '+g.unit));
var 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'));
}
var addRow=tpl('tpl-add-row');
var inp=addRow.querySelector('.num-in');
inp.placeholder=g.daily; inp.value=addAmt[k]||''; inp.dataset.k=k; inp.dataset.g=g.id; inp.dataset.o=off;
var 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){
var c=calc(g), t=c.tOff;
var 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)';
var bc,bt,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;}
var 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;
var bd=el.querySelector('.card-bd');
bd.insertBefore(buildNameWrap(g),bd.firstElementChild);
var hc=heuteColor(c.tdone,g.daily);
el.querySelector('.m-dr').textContent=c.dr;
el.querySelector('.m-end').textContent=fs(c.end);
var mH=el.querySelector('.m-heute'); mH.textContent=c.tdone+'/'+g.daily; mH.style.color=hc;
el.querySelector('.m-total').textContent=c.done+'/'+c.tot;
var badge=el.querySelector('.badge'); badge.className='badge '+bc; badge.textContent=bt;
var 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;
var 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);
var badge=el.querySelector('.badge'); badge.className='badge '+bc; badge.textContent=bt;
var fill=el.querySelector('.prog-fill'); fill.style.width=c.pct+'%'; fill.style.background=fc;
el.querySelector('.pr-done').textContent=c.done+' '+g.unit+' gemacht';
el.querySelector('.pr-pct').textContent=c.pct+'% von '+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(function(u){ u.textContent=g.unit; });
var sel=selDay[g.id]!=null?selDay[g.id]:t;
var dotsWrap=el.querySelector('.dots-wrap');
for(var i=0;i<g.days;i++){
var it=i===t, iy=i===t-1, is=sel===i, ed=editable(g,i);
var 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;
}
// ── Quick-Buchen ──────────────────────────────────────────────────────────────
function buildQuickBook(){
var active=goals.filter(function(g){ var c=calc(g); return tOff(g)<g.days&&!c.ok; });
if(!active.length) return null;
var frag=document.createDocumentFragment();
var lbl=document.createElement('div'); lbl.className='sec-lbl'; lbl.textContent='Quick-Buchen';
frag.appendChild(lbl);
var card=document.createElement('div'); card.className='card qb-card';
for(var i=0;i<active.length;i++){
var g=active[i], c=calc(g), k=g.id+'_'+c.tOff;
var row=tpl('tpl-qb-row');
row.querySelector('.qb-name').textContent=g.name;
var stat=row.querySelector('.qb-stat'); stat.textContent=c.tdone+'/'+g.daily; stat.style.color=heuteColor(c.tdone,g.daily);
var inp=row.querySelector('.num-in');
inp.placeholder=g.daily; inp.value=addAmt[k]||''; inp.dataset.k=k; inp.dataset.g=g.id; inp.dataset.o=c.tOff;
var btn=row.querySelector('.btn-as'); btn.dataset.g=g.id; btn.dataset.o=c.tOff;
card.appendChild(row);
}
frag.appendChild(card);
return frag;
}
// ── Render ────────────────────────────────────────────────────────────────────
function calcAwards(){
var units=0;
for(var i=0;i<goals.length;i++){
var g=goals[i];
if(tOff(g)>=g.days) units+=Math.floor(g.days/30);
}
var gold=Math.floor(units/25); units%=25;
var silver=Math.floor(units/5); var bronze=units%5;
return{gold:gold,silver:silver,bronze:bronze};
}
function render(){
var m=document.getElementById('main');
var frag=document.createDocumentFragment();
if(!prefs.hd){
var hint=tpl('tpl-hint');
hint.querySelector('.hclose').onclick=function(){ prefs.hd=1; saveP(); hint.remove(); };
frag.appendChild(hint);
}
var aw=calcAwards();
if(aw.gold||aw.silver||aw.bronze){
var awards=document.createElement('div'); awards.className='awards';
var medals=[['🥇',aw.gold],['🥈',aw.silver],['🥉',aw.bronze]];
for(var mi=0;mi<medals.length;mi++){
for(var ai=0;ai<medals[mi][1];ai++){
var sp=document.createElement('span'); sp.className='aw'; sp.textContent=medals[mi][0];
awards.appendChild(sp);
}
}
frag.appendChild(awards);
}
if(!goals.length){
frag.appendChild(tpl('tpl-empty'));
m.innerHTML=''; m.appendChild(frag); wire(); return;
}
if(userName){
var gr=document.createElement('div'); gr.className='greeting'; gr.textContent='Hallo '+userName+'!';
frag.appendChild(gr);
}
var qb=buildQuickBook(); if(qb) frag.appendChild(qb);
var open=[],done=[];
for(var gi=0;gi<goals.length;gi++){
var g=goals[gi], c=calc(g);
if(c.ok) done.push(g); else open.push(g);
}
if(open.length){
var sl=document.createElement('div'); sl.className='sec-lbl'; sl.textContent='Offen';
frag.appendChild(sl);
for(var i=0;i<open.length;i++) frag.appendChild(buildCard(open[i]));
}
if(done.length){
var sl2=document.createElement('div'); sl2.className='sec-lbl'; sl2.textContent='Heute erledigt';
frag.appendChild(sl2);
for(var j=0;j<done.length;j++) frag.appendChild(buildCard(done[j]));
}
m.innerHTML=''; m.appendChild(frag); wire();
}
function wire(){
document.querySelectorAll('.card-hdr[data-g]').forEach(function(el){
el.onclick=function(e){
if(e.target.classList.contains('btn-ren')||e.target.classList.contains('ren-input')) return;
toggleCollapse(this.dataset.g);
};
});
document.querySelectorAll('.btn-ren').forEach(function(b){
b.onclick=function(e){e.stopPropagation();startRen(this.dataset.g);};
});
document.querySelectorAll('.ren-input').forEach(function(inp){
var gid=inp.dataset.g;
inp.oninput=function(){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(function(d){
d.onclick=function(e){e.stopPropagation();selD(this.dataset.g,parseInt(this.dataset.d,10));};
});
document.querySelectorAll('.btn-as').forEach(function(b){
b.onclick=function(){addSet(this.dataset.g,parseInt(this.dataset.o,10));};
});
document.querySelectorAll('.num-in').forEach(function(inp){
var k=inp.dataset.k, g=inp.dataset.g, o=parseInt(inp.dataset.o,10);
inp.oninput=function(){addAmt[k]=this.value;};
inp.onkeydown=function(e){if(e.key==='Enter')addSet(g,o);};
});
document.querySelectorAll('.sdel').forEach(function(b){
b.onclick=function(){remSet(this.dataset.g,parseInt(this.dataset.o,10),parseInt(this.dataset.i,10));};
});
document.querySelectorAll('.btn-del').forEach(function(b){
b.onclick=function(){delGoal(this.dataset.g);};
});
}
// ── Start ─────────────────────────────────────────────────────────────────────
function updateHeader(){
document.getElementById('tlbl').textContent=TODAY.toLocaleDateString('de-DE',{weekday:'long',day:'numeric',month:'long'});
}
document.getElementById('btnNew').onclick=openNew;
document.getElementById('btnData').onclick=openData;
updateHeader();
var _qs=new URLSearchParams(window.location.search);
var inviteToken=_qs.get('invite');
var resetSelector=_qs.get('reset_selector');
var resetToken=_qs.get('reset_token');
if(inviteToken||resetSelector) history.replaceState(null,'',location.pathname);
if(resetSelector&&resetToken){
render(); showResetPassword(resetSelector,resetToken);
} else {
api('GET','me')
.then(function(r){ userName=r.name||''; updateHeader(); return loadGoals(); })
.then(function(g){ goals=g; render(); })
.catch(function(){
render();
if(inviteToken){ showRegister(inviteToken); }
else { showLogin(); }
});
}
function scheduleMidnight(){
var n=new Date();
var ms=new Date(n.getFullYear(),n.getMonth(),n.getDate()+1,0,0,5).getTime()-n.getTime();
setTimeout(function(){
TODAY=new Date();TODAY.setHours(0,0,0,0);selDay={};collapsed={};
updateHeader();render();scheduleMidnight();
},ms);
}
scheduleMidnight();
document.addEventListener('visibilitychange',function(){
if(document.visibilityState==='visible'){
var n=new Date();n.setHours(0,0,0,0);
if(n.getTime()!==TODAY.getTime()){TODAY=n;selDay={};collapsed={};render();scheduleMidnight();}
loadGoals().then(function(g){goals=g;render();}).catch(function(){});
}
});

9
public/index.php Normal file
View file

@ -0,0 +1,9 @@
<?php
use App\Kernel;
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
return static function (array $context) {
return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
};

BIN
public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

113
public/style.css Normal file
View file

@ -0,0 +1,113 @@
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600&family=DM+Mono:wght@400;500&display=swap');
:root{--bg:#f5f4f0;--bg2:#fff;--bg3:#f0eeea;--border:rgba(0,0,0,.07);--border2:rgba(0,0,0,.12);--text:#1a1a1a;--text2:#666;--text3:#aaa;--green:#16a34a;--green-bg:rgba(22,163,74,.08);--blue:#2563eb;--blue-bg:rgba(37,99,235,.08);--amber:#d97706;--amber-bg:rgba(217,119,6,.08);--red:#dc2626;--red-bg:rgba(220,38,38,.08);--r:14px;--rs:8px}
*{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent}
body{font-family:'DM Sans',sans-serif;background:var(--bg);color:var(--text);min-height:100dvh;padding-bottom:80px}
.main-wrap{max-width:480px;margin:0 auto}
.hdr{position:sticky;top:0;z-index:100;background:rgba(245,244,240,.92);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border-bottom:1px solid var(--border);padding:16px 20px 14px;display:flex;align-items:center;justify-content:space-between}
.hdr-title{font-size:18px;font-weight:600;letter-spacing:-.3px}
.hdr-logo{height:100px;width:auto;mix-blend-mode:multiply;display:block}
.hdr-sub{font-size:12px;color:var(--text3);margin-top:1px;font-family:'DM Mono',monospace}
.hdr-btns{display:flex;gap:8px;align-items:center}
.btn-add{width:38px;height:38px;border-radius:50%;background:var(--text);color:var(--bg);border:none;cursor:pointer;font-size:22px;display:flex;align-items:center;justify-content:center;font-weight:300;transition:transform .15s}
.btn-add:active{transform:scale(.92)}
.btn-menu{width:38px;height:38px;border-radius:50%;background:var(--bg3);color:var(--text2);border:1px solid var(--border);cursor:pointer;font-size:20px;display:flex;align-items:center;justify-content:center;transition:transform .15s}
.btn-menu:active{transform:scale(.92)}
.main{padding:16px 16px 0}
.greeting{font-size:17px;font-weight:600;letter-spacing:-.2px;padding:0 2px 10px}
.sec-lbl{font-size:10px;color:var(--text3);text-transform:uppercase;letter-spacing:.6px;font-family:'DM Mono',monospace;margin-bottom:6px;margin-top:4px;padding:0 2px}
.empty{text-align:center;padding:60px 20px;color:var(--text3);font-size:14px;line-height:1.8}
.card{background:var(--bg2);border:1px solid var(--border);border-radius:var(--r);margin-bottom:8px;overflow:hidden}
.card.done{opacity:.7}
.card-hdr{padding:14px 16px 12px;display:flex;align-items:center;justify-content:space-between;gap:10px;cursor:pointer;user-select:none}
.name-wrap{display:flex;align-items:center;gap:6px}
.goal-name{font-size:16px;font-weight:600;letter-spacing:-.2px}
.btn-ren{background:none;border:none;color:var(--text3);cursor:pointer;font-size:14px;padding:2px 4px;line-height:1;flex-shrink:0}
.btn-ren:active{color:var(--blue)}
.ren-input{font-family:'DM Sans',sans-serif;font-size:16px;font-weight:600;background:var(--bg3);border:1px solid var(--blue);border-radius:6px;color:var(--text);padding:2px 8px;width:100%;letter-spacing:-.2px}
.ren-input:focus{outline:none}
.goal-meta{font-size:11px;color:var(--text3);margin-top:3px;font-family:'DM Mono',monospace;line-height:1.5}
.chevron{font-size:13px;color:var(--text3);margin-left:4px;flex-shrink:0}
.badge{font-size:10px;font-weight:600;padding:3px 8px;border-radius:99px;white-space:nowrap;flex-shrink:0;letter-spacing:.3px;text-transform:uppercase}
.b-ok{background:var(--green-bg);color:var(--green)}.b-done{background:var(--green-bg);color:var(--green)}.b-warn{background:var(--amber-bg);color:var(--amber)}.b-danger{background:var(--red-bg);color:var(--red)}.b-buf{background:var(--blue-bg);color:var(--blue)}
.prog-wrap{padding:0 16px 12px}
.prog-track{height:4px;background:var(--bg3);border-radius:99px;overflow:hidden}
.prog-fill{height:100%;border-radius:99px;transition:width .4s ease}
.prog-row{display:flex;justify-content:space-between;font-size:11px;color:var(--text3);margin-top:5px;font-family:'DM Mono',monospace}
.heute-stats{padding:0 16px 14px}
.heute-group{background:var(--bg3);border-radius:var(--rs);padding:8px 8px 6px}
.heute-lbl{font-size:9px;color:var(--text3);text-transform:uppercase;letter-spacing:.5px;text-align:center;margin-bottom:6px;font-family:'DM Mono',monospace}
.heute-inner{display:grid;grid-template-columns:1fr 1fr 1fr;gap:5px}
.stat{background:var(--bg2);border-radius:6px;padding:7px 6px 5px;text-align:center}
.slbl{font-size:9px;color:var(--text3);margin-bottom:2px}
.sval{font-size:14px;font-weight:600;font-family:'DM Mono',monospace}
.sunit{font-size:8px;color:var(--text3)}
.dots-sec{padding:0 16px 12px}
.dots-lbl{font-size:11px;color:var(--text3);margin-bottom:6px}
.dots-wrap{display:flex;gap:3px;flex-wrap:wrap}
.dot{width:26px;height:26px;border-radius:5px;display:flex;align-items:center;justify-content:center;font-size:8px;font-weight:600;font-family:'DM Mono',monospace;transition:opacity .15s}
.df{background:var(--bg3);color:var(--text3)}
.dm{background:rgba(220,38,38,.12);color:var(--red)}
.dp{background:rgba(217,119,6,.12);color:var(--amber)}
.dd{background:rgba(22,163,74,.12);color:var(--green)}
.db{background:rgba(37,99,235,.12);color:var(--blue)}
.de{cursor:pointer}.de:active{opacity:.6;transform:scale(.9)}.dl{opacity:.6;cursor:default}
.rt{box-shadow:0 0 0 2px var(--text2)}.ry{box-shadow:0 0 0 2px var(--text3)}.rs{box-shadow:0 0 0 2px var(--blue) !important}
.legend{display:flex;gap:10px;flex-wrap:wrap;margin-top:8px}
.leg{display:flex;align-items:center;gap:4px;font-size:10px;color:var(--text3)}
.ldot{width:8px;height:8px;border-radius:2px}
.dpanel{margin:0 16px 14px;background:var(--bg3);border-radius:var(--rs);overflow:hidden;border:1px solid var(--border2)}
.dpanel-hdr{padding:10px 12px;display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid var(--border)}
.dpanel-title{font-size:13px;font-weight:600}
.dpanel-sub{font-size:11px;color:var(--text2);font-family:'DM Mono',monospace}
.dpanel-body{padding:10px 12px}
.set-row{display:flex;align-items:center;justify-content:space-between;padding:5px 0;border-bottom:1px solid var(--border);font-size:13px}
.set-row:last-of-type{border-bottom:none}
.stime{font-size:10px;color:var(--text3);font-family:'DM Mono',monospace;margin-right:4px}
.sdel{background:none;border:none;color:var(--text3);cursor:pointer;font-size:16px;padding:2px 6px;border-radius:4px}
.sdel:active{color:var(--red)}
.add-row{display:flex;gap:8px;align-items:center;margin-top:10px}
.num-in{font-family:'DM Mono',monospace;font-size:15px;padding:8px 10px;border-radius:var(--rs);border:1px solid var(--border2);background:var(--bg2);color:var(--text);width:90px;text-align:center}
.num-in:focus{outline:none;border-color:var(--blue)}
.ulbl{font-size:12px;color:var(--text3)}
.btn-as{flex:1;padding:8px;border-radius:var(--rs);background:var(--blue-bg);color:var(--blue);border:1px solid rgba(37,99,235,.2);cursor:pointer;font-family:'DM Sans',sans-serif;font-size:13px;font-weight:600}
.btn-as:active{opacity:.7}
.nosets{font-size:12px;color:var(--text3);padding:4px 0 8px}
.card-foot{padding:8px 16px 12px;display:flex;justify-content:flex-end}
.btn-del{background:none;border:none;color:var(--text3);font-size:12px;cursor:pointer;padding:4px 0;font-family:'DM Sans',sans-serif}
.btn-del:active{color:var(--red)}
.qb-card{padding:0 16px}
.qb-row{display:flex;align-items:center;gap:8px;padding:10px 0;border-bottom:1px solid var(--border)}
.qb-row:last-child{border-bottom:none}
.qb-name{flex:1;font-size:16px;font-weight:600;letter-spacing:-.2px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;min-width:0}
.qb-stat{font-size:11px;font-family:'DM Mono',monospace;color:var(--text3);white-space:nowrap}
.qb-row .num-in{width:62px;padding:6px 6px;font-size:13px}
.qb-row .btn-as{flex:none;padding:8px 14px;font-size:13px}
.ov{position:fixed;inset:0;z-index:200;background:rgba(0,0,0,.5);display:flex;align-items:flex-end;animation:fi .2s ease}
@keyframes fi{from{opacity:0}to{opacity:1}}
.sheet{width:100%;max-width:480px;background:var(--bg2);border-radius:20px 20px 0 0;border:1px solid var(--border2);padding:20px 20px 40px;animation:su .25s ease;max-height:90dvh;overflow-y:auto}
@keyframes su{from{transform:translateY(100%)}to{transform:translateY(0)}}
.shandle{width:36px;height:4px;border-radius:2px;background:var(--border2);margin:0 auto 20px}
.stitle{font-size:17px;font-weight:600;margin-bottom:4px}
.ssub{font-size:12px;color:var(--text3);margin-bottom:16px}
.fgrid{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:10px}
.ff{margin-bottom:10px}
.ff label{font-size:11px;color:var(--text3);display:block;margin-bottom:4px;text-transform:uppercase;letter-spacing:.5px}
.fi{width:100%;font-family:'DM Sans',sans-serif;font-size:15px;padding:10px 12px;border-radius:var(--rs);border:1px solid var(--border2);background:var(--bg3);color:var(--text)}
.fi:focus{outline:none;border-color:var(--blue)}
.factions{display:flex;gap:8px;margin-top:16px}
.btn-p{flex:1;padding:13px;border-radius:var(--rs);background:var(--text);color:var(--bg);border:none;cursor:pointer;font-family:'DM Sans',sans-serif;font-size:15px;font-weight:600}
.btn-p:active{opacity:.8}
.btn-c{padding:13px 20px;border-radius:var(--rs);background:var(--bg3);color:var(--text2);border:1px solid var(--border);cursor:pointer;font-family:'DM Sans',sans-serif;font-size:15px}
.dbtn{width:100%;padding:13px 16px;border-radius:var(--rs);background:var(--bg3);border:1px solid var(--border);color:var(--text);font-family:'DM Sans',sans-serif;font-size:15px;text-align:left;cursor:pointer;margin-bottom:8px;display:flex;align-items:center;gap:12px}
.dbtn:active{background:var(--border2)}
.dico{font-size:18px;width:24px;text-align:center;flex-shrink:0}
.dlbl{flex:1}.dsub{font-size:11px;color:var(--text3);display:block;margin-top:1px}
.ddanger{color:var(--red)}
.ddiv{height:1px;background:var(--border);margin:12px 0}
.awards{display:flex;gap:4px;align-items:center;flex-wrap:wrap;padding:0 16px 10px}
.aw{font-size:24px;line-height:1}
.login-err{background:var(--red-bg);color:var(--red);border-radius:var(--rs);padding:10px 12px;font-size:13px;margin-bottom:12px}
.toast{position:fixed;bottom:100px;left:50%;transform:translateX(-50%);background:#1a1a1a;color:#fff;padding:10px 16px;border-radius:8px;font-size:13px;z-index:300;white-space:nowrap;animation:fi .2s ease}
.hint{margin:0 16px 12px;background:var(--blue-bg);border:1px solid rgba(37,99,235,.15);border-radius:var(--rs);padding:10px 12px;font-size:12px;color:var(--blue);line-height:1.5;display:flex;justify-content:space-between;align-items:center;gap:8px}
.hclose{background:none;border:none;color:var(--blue);cursor:pointer;font-size:16px;padding:0 2px}
.btn-lnk{background:none;border:none;color:var(--text2);font-size:13px;cursor:pointer;padding:4px 8px;text-decoration:underline;text-underline-offset:2px}

0
src/Controller/.gitignore vendored Normal file
View file

View file

@ -0,0 +1,16 @@
<?php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class AppController extends AbstractController
{
#[Route('/{path}', name: 'app', requirements: ['path' => '.*'], priority: -10)]
public function index(): Response
{
return $this->render('app.html.twig');
}
}

View file

@ -0,0 +1,212 @@
<?php
namespace App\Controller;
use App\Entity\User;
use App\Repository\InviteRepository;
use App\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
#[Route('/api')]
class AuthController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $em,
private readonly UserRepository $users,
) {}
#[Route('/me', methods: ['GET'])]
public function me(): JsonResponse
{
$user = $this->getUser();
if (!$user instanceof User) {
return new JsonResponse(['ok' => false], Response::HTTP_UNAUTHORIZED);
}
return new JsonResponse([
'ok' => true,
'email' => $user->getEmail(),
'id' => $user->getId(),
'name' => $user->getUsername() ?? '',
]);
}
#[Route('/me', methods: ['PATCH'])]
public function updateMe(Request $request): JsonResponse
{
$user = $this->getUser();
if (!$user instanceof User) {
return new JsonResponse(['error' => 'Unauthorized'], Response::HTTP_UNAUTHORIZED);
}
$data = json_decode($request->getContent(), true) ?? [];
$name = trim($data['name'] ?? '');
if (!$name) {
return new JsonResponse(['error' => 'Name fehlt'], Response::HTTP_BAD_REQUEST);
}
$user->setUsername($name);
$this->em->flush();
return new JsonResponse(['ok' => true, 'name' => $name]);
}
#[Route('/logout', methods: ['POST'])]
public function logout(): never
{
throw new \LogicException('Handled by firewall.');
}
#[Route('/register', methods: ['POST'])]
public function register(
Request $request,
InviteRepository $invites,
UserPasswordHasherInterface $hasher,
): JsonResponse {
$data = json_decode($request->getContent(), true) ?? [];
$email = trim($data['email'] ?? '');
$password = $data['password'] ?? '';
$token = trim($data['token'] ?? '');
$name = trim($data['name'] ?? '');
if (!$email || !$password || !$token) {
return new JsonResponse(['error' => 'Fehlende Felder'], Response::HTTP_BAD_REQUEST);
}
if (strlen($password) < 8) {
return new JsonResponse(['error' => 'Passwort zu kurz (min. 8 Zeichen)'], Response::HTTP_BAD_REQUEST);
}
$invite = $invites->findPendingByToken($token);
if (!$invite) {
return new JsonResponse(['error' => 'Ungültiger oder abgelaufener Einladungslink'], Response::HTTP_BAD_REQUEST);
}
if ($this->users->findOneBy(['email' => $email])) {
return new JsonResponse(['error' => 'E-Mail bereits registriert'], Response::HTTP_CONFLICT);
}
$user = new User();
$user->setEmail($email)
->setPassword($hasher->hashPassword($user, $password))
->setUsername($name ?: null)
->setVerified(true);
$this->em->persist($user);
$this->em->flush(); // flush first to get user ID
$invite->setUsedBy($user->getId());
$invite->setUsedAt(new \DateTimeImmutable());
$this->em->flush();
return new JsonResponse(['ok' => true, 'email' => $email, 'name' => $name]);
}
#[Route('/reset-request', methods: ['POST'])]
public function resetRequest(Request $request, MailerInterface $mailer): JsonResponse
{
$data = json_decode($request->getContent(), true) ?? [];
$email = trim($data['email'] ?? '');
if (!$email) {
return new JsonResponse(['error' => 'E-Mail fehlt'], Response::HTTP_BAD_REQUEST);
}
$user = $this->users->findOneBy(['email' => $email]);
if ($user) {
$selector = bin2hex(random_bytes(12));
$token = bin2hex(random_bytes(32));
$hash = password_hash($token, PASSWORD_BCRYPT);
$expires = time() + 86400;
$this->em->getConnection()->executeStatement(
'INSERT INTO users_resets (user, selector, token, expires) VALUES (?, ?, ?, ?)',
[$user->getId(), $selector, $hash, $expires]
);
$appUrl = $_ENV['APP_URL'] ?? 'http://localhost';
$url = $appUrl . '/?reset_selector=' . rawurlencode($selector) . '&reset_token=' . rawurlencode($token);
$from = $_ENV['MAILER_FROM'] ?? 'noreply@example.com';
$mail = (new Email())
->from($from)
->to($email)
->subject('Dudi Passwort zurücksetzen')
->text("Hallo,\n\nklicke auf den folgenden Link um dein Passwort zurückzusetzen (gültig 24h):\n\n$url\n\nFalls du das nicht angefragt hast, ignoriere diese E-Mail.");
try {
$mailer->send($mail);
} catch (\Exception) {
return new JsonResponse(['error' => 'Mail konnte nicht gesendet werden'], Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
return new JsonResponse(['ok' => true]);
}
#[Route('/reset-password', methods: ['POST'])]
public function resetPassword(Request $request, UserPasswordHasherInterface $hasher): JsonResponse
{
$data = json_decode($request->getContent(), true) ?? [];
$selector = $data['selector'] ?? '';
$token = $data['token'] ?? '';
$password = $data['password'] ?? '';
if (!$selector || !$token || !$password) {
return new JsonResponse(['error' => 'Fehlende Felder'], Response::HTTP_BAD_REQUEST);
}
$row = $this->em->getConnection()->fetchAssociative(
'SELECT * FROM users_resets WHERE selector = ? AND expires > ?',
[$selector, time()]
);
if (!$row || !password_verify($token, $row['token'])) {
return new JsonResponse(['error' => 'Ungültiger oder abgelaufener Reset-Link'], Response::HTTP_BAD_REQUEST);
}
if (strlen($password) < 8) {
return new JsonResponse(['error' => 'Passwort zu kurz (min. 8 Zeichen)'], Response::HTTP_BAD_REQUEST);
}
$user = $this->users->find($row['user']);
if (!$user) {
return new JsonResponse(['error' => 'Benutzer nicht gefunden'], Response::HTTP_BAD_REQUEST);
}
$user->setPassword($hasher->hashPassword($user, $password));
$this->em->getConnection()->executeStatement('DELETE FROM users_resets WHERE selector = ?', [$selector]);
$this->em->flush();
return new JsonResponse(['ok' => true]);
}
#[Route('/change-password', methods: ['POST'])]
public function changePassword(Request $request, UserPasswordHasherInterface $hasher): JsonResponse
{
$user = $this->getUser();
if (!$user instanceof User) {
return new JsonResponse(['error' => 'Nicht eingeloggt'], Response::HTTP_UNAUTHORIZED);
}
$data = json_decode($request->getContent(), true) ?? [];
$oldPass = $data['old_password'] ?? '';
$newPass = $data['new_password'] ?? '';
if (!$oldPass || !$newPass) {
return new JsonResponse(['error' => 'Fehlende Felder'], Response::HTTP_BAD_REQUEST);
}
if (!$hasher->isPasswordValid($user, $oldPass)) {
return new JsonResponse(['error' => 'Aktuelles Passwort falsch'], Response::HTTP_UNAUTHORIZED);
}
if (strlen($newPass) < 8) {
return new JsonResponse(['error' => 'Neues Passwort zu kurz (min. 8 Zeichen)'], Response::HTTP_BAD_REQUEST);
}
$user->setPassword($hasher->hashPassword($user, $newPass));
$this->em->flush();
return new JsonResponse(['ok' => true]);
}
}

View file

@ -0,0 +1,108 @@
<?php
namespace App\Controller;
use App\Entity\Goal;
use App\Entity\User;
use App\Repository\GoalRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/api/goals')]
class GoalController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $em,
private readonly GoalRepository $goals,
) {}
#[Route('', methods: ['GET'])]
public function list(): JsonResponse
{
$user = $this->getUser();
if (!$user instanceof User) {
return new JsonResponse(['error' => 'Unauthorized'], Response::HTTP_UNAUTHORIZED);
}
$goals = array_map(fn(Goal $g) => $g->toArray(), $this->goals->findByUser($user->getId()));
return new JsonResponse($goals);
}
#[Route('', methods: ['POST'])]
public function create(Request $request): JsonResponse
{
$user = $this->getUser();
if (!$user instanceof User) {
return new JsonResponse(['error' => 'Unauthorized'], Response::HTTP_UNAUTHORIZED);
}
$data = json_decode($request->getContent(), true) ?? [];
$name = trim($data['name'] ?? '');
if (!$name) {
return new JsonResponse(['error' => 'Name fehlt'], Response::HTTP_BAD_REQUEST);
}
try {
$start = new \DateTime($data['start'] ?? 'now');
} catch (\Exception) {
$start = new \DateTime();
}
$goal = new Goal();
$goal->setUserId($user->getId())
->setName($name)
->setUnit(trim($data['unit'] ?? 'Stück') ?: 'Stück')
->setDaily((float)($data['daily'] ?? 1))
->setDays((int)($data['days'] ?? 30))
->setStart($start)
->setSets(is_array($data['sets'] ?? null) ? $data['sets'] : []);
$this->em->persist($goal);
$this->em->flush();
return new JsonResponse($goal->toArray());
}
#[Route('/{id}', methods: ['PATCH'])]
public function update(int $id, Request $request): JsonResponse
{
$user = $this->getUser();
if (!$user instanceof User) {
return new JsonResponse(['error' => 'Unauthorized'], Response::HTTP_UNAUTHORIZED);
}
$goal = $this->goals->findOneBy(['id' => $id, 'userId' => $user->getId()]);
if (!$goal) {
return new JsonResponse(['error' => 'Nicht gefunden'], Response::HTTP_NOT_FOUND);
}
$data = json_decode($request->getContent(), true) ?? [];
if (isset($data['name'])) $goal->setName((string)$data['name']);
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'])) $goal->setSets((array)$data['sets']);
$this->em->flush();
return new JsonResponse(['ok' => true]);
}
#[Route('/{id}', methods: ['DELETE'])]
public function delete(int $id): JsonResponse
{
$user = $this->getUser();
if (!$user instanceof User) {
return new JsonResponse(['error' => 'Unauthorized'], Response::HTTP_UNAUTHORIZED);
}
$goal = $this->goals->findOneBy(['id' => $id, 'userId' => $user->getId()]);
if ($goal) {
$this->em->remove($goal);
$this->em->flush();
}
return new JsonResponse(['ok' => true]);
}
}

View file

@ -0,0 +1,90 @@
<?php
namespace App\Controller;
use App\Entity\Invite;
use App\Entity\User;
use App\Repository\InviteRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/api')]
class InviteController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $em,
private readonly InviteRepository $invites,
) {}
#[Route('/invite', methods: ['POST'])]
public function create(Request $request): JsonResponse
{
$user = $this->getUser();
if (!$user instanceof User) {
return new JsonResponse(['error' => 'Unauthorized'], Response::HTTP_UNAUTHORIZED);
}
$data = json_decode($request->getContent(), true) ?? [];
$note = trim($data['note'] ?? '') ?: null;
$token = bin2hex(random_bytes(32));
$invite = new Invite();
$invite->setToken($token)
->setNote($note)
->setCreatedBy($user->getId())
->setExpiresAt(new \DateTimeImmutable('+7 days'));
$this->em->persist($invite);
$this->em->flush();
$appUrl = $_ENV['APP_URL'] ?? 'http://localhost';
return new JsonResponse(['url' => $appUrl . '/?invite=' . $token]);
}
#[Route('/invites', methods: ['GET'])]
public function list(): JsonResponse
{
$user = $this->getUser();
if (!$user instanceof User) {
return new JsonResponse(['error' => 'Unauthorized'], Response::HTTP_UNAUTHORIZED);
}
$rows = $this->invites->findByCreator($user->getId());
$appUrl = $_ENV['APP_URL'] ?? 'http://localhost';
$result = array_map(function (Invite $inv) use ($appUrl) {
$pending = $inv->isPending();
$usedByEmail = null;
if ($inv->getUsedBy()) {
$usedByEmail = $this->em->getConnection()->fetchOne(
'SELECT email FROM users WHERE id = ?',
[$inv->getUsedBy()]
) ?: null;
}
if ($usedByEmail !== null) {
$status = 'used';
} elseif (!$pending) {
$status = 'expired';
} else {
$status = 'pending';
}
return [
'url' => $status === 'pending' ? $appUrl . '/?invite=' . $inv->getToken() : null,
'created_at' => $inv->getCreatedAt()->format('Y-m-d H:i:s'),
'expires_at' => $inv->getExpiresAt()->format('Y-m-d H:i:s'),
'used_at' => $inv->getUsedAt()?->format('Y-m-d H:i:s'),
'note' => $inv->getNote(),
'used_by_email' => $usedByEmail,
'status' => $status,
];
}, $rows);
return new JsonResponse($result);
}
}

0
src/Entity/.gitignore vendored Normal file
View file

75
src/Entity/Goal.php Normal file
View file

@ -0,0 +1,75 @@
<?php
namespace App\Entity;
use App\Repository\GoalRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: GoalRepository::class)]
#[ORM\Table(name: 'goals')]
class Goal
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column]
private int $userId;
#[ORM\Column(length: 255)]
private string $name;
#[ORM\Column(length: 50)]
private string $unit = 'Stück';
#[ORM\Column]
private float $daily = 1;
#[ORM\Column]
private int $days = 30;
#[ORM\Column(type: 'datetime')]
private \DateTimeInterface $start;
#[ORM\Column(type: 'json')]
private array $sets = [];
#[ORM\Column(type: 'datetime_immutable', options: ['default' => 'CURRENT_TIMESTAMP'])]
private \DateTimeImmutable $createdAt;
public function __construct()
{
$this->createdAt = new \DateTimeImmutable();
}
public function getId(): ?int { return $this->id; }
public function getUserId(): int { return $this->userId; }
public function setUserId(int $userId): static { $this->userId = $userId; return $this; }
public function getName(): string { return $this->name; }
public function setName(string $name): static { $this->name = $name; return $this; }
public function getUnit(): string { return $this->unit; }
public function setUnit(string $unit): static { $this->unit = $unit; return $this; }
public function getDaily(): float { return $this->daily; }
public function setDaily(float $daily): static { $this->daily = $daily; return $this; }
public function getDays(): int { return $this->days; }
public function setDays(int $days): static { $this->days = $days; return $this; }
public function getStart(): \DateTimeInterface { return $this->start; }
public function setStart(\DateTimeInterface $start): static { $this->start = $start; return $this; }
public function getSets(): array { return $this->sets; }
public function setSets(array $sets): static { $this->sets = $sets; return $this; }
public function getCreatedAt(): \DateTimeImmutable { return $this->createdAt; }
public function toArray(): array
{
return [
'id' => (string) $this->id,
'name' => $this->name,
'unit' => $this->unit,
'daily' => $this->daily,
'days' => $this->days,
'start' => $this->start->format('Y-m-d H:i:s'),
'sets' => $this->sets,
];
}
}

62
src/Entity/Invite.php Normal file
View file

@ -0,0 +1,62 @@
<?php
namespace App\Entity;
use App\Repository\InviteRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: InviteRepository::class)]
#[ORM\Table(name: 'invites')]
class Invite
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 64, unique: true)]
private string $token;
#[ORM\Column(length: 255, nullable: true)]
private ?string $note = null;
#[ORM\Column]
private int $createdBy;
#[ORM\Column(nullable: true)]
private ?int $usedBy = null;
#[ORM\Column(type: 'datetime_immutable', options: ['default' => 'CURRENT_TIMESTAMP'])]
private \DateTimeImmutable $createdAt;
#[ORM\Column(type: 'datetime_immutable')]
private \DateTimeImmutable $expiresAt;
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
private ?\DateTimeImmutable $usedAt = null;
public function __construct()
{
$this->createdAt = new \DateTimeImmutable();
}
public function getId(): ?int { return $this->id; }
public function getToken(): string { return $this->token; }
public function setToken(string $token): static { $this->token = $token; return $this; }
public function getNote(): ?string { return $this->note; }
public function setNote(?string $note): static { $this->note = $note; return $this; }
public function getCreatedBy(): int { return $this->createdBy; }
public function setCreatedBy(int $createdBy): static { $this->createdBy = $createdBy; return $this; }
public function getUsedBy(): ?int { return $this->usedBy; }
public function setUsedBy(?int $usedBy): static { $this->usedBy = $usedBy; return $this; }
public function getCreatedAt(): \DateTimeImmutable { return $this->createdAt; }
public function getExpiresAt(): \DateTimeImmutable { return $this->expiresAt; }
public function setExpiresAt(\DateTimeImmutable $expiresAt): static { $this->expiresAt = $expiresAt; return $this; }
public function getUsedAt(): ?\DateTimeImmutable { return $this->usedAt; }
public function setUsedAt(?\DateTimeImmutable $usedAt): static { $this->usedAt = $usedAt; return $this; }
public function isPending(): bool
{
return $this->usedBy === null && $this->expiresAt > new \DateTimeImmutable();
}
}

59
src/Entity/User.php Normal file
View file

@ -0,0 +1,59 @@
<?php
namespace App\Entity;
use App\Repository\UserRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\Table(name: 'users')]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer', options: ['unsigned' => true])]
private ?int $id = null;
#[ORM\Column(length: 249, unique: true)]
private string $email;
#[ORM\Column(length: 255)]
private string $password;
#[ORM\Column(length: 100, nullable: true)]
private ?string $username = null;
#[ORM\Column(type: 'boolean', options: ['unsigned' => true, 'default' => 0])]
private bool $verified = false;
#[ORM\Column(type: 'integer', options: ['unsigned' => true])]
private int $registered;
#[ORM\Column(type: 'integer', nullable: true, options: ['unsigned' => true])]
private ?int $lastLogin = null;
public function __construct()
{
$this->registered = time();
}
public function getId(): ?int { return $this->id; }
public function getEmail(): string { return $this->email; }
public function setEmail(string $email): static { $this->email = $email; return $this; }
public function getPassword(): string { return $this->password; }
public function setPassword(string $password): static { $this->password = $password; return $this; }
public function getUsername(): ?string { return $this->username; }
public function setUsername(?string $username): static { $this->username = $username; return $this; }
public function isVerified(): bool { return $this->verified; }
public function setVerified(bool $verified): static { $this->verified = $verified; return $this; }
public function getRegistered(): int { return $this->registered; }
public function getLastLogin(): ?int { return $this->lastLogin; }
public function setLastLogin(?int $lastLogin): static { $this->lastLogin = $lastLogin; return $this; }
// UserInterface
public function getUserIdentifier(): string { return $this->email; }
public function getRoles(): array { return ['ROLE_USER']; }
public function eraseCredentials(): void {}
}

11
src/Kernel.php Normal file
View file

@ -0,0 +1,11 @@
<?php
namespace App;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
class Kernel extends BaseKernel
{
use MicroKernelTrait;
}

0
src/Repository/.gitignore vendored Normal file
View file

View file

@ -0,0 +1,21 @@
<?php
namespace App\Repository;
use App\Entity\Goal;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class GoalRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Goal::class);
}
/** @return Goal[] */
public function findByUser(int $userId): array
{
return $this->findBy(['userId' => $userId], ['createdAt' => 'ASC']);
}
}

View file

@ -0,0 +1,33 @@
<?php
namespace App\Repository;
use App\Entity\Invite;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class InviteRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Invite::class);
}
public function findPendingByToken(string $token): ?Invite
{
return $this->createQueryBuilder('i')
->where('i.token = :token')
->andWhere('i.usedBy IS NULL')
->andWhere('i.expiresAt > :now')
->setParameter('token', $token)
->setParameter('now', new \DateTimeImmutable())
->getQuery()
->getOneOrNullResult();
}
/** @return Invite[] */
public function findByCreator(int $userId): array
{
return $this->findBy(['createdBy' => $userId], ['createdAt' => 'DESC']);
}
}

View file

@ -0,0 +1,27 @@
<?php
namespace App\Repository;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;
class UserRepository extends ServiceEntityRepository implements PasswordUpgraderInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, User::class);
}
public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void
{
if (!$user instanceof User) {
throw new UnsupportedUserException(sprintf('Instances of "%s" are not supported.', $user::class));
}
$user->setPassword($newHashedPassword);
$this->getEntityManager()->flush();
}
}

View file

@ -0,0 +1,55 @@
<?php
namespace App\Security;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\RememberMeBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\PasswordCredentials;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
class JsonLoginAuthenticator extends AbstractAuthenticator
{
public function supports(Request $request): ?bool
{
return $request->getPathInfo() === '/api/login' && $request->isMethod('POST');
}
public function authenticate(Request $request): Passport
{
$data = json_decode($request->getContent(), true) ?? [];
$email = trim($data['email'] ?? '');
$password = $data['password'] ?? '';
if (!$email || !$password) {
throw new BadCredentialsException('Falsche E-Mail oder Passwort');
}
return new Passport(
new UserBadge($email),
new PasswordCredentials($password),
[new RememberMeBadge()]
);
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
$user = $token->getUser();
return new JsonResponse([
'ok' => true,
'email' => $user->getUserIdentifier(),
'name' => $user instanceof \App\Entity\User ? ($user->getUsername() ?? '') : '',
]);
}
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
{
return new JsonResponse(['error' => 'Falsche E-Mail oder Passwort'], Response::HTTP_UNAUTHORIZED);
}
}

113
style.css Normal file
View file

@ -0,0 +1,113 @@
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600&family=DM+Mono:wght@400;500&display=swap');
:root{--bg:#f5f4f0;--bg2:#fff;--bg3:#f0eeea;--border:rgba(0,0,0,.07);--border2:rgba(0,0,0,.12);--text:#1a1a1a;--text2:#666;--text3:#aaa;--green:#16a34a;--green-bg:rgba(22,163,74,.08);--blue:#2563eb;--blue-bg:rgba(37,99,235,.08);--amber:#d97706;--amber-bg:rgba(217,119,6,.08);--red:#dc2626;--red-bg:rgba(220,38,38,.08);--r:14px;--rs:8px}
*{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent}
body{font-family:'DM Sans',sans-serif;background:var(--bg);color:var(--text);min-height:100dvh;padding-bottom:80px}
.main-wrap{max-width:480px;margin:0 auto}
.hdr{position:sticky;top:0;z-index:100;background:rgba(245,244,240,.92);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border-bottom:1px solid var(--border);padding:16px 20px 14px;display:flex;align-items:center;justify-content:space-between}
.hdr-title{font-size:18px;font-weight:600;letter-spacing:-.3px}
.hdr-logo{height:100px;width:auto;mix-blend-mode:multiply;display:block}
.hdr-sub{font-size:12px;color:var(--text3);margin-top:1px;font-family:'DM Mono',monospace}
.hdr-btns{display:flex;gap:8px;align-items:center}
.btn-add{width:38px;height:38px;border-radius:50%;background:var(--text);color:var(--bg);border:none;cursor:pointer;font-size:22px;display:flex;align-items:center;justify-content:center;font-weight:300;transition:transform .15s}
.btn-add:active{transform:scale(.92)}
.btn-menu{width:38px;height:38px;border-radius:50%;background:var(--bg3);color:var(--text2);border:1px solid var(--border);cursor:pointer;font-size:20px;display:flex;align-items:center;justify-content:center;transition:transform .15s}
.btn-menu:active{transform:scale(.92)}
.main{padding:16px 16px 0}
.greeting{font-size:17px;font-weight:600;letter-spacing:-.2px;padding:0 2px 10px}
.sec-lbl{font-size:10px;color:var(--text3);text-transform:uppercase;letter-spacing:.6px;font-family:'DM Mono',monospace;margin-bottom:6px;margin-top:4px;padding:0 2px}
.empty{text-align:center;padding:60px 20px;color:var(--text3);font-size:14px;line-height:1.8}
.card{background:var(--bg2);border:1px solid var(--border);border-radius:var(--r);margin-bottom:8px;overflow:hidden}
.card.done{opacity:.7}
.card-hdr{padding:14px 16px 12px;display:flex;align-items:center;justify-content:space-between;gap:10px;cursor:pointer;user-select:none}
.name-wrap{display:flex;align-items:center;gap:6px}
.goal-name{font-size:16px;font-weight:600;letter-spacing:-.2px}
.btn-ren{background:none;border:none;color:var(--text3);cursor:pointer;font-size:14px;padding:2px 4px;line-height:1;flex-shrink:0}
.btn-ren:active{color:var(--blue)}
.ren-input{font-family:'DM Sans',sans-serif;font-size:16px;font-weight:600;background:var(--bg3);border:1px solid var(--blue);border-radius:6px;color:var(--text);padding:2px 8px;width:100%;letter-spacing:-.2px}
.ren-input:focus{outline:none}
.goal-meta{font-size:11px;color:var(--text3);margin-top:3px;font-family:'DM Mono',monospace;line-height:1.5}
.chevron{font-size:13px;color:var(--text3);margin-left:4px;flex-shrink:0}
.badge{font-size:10px;font-weight:600;padding:3px 8px;border-radius:99px;white-space:nowrap;flex-shrink:0;letter-spacing:.3px;text-transform:uppercase}
.b-ok{background:var(--green-bg);color:var(--green)}.b-done{background:var(--green-bg);color:var(--green)}.b-warn{background:var(--amber-bg);color:var(--amber)}.b-danger{background:var(--red-bg);color:var(--red)}.b-buf{background:var(--blue-bg);color:var(--blue)}
.prog-wrap{padding:0 16px 12px}
.prog-track{height:4px;background:var(--bg3);border-radius:99px;overflow:hidden}
.prog-fill{height:100%;border-radius:99px;transition:width .4s ease}
.prog-row{display:flex;justify-content:space-between;font-size:11px;color:var(--text3);margin-top:5px;font-family:'DM Mono',monospace}
.heute-stats{padding:0 16px 14px}
.heute-group{background:var(--bg3);border-radius:var(--rs);padding:8px 8px 6px}
.heute-lbl{font-size:9px;color:var(--text3);text-transform:uppercase;letter-spacing:.5px;text-align:center;margin-bottom:6px;font-family:'DM Mono',monospace}
.heute-inner{display:grid;grid-template-columns:1fr 1fr 1fr;gap:5px}
.stat{background:var(--bg2);border-radius:6px;padding:7px 6px 5px;text-align:center}
.slbl{font-size:9px;color:var(--text3);margin-bottom:2px}
.sval{font-size:14px;font-weight:600;font-family:'DM Mono',monospace}
.sunit{font-size:8px;color:var(--text3)}
.dots-sec{padding:0 16px 12px}
.dots-lbl{font-size:11px;color:var(--text3);margin-bottom:6px}
.dots-wrap{display:flex;gap:3px;flex-wrap:wrap}
.dot{width:26px;height:26px;border-radius:5px;display:flex;align-items:center;justify-content:center;font-size:8px;font-weight:600;font-family:'DM Mono',monospace;transition:opacity .15s}
.df{background:var(--bg3);color:var(--text3)}
.dm{background:rgba(220,38,38,.12);color:var(--red)}
.dp{background:rgba(217,119,6,.12);color:var(--amber)}
.dd{background:rgba(22,163,74,.12);color:var(--green)}
.db{background:rgba(37,99,235,.12);color:var(--blue)}
.de{cursor:pointer}.de:active{opacity:.6;transform:scale(.9)}.dl{opacity:.6;cursor:default}
.rt{box-shadow:0 0 0 2px var(--text2)}.ry{box-shadow:0 0 0 2px var(--text3)}.rs{box-shadow:0 0 0 2px var(--blue) !important}
.legend{display:flex;gap:10px;flex-wrap:wrap;margin-top:8px}
.leg{display:flex;align-items:center;gap:4px;font-size:10px;color:var(--text3)}
.ldot{width:8px;height:8px;border-radius:2px}
.dpanel{margin:0 16px 14px;background:var(--bg3);border-radius:var(--rs);overflow:hidden;border:1px solid var(--border2)}
.dpanel-hdr{padding:10px 12px;display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid var(--border)}
.dpanel-title{font-size:13px;font-weight:600}
.dpanel-sub{font-size:11px;color:var(--text2);font-family:'DM Mono',monospace}
.dpanel-body{padding:10px 12px}
.set-row{display:flex;align-items:center;justify-content:space-between;padding:5px 0;border-bottom:1px solid var(--border);font-size:13px}
.set-row:last-of-type{border-bottom:none}
.stime{font-size:10px;color:var(--text3);font-family:'DM Mono',monospace;margin-right:4px}
.sdel{background:none;border:none;color:var(--text3);cursor:pointer;font-size:16px;padding:2px 6px;border-radius:4px}
.sdel:active{color:var(--red)}
.add-row{display:flex;gap:8px;align-items:center;margin-top:10px}
.num-in{font-family:'DM Mono',monospace;font-size:15px;padding:8px 10px;border-radius:var(--rs);border:1px solid var(--border2);background:var(--bg2);color:var(--text);width:90px;text-align:center}
.num-in:focus{outline:none;border-color:var(--blue)}
.ulbl{font-size:12px;color:var(--text3)}
.btn-as{flex:1;padding:8px;border-radius:var(--rs);background:var(--blue-bg);color:var(--blue);border:1px solid rgba(37,99,235,.2);cursor:pointer;font-family:'DM Sans',sans-serif;font-size:13px;font-weight:600}
.btn-as:active{opacity:.7}
.nosets{font-size:12px;color:var(--text3);padding:4px 0 8px}
.card-foot{padding:8px 16px 12px;display:flex;justify-content:flex-end}
.btn-del{background:none;border:none;color:var(--text3);font-size:12px;cursor:pointer;padding:4px 0;font-family:'DM Sans',sans-serif}
.btn-del:active{color:var(--red)}
.qb-card{padding:0 16px}
.qb-row{display:flex;align-items:center;gap:8px;padding:10px 0;border-bottom:1px solid var(--border)}
.qb-row:last-child{border-bottom:none}
.qb-name{flex:1;font-size:16px;font-weight:600;letter-spacing:-.2px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;min-width:0}
.qb-stat{font-size:11px;font-family:'DM Mono',monospace;color:var(--text3);white-space:nowrap}
.qb-row .num-in{width:62px;padding:6px 6px;font-size:13px}
.qb-row .btn-as{flex:none;padding:8px 14px;font-size:13px}
.ov{position:fixed;inset:0;z-index:200;background:rgba(0,0,0,.5);display:flex;align-items:flex-end;animation:fi .2s ease}
@keyframes fi{from{opacity:0}to{opacity:1}}
.sheet{width:100%;max-width:480px;background:var(--bg2);border-radius:20px 20px 0 0;border:1px solid var(--border2);padding:20px 20px 40px;animation:su .25s ease;max-height:90dvh;overflow-y:auto}
@keyframes su{from{transform:translateY(100%)}to{transform:translateY(0)}}
.shandle{width:36px;height:4px;border-radius:2px;background:var(--border2);margin:0 auto 20px}
.stitle{font-size:17px;font-weight:600;margin-bottom:4px}
.ssub{font-size:12px;color:var(--text3);margin-bottom:16px}
.fgrid{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:10px}
.ff{margin-bottom:10px}
.ff label{font-size:11px;color:var(--text3);display:block;margin-bottom:4px;text-transform:uppercase;letter-spacing:.5px}
.fi{width:100%;font-family:'DM Sans',sans-serif;font-size:15px;padding:10px 12px;border-radius:var(--rs);border:1px solid var(--border2);background:var(--bg3);color:var(--text)}
.fi:focus{outline:none;border-color:var(--blue)}
.factions{display:flex;gap:8px;margin-top:16px}
.btn-p{flex:1;padding:13px;border-radius:var(--rs);background:var(--text);color:var(--bg);border:none;cursor:pointer;font-family:'DM Sans',sans-serif;font-size:15px;font-weight:600}
.btn-p:active{opacity:.8}
.btn-c{padding:13px 20px;border-radius:var(--rs);background:var(--bg3);color:var(--text2);border:1px solid var(--border);cursor:pointer;font-family:'DM Sans',sans-serif;font-size:15px}
.dbtn{width:100%;padding:13px 16px;border-radius:var(--rs);background:var(--bg3);border:1px solid var(--border);color:var(--text);font-family:'DM Sans',sans-serif;font-size:15px;text-align:left;cursor:pointer;margin-bottom:8px;display:flex;align-items:center;gap:12px}
.dbtn:active{background:var(--border2)}
.dico{font-size:18px;width:24px;text-align:center;flex-shrink:0}
.dlbl{flex:1}.dsub{font-size:11px;color:var(--text3);display:block;margin-top:1px}
.ddanger{color:var(--red)}
.ddiv{height:1px;background:var(--border);margin:12px 0}
.awards{display:flex;gap:4px;align-items:center;flex-wrap:wrap;padding:0 16px 10px}
.aw{font-size:24px;line-height:1}
.login-err{background:var(--red-bg);color:var(--red);border-radius:var(--rs);padding:10px 12px;font-size:13px;margin-bottom:12px}
.toast{position:fixed;bottom:100px;left:50%;transform:translateX(-50%);background:#1a1a1a;color:#fff;padding:10px 16px;border-radius:8px;font-size:13px;z-index:300;white-space:nowrap;animation:fi .2s ease}
.hint{margin:0 16px 12px;background:var(--blue-bg);border:1px solid rgba(37,99,235,.15);border-radius:var(--rs);padding:10px 12px;font-size:12px;color:var(--blue);line-height:1.5;display:flex;justify-content:space-between;align-items:center;gap:8px}
.hclose{background:none;border:none;color:var(--blue);cursor:pointer;font-size:16px;padding:0 2px}
.btn-lnk{background:none;border:none;color:var(--text2);font-size:13px;cursor:pointer;padding:4px 8px;text-decoration:underline;text-underline-offset:2px}

170
symfony.lock Normal file
View file

@ -0,0 +1,170 @@
{
"doctrine/deprecations": {
"version": "1.1",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "1.0",
"ref": "fdd756167454623e21f1d769c5b814b243782a67"
}
},
"doctrine/doctrine-bundle": {
"version": "3.2",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "3.0",
"ref": "d39a3bd844edfe90c20ae520b804a3bf4f82b4ad"
},
"files": [
"config/packages/doctrine.yaml",
"src/Entity/.gitignore",
"src/Repository/.gitignore"
]
},
"doctrine/doctrine-migrations-bundle": {
"version": "4.0",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "3.1",
"ref": "1d01ec03c6ecbd67c3375c5478c9a423ae5d6a33"
},
"files": [
"config/packages/doctrine_migrations.yaml",
"migrations/.gitignore"
]
},
"symfony/console": {
"version": "8.0",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "5.3",
"ref": "1781ff40d8a17d87cf53f8d4cf0c8346ed2bb461"
},
"files": [
"bin/console"
]
},
"symfony/flex": {
"version": "2.10",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "2.4",
"ref": "52e9754527a15e2b79d9a610f98185a1fe46622a"
},
"files": [
".env",
".env.dev"
]
},
"symfony/framework-bundle": {
"version": "8.0",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.4",
"ref": "d5dcd308c8becd725c9d8b91e31aab1ff0bbc30b"
},
"files": [
"config/packages/cache.yaml",
"config/packages/framework.yaml",
"config/preload.php",
"config/routes/framework.yaml",
"config/services.yaml",
"public/index.php",
"src/Controller/.gitignore",
"src/Kernel.php",
".editorconfig"
]
},
"symfony/mailer": {
"version": "8.0",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "4.3",
"ref": "09051cfde49476e3c12cd3a0e44289ace1c75a4f"
},
"files": [
"config/packages/mailer.yaml"
]
},
"symfony/messenger": {
"version": "8.0",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.0",
"ref": "d8936e2e2230637ef97e5eecc0eea074eecae58b"
},
"files": [
"config/packages/messenger.yaml"
]
},
"symfony/property-info": {
"version": "8.0",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.3",
"ref": "dae70df71978ae9226ae915ffd5fad817f5ca1f7"
},
"files": [
"config/packages/property_info.yaml"
]
},
"symfony/routing": {
"version": "8.0",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.4",
"ref": "bc94c4fd86f393f3ab3947c18b830ea343e51ded"
},
"files": [
"config/packages/routing.yaml",
"config/routes.yaml"
]
},
"symfony/security-bundle": {
"version": "8.0",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.4",
"ref": "c42fee7802181cdd50f61b8622715829f5d2335c"
},
"files": [
"config/packages/security.yaml",
"config/routes/security.yaml"
]
},
"symfony/twig-bundle": {
"version": "8.0",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.4",
"ref": "f250159ebe99153d0c640a3e7742876fc7453f2c"
},
"files": [
"config/packages/twig.yaml",
"templates/base.html.twig"
]
},
"symfony/validator": {
"version": "8.0",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.0",
"ref": "8c1c4e28d26a124b0bb273f537ca8ce443472bfd"
},
"files": [
"config/packages/validator.yaml"
]
}
}

329
templates/app.html.twig Normal file
View file

@ -0,0 +1,329 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0"/>
<meta name="theme-color" content="#f5f4f0"/>
<meta name="mobile-web-app-capable" content="yes"/>
<meta name="apple-mobile-web-app-capable" content="yes"/>
<title>Dudi</title>
<link rel="stylesheet" href="style.css"/>
</head>
<body>
<div class="main-wrap">
<div class="hdr">
<div>
<img src="logo.png" alt="Dudi" class="hdr-logo"/>
<div class="hdr-sub" id="tlbl"></div>
</div>
<div class="hdr-btns">
<button class="btn-menu" id="btnData">⋯</button>
<button class="btn-add" id="btnNew">+</button>
</div>
</div>
<div class="main" id="main"></div>
</div>
<div id="ov" style="display:none"></div>
<!-- ── Templates ──────────────────────────────────────────────────────────── -->
<template id="tpl-hint">
<div class="hint">Menü → "Zum Startbildschirm" für App-Icon<button class="hclose">×</button></div>
</template>
<template id="tpl-empty">
<div class="empty"><div style="font-size:40px;opacity:.4;margin-bottom:12px">🎯</div>Noch keine Ziele.<br>Tippe auf <strong>+</strong> um zu starten.</div>
</template>
<template id="tpl-dot">
<div></div>
</template>
<template id="tpl-nosets">
<div class="nosets">Noch kein Eintrag</div>
</template>
<template id="tpl-set-row">
<div class="set-row">
<span></span>
<button class="sdel">×</button>
</div>
</template>
<template id="tpl-add-row">
<div class="add-row">
<input class="num-in" type="number" min="1"/>
<span class="ulbl"></span>
<button class="btn-as">Eintragen</button>
</div>
</template>
<template id="tpl-panel">
<div class="dpanel">
<div class="dpanel-hdr">
<span class="dpanel-title"></span>
<span class="dpanel-sub"></span>
</div>
<div class="dpanel-body"></div>
</div>
</template>
<template id="tpl-qb-row">
<div class="qb-row">
<div class="qb-name"></div>
<div class="qb-stat"></div>
<input class="num-in" type="number" min="1"/>
<button class="btn-as">Eintragen</button>
</div>
</template>
<template id="tpl-name-view">
<div class="name-wrap">
<div class="goal-name"></div>
<button class="btn-ren">✎</button>
</div>
</template>
<template id="tpl-name-edit">
<div class="name-wrap">
<input class="ren-input" type="text"/>
</div>
</template>
<template id="tpl-card-collapsed">
<div class="card">
<div class="card-hdr">
<div class="card-bd" style="flex:1;min-width:0">
<div class="goal-meta">Noch <span class="m-dr"></span>T · endet <span class="m-end"></span> · heute: <span class="m-heute"></span><br>total: <span class="m-total"></span></div>
</div>
<span class="badge"></span>
<span class="chevron">▸</span>
</div>
<div style="padding:0 16px 12px">
<div class="prog-track"><div class="prog-fill"></div></div>
</div>
</div>
</template>
<template id="tpl-card-expanded">
<div class="card">
<div class="card-hdr">
<div class="card-bd" style="flex:1;min-width:0">
<div class="goal-meta">Noch <span class="m-dr"></span>T · endet <span class="m-end"></span></div>
</div>
<span class="badge"></span>
<span class="chevron">▴</span>
</div>
<div class="prog-wrap">
<div class="prog-track"><div class="prog-fill"></div></div>
<div class="prog-row">
<span class="pr-done"></span>
<span class="pr-pct"></span>
</div>
</div>
<div class="heute-stats">
<div class="heute-group">
<div class="heute-lbl">Heute</div>
<div class="heute-inner">
<div class="stat">
<div class="slbl">Gemacht</div>
<div class="sval"><span class="sv-tdone"></span><div class="sunit"></div></div>
</div>
<div class="stat">
<div class="slbl">Tagesziel</div>
<div class="sval"><span class="sv-daily"></span><div class="sunit"></div></div>
</div>
<div class="stat">
<div class="slbl">Noch</div>
<div class="sval sv-noch"><span class="sv-st"></span><div class="sunit"></div></div>
</div>
</div>
</div>
</div>
<div class="dots-sec">
<div class="dots-lbl">Verlauf — heute &amp; gestern bearbeitbar</div>
<div class="dots-wrap"></div>
<div class="legend">
<span class="leg"><span class="ldot" style="background:rgba(37,99,235,.3)"></span>Puffer</span>
<span class="leg"><span class="ldot" style="background:rgba(22,163,74,.3)"></span>Erreicht</span>
<span class="leg"><span class="ldot" style="background:rgba(217,119,6,.3)"></span>Teilweise</span>
<span class="leg"><span class="ldot" style="background:rgba(220,38,38,.3)"></span>Verpasst</span>
</div>
</div>
<div class="card-foot"><button class="btn-del">Ziel löschen</button></div>
</div>
</template>
<template id="tpl-sheet">
<div class="sheet">
<div class="shandle"></div>
</div>
</template>
<template id="tpl-login">
<div>
<div class="stitle">Anmelden</div>
<div class="ssub">Dudi</div>
<div class="login-err" style="display:none"></div>
<div class="ff"><label>E-Mail</label><input class="fi lf-email" type="email" autocomplete="email"/></div>
<div class="ff"><label>Passwort</label><input class="fi lf-pass" type="password" autocomplete="current-password"/></div>
<div class="factions"><button class="btn-p lf-sub">Anmelden</button></div>
<div style="text-align:center;margin-top:8px"><button class="btn-lnk lf-fgt">Passwort vergessen?</button></div>
</div>
</template>
<template id="tpl-forgot-pw">
<div>
<div class="stitle">Passwort vergessen</div>
<div class="ssub">Wir schicken dir einen Reset-Link</div>
<div class="ff"><label>E-Mail</label><input class="fi fp-email" type="email" autocomplete="email"/></div>
<div class="login-err" style="display:none"></div>
<div class="factions">
<button class="btn-p fp-sub">Link senden</button>
<button class="btn-c fp-back">Zurück</button>
</div>
</div>
</template>
<template id="tpl-email-sent">
<div>
<div class="stitle">E-Mail gesendet</div>
<div class="ssub">Falls die Adresse bekannt ist, erhältst du in Kürze einen Reset-Link.</div>
<div class="factions"><button class="btn-p es-ok">OK</button></div>
</div>
</template>
<template id="tpl-reset-pw">
<div>
<div class="stitle">Neues Passwort</div>
<div class="ff"><label>Neues Passwort</label><input class="fi rp-pass" type="password" autocomplete="new-password" placeholder="mind. 8 Zeichen"/></div>
<div class="login-err" style="display:none"></div>
<div class="factions"><button class="btn-p rp-sub">Passwort setzen</button></div>
</div>
</template>
<template id="tpl-pw-changed">
<div>
<div class="stitle">Passwort geändert</div>
<div class="ssub">Du kannst dich jetzt anmelden.</div>
<div class="factions"><button class="btn-p pc-ok">Anmelden</button></div>
</div>
</template>
<template id="tpl-change-name">
<div>
<div class="stitle">Name ändern</div>
<div class="ff"><label>Dein Name</label><input class="fi cn-name" type="text" autocomplete="name"/></div>
<div class="login-err" style="display:none"></div>
<div class="factions">
<button class="btn-p cn-sub">Speichern</button>
<button class="btn-c cn-can">Abbrechen</button>
</div>
</div>
</template>
<template id="tpl-change-pw">
<div>
<div class="stitle">Passwort ändern</div>
<div class="ff"><label>Aktuelles Passwort</label><input class="fi cp-old" type="password" autocomplete="current-password"/></div>
<div class="ff"><label>Neues Passwort</label><input class="fi cp-new" type="password" autocomplete="new-password" placeholder="mind. 8 Zeichen"/></div>
<div class="ff"><label>Neues Passwort bestätigen</label><input class="fi cp-new2" type="password" autocomplete="new-password" placeholder="mind. 8 Zeichen"/></div>
<div class="login-err" style="display:none"></div>
<div class="factions">
<button class="btn-p cp-sub">Ändern</button>
<button class="btn-c cp-can">Abbrechen</button>
</div>
</div>
</template>
<template id="tpl-register">
<div>
<div class="stitle">Konto erstellen</div>
<div class="ssub">Du wurdest eingeladen</div>
<div class="ff"><label>Dein Name</label><input class="fi rg-name" type="text" autocomplete="name" placeholder="Wie sollen wir dich nennen?"/></div>
<div class="ff"><label>E-Mail</label><input class="fi rg-email" type="email" autocomplete="email"/></div>
<div class="ff"><label>Passwort</label><input class="fi rg-pass" type="password" autocomplete="new-password" placeholder="mind. 8 Zeichen"/></div>
<div class="ff"><label>Passwort bestätigen</label><input class="fi rg-pass2" type="password" autocomplete="new-password" placeholder="Passwort wiederholen"/></div>
<div class="login-err" style="display:none"></div>
<div class="factions"><button class="btn-p rg-sub">Registrieren</button></div>
</div>
</template>
<template id="tpl-new-goal">
<div>
<div class="stitle">Neues Ziel</div>
<div class="ff"><label>Übung / Gewohnheit</label><input class="fi ng-name" type="text" placeholder="Liegestütz, Plank …"/></div>
<div class="fgrid">
<div class="ff"><label>Einheit</label><input class="fi ng-unit" type="text" value="Stück"/></div>
<div class="ff"><label>Tagesziel</label><input class="fi ng-daily" type="number" min="1" value="50"/></div>
</div>
<div class="ff"><label>Dauer in Tagen</label><input class="fi ng-days" type="number" min="7" max="365" value="30"/></div>
<div class="factions">
<button class="btn-p ng-sub">Ziel starten</button>
<button class="btn-c ng-can">Abbrechen</button>
</div>
</div>
</template>
<template id="tpl-data-menu">
<div>
<div class="stitle">Daten verwalten</div>
<div class="ssub">Export, Import und Backup</div>
<button class="dbtn dm-exp"><span class="dico">⬇</span><span class="dlbl">Exportieren<span class="dsub">Alle Ziele als JSON-Datei speichern</span></span></button>
<button class="dbtn dm-imp"><span class="dico">⬆</span><span class="dlbl">Importieren<span class="dsub">Backup laden oder zusammenführen</span></span></button>
<div class="ddiv"></div>
<button class="dbtn dm-inv"><span class="dico">🔗</span><span class="dlbl">Freund einladen<span class="dsub">Einladungslink generieren</span></span></button>
<button class="dbtn dm-invlist"><span class="dico">📋</span><span class="dlbl">Meine Einladungen<span class="dsub">Status aller gesendeten Einladungen</span></span></button>
<div class="ddiv"></div>
<button class="dbtn dm-name"><span class="dico">✏️</span><span class="dlbl">Name ändern</span></button>
<button class="dbtn dm-cpw"><span class="dico">🔑</span><span class="dlbl">Passwort ändern</span></button>
<button class="dbtn dm-lgout"><span class="dico">→</span><span class="dlbl">Abmelden</span></button>
<button class="btn-c dm-cls" style="width:100%;margin-top:4px;text-align:center">Schließen</button>
<div class="ddiv" style="margin-top:16px"></div>
<button class="dbtn ddanger dm-clr"><span class="dico">✕</span><span class="dlbl">Alle Daten löschen<span class="dsub">Kann nicht rückgängig gemacht werden</span></span></button>
</div>
</template>
<template id="tpl-invite-form">
<div>
<div class="stitle">Freund einladen</div>
<div class="ssub">Link gilt 7 Tage und kann nur einmal verwendet werden</div>
<div class="ff"><label>Name (für deine Übersicht)</label><input class="fi inv-name" type="text" placeholder="z.B. Max"/></div>
<div class="factions">
<button class="btn-p inv-gen">Link generieren</button>
<button class="btn-c inv-cancel">Abbrechen</button>
</div>
</div>
</template>
<template id="tpl-invite-link">
<div>
<div class="stitle"></div>
<div class="ssub">Link gilt 7 Tage und kann nur einmal verwendet werden</div>
<div class="ff"><input class="fi il-url" type="text" readonly/></div>
<div class="factions">
<button class="btn-p il-copy">Link kopieren</button>
<button class="btn-c il-close">Schließen</button>
</div>
</div>
</template>
<template id="tpl-invite-list">
<div>
<div class="stitle">Meine Einladungen</div>
<div class="dpanel-body"></div>
<div class="factions"><button class="btn-c il-close">Schließen</button></div>
</div>
</template>
<template id="tpl-invite-row">
<div class="set-row">
<span style="flex:1"><strong class="ir-label"></strong><span class="ir-detail" style="opacity:.6;font-size:.85em"></span></span>
<button class="ir-copy btn-lnk" style="font-size:.8em;display:none">🔗 Link</button>
<span class="ir-status" style="font-size:.85em;font-weight:600"></span>
</div>
</template>
<script src="app.js"></script>
</body>
</html>