18 Commits

10 changed files with 846 additions and 87 deletions
+459 -49
View File
@@ -18,7 +18,7 @@
--radius-md: 18px; --radius-md: 18px;
--radius-sm: 14px; --radius-sm: 14px;
--panel-blur: 28px; --panel-blur: 28px;
--font-ui: "SF Pro Display", "Avenir Next", "Segoe UI Variable", "Helvetica Neue", system-ui, sans-serif; --font-ui: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", sans-serif;
--track-accent: rgba(139, 228, 255, 0.34); --track-accent: rgba(139, 228, 255, 0.34);
--track-surface: rgba(255, 255, 255, 0.08); --track-surface: rgba(255, 255, 255, 0.08);
--track-glow: rgba(139, 228, 255, 0.18); --track-glow: rgba(139, 228, 255, 0.18);
@@ -540,6 +540,11 @@ body.page-dashboard .content {
position: relative; position: relative;
z-index: 1; z-index: 1;
margin-bottom: 0; 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; touch-action: pan-y;
transform: translate3d(var(--day-slider-offset, 0), 0, 0) scale(var(--day-slider-scale, 1)); 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); transition: transform 260ms cubic-bezier(0.2, 0.8, 0.2, 1);
@@ -996,48 +1001,45 @@ body.page-dashboard .content {
.sleep-phase-bar { .sleep-phase-bar {
position: relative; position: relative;
height: 2.35rem; height: 0.74rem;
margin-top: 1.55rem; margin-top: 1.55rem;
overflow: visible; overflow: visible;
border-radius: 999px; border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.14); border: 1px solid rgba(255, 255, 255, 0.16);
background: rgba(255, 255, 255, 0.08); 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 { .sleep-phase-bar__fill {
position: absolute; fill: #64d2ff;
inset: 0 auto 0 0; filter: drop-shadow(0 0 5px rgba(10, 132, 255, 0.48));
}
.sleep-phase-bar__target-line {
stroke: rgba(255, 255, 255, 0.95);
stroke-width: 1.25;
vector-effect: non-scaling-stroke;
}
.sleep-phase-summary {
display: flex; display: flex;
width: var(--sleep-actual-width, 0); justify-content: space-between;
min-width: 0.18rem; gap: 0.75rem;
overflow: hidden; margin-top: 0.55rem;
border-radius: inherit; color: rgba(239, 247, 255, 0.64);
background: linear-gradient(135deg, rgba(90, 188, 242, 0.9), rgba(44, 126, 190, 0.9)); font-size: 0.78rem;
box-shadow: 0 12px 28px rgba(6, 16, 28, 0.2); font-weight: 650;
}
.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: 2;
}
.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-bar__segment { .sleep-phase-bar__segment {
@@ -1137,8 +1139,31 @@ body.page-dashboard .content {
font-size: 0.68rem; font-size: 0.68rem;
transform: translateY(-50%); transform: translateY(-50%);
pointer-events: none; pointer-events: none;
z-index: 2;
} }
.sleep-phase-legend {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
margin-top: 0.7rem;
}
.sleep-phase-legend__item {
display: inline-flex;
align-items: center;
min-height: 2rem;
padding: 0.32rem 0.62rem;
border-radius: 999px;
color: rgba(255, 255, 255, 0.92);
font-size: 0.78rem;
gap: 0.25rem;
}
.sleep-phase-legend__item--deep { background: rgba(44, 82, 180, 0.48); }
.sleep-phase-legend__item--rem { background: rgba(120, 83, 210, 0.48); }
.sleep-phase-legend__item--core { background: rgba(54, 147, 173, 0.48); }
.media-lightbox[hidden] { .media-lightbox[hidden] {
display: none; display: none;
} }
@@ -1320,7 +1345,9 @@ body.page-dashboard .content {
.dashboard-fab { .dashboard-fab {
position: fixed; position: fixed;
right: max(1rem, env(safe-area-inset-right)); 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; width: 4rem;
height: 4rem; height: 4rem;
border: 0; border: 0;
@@ -1344,15 +1371,16 @@ body.page-dashboard .content {
.dashboard-fab-menu { .dashboard-fab-menu {
position: fixed; position: fixed;
right: max(1rem, env(safe-area-inset-right)); right: max(1rem, env(safe-area-inset-right));
bottom: calc(5.8rem + env(safe-area-inset-bottom)); top: calc(5.45rem + env(safe-area-inset-top));
z-index: 28; bottom: auto;
z-index: 123;
display: grid; display: grid;
gap: 0.55rem; gap: 0.55rem;
width: min(18rem, calc(100vw - 2rem)); width: min(18rem, calc(100vw - 2rem));
padding: 0.7rem; padding: 0.7rem;
border-radius: 1.6rem; border-radius: 1.6rem;
animation: fabMenuIn 180ms ease both; animation: fabMenuIn 180ms ease both;
transform-origin: right bottom; transform-origin: right top;
} }
.dashboard-fab-menu button { .dashboard-fab-menu button {
@@ -1374,7 +1402,7 @@ body.page-dashboard .content {
} }
@keyframes fabMenuIn { @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); } to { opacity: 1; transform: translateY(0) scale(1); }
} }
@@ -2218,6 +2246,17 @@ body.page-dashboard .content {
border-radius: var(--radius-xl); 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 { .options-menu-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -2236,15 +2275,18 @@ body.page-dashboard .content {
gap: 0.35rem; gap: 0.35rem;
align-items: flex-start; align-items: flex-start;
width: 100%; width: 100%;
padding: 1.15rem 1.25rem; min-height: 6.7rem;
border-radius: 1.55rem; padding: 1.2rem 1.25rem;
border: 1px solid rgba(152, 194, 232, 0.16); border-radius: 1.75rem;
border: 1px solid rgba(190, 222, 250, 0.18);
background: background:
linear-gradient(180deg, rgba(41, 59, 80, 0.72), rgba(25, 42, 63, 0.6)), radial-gradient(circle at 18% 0%, rgba(255, 255, 255, 0.18), transparent 42%),
radial-gradient(circle at top left, rgba(255, 255, 255, 0.08), transparent 48%); linear-gradient(180deg, rgba(60, 81, 105, 0.52), rgba(24, 41, 62, 0.34));
color: var(--text); color: var(--text);
text-decoration: none; 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 { .options-menu-card strong {
@@ -2257,6 +2299,10 @@ body.page-dashboard .content {
text-align: left; text-align: left;
} }
.options-menu-card:active {
transform: scale(0.985);
}
.options-menu-card--danger { .options-menu-card--danger {
background: rgba(255, 130, 130, 0.08); background: rgba(255, 130, 130, 0.08);
border-color: rgba(255, 143, 143, 0.18); border-color: rgba(255, 143, 143, 0.18);
@@ -2437,9 +2483,12 @@ body.page-dashboard .content {
} }
.dashboard-shell__background { .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%; width: 100%;
height: calc(100dvh + env(safe-area-inset-top) + env(safe-area-inset-bottom)); height: auto;
transform: none; transform: none;
} }
@@ -4685,6 +4734,367 @@ input[type="range"] {
} }
} }
.ios-tabbar {
display: none;
}
@media (max-width: 760px) {
.dashboard-topbar {
display: none;
}
body {
-webkit-tap-highlight-color: transparent;
}
.ios-tabbar {
position: fixed;
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.26rem;
min-height: 4.45rem;
padding: 0.48rem;
border: 1px solid rgba(255, 255, 255, 0.32);
border-radius: 2.05rem;
background:
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.18rem;
min-height: 3.42rem;
border-radius: 1.55rem;
color: rgba(239, 247, 255, 0.68);
font-size: 0.72rem;
font-weight: 590;
text-decoration: none;
transition: background 160ms ease, color 160ms ease, transform 160ms ease;
}
.ios-tabbar a.active {
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: 0 10px 30px rgba(10, 132, 255, 0.36), inset 0 1px 0 rgba(255, 255, 255, 0.38);
}
.ios-tabbar__icon {
display: grid;
place-items: center;
width: 1.6rem;
height: 1.6rem;
border-radius: 0;
background: transparent;
opacity: 0.96;
}
.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,
body.page-options.is-authenticated .content {
padding-bottom: calc(6.2rem + env(safe-area-inset-bottom));
}
.dashboard-day,
.dashboard-range-view {
width: min(100%, 430px);
padding-top: 0.7rem;
}
.dashboard-day__hero,
.dashboard-range-view__hero,
.day-summary-card,
.timeline-card,
.range-card,
.range-day-card,
.dashboard-composer {
border-radius: 1.65rem;
box-shadow: 0 14px 40px rgba(4, 18, 31, 0.18);
}
.timeline-card,
.range-day-card {
padding: 1.05rem;
}
.timeline-card {
grid-template-columns: 1fr;
overflow: visible;
}
.timeline-card--with-image {
padding: 0.9rem;
}
.timeline-card__body {
display: flex;
flex-direction: column;
width: 100%;
min-width: 0;
}
.timeline-card__body h3 {
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 {
height: auto;
min-height: 0;
max-height: none;
margin: 0 0 0.9rem;
aspect-ratio: auto;
background: rgba(255, 255, 255, 0.08);
}
.timeline-media-button .timeline-card__image {
width: 100%;
height: auto;
max-height: min(28rem, 68vh);
object-fit: contain;
object-position: center;
}
.timeline-card .signal-row,
.timeline-card--with-image .signal-row {
position: static;
display: flex;
flex-wrap: wrap;
order: -1;
gap: 0.45rem;
margin: 0 0 1rem;
}
.timeline-card .signal-pill {
width: 2.55rem;
min-width: 2.55rem;
height: 2.55rem;
min-height: 2.55rem;
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: none;
}
.timeline-card .signal-pill__icon {
display: block;
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.9rem;
right: 4.45rem;
left: auto;
display: inline-flex;
min-height: 2.15rem;
padding: 0 0.72rem;
border-radius: 999px;
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.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.18rem;
right: 4.7rem;
}
.timeline-card--with-image .timeline-card__delete {
top: 0.95rem;
right: 0.95rem;
}
.sleep-phase-bar {
margin-top: 1rem;
}
.sleep-phase-bar__rest-label {
max-width: calc(100% - 5.8rem);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
button,
input,
select,
textarea,
.ghost-button,
.dashboard-switcher a,
.range-moment-list__item,
.signal-pill {
min-height: 44px;
}
}
@media (max-width: 760px) and (prefers-color-scheme: light) {
.ios-tabbar {
border-color: rgba(120, 146, 172, 0.22);
background:
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.66);
}
.ios-tabbar a.active {
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) { @media (max-width: 640px) {
.shell { .shell {
padding: 0.8rem; padding: 0.8rem;
+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); $this->triggerReminderCheckFromTraffic($method, $path);
$hasUsers = $this->users->hasAnyUsers(); $hasUsers = $this->users->hasAnyUsers();
$isAuthenticated = $this->auth->check(); $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. // A failed setup must never leave the app in a half-authenticated redirect loop.
if (!$hasUsers && $isAuthenticated) { if (!$hasUsers && $isAuthenticated) {
@@ -157,6 +157,14 @@ final class App
$this->handleHealthImportStatus(); $this->handleHealthImportStatus();
return; return;
case '/api/putzliga':
if ($method !== 'POST') {
json_response(['ok' => false, 'message' => 'Method Not Allowed'], 405);
}
$this->handlePutzligaImport();
return;
default: default:
http_response_code(404); http_response_code(404);
View::render('not-found', [ 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 private function healthImportBearerToken(): string
{ {
$header = trim((string) ($_SERVER['HTTP_AUTHORIZATION'] ?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? '')); $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_deep' => (float) ($event['sleep_deep'] ?? 0),
'sleep_rem' => (float) ($event['sleep_rem'] ?? 0), 'sleep_rem' => (float) ($event['sleep_rem'] ?? 0),
'sleep_core' => (float) ($event['sleep_core'] ?? 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'] : []), 'route_map' => $this->buildOsmRouteMap(is_array($event['route'] ?? null) ? $event['route'] : []),
]; ];
} }
@@ -1873,8 +1957,9 @@ final class App
$event['image_url'] = null; $event['image_url'] = null;
$eventImage = trim((string) ($event['image'] ?? '')); $eventImage = trim((string) ($event['image'] ?? ''));
if ($eventImage !== '' && is_file($this->dashboardMediaDirectory($username) . '/' . basename($eventImage))) { $eventImagePath = $this->dashboardMediaDirectory($username) . '/' . basename($eventImage);
$event['image_url'] = '/event-image?date=' . rawurlencode($date) . '&id=' . rawurlencode((string) ($event['id'] ?? '')); 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; $events[] = $event;
} }
@@ -1886,7 +1971,7 @@ final class App
$path = $this->dashboardMediaDirectory($username) . '/' . basename($fileName); $path = $this->dashboardMediaDirectory($username) . '/' . basename($fileName);
if (is_file($path)) { 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; return $entry;
@@ -1930,7 +2015,7 @@ final class App
$fileName = $date . '-' . substr($hash, 0, 16) . '.' . $targetExtension; $fileName = $date . '-' . substr($hash, 0, 16) . '.' . $targetExtension;
$target = $directory . '/' . $fileName; $target = $directory . '/' . $fileName;
if (is_file($target)) { if (is_file($target) && $targetExtension !== 'webp') {
return $fileName; return $fileName;
} }
@@ -1968,6 +2053,8 @@ final class App
return false; return false;
} }
$source = $this->applyImageOrientation($source, $sourcePath, $mime);
$width = imagesx($source); $width = imagesx($source);
$height = imagesy($source); $height = imagesy($source);
if ($width <= 0 || $height <= 0) { if ($width <= 0 || $height <= 0) {
@@ -1996,6 +2083,30 @@ final class App
return $written && is_file($target); 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 private function deleteDashboardImage(string $username, string $fileName): void
{ {
$fileName = basename(trim($fileName)); $fileName = basename(trim($fileName));
@@ -2394,6 +2505,14 @@ final class App
$healthImportToken = null; $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'] ?? '')); $optionsOpenPanel = trim((string) ($_GET['panel'] ?? ''));
if ($optionsOpenPanel === 'score') { if ($optionsOpenPanel === 'score') {
$optionsOpenPanel = ''; $optionsOpenPanel = '';
@@ -2414,6 +2533,9 @@ final class App
'healthImportConfig' => $this->users->healthImportConfig($user['username']), 'healthImportConfig' => $this->users->healthImportConfig($user['username']),
'healthImportToken' => $healthImportToken, 'healthImportToken' => $healthImportToken,
'healthImportUrl' => app_origin() . '/api/health', 'healthImportUrl' => app_origin() . '/api/health',
'putzligaImportConfig' => $this->users->putzligaImportConfig($user['username']),
'putzligaImportToken' => $putzligaImportToken,
'putzligaImportUrl' => app_origin() . '/api/putzliga',
'backupAvailable' => class_exists('ZipArchive'), 'backupAvailable' => class_exists('ZipArchive'),
'aiConfig' => $user['is_admin'] ? $this->aiConfig->get() : null, 'aiConfig' => $user['is_admin'] ? $this->aiConfig->get() : null,
'aiStatus' => $user['is_admin'] ? $this->openAi->configuration() : null, 'aiStatus' => $user['is_admin'] ? $this->openAi->configuration() : null,
@@ -2499,6 +2621,19 @@ final class App
redirect('/options?panel=health'); 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') { if ($form === 'password') {
$current = (string) ($_POST['current_password'] ?? ''); $current = (string) ($_POST['current_password'] ?? '');
$new = (string) ($_POST['new_password'] ?? ''); $new = (string) ($_POST['new_password'] ?? '');
+26
View File
@@ -221,6 +221,8 @@ final class EntryRepository
$eventLines[] = '- Tiefschlaf: ' . (string) ($event['sleep_deep'] ?? 0); $eventLines[] = '- Tiefschlaf: ' . (string) ($event['sleep_deep'] ?? 0);
$eventLines[] = '- REM-Schlaf: ' . (string) ($event['sleep_rem'] ?? 0); $eventLines[] = '- REM-Schlaf: ' . (string) ($event['sleep_rem'] ?? 0);
$eventLines[] = '- Kernschlaf: ' . (string) ($event['sleep_core'] ?? 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'] : []; $route = is_array($event['route'] ?? null) ? $event['route'] : [];
$eventLines[] = '- Route: ' . ($route !== [] ? base64_encode((string) json_encode($route, JSON_UNESCAPED_SLASHES)) : ''); $eventLines[] = '- Route: ' . ($route !== [] ? base64_encode((string) json_encode($route, JSON_UNESCAPED_SLASHES)) : '');
$eventLines[] = ''; $eventLines[] = '';
@@ -348,6 +350,7 @@ final class EntryRepository
'sleep_deep' => (float) ($this->extract('/^- Tiefschlaf:\s*(.*)$/mu', $block) ?? 0), 'sleep_deep' => (float) ($this->extract('/^- Tiefschlaf:\s*(.*)$/mu', $block) ?? 0),
'sleep_rem' => (float) ($this->extract('/^- REM-Schlaf:\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), '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) ?? '')), 'route' => $this->decodeRoute((string) ($this->extract('/^- Route:\s*(.*)$/mu', $block) ?? '')),
]; ];
} }
@@ -373,6 +376,29 @@ final class EntryRepository
return $base; 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 private function extractSection(string $content, string $startHeading, string $endHeading): ?string
{ {
$pattern = '/' . preg_quote($startHeading, '/') . '\s*(.*?)\s*' . preg_quote($endHeading, '/') . '/su'; $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_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_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)), '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'] ?? []), '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 public function issueHealthImportToken(string $username): string
{ {
$token = 'mood_health_' . bin2hex(random_bytes(24)); $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 public function recordHealthImport(string $username, string $status, string $message): void
{ {
$normalized = normalize_username($username); $normalized = normalize_username($username);
+20
View File
@@ -125,6 +125,26 @@ $jsVersion = is_file(base_path('assets/js/app.js')) ? (string) filemtime(base_pa
</footer> </footer>
<?php endif; ?> <?php endif; ?>
</main> </main>
<?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"><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"><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"><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"><img src="<?= e(icon_path('options')) ?>" alt=""></span>
<span>Optionen</span>
</a>
</nav>
<?php endif; ?>
</div> </div>
</body> </body>
</html> </html>
+43 -33
View File
@@ -112,12 +112,14 @@ $formatBalanceValue = static function (?array $entry) use ($settings): string {
<?php foreach ($dashboardTimeline as $item): ?> <?php foreach ($dashboardTimeline as $item): ?>
<?php $eventType = (string) ($item['type'] ?? 'event'); ?> <?php $eventType = (string) ($item['type'] ?? 'event'); ?>
<?php $eventComment = trim((string) ($item['comment'] ?? '')); ?> <?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 $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 $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 $isImportedWalkSport = $isImportedHealth && $eventType === 'sport' && str_contains(strtolower((string) ($sportType['label'] ?? $eventComment)), 'spaziergang'); ?>
<?php $eventTone = signal_value_class(normalize_signal_value($item['mood'] ?? 0)); ?> <?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 $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'), 'sport' => $isImportedWalkSport ? 'Spaziergang' : (string) ($sportType['label'] ?? 'Sport'),
'walk' => 'Spaziergang', 'walk' => 'Spaziergang',
'sleep' => 'Schlaf', 'sleep' => 'Schlaf',
@@ -183,6 +185,7 @@ $formatBalanceValue = static function (?array $entry) use ($settings): string {
'sleep_deep' => (float) ($item['sleep_deep'] ?? 0), 'sleep_deep' => (float) ($item['sleep_deep'] ?? 0),
'sleep_rem' => (float) ($item['sleep_rem'] ?? 0), 'sleep_rem' => (float) ($item['sleep_rem'] ?? 0),
'sleep_core' => (float) ($item['sleep_core'] ?? 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 $hasEventImage = is_string($item['image_url'] ?? null); ?>
<?php $routeMap = is_array($item['route_map'] ?? null) ? $item['route_map'] : null; ?> <?php $routeMap = is_array($item['route_map'] ?? null) ? $item['route_map'] : null; ?>
@@ -195,7 +198,7 @@ $formatBalanceValue = static function (?array $entry) use ($settings): string {
<span class="timeline-card__time-chip"><?= e($item['time'] !== '' ? $item['time'] : '--:--') ?></span> <span class="timeline-card__time-chip"><?= e($item['time'] !== '' ? $item['time'] : '--:--') ?></span>
<div class="timeline-card__meta"> <div class="timeline-card__meta">
<div class="timeline-card__icon-wrap" title="<?= e(day_event_type_label((string) $item['type'])) ?>"> <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>
<div> <div>
<strong class="timeline-card__time"><?= e($item['time'] !== '' ? $item['time'] : '--:--') ?></strong> <strong class="timeline-card__time"><?= e($item['time'] !== '' ? $item['time'] : '--:--') ?></strong>
@@ -219,34 +222,34 @@ $formatBalanceValue = static function (?array $entry) use ($settings): string {
<p class="timeline-card__value"><?= e((string) $item['comment']) ?></p> <p class="timeline-card__value"><?= e((string) $item['comment']) ?></p>
<?php endif; ?> <?php endif; ?>
<?php if ($eventType === 'sleep' && $sleepActualTotal > 0): ?> <?php if ($isPutzliga && is_array($item['task_titles'] ?? null) && $item['task_titles'] !== []): ?>
<div class="sleep-phase-bar" aria-label="Schlafphasen" style="--sleep-optimal-left: <?= e((string) $sleepOptimalPercent) ?>%; --sleep-actual-width: <?= e((string) $sleepActualPercent) ?>%"> <div class="timeline-card__stats" aria-label="Erledigte Aufgaben">
<div class="sleep-phase-bar__fill" style="width: <?= e((string) $sleepActualPercent) ?>%"> <?php foreach ($item['task_titles'] as $taskTitle): ?>
<?php if ($sleepPhaseTotal > 0): ?> <span><?= e((string) $taskTitle) ?></span>
<?php foreach (['deep' => ['Tief', 'deep'], 'rem' => ['REM', 'rem'], 'core' => ['Kern', 'core']] as $phase => [$label, $class]): ?> <?php endforeach; ?>
<?php $phaseHours = max(0.0, (float) ($sleepPhases[$phase] ?? 0)); ?>
<?php if ($phaseHours <= 0) { continue; } ?>
<?php $phasePercent = $sleepActualTotal > 0 ? max(0.5, min(100, ($phaseHours / $sleepActualTotal) * 100)) : 0; ?>
<span class="sleep-phase-bar__segment sleep-phase-bar__segment--<?= e($class) ?><?= $phasePercent < 13 ? ' is-compact' : '' ?>" style="width: <?= e((string) $phasePercent) ?>%; flex-basis: <?= e((string) $phasePercent) ?>%" title="<?= e($label) ?>: <?= e(format_duration_hours($phaseHours)) ?>" data-tooltip="<?= e($label) ?>: <?= e(format_duration_hours($phaseHours)) ?>">
<strong><?= e($label) ?></strong> <?= e(format_duration_hours($phaseHours)) ?>
</span>
<?php $sleepPhaseLeft += $phaseHours; ?>
<?php endforeach; ?>
<?php endif; ?>
<?php if ($sleepUnclassified > 0): ?>
<?php $unclassifiedPercent = $sleepActualTotal > 0 ? max(0.5, min(100, ($sleepUnclassified / $sleepActualTotal) * 100)) : 0; ?>
<span class="sleep-phase-bar__segment sleep-phase-bar__segment--total<?= $sleepPhaseTotal > 0 ? ' is-after-phase' : '' ?><?= $unclassifiedPercent < 13 ? ' is-compact' : '' ?>" style="width: <?= e((string) $unclassifiedPercent) ?>%; flex-basis: <?= e((string) $unclassifiedPercent) ?>%" title="Schlaf: <?= e(format_duration_hours($sleepUnclassified)) ?>" data-tooltip="Schlaf: <?= e(format_duration_hours($sleepUnclassified)) ?>">
<strong>Schlaf</strong> <?= e(format_duration_hours($sleepUnclassified)) ?>
</span>
<?php endif; ?>
</div>
<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; ?>
</div> </div>
<?php endif; ?> <?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]): ?>
<?php $phaseHours = max(0.0, (float) ($sleepPhases[$phase] ?? 0)); ?>
<?php if ($phaseHours <= 0) { continue; } ?>
<span class="sleep-phase-legend__item sleep-phase-legend__item--<?= e($class) ?>"><strong><?= e($label) ?></strong> <?= e(format_duration_hours($phaseHours)) ?></span>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php endif; ?>
<?php if ($eventStats !== []): ?> <?php if ($eventStats !== []): ?>
<div class="timeline-card__stats" aria-label="Importdetails"> <div class="timeline-card__stats" aria-label="Importdetails">
<?php foreach ($eventStats as $stat): ?> <?php foreach ($eventStats as $stat): ?>
@@ -286,7 +289,7 @@ $formatBalanceValue = static function (?array $entry) use ($settings): string {
<input type="hidden" name="form_name" value="delete_event"> <input type="hidden" name="form_name" value="delete_event">
<input type="hidden" name="date" value="<?= e((string) $dayEntry['date']) ?>"> <input type="hidden" name="date" value="<?= e((string) $dayEntry['date']) ?>">
<input type="hidden" name="event_id" value="<?= e((string) $item['id']) ?>"> <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> </form>
</article> </article>
<?php endforeach; ?> <?php endforeach; ?>
@@ -588,24 +591,31 @@ $formatBalanceValue = static function (?array $entry) use ($settings): string {
$sportType = $eventType === 'sport' ? find_sport_type($settings, (string) ($event['sport_type_id'] ?? '')) : null; $sportType = $eventType === 'sport' ? find_sport_type($settings, (string) ($event['sport_type_id'] ?? '')) : null;
$eventValue = (float) ($event['value'] ?? 0); $eventValue = (float) ($event['value'] ?? 0);
$eventValueText = $eventValue > 0 ? ($eventType === 'sleep' ? format_duration_hours($eventValue) : rtrim(rtrim(number_format($eventValue, 2, ',', '.'), '0'), ',') . ' ' . (string) ($event['unit'] ?? '')) : ''; $eventValueText = $eventValue > 0 ? ($eventType === 'sleep' ? format_duration_hours($eventValue) : rtrim(rtrim(number_format($eventValue, 2, ',', '.'), '0'), ',') . ' ' . (string) ($event['unit'] ?? '')) : '';
$eventTitle = trim((string) ($event['comment'] ?? '')) !== '' ? trim((string) $event['comment']) : day_event_type_label($eventType); $isPutzligaEvent = (string) ($event['source'] ?? '') === 'putzliga';
$eventDetail = $eventValueText; $eventComment = $isPutzligaEvent ? '' : trim((string) ($event['comment'] ?? ''));
if (preg_match('/^\s*-\s*(?:Stimmung|Energie|Stress)\s*:\s*[+-]?\d+\s*$/u', $eventComment) === 1) {
$eventComment = '';
}
$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') { if ($eventType === 'sport') {
$eventTitle = (string) ($sportType['label'] ?? 'Sport'); $eventTitle = (string) ($sportType['label'] ?? 'Sport');
} }
if ($eventType === 'sleep' && trim((string) ($event['comment'] ?? '')) === '') { if ($eventType === 'sleep') {
$eventTitle = 'Schlaf'; $eventTitle = 'Schlaf';
} elseif ($eventType === 'walk') {
$eventTitle = 'Spaziergang';
} }
?> ?>
<li class="range-moment-list__item range-moment-list__item--<?= e($eventTone) ?>"> <li class="range-moment-list__item range-moment-list__item--<?= e($eventTone) ?>">
<span class="range-moment-list__bullet" aria-hidden="true"></span> <span class="range-moment-list__bullet" aria-hidden="true"></span>
<span> <span>
<strong><?= e($eventTitle) ?></strong> <strong><?= e($eventTitle) ?></strong>
<?php if ($eventDetail !== ''): ?> <?php foreach ($eventDetails as $eventDetail): ?>
<span><?= e($eventDetail) ?></span> <span><?= e($eventDetail) ?></span>
<?php endif; ?> <?php endforeach; ?>
</span> </span>
</li> </li>
<?php endforeach; ?> <?php endforeach; ?>
+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> <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>
<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)): ?> <?php if (!empty($healthImportToken)): ?>
<article class="detail-card detail-card--overlay health-token-card"> <article class="detail-card detail-card--overlay health-token-card">
<p class="eyebrow">Neuer Token</p> <p class="eyebrow">Neuer Token</p>