14 Commits

10 changed files with 598 additions and 120 deletions
+246 -99
View File
@@ -540,6 +540,11 @@ body.page-dashboard .content {
position: relative;
z-index: 1;
margin-bottom: 0;
border: 1px solid rgba(143, 191, 255, 0.22);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.025)),
rgba(6, 17, 30, 0.18);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08), 0 18px 54px rgba(0, 0, 0, 0.14);
touch-action: pan-y;
transform: translate3d(var(--day-slider-offset, 0), 0, 0) scale(var(--day-slider-scale, 1));
transition: transform 260ms cubic-bezier(0.2, 0.8, 0.2, 1);
@@ -996,47 +1001,45 @@ body.page-dashboard .content {
.sleep-phase-bar {
position: relative;
height: 2.35rem;
height: 0.74rem;
margin-top: 1.55rem;
overflow: visible;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.16);
background: rgba(255, 255, 255, 0.16);
}
.sleep-phase-bar__svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: hidden;
}
.sleep-phase-bar__track {
fill: rgba(255, 255, 255, 0.16);
}
.sleep-phase-bar__fill {
display: block;
height: 100%;
min-width: 0.28rem;
max-width: 100%;
overflow: hidden;
border-radius: inherit;
background: linear-gradient(135deg, rgba(90, 188, 242, 0.92), rgba(44, 126, 190, 0.92));
box-shadow: 0 10px 22px rgba(44, 126, 190, 0.22);
fill: #64d2ff;
filter: drop-shadow(0 0 5px rgba(10, 132, 255, 0.48));
}
.sleep-phase-bar__target {
position: absolute;
left: min(var(--sleep-optimal-left, 100%), calc(100% - 2px));
top: -1.28rem;
bottom: 0;
width: 0;
border-left: 2px solid rgba(255, 255, 255, 0.92);
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.45));
pointer-events: none;
z-index: 3;
.sleep-phase-bar__target-line {
stroke: rgba(255, 255, 255, 0.95);
stroke-width: 1.25;
vector-effect: non-scaling-stroke;
}
.sleep-phase-bar__target span {
position: absolute;
top: 0;
left: 0.28rem;
padding: 0.08rem 0.34rem;
border-radius: 999px;
background: rgba(6, 16, 28, 0.72);
color: rgba(255, 255, 255, 0.95);
font-size: 0.68rem;
white-space: nowrap;
.sleep-phase-summary {
display: flex;
justify-content: space-between;
gap: 0.75rem;
margin-top: 0.55rem;
color: rgba(239, 247, 255, 0.64);
font-size: 0.78rem;
font-weight: 650;
}
.sleep-phase-bar__segment {
@@ -1342,7 +1345,9 @@ body.page-dashboard .content {
.dashboard-fab {
position: fixed;
right: max(1rem, env(safe-area-inset-right));
bottom: calc(1.2rem + env(safe-area-inset-bottom));
top: calc(0.95rem + env(safe-area-inset-top));
bottom: auto;
z-index: 124;
width: 4rem;
height: 4rem;
border: 0;
@@ -1366,15 +1371,16 @@ body.page-dashboard .content {
.dashboard-fab-menu {
position: fixed;
right: max(1rem, env(safe-area-inset-right));
bottom: calc(5.8rem + env(safe-area-inset-bottom));
z-index: 28;
top: calc(5.45rem + env(safe-area-inset-top));
bottom: auto;
z-index: 123;
display: grid;
gap: 0.55rem;
width: min(18rem, calc(100vw - 2rem));
padding: 0.7rem;
border-radius: 1.6rem;
animation: fabMenuIn 180ms ease both;
transform-origin: right bottom;
transform-origin: right top;
}
.dashboard-fab-menu button {
@@ -1396,7 +1402,7 @@ body.page-dashboard .content {
}
@keyframes fabMenuIn {
from { opacity: 0; transform: translateY(0.6rem) scale(0.94); }
from { opacity: 0; transform: translateY(-0.4rem) scale(0.94); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@@ -2240,6 +2246,17 @@ body.page-dashboard .content {
border-radius: var(--radius-xl);
}
.options-menu-panel .section-head {
margin-bottom: 1.1rem;
padding: 1.15rem;
border-radius: 1.8rem;
background:
radial-gradient(circle at 20% 0%, rgba(255, 255, 255, 0.18), transparent 52%),
linear-gradient(180deg, rgba(68, 92, 118, 0.38), rgba(20, 38, 58, 0.28));
border: 1px solid rgba(180, 214, 246, 0.16);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.12);
}
.options-menu-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -2258,15 +2275,18 @@ body.page-dashboard .content {
gap: 0.35rem;
align-items: flex-start;
width: 100%;
padding: 1.15rem 1.25rem;
border-radius: 1.55rem;
border: 1px solid rgba(152, 194, 232, 0.16);
min-height: 6.7rem;
padding: 1.2rem 1.25rem;
border-radius: 1.75rem;
border: 1px solid rgba(190, 222, 250, 0.18);
background:
linear-gradient(180deg, rgba(41, 59, 80, 0.72), rgba(25, 42, 63, 0.6)),
radial-gradient(circle at top left, rgba(255, 255, 255, 0.08), transparent 48%);
radial-gradient(circle at 18% 0%, rgba(255, 255, 255, 0.18), transparent 42%),
linear-gradient(180deg, rgba(60, 81, 105, 0.52), rgba(24, 41, 62, 0.34));
color: var(--text);
text-decoration: none;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
box-shadow: 0 16px 42px rgba(0, 0, 0, 0.16), inset 0 1px 0 rgba(255, 255, 255, 0.12);
backdrop-filter: blur(24px) saturate(1.45);
-webkit-backdrop-filter: blur(24px) saturate(1.45);
}
.options-menu-card strong {
@@ -2279,6 +2299,10 @@ body.page-dashboard .content {
text-align: left;
}
.options-menu-card:active {
transform: scale(0.985);
}
.options-menu-card--danger {
background: rgba(255, 130, 130, 0.08);
border-color: rgba(255, 143, 143, 0.18);
@@ -2459,9 +2483,12 @@ body.page-dashboard .content {
}
.dashboard-shell__background {
inset: calc(-1 * env(safe-area-inset-top)) 0 calc(-1 * env(safe-area-inset-bottom)) 0;
top: calc(-1 * env(safe-area-inset-top));
right: 0;
bottom: calc(-1 * env(safe-area-inset-bottom));
left: 0;
width: 100%;
height: calc(100dvh + env(safe-area-inset-top) + env(safe-area-inset-bottom));
height: auto;
transform: none;
}
@@ -4722,58 +4749,69 @@ input[type="range"] {
.ios-tabbar {
position: fixed;
left: 0.75rem;
right: 0.75rem;
bottom: max(0.65rem, env(safe-area-inset-bottom));
left: 0.9rem;
right: 0.9rem;
bottom: max(0.8rem, env(safe-area-inset-bottom));
z-index: 120;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.25rem;
min-height: 4.8rem;
gap: 0.26rem;
min-height: 4.45rem;
padding: 0.48rem;
border: 1px solid rgba(255, 255, 255, 0.28);
border-radius: 2rem;
border: 1px solid rgba(255, 255, 255, 0.32);
border-radius: 2.05rem;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.22), rgba(255, 255, 255, 0.08)),
rgba(16, 25, 38, 0.58);
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.34), inset 0 1px 0 rgba(255, 255, 255, 0.22);
backdrop-filter: blur(32px) saturate(1.6);
-webkit-backdrop-filter: blur(32px) saturate(1.6);
radial-gradient(circle at 50% 0%, rgba(255, 255, 255, 0.3), transparent 46%),
linear-gradient(180deg, rgba(255, 255, 255, 0.18), rgba(255, 255, 255, 0.07)),
rgba(35, 45, 60, 0.68);
box-shadow: 0 18px 55px rgba(0, 0, 0, 0.38), inset 0 1px 0 rgba(255, 255, 255, 0.28);
backdrop-filter: blur(38px) saturate(1.8) contrast(1.06);
-webkit-backdrop-filter: blur(38px) saturate(1.8) contrast(1.06);
}
.ios-tabbar a {
display: grid;
place-items: center;
align-content: center;
gap: 0.24rem;
min-height: 3.55rem;
border-radius: 1.35rem;
gap: 0.18rem;
min-height: 3.42rem;
border-radius: 1.55rem;
color: rgba(239, 247, 255, 0.68);
font-size: 0.72rem;
font-weight: 650;
font-weight: 590;
text-decoration: none;
transition: background 160ms ease, color 160ms ease, transform 160ms ease;
}
.ios-tabbar a.active {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.22), rgba(255, 255, 255, 0.1));
background:
radial-gradient(circle at 30% 0%, rgba(255, 255, 255, 0.4), transparent 52%),
linear-gradient(180deg, rgba(100, 210, 255, 0.72), rgba(10, 132, 255, 0.58));
color: #fff;
transform: translateY(-1px);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.18);
box-shadow: 0 10px 30px rgba(10, 132, 255, 0.36), inset 0 1px 0 rgba(255, 255, 255, 0.38);
}
.ios-tabbar__icon {
width: 1.35rem;
height: 1.35rem;
border-radius: 0.45rem;
border: 2px solid currentColor;
opacity: 0.9;
display: grid;
place-items: center;
width: 1.6rem;
height: 1.6rem;
border-radius: 0;
background: transparent;
opacity: 0.96;
}
.ios-tabbar a:nth-child(1) .ios-tabbar__icon { border-radius: 50%; }
.ios-tabbar a:nth-child(2) .ios-tabbar__icon { border-radius: 0.35rem; box-shadow: inset 0 -0.35rem 0 currentColor; }
.ios-tabbar a:nth-child(3) .ios-tabbar__icon { border-radius: 0.3rem; box-shadow: inset 0 0 0 0.22rem rgba(255, 255, 255, 0.18); }
.ios-tabbar a:nth-child(4) .ios-tabbar__icon { border-radius: 50%; box-shadow: inset 0 0 0 0.28rem currentColor; }
.ios-tabbar__icon img {
width: 1.28rem;
height: 1.28rem;
filter: brightness(0) invert(1);
opacity: 0.92;
}
.ios-tabbar a.active .ios-tabbar__icon {
background: transparent;
}
body.is-authenticated .content,
body.page-dashboard.is-authenticated .content,
@@ -4784,7 +4822,7 @@ input[type="range"] {
.dashboard-day,
.dashboard-range-view {
width: min(100%, 430px);
padding-top: calc(4.6rem + env(safe-area-inset-top));
padding-top: 0.7rem;
}
.dashboard-day__hero,
@@ -4813,17 +4851,32 @@ input[type="range"] {
}
.timeline-card__body {
display: flex;
flex-direction: column;
width: 100%;
min-width: 0;
}
.timeline-card__body h3 {
padding-right: 5.2rem;
width: 100%;
padding-right: 0;
font-size: 1.12rem;
font-weight: 520;
line-height: 1.28;
letter-spacing: -0.018em;
overflow-wrap: anywhere;
}
.timeline-card__comment,
.timeline-card__value {
width: 100%;
max-width: none;
font-size: 1.02rem;
line-height: 1.34;
letter-spacing: -0.012em;
overflow-wrap: anywhere;
word-break: normal;
hyphens: auto;
}
.timeline-media-button {
@@ -4848,58 +4901,84 @@ input[type="range"] {
position: static;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 1rem;
order: -1;
gap: 0.45rem;
margin: 0 0 1rem;
}
.timeline-card .signal-pill {
width: auto;
min-width: 0;
height: auto;
width: 2.55rem;
min-width: 2.55rem;
height: 2.55rem;
min-height: 2.55rem;
padding: 0.42rem 0.72rem;
padding: 0;
justify-content: center;
border-radius: 999px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.24), inset 0 1px 0 rgba(255, 255, 255, 0.26);
}
.timeline-card .signal-pill strong,
.timeline-card .signal-pill span {
display: inline;
display: none;
}
.timeline-card .signal-pill__icon {
display: block;
width: 1rem;
height: 1rem;
width: 1.12rem;
height: 1.12rem;
filter: brightness(0) invert(1) drop-shadow(0 1px 3px rgba(0, 0, 0, 0.55));
}
.timeline-card__time-chip {
position: absolute;
top: 0.85rem;
right: 4.8rem;
top: 0.9rem;
right: 4.45rem;
left: auto;
display: inline-flex;
min-height: 2.35rem;
padding: 0 0.78rem;
min-height: 2.15rem;
padding: 0 0.72rem;
border-radius: 999px;
background: rgba(8, 18, 30, 0.42);
background: rgba(20, 20, 26, 0.38);
border: 1px solid rgba(255, 255, 255, 0.16);
color: rgba(255, 255, 255, 0.86);
}
.timeline-card__delete,
.timeline-card--with-image .timeline-card__delete {
top: 0.75rem;
right: 0.75rem;
top: 0.68rem;
right: 0.68rem;
}
.timeline-card__delete .ghost-button {
width: 2.85rem;
height: 2.85rem;
min-width: 2.85rem;
min-height: 2.85rem;
border-radius: 999px;
border-color: rgba(255, 69, 58, 0.28);
background: rgba(255, 69, 58, 0.14);
color: #ff453a;
font-size: 1.05rem;
font-weight: 560;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.18);
backdrop-filter: blur(18px) saturate(1.3);
-webkit-backdrop-filter: blur(18px) saturate(1.3);
}
.timeline-card__delete .trash-button img {
width: 1.2rem;
height: 1.2rem;
opacity: 0.95;
}
.timeline-card--with-image .timeline-card__time-chip {
top: 1.25rem;
right: 4.9rem;
top: 1.18rem;
right: 4.7rem;
}
.timeline-card--with-image .timeline-card__delete {
top: 1.05rem;
right: 1.05rem;
top: 0.95rem;
right: 0.95rem;
}
.sleep-phase-bar {
@@ -4929,23 +5008,91 @@ input[type="range"] {
.ios-tabbar {
border-color: rgba(120, 146, 172, 0.22);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.86), rgba(255, 255, 255, 0.56)),
rgba(248, 251, 255, 0.7);
box-shadow: 0 24px 64px rgba(78, 105, 130, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.8);
radial-gradient(circle at 18% 0%, rgba(255, 255, 255, 0.98), transparent 42%),
linear-gradient(180deg, rgba(255, 255, 255, 0.9), rgba(235, 244, 251, 0.68)),
rgba(234, 244, 252, 0.82);
box-shadow: 0 18px 56px rgba(78, 105, 130, 0.22), inset 0 1px 0 rgba(255, 255, 255, 0.9);
}
.ios-tabbar a {
color: rgba(18, 48, 75, 0.58);
color: rgba(18, 48, 75, 0.66);
}
.ios-tabbar a.active {
background: rgba(20, 148, 222, 0.12);
color: #12304b;
background:
radial-gradient(circle at 30% 0%, rgba(255, 255, 255, 0.78), transparent 52%),
linear-gradient(180deg, rgba(100, 210, 255, 0.68), rgba(0, 122, 255, 0.46));
color: #fff;
}
.ios-tabbar__icon {
background: rgba(18, 48, 75, 0.04);
}
.ios-tabbar__icon img {
filter: saturate(1.2) contrast(1.06) brightness(0.72);
opacity: 0.82;
}
.ios-tabbar a.active .ios-tabbar__icon img {
filter: brightness(0) invert(1);
opacity: 0.96;
}
.dashboard-day__hero[data-day-slider] {
border-color: rgba(92, 129, 160, 0.28);
background:
radial-gradient(circle at 50% 0%, rgba(255, 255, 255, 0.78), transparent 52%),
linear-gradient(180deg, rgba(255, 255, 255, 0.68), rgba(229, 239, 247, 0.54));
box-shadow: 0 18px 44px rgba(78, 105, 130, 0.16), inset 0 1px 0 rgba(255, 255, 255, 0.72);
}
.score-scale {
color: rgba(18, 48, 75, 0.58);
}
.compare-day__line.score-empty .compare-day__marker,
.compare-day.is-empty .compare-day__marker {
background: rgba(18, 48, 75, 0.28);
box-shadow: none;
}
.sleep-phase-bar {
border-color: rgba(18, 48, 75, 0.08);
background: rgba(18, 48, 75, 0.08);
}
.sleep-phase-bar__track {
fill: rgba(18, 48, 75, 0.1);
}
.sleep-phase-bar__fill {
fill: #0a84ff;
filter: drop-shadow(0 0 4px rgba(10, 132, 255, 0.28));
}
.sleep-phase-bar__target-line {
stroke: rgba(18, 48, 75, 0.48);
}
.sleep-phase-summary {
color: rgba(18, 48, 75, 0.66);
}
.sleep-phase-legend__item {
color: rgba(18, 48, 75, 0.9);
}
.sleep-phase-legend__item--deep { background: rgba(74, 102, 210, 0.26); }
.sleep-phase-legend__item--rem { background: rgba(128, 87, 214, 0.24); }
.sleep-phase-legend__item--core { background: rgba(60, 159, 185, 0.24); }
.timeline-card__delete .ghost-button {
border-color: rgba(255, 59, 48, 0.24);
background: rgba(255, 255, 255, 0.66);
color: #ff3b30;
box-shadow: 0 8px 22px rgba(78, 105, 130, 0.14), inset 0 1px 0 rgba(255, 255, 255, 0.8);
}
}
@media (max-width: 640px) {
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 672 672"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path opacity=".4" fill="currentColor" d="M112 317.3C112 304.2 112.6 295.5 113.8 289.6C114.8 284.8 115.2 282.9 119.9 281.8C124.7 280.7 132.1 280 144 280L164 280C175.9 280 183.3 280.7 188.1 281.8C192.8 282.9 193.3 284.7 194.2 289.6C195.4 295.5 196 304.2 196 317.3L196 494.6C196 507.7 195.4 516.4 194.2 522.3C193.2 527.1 192.8 529 188.1 530.1C183.3 531.2 175.9 531.9 164 531.9L144 531.9C132.1 531.9 124.7 531.2 119.9 530.1C115.2 529 114.7 527.2 113.8 522.3C112.6 516.4 112 507.7 112 494.6L112 317.3z"/><path fill="currentColor" d="M144 224C120.1 224 92.7 225.7 74.6 245.3C65.5 255.2 61.1 267.1 58.8 278.7C56.5 290.1 55.9 303.2 55.9 317.3L56 494.7C56 508.8 56.6 521.9 58.9 533.3C61.2 544.9 65.6 556.8 74.7 566.7C92.7 586.3 120.2 588 144.1 588L164.1 588C188 588 215.4 586.3 233.5 566.7C242.6 556.8 247 544.9 249.3 533.3C251.6 521.9 252.2 508.8 252.2 494.7L252.2 317.4C252.2 303.3 251.6 290.2 249.3 278.8C247 267.2 242.6 255.3 233.5 245.4C215.5 225.8 188 224.1 164.1 224.1L144.1 224.1zM113.8 289.6C114.8 284.8 115.2 282.9 119.9 281.8C124.7 280.7 132.1 280 144 280L164 280C175.9 280 183.3 280.7 188.1 281.8C192.8 282.9 193.3 284.7 194.2 289.6C195.4 295.5 196 304.2 196 317.3L196 494.6C196 507.7 195.4 516.4 194.2 522.3C193.2 527.1 192.8 529 188.1 530.1C183.3 531.2 175.9 531.9 164 531.9L144 531.9C132.1 531.9 124.7 531.2 119.9 530.1C115.2 529 114.7 527.2 113.8 522.3C112.6 516.4 112 507.7 112 494.6L112 317.3C112 304.2 112.6 295.5 113.8 289.6zM398.6 135C402.2 120.4 413.9 112 423 112C433.5 112 441.4 121.4 439.7 131.7L420.4 247.4C419 255.5 421.3 263.8 426.7 270.1C432.1 276.4 439.8 280 448 280L525.2 280C533.8 280 545.9 282.2 549 291.6C554.3 307.8 560 335.2 560 378C560 432.1 540.8 471.5 526.5 493.4C521.8 500.6 512.8 505.8 500.8 506.6C445.1 510.2 384.7 508.4 330.9 491.4C316.2 486.6 300.4 494.5 295.5 509.1C290.6 523.8 298.5 539.6 313.2 544.5C373.5 564.4 441.9 566.5 504.5 562.5C530.5 560.8 557.3 548.8 573.5 524C592 495.7 616.1 445.8 616.1 378C616.1 330.3 609.8 296.9 602.3 274.1C590.1 237 554.2 224 525.3 224L481.1 224L494.9 140.9C502.3 96.4 468 56 422.9 56C382.3 56 352.4 88.1 344.1 121.7C337 150.6 321.3 192.3 286.4 234.1C276.5 246 278.1 263.6 290 273.5C301.9 283.4 319.5 281.8 329.4 269.9C370.8 220.2 389.8 170.4 398.5 135z"/></svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 7h16"/>
<path d="M9 7V5.8A1.8 1.8 0 0 1 10.8 4h2.4A1.8 1.8 0 0 1 15 5.8V7"/>
<path d="M7 7l.8 12.2A2 2 0 0 0 9.8 21h4.4a2 2 0 0 0 2-1.8L17 7"/>
<path d="M10 11v6"/>
<path d="M14 11v6"/>
</svg>

After

Width:  |  Height:  |  Size: 374 B

+140 -5
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' => 'Putzliga',
'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'] ?? ''));
@@ -1443,6 +1526,7 @@ final class App
'sleep_deep' => (float) ($event['sleep_deep'] ?? 0),
'sleep_rem' => (float) ($event['sleep_rem'] ?? 0),
'sleep_core' => (float) ($event['sleep_core'] ?? 0),
'task_titles' => is_array($event['task_titles'] ?? null) ? $event['task_titles'] : [],
'route_map' => $this->buildOsmRouteMap(is_array($event['route'] ?? null) ? $event['route'] : []),
];
}
@@ -1873,8 +1957,9 @@ final class App
$event['image_url'] = null;
$eventImage = trim((string) ($event['image'] ?? ''));
if ($eventImage !== '' && is_file($this->dashboardMediaDirectory($username) . '/' . basename($eventImage))) {
$event['image_url'] = '/event-image?date=' . rawurlencode($date) . '&id=' . rawurlencode((string) ($event['id'] ?? ''));
$eventImagePath = $this->dashboardMediaDirectory($username) . '/' . basename($eventImage);
if ($eventImage !== '' && is_file($eventImagePath)) {
$event['image_url'] = '/event-image?date=' . rawurlencode($date) . '&id=' . rawurlencode((string) ($event['id'] ?? '')) . '&v=' . rawurlencode((string) filemtime($eventImagePath));
}
$events[] = $event;
}
@@ -1886,7 +1971,7 @@ final class App
$path = $this->dashboardMediaDirectory($username) . '/' . basename($fileName);
if (is_file($path)) {
$entry['background_image_url'] = '/day-image?date=' . rawurlencode($date);
$entry['background_image_url'] = '/day-image?date=' . rawurlencode($date) . '&v=' . rawurlencode((string) filemtime($path));
}
return $entry;
@@ -1930,7 +2015,7 @@ final class App
$fileName = $date . '-' . substr($hash, 0, 16) . '.' . $targetExtension;
$target = $directory . '/' . $fileName;
if (is_file($target)) {
if (is_file($target) && $targetExtension !== 'webp') {
return $fileName;
}
@@ -1968,6 +2053,8 @@ final class App
return false;
}
$source = $this->applyImageOrientation($source, $sourcePath, $mime);
$width = imagesx($source);
$height = imagesy($source);
if ($width <= 0 || $height <= 0) {
@@ -1996,6 +2083,30 @@ final class App
return $written && is_file($target);
}
private function applyImageOrientation(GdImage $source, string $sourcePath, string $mime): GdImage
{
if ($mime !== 'image/jpeg' || !function_exists('exif_read_data')) {
return $source;
}
$exif = @exif_read_data($sourcePath);
$orientation = is_array($exif) ? (int) ($exif['Orientation'] ?? 1) : 1;
$oriented = match ($orientation) {
3 => imagerotate($source, 180, 0),
6 => imagerotate($source, -90, 0),
8 => imagerotate($source, 90, 0),
default => $source,
};
if ($oriented instanceof GdImage && $oriented !== $source) {
imagedestroy($source);
return $oriented;
}
return $source;
}
private function deleteDashboardImage(string $username, string $fileName): void
{
$fileName = basename(trim($fileName));
@@ -2394,6 +2505,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 +2533,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 +2621,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);
+4 -4
View File
@@ -128,19 +128,19 @@ $jsVersion = is_file(base_path('assets/js/app.js')) ? (string) filemtime(base_pa
<?php if ($authUser !== null): ?>
<nav class="ios-tabbar" aria-label="Mobile Navigation">
<a class="<?= $page === 'dashboard' && ($dashboardView ?? 'day') === 'day' ? 'active' : '' ?>" href="/?view=day&amp;date=<?= e(rawurlencode(today())) ?>">
<span class="ios-tabbar__icon" aria-hidden="true"></span>
<span class="ios-tabbar__icon" aria-hidden="true"><img src="<?= e(icon_path('dashboard')) ?>" alt=""></span>
<span>Heute</span>
</a>
<a class="<?= $page === 'dashboard' && ($dashboardView ?? '') === 'week' ? 'active' : '' ?>" href="/?view=week&amp;date=<?= e(rawurlencode(today())) ?>">
<span class="ios-tabbar__icon" aria-hidden="true"></span>
<span class="ios-tabbar__icon" aria-hidden="true"><img src="<?= e(icon_path('archive')) ?>" alt=""></span>
<span>Woche</span>
</a>
<a class="<?= $page === 'dashboard' && ($dashboardView ?? '') === 'month' ? 'active' : '' ?>" href="/?view=month&amp;date=<?= e(rawurlencode(today())) ?>">
<span class="ios-tabbar__icon" aria-hidden="true"></span>
<span class="ios-tabbar__icon" aria-hidden="true"><img src="<?= e(icon_path('track')) ?>" alt=""></span>
<span>Monat</span>
</a>
<a class="<?= $page === 'options' ? 'active' : '' ?>" href="/options">
<span class="ios-tabbar__icon" aria-hidden="true"></span>
<span class="ios-tabbar__icon" aria-hidden="true"><img src="<?= e(icon_path('options')) ?>" alt=""></span>
<span>Optionen</span>
</a>
</nav>
+24 -12
View File
@@ -114,11 +114,12 @@ $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)); ?>
<?php $eventValueText = (float) $item['value'] > 0 ? ($eventType === 'sleep' ? format_duration_hours((float) $item['value']) : rtrim(rtrim(number_format((float) $item['value'], 2, ',', '.'), '0'), ',') . ' ' . (string) $item['unit']) : ''; ?>
<?php $eventTitle = match ($eventType) {
<?php $eventTitle = $isPutzliga ? 'Putzliga' : match ($eventType) {
'sport' => $isImportedWalkSport ? 'Spaziergang' : (string) ($sportType['label'] ?? 'Sport'),
'walk' => 'Spaziergang',
'sleep' => 'Schlaf',
@@ -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; ?>
@@ -196,7 +198,7 @@ $formatBalanceValue = static function (?array $entry) use ($settings): string {
<span class="timeline-card__time-chip"><?= e($item['time'] !== '' ? $item['time'] : '--:--') ?></span>
<div class="timeline-card__meta">
<div class="timeline-card__icon-wrap" title="<?= e(day_event_type_label((string) $item['type'])) ?>">
<img class="timeline-card__icon" src="<?= e(day_event_type_icon((string) $item['type'])) ?>" alt="">
<img class="timeline-card__icon" src="<?= e($isPutzliga ? icon_path('putzliga') : day_event_type_icon((string) $item['type'])) ?>" alt="">
</div>
<div>
<strong class="timeline-card__time"><?= e($item['time'] !== '' ? $item['time'] : '--:--') ?></strong>
@@ -220,14 +222,23 @@ $formatBalanceValue = static function (?array $entry) use ($settings): string {
<p class="timeline-card__value"><?= e((string) $item['comment']) ?></p>
<?php endif; ?>
<?php if ($eventType === 'sleep' && $sleepActualTotal > 0): ?>
<div class="sleep-phase-bar" aria-label="Schlafdauer" style="--sleep-optimal-left: <?= e((string) $sleepOptimalPercent) ?>%">
<span class="sleep-phase-bar__fill" style="width: <?= e((string) $sleepActualPercent) ?>%" aria-hidden="true"></span>
<span class="sleep-phase-bar__target"><span><?= e(format_duration_hours($optimalSleepHours)) ?></span></span>
<?php if ($sleepPhaseRemainder > 0): ?>
<span class="sleep-phase-bar__rest-label">noch <?= e(format_duration_hours($sleepPhaseRemainder)) ?> bis Skalenende</span>
<?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">
<rect class="sleep-phase-bar__track" x="0" y="2" width="100" height="6" rx="3"></rect>
<rect class="sleep-phase-bar__fill" x="0" y="2" width="<?= e((string) $sleepActualPercent) ?>" height="6" rx="3"></rect>
<line class="sleep-phase-bar__target-line" x1="<?= e((string) $sleepOptimalPercent) ?>" y1="0" x2="<?= e((string) $sleepOptimalPercent) ?>" y2="10"></line>
</svg>
</div>
<div class="sleep-phase-summary"><span><?= e(format_duration_hours($sleepActualTotal)) ?></span><span>Ziel <?= e(format_duration_hours($optimalSleepHours)) ?></span></div>
<?php if ($sleepPhaseTotal > 0): ?>
<div class="sleep-phase-legend" aria-label="Schlafphasen">
<?php foreach (['deep' => ['Tief', 'deep'], 'rem' => ['REM', 'rem'], 'core' => ['Kern', 'core']] as $phase => [$label, $class]): ?>
@@ -278,7 +289,7 @@ $formatBalanceValue = static function (?array $entry) use ($settings): string {
<input type="hidden" name="form_name" value="delete_event">
<input type="hidden" name="date" value="<?= e((string) $dayEntry['date']) ?>">
<input type="hidden" name="event_id" value="<?= e((string) $item['id']) ?>">
<button class="ghost-button ghost-button--small" type="submit" data-confirm-delete aria-label="Moment löschen">×</button>
<button class="ghost-button ghost-button--small trash-button" type="submit" data-confirm-delete aria-label="Moment löschen"><img src="<?= e(icon_path('trash')) ?>" alt="" aria-hidden="true"></button>
</form>
</article>
<?php endforeach; ?>
@@ -580,11 +591,12 @@ $formatBalanceValue = static function (?array $entry) use ($settings): string {
$sportType = $eventType === 'sport' ? find_sport_type($settings, (string) ($event['sport_type_id'] ?? '')) : null;
$eventValue = (float) ($event['value'] ?? 0);
$eventValueText = $eventValue > 0 ? ($eventType === 'sleep' ? format_duration_hours($eventValue) : rtrim(rtrim(number_format($eventValue, 2, ',', '.'), '0'), ',') . ' ' . (string) ($event['unit'] ?? '')) : '';
$eventComment = trim((string) ($event['comment'] ?? ''));
$isPutzligaEvent = (string) ($event['source'] ?? '') === 'putzliga';
$eventComment = $isPutzligaEvent ? '' : trim((string) ($event['comment'] ?? ''));
if (preg_match('/^\s*-\s*(?:Stimmung|Energie|Stress)\s*:\s*[+-]?\d+\s*$/u', $eventComment) === 1) {
$eventComment = '';
}
$eventTitle = day_event_type_label($eventType);
$eventTitle = $isPutzligaEvent ? 'Putzliga' : day_event_type_label($eventType);
$eventDetails = array_values(array_filter([$eventValueText, $eventComment], static fn (string $value): bool => trim($value) !== ''));
if ($eventType === 'sport') {
+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 mit den erledigten Aufgaben als Chips 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>