Compare commits
16 Commits
2932cbb5b2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 49914fcc9a | |||
| 5c4c977e23 | |||
| 811821afa2 | |||
| b80eb9f519 | |||
| 8ad8ca28af | |||
| 714198059b | |||
| 7d49dad707 | |||
| 64f444808b | |||
| fda92eb47b | |||
| d4d2313b01 | |||
| fef6c2407d | |||
| 18deb121dc | |||
| 0f3f25a412 | |||
| 2a3eaafabb | |||
| 50dec55ca8 | |||
| 4eed74b8bb |
+338
-83
@@ -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,58 +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::before {
|
||||
content: "";
|
||||
.sleep-phase-bar__svg {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
width: var(--sleep-actual-width, 0);
|
||||
min-width: 0.2rem;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(135deg, rgba(90, 188, 242, 0.9), rgba(44, 126, 190, 0.9));
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sleep-phase-bar__track {
|
||||
fill: rgba(255, 255, 255, 0.16);
|
||||
}
|
||||
|
||||
.sleep-phase-bar__fill {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
width: var(--sleep-actual-width, 0);
|
||||
min-width: 0.18rem;
|
||||
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);
|
||||
z-index: 1;
|
||||
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 {
|
||||
@@ -1353,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;
|
||||
@@ -1377,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 {
|
||||
@@ -1407,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); }
|
||||
}
|
||||
|
||||
@@ -2251,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));
|
||||
@@ -2269,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 {
|
||||
@@ -2290,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);
|
||||
@@ -2470,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;
|
||||
}
|
||||
|
||||
@@ -4723,61 +4739,79 @@ input[type="range"] {
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.dashboard-topbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
body {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.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.18);
|
||||
border-radius: 1.9rem;
|
||||
background: rgba(20, 31, 46, 0.72);
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.28);
|
||||
backdrop-filter: blur(24px) saturate(1.35);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(1.35);
|
||||
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.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: rgba(255, 255, 255, 0.14);
|
||||
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 {
|
||||
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,
|
||||
@@ -4788,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,
|
||||
@@ -4807,6 +4841,157 @@ input[type="range"] {
|
||||
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,
|
||||
@@ -4822,22 +5007,92 @@ input[type="range"] {
|
||||
@media (max-width: 760px) and (prefers-color-scheme: light) {
|
||||
.ios-tabbar {
|
||||
border-color: rgba(120, 146, 172, 0.22);
|
||||
background: rgba(248, 251, 255, 0.78);
|
||||
box-shadow: 0 20px 56px rgba(78, 105, 130, 0.18);
|
||||
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.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) {
|
||||
|
||||
@@ -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 |
@@ -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
@@ -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'] ?? '');
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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'] ?? []),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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&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&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&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>
|
||||
|
||||
@@ -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) ?>%; --sleep-actual-width: <?= e((string) $sleepActualPercent) ?>%">
|
||||
<span class="sleep-phase-bar__fill" 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') {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user