add health import
This commit is contained in:
@@ -2,6 +2,10 @@ Options -Indexes
|
||||
DirectoryIndex index.php
|
||||
AddType application/manifest+json .webmanifest
|
||||
|
||||
<IfModule mod_setenvif.c>
|
||||
SetEnvIf Authorization "(.+)" HTTP_AUTHORIZATION=$1
|
||||
</IfModule>
|
||||
|
||||
<IfModule mod_rewrite.c>
|
||||
RewriteEngine On
|
||||
|
||||
|
||||
+293
-2
@@ -625,9 +625,35 @@ body.page-dashboard .content {
|
||||
|
||||
.day-summary-card__title {
|
||||
display: block;
|
||||
font-size: clamp(1.05rem, 2.5vw, 1.5rem);
|
||||
font-weight: 400;
|
||||
font-size: clamp(1.35rem, 4vw, 2.35rem);
|
||||
font-weight: 650;
|
||||
line-height: 1.2;
|
||||
color: #fff;
|
||||
text-shadow: 0 8px 24px rgba(0, 0, 0, 0.38);
|
||||
}
|
||||
|
||||
.day-summary-card__chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
margin-top: 0.85rem;
|
||||
}
|
||||
|
||||
.day-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 2rem;
|
||||
padding: 0.35rem 0.72rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.day-chip--bonus {
|
||||
background: rgba(139, 255, 207, 0.16);
|
||||
border-color: rgba(139, 255, 207, 0.32);
|
||||
}
|
||||
|
||||
.day-summary-card__head {
|
||||
@@ -778,6 +804,72 @@ body.page-dashboard .content {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.timeline-card__comment {
|
||||
margin: 0.28rem 0 0;
|
||||
color: rgba(239, 247, 255, 0.9);
|
||||
font-size: 0.98rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.timeline-card__stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.42rem;
|
||||
margin-top: 0.72rem;
|
||||
}
|
||||
|
||||
.timeline-card__stats span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 1.9rem;
|
||||
padding: 0.32rem 0.66rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
color: rgba(239, 247, 255, 0.88);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.timeline-route-map {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 24rem;
|
||||
height: 10.5rem;
|
||||
margin-top: 0.8rem;
|
||||
overflow: hidden;
|
||||
border-radius: 1.1rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.timeline-route-map svg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.timeline-route-map polyline {
|
||||
fill: none;
|
||||
stroke: #1494de;
|
||||
stroke-width: 5;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
filter: drop-shadow(0 1px 2px rgba(255, 255, 255, 0.9));
|
||||
}
|
||||
|
||||
.timeline-route-map a {
|
||||
position: absolute;
|
||||
right: 0.35rem;
|
||||
bottom: 0.28rem;
|
||||
padding: 0.12rem 0.35rem;
|
||||
border-radius: 0.45rem;
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
color: rgba(10, 22, 35, 0.82);
|
||||
font-size: 0.66rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.timeline-card__meta strong {
|
||||
font-size: 0.92rem;
|
||||
color: rgba(239, 247, 255, 0.68);
|
||||
@@ -1620,6 +1712,13 @@ body.page-dashboard .content {
|
||||
border-color: rgba(69, 201, 141, 0.34);
|
||||
}
|
||||
|
||||
.week-insight-card {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.78), rgba(248, 253, 255, 0.62)),
|
||||
radial-gradient(circle at top left, rgba(90, 188, 242, 0.14), transparent 42%);
|
||||
border-color: rgba(92, 129, 160, 0.2);
|
||||
}
|
||||
|
||||
.range-moment-list__item,
|
||||
.timeline-card__body h3,
|
||||
.day-summary-card__title,
|
||||
@@ -1655,6 +1754,27 @@ body.page-dashboard .content {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.week-insight-card {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
margin-bottom: 1rem;
|
||||
padding: 1rem 1.1rem;
|
||||
border-radius: 1.55rem;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(25, 36, 56, 0.72), rgba(16, 25, 40, 0.58)),
|
||||
radial-gradient(circle at top left, rgba(139, 228, 255, 0.16), transparent 44%);
|
||||
}
|
||||
|
||||
.week-insight-card p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.week-insight-card strong {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.range-card {
|
||||
padding: 1rem 1.1rem;
|
||||
border-radius: 1.45rem;
|
||||
@@ -1746,6 +1866,38 @@ body.page-dashboard .content {
|
||||
border-color: rgba(255, 143, 143, 0.18);
|
||||
}
|
||||
|
||||
.health-import-progress {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.health-import-progress__bar {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 0.72rem;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
.health-import-progress__bar::-webkit-progress-bar {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.health-import-progress__bar::-webkit-progress-value {
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, var(--primary), var(--accent));
|
||||
}
|
||||
|
||||
.health-import-progress__bar::-moz-progress-bar {
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, var(--primary), var(--accent));
|
||||
}
|
||||
|
||||
.options-logout-form {
|
||||
margin: 0;
|
||||
}
|
||||
@@ -2116,6 +2268,145 @@ body.page-dashboard .content {
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
.dashboard-overlay__backdrop,
|
||||
.options-overlay__backdrop {
|
||||
background: rgba(224, 235, 245, 0.64);
|
||||
}
|
||||
|
||||
.dashboard-modal,
|
||||
.dashboard-modal--settings,
|
||||
.options-modal {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.97), rgba(242, 248, 253, 0.93)),
|
||||
radial-gradient(circle at 50% 0%, rgba(90, 188, 242, 0.16), transparent 46%);
|
||||
border-color: rgba(92, 129, 160, 0.2);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.dashboard-modal__controls,
|
||||
.options-modal__controls {
|
||||
background: linear-gradient(180deg, rgba(247, 252, 255, 0.96), rgba(247, 252, 255, 0.76), transparent);
|
||||
}
|
||||
|
||||
.dashboard-modal__round {
|
||||
background: rgba(255, 255, 255, 0.74);
|
||||
border-color: rgba(92, 129, 160, 0.22);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.dashboard-modal__round--confirm {
|
||||
background: linear-gradient(180deg, rgba(90, 188, 242, 0.24), rgba(99, 217, 180, 0.2));
|
||||
color: #0e2b45;
|
||||
}
|
||||
|
||||
.dashboard-modal__subtitle,
|
||||
.overlay-signal-card p {
|
||||
color: rgba(18, 48, 75, 0.62);
|
||||
}
|
||||
|
||||
.dashboard-modal__textarea textarea,
|
||||
.options-modal input[type="text"],
|
||||
.options-modal input[type="password"],
|
||||
.options-modal input[type="number"],
|
||||
.options-modal input[type="date"],
|
||||
.options-modal select,
|
||||
.options-modal textarea {
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
border-color: rgba(92, 129, 160, 0.22);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.overlay-signal-card {
|
||||
border-top-color: rgba(92, 129, 160, 0.16);
|
||||
}
|
||||
|
||||
.overlay-signal-card__buttons {
|
||||
background: rgba(18, 48, 75, 0.08);
|
||||
}
|
||||
|
||||
.overlay-signal-card__buttons button + button {
|
||||
border-left-color: rgba(18, 48, 75, 0.12);
|
||||
}
|
||||
|
||||
.dashboard-fab-menu {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.88), rgba(245, 251, 255, 0.76)),
|
||||
radial-gradient(circle at top left, rgba(90, 188, 242, 0.16), transparent 48%);
|
||||
border-color: rgba(92, 129, 160, 0.2);
|
||||
}
|
||||
|
||||
.dashboard-fab-menu button,
|
||||
.moment-type-card,
|
||||
.moment-choice-pill span,
|
||||
.options-menu-card,
|
||||
.options-modal .settings-section,
|
||||
.options-modal .band-card,
|
||||
.options-modal .sport-type-card,
|
||||
.options-modal .checkbox-row--panel,
|
||||
.options-modal .push-panel,
|
||||
.options-modal .detail-card--overlay {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.78), rgba(247, 252, 255, 0.62)),
|
||||
radial-gradient(circle at top left, rgba(90, 188, 242, 0.12), transparent 48%);
|
||||
border-color: rgba(92, 129, 160, 0.2);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.options-menu-card--danger {
|
||||
background: rgba(219, 107, 107, 0.1);
|
||||
border-color: rgba(219, 107, 107, 0.22);
|
||||
}
|
||||
|
||||
.health-import-progress__bar {
|
||||
background: rgba(18, 48, 75, 0.08);
|
||||
border-color: rgba(92, 129, 160, 0.16);
|
||||
}
|
||||
|
||||
.week-insight-card {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.78), rgba(248, 253, 255, 0.62)),
|
||||
radial-gradient(circle at top left, rgba(90, 188, 242, 0.14), transparent 42%);
|
||||
border-color: rgba(92, 129, 160, 0.2);
|
||||
}
|
||||
|
||||
.moment-type-card.is-selected,
|
||||
.moment-choice-pill input:checked + span {
|
||||
background: rgba(90, 188, 242, 0.18);
|
||||
border-color: rgba(20, 148, 222, 0.34);
|
||||
}
|
||||
|
||||
.day-summary-card__title {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.day-chip {
|
||||
background: rgba(255, 255, 255, 0.62);
|
||||
border-color: rgba(92, 129, 160, 0.2);
|
||||
color: rgba(18, 48, 75, 0.9);
|
||||
}
|
||||
|
||||
.day-chip--bonus {
|
||||
background: rgba(99, 217, 180, 0.18);
|
||||
border-color: rgba(69, 201, 141, 0.3);
|
||||
}
|
||||
|
||||
.timeline-card__comment {
|
||||
color: rgba(18, 48, 75, 0.82);
|
||||
}
|
||||
|
||||
.timeline-card__stats span {
|
||||
background: rgba(255, 255, 255, 0.58);
|
||||
border-color: rgba(92, 129, 160, 0.18);
|
||||
color: rgba(18, 48, 75, 0.82);
|
||||
}
|
||||
|
||||
.timeline-route-map {
|
||||
border-color: rgba(92, 129, 160, 0.2);
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
@@ -1557,6 +1557,100 @@
|
||||
}
|
||||
}
|
||||
|
||||
function initHealthImportStatus() {
|
||||
const panel = document.querySelector("[data-health-import-status]");
|
||||
if (!panel) {
|
||||
return;
|
||||
}
|
||||
|
||||
const progressWrap = panel.querySelector("[data-health-progress-wrap]");
|
||||
const progress = panel.querySelector("[data-health-progress-bar]");
|
||||
const progressText = panel.querySelector("[data-health-progress-text]");
|
||||
const lastImport = panel.querySelector("[data-health-last-import]");
|
||||
const lastMessage = panel.querySelector("[data-health-last-message]");
|
||||
let timer = null;
|
||||
|
||||
const formatDuration = seconds => {
|
||||
const rounded = Math.max(0, Math.round(Number(seconds) || 0));
|
||||
if (rounded < 60) {
|
||||
return `${rounded} s`;
|
||||
}
|
||||
|
||||
return `${Math.ceil(rounded / 60)} min`;
|
||||
};
|
||||
|
||||
const render = status => {
|
||||
const done = Math.max(0, Number(status.progress_done || 0));
|
||||
const total = Math.max(0, Number(status.progress_total || 0));
|
||||
const percent = total > 0 ? Math.min(100, Math.round((done / total) * 100)) : 0;
|
||||
|
||||
if (progress) {
|
||||
progress.value = String(percent);
|
||||
progress.textContent = `${percent}%`;
|
||||
}
|
||||
|
||||
if (progressWrap) {
|
||||
progressWrap.dataset.progressDone = String(done);
|
||||
progressWrap.dataset.progressTotal = String(total);
|
||||
}
|
||||
|
||||
if (lastImport) {
|
||||
lastImport.textContent = status.last_import_at ? new Date(status.last_import_at).toLocaleString("de-DE") : "-";
|
||||
}
|
||||
|
||||
if (lastMessage) {
|
||||
lastMessage.textContent = status.last_message || "-";
|
||||
}
|
||||
|
||||
if (progressText) {
|
||||
if (status.last_status === "running") {
|
||||
let eta = "wird berechnet";
|
||||
const started = Date.parse(status.started_at || "");
|
||||
if (started && done > 0 && total > done) {
|
||||
const elapsed = (Date.now() - started) / 1000;
|
||||
eta = formatDuration((elapsed / done) * (total - done));
|
||||
}
|
||||
progressText.textContent = `Import läuft: ${done} von ${total} verarbeitet. Restzeit ca. ${eta}.`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (status.last_status === "error") {
|
||||
progressText.textContent = `${status.last_message || "Import fehlgeschlagen."} Derselbe Export kann erneut gesendet werden und wird idempotent übernommen.`;
|
||||
return;
|
||||
}
|
||||
|
||||
progressText.textContent = status.last_message || "Noch kein Import gelaufen.";
|
||||
}
|
||||
};
|
||||
|
||||
const initialDone = progressWrap ? Number(progressWrap.dataset.progressDone || 0) : 0;
|
||||
const initialTotal = progressWrap ? Number(progressWrap.dataset.progressTotal || 0) : 0;
|
||||
render({ progress_done: initialDone, progress_total: initialTotal });
|
||||
|
||||
const refresh = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/health/status", { credentials: "same-origin" });
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
const data = await response.json();
|
||||
if (!data || !data.ok || !data.status) {
|
||||
return;
|
||||
}
|
||||
render(data.status);
|
||||
if (data.status.last_status !== "running" && timer !== null) {
|
||||
window.clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
} catch (error) {
|
||||
// Status polling is best-effort; the import itself is server-side.
|
||||
}
|
||||
};
|
||||
|
||||
refresh();
|
||||
timer = window.setInterval(refresh, 3500);
|
||||
}
|
||||
|
||||
function csrfToken() {
|
||||
return document.querySelector('meta[name="csrf-token"]')?.getAttribute("content") || "";
|
||||
}
|
||||
@@ -1888,6 +1982,7 @@
|
||||
initDashboardCharts();
|
||||
initDashboardExperience();
|
||||
initOptionsPanels();
|
||||
initHealthImportStatus();
|
||||
initSportTypeManager();
|
||||
initPwaShell();
|
||||
initPullToRefresh();
|
||||
|
||||
+827
-2
@@ -40,7 +40,7 @@ final class App
|
||||
$this->triggerReminderCheckFromTraffic($method, $path);
|
||||
$hasUsers = $this->users->hasAnyUsers();
|
||||
$isAuthenticated = $this->auth->check();
|
||||
$systemPaths = ['/reminders/run'];
|
||||
$systemPaths = ['/reminders/run', '/api/health'];
|
||||
|
||||
// A failed setup must never leave the app in a half-authenticated redirect loop.
|
||||
if (!$hasUsers && $isAuthenticated) {
|
||||
@@ -141,6 +141,22 @@ final class App
|
||||
$this->handleReminderRun();
|
||||
return;
|
||||
|
||||
case '/api/health':
|
||||
if ($method !== 'POST') {
|
||||
json_response(['ok' => false, 'message' => 'Method Not Allowed'], 405);
|
||||
}
|
||||
|
||||
$this->handleHealthImport();
|
||||
return;
|
||||
|
||||
case '/api/health/status':
|
||||
if ($method !== 'GET') {
|
||||
json_response(['ok' => false, 'message' => 'Method Not Allowed'], 405);
|
||||
}
|
||||
|
||||
$this->handleHealthImportStatus();
|
||||
return;
|
||||
|
||||
default:
|
||||
http_response_code(404);
|
||||
View::render('not-found', [
|
||||
@@ -234,6 +250,631 @@ final class App
|
||||
redirect('/');
|
||||
}
|
||||
|
||||
private function handleHealthImport(): void
|
||||
{
|
||||
ignore_user_abort(true);
|
||||
@set_time_limit(0);
|
||||
|
||||
$token = $this->healthImportBearerToken();
|
||||
if ($token === '') {
|
||||
json_response(['ok' => false, 'message' => 'Bearer-Token fehlt.'], 401);
|
||||
}
|
||||
|
||||
$user = $this->users->findByHealthImportToken($token);
|
||||
if ($user === null) {
|
||||
json_response(['ok' => false, 'message' => 'Bearer-Token ist ungültig.'], 401);
|
||||
}
|
||||
|
||||
$username = (string) ($user['username'] ?? '');
|
||||
$payload = request_json_body();
|
||||
if ($payload === []) {
|
||||
$this->users->recordHealthImport($username, 'error', 'Leerer oder ungültiger JSON-Import.');
|
||||
json_response(['ok' => false, 'message' => 'Leerer oder ungültiger JSON-Import.'], 400);
|
||||
}
|
||||
|
||||
try {
|
||||
$settings = $this->hydrateSettings($this->settings->forUser($username));
|
||||
$result = $this->importHealthPayload($username, $settings, $payload);
|
||||
$message = sprintf(
|
||||
'Importiert: %d Tage, %d Schlaf, %d Sport, %d Spaziergänge.',
|
||||
(int) $result['days'],
|
||||
(int) $result['sleep'],
|
||||
(int) $result['sport'],
|
||||
(int) $result['walk']
|
||||
);
|
||||
$this->users->recordHealthImport($username, 'ok', $message);
|
||||
json_response(['ok' => true, 'message' => $message, 'result' => $result]);
|
||||
} catch (RuntimeException $exception) {
|
||||
$this->users->recordHealthImport($username, 'error', $exception->getMessage());
|
||||
json_response(['ok' => false, 'message' => $exception->getMessage()], 400);
|
||||
}
|
||||
}
|
||||
|
||||
private function handleHealthImportStatus(): void
|
||||
{
|
||||
$user = $this->requireUser();
|
||||
|
||||
json_response([
|
||||
'ok' => true,
|
||||
'status' => $this->users->healthImportConfig((string) ($user['username'] ?? '')),
|
||||
]);
|
||||
}
|
||||
|
||||
private function healthImportBearerToken(): string
|
||||
{
|
||||
$header = trim((string) ($_SERVER['HTTP_AUTHORIZATION'] ?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? ''));
|
||||
if (preg_match('/^Bearer\s+(.+)$/i', $header, $matches) === 1) {
|
||||
return trim((string) ($matches[1] ?? ''));
|
||||
}
|
||||
|
||||
return trim((string) ($_SERVER['HTTP_X_MOOD_HEALTH_TOKEN'] ?? ''));
|
||||
}
|
||||
|
||||
private function importHealthPayload(string $username, array $settings, array $payload): array
|
||||
{
|
||||
$metrics = $this->healthMetricsFromPayload($payload);
|
||||
$workouts = $this->healthWorkoutsFromPayload($payload);
|
||||
$metricImport = $this->healthEventsFromMetrics($metrics);
|
||||
$workoutImport = $this->healthEventsFromWorkouts($workouts, $settings);
|
||||
|
||||
$entries = $this->entries->all($username);
|
||||
$entryMap = [];
|
||||
foreach ($entries as $entry) {
|
||||
if (is_array($entry) && $this->isValidDate((string) ($entry['date'] ?? ''))) {
|
||||
$entryMap[(string) $entry['date']] = $entry;
|
||||
}
|
||||
}
|
||||
|
||||
$dates = array_unique(array_merge(
|
||||
array_keys($metricImport['steps']),
|
||||
array_keys($metricImport['sleep']),
|
||||
array_keys($workoutImport['sport']),
|
||||
array_keys($workoutImport['walk'])
|
||||
));
|
||||
sort($dates, SORT_STRING);
|
||||
|
||||
if ($dates === []) {
|
||||
throw new RuntimeException('Der Import enthielt keine unterstützten Health-Daten.');
|
||||
}
|
||||
|
||||
$totalItems = max(1, $this->countHealthImportItems($metricImport, $workoutImport));
|
||||
$processedItems = 0;
|
||||
$startedAt = date(DATE_ATOM);
|
||||
$this->users->recordHealthImportProgress($username, 'Import vorbereitet.', 0, $totalItems, $startedAt);
|
||||
|
||||
$sleepCount = 0;
|
||||
$sportCount = 0;
|
||||
$walkCount = 0;
|
||||
$now = date(DATE_ATOM);
|
||||
|
||||
foreach ($dates as $date) {
|
||||
if (!$this->isValidDate((string) $date)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$current = $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' => '',
|
||||
]);
|
||||
|
||||
$events = array_values(array_filter(
|
||||
is_array($current['events'] ?? null) ? $current['events'] : [],
|
||||
'is_array'
|
||||
));
|
||||
|
||||
if (isset($metricImport['steps'][$date])) {
|
||||
$health = is_array($current['health'] ?? null) ? $current['health'] : [];
|
||||
$health['steps'] = max(0, (int) $metricImport['steps'][$date]);
|
||||
$health['steps_imported_at'] = $now;
|
||||
$current['health'] = $health;
|
||||
$processedItems++;
|
||||
}
|
||||
|
||||
if (isset($metricImport['sleep'][$date])) {
|
||||
$events = array_values(array_filter($events, static fn (array $event): bool => (string) ($event['type'] ?? '') !== 'sleep'));
|
||||
foreach ($metricImport['sleep'][$date] as $event) {
|
||||
$events[] = $event;
|
||||
$sleepCount++;
|
||||
$processedItems++;
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($workoutImport['sport'][$date])) {
|
||||
$events = array_values(array_filter($events, static fn (array $event): bool => (string) ($event['type'] ?? '') !== 'sport'));
|
||||
foreach ($workoutImport['sport'][$date] as $event) {
|
||||
$events[] = $event;
|
||||
$sportCount++;
|
||||
$processedItems++;
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($workoutImport['walk'][$date])) {
|
||||
$importIDs = [];
|
||||
foreach ($workoutImport['walk'][$date] as $event) {
|
||||
$importID = (string) ($event['import_id'] ?? '');
|
||||
if ($importID !== '') {
|
||||
$importIDs[$importID] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$events = array_values(array_filter($events, static function (array $event) use ($importIDs): bool {
|
||||
$importID = (string) ($event['import_id'] ?? '');
|
||||
|
||||
return $importID === '' || !isset($importIDs[$importID]);
|
||||
}));
|
||||
|
||||
foreach ($workoutImport['walk'][$date] as $event) {
|
||||
$events[] = $event;
|
||||
$walkCount++;
|
||||
$processedItems++;
|
||||
}
|
||||
}
|
||||
|
||||
$current['events'] = $events;
|
||||
$entryMap[$date] = $current;
|
||||
$this->users->recordHealthImportProgress(
|
||||
$username,
|
||||
'Verarbeite ' . format_display_date((string) $date, false) . '.',
|
||||
$processedItems,
|
||||
$totalItems,
|
||||
$startedAt
|
||||
);
|
||||
}
|
||||
|
||||
if (!empty($workoutImport['settings_changed'])) {
|
||||
$this->settings->saveForUser($username, $settings);
|
||||
}
|
||||
|
||||
$this->persistUserEntries($username, $settings, array_values($entryMap));
|
||||
|
||||
return [
|
||||
'days' => count($dates),
|
||||
'steps' => count($metricImport['steps']),
|
||||
'sleep' => $sleepCount,
|
||||
'sport' => $sportCount,
|
||||
'walk' => $walkCount,
|
||||
'sport_types_added' => (int) ($workoutImport['sport_types_added'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
private function countHealthImportItems(array $metricImport, array $workoutImport): int
|
||||
{
|
||||
return count($metricImport['steps'] ?? [])
|
||||
+ array_sum(array_map('count', $metricImport['sleep'] ?? []))
|
||||
+ array_sum(array_map('count', $workoutImport['sport'] ?? []))
|
||||
+ array_sum(array_map('count', $workoutImport['walk'] ?? []));
|
||||
}
|
||||
|
||||
private function healthMetricsFromPayload(array $payload): array
|
||||
{
|
||||
return array_values(array_filter(
|
||||
is_array($payload['metrics'] ?? null) ? $payload['metrics'] : [],
|
||||
'is_array'
|
||||
));
|
||||
}
|
||||
|
||||
private function healthWorkoutsFromPayload(array $payload): array
|
||||
{
|
||||
if (is_array($payload['workouts'] ?? null)) {
|
||||
return array_values(array_filter($payload['workouts'], 'is_array'));
|
||||
}
|
||||
|
||||
if (array_is_list($payload) && isset($payload[0]) && is_array($payload[0])) {
|
||||
return array_values(array_filter($payload, 'is_array'));
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private function healthEventsFromMetrics(array $metrics): array
|
||||
{
|
||||
$steps = [];
|
||||
$sleepBuckets = [];
|
||||
|
||||
foreach ($metrics as $metric) {
|
||||
$name = strtolower((string) ($metric['name'] ?? ''));
|
||||
$data = array_values(array_filter(is_array($metric['data'] ?? null) ? $metric['data'] : [], 'is_array'));
|
||||
|
||||
if ($name === 'step_count') {
|
||||
foreach ($data as $point) {
|
||||
$date = $this->healthPointDate($point['date'] ?? null);
|
||||
if ($date === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$steps[$date] = ($steps[$date] ?? 0) + max(0, (int) round((float) ($point['qty'] ?? 0)));
|
||||
}
|
||||
}
|
||||
|
||||
if ($name === 'sleep_analysis') {
|
||||
foreach ($data as $point) {
|
||||
$date = $this->healthPointDate($point['date'] ?? ($point['sleepEnd'] ?? ($point['endDate'] ?? null)));
|
||||
if ($date === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$hours = $this->healthSleepHours($point);
|
||||
if ($hours <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$bucket = $sleepBuckets[$date] ?? [
|
||||
'hours' => 0.0,
|
||||
'start' => null,
|
||||
'end' => null,
|
||||
'core' => 0.0,
|
||||
'deep' => 0.0,
|
||||
'rem' => 0.0,
|
||||
];
|
||||
|
||||
if (isset($point['totalSleep']) || isset($point['asleep'])) {
|
||||
$bucket['hours'] = max((float) $bucket['hours'], $hours);
|
||||
} else {
|
||||
$bucket['hours'] = (float) $bucket['hours'] + $hours;
|
||||
}
|
||||
|
||||
foreach (['core', 'deep', 'rem'] as $phase) {
|
||||
if (is_numeric($point[$phase] ?? null)) {
|
||||
$bucket[$phase] = max((float) $bucket[$phase], (float) $point[$phase]);
|
||||
}
|
||||
}
|
||||
|
||||
$start = $this->healthDateTime($point['sleepStart'] ?? ($point['inBedStart'] ?? ($point['startDate'] ?? null)));
|
||||
$end = $this->healthDateTime($point['sleepEnd'] ?? ($point['inBedEnd'] ?? ($point['endDate'] ?? null)));
|
||||
if ($start !== null && ($bucket['start'] === null || $start < $bucket['start'])) {
|
||||
$bucket['start'] = $start;
|
||||
}
|
||||
if ($end !== null && ($bucket['end'] === null || $end > $bucket['end'])) {
|
||||
$bucket['end'] = $end;
|
||||
}
|
||||
|
||||
$sleepBuckets[$date] = $bucket;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$sleep = [];
|
||||
foreach ($sleepBuckets as $date => $bucket) {
|
||||
$commentParts = ['Automatisch importierter Schlaf'];
|
||||
if ($bucket['start'] instanceof DateTimeImmutable && $bucket['end'] instanceof DateTimeImmutable) {
|
||||
$commentParts[] = $bucket['start']->format('H:i') . '-' . $bucket['end']->format('H:i');
|
||||
}
|
||||
foreach (['deep' => 'Tief', 'rem' => 'REM', 'core' => 'Kern'] as $phase => $label) {
|
||||
if ((float) ($bucket[$phase] ?? 0) > 0) {
|
||||
$commentParts[] = $label . ' ' . format_points((float) $bucket[$phase]) . ' h';
|
||||
}
|
||||
}
|
||||
|
||||
$sleep[$date][] = [
|
||||
'id' => 'health-sleep-' . $date,
|
||||
'type' => 'sleep',
|
||||
'time' => $bucket['start'] instanceof DateTimeImmutable ? $bucket['start']->format('H:i') : '',
|
||||
'comment' => implode(' · ', $commentParts),
|
||||
'value' => round((float) $bucket['hours'], 2),
|
||||
'unit' => 'h',
|
||||
'sport_type_id' => '',
|
||||
'consumed' => true,
|
||||
'mood' => 0,
|
||||
'energy' => 0,
|
||||
'stress' => 0,
|
||||
'source' => 'health_auto_export',
|
||||
'import_id' => 'health-sleep-' . $date,
|
||||
'route' => [],
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'steps' => $steps,
|
||||
'sleep' => $sleep,
|
||||
];
|
||||
}
|
||||
|
||||
private function healthEventsFromWorkouts(array $workouts, array &$settings): array
|
||||
{
|
||||
$sport = [];
|
||||
$walk = [];
|
||||
$settingsChanged = false;
|
||||
$sportTypesAdded = 0;
|
||||
|
||||
foreach ($workouts as $workout) {
|
||||
$start = $this->healthDateTime($workout['start'] ?? null);
|
||||
$end = $this->healthDateTime($workout['end'] ?? null);
|
||||
if ($start === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$date = $start->format('Y-m-d');
|
||||
$duration = is_numeric($workout['duration'] ?? null)
|
||||
? ((float) $workout['duration'] / 60)
|
||||
: ($end !== null ? max(0, ($end->getTimestamp() - $start->getTimestamp()) / 60) : 0);
|
||||
if ($duration <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$name = trim((string) ($workout['name'] ?? 'Workout')) ?: 'Workout';
|
||||
$importID = 'health-workout-' . trim((string) ($workout['id'] ?? sha1($name . $start->format(DATE_ATOM) . (string) ($workout['duration'] ?? ''))));
|
||||
$route = $this->healthRouteFromWorkout($workout);
|
||||
$comment = $this->healthWorkoutComment($name, $workout, $start, $end);
|
||||
$durationLabel = format_points($duration) . ' min';
|
||||
$distanceLabel = $this->healthQuantityLabel($workout['distance'] ?? null);
|
||||
$energyLabel = $this->healthQuantityLabel($workout['activeEnergyBurned'] ?? ($workout['activeEnergy'] ?? null));
|
||||
$heartRate = $workout['heartRate']['avg']['qty'] ?? ($workout['avgHeartRate']['qty'] ?? null);
|
||||
$heartRateLabel = is_numeric($heartRate) ? 'Ø Puls ' . (string) round((float) $heartRate) . ' bpm' : '';
|
||||
|
||||
if ($this->healthWorkoutIsWalk($name)) {
|
||||
$walk[$date][] = [
|
||||
'id' => 'health-walk-' . substr(sha1($importID), 0, 12),
|
||||
'type' => 'walk',
|
||||
'time' => $start->format('H:i'),
|
||||
'comment' => $comment,
|
||||
'value' => round($duration),
|
||||
'unit' => 'min',
|
||||
'sport_type_id' => '',
|
||||
'consumed' => true,
|
||||
'mood' => 0,
|
||||
'energy' => 0,
|
||||
'stress' => 0,
|
||||
'source' => 'health_auto_export',
|
||||
'import_id' => $importID,
|
||||
'duration_label' => $durationLabel,
|
||||
'distance_label' => $distanceLabel,
|
||||
'energy_label' => $energyLabel,
|
||||
'heart_rate_label' => $heartRateLabel,
|
||||
'route' => $route,
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
[$sportTypeID, $wasAdded] = $this->healthSportTypeForWorkout($name, $settings);
|
||||
if ($wasAdded) {
|
||||
$settingsChanged = true;
|
||||
$sportTypesAdded++;
|
||||
}
|
||||
|
||||
$sport[$date][] = [
|
||||
'id' => 'health-sport-' . substr(sha1($importID), 0, 12),
|
||||
'type' => 'sport',
|
||||
'time' => $start->format('H:i'),
|
||||
'comment' => $comment,
|
||||
'value' => round($duration),
|
||||
'unit' => 'min',
|
||||
'sport_type_id' => $sportTypeID,
|
||||
'consumed' => true,
|
||||
'mood' => 0,
|
||||
'energy' => 0,
|
||||
'stress' => 0,
|
||||
'source' => 'health_auto_export',
|
||||
'import_id' => $importID,
|
||||
'duration_label' => $durationLabel,
|
||||
'distance_label' => $distanceLabel,
|
||||
'energy_label' => $energyLabel,
|
||||
'heart_rate_label' => $heartRateLabel,
|
||||
'route' => $route,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'sport' => $sport,
|
||||
'walk' => $walk,
|
||||
'settings_changed' => $settingsChanged,
|
||||
'sport_types_added' => $sportTypesAdded,
|
||||
];
|
||||
}
|
||||
|
||||
private function healthPointDate(mixed $value): ?string
|
||||
{
|
||||
$dateTime = $this->healthDateTime($value);
|
||||
|
||||
return $dateTime?->format('Y-m-d');
|
||||
}
|
||||
|
||||
private function healthDateTime(mixed $value): ?DateTimeImmutable
|
||||
{
|
||||
$raw = trim((string) $value);
|
||||
if ($raw === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$timezone = new DateTimeZone(date_default_timezone_get());
|
||||
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $raw) === 1) {
|
||||
$date = DateTimeImmutable::createFromFormat('!Y-m-d', $raw, $timezone);
|
||||
|
||||
return $date instanceof DateTimeImmutable ? $date : null;
|
||||
}
|
||||
|
||||
try {
|
||||
return (new DateTimeImmutable($raw))->setTimezone($timezone);
|
||||
} catch (Exception) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function healthSleepHours(array $point): float
|
||||
{
|
||||
foreach (['totalSleep', 'asleep', 'qty', 'inBed'] as $key) {
|
||||
if (is_numeric($point[$key] ?? null)) {
|
||||
return max(0.0, min(24.0, (float) $point[$key]));
|
||||
}
|
||||
}
|
||||
|
||||
$start = $this->healthDateTime($point['sleepStart'] ?? ($point['startDate'] ?? null));
|
||||
$end = $this->healthDateTime($point['sleepEnd'] ?? ($point['endDate'] ?? null));
|
||||
|
||||
if ($start !== null && $end !== null && $end > $start) {
|
||||
return min(24.0, ($end->getTimestamp() - $start->getTimestamp()) / 3600);
|
||||
}
|
||||
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
private function healthWorkoutIsWalk(string $name): bool
|
||||
{
|
||||
$normalized = normalize_sport_type_id($name);
|
||||
|
||||
return in_array($normalized, ['walking', 'walk', 'outdoor-walk', 'indoor-walk'], true)
|
||||
|| str_contains($normalized, 'walking');
|
||||
}
|
||||
|
||||
private function healthSportTypeForWorkout(string $name, array &$settings): array
|
||||
{
|
||||
$mapping = $this->healthWorkoutSportMapping($name);
|
||||
$candidates = array_values(array_unique(array_filter(array_merge(
|
||||
[(string) $mapping['id'], normalize_sport_type_id($name)],
|
||||
$mapping['aliases']
|
||||
))));
|
||||
|
||||
foreach (normalized_sport_types($settings) as $type) {
|
||||
$typeValues = array_filter([
|
||||
normalize_sport_type_id((string) ($type['id'] ?? '')),
|
||||
normalize_sport_type_id((string) ($type['label'] ?? '')),
|
||||
normalize_sport_type_id((string) ($type['recovery_group'] ?? '')),
|
||||
]);
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
foreach ($typeValues as $typeValue) {
|
||||
if ($candidate === $typeValue || str_contains($typeValue, $candidate) || str_contains($candidate, $typeValue)) {
|
||||
return [(string) ($type['id'] ?? $candidate), false];
|
||||
}
|
||||
}
|
||||
|
||||
if (in_array($candidate, $typeValues, true)) {
|
||||
return [(string) ($type['id'] ?? $candidate), false];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$settings['sport_types'][] = [
|
||||
'id' => (string) $mapping['id'],
|
||||
'label' => (string) $mapping['label'],
|
||||
'icon' => (string) $mapping['icon'],
|
||||
'location' => '',
|
||||
'recovery_group' => (string) $mapping['id'],
|
||||
'bonus_points' => 2,
|
||||
'allow_consecutive' => false,
|
||||
];
|
||||
$settings['sport_types'] = normalized_sport_types($settings);
|
||||
|
||||
return [(string) $mapping['id'], true];
|
||||
}
|
||||
|
||||
private function healthWorkoutSportMapping(string $name): array
|
||||
{
|
||||
$normalized = normalize_sport_type_id($name);
|
||||
$mappings = [
|
||||
'running' => ['label' => 'Joggen', 'icon' => 'run', 'aliases' => ['running', 'run', 'jogging', 'joggen']],
|
||||
'cycling' => ['label' => 'Radfahren', 'icon' => 'bike', 'aliases' => ['cycling', 'biking', 'bike', 'radfahren', 'fahrrad']],
|
||||
'swimming' => ['label' => 'Schwimmen', 'icon' => 'swim', 'aliases' => ['swimming', 'swim', 'schwimmen']],
|
||||
'hiking' => ['label' => 'Wandern', 'icon' => 'hike', 'aliases' => ['hiking', 'wandern', 'hike']],
|
||||
'rowing' => ['label' => 'Rudergerät', 'icon' => 'row', 'aliases' => ['rowing', 'rower', 'rudergeraet', 'rudern']],
|
||||
'yoga' => ['label' => 'Yoga', 'icon' => 'yoga', 'aliases' => ['yoga']],
|
||||
'strength' => ['label' => 'Krafttraining', 'icon' => 'strength', 'aliases' => ['strength', 'strength-training', 'traditional-strength-training', 'functional-strength-training', 'krafttraining']],
|
||||
'hiit-workout' => ['label' => 'HIIT / Workout', 'icon' => 'hiit', 'aliases' => ['hiit', 'high-intensity-interval-training', 'workout', 'cross-training', 'functional-training']],
|
||||
'dance' => ['label' => 'Tanzen', 'icon' => 'dance', 'aliases' => ['dance', 'dancing', 'tanzen']],
|
||||
'core' => ['label' => 'Core', 'icon' => 'core', 'aliases' => ['core', 'core-training']],
|
||||
'pilates' => ['label' => 'Pilates', 'icon' => 'yoga', 'aliases' => ['pilates']],
|
||||
'elliptical' => ['label' => 'Crosstrainer', 'icon' => 'hiit', 'aliases' => ['elliptical', 'cross-trainer', 'crosstrainer']],
|
||||
'stair-climbing' => ['label' => 'Treppensteigen', 'icon' => 'hike', 'aliases' => ['stair-climbing', 'stairs', 'treppensteigen']],
|
||||
];
|
||||
|
||||
foreach ($mappings as $id => $mapping) {
|
||||
foreach ($mapping['aliases'] as $alias) {
|
||||
if ($normalized === $alias || str_contains($normalized, $alias)) {
|
||||
return ['id' => $id] + $mapping;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$id = normalize_sport_type_id($name) ?: 'workout';
|
||||
|
||||
return [
|
||||
'id' => $id,
|
||||
'label' => trim($name) !== '' ? trim($name) : 'Workout',
|
||||
'icon' => 'hiit',
|
||||
'aliases' => [$id],
|
||||
];
|
||||
}
|
||||
|
||||
private function healthWorkoutComment(string $name, array $workout, DateTimeImmutable $start, ?DateTimeImmutable $end): string
|
||||
{
|
||||
$parts = ['Apple Health · ' . $name];
|
||||
if ($end !== null) {
|
||||
$parts[] = $start->format('H:i') . '-' . $end->format('H:i');
|
||||
}
|
||||
|
||||
$heartRate = $workout['heartRate']['avg']['qty'] ?? ($workout['avgHeartRate']['qty'] ?? null);
|
||||
if (is_numeric($heartRate)) {
|
||||
$parts[] = 'Ø Puls ' . (string) round((float) $heartRate) . ' bpm';
|
||||
}
|
||||
|
||||
return implode(' · ', $parts);
|
||||
}
|
||||
|
||||
private function healthQuantityLabel(mixed $quantity): string
|
||||
{
|
||||
if (!is_array($quantity) || !is_numeric($quantity['qty'] ?? null)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$qty = (float) $quantity['qty'];
|
||||
$units = trim((string) ($quantity['units'] ?? ''));
|
||||
|
||||
if ($units === '') {
|
||||
return format_points($qty);
|
||||
}
|
||||
|
||||
return format_points($qty) . ' ' . $units;
|
||||
}
|
||||
|
||||
private function healthRouteFromWorkout(array $workout): array
|
||||
{
|
||||
$route = is_array($workout['route'] ?? null) ? $workout['route'] : [];
|
||||
$points = [];
|
||||
|
||||
foreach ($route as $point) {
|
||||
if (!is_array($point)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$lat = $point['latitude'] ?? ($point['lat'] ?? null);
|
||||
$lon = $point['longitude'] ?? ($point['lon'] ?? null);
|
||||
if (!is_numeric($lat) || !is_numeric($lon)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$lat = (float) $lat;
|
||||
$lon = (float) $lon;
|
||||
if ($lat < -90 || $lat > 90 || $lon < -180 || $lon > 180) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$points[] = ['lat' => round($lat, 6), 'lon' => round($lon, 6)];
|
||||
}
|
||||
|
||||
if (count($points) <= 180) {
|
||||
return $points;
|
||||
}
|
||||
|
||||
$step = max(1, (int) floor(count($points) / 180));
|
||||
$reduced = [];
|
||||
foreach ($points as $index => $point) {
|
||||
if ($index % $step === 0) {
|
||||
$reduced[] = $point;
|
||||
}
|
||||
}
|
||||
|
||||
$last = $points[count($points) - 1];
|
||||
if ($reduced === [] || $reduced[count($reduced) - 1] !== $last) {
|
||||
$reduced[] = $last;
|
||||
}
|
||||
|
||||
return $reduced;
|
||||
}
|
||||
|
||||
private function showDashboard(): void
|
||||
{
|
||||
$user = $this->requireUser();
|
||||
@@ -370,6 +1011,13 @@ final class App
|
||||
|
||||
if ((string) ($event['id'] ?? '') === $eventID) {
|
||||
$updatedEvent['image'] = (string) ($event['image'] ?? '');
|
||||
$updatedEvent['source'] = (string) ($event['source'] ?? '');
|
||||
$updatedEvent['import_id'] = (string) ($event['import_id'] ?? '');
|
||||
$updatedEvent['duration_label'] = (string) ($event['duration_label'] ?? '');
|
||||
$updatedEvent['distance_label'] = (string) ($event['distance_label'] ?? '');
|
||||
$updatedEvent['energy_label'] = (string) ($event['energy_label'] ?? '');
|
||||
$updatedEvent['heart_rate_label'] = (string) ($event['heart_rate_label'] ?? '');
|
||||
$updatedEvent['route'] = is_array($event['route'] ?? null) ? $event['route'] : [];
|
||||
if (is_array($upload) && (int) ($upload['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE) {
|
||||
$this->deleteDashboardImage($user['username'], (string) ($event['image'] ?? ''));
|
||||
$updatedEvent['image'] = $this->storeDashboardImage($user['username'], $date, $upload);
|
||||
@@ -473,12 +1121,106 @@ final class App
|
||||
'mood' => normalize_signal_value($event['mood'] ?? 0),
|
||||
'energy' => normalize_signal_value($event['energy'] ?? 0),
|
||||
'stress' => normalize_signal_value($event['stress'] ?? 0),
|
||||
'source' => (string) ($event['source'] ?? ''),
|
||||
'import_id' => (string) ($event['import_id'] ?? ''),
|
||||
'duration_label' => (string) ($event['duration_label'] ?? ''),
|
||||
'distance_label' => (string) ($event['distance_label'] ?? ''),
|
||||
'energy_label' => (string) ($event['energy_label'] ?? ''),
|
||||
'heart_rate_label' => (string) ($event['heart_rate_label'] ?? ''),
|
||||
'route_map' => $this->buildOsmRouteMap(is_array($event['route'] ?? null) ? $event['route'] : []),
|
||||
];
|
||||
}
|
||||
|
||||
return $timeline;
|
||||
}
|
||||
|
||||
private function buildOsmRouteMap(array $route): ?array
|
||||
{
|
||||
$points = array_values(array_filter($route, static function (mixed $point): bool {
|
||||
return is_array($point)
|
||||
&& is_numeric($point['lat'] ?? null)
|
||||
&& is_numeric($point['lon'] ?? null)
|
||||
&& (float) $point['lat'] >= -90
|
||||
&& (float) $point['lat'] <= 90
|
||||
&& (float) $point['lon'] >= -180
|
||||
&& (float) $point['lon'] <= 180;
|
||||
}));
|
||||
|
||||
if (count($points) < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$width = 320;
|
||||
$height = 168;
|
||||
$tileSize = 256;
|
||||
$padding = 24;
|
||||
$zoom = 15;
|
||||
|
||||
for ($candidateZoom = 16; $candidateZoom >= 3; $candidateZoom--) {
|
||||
$projected = array_map(fn (array $point): array => $this->projectOsmPoint((float) $point['lat'], (float) $point['lon'], $candidateZoom, $tileSize), $points);
|
||||
$xs = array_column($projected, 'x');
|
||||
$ys = array_column($projected, 'y');
|
||||
$spanX = max($xs) - min($xs);
|
||||
$spanY = max($ys) - min($ys);
|
||||
|
||||
if ($spanX <= ($width - ($padding * 2)) && $spanY <= ($height - ($padding * 2))) {
|
||||
$zoom = $candidateZoom;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$projected = array_map(fn (array $point): array => $this->projectOsmPoint((float) $point['lat'], (float) $point['lon'], $zoom, $tileSize), $points);
|
||||
$xs = array_column($projected, 'x');
|
||||
$ys = array_column($projected, 'y');
|
||||
$left = ((min($xs) + max($xs)) / 2) - ($width / 2);
|
||||
$top = ((min($ys) + max($ys)) / 2) - ($height / 2);
|
||||
|
||||
$tileMinX = (int) floor($left / $tileSize);
|
||||
$tileMaxX = (int) floor(($left + $width) / $tileSize);
|
||||
$tileMinY = (int) floor($top / $tileSize);
|
||||
$tileMaxY = (int) floor(($top + $height) / $tileSize);
|
||||
$tileLimit = 2 ** $zoom;
|
||||
$tiles = [];
|
||||
|
||||
for ($x = $tileMinX; $x <= $tileMaxX; $x++) {
|
||||
for ($y = $tileMinY; $y <= $tileMaxY; $y++) {
|
||||
if ($y < 0 || $y >= $tileLimit) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$wrappedX = (($x % $tileLimit) + $tileLimit) % $tileLimit;
|
||||
$tiles[] = [
|
||||
'url' => 'https://tile.openstreetmap.org/' . $zoom . '/' . $wrappedX . '/' . $y . '.png',
|
||||
'left' => round(($x * $tileSize) - $left, 2),
|
||||
'top' => round(($y * $tileSize) - $top, 2),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$linePoints = implode(' ', array_map(
|
||||
static fn (array $point): string => round($point['x'] - $left, 1) . ',' . round($point['y'] - $top, 1),
|
||||
$projected
|
||||
));
|
||||
|
||||
return [
|
||||
'width' => $width,
|
||||
'height' => $height,
|
||||
'tiles' => $tiles,
|
||||
'line' => $linePoints,
|
||||
];
|
||||
}
|
||||
|
||||
private function projectOsmPoint(float $lat, float $lon, int $zoom, int $tileSize): array
|
||||
{
|
||||
$lat = max(-85.05112878, min(85.05112878, $lat));
|
||||
$scale = (2 ** $zoom) * $tileSize;
|
||||
$x = (($lon + 180.0) / 360.0) * $scale;
|
||||
$latRad = deg2rad($lat);
|
||||
$y = (0.5 - (log(tan($latRad) + (1 / cos($latRad))) / (2 * M_PI))) * $scale;
|
||||
|
||||
return ['x' => $x, 'y' => $y];
|
||||
}
|
||||
|
||||
private function buildDashboardCompareDays(string $date, array $entryMap, array $settings): array
|
||||
{
|
||||
$days = [];
|
||||
@@ -571,6 +1313,52 @@ final class App
|
||||
'range' => $start->format('j.n.Y') . ' - ' . $start->modify('+6 day')->format('j.n.Y'),
|
||||
'is_selected' => $isSelected,
|
||||
'days' => $days,
|
||||
'insights' => $this->buildWeekHealthInsights($start, $days, $entryMap),
|
||||
];
|
||||
}
|
||||
|
||||
private function buildWeekHealthInsights(DateTimeImmutable $start, array $days, array $entryMap): array
|
||||
{
|
||||
$weekSteps = [];
|
||||
$sportMinutes = 0;
|
||||
|
||||
foreach ($days as $day) {
|
||||
$entry = is_array($day['entry'] ?? null) ? $day['entry'] : null;
|
||||
if ($entry === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$steps = (int) ($entry['health']['steps'] ?? 0);
|
||||
if ($steps > 0) {
|
||||
$weekSteps[] = $steps;
|
||||
}
|
||||
|
||||
$sportMinutes += (int) ($entry['sport_minutes'] ?? 0);
|
||||
}
|
||||
|
||||
$weekAverageSteps = $weekSteps !== [] ? (int) round(array_sum($weekSteps) / count($weekSteps)) : 0;
|
||||
$previousMonthStart = $start->modify('first day of previous month');
|
||||
$previousMonthEnd = $previousMonthStart->modify('last day of this month');
|
||||
$previousMonthSteps = [];
|
||||
|
||||
for ($day = $previousMonthStart; $day <= $previousMonthEnd; $day = $day->modify('+1 day')) {
|
||||
$entry = $entryMap[$day->format('Y-m-d')] ?? null;
|
||||
$steps = is_array($entry) ? (int) ($entry['health']['steps'] ?? 0) : 0;
|
||||
if ($steps > 0) {
|
||||
$previousMonthSteps[] = $steps;
|
||||
}
|
||||
}
|
||||
|
||||
$previousAverageSteps = $previousMonthSteps !== [] ? (int) round(array_sum($previousMonthSteps) / count($previousMonthSteps)) : 0;
|
||||
$stepDifference = $previousAverageSteps > 0 ? $weekAverageSteps - $previousAverageSteps : 0;
|
||||
|
||||
return [
|
||||
'average_steps' => $weekAverageSteps,
|
||||
'previous_month_average_steps' => $previousAverageSteps,
|
||||
'step_difference' => $stepDifference,
|
||||
'step_direction' => $stepDifference >= 0 ? 'mehr' : 'weniger',
|
||||
'daily_sport_minutes' => (int) round($sportMinutes / 7),
|
||||
'has_step_comparison' => $weekAverageSteps > 0 && $previousAverageSteps > 0,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -656,6 +1444,10 @@ final class App
|
||||
return true;
|
||||
}
|
||||
|
||||
if ((int) ($entry['health']['steps'] ?? 0) > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return count(array_filter(is_array($entry['events'] ?? null) ? $entry['events'] : [], 'is_array')) > 0;
|
||||
}
|
||||
|
||||
@@ -1169,6 +1961,14 @@ final class App
|
||||
}
|
||||
}
|
||||
|
||||
$pendingHealthTokens = is_array($_SESSION['_health_import_token'] ?? null) ? $_SESSION['_health_import_token'] : [];
|
||||
$healthImportToken = $pendingHealthTokens[$user['username']] ?? null;
|
||||
if (is_string($healthImportToken)) {
|
||||
unset($_SESSION['_health_import_token'][$user['username']]);
|
||||
} else {
|
||||
$healthImportToken = null;
|
||||
}
|
||||
|
||||
$optionsOpenPanel = trim((string) ($_GET['panel'] ?? ''));
|
||||
if ($optionsOpenPanel === 'score') {
|
||||
$optionsOpenPanel = '';
|
||||
@@ -1186,6 +1986,9 @@ final class App
|
||||
'pushAvailable' => $pushAvailable,
|
||||
'pushPublicKey' => $pushPublicKey,
|
||||
'pushSubscriptionCount' => $this->notifications->subscriptionCount($user['username']),
|
||||
'healthImportConfig' => $this->users->healthImportConfig($user['username']),
|
||||
'healthImportToken' => $healthImportToken,
|
||||
'healthImportUrl' => app_origin() . '/api/health',
|
||||
'backupAvailable' => class_exists('ZipArchive'),
|
||||
'aiConfig' => $user['is_admin'] ? $this->aiConfig->get() : null,
|
||||
'aiStatus' => $user['is_admin'] ? $this->openAi->configuration() : null,
|
||||
@@ -1258,6 +2061,19 @@ final class App
|
||||
redirect('/options');
|
||||
}
|
||||
|
||||
if ($form === 'health_import_token') {
|
||||
$token = $this->users->issueHealthImportToken($user['username']);
|
||||
$_SESSION['_health_import_token'][$user['username']] = $token;
|
||||
flash('success', 'Der Health-Import-Token wurde erstellt. Kopiere ihn jetzt in Health Auto Export.');
|
||||
redirect('/options?panel=health');
|
||||
}
|
||||
|
||||
if ($form === 'health_import_revoke') {
|
||||
$this->users->revokeHealthImportToken($user['username']);
|
||||
flash('success', 'Der Health-Import-Token wurde deaktiviert.');
|
||||
redirect('/options?panel=health');
|
||||
}
|
||||
|
||||
if ($form === 'password') {
|
||||
$current = (string) ($_POST['current_password'] ?? '');
|
||||
$new = (string) ($_POST['new_password'] ?? '');
|
||||
@@ -1690,6 +2506,15 @@ final class App
|
||||
$settings['scoring']['pain_multiplier'] = max(0, min(10, (int) ($input['scoring']['pain_multiplier'] ?? ($settings['scoring']['pain_multiplier'] ?? 3))));
|
||||
$settings['scoring']['sleep_feeling_multiplier'] = max(0, min(10, (int) ($input['scoring']['sleep_feeling_multiplier'] ?? 2)));
|
||||
$settings['scoring']['journal_points'] = max(0, min(20, (int) ($input['scoring']['journal_points'] ?? 2)));
|
||||
$stepBonus = is_array($settings['scoring']['step_bonus'] ?? null) ? $settings['scoring']['step_bonus'] : $defaults['scoring']['step_bonus'];
|
||||
$settings['scoring']['step_bonus'] = [
|
||||
'min' => max(0, min(100000, (int) ($input['scoring']['step_bonus']['min'] ?? $stepBonus['min'] ?? 10000))),
|
||||
'max' => max(0, min(100000, (int) ($input['scoring']['step_bonus']['max'] ?? $stepBonus['max'] ?? 15000))),
|
||||
'points' => max(0, min(20, (int) ($input['scoring']['step_bonus']['points'] ?? $stepBonus['points'] ?? 1))),
|
||||
];
|
||||
if ($settings['scoring']['step_bonus']['max'] < $settings['scoring']['step_bonus']['min']) {
|
||||
$settings['scoring']['step_bonus']['max'] = $settings['scoring']['step_bonus']['min'];
|
||||
}
|
||||
$settings['tracking'] = [
|
||||
'pain_enabled' => isset($input['tracking']['pain_enabled']) && $input['tracking']['pain_enabled'] === '1',
|
||||
];
|
||||
@@ -2300,7 +3125,7 @@ final class App
|
||||
header('X-Robots-Tag: noindex, nofollow, noarchive, nosnippet');
|
||||
header('Cross-Origin-Opener-Policy: same-origin');
|
||||
header('Permissions-Policy: camera=(), microphone=(), geolocation=()');
|
||||
header("Content-Security-Policy: default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; object-src 'none'");
|
||||
header("Content-Security-Policy: default-src 'self'; img-src 'self' data: https://tile.openstreetmap.org; style-src 'self'; script-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; object-src 'none'");
|
||||
}
|
||||
|
||||
private function enforceCsrf(): void
|
||||
|
||||
@@ -187,6 +187,7 @@ final class EntryRepository
|
||||
'stress' => normalize_signal_value($entry['summary_stress'] ?? legacy_to_signal_scale($entry['stress'] ?? 5)),
|
||||
];
|
||||
$events = is_array($entry['events'] ?? null) ? $entry['events'] : [];
|
||||
$health = is_array($entry['health'] ?? null) ? $entry['health'] : [];
|
||||
$sportTypes = $evaluation['sport_types'] ?? [];
|
||||
$sportTypeValues = array_map(
|
||||
static fn (array $type): string => (string) $type['label'] . ' [' . (string) $type['id'] . ']',
|
||||
@@ -211,6 +212,14 @@ final class EntryRepository
|
||||
$eventLines[] = '- Stimmung: ' . (string) ($event['mood'] ?? 0);
|
||||
$eventLines[] = '- Energie: ' . (string) ($event['energy'] ?? 0);
|
||||
$eventLines[] = '- Stress: ' . (string) ($event['stress'] ?? 0);
|
||||
$eventLines[] = '- Quelle: ' . (string) ($event['source'] ?? '');
|
||||
$eventLines[] = '- Import-ID: ' . (string) ($event['import_id'] ?? '');
|
||||
$eventLines[] = '- Dauer-Label: ' . (string) ($event['duration_label'] ?? '');
|
||||
$eventLines[] = '- Distanz-Label: ' . (string) ($event['distance_label'] ?? '');
|
||||
$eventLines[] = '- Energie-Label: ' . (string) ($event['energy_label'] ?? '');
|
||||
$eventLines[] = '- Puls-Label: ' . (string) ($event['heart_rate_label'] ?? '');
|
||||
$route = is_array($event['route'] ?? null) ? $event['route'] : [];
|
||||
$eventLines[] = '- Route: ' . ($route !== [] ? base64_encode((string) json_encode($route, JSON_UNESCAPED_SLASHES)) : '');
|
||||
$eventLines[] = '';
|
||||
}
|
||||
|
||||
@@ -230,6 +239,10 @@ final class EntryRepository
|
||||
'',
|
||||
'## Ereignisse',
|
||||
...($eventLines !== [] ? $eventLines : ['- Keine Ereignisse', '']),
|
||||
'## Gesundheitsdaten',
|
||||
'- Schritte: ' . (int) ($health['steps'] ?? 0),
|
||||
'- Schritte importiert am: ' . (string) ($health['steps_imported_at'] ?? ''),
|
||||
'',
|
||||
'## Tracking',
|
||||
'## Werte',
|
||||
'- Stimmung: ' . $entry['mood'],
|
||||
@@ -258,6 +271,7 @@ final class EntryRepository
|
||||
'- Sport: ' . format_points((float) $evaluation['components']['sport_minutes']),
|
||||
'- Sportbonus: ' . format_points((float) ($evaluation['components']['sport_bonus'] ?? 0)),
|
||||
'- Spaziergang: ' . format_points((float) $evaluation['components']['walk_minutes']),
|
||||
'- Schrittebonus: ' . format_points((float) ($evaluation['components']['step_bonus'] ?? 0)),
|
||||
'- Momente: ' . format_points((float) ($evaluation['components']['events'] ?? 0)),
|
||||
'- Alkohol: ' . format_points((float) ($evaluation['components']['alcohol'] ?? 0)),
|
||||
'- Notiz: ' . format_points((float) $evaluation['components']['note']),
|
||||
@@ -278,7 +292,9 @@ final class EntryRepository
|
||||
$backgroundImage = '';
|
||||
}
|
||||
$summarySection = $this->extractSection($content, '## Tagesbilanz', '## Ereignisse');
|
||||
$eventsSection = $this->extractSection($content, '## Ereignisse', '## Tracking');
|
||||
$eventsSection = $this->extractSection($content, '## Ereignisse', '## Gesundheitsdaten')
|
||||
?? $this->extractSection($content, '## Ereignisse', '## Tracking');
|
||||
$healthSection = $this->extractSection($content, '## Gesundheitsdaten', '## Tracking');
|
||||
$legacySection = $this->extractSection($content, '## Werte', '## Bewertung');
|
||||
$legacyContent = $legacySection !== null ? "## Werte\n" . $legacySection : $content;
|
||||
|
||||
@@ -320,9 +336,21 @@ final class EntryRepository
|
||||
'mood' => normalize_signal_value($this->extract('/^- Stimmung:\s*(-?\d+)$/m', $block) ?? 0),
|
||||
'energy' => normalize_signal_value($this->extract('/^- Energie:\s*(-?\d+)$/m', $block) ?? 0),
|
||||
'stress' => normalize_signal_value($this->extract('/^- Stress:\s*(-?\d+)$/m', $block) ?? 0),
|
||||
'source' => (string) ($this->extract('/^- Quelle:\s*(.*)$/mu', $block) ?? ''),
|
||||
'import_id' => (string) ($this->extract('/^- Import-ID:\s*(.*)$/mu', $block) ?? ''),
|
||||
'duration_label' => (string) ($this->extract('/^- Dauer-Label:\s*(.*)$/mu', $block) ?? ''),
|
||||
'distance_label' => (string) ($this->extract('/^- Distanz-Label:\s*(.*)$/mu', $block) ?? ''),
|
||||
'energy_label' => (string) ($this->extract('/^- Energie-Label:\s*(.*)$/mu', $block) ?? ''),
|
||||
'heart_rate_label' => (string) ($this->extract('/^- Puls-Label:\s*(.*)$/mu', $block) ?? ''),
|
||||
'route' => $this->decodeRoute((string) ($this->extract('/^- Route:\s*(.*)$/mu', $block) ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
$health = [
|
||||
'steps' => max(0, (int) ($this->extract('/^- Schritte:\s*(\d+)$/m', $healthSection ?? '') ?? 0)),
|
||||
'steps_imported_at' => (string) ($this->extract('/^- Schritte importiert am:\s*(.*)$/mu', $healthSection ?? '') ?? ''),
|
||||
];
|
||||
|
||||
$base['date'] = $date;
|
||||
$base['background_image'] = $backgroundImage;
|
||||
$base['summary'] = $summary;
|
||||
@@ -331,6 +359,7 @@ final class EntryRepository
|
||||
$base['summary_energy'] = $summary['energy'];
|
||||
$base['summary_stress'] = $summary['stress'];
|
||||
$base['summary_alcohol'] = !empty($summary['alcohol']);
|
||||
$base['health'] = $health;
|
||||
$base['events'] = $events;
|
||||
$base['alcohol'] = !empty($summary['alcohol']);
|
||||
$base['note'] = $summary['comment'];
|
||||
@@ -355,4 +384,42 @@ final class EntryRepository
|
||||
|
||||
return preg_match('/^[A-Za-z0-9._-]+\.(?:jpe?g|png|webp)$/i', $fileName) === 1 ? $fileName : '';
|
||||
}
|
||||
|
||||
private function decodeRoute(string $encoded): array
|
||||
{
|
||||
$encoded = trim($encoded);
|
||||
if ($encoded === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$decoded = base64_decode($encoded, true);
|
||||
if (!is_string($decoded)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$route = json_decode($decoded, true);
|
||||
if (!is_array($route)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$points = [];
|
||||
foreach ($route as $point) {
|
||||
if (!is_array($point) || !is_numeric($point['lat'] ?? null) || !is_numeric($point['lon'] ?? null)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$lat = (float) $point['lat'];
|
||||
$lon = (float) $point['lon'];
|
||||
if ($lat < -90 || $lat > 90 || $lon < -180 || $lon > 180) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$points[] = [
|
||||
'lat' => round($lat, 6),
|
||||
'lon' => round($lon, 6),
|
||||
];
|
||||
}
|
||||
|
||||
return $points;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ final class ScoringService
|
||||
$events = $this->normalizeEvents($input['events'] ?? []);
|
||||
$derived = $this->deriveLegacyFieldsFromEvents($events, $summary, $input);
|
||||
$sportTypes = normalize_sport_type_selection($hasEventInput ? ($derived['sport_types'] ?? []) : ($input['sport_types'] ?? ($derived['sport_types'] ?? ($input['sport_type'] ?? []))));
|
||||
$health = $this->normalizeHealth($input['health'] ?? []);
|
||||
|
||||
return [
|
||||
'date' => $input['date'] ?? today(),
|
||||
@@ -46,6 +47,7 @@ final class ScoringService
|
||||
'summary_stress' => $summary['stress'],
|
||||
'summary_alcohol' => !empty($summary['alcohol']),
|
||||
'background_image' => trim((string) ($input['background_image'] ?? '')),
|
||||
'health' => $health,
|
||||
'events' => $events,
|
||||
];
|
||||
}
|
||||
@@ -69,7 +71,8 @@ final class ScoringService
|
||||
'sleep_feeling' => $entry['sleep_feeling'] * (float) $scoring['sleep_feeling_multiplier'],
|
||||
'sport_minutes' => $this->bandPoints((int) $entry['sport_minutes'], $scoring['sport_bands']),
|
||||
'sport_bonus' => $sportBonus,
|
||||
'walk_minutes' => $this->walkPoints($entry, $settings),
|
||||
'walk_minutes' => 0.0,
|
||||
'step_bonus' => $this->stepBonusPoints($entry, $scoring['step_bonus'] ?? []),
|
||||
'events' => $eventSignalPoints,
|
||||
'alcohol' => !empty($entry['alcohol']) ? ((float) abs((float) ($scoring['alcohol_penalty'] ?? 5)) * -1) : 0.0,
|
||||
'note' => $entry['note'] === '' ? 0 : (float) $scoring['journal_points'],
|
||||
@@ -89,7 +92,7 @@ final class ScoringService
|
||||
(5 * (float) $scoring['sleep_feeling_multiplier']) +
|
||||
$this->maxBandPoints($scoring['sport_bands']) +
|
||||
$this->maxSportBonusPoints($settings) +
|
||||
$this->maxWalkPoints($entry, $settings) +
|
||||
max(0.0, (float) ($scoring['step_bonus']['points'] ?? 0)) +
|
||||
($eventSignalPoints !== 0.0 ? 8.0 : 0.0) +
|
||||
(float) $scoring['journal_points'],
|
||||
1
|
||||
@@ -217,6 +220,19 @@ final class ScoringService
|
||||
return $this->bandPoints((int) $entry['walk_minutes'], $scoring['walk_bands'] ?? []);
|
||||
}
|
||||
|
||||
private function stepBonusPoints(array $entry, array $config): float
|
||||
{
|
||||
$steps = (int) ($entry['health']['steps'] ?? 0);
|
||||
$min = max(0, (int) ($config['min'] ?? 10000));
|
||||
$max = max($min, (int) ($config['max'] ?? 15000));
|
||||
|
||||
if ($steps > $min && $steps <= $max) {
|
||||
return max(0.0, (float) ($config['points'] ?? 1));
|
||||
}
|
||||
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
private function maxWalkPoints(array $entry, array $settings): float
|
||||
{
|
||||
$scoring = $settings['scoring'] ?? [];
|
||||
@@ -403,6 +419,13 @@ final class ScoringService
|
||||
'mood' => normalize_signal_value($event['mood'] ?? 0),
|
||||
'energy' => normalize_signal_value($event['energy'] ?? 0),
|
||||
'stress' => normalize_signal_value($event['stress'] ?? 0),
|
||||
'source' => trim((string) ($event['source'] ?? '')),
|
||||
'import_id' => trim((string) ($event['import_id'] ?? '')),
|
||||
'duration_label' => trim((string) ($event['duration_label'] ?? '')),
|
||||
'distance_label' => trim((string) ($event['distance_label'] ?? '')),
|
||||
'energy_label' => trim((string) ($event['energy_label'] ?? '')),
|
||||
'heart_rate_label' => trim((string) ($event['heart_rate_label'] ?? '')),
|
||||
'route' => $this->normalizeRoute($event['route'] ?? []),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -413,6 +436,73 @@ final class ScoringService
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
private function normalizeHealth(mixed $health): array
|
||||
{
|
||||
if (!is_array($health)) {
|
||||
return [
|
||||
'steps' => 0,
|
||||
'steps_imported_at' => '',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'steps' => max(0, min(100000, (int) ($health['steps'] ?? 0))),
|
||||
'steps_imported_at' => trim((string) ($health['steps_imported_at'] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeRoute(mixed $route): array
|
||||
{
|
||||
if (!is_array($route)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$points = [];
|
||||
foreach ($route as $point) {
|
||||
if (!is_array($point)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$lat = $point['lat'] ?? $point['latitude'] ?? null;
|
||||
$lon = $point['lon'] ?? $point['longitude'] ?? null;
|
||||
|
||||
if (!is_numeric($lat) || !is_numeric($lon)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$lat = (float) $lat;
|
||||
$lon = (float) $lon;
|
||||
|
||||
if ($lat < -90 || $lat > 90 || $lon < -180 || $lon > 180) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$points[] = [
|
||||
'lat' => round($lat, 6),
|
||||
'lon' => round($lon, 6),
|
||||
];
|
||||
}
|
||||
|
||||
if (count($points) <= 180) {
|
||||
return $points;
|
||||
}
|
||||
|
||||
$step = max(1, (int) floor(count($points) / 180));
|
||||
$reduced = [];
|
||||
foreach ($points as $index => $point) {
|
||||
if ($index % $step === 0) {
|
||||
$reduced[] = $point;
|
||||
}
|
||||
}
|
||||
|
||||
$last = $points[count($points) - 1];
|
||||
if ($reduced === [] || $reduced[count($reduced) - 1] !== $last) {
|
||||
$reduced[] = $last;
|
||||
}
|
||||
|
||||
return $reduced;
|
||||
}
|
||||
|
||||
private function deriveLegacyFieldsFromEvents(array $events, array $summary, array $input): array
|
||||
{
|
||||
$sportMinutes = 0;
|
||||
|
||||
@@ -38,7 +38,7 @@ final class UserRepository
|
||||
|
||||
public function verify(string $username, string $password): ?array
|
||||
{
|
||||
$user = $this->find($username);
|
||||
$user = $this->find($username) ?? [];
|
||||
|
||||
if ($user === null) {
|
||||
return null;
|
||||
@@ -51,6 +51,257 @@ final class UserRepository
|
||||
return $user;
|
||||
}
|
||||
|
||||
public function findByRememberToken(string $selector, string $validator): ?array
|
||||
{
|
||||
$validatorHash = hash('sha256', $validator);
|
||||
$now = time();
|
||||
|
||||
foreach ($this->all() as $user) {
|
||||
$token = $user['remember_token'] ?? null;
|
||||
|
||||
if (!is_array($token) || !hash_equals((string) ($token['selector'] ?? ''), $selector)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$expiresAt = strtotime((string) ($token['expires_at'] ?? '')) ?: 0;
|
||||
|
||||
if ($expiresAt < $now) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!hash_equals((string) ($token['validator_hash'] ?? ''), $validatorHash)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function storeRememberToken(string $username, string $selector, string $validatorHash, int $expiresAt): void
|
||||
{
|
||||
$normalized = normalize_username($username);
|
||||
$users = $this->all();
|
||||
$updated = false;
|
||||
|
||||
foreach ($users as &$user) {
|
||||
if (($user['username'] ?? '') !== $normalized) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$user['remember_token'] = [
|
||||
'selector' => $selector,
|
||||
'validator_hash' => $validatorHash,
|
||||
'expires_at' => date(DATE_ATOM, $expiresAt),
|
||||
'created_at' => date(DATE_ATOM),
|
||||
];
|
||||
$user['updated_at'] = date(DATE_ATOM);
|
||||
$updated = true;
|
||||
break;
|
||||
}
|
||||
unset($user);
|
||||
|
||||
if (!$updated) {
|
||||
throw new RuntimeException('Der Remember-Me-Token konnte keinem Benutzer zugeordnet werden.');
|
||||
}
|
||||
|
||||
$this->write(['users' => $users]);
|
||||
}
|
||||
|
||||
public function clearRememberToken(string $username): void
|
||||
{
|
||||
$normalized = normalize_username($username);
|
||||
$users = $this->all();
|
||||
$updated = false;
|
||||
|
||||
foreach ($users as &$user) {
|
||||
if (($user['username'] ?? '') !== $normalized || !array_key_exists('remember_token', $user)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
unset($user['remember_token']);
|
||||
$user['updated_at'] = date(DATE_ATOM);
|
||||
$updated = true;
|
||||
break;
|
||||
}
|
||||
unset($user);
|
||||
|
||||
if ($updated) {
|
||||
$this->write(['users' => $users]);
|
||||
}
|
||||
}
|
||||
|
||||
public function findByHealthImportToken(string $token): ?array
|
||||
{
|
||||
$tokenHash = hash('sha256', $token);
|
||||
|
||||
foreach ($this->all() as $user) {
|
||||
$config = $user['health_import'] ?? null;
|
||||
|
||||
if (!is_array($config) || empty($config['enabled'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (hash_equals((string) ($config['token_hash'] ?? ''), $tokenHash)) {
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function healthImportConfig(string $username): array
|
||||
{
|
||||
$user = $this->find($username);
|
||||
$config = is_array($user['health_import'] ?? null) ? $user['health_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'] ?? ''),
|
||||
'progress_done' => max(0, (int) ($config['progress_done'] ?? 0)),
|
||||
'progress_total' => max(0, (int) ($config['progress_total'] ?? 0)),
|
||||
'started_at' => (string) ($config['started_at'] ?? ''),
|
||||
'updated_at' => (string) ($config['updated_at'] ?? ''),
|
||||
'finished_at' => (string) ($config['finished_at'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
public function issueHealthImportToken(string $username): string
|
||||
{
|
||||
$token = 'mood_health_' . 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['health_import'] ?? null) ? $user['health_import'] : [];
|
||||
$user['health_import'] = [
|
||||
'enabled' => true,
|
||||
'token_hash' => hash('sha256', $token),
|
||||
'token_prefix' => substr($token, 0, 18),
|
||||
'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'] ?? ''),
|
||||
'progress_done' => max(0, (int) ($currentConfig['progress_done'] ?? 0)),
|
||||
'progress_total' => max(0, (int) ($currentConfig['progress_total'] ?? 0)),
|
||||
'started_at' => (string) ($currentConfig['started_at'] ?? ''),
|
||||
'updated_at' => (string) ($currentConfig['updated_at'] ?? ''),
|
||||
'finished_at' => (string) ($currentConfig['finished_at'] ?? ''),
|
||||
];
|
||||
$user['updated_at'] = date(DATE_ATOM);
|
||||
$updated = true;
|
||||
break;
|
||||
}
|
||||
unset($user);
|
||||
|
||||
if (!$updated) {
|
||||
throw new RuntimeException('Der Health-Import-Token konnte keinem Benutzer zugeordnet werden.');
|
||||
}
|
||||
|
||||
$this->write(['users' => $users]);
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
public function revokeHealthImportToken(string $username): void
|
||||
{
|
||||
$normalized = normalize_username($username);
|
||||
$users = $this->all();
|
||||
$updated = false;
|
||||
|
||||
foreach ($users as &$user) {
|
||||
if (($user['username'] ?? '') !== $normalized || !array_key_exists('health_import', $user)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
unset($user['health_import']);
|
||||
$user['updated_at'] = date(DATE_ATOM);
|
||||
$updated = true;
|
||||
break;
|
||||
}
|
||||
unset($user);
|
||||
|
||||
if ($updated) {
|
||||
$this->write(['users' => $users]);
|
||||
}
|
||||
}
|
||||
|
||||
public function recordHealthImport(string $username, string $status, string $message): void
|
||||
{
|
||||
$normalized = normalize_username($username);
|
||||
$users = $this->all();
|
||||
$updated = false;
|
||||
|
||||
foreach ($users as &$user) {
|
||||
if (($user['username'] ?? '') !== $normalized) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$config = is_array($user['health_import'] ?? null) ? $user['health_import'] : [];
|
||||
$config['last_import_at'] = date(DATE_ATOM);
|
||||
$config['last_status'] = $status;
|
||||
$config['last_message'] = substr($message, 0, 240);
|
||||
$config['updated_at'] = date(DATE_ATOM);
|
||||
if ($status !== 'running') {
|
||||
$config['finished_at'] = date(DATE_ATOM);
|
||||
if ($status === 'ok') {
|
||||
$total = max(0, (int) ($config['progress_total'] ?? 0));
|
||||
$config['progress_done'] = $total > 0 ? $total : max(0, (int) ($config['progress_done'] ?? 0));
|
||||
}
|
||||
}
|
||||
$user['health_import'] = $config;
|
||||
$user['updated_at'] = date(DATE_ATOM);
|
||||
$updated = true;
|
||||
break;
|
||||
}
|
||||
unset($user);
|
||||
|
||||
if ($updated) {
|
||||
$this->write(['users' => $users]);
|
||||
}
|
||||
}
|
||||
|
||||
public function recordHealthImportProgress(string $username, string $message, int $done, int $total, ?string $startedAt = null): void
|
||||
{
|
||||
$normalized = normalize_username($username);
|
||||
$users = $this->all();
|
||||
$updated = false;
|
||||
|
||||
foreach ($users as &$user) {
|
||||
if (($user['username'] ?? '') !== $normalized) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$config = is_array($user['health_import'] ?? null) ? $user['health_import'] : [];
|
||||
$config['last_status'] = 'running';
|
||||
$config['last_message'] = substr($message, 0, 240);
|
||||
$config['progress_done'] = max(0, min($done, max($total, 0)));
|
||||
$config['progress_total'] = max(0, $total);
|
||||
$config['started_at'] = $startedAt ?? (string) ($config['started_at'] ?? date(DATE_ATOM));
|
||||
$config['updated_at'] = date(DATE_ATOM);
|
||||
$config['finished_at'] = '';
|
||||
$user['health_import'] = $config;
|
||||
$user['updated_at'] = date(DATE_ATOM);
|
||||
$updated = true;
|
||||
break;
|
||||
}
|
||||
unset($user);
|
||||
|
||||
if ($updated) {
|
||||
$this->write(['users' => $users]);
|
||||
}
|
||||
}
|
||||
|
||||
public function create(string $username, string $password, bool $isAdmin = false): array
|
||||
{
|
||||
$normalized = normalize_username($username);
|
||||
|
||||
+60
-1
@@ -11,7 +11,7 @@ final class Auth
|
||||
public function check(): bool
|
||||
{
|
||||
if (!isset($_SESSION['user']) || !is_array($_SESSION['user'])) {
|
||||
return false;
|
||||
return $this->attemptRememberedLogin();
|
||||
}
|
||||
|
||||
$username = $_SESSION['user']['username'] ?? null;
|
||||
@@ -62,17 +62,76 @@ final class Auth
|
||||
$_SESSION['remember_me'] = $remember;
|
||||
|
||||
if ($remember) {
|
||||
$this->issueRememberCookie($user['username']);
|
||||
setcookie(session_name(), session_id(), session_cookie_options_for(time() + remember_me_lifetime()));
|
||||
} else {
|
||||
$this->users->clearRememberToken($user['username']);
|
||||
$this->clearRememberCookie();
|
||||
setcookie(session_name(), session_id(), session_cookie_options_for());
|
||||
}
|
||||
}
|
||||
|
||||
public function logout(): void
|
||||
{
|
||||
$username = $_SESSION['user']['username'] ?? null;
|
||||
|
||||
if (is_string($username) && $username !== '') {
|
||||
$this->users->clearRememberToken($username);
|
||||
}
|
||||
|
||||
unset($_SESSION['user']);
|
||||
unset($_SESSION['remember_me']);
|
||||
$this->clearRememberCookie();
|
||||
setcookie(session_name(), '', session_cookie_options_for(time() - 3600));
|
||||
session_regenerate_id(true);
|
||||
}
|
||||
|
||||
private function attemptRememberedLogin(): bool
|
||||
{
|
||||
$cookie = $_COOKIE[remember_cookie_name()] ?? '';
|
||||
|
||||
if (!is_string($cookie) || $cookie === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$parts = explode(':', $cookie, 2);
|
||||
|
||||
if (count($parts) !== 2) {
|
||||
$this->clearRememberCookie();
|
||||
return false;
|
||||
}
|
||||
|
||||
[$selector, $validator] = $parts;
|
||||
|
||||
if (!ctype_xdigit($selector) || strlen($selector) !== 32 || !ctype_xdigit($validator) || strlen($validator) !== 64) {
|
||||
$this->clearRememberCookie();
|
||||
return false;
|
||||
}
|
||||
|
||||
$user = $this->users->findByRememberToken($selector, $validator);
|
||||
|
||||
if ($user === null) {
|
||||
$this->clearRememberCookie();
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->login($user, true);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function issueRememberCookie(string $username): void
|
||||
{
|
||||
$selector = bin2hex(random_bytes(16));
|
||||
$validator = bin2hex(random_bytes(32));
|
||||
$expiresAt = time() + remember_me_lifetime();
|
||||
|
||||
$this->users->storeRememberToken($username, $selector, hash('sha256', $validator), $expiresAt);
|
||||
setcookie(remember_cookie_name(), $selector . ':' . $validator, session_cookie_options_for($expiresAt));
|
||||
}
|
||||
|
||||
private function clearRememberCookie(): void
|
||||
{
|
||||
setcookie(remember_cookie_name(), '', session_cookie_options_for(time() - 3600));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ final class Defaults
|
||||
],
|
||||
[
|
||||
'id' => 'rowing',
|
||||
'label' => 'Rudern',
|
||||
'label' => 'Rudergerät',
|
||||
'icon' => 'row',
|
||||
'location' => '',
|
||||
'recovery_group' => 'rudern',
|
||||
@@ -152,6 +152,11 @@ final class Defaults
|
||||
['steps' => 15000, 'points' => 4],
|
||||
['steps' => 20000, 'points' => 0],
|
||||
],
|
||||
'step_bonus' => [
|
||||
'min' => 10000,
|
||||
'max' => 15000,
|
||||
'points' => 1,
|
||||
],
|
||||
'journal_points' => 2,
|
||||
'alcohol_penalty' => 5,
|
||||
],
|
||||
|
||||
@@ -335,6 +335,11 @@ function remember_me_lifetime(): int
|
||||
return 60 * 60 * 24 * 30;
|
||||
}
|
||||
|
||||
function remember_cookie_name(): string
|
||||
{
|
||||
return 'mood_remember';
|
||||
}
|
||||
|
||||
function session_cookie_params_for(int $lifetime = 0): array
|
||||
{
|
||||
return [
|
||||
@@ -786,6 +791,10 @@ function day_entry_has_content(array $entry): bool
|
||||
return true;
|
||||
}
|
||||
|
||||
if ((int) ($entry['health']['steps'] ?? 0) > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return count(array_filter(is_array($entry['events'] ?? null) ? $entry['events'] : [], 'is_array')) > 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,9 @@ $summaryMood = normalize_signal_value($dayEntry['summary']['mood'] ?? $dayEntry[
|
||||
$summaryEnergy = normalize_signal_value($dayEntry['summary']['energy'] ?? $dayEntry['summary_energy'] ?? 0);
|
||||
$summaryStress = normalize_signal_value($dayEntry['summary']['stress'] ?? $dayEntry['summary_stress'] ?? 0);
|
||||
$summaryAlcohol = !empty($dayEntry['summary']['alcohol'] ?? $dayEntry['summary_alcohol'] ?? false);
|
||||
$dayHealth = is_array($dayEntry['health'] ?? null) ? $dayEntry['health'] : [];
|
||||
$daySteps = (int) ($dayHealth['steps'] ?? 0);
|
||||
$dayStepBonus = (float) ($dayEntry['evaluation']['components']['step_bonus'] ?? 0);
|
||||
?>
|
||||
|
||||
<section class="dashboard-shell<?= $dayBackground !== null ? ' dashboard-shell--with-image' : '' ?>" data-dashboard-root>
|
||||
@@ -48,6 +51,14 @@ $summaryAlcohol = !empty($dayEntry['summary']['alcohol'] ?? $dayEntry['summary_a
|
||||
<button class="day-summary-card glass-panel<?= $dashboardHasContent ? ' is-filled' : '' ?>" type="button" data-summary-overlay-open>
|
||||
<span class="day-summary-card__label">Tagesbilanz</span>
|
||||
<strong class="day-summary-card__title"><?= $summaryComment !== '' ? e($summaryComment) : 'Tagesbilanz' ?></strong>
|
||||
<?php if ($daySteps > 0): ?>
|
||||
<span class="day-summary-card__chips">
|
||||
<span class="day-chip"><?= e(number_format($daySteps, 0, ',', '.')) ?> Schritte</span>
|
||||
<?php if ($dayStepBonus > 0): ?>
|
||||
<span class="day-chip day-chip--bonus">+<?= e(format_points($dayStepBonus)) ?> Punkt</span>
|
||||
<?php endif; ?>
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
</button>
|
||||
|
||||
<section class="dashboard-moments-block">
|
||||
@@ -70,6 +81,7 @@ $summaryAlcohol = !empty($dayEntry['summary']['alcohol'] ?? $dayEntry['summary_a
|
||||
|
||||
<?php foreach ($dashboardTimeline as $item): ?>
|
||||
<?php $eventType = (string) ($item['type'] ?? 'event'); ?>
|
||||
<?php $eventComment = trim((string) ($item['comment'] ?? '')); ?>
|
||||
<?php $sportType = $eventType === 'sport' ? find_sport_type($settings, (string) ($item['sport_type_id'] ?? '')) : null; ?>
|
||||
<?php $eventTone = signal_value_class(normalize_signal_value($item['mood'] ?? 0)); ?>
|
||||
<?php $eventValueText = (float) $item['value'] > 0 ? rtrim(rtrim(number_format((float) $item['value'], 2, ',', '.'), '0'), ',') . ' ' . (string) $item['unit'] : ''; ?>
|
||||
@@ -84,6 +96,13 @@ $summaryAlcohol = !empty($dayEntry['summary']['alcohol'] ?? $dayEntry['summary_a
|
||||
'walk', 'sleep' => trim($eventValueText),
|
||||
default => trim($eventValueText . ($sportType !== null ? ' · ' . (string) ($sportType['label'] ?? '') : '')),
|
||||
}; ?>
|
||||
<?php $showEventComment = in_array($eventType, ['sport', 'walk', 'sleep'], true) && $eventComment !== ''; ?>
|
||||
<?php $eventStats = array_values(array_filter([
|
||||
(string) ($item['duration_label'] ?? ''),
|
||||
(string) ($item['distance_label'] ?? ''),
|
||||
(string) ($item['energy_label'] ?? ''),
|
||||
(string) ($item['heart_rate_label'] ?? ''),
|
||||
], static fn (string $value): bool => trim($value) !== '')); ?>
|
||||
<?php $eventPayload = encode_payload([
|
||||
'id' => (string) ($item['id'] ?? ''),
|
||||
'type' => (string) ($item['type'] ?? 'event'),
|
||||
@@ -97,8 +116,15 @@ $summaryAlcohol = !empty($dayEntry['summary']['alcohol'] ?? $dayEntry['summary_a
|
||||
'mood' => normalize_signal_value($item['mood'] ?? 0),
|
||||
'energy' => normalize_signal_value($item['energy'] ?? 0),
|
||||
'stress' => normalize_signal_value($item['stress'] ?? 0),
|
||||
'source' => (string) ($item['source'] ?? ''),
|
||||
'import_id' => (string) ($item['import_id'] ?? ''),
|
||||
'duration_label' => (string) ($item['duration_label'] ?? ''),
|
||||
'distance_label' => (string) ($item['distance_label'] ?? ''),
|
||||
'energy_label' => (string) ($item['energy_label'] ?? ''),
|
||||
'heart_rate_label' => (string) ($item['heart_rate_label'] ?? ''),
|
||||
]); ?>
|
||||
<?php $hasEventImage = is_string($item['image_url'] ?? null); ?>
|
||||
<?php $routeMap = is_array($item['route_map'] ?? null) ? $item['route_map'] : null; ?>
|
||||
<article class="timeline-card timeline-card--event timeline-card--<?= e($eventTone) ?><?= $hasEventImage ? ' timeline-card--with-image' : '' ?> glass-panel" data-event-editable data-event-payload="<?= e($eventPayload) ?>">
|
||||
<?php if ($hasEventImage): ?>
|
||||
<img class="timeline-card__image" src="<?= e((string) $item['image_url']) ?>" alt="">
|
||||
@@ -115,6 +141,9 @@ $summaryAlcohol = !empty($dayEntry['summary']['alcohol'] ?? $dayEntry['summary_a
|
||||
|
||||
<div class="timeline-card__body">
|
||||
<h3><?= e($eventTitle) ?></h3>
|
||||
<?php if ($showEventComment): ?>
|
||||
<p class="timeline-card__comment"><?= e($eventComment) ?></p>
|
||||
<?php endif; ?>
|
||||
<?php if ((string) ($item['type'] ?? '') === 'alcohol'): ?>
|
||||
<p class="timeline-card__value">
|
||||
<?= !empty($item['consumed']) ? 'Heute getrunken' : 'Heute nicht getrunken' ?>
|
||||
@@ -127,6 +156,26 @@ $summaryAlcohol = !empty($dayEntry['summary']['alcohol'] ?? $dayEntry['summary_a
|
||||
<p class="timeline-card__value"><?= e((string) $item['comment']) ?></p>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($eventStats !== []): ?>
|
||||
<div class="timeline-card__stats" aria-label="Importdetails">
|
||||
<?php foreach ($eventStats as $stat): ?>
|
||||
<span><?= e($stat) ?></span>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($routeMap !== null): ?>
|
||||
<div class="timeline-route-map" aria-label="Route auf OpenStreetMap">
|
||||
<svg viewBox="0 0 <?= e((string) $routeMap['width']) ?> <?= e((string) $routeMap['height']) ?>" aria-hidden="true">
|
||||
<?php foreach ($routeMap['tiles'] as $tile): ?>
|
||||
<image href="<?= e((string) $tile['url']) ?>" x="<?= e((string) $tile['left']) ?>" y="<?= e((string) $tile['top']) ?>" width="256" height="256"></image>
|
||||
<?php endforeach; ?>
|
||||
<polyline points="<?= e((string) $routeMap['line']) ?>"></polyline>
|
||||
</svg>
|
||||
<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener noreferrer">© OpenStreetMap</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="signal-row">
|
||||
<?php foreach (['mood' => 'Stimmung', 'energy' => 'Energie', 'stress' => 'Stress'] as $metric => $label): ?>
|
||||
<?php $value = normalize_signal_value($item[$metric] ?? 0); ?>
|
||||
@@ -371,6 +420,23 @@ $summaryAlcohol = !empty($dayEntry['summary']['alcohol'] ?? $dayEntry['summary_a
|
||||
<h2><?= e($dashboardWeek['range']) ?></h2>
|
||||
</header>
|
||||
|
||||
<?php $weekInsights = is_array($dashboardWeek['insights'] ?? null) ? $dashboardWeek['insights'] : []; ?>
|
||||
<?php if ((int) ($weekInsights['average_steps'] ?? 0) > 0 || (int) ($weekInsights['daily_sport_minutes'] ?? 0) > 0): ?>
|
||||
<section class="week-insight-card glass-panel">
|
||||
<?php if ((int) ($weekInsights['average_steps'] ?? 0) > 0): ?>
|
||||
<p>
|
||||
Du bist in dieser Woche durchschnittlich <strong><?= e(number_format((int) $weekInsights['average_steps'], 0, ',', '.')) ?> Schritte</strong> gegangen.
|
||||
<?php if (!empty($weekInsights['has_step_comparison'])): ?>
|
||||
Das sind <strong><?= e(number_format(abs((int) $weekInsights['step_difference']), 0, ',', '.')) ?> Schritte <?= e((string) $weekInsights['step_direction']) ?></strong> als im vergangenen Monat.
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
<?php if ((int) ($weekInsights['daily_sport_minutes'] ?? 0) > 0): ?>
|
||||
<p>Täglich hast du im Schnitt <strong><?= e((string) $weekInsights['daily_sport_minutes']) ?> Minuten Sport</strong> gemacht.</p>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="range-period-rail range-period-rail--week">
|
||||
<?php foreach (($dashboardWeek['periods'] ?? [$dashboardWeek]) as $week): ?>
|
||||
<article class="range-period-panel<?= !empty($week['is_selected']) ? ' is-selected' : '' ?>">
|
||||
@@ -522,6 +588,7 @@ $summaryAlcohol = !empty($dayEntry['summary']['alcohol'] ?? $dayEntry['summary_a
|
||||
<div class="settings-menu-grid">
|
||||
<a class="options-menu-card" href="/options?panel=sports"><strong>Sportarten anpassen</strong><span>Eigene Sportarten und Bonuspunkte</span></a>
|
||||
<a class="options-menu-card" href="/options?panel=walk"><strong>Spaziergang anpassen</strong><span>Zeit oder Schritte auswerten</span></a>
|
||||
<a class="options-menu-card" href="/options?panel=health"><strong>Health Import</strong><span>Apple Health automatisch übernehmen</span></a>
|
||||
<a class="options-menu-card" href="/options?panel=reminders"><strong>Erinnerungen setzen</strong><span>Push und tägliche Erinnerung</span></a>
|
||||
<a class="options-menu-card" href="/options?panel=ratings"><strong>Bewertungsskala ändern</strong><span>Labels und Schutzregeln</span></a>
|
||||
<a class="options-menu-card" href="/options?panel=stats"><strong>Statistik</strong><span>Verlauf und Aktivität</span></a>
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
<button class="options-menu-card" type="button" data-options-open="sports"><strong>Sportarten anpassen</strong><span>Eigene Sportarten und Bonuspunkte</span></button>
|
||||
<button class="options-menu-card" type="button" data-options-open="walk"><strong>Spaziergang anpassen</strong><span>Zeit oder Schritte auswerten</span></button>
|
||||
<button class="options-menu-card" type="button" data-options-open="reminders"><strong>Erinnerungen setzen</strong><span>Push und tägliche Erinnerung</span></button>
|
||||
<button class="options-menu-card" type="button" data-options-open="health"><strong>Health Import</strong><span>Apple Health automatisch übernehmen</span></button>
|
||||
<button class="options-menu-card" type="button" data-options-open="ratings"><strong>Bewertungsskala ändern</strong><span>Labels und Schutzregeln</span></button>
|
||||
<button class="options-menu-card" type="button" data-options-open="stats"><strong>Statistik</strong><span>Verlauf und Aktivität</span></button>
|
||||
<?php if (!empty($authUser['is_admin'])): ?>
|
||||
@@ -94,13 +95,21 @@
|
||||
</div>
|
||||
|
||||
<div class="options-panel" data-options-panel="walk" hidden>
|
||||
<h2>Spaziergang anpassen</h2>
|
||||
<h2>Spaziergang und Schritte</h2>
|
||||
<form method="post" action="/options" class="stack-form">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="form_name" value="settings">
|
||||
<label><span>Spaziergang auswerten nach</span><select name="settings[walk][mode]"><?php foreach ($walkModeOptions as $modeValue => $modeLabel): ?><option value="<?= e($modeValue) ?>" <?= ($settings['walk']['mode'] ?? 'time') === $modeValue ? 'selected' : '' ?>><?= e($modeLabel) ?></option><?php endforeach; ?></select></label>
|
||||
<div class="band-grid"><?php foreach ($settings['scoring']['walk_bands'] as $index => $band): ?><div class="band-card"><label><span>Min</span><input type="number" name="settings[scoring][walk_bands][<?= e((string) $index) ?>][min]" value="<?= e((string) $band['min']) ?>"></label><label><span>Max</span><input type="number" name="settings[scoring][walk_bands][<?= e((string) $index) ?>][max]" value="<?= e((string) $band['max']) ?>"></label><label><span>Punkte</span><input type="number" name="settings[scoring][walk_bands][<?= e((string) $index) ?>][points]" value="<?= e((string) $band['points']) ?>"></label></div><?php endforeach; ?></div>
|
||||
<button class="primary-button" type="submit">Spaziergang speichern</button>
|
||||
<p class="helper-text">Spaziergänge werden als Momente angezeigt. Punkte kommen nicht mehr aus einzelnen Spaziergängen, sondern aus der täglichen Gesamtschrittzahl.</p>
|
||||
<label><span>Spaziergang anzeigen nach</span><select name="settings[walk][mode]"><?php foreach ($walkModeOptions as $modeValue => $modeLabel): ?><option value="<?= e($modeValue) ?>" <?= ($settings['walk']['mode'] ?? 'time') === $modeValue ? 'selected' : '' ?>><?= e($modeLabel) ?></option><?php endforeach; ?></select></label>
|
||||
<div class="settings-section">
|
||||
<h4>Schritte-Bonus</h4>
|
||||
<div class="field-grid field-grid--three">
|
||||
<label><span>Mehr als</span><input type="number" name="settings[scoring][step_bonus][min]" value="<?= e((string) ($settings['scoring']['step_bonus']['min'] ?? 10000)) ?>" min="0" max="100000"></label>
|
||||
<label><span>Bis einschließlich</span><input type="number" name="settings[scoring][step_bonus][max]" value="<?= e((string) ($settings['scoring']['step_bonus']['max'] ?? 15000)) ?>" min="0" max="100000"></label>
|
||||
<label><span>Bonuspunkte</span><input type="number" name="settings[scoring][step_bonus][points]" value="<?= e((string) ($settings['scoring']['step_bonus']['points'] ?? 1)) ?>" min="0" max="20"></label>
|
||||
</div>
|
||||
</div>
|
||||
<button class="primary-button" type="submit">Schritte speichern</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -121,6 +130,63 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="options-panel" data-options-panel="health" hidden>
|
||||
<h2>Health Import</h2>
|
||||
<article class="detail-card detail-card--overlay">
|
||||
<p class="eyebrow">REST-Endpunkt</p>
|
||||
<div class="stack-form">
|
||||
<label><span>URL in Health Auto Export</span><input type="text" value="<?= e((string) $healthImportUrl) ?>" readonly></label>
|
||||
<label><span>HTTP-Header</span><input type="text" value="Authorization: Bearer <?= !empty($healthImportConfig['token_prefix']) ? e((string) $healthImportConfig['token_prefix']) . '…' : 'TOKEN' ?>" readonly></label>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<?php if (!empty($healthImportToken)): ?>
|
||||
<article class="detail-card detail-card--overlay health-token-card">
|
||||
<p class="eyebrow">Neuer Token</p>
|
||||
<label><span>Nur jetzt sichtbar</span><input type="text" value="<?= e((string) $healthImportToken) ?>" readonly></label>
|
||||
<p class="helper-text">Kopiere diesen Token als Bearer-Token in Health Auto Export. Danach wird nur noch der Anfang angezeigt.</p>
|
||||
</article>
|
||||
<?php endif; ?>
|
||||
|
||||
<article class="detail-card detail-card--overlay" data-health-import-status>
|
||||
<p class="eyebrow">Status</p>
|
||||
<div class="health-import-progress" data-health-progress-wrap data-progress-done="<?= e((string) ($healthImportConfig['progress_done'] ?? 0)) ?>" data-progress-total="<?= e((string) ($healthImportConfig['progress_total'] ?? 0)) ?>">
|
||||
<progress class="health-import-progress__bar" data-health-progress-bar max="100" value="0">0%</progress>
|
||||
<p class="helper-text" data-health-progress-text>
|
||||
<?php if (($healthImportConfig['last_status'] ?? '') === 'running'): ?>
|
||||
Import läuft: <?= e((string) ($healthImportConfig['progress_done'] ?? 0)) ?> von <?= e((string) ($healthImportConfig['progress_total'] ?? 0)) ?> verarbeitet.
|
||||
<?php elseif (!empty($healthImportConfig['last_message'])): ?>
|
||||
<?= e((string) $healthImportConfig['last_message']) ?>
|
||||
<?php else: ?>
|
||||
Noch kein Import gelaufen.
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
</div>
|
||||
<div class="user-list">
|
||||
<div class="user-row"><strong>Token</strong><span data-health-token-state><?= !empty($healthImportConfig['enabled']) ? 'aktiv' : 'nicht eingerichtet' ?></span></div>
|
||||
<?php if (!empty($healthImportConfig['last_import_at'])): ?>
|
||||
<div class="user-row"><strong>Letzter Import</strong><span data-health-last-import><?= e(format_display_datetime((string) $healthImportConfig['last_import_at'])) ?></span></div>
|
||||
<?php else: ?>
|
||||
<div class="user-row"><strong>Letzter Import</strong><span data-health-last-import>-</span></div>
|
||||
<?php endif; ?>
|
||||
<div class="user-row"><strong>Statusmeldung</strong><span data-health-last-message><?= !empty($healthImportConfig['last_message']) ? e((string) $healthImportConfig['last_message']) : '-' ?></span></div>
|
||||
</div>
|
||||
<form method="post" action="/options" class="stack-form">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="form_name" value="health_import_token">
|
||||
<button class="primary-button" type="submit"><?= !empty($healthImportConfig['enabled']) ? 'Token neu erstellen' : 'Token erstellen' ?></button>
|
||||
</form>
|
||||
<?php if (!empty($healthImportConfig['enabled'])): ?>
|
||||
<form method="post" action="/options" class="stack-form">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="form_name" value="health_import_revoke">
|
||||
<button class="ghost-button" type="submit">Token deaktivieren</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="options-panel" data-options-panel="ratings" hidden>
|
||||
<h2>Bewertungsskala ändern</h2>
|
||||
<form method="post" action="/options" class="stack-form stack-form--spacious">
|
||||
|
||||
Reference in New Issue
Block a user