diff --git a/projects/mila/src/components/SleepChart.vue b/projects/mila/src/components/SleepChart.vue
index 3aedad6..a49f6be 100644
--- a/projects/mila/src/components/SleepChart.vue
+++ b/projects/mila/src/components/SleepChart.vue
@@ -57,7 +57,10 @@ const bars = computed(() => {
let nightHours = 0
let napHours = 0
- if (day.sessions && day.sessions.length > 0) {
+ if (typeof day.night_minutes === 'number' || typeof day.nap_minutes === 'number') {
+ nightHours = Math.max(0, (day.night_minutes || 0) / 60)
+ napHours = Math.max(0, (day.nap_minutes || 0) / 60)
+ } else if (day.sessions && day.sessions.length > 0) {
for (const s of day.sessions) {
const mins = s.duration_minutes || 0
if (s.type === 'night') {
@@ -71,7 +74,7 @@ const bars = computed(() => {
napHours = Math.max(0, day.total_hours - nightHours)
}
- const totalHours = Math.max(0, day.total_hours)
+ const totalHours = Math.max(0, day.total_hours, nightHours + napHours)
const yTotal = getY(totalHours)
const yNight = getY(nightHours)
const nightHeight = Math.max(0, (paddingTop + innerHeight) - yNight)
@@ -172,6 +175,59 @@ function getMinuteFromTime(timeStr: string): number {
return parseInt(parts[0], 10) * 60 + parseInt(parts[1], 10)
}
+interface RasterBlock {
+ startMin: number
+ durationMin: number
+ type: 'night' | 'nap'
+ label: string
+ title: string
+}
+
+function getDay24hBlocks(day: HistoryRow, dayIdx: number): RasterBlock[] {
+ const blocks: RasterBlock[] = []
+
+ // 1. Check previous calendar day (props.history[dayIdx + 1]) for overnight sleep spilling into 00:00..wake_time
+ const prevDay = props.history[dayIdx + 1]
+ if (prevDay?.sessions) {
+ for (const s of prevDay.sessions) {
+ if (!s.start) continue
+ const sMin = getMinuteFromTime(s.start)
+ const dur = s.duration_minutes || 60
+ if (sMin + dur > 1440) {
+ const morningMin = Math.min(1440, (sMin + dur) - 1440)
+ if (morningMin > 0) {
+ blocks.push({
+ startMin: 0,
+ durationMin: morningMin,
+ type: s.type === 'night' ? 'night' : 'nap',
+ label: `${morningMin}m`,
+ title: `Nachtschlaf (Vorabend ${s.start} – ${s.end || 'morgens'}): ${morningMin} min nach 00:00 Uhr (${s.duration_minutes} min gesamt)`,
+ })
+ }
+ }
+ }
+ }
+
+ // 2. Add sessions starting on this calendar day (clamped to 24:00)
+ if (day.sessions) {
+ for (const s of day.sessions) {
+ if (!s.start) continue
+ const sMin = getMinuteFromTime(s.start)
+ const dur = s.duration_minutes || 60
+ const clampedDur = Math.max(1, Math.min(dur, 1440 - sMin))
+ blocks.push({
+ startMin: sMin,
+ durationMin: clampedDur,
+ type: s.type === 'night' ? 'night' : 'nap',
+ label: `${clampedDur}m`,
+ title: `${s.type === 'night' ? 'Nachtschlaf' : 'Nap'}: ${s.start}–${s.end || 'jetzt'} (${s.duration_minutes} min)`,
+ })
+ }
+ }
+
+ return blocks
+}
+
function handlePointer(event: MouseEvent | TouchEvent) {
const svg = (event.currentTarget as HTMLElement).getBoundingClientRect()
const clientX = 'touches' in event ? event.touches[0].clientX : event.clientX
@@ -474,7 +530,7 @@ function handlePointer(event: MouseEvent | TouchEvent) {
@@ -494,24 +550,22 @@ function handlePointer(event: MouseEvent | TouchEvent) {
-
-
-
-
-
- {{ s.duration_minutes }}m
-
-
-
+
+
+
+ {{ b.label }}
+
+
Keine Einzelschläfchen
diff --git a/projects/mila/src/types.ts b/projects/mila/src/types.ts
index 76e6a60..b4410f0 100644
--- a/projects/mila/src/types.ts
+++ b/projects/mila/src/types.ts
@@ -36,6 +36,8 @@ export interface HistoryRow {
formatted_date: string
is_today: boolean
total_hours: number
+ night_minutes?: number
+ nap_minutes?: number
night_duration_str: string
nap_str: string
feed_bottles_ml: number
diff --git a/terraform/infrastructure/authentik.tf b/terraform/infrastructure/authentik.tf
index aec8e5e..c0cffc5 100644
--- a/terraform/infrastructure/authentik.tf
+++ b/terraform/infrastructure/authentik.tf
@@ -376,6 +376,23 @@ resource "authentik_application" "hosting_family" {
protocol_provider = authentik_provider_proxy.hosting_family.id
}
+# 11. Family Portal (family.haumdaucher.de)
+resource "authentik_provider_proxy" "family_portal" {
+ name = "Family Portal"
+ external_host = "https://family.haumdaucher.de"
+ mode = "forward_single"
+ authorization_flow = data.authentik_flow.default_authorization_flow.id
+ invalidation_flow = data.authentik_flow.default_invalidation_flow.id
+ intercept_header_auth = true
+ access_token_validity = "days=30"
+}
+
+resource "authentik_application" "family_portal" {
+ name = "Family Portal"
+ slug = "family-portal"
+ protocol_provider = authentik_provider_proxy.family_portal.id
+}
+
# --- Outpost: Embedded Proxy Outpost ---
resource "authentik_outpost" "embedded" {
@@ -392,6 +409,7 @@ resource "authentik_outpost" "embedded" {
authentik_provider_proxy.n8n.id,
authentik_provider_proxy.silverbullet.id,
authentik_provider_proxy.hosting_family.id,
+ authentik_provider_proxy.family_portal.id,
]
}
@@ -789,6 +807,18 @@ resource "authentik_policy_binding" "hosting_family_family" {
order = 1
}
+resource "authentik_policy_binding" "family_portal_admin" {
+ target = authentik_application.family_portal.uuid
+ group = authentik_group.infra_admins.id
+ order = 0
+}
+
+resource "authentik_policy_binding" "family_portal_family" {
+ target = authentik_application.family_portal.uuid
+ group = authentik_group.family.id
+ order = 1
+}
+
# 2. Shared & Member Applications (Accessible to all authenticated users)
resource "authentik_policy_binding" "grafana_users" {
diff --git a/terraform/infrastructure/terraform.tfstate b/terraform/infrastructure/terraform.tfstate
index e89d4a2..299c0b7 100644
--- a/terraform/infrastructure/terraform.tfstate
+++ b/terraform/infrastructure/terraform.tfstate
@@ -1,7 +1,7 @@
{
"version": 4,
"terraform_version": "1.14.3",
- "serial": 423,
+ "serial": 429,
"lineage": "8aff5d23-05f6-10eb-0ae6-1084c787677e",
"outputs": {
"coder_oauth_client_secret": {
@@ -217,7 +217,7 @@
"id": "7",
"is_active": true,
"is_superuser": false,
- "last_login": "2026-09-05T14:23:02.674777Z",
+ "last_login": "2026-10-12T06:28:53.399647Z",
"name": "Amelie Graf",
"path": "users",
"pk": 7,
@@ -306,6 +306,41 @@
}
]
},
+ {
+ "mode": "managed",
+ "type": "authentik_application",
+ "name": "family_portal",
+ "provider": "provider[\"registry.terraform.io/goauthentik/authentik\"]",
+ "instances": [
+ {
+ "schema_version": 0,
+ "attributes": {
+ "backchannel_providers": null,
+ "group": "",
+ "id": "family-portal",
+ "meta_description": "",
+ "meta_hide": false,
+ "meta_icon": "",
+ "meta_launch_url": "",
+ "meta_publisher": "",
+ "name": "Family Portal",
+ "open_in_new_tab": false,
+ "policy_engine_mode": "any",
+ "protocol_provider": 17,
+ "slug": "family-portal",
+ "uuid": "48cb7294-f186-4fa9-80d8-007b9ebb8f62"
+ },
+ "sensitive_attributes": [],
+ "identity_schema_version": 0,
+ "private": "bnVsbA==",
+ "dependencies": [
+ "authentik_provider_proxy.family_portal",
+ "data.authentik_flow.default_authorization_flow",
+ "data.authentik_flow.default_invalidation_flow"
+ ]
+ }
+ ]
+ },
{
"mode": "managed",
"type": "authentik_application",
@@ -1155,7 +1190,8 @@
3,
10,
15,
- 16
+ 16,
+ 17
],
"service_connection": "",
"type": "proxy"
@@ -1165,6 +1201,7 @@
"private": "bnVsbA==",
"dependencies": [
"authentik_provider_proxy.alertmanager",
+ "authentik_provider_proxy.family_portal",
"authentik_provider_proxy.gitea_phpmyadmin",
"authentik_provider_proxy.hass_coder",
"authentik_provider_proxy.hosting_family",
@@ -1251,6 +1288,73 @@
}
]
},
+ {
+ "mode": "managed",
+ "type": "authentik_policy_binding",
+ "name": "family_portal_admin",
+ "provider": "provider[\"registry.terraform.io/goauthentik/authentik\"]",
+ "instances": [
+ {
+ "schema_version": 0,
+ "attributes": {
+ "enabled": true,
+ "failure_result": false,
+ "group": "c0ad76f4-d157-4c9e-882c-9ae5b5fc1690",
+ "id": "f9dffd65-0fe7-4384-a2e1-684ff2a85475",
+ "negate": false,
+ "order": 0,
+ "policy": "",
+ "target": "48cb7294-f186-4fa9-80d8-007b9ebb8f62",
+ "timeout": 30,
+ "user": 0
+ },
+ "sensitive_attributes": [],
+ "identity_schema_version": 0,
+ "private": "bnVsbA==",
+ "dependencies": [
+ "authentik_application.family_portal",
+ "authentik_group.infra_admins",
+ "authentik_provider_proxy.family_portal",
+ "data.authentik_flow.default_authorization_flow",
+ "data.authentik_flow.default_invalidation_flow"
+ ]
+ }
+ ]
+ },
+ {
+ "mode": "managed",
+ "type": "authentik_policy_binding",
+ "name": "family_portal_family",
+ "provider": "provider[\"registry.terraform.io/goauthentik/authentik\"]",
+ "instances": [
+ {
+ "schema_version": 0,
+ "attributes": {
+ "enabled": true,
+ "failure_result": false,
+ "group": "5e91f468-a0f2-4433-aa23-a7b6ad69df38",
+ "id": "033b6b44-6a29-4d01-b7f1-750b33070541",
+ "negate": false,
+ "order": 1,
+ "policy": "",
+ "target": "48cb7294-f186-4fa9-80d8-007b9ebb8f62",
+ "timeout": 30,
+ "user": 0
+ },
+ "sensitive_attributes": [],
+ "identity_schema_version": 0,
+ "private": "bnVsbA==",
+ "dependencies": [
+ "authentik_application.family_portal",
+ "authentik_group.family",
+ "authentik_provider_proxy.family_portal",
+ "data.authentik_flow.default_authorization_flow",
+ "data.authentik_flow.default_invalidation_flow",
+ "data.authentik_user.amy"
+ ]
+ }
+ ]
+ },
{
"mode": "managed",
"type": "authentik_policy_binding",
@@ -2302,6 +2406,48 @@
}
]
},
+ {
+ "mode": "managed",
+ "type": "authentik_provider_proxy",
+ "name": "family_portal",
+ "provider": "provider[\"registry.terraform.io/goauthentik/authentik\"]",
+ "instances": [
+ {
+ "schema_version": 0,
+ "attributes": {
+ "access_token_validity": "days=30",
+ "authentication_flow": "",
+ "authorization_flow": "68f43818-60ba-4d8e-ae66-cbac29d7be42",
+ "basic_auth_enabled": false,
+ "basic_auth_password_attribute": "",
+ "basic_auth_username_attribute": "",
+ "client_id": "4HO1RbjsgDq3JI5K4VeXF3SFCmeCTePXs3466tfz",
+ "cookie_domain": "",
+ "external_host": "https://family.haumdaucher.de",
+ "id": "17",
+ "intercept_header_auth": true,
+ "internal_host": "",
+ "internal_host_ssl_validation": true,
+ "invalidation_flow": "9add9e98-4fe7-48c5-bca8-d68968e0a7ba",
+ "jwks_sources": null,
+ "jwt_federation_providers": null,
+ "jwt_federation_sources": null,
+ "mode": "forward_single",
+ "name": "Family Portal",
+ "property_mappings": null,
+ "refresh_token_validity": "days=30",
+ "skip_path_regex": ""
+ },
+ "sensitive_attributes": [],
+ "identity_schema_version": 0,
+ "private": "bnVsbA==",
+ "dependencies": [
+ "data.authentik_flow.default_authorization_flow",
+ "data.authentik_flow.default_invalidation_flow"
+ ]
+ }
+ ]
+ },
{
"mode": "managed",
"type": "authentik_provider_proxy",
@@ -2410,8 +2556,8 @@
"internal_host_ssl_validation": true,
"invalidation_flow": "9add9e98-4fe7-48c5-bca8-d68968e0a7ba",
"jwks_sources": null,
- "jwt_federation_providers": null,
- "jwt_federation_sources": null,
+ "jwt_federation_providers": [],
+ "jwt_federation_sources": [],
"mode": "forward_single",
"name": "Hosting Family",
"property_mappings": null,
diff --git a/terraform/infrastructure/terraform.tfstate.backup b/terraform/infrastructure/terraform.tfstate.backup
index 44c43aa..e89d4a2 100644
--- a/terraform/infrastructure/terraform.tfstate.backup
+++ b/terraform/infrastructure/terraform.tfstate.backup
@@ -1,7 +1,7 @@
{
"version": 4,
"terraform_version": "1.14.3",
- "serial": 417,
+ "serial": 423,
"lineage": "8aff5d23-05f6-10eb-0ae6-1084c787677e",
"outputs": {
"coder_oauth_client_secret": {
@@ -211,7 +211,8 @@
"date_joined": "2026-09-05T14:23:01.528947Z",
"email": "",
"groups": [
- "ad33b003-77a4-4571-bec8-8301f6d6552b"
+ "ad33b003-77a4-4571-bec8-8301f6d6552b",
+ "5e91f468-a0f2-4433-aa23-a7b6ad69df38"
],
"id": "7",
"is_active": true,
@@ -495,6 +496,41 @@
}
]
},
+ {
+ "mode": "managed",
+ "type": "authentik_application",
+ "name": "hosting_family",
+ "provider": "provider[\"registry.terraform.io/goauthentik/authentik\"]",
+ "instances": [
+ {
+ "schema_version": 0,
+ "attributes": {
+ "backchannel_providers": null,
+ "group": "",
+ "id": "hosting-family",
+ "meta_description": "",
+ "meta_hide": false,
+ "meta_icon": "",
+ "meta_launch_url": "",
+ "meta_publisher": "",
+ "name": "Hosting Family",
+ "open_in_new_tab": false,
+ "policy_engine_mode": "any",
+ "protocol_provider": 16,
+ "slug": "hosting-family",
+ "uuid": "14ad2eb5-c3b8-4fe6-b980-26f1a91f44a3"
+ },
+ "sensitive_attributes": [],
+ "identity_schema_version": 0,
+ "private": "bnVsbA==",
+ "dependencies": [
+ "authentik_provider_proxy.hosting_family",
+ "data.authentik_flow.default_authorization_flow",
+ "data.authentik_flow.default_invalidation_flow"
+ ]
+ }
+ ]
+ },
{
"mode": "managed",
"type": "authentik_application",
@@ -1007,8 +1043,8 @@
"id": "5e91f468-a0f2-4433-aa23-a7b6ad69df38",
"is_superuser": false,
"name": "family",
- "parents": null,
- "roles": null,
+ "parents": [],
+ "roles": [],
"users": [
7
]
@@ -1118,7 +1154,8 @@
7,
3,
10,
- 15
+ 15,
+ 16
],
"service_connection": "",
"type": "proxy"
@@ -1130,6 +1167,7 @@
"authentik_provider_proxy.alertmanager",
"authentik_provider_proxy.gitea_phpmyadmin",
"authentik_provider_proxy.hass_coder",
+ "authentik_provider_proxy.hosting_family",
"authentik_provider_proxy.n8n",
"authentik_provider_proxy.nextcloud_phpmyadmin",
"authentik_provider_proxy.openclaw",
@@ -1393,6 +1431,73 @@
}
]
},
+ {
+ "mode": "managed",
+ "type": "authentik_policy_binding",
+ "name": "hosting_family_admin",
+ "provider": "provider[\"registry.terraform.io/goauthentik/authentik\"]",
+ "instances": [
+ {
+ "schema_version": 0,
+ "attributes": {
+ "enabled": true,
+ "failure_result": false,
+ "group": "c0ad76f4-d157-4c9e-882c-9ae5b5fc1690",
+ "id": "55b2f74f-f650-40a2-9781-a00a5978e1e8",
+ "negate": false,
+ "order": 0,
+ "policy": "",
+ "target": "14ad2eb5-c3b8-4fe6-b980-26f1a91f44a3",
+ "timeout": 30,
+ "user": 0
+ },
+ "sensitive_attributes": [],
+ "identity_schema_version": 0,
+ "private": "bnVsbA==",
+ "dependencies": [
+ "authentik_application.hosting_family",
+ "authentik_group.infra_admins",
+ "authentik_provider_proxy.hosting_family",
+ "data.authentik_flow.default_authorization_flow",
+ "data.authentik_flow.default_invalidation_flow"
+ ]
+ }
+ ]
+ },
+ {
+ "mode": "managed",
+ "type": "authentik_policy_binding",
+ "name": "hosting_family_family",
+ "provider": "provider[\"registry.terraform.io/goauthentik/authentik\"]",
+ "instances": [
+ {
+ "schema_version": 0,
+ "attributes": {
+ "enabled": true,
+ "failure_result": false,
+ "group": "5e91f468-a0f2-4433-aa23-a7b6ad69df38",
+ "id": "b598a889-f04b-4955-8a01-6558cfaad60e",
+ "negate": false,
+ "order": 1,
+ "policy": "",
+ "target": "14ad2eb5-c3b8-4fe6-b980-26f1a91f44a3",
+ "timeout": 30,
+ "user": 0
+ },
+ "sensitive_attributes": [],
+ "identity_schema_version": 0,
+ "private": "bnVsbA==",
+ "dependencies": [
+ "authentik_application.hosting_family",
+ "authentik_group.family",
+ "authentik_provider_proxy.hosting_family",
+ "data.authentik_flow.default_authorization_flow",
+ "data.authentik_flow.default_invalidation_flow",
+ "data.authentik_user.amy"
+ ]
+ }
+ ]
+ },
{
"mode": "managed",
"type": "authentik_policy_binding",
@@ -2281,6 +2386,48 @@
}
]
},
+ {
+ "mode": "managed",
+ "type": "authentik_provider_proxy",
+ "name": "hosting_family",
+ "provider": "provider[\"registry.terraform.io/goauthentik/authentik\"]",
+ "instances": [
+ {
+ "schema_version": 0,
+ "attributes": {
+ "access_token_validity": "days=30",
+ "authentication_flow": "",
+ "authorization_flow": "68f43818-60ba-4d8e-ae66-cbac29d7be42",
+ "basic_auth_enabled": false,
+ "basic_auth_password_attribute": "",
+ "basic_auth_username_attribute": "",
+ "client_id": "27XovQEtW8nMAgUN6kANZnQZMMbWVEbi8Mh6VsGP",
+ "cookie_domain": "",
+ "external_host": "https://hosting.haumdaucher.de",
+ "id": "16",
+ "intercept_header_auth": true,
+ "internal_host": "",
+ "internal_host_ssl_validation": true,
+ "invalidation_flow": "9add9e98-4fe7-48c5-bca8-d68968e0a7ba",
+ "jwks_sources": null,
+ "jwt_federation_providers": null,
+ "jwt_federation_sources": null,
+ "mode": "forward_single",
+ "name": "Hosting Family",
+ "property_mappings": null,
+ "refresh_token_validity": "days=30",
+ "skip_path_regex": ""
+ },
+ "sensitive_attributes": [],
+ "identity_schema_version": 0,
+ "private": "bnVsbA==",
+ "dependencies": [
+ "data.authentik_flow.default_authorization_flow",
+ "data.authentik_flow.default_invalidation_flow"
+ ]
+ }
+ ]
+ },
{
"mode": "managed",
"type": "authentik_provider_proxy",