diff --git a/pyproject.toml b/pyproject.toml index dee99af..4f181a0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "huckleberry-api" -version = "0.4.2" +version = "0.4.3" description = "Python API client for Huckleberry baby tracking app using Firebase Firestore" readme = { file = "README.md", content-type = "text/markdown" } license = { text = "MIT" } diff --git a/src/huckleberry_api/firebase_types.py b/src/huckleberry_api/firebase_types.py index 2ab09b2..6a666f9 100644 --- a/src/huckleberry_api/firebase_types.py +++ b/src/huckleberry_api/firebase_types.py @@ -8,7 +8,7 @@ from typing import Literal, TypeAlias -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, field_validator Number: TypeAlias = int | float JsonScalar: TypeAlias = str | int | float | bool | None @@ -161,10 +161,25 @@ class FirebaseChildSweetspot(StrictModel): """Known payload for childs/{child_id}.sweetspot.""" selectedNapDay: Number | None = None - sweetSpotTimes: dict[str, Number] | None = None + sweetSpotTimes: list[Number | None] | None = None sweetspotStrings: FirebaseChildSweetspotStrings | None = None uuid: str | None = None + @field_validator("sweetSpotTimes", mode="before") + @classmethod + def _normalize_sweetspot_times(cls, value: object) -> object: + """Firebase sends this as either a dict or a list; normalize to list.""" + if isinstance(value, dict): + if not value: + return None + # Determine max index to size the list + max_idx = max(int(k) for k in value.keys()) + result: list[Number | None] = [None] * (max_idx + 1) + for k, v in value.items(): + result[int(k)] = v + return result + return value + class FirebaseChildDocument(StrictModel): """Known fields from childs/{child_id}.""" diff --git a/tests/test_firebase_types.py b/tests/test_firebase_types.py index 43a600f..278abaa 100644 --- a/tests/test_firebase_types.py +++ b/tests/test_firebase_types.py @@ -7,6 +7,8 @@ FirebaseActivityPrefs, FirebaseActivityTimerData, FirebaseActivityTimerEntryData, + FirebaseChildDocument, + FirebaseChildSweetspot, FirebaseDiaperDocumentData, FirebaseFeedDocumentData, FirebaseGrowthData, @@ -451,3 +453,41 @@ def test_activity_multi_container_model() -> None: assert len(model.data) == 2 assert model.data["interval1"].mode == "bath" assert model.data["interval2"].mode == "storyTime" + + +def test_child_document_accepts_list_shaped_sweetspot_times() -> None: + """sweetSpotTimes is a list (not dict) with None placeholders from Firebase.""" + model = FirebaseChildDocument.model_validate( + { + "childsName": "Test Child", + "birthdate": "2023-01-01", + "gender": "M", + "sweetspot": { + "selectedNapDay": 3, + "sweetSpotTimes": [None, None, None, 1777506600.0, 1777504800.0], + }, + } + ) + + assert model.sweetspot is not None + assert model.sweetspot.sweetSpotTimes == [None, None, None, 1777506600.0, 1777504800.0] + assert model.sweetspot.selectedNapDay == 3 + + +def test_child_document_normalizes_dict_shaped_sweetspot_times() -> None: + """sweetSpotTimes may also arrive as a sparse dict from Firebase.""" + model = FirebaseChildDocument.model_validate( + { + "childsName": "Test Child", + "birthdate": "2023-01-01", + "gender": "M", + "sweetspot": { + "selectedNapDay": 4, + "sweetSpotTimes": {"3": 1777567800.0, "4": 1777566600.0}, + }, + } + ) + + assert model.sweetspot is not None + assert model.sweetspot.sweetSpotTimes == [None, None, None, 1777567800.0, 1777566600.0] + assert model.sweetspot.selectedNapDay == 4