Add Putzliga mood import

This commit is contained in:
2026-05-21 20:16:21 +02:00
parent 714198059b
commit 8ad8ca28af
6 changed files with 294 additions and 1 deletions
+108 -1
View File
@@ -40,7 +40,7 @@ final class App
$this->triggerReminderCheckFromTraffic($method, $path);
$hasUsers = $this->users->hasAnyUsers();
$isAuthenticated = $this->auth->check();
$systemPaths = ['/reminders/run', '/api/health'];
$systemPaths = ['/reminders/run', '/api/health', '/api/putzliga'];
// A failed setup must never leave the app in a half-authenticated redirect loop.
if (!$hasUsers && $isAuthenticated) {
@@ -157,6 +157,14 @@ final class App
$this->handleHealthImportStatus();
return;
case '/api/putzliga':
if ($method !== 'POST') {
json_response(['ok' => false, 'message' => 'Method Not Allowed'], 405);
}
$this->handlePutzligaImport();
return;
default:
http_response_code(404);
View::render('not-found', [
@@ -348,6 +356,81 @@ final class App
]);
}
private function handlePutzligaImport(): void
{
$token = $this->healthImportBearerToken();
if ($token === '') {
json_response(['ok' => false, 'message' => 'Bearer-Token fehlt.'], 401);
}
$user = $this->users->findByPutzligaImportToken($token);
if ($user === null) {
json_response(['ok' => false, 'message' => 'Bearer-Token ist ungültig.'], 401);
}
$payload = $this->decodeHealthImportPayload((string) file_get_contents('php://input'));
$date = (string) ($payload['date'] ?? '');
$tasksPayload = is_array($payload['tasks'] ?? null) ? $payload['tasks'] : [];
$tasks = array_values(array_filter(array_map(
static fn (mixed $task): string => trim((string) $task),
$tasksPayload
), static fn (string $task): bool => $task !== ''));
if (!$this->isValidDate($date)) {
json_response(['ok' => false, 'message' => 'Datum fehlt oder ist ungültig.'], 400);
}
if (count($tasks) < 3) {
json_response(['ok' => false, 'message' => 'Mindestens 3 erledigte Aufgaben sind nötig.'], 400);
}
$username = (string) ($user['username'] ?? '');
$settings = $this->hydrateSettings($this->settings->forUser($username));
$entries = $this->entries->all($username);
$entryMap = [];
foreach ($entries as $entry) {
if (is_array($entry) && $this->isValidDate((string) ($entry['date'] ?? ''))) {
$entryMap[(string) $entry['date']] = $entry;
}
}
$entry = $entryMap[$date] ?? $this->scoring->normalize([
'date' => $date,
'pain_enabled' => !empty($settings['tracking']['pain_enabled']),
'walk_mode' => (string) ($settings['walk']['mode'] ?? 'time'),
'summary' => ['comment' => '', 'mood' => 0, 'energy' => 0, 'stress' => 0, 'alcohol' => false],
'events' => [],
'background_image' => '',
]);
$importID = 'putzliga-' . $date;
$events = array_values(array_filter(
is_array($entry['events'] ?? null) ? $entry['events'] : [],
static fn (array $event): bool => (string) ($event['import_id'] ?? '') !== $importID
));
$events[] = [
'id' => $importID,
'type' => 'event',
'time' => (string) ($payload['time'] ?? ''),
'comment' => 'Du warst fleißig',
'value' => 0,
'unit' => '',
'mood' => 0,
'energy' => 1,
'stress' => 0,
'source' => 'putzliga',
'import_id' => $importID,
'task_titles' => array_slice(array_values(array_unique($tasks)), 0, 20),
];
$entry['events'] = $events;
$entryMap[$date] = $entry;
$this->persistUserEntries($username, $settings, array_values($entryMap));
$this->users->recordPutzligaImport($username, 'ok', count($tasks) . ' Aufgaben synchronisiert.');
json_response(['ok' => true, 'message' => 'Putzliga-Moment aktualisiert.', 'tasks' => count($tasks)]);
}
private function healthImportBearerToken(): string
{
$header = trim((string) ($_SERVER['HTTP_AUTHORIZATION'] ?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? ''));
@@ -2394,6 +2477,14 @@ final class App
$healthImportToken = null;
}
$pendingPutzligaTokens = is_array($_SESSION['_putzliga_import_token'] ?? null) ? $_SESSION['_putzliga_import_token'] : [];
$putzligaImportToken = $pendingPutzligaTokens[$user['username']] ?? null;
if (is_string($putzligaImportToken)) {
unset($_SESSION['_putzliga_import_token'][$user['username']]);
} else {
$putzligaImportToken = null;
}
$optionsOpenPanel = trim((string) ($_GET['panel'] ?? ''));
if ($optionsOpenPanel === 'score') {
$optionsOpenPanel = '';
@@ -2414,6 +2505,9 @@ final class App
'healthImportConfig' => $this->users->healthImportConfig($user['username']),
'healthImportToken' => $healthImportToken,
'healthImportUrl' => app_origin() . '/api/health',
'putzligaImportConfig' => $this->users->putzligaImportConfig($user['username']),
'putzligaImportToken' => $putzligaImportToken,
'putzligaImportUrl' => app_origin() . '/api/putzliga',
'backupAvailable' => class_exists('ZipArchive'),
'aiConfig' => $user['is_admin'] ? $this->aiConfig->get() : null,
'aiStatus' => $user['is_admin'] ? $this->openAi->configuration() : null,
@@ -2499,6 +2593,19 @@ final class App
redirect('/options?panel=health');
}
if ($form === 'putzliga_import_token') {
$token = $this->users->issuePutzligaImportToken($user['username']);
$_SESSION['_putzliga_import_token'][$user['username']] = $token;
flash('success', 'Der Putzliga-Token wurde erstellt. Kopiere ihn in Putzliga.');
redirect('/options?panel=health');
}
if ($form === 'putzliga_import_revoke') {
$this->users->revokePutzligaImportToken($user['username']);
flash('success', 'Der Putzliga-Token wurde deaktiviert.');
redirect('/options?panel=health');
}
if ($form === 'password') {
$current = (string) ($_POST['current_password'] ?? '');
$new = (string) ($_POST['new_password'] ?? '');
+26
View File
@@ -221,6 +221,8 @@ final class EntryRepository
$eventLines[] = '- Tiefschlaf: ' . (string) ($event['sleep_deep'] ?? 0);
$eventLines[] = '- REM-Schlaf: ' . (string) ($event['sleep_rem'] ?? 0);
$eventLines[] = '- Kernschlaf: ' . (string) ($event['sleep_core'] ?? 0);
$taskTitles = is_array($event['task_titles'] ?? null) ? array_values(array_filter($event['task_titles'], 'is_string')) : [];
$eventLines[] = '- Aufgaben: ' . ($taskTitles !== [] ? base64_encode((string) json_encode($taskTitles, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)) : '');
$route = is_array($event['route'] ?? null) ? $event['route'] : [];
$eventLines[] = '- Route: ' . ($route !== [] ? base64_encode((string) json_encode($route, JSON_UNESCAPED_SLASHES)) : '');
$eventLines[] = '';
@@ -348,6 +350,7 @@ final class EntryRepository
'sleep_deep' => (float) ($this->extract('/^- Tiefschlaf:\s*(.*)$/mu', $block) ?? 0),
'sleep_rem' => (float) ($this->extract('/^- REM-Schlaf:\s*(.*)$/mu', $block) ?? 0),
'sleep_core' => (float) ($this->extract('/^- Kernschlaf:\s*(.*)$/mu', $block) ?? 0),
'task_titles' => $this->decodeStringList((string) ($this->extract('/^- Aufgaben:\s*(.*)$/mu', $block) ?? '')),
'route' => $this->decodeRoute((string) ($this->extract('/^- Route:\s*(.*)$/mu', $block) ?? '')),
];
}
@@ -373,6 +376,29 @@ final class EntryRepository
return $base;
}
private function decodeStringList(string $encoded): array
{
$encoded = trim($encoded);
if ($encoded === '') {
return [];
}
$decoded = base64_decode($encoded, true);
if ($decoded === false) {
return [];
}
$items = json_decode($decoded, true);
if (!is_array($items)) {
return [];
}
return array_values(array_filter(array_map(
static fn (mixed $item): string => trim((string) $item),
$items
), static fn (string $item): bool => $item !== ''));
}
private function extractSection(string $content, string $startHeading, string $endHeading): ?string
{
$pattern = '/' . preg_quote($startHeading, '/') . '\s*(.*?)\s*' . preg_quote($endHeading, '/') . '/su';
+4
View File
@@ -484,6 +484,10 @@ final class ScoringService
'sleep_deep' => max(0.0, min(24.0, is_numeric($event['sleep_deep'] ?? null) ? (float) $event['sleep_deep'] : 0.0)),
'sleep_rem' => max(0.0, min(24.0, is_numeric($event['sleep_rem'] ?? null) ? (float) $event['sleep_rem'] : 0.0)),
'sleep_core' => max(0.0, min(24.0, is_numeric($event['sleep_core'] ?? null) ? (float) $event['sleep_core'] : 0.0)),
'task_titles' => array_values(array_slice(array_filter(array_map(
static fn (mixed $title): string => trim((string) $title),
is_array($event['task_titles'] ?? null) ? $event['task_titles'] : []
), static fn (string $title): bool => $title !== ''), 0, 20)),
'route' => $this->normalizeRoute($event['route'] ?? []),
];
}
+117
View File
@@ -171,6 +171,40 @@ final class UserRepository
];
}
public function findByPutzligaImportToken(string $token): ?array
{
$tokenHash = hash('sha256', $token);
foreach ($this->all() as $user) {
$config = $user['putzliga_import'] ?? null;
if (!is_array($config) || empty($config['enabled'])) {
continue;
}
if (hash_equals((string) ($config['token_hash'] ?? ''), $tokenHash)) {
return $user;
}
}
return null;
}
public function putzligaImportConfig(string $username): array
{
$user = $this->find($username);
$config = is_array($user['putzliga_import'] ?? null) ? $user['putzliga_import'] : [];
return [
'enabled' => !empty($config['enabled']),
'token_prefix' => (string) ($config['token_prefix'] ?? ''),
'created_at' => (string) ($config['created_at'] ?? ''),
'last_import_at' => (string) ($config['last_import_at'] ?? ''),
'last_status' => (string) ($config['last_status'] ?? ''),
'last_message' => (string) ($config['last_message'] ?? ''),
];
}
public function issueHealthImportToken(string $username): string
{
$token = 'mood_health_' . bin2hex(random_bytes(24));
@@ -236,6 +270,89 @@ final class UserRepository
}
}
public function issuePutzligaImportToken(string $username): string
{
$token = 'mood_putzliga_' . bin2hex(random_bytes(24));
$normalized = normalize_username($username);
$users = $this->all();
$updated = false;
foreach ($users as &$user) {
if (($user['username'] ?? '') !== $normalized) {
continue;
}
$currentConfig = is_array($user['putzliga_import'] ?? null) ? $user['putzliga_import'] : [];
$user['putzliga_import'] = [
'enabled' => true,
'token_hash' => hash('sha256', $token),
'token_prefix' => substr($token, 0, 20),
'created_at' => date(DATE_ATOM),
'last_import_at' => (string) ($currentConfig['last_import_at'] ?? ''),
'last_status' => (string) ($currentConfig['last_status'] ?? ''),
'last_message' => (string) ($currentConfig['last_message'] ?? ''),
];
$user['updated_at'] = date(DATE_ATOM);
$updated = true;
break;
}
unset($user);
if (!$updated) {
throw new RuntimeException('Der Putzliga-Token konnte keinem Benutzer zugeordnet werden.');
}
$this->write(['users' => $users]);
return $token;
}
public function revokePutzligaImportToken(string $username): void
{
$normalized = normalize_username($username);
$users = $this->all();
$updated = false;
foreach ($users as &$user) {
if (($user['username'] ?? '') !== $normalized || !array_key_exists('putzliga_import', $user)) {
continue;
}
unset($user['putzliga_import']);
$user['updated_at'] = date(DATE_ATOM);
$updated = true;
break;
}
unset($user);
if ($updated) {
$this->write(['users' => $users]);
}
}
public function recordPutzligaImport(string $username, string $status, string $message): void
{
$normalized = normalize_username($username);
$users = $this->all();
foreach ($users as &$user) {
if (($user['username'] ?? '') !== $normalized) {
continue;
}
$config = is_array($user['putzliga_import'] ?? null) ? $user['putzliga_import'] : [];
$config['last_import_at'] = date(DATE_ATOM);
$config['last_status'] = $status;
$config['last_message'] = substr($message, 0, 240);
$user['putzliga_import'] = $config;
$user['updated_at'] = date(DATE_ATOM);
break;
}
unset($user);
$this->write(['users' => $users]);
}
public function recordHealthImport(string $username, string $status, string $message): void
{
$normalized = normalize_username($username);
+10
View File
@@ -114,6 +114,7 @@ $formatBalanceValue = static function (?array $entry) use ($settings): string {
<?php $eventComment = trim((string) ($item['comment'] ?? '')); ?>
<?php if (preg_match('/^\s*-\s*(?:Stimmung|Energie|Stress)\s*:\s*[+-]?\d+\s*$/u', $eventComment) === 1) { $eventComment = ''; } ?>
<?php $isImportedHealth = (string) ($item['source'] ?? '') === 'health_auto_export'; ?>
<?php $isPutzliga = (string) ($item['source'] ?? '') === 'putzliga'; ?>
<?php $sportType = $eventType === 'sport' ? find_sport_type($settings, (string) ($item['sport_type_id'] ?? '')) : null; ?>
<?php $isImportedWalkSport = $isImportedHealth && $eventType === 'sport' && str_contains(strtolower((string) ($sportType['label'] ?? $eventComment)), 'spaziergang'); ?>
<?php $eventTone = signal_value_class(normalize_signal_value($item['mood'] ?? 0)); ?>
@@ -184,6 +185,7 @@ $formatBalanceValue = static function (?array $entry) use ($settings): string {
'sleep_deep' => (float) ($item['sleep_deep'] ?? 0),
'sleep_rem' => (float) ($item['sleep_rem'] ?? 0),
'sleep_core' => (float) ($item['sleep_core'] ?? 0),
'task_titles' => is_array($item['task_titles'] ?? null) ? $item['task_titles'] : [],
]); ?>
<?php $hasEventImage = is_string($item['image_url'] ?? null); ?>
<?php $routeMap = is_array($item['route_map'] ?? null) ? $item['route_map'] : null; ?>
@@ -220,6 +222,14 @@ $formatBalanceValue = static function (?array $entry) use ($settings): string {
<p class="timeline-card__value"><?= e((string) $item['comment']) ?></p>
<?php endif; ?>
<?php if ($isPutzliga && is_array($item['task_titles'] ?? null) && $item['task_titles'] !== []): ?>
<div class="timeline-card__stats" aria-label="Erledigte Aufgaben">
<?php foreach ($item['task_titles'] as $taskTitle): ?>
<span><?= e((string) $taskTitle) ?></span>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php if ($eventType === 'sleep' && $sleepActualTotal > 0): ?>
<div class="sleep-phase-bar" aria-label="Schlafdauer" style="--sleep-optimal-left: <?= e((string) $sleepOptimalPercent) ?>%; --sleep-actual-left: <?= e((string) $sleepActualPercent) ?>%">
<svg class="sleep-phase-bar__svg" viewBox="0 0 100 10" preserveAspectRatio="none" aria-hidden="true" focusable="false">
+29
View File
@@ -165,6 +165,35 @@
<p class="helper-text">Lege in Health Auto Export zwei REST-API-Automationen an: Gesundheitsmetriken mit <code>step_count</code> und <code>sleep_analysis</code>, sowie Workouts mit JSON Version 2 und Routendaten.</p>
</article>
<article class="detail-card detail-card--overlay">
<p class="eyebrow">Putzliga</p>
<div class="stack-form">
<label><span>URL in Putzliga</span><input type="text" value="<?= e((string) $putzligaImportUrl) ?>" readonly></label>
<label><span>HTTP-Header</span><input type="text" value="Authorization: Bearer <?= !empty($putzligaImportConfig['token_prefix']) ? e((string) $putzligaImportConfig['token_prefix']) . '…' : 'TOKEN' ?>" readonly></label>
</div>
<p class="helper-text">Putzliga erstellt ab 3 erledigten Aufgaben pro Tag einen Moment „Du warst fleißig“ und aktualisiert ihn, wenn weitere Aufgaben dazukommen.</p>
<?php if (!empty($putzligaImportToken)): ?>
<label><span>Neuer Token, nur jetzt sichtbar</span><input type="text" value="<?= e((string) $putzligaImportToken) ?>" readonly></label>
<?php endif; ?>
<div class="user-list">
<div class="user-row"><strong>Token</strong><span><?= !empty($putzligaImportConfig['enabled']) ? 'aktiv' : 'nicht eingerichtet' ?></span></div>
<div class="user-row"><strong>Letzter Sync</strong><span><?= !empty($putzligaImportConfig['last_import_at']) ? e(format_display_datetime((string) $putzligaImportConfig['last_import_at'])) : '-' ?></span></div>
<div class="user-row"><strong>Statusmeldung</strong><span><?= !empty($putzligaImportConfig['last_message']) ? e((string) $putzligaImportConfig['last_message']) : '-' ?></span></div>
</div>
<form method="post" action="/options" class="stack-form">
<?= csrf_field() ?>
<input type="hidden" name="form_name" value="putzliga_import_token">
<button class="primary-button" type="submit"><?= !empty($putzligaImportConfig['enabled']) ? 'Putzliga-Token neu erstellen' : 'Putzliga-Token erstellen' ?></button>
</form>
<?php if (!empty($putzligaImportConfig['enabled'])): ?>
<form method="post" action="/options" class="stack-form">
<?= csrf_field() ?>
<input type="hidden" name="form_name" value="putzliga_import_revoke">
<button class="ghost-button" type="submit">Putzliga-Token deaktivieren</button>
</form>
<?php endif; ?>
</article>
<?php if (!empty($healthImportToken)): ?>
<article class="detail-card detail-card--overlay health-token-card">
<p class="eyebrow">Neuer Token</p>