first commit
This commit is contained in:
@@ -0,0 +1,805 @@
|
||||
:root {
|
||||
--bg: #09131f;
|
||||
--bg-soft: #10253a;
|
||||
--surface: rgba(242, 249, 255, 0.16);
|
||||
--surface-strong: rgba(255, 255, 255, 0.24);
|
||||
--surface-border: rgba(255, 255, 255, 0.26);
|
||||
--text: #eff7ff;
|
||||
--muted: rgba(239, 247, 255, 0.72);
|
||||
--shadow: 0 24px 70px rgba(4, 18, 31, 0.35);
|
||||
--primary: #8be4ff;
|
||||
--primary-strong: #3cc7ff;
|
||||
--accent: #8bffcf;
|
||||
--warm: #ffbf8d;
|
||||
--danger: #ff8f8f;
|
||||
--good: #7ff3bb;
|
||||
--radius-xl: 28px;
|
||||
--radius-lg: 22px;
|
||||
--radius-md: 18px;
|
||||
--radius-sm: 14px;
|
||||
--panel-blur: 28px;
|
||||
--font-ui: "SF Pro Display", "Avenir Next", "Segoe UI Variable", "Helvetica Neue", system-ui, sans-serif;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
min-height: 100%;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
font-family: var(--font-ui);
|
||||
background:
|
||||
radial-gradient(circle at 18% 18%, rgba(76, 171, 255, 0.24), transparent 34%),
|
||||
radial-gradient(circle at 82% 12%, rgba(113, 255, 210, 0.18), transparent 28%),
|
||||
linear-gradient(145deg, #07111b 0%, #0b1e2e 35%, #13273b 65%, #08111b 100%);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.aurora {
|
||||
position: fixed;
|
||||
inset: auto;
|
||||
z-index: 0;
|
||||
border-radius: 999px;
|
||||
pointer-events: none;
|
||||
filter: blur(24px);
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.aurora-one {
|
||||
top: 5rem;
|
||||
right: 8vw;
|
||||
width: 18rem;
|
||||
height: 18rem;
|
||||
background: radial-gradient(circle, rgba(90, 196, 255, 0.44), transparent 70%);
|
||||
}
|
||||
|
||||
.aurora-two {
|
||||
bottom: 8vh;
|
||||
left: 8vw;
|
||||
width: 22rem;
|
||||
height: 22rem;
|
||||
background: radial-gradient(circle, rgba(149, 255, 214, 0.34), transparent 70%);
|
||||
}
|
||||
|
||||
.shell {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 300px minmax(0, 1fr);
|
||||
min-height: 100vh;
|
||||
gap: 1.25rem;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.sidebar,
|
||||
.topbar,
|
||||
.glass-panel {
|
||||
border: 1px solid var(--surface-border);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.16), rgba(255, 255, 255, 0.08)),
|
||||
radial-gradient(circle at top left, rgba(255, 255, 255, 0.2), transparent 48%);
|
||||
backdrop-filter: blur(var(--panel-blur)) saturate(150%);
|
||||
-webkit-backdrop-filter: blur(var(--panel-blur)) saturate(150%);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding: 1.35rem;
|
||||
border-radius: var(--radius-xl);
|
||||
min-height: calc(100vh - 2.5rem);
|
||||
position: sticky;
|
||||
top: 1.25rem;
|
||||
}
|
||||
|
||||
.content {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.2rem;
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.topbar__meta {
|
||||
display: flex;
|
||||
gap: 0.65rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.meta-pill,
|
||||
.chart-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.48rem 0.8rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: var(--muted);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
font-size: 0.88rem;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.chart-chip--warm {
|
||||
background: rgba(255, 173, 124, 0.12);
|
||||
}
|
||||
|
||||
.chart-chip--cool {
|
||||
background: rgba(118, 228, 255, 0.12);
|
||||
}
|
||||
|
||||
.brand-block {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.95rem;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 18px;
|
||||
background: linear-gradient(180deg, rgba(146, 232, 255, 0.9), rgba(95, 181, 255, 0.55));
|
||||
color: #032338;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 800;
|
||||
box-shadow: inset 0 1px 1px rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
.brand-block h1,
|
||||
.topbar h2,
|
||||
.hero-card h3,
|
||||
.section-head h3,
|
||||
.detail-card h3,
|
||||
.auth-card h1 {
|
||||
margin: 0;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.brand-block h1 {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 0.28rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
color: rgba(239, 247, 255, 0.62);
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
.main-nav {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.main-nav a {
|
||||
padding: 0.9rem 1rem;
|
||||
border-radius: 18px;
|
||||
color: var(--muted);
|
||||
transition: transform 180ms ease, background 180ms ease, color 180ms ease;
|
||||
}
|
||||
|
||||
.main-nav a:hover,
|
||||
.main-nav a.active {
|
||||
background: rgba(255, 255, 255, 0.13);
|
||||
color: var(--text);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.user-chip {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.9rem 1rem;
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.user-chip__name {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.user-chip__role {
|
||||
font-size: 0.88rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.flash {
|
||||
padding: 0.92rem 1.1rem;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.flash-success {
|
||||
border-color: rgba(127, 243, 187, 0.35);
|
||||
}
|
||||
|
||||
.flash-error {
|
||||
border-color: rgba(255, 143, 143, 0.38);
|
||||
}
|
||||
|
||||
.hero-grid,
|
||||
.stats-grid,
|
||||
.dashboard-grid,
|
||||
.page-grid,
|
||||
.field-grid,
|
||||
.band-grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.hero-grid {
|
||||
grid-template-columns: minmax(0, 1.8fr) minmax(280px, 0.8fr);
|
||||
}
|
||||
|
||||
.hero-card,
|
||||
.metric-card,
|
||||
.chart-card,
|
||||
.form-panel,
|
||||
.detail-card,
|
||||
.info-card,
|
||||
.preview-card,
|
||||
.archive-list,
|
||||
.auth-card {
|
||||
padding: 1.25rem;
|
||||
border-radius: var(--radius-xl);
|
||||
}
|
||||
|
||||
.hero-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hero-card::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: auto -10% -45% auto;
|
||||
width: 15rem;
|
||||
height: 15rem;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(255, 255, 255, 0.18), transparent 64%);
|
||||
}
|
||||
|
||||
.hero-card--wide {
|
||||
padding: 1.6rem;
|
||||
}
|
||||
|
||||
.hero-copy,
|
||||
.auth-copy,
|
||||
.helper-text,
|
||||
.detail-card p,
|
||||
.info-card p {
|
||||
color: var(--muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.hero-score {
|
||||
font-size: clamp(2.5rem, 4vw, 4rem);
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
margin-top: 0.55rem;
|
||||
}
|
||||
|
||||
.hero-label {
|
||||
margin: 0.45rem 0 0;
|
||||
color: var(--text);
|
||||
font-size: 1.03rem;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.metric-card span {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.metric-card strong {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.chart-card--calendar,
|
||||
.chart-card--wide,
|
||||
.form-panel--wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.section-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.calendar-heatmap {
|
||||
min-height: 16rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.calendar-legend {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.45rem;
|
||||
margin-top: 1rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.calendar-scale {
|
||||
display: flex;
|
||||
gap: 0.32rem;
|
||||
}
|
||||
|
||||
.calendar-dot {
|
||||
width: 0.8rem;
|
||||
height: 0.8rem;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.calendar-dot--1 {
|
||||
background: rgba(126, 239, 205, 0.32);
|
||||
}
|
||||
|
||||
.calendar-dot--2 {
|
||||
background: rgba(126, 239, 205, 0.56);
|
||||
}
|
||||
|
||||
.calendar-dot--3 {
|
||||
background: rgba(126, 239, 205, 0.82);
|
||||
}
|
||||
|
||||
.calendar-svg,
|
||||
.line-chart svg {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.calendar-tooltip {
|
||||
fill: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.line-chart,
|
||||
.bar-chart {
|
||||
min-height: 17rem;
|
||||
}
|
||||
|
||||
.line-chart svg {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.line-fill {
|
||||
opacity: 0.12;
|
||||
}
|
||||
|
||||
.line-stroke {
|
||||
fill: none;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
.line-point {
|
||||
stroke: rgba(7, 17, 27, 0.9);
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.chart-axis {
|
||||
stroke: rgba(255, 255, 255, 0.1);
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.chart-label {
|
||||
fill: rgba(239, 247, 255, 0.65);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.bar-chart svg {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.bar-grid {
|
||||
fill: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.bar-segment--sport {
|
||||
fill: rgba(87, 214, 255, 0.88);
|
||||
}
|
||||
|
||||
.bar-segment--walk {
|
||||
fill: rgba(138, 255, 198, 0.82);
|
||||
}
|
||||
|
||||
.bar-label {
|
||||
fill: rgba(239, 247, 255, 0.62);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.bar-value {
|
||||
fill: rgba(239, 247, 255, 0.82);
|
||||
font-size: 10px;
|
||||
text-anchor: middle;
|
||||
}
|
||||
|
||||
.page-grid {
|
||||
grid-template-columns: minmax(0, 1.45fr) minmax(280px, 0.8fr);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.stack-column,
|
||||
.stack-form {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.stack-form--spacious {
|
||||
gap: 1.4rem;
|
||||
}
|
||||
|
||||
.tracker-form,
|
||||
.settings-section {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.field-grid--single {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.field-grid--two {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.field-grid--three {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.field-grid--four {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
label {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
label span {
|
||||
font-size: 0.93rem;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="password"],
|
||||
input[type="number"],
|
||||
input[type="date"],
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.09);
|
||||
color: var(--text);
|
||||
padding: 0.9rem 1rem;
|
||||
outline: none;
|
||||
transition: border-color 180ms ease, background 180ms ease, transform 180ms ease;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
border-color: rgba(139, 228, 255, 0.5);
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
min-height: 11rem;
|
||||
}
|
||||
|
||||
input[type="range"] {
|
||||
width: 100%;
|
||||
accent-color: var(--primary-strong);
|
||||
}
|
||||
|
||||
.range-card {
|
||||
padding: 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.range-card output {
|
||||
font-size: 1.7rem;
|
||||
color: var(--text);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.component-list,
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.component-list div,
|
||||
.detail-grid div,
|
||||
.user-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.85rem;
|
||||
padding: 0.85rem 0.95rem;
|
||||
border-radius: 15px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.component-list dt,
|
||||
.detail-grid dt {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.component-list dd,
|
||||
.detail-grid dd {
|
||||
margin: 0;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.primary-button,
|
||||
.ghost-button,
|
||||
.button-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 3rem;
|
||||
padding: 0.75rem 1.2rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid transparent;
|
||||
transition: transform 180ms ease, box-shadow 180ms ease, background 180ms ease;
|
||||
}
|
||||
|
||||
.primary-button,
|
||||
.button-link {
|
||||
color: #092033;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(180deg, rgba(164, 239, 255, 0.98), rgba(95, 198, 255, 0.78));
|
||||
box-shadow: 0 16px 30px rgba(59, 167, 230, 0.28);
|
||||
}
|
||||
|
||||
.primary-button:hover,
|
||||
.ghost-button:hover,
|
||||
.button-link:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.ghost-button,
|
||||
.ghost-link {
|
||||
color: var(--text);
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.ghost-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 2.75rem;
|
||||
padding: 0.55rem 0.95rem;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.archive-items,
|
||||
.user-list {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.archive-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
padding: 1rem 1.05rem;
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid transparent;
|
||||
transition: transform 180ms ease, border-color 180ms ease, background 180ms ease;
|
||||
}
|
||||
|
||||
.archive-item span {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.archive-item:hover,
|
||||
.archive-item.active {
|
||||
transform: translateY(-1px);
|
||||
border-color: rgba(139, 228, 255, 0.34);
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.archive-item__meta {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.note-box {
|
||||
padding: 1rem;
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.note-box h4 {
|
||||
margin: 0 0 0.55rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
color: var(--muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.band-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.band-card {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.checkbox-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.checkbox-row input {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.auth-shell {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
width: min(100%, 30rem);
|
||||
}
|
||||
|
||||
.auth-card form {
|
||||
margin-top: 1.4rem;
|
||||
}
|
||||
|
||||
.button-link {
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: static;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.topbar,
|
||||
.section-head,
|
||||
.form-actions {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.hero-grid,
|
||||
.dashboard-grid,
|
||||
.page-grid,
|
||||
.stats-grid,
|
||||
.field-grid--two,
|
||||
.field-grid--three,
|
||||
.field-grid--four,
|
||||
.band-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.bar-chart {
|
||||
overflow-x: auto;
|
||||
padding-bottom: 0.4rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.shell {
|
||||
padding: 0.8rem;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.sidebar,
|
||||
.hero-card,
|
||||
.metric-card,
|
||||
.chart-card,
|
||||
.form-panel,
|
||||
.detail-card,
|
||||
.info-card,
|
||||
.preview-card,
|
||||
.archive-list,
|
||||
.topbar {
|
||||
border-radius: 22px;
|
||||
}
|
||||
|
||||
.hero-score {
|
||||
font-size: 2.8rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
(function () {
|
||||
const textDecoder = new TextDecoder();
|
||||
|
||||
function decodePayload(raw) {
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bytes = Uint8Array.from(atob(raw), char => char.charCodeAt(0));
|
||||
return JSON.parse(textDecoder.decode(bytes));
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
const rounded = Math.round(value * 10) / 10;
|
||||
return Number.isInteger(rounded)
|
||||
? String(rounded)
|
||||
: rounded.toLocaleString("de-DE", { minimumFractionDigits: 1, maximumFractionDigits: 1 });
|
||||
}
|
||||
|
||||
function formatDateLabel(value) {
|
||||
const [year, month, day] = value.split("-");
|
||||
return `${day}.${month}.`;
|
||||
}
|
||||
|
||||
function toLocalIso(date) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function getCssVar(name, fallback) {
|
||||
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||||
return value || fallback;
|
||||
}
|
||||
|
||||
function updateRangeOutputs() {
|
||||
document.querySelectorAll("[data-output-for]").forEach(output => {
|
||||
const input = document.querySelector(`[name="${output.dataset.outputFor}"]`);
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sync = () => {
|
||||
output.textContent = input.value;
|
||||
};
|
||||
|
||||
sync();
|
||||
input.addEventListener("input", sync);
|
||||
});
|
||||
}
|
||||
|
||||
function sleepDurationPoints(hours, points) {
|
||||
if (hours < 4) {
|
||||
return Number(points.lt4 || 0);
|
||||
}
|
||||
|
||||
if (hours >= 10) {
|
||||
return Number(points.h10plus || 0);
|
||||
}
|
||||
|
||||
const anchors = {
|
||||
4: Number(points.h4 || 0),
|
||||
5: Number(points.h5 || 0),
|
||||
6: Number(points.h6 || 0),
|
||||
7: Number(points.h7 || 0),
|
||||
8: Number(points.h8 || 0),
|
||||
9: Number(points.h9 || 0),
|
||||
10: Number(points.h10plus || 0),
|
||||
};
|
||||
|
||||
const lower = Math.floor(hours);
|
||||
const upper = Math.ceil(hours);
|
||||
|
||||
if (lower === upper) {
|
||||
return anchors[lower] || 0;
|
||||
}
|
||||
|
||||
const fraction = hours - lower;
|
||||
const lowerPoints = anchors[lower] || 0;
|
||||
const upperPoints = anchors[upper] || 0;
|
||||
return Math.round((lowerPoints + ((upperPoints - lowerPoints) * fraction)) * 10) / 10;
|
||||
}
|
||||
|
||||
function bandPoints(value, bands) {
|
||||
for (const band of bands || []) {
|
||||
if (value >= Number(band.min || 0) && value <= Number(band.max || 0)) {
|
||||
return Number(band.points || 0);
|
||||
}
|
||||
}
|
||||
|
||||
const last = (bands || []).slice(-1)[0];
|
||||
return last ? Number(last.points || 0) : 0;
|
||||
}
|
||||
|
||||
function sortedRatings(ratings) {
|
||||
return [...(ratings || [])].sort((a, b) => Number(a.min || 0) - Number(b.min || 0));
|
||||
}
|
||||
|
||||
function labelForScore(score, ratings) {
|
||||
for (const rating of ratings) {
|
||||
if (score >= Number(rating.min || 0) && score <= Number(rating.max || 0)) {
|
||||
return rating.label;
|
||||
}
|
||||
}
|
||||
|
||||
if (!ratings.length) {
|
||||
return "unbewertet";
|
||||
}
|
||||
|
||||
return score < Number(ratings[0].min || 0) ? ratings[0].label : ratings[ratings.length - 1].label;
|
||||
}
|
||||
|
||||
function capLabel(current, cap, ratings) {
|
||||
const order = ratings.map(item => item.label);
|
||||
const currentIndex = order.indexOf(current);
|
||||
const capIndex = order.indexOf(cap);
|
||||
|
||||
if (currentIndex === -1 || capIndex === -1) {
|
||||
return current;
|
||||
}
|
||||
|
||||
return currentIndex > capIndex ? cap : current;
|
||||
}
|
||||
|
||||
function evaluateEntry(entry, settings) {
|
||||
const ratings = sortedRatings(settings.ratings || []);
|
||||
const scoring = settings.scoring || {};
|
||||
const components = {
|
||||
mood: Number(entry.mood) * Number(scoring.mood_multiplier || 0),
|
||||
energy: Number(entry.energy) * Number(scoring.energy_multiplier || 0),
|
||||
stress: (11 - Number(entry.stress)) * Number(scoring.stress_multiplier || 0),
|
||||
sleep_hours: sleepDurationPoints(Number(entry.sleep_hours), scoring.sleep_duration_points || {}),
|
||||
sleep_feeling: Number(entry.sleep_feeling) * Number(scoring.sleep_feeling_multiplier || 0),
|
||||
sport_minutes: bandPoints(Number(entry.sport_minutes), scoring.sport_bands || []),
|
||||
walk_minutes: bandPoints(Number(entry.walk_minutes), scoring.walk_bands || []),
|
||||
note: String(entry.note || "").trim() === "" ? 0 : Number(scoring.journal_points || 0),
|
||||
};
|
||||
|
||||
const total = Math.round(Object.values(components).reduce((sum, value) => sum + Number(value), 0) * 10) / 10;
|
||||
let label = labelForScore(total, ratings);
|
||||
|
||||
for (const guardrail of settings.guardrails || []) {
|
||||
const moodMatch = Number(entry.mood) <= Number(guardrail.mood_max || 10);
|
||||
const energyMatch = guardrail.energy_max === null || guardrail.energy_max === ""
|
||||
? true
|
||||
: Number(entry.energy) <= Number(guardrail.energy_max);
|
||||
|
||||
if (moodMatch && energyMatch) {
|
||||
label = capLabel(label, guardrail.cap_label, ratings);
|
||||
}
|
||||
}
|
||||
|
||||
return { total, label, components };
|
||||
}
|
||||
|
||||
function initTrackPreview() {
|
||||
const card = document.querySelector("#live-score-card");
|
||||
const form = document.querySelector("#tracker-form");
|
||||
|
||||
if (!card || !form) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = decodePayload(card.dataset.payload);
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
|
||||
const totalNode = card.querySelector("[data-preview-total]");
|
||||
const labelNode = card.querySelector("[data-preview-label]");
|
||||
const componentsNode = card.querySelector("[data-preview-components]");
|
||||
const componentLabels = {
|
||||
mood: "Stimmung",
|
||||
energy: "Energie",
|
||||
stress: "Stress",
|
||||
sleep_hours: "Schlafdauer",
|
||||
sleep_feeling: "Schlafgefühl",
|
||||
sport_minutes: "Sport",
|
||||
walk_minutes: "Spaziergang",
|
||||
note: "Notiz",
|
||||
};
|
||||
|
||||
const collect = () => ({
|
||||
mood: Number(form.elements.mood.value),
|
||||
energy: Number(form.elements.energy.value),
|
||||
stress: Number(form.elements.stress.value),
|
||||
sleep_hours: Number(form.elements.sleep_hours.value || 0),
|
||||
sleep_feeling: Number(form.elements.sleep_feeling.value),
|
||||
sport_minutes: Number(form.elements.sport_minutes.value || 0),
|
||||
walk_minutes: Number(form.elements.walk_minutes.value || 0),
|
||||
note: form.elements.note.value || "",
|
||||
});
|
||||
|
||||
const render = () => {
|
||||
const result = evaluateEntry(collect(), payload.settings);
|
||||
totalNode.textContent = formatNumber(result.total);
|
||||
labelNode.textContent = result.label;
|
||||
componentsNode.innerHTML = Object.entries(result.components).map(([key, value]) => {
|
||||
return `<div><dt>${componentLabels[key] || key}</dt><dd>${formatNumber(Number(value))}</dd></div>`;
|
||||
}).join("");
|
||||
};
|
||||
|
||||
render();
|
||||
form.addEventListener("input", render);
|
||||
form.addEventListener("change", render);
|
||||
}
|
||||
|
||||
function emptyState(message) {
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "empty-state";
|
||||
wrapper.textContent = message;
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
function linePath(points) {
|
||||
if (!points.length) {
|
||||
return "";
|
||||
}
|
||||
|
||||
let path = `M ${points[0].x} ${points[0].y}`;
|
||||
for (let index = 1; index < points.length; index += 1) {
|
||||
const previous = points[index - 1];
|
||||
const current = points[index];
|
||||
const midX = (previous.x + current.x) / 2;
|
||||
path += ` Q ${midX} ${previous.y}, ${current.x} ${current.y}`;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
function renderLineChart(container, items, color) {
|
||||
if (!items || !items.length) {
|
||||
container.append(emptyState("Noch nicht genug Daten für diesen Verlauf."));
|
||||
return;
|
||||
}
|
||||
|
||||
const width = 760;
|
||||
const height = 220;
|
||||
const padding = { top: 18, right: 18, bottom: 38, left: 14 };
|
||||
const maxValue = Math.max(...items.map(item => Number(item.value)), 10);
|
||||
const minValue = 0;
|
||||
const step = items.length > 1 ? (width - padding.left - padding.right) / (items.length - 1) : 0;
|
||||
|
||||
const points = items.map((item, index) => {
|
||||
const ratio = (Number(item.value) - minValue) / Math.max(maxValue - minValue, 1);
|
||||
return {
|
||||
x: padding.left + (index * step),
|
||||
y: padding.top + ((1 - ratio) * (height - padding.top - padding.bottom)),
|
||||
label: formatDateLabel(item.date),
|
||||
value: Number(item.value),
|
||||
};
|
||||
});
|
||||
|
||||
const path = linePath(points);
|
||||
const fillPath = `${path} L ${points[points.length - 1].x} ${height - padding.bottom} L ${points[0].x} ${height - padding.bottom} Z`;
|
||||
|
||||
const labels = points.filter((_, index) => index === 0 || index === points.length - 1 || index % Math.ceil(points.length / 5) === 0);
|
||||
|
||||
container.innerHTML = `
|
||||
<svg viewBox="0 0 ${width} ${height}" role="img" aria-label="Verlauf">
|
||||
<line class="chart-axis" x1="${padding.left}" y1="${height - padding.bottom}" x2="${width - padding.right}" y2="${height - padding.bottom}"></line>
|
||||
<path class="line-fill" d="${fillPath}" fill="${color}"></path>
|
||||
<path class="line-stroke" d="${path}" stroke="${color}"></path>
|
||||
${points.map(point => `<circle class="line-point" cx="${point.x}" cy="${point.y}" r="4" fill="${color}"><title>${point.label}: ${formatNumber(point.value)}</title></circle>`).join("")}
|
||||
${labels.map(point => `<text class="chart-label" x="${point.x}" y="${height - 12}" text-anchor="middle">${point.label}</text>`).join("")}
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderBarChart(container, items) {
|
||||
if (!items || !items.length) {
|
||||
container.append(emptyState("Sobald Sport- oder Gehwerte vorhanden sind, erscheint hier die Entwicklung."));
|
||||
return;
|
||||
}
|
||||
|
||||
const recent = items.slice(-18);
|
||||
const maxValue = Math.max(...recent.map(item => Number(item.value)), 1);
|
||||
const width = Math.max(recent.length * 34, 520);
|
||||
const height = 220;
|
||||
const chartHeight = 150;
|
||||
const baseY = 176;
|
||||
|
||||
const bars = recent.map((item, index) => {
|
||||
const sport = Number(item.sport || 0);
|
||||
const walk = Number(item.walk || 0);
|
||||
const total = sport + walk;
|
||||
const x = 18 + (index * 34);
|
||||
const columnHeight = total > 0 ? Math.max((total / maxValue) * chartHeight, 8) : 0;
|
||||
const walkHeight = total > 0 ? (walk / total) * columnHeight : 0;
|
||||
const sportHeight = total > 0 ? (sport / total) * columnHeight : 0;
|
||||
const backgroundY = baseY - chartHeight;
|
||||
const walkY = baseY - walkHeight;
|
||||
const sportY = walkY - sportHeight;
|
||||
|
||||
return `
|
||||
<rect class="bar-grid" x="${x}" y="${backgroundY}" width="18" height="${chartHeight}" rx="9"></rect>
|
||||
<rect class="bar-segment--walk" x="${x}" y="${walkY}" width="18" height="${walkHeight}" rx="9">
|
||||
<title>${formatDateLabel(item.date)} · Spaziergang ${walk} min</title>
|
||||
</rect>
|
||||
<rect class="bar-segment--sport" x="${x}" y="${sportY}" width="18" height="${sportHeight}" rx="9">
|
||||
<title>${formatDateLabel(item.date)} · Sport ${sport} min</title>
|
||||
</rect>
|
||||
<text class="bar-value" x="${x + 9}" y="${baseY - chartHeight - 10}">${Math.round(total)}</text>
|
||||
<text class="bar-label" x="${x + 9}" y="202" text-anchor="middle">${formatDateLabel(item.date)}</text>
|
||||
`;
|
||||
}).join("");
|
||||
|
||||
container.innerHTML = `
|
||||
<svg viewBox="0 0 ${width} ${height}" role="img" aria-label="Sport und Spaziergang">
|
||||
${bars}
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderCalendar(container, items) {
|
||||
if (!items || !items.length) {
|
||||
container.append(emptyState("Der Kalender füllt sich automatisch mit den gespeicherten Tagen."));
|
||||
return;
|
||||
}
|
||||
|
||||
const map = new Map(items.map(item => [item.date, item]));
|
||||
const end = new Date();
|
||||
const start = new Date(end);
|
||||
start.setDate(end.getDate() - 364);
|
||||
|
||||
const days = [];
|
||||
const cursor = new Date(start);
|
||||
while (cursor <= end) {
|
||||
const iso = toLocalIso(cursor);
|
||||
const entry = map.get(iso) || null;
|
||||
days.push({
|
||||
date: iso,
|
||||
entry,
|
||||
weekday: cursor.getDay(),
|
||||
month: cursor.getMonth(),
|
||||
day: cursor.getDate(),
|
||||
});
|
||||
cursor.setDate(cursor.getDate() + 1);
|
||||
}
|
||||
|
||||
const width = Math.ceil(days.length / 7) * 17 + 56;
|
||||
const height = 148;
|
||||
const cellSize = 12;
|
||||
const cellGap = 5;
|
||||
const xOffset = 34;
|
||||
const yOffset = 24;
|
||||
const monthLabels = [];
|
||||
let lastMonth = -1;
|
||||
|
||||
const cells = days.map((item, index) => {
|
||||
const week = Math.floor(index / 7);
|
||||
const row = item.weekday === 0 ? 6 : item.weekday - 1;
|
||||
const x = xOffset + (week * (cellSize + cellGap));
|
||||
const y = yOffset + (row * (cellSize + cellGap));
|
||||
const level = item.entry ? Math.max(0, Math.min(1, Number(item.entry.score) / Math.max(Number(item.entry.max || 1), 1))) : 0;
|
||||
const fill = item.entry
|
||||
? `rgba(126, 239, 205, ${0.18 + (level * 0.72)})`
|
||||
: "rgba(255, 255, 255, 0.06)";
|
||||
|
||||
if (item.day <= 7 && item.month !== lastMonth) {
|
||||
monthLabels.push({
|
||||
x,
|
||||
label: new Intl.DateTimeFormat("de-DE", { month: "short" }).format(new Date(`${item.date}T12:00:00`)),
|
||||
});
|
||||
lastMonth = item.month;
|
||||
}
|
||||
|
||||
const title = item.entry
|
||||
? `${item.date}: ${formatNumber(Number(item.entry.score))} Punkte · ${item.entry.label}`
|
||||
: `${item.date}: kein Eintrag`;
|
||||
|
||||
return `<rect x="${x}" y="${y}" width="${cellSize}" height="${cellSize}" rx="4" fill="${fill}"><title>${title}</title></rect>`;
|
||||
}).join("");
|
||||
|
||||
container.innerHTML = `
|
||||
<svg class="calendar-svg" viewBox="0 0 ${width} ${height}" role="img" aria-label="Kalender">
|
||||
${monthLabels.map(item => `<text class="calendar-tooltip" x="${item.x}" y="14">${item.label}</text>`).join("")}
|
||||
<text class="calendar-tooltip" x="0" y="34">Mo</text>
|
||||
<text class="calendar-tooltip" x="0" y="68">Mi</text>
|
||||
<text class="calendar-tooltip" x="0" y="102">Fr</text>
|
||||
${cells}
|
||||
</svg>
|
||||
<div class="calendar-legend">
|
||||
<span>weniger</span>
|
||||
<div class="calendar-scale">
|
||||
<span class="calendar-dot"></span>
|
||||
<span class="calendar-dot calendar-dot--1"></span>
|
||||
<span class="calendar-dot calendar-dot--2"></span>
|
||||
<span class="calendar-dot calendar-dot--3"></span>
|
||||
</div>
|
||||
<span>mehr</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function initDashboardCharts() {
|
||||
const calendar = document.querySelector("#calendar-heatmap");
|
||||
if (calendar) {
|
||||
const payload = decodePayload(calendar.dataset.payload);
|
||||
if (payload) {
|
||||
renderCalendar(calendar, payload.calendar || []);
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll(".line-chart[data-chart-type='line']").forEach(chart => {
|
||||
const payload = decodePayload(chart.dataset.payload);
|
||||
const seriesName = chart.dataset.series;
|
||||
const color = seriesName === "stress"
|
||||
? getCssVar("--warm", "#ffbf8d")
|
||||
: getCssVar("--primary-strong", "#3cc7ff");
|
||||
|
||||
renderLineChart(chart, payload ? payload[seriesName] || [] : [], color);
|
||||
});
|
||||
|
||||
document.querySelectorAll(".bar-chart[data-chart-type='bars']").forEach(chart => {
|
||||
const payload = decodePayload(chart.dataset.payload);
|
||||
renderBarChart(chart, payload ? payload.sport || [] : []);
|
||||
});
|
||||
}
|
||||
|
||||
updateRangeOutputs();
|
||||
initTrackPreview();
|
||||
initDashboardCharts();
|
||||
})();
|
||||
Reference in New Issue
Block a user