Skip to content

Commit

Permalink
Next Sleep dashboard widget
Browse files Browse the repository at this point in the history
  • Loading branch information
fuhrysteve committed Jun 3, 2024
1 parent 946f006 commit 9ed4363
Show file tree
Hide file tree
Showing 11 changed files with 1,009 additions and 18 deletions.
1 change: 1 addition & 0 deletions Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ boto3 = "*"
dj-database-url = "*"
django = "~=5.0"
django-axes = "*"
django-enumfield = "*"
django-filter = "*"
django-imagekit = "*"
django-storages = "*"
Expand Down
684 changes: 684 additions & 0 deletions babybuddy/migrations/0033_alter_settings_language_and_more.py

Large diffs are not rendered by default.

11 changes: 11 additions & 0 deletions babybuddy/widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,14 @@ class DateInput(DateTimeBaseInput):

class TimeInput(DateTimeBaseInput):
input_type = "time"


class TimeDurationInput(widgets.TimeInput):
def __init__(self, attrs=None, format=None):
super().__init__(attrs, format)
self.attrs.update({"step": 1, "min": "00:00:00", "max": "23:59:59"})

def format_value(self, value):
if isinstance(value, datetime.timedelta):
value = str(value)
return value
30 changes: 27 additions & 3 deletions core/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@
from django.utils import timezone
from django.utils.translation import gettext_lazy as _

from django_enumfield.forms.fields import EnumChoiceField
from taggit.forms import TagField

from babybuddy.widgets import DateInput, DateTimeInput, TimeInput
from babybuddy.widgets import DateInput, DateTimeInput, TimeInput, TimeDurationInput
from core import models
from core.models import Timer
from core.widgets import TagsEditor, ChildRadioSelect, PillRadioSelect
Expand Down Expand Up @@ -188,14 +189,37 @@ class Meta:


class ChildForm(forms.ModelForm):

number_of_naps = EnumChoiceField(
models.Child.NumberOfNaps, initial=models.Child.NumberOfNaps.AUTO, required=True
)

class Meta:
model = models.Child
fields = ["first_name", "last_name", "birth_date", "birth_time"]
fields = [
"first_name",
"last_name",
"birth_date",
"birth_time",
"wake_window",
"number_of_naps",
]
if settings.BABY_BUDDY["ALLOW_UPLOADS"]:
fields.append("picture")
widgets = {
"birth_date": DateInput(),
"birth_time": TimeInput(),
"wake_window": TimeDurationInput(),
}
help_texts = {
"number_of_naps": _(
"The number of naps the child typically takes per day. "
"AUTO will determine the number of naps based on the child's age."
),
"wake_window": _(
"The amount of time in minutes a child can stay awake "
"between naps. Leave blank to auto-calculate."
),
}


Expand Down Expand Up @@ -237,7 +261,7 @@ class Meta:
fields = ["child", "time", "wet", "solid", "color", "amount", "notes", "tags"]
widgets = {
"child": ChildRadioSelect(),
"color": PillRadioSelect(),
"color": PillRadioSelect(),
"time": DateTimeInput(),
"notes": forms.Textarea(attrs={"rows": 5}),
}
Expand Down
29 changes: 29 additions & 0 deletions core/migrations/0035_child_number_of_naps_child_wake_window.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Generated by Django 5.0.6 on 2024-06-03 10:45

import core.models
import django_enumfield.db.fields
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("core", "0034_alter_tag_options"),
]

operations = [
migrations.AddField(
model_name="child",
name="number_of_naps",
field=django_enumfield.db.fields.EnumField(
default=1, enum=core.models.Child.NumberOfNaps
),
),
migrations.AddField(
model_name="child",
name="wake_window",
field=models.DurationField(
blank=True, null=True, verbose_name="Wake Window"
),
),
]
161 changes: 157 additions & 4 deletions core/models.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# -*- coding: utf-8 -*-
from enum import Enum
import datetime
import re
from datetime import timedelta
Expand All @@ -11,6 +12,7 @@
from django.utils import timezone
from django.utils.text import format_lazy, slugify
from django.utils.translation import gettext_lazy as _
from django_enumfield import enum
from taggit.managers import TaggableManager as TaggitTaggableManager
from taggit.models import GenericTaggedItemBase, TagBase

Expand Down Expand Up @@ -182,6 +184,96 @@ class Child(models.Model):
picture = models.ImageField(
blank=True, null=True, upload_to="child/picture/", verbose_name=_("Picture")
)
wake_window = models.DurationField(
blank=True, null=True, verbose_name=_("Wake Window")
)

class NumberOfNaps(enum.Enum):
AUTO = 1
ZERO = 0

ONE = 100
ONE_OR_TWO = 150
TWO = 200
TWO_OR_THREE = 250
THREE = 300
THREE_OR_FOUR = 350
FOUR = 400

__labels__ = {
AUTO: _("Auto"),
ZERO: _("0"),
ONE: _("One"),
ONE_OR_TWO: _("1 or 2"),
TWO: _("2"),
TWO_OR_THREE: _("2 or 3"),
THREE: _("3"),
THREE_OR_FOUR: _("3 or 4"),
FOUR: _("4"),
}

@classmethod
def auto(self, child, now=None):
"""
0 to 3 months 3 to 4 (0-90 days)
4 to 7 months 2 to 3 (91-210 days)
8 to 12 months 2 (211-365 days)
12 months to 18 months 1-2 (366-545 days)
18 months to 3 years 1 (546-1095 days)
"""
now = now or timezone.localtime()
age_in_days = (now.date() - child.birth_date).days
if age_in_days < 90:
return self.THREE_OR_FOUR
if age_in_days < 210:
return self.TWO_OR_THREE
if age_in_days < 365:
return self.TWO
if age_in_days < 545:
return self.ONE_OR_TWO
if age_in_days < 1095:
return self.ONE
return self.ZERO

def has_hit_sleeps_for_day(self, child, sleeps_today=None, now=None):
"""
Check if the child has hit the number of naps for the day.
"""
sleeps_today = sleeps_today or child.sleep.filter(
start__date=timezone.localdate()
)
if self == self.AUTO:
return len(sleeps_today) >= self.auto(child, now=now)
return len(sleeps_today) >= int(self.value)

def default_wake_window(self, child):
"""
Guess the wake window based on the number of naps and age
"""
sleep_needed_nighttime, sleep_needed_nap = (
child.sleep_needed_nighttime_nap()
)

number_of_naps = self.auto(child) if self == self.AUTO else self

if number_of_naps is self.__class__.ZERO:
return sleep_needed_nighttime + sleep_needed_nap - timedelta(hours=24)
if number_of_naps is self.__class__.ONE:
return sleep_needed_nighttime + sleep_needed_nap - timedelta(hours=12)
if number_of_naps in (self.__class__.TWO, self.__class__.ONE_OR_TWO):
return sleep_needed_nighttime + sleep_needed_nap - timedelta(hours=10)
if number_of_naps in (self.__class__.THREE, self.__class__.TWO_OR_THREE):
return sleep_needed_nighttime + sleep_needed_nap - timedelta(hours=9)
if number_of_naps in (self.__class__.FOUR, self.__class__.THREE_OR_FOUR):
return sleep_needed_nighttime + sleep_needed_nap - timedelta(hours=8)
return sleep_needed_nighttime + sleep_needed_nap - timedelta(hours=12)

number_of_naps = enum.EnumField(
NumberOfNaps,
blank=False,
default=NumberOfNaps.AUTO,
verbose_name=_("Number of Naps"),
)

objects = models.Manager()

Expand Down Expand Up @@ -213,17 +305,78 @@ def name(self, reverse=False):
return "{} {}".format(self.first_name, self.last_name)

def birth_datetime(self):
if self.birth_time:
return timezone.make_aware(
datetime.datetime.combine(self.birth_date, self.birth_time)
return timezone.make_aware(
datetime.datetime.combine(
self.birth_date, self.birth_time or datetime.time(0)
)
return self.birth_date
)

@property
def wake_window_or_default(self):
return self.wake_window or self.number_of_naps.default_wake_window(child=self)

@classmethod
def count(cls):
"""Get a (cached) count of total number of Child instances."""
return cache.get_or_set(cls.cache_key_count, Child.objects.count, None)

def sleep_needed_nighttime_nap(self, now=None):
"""
Guess the amount of sleep needed per day based on age.
0 Month: 16 hours, 8.5, 8 (0-30 days)
1 Month 15.5 hours, 8.5, 7 (31-90 days)
3 Months: 15 hours, 9.5, 4.5 (91-180 days)
6 Months: 14 hours, 10, 4 (181-270 days)
9 Months: 14 hours, 11, 3 (271-365 days)
12 Months: 14 hours, 11, 3 (366-545 days)
18 Months: 13.5 hours, 11, 2.5 (546-730 days)
2 Years: 13 hours, 11, 2 (731-1095 days)
Returns tuple (nighttime: timedelta, naptime: timedelta)
"""
now = now or timezone.localtime()
age_in_days = (now.date() - self.birth_date).days
if age_in_days < 30:
return timedelta(hours=8.5), timedelta(hours=7)
if age_in_days < 90:
return timedelta(hours=9.5), timedelta(hours=4.5)
if age_in_days < 180:
return timedelta(hours=10), timedelta(hours=4)
if age_in_days < 270:
return timedelta(hours=11), timedelta(hours=3)
if age_in_days < 365:
return timedelta(hours=11), timedelta(hours=3)
if age_in_days < 545:
return timedelta(hours=11), timedelta(hours=3)
if age_in_days < 730:
return timedelta(hours=11), timedelta(hours=2.5)
return timedelta(hours=11), timedelta(hours=2)

def predict_next_sleep(self, now=None):
"""
Predict the next sleep time based on the last sleep entry, number of
naps per day, and wake window.
returns a tuple of (predicted_start: datetime, next_sleep_is_nap: bool)
"""

now = now or timezone.localtime()
last_sleep = self.sleep.order_by("-start").first()
sleeps_today = self.sleep.filter(start__date=now)
naps_today = sleeps_today.filter(nap=True)
next_sleep_is_nap = not self.number_of_naps.has_hit_sleeps_for_day(
self, sleeps_today
)

if not last_sleep:
return None, next_sleep_is_nap
if last_sleep.end is None:
return None, next_sleep_is_nap
if last_sleep.end > now - self.wake_window_or_default:
return None, next_sleep_is_nap
return last_sleep.end + self.wake_window_or_default, next_sleep_is_nap


class DiaperChange(models.Model):
model_name = "diaperchange"
Expand Down
54 changes: 54 additions & 0 deletions core/tests/tests_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,60 @@ def test_child_birth_datetime(self):
timezone.make_aware(datetime.datetime.combine(birth_date, birth_time)),
)

def test_number_of_naps_auto(self):
NumberOfNaps = models.Child.NumberOfNaps

child = models.Child.objects.create(
first_name="First",
last_name="Last",
birth_date=datetime.date(2020, 1, 1),
)

assert (
NumberOfNaps.auto(
child=child, now=child.birth_datetime() + datetime.timedelta(50)
)
== NumberOfNaps.THREE_TO_FOUR
)
assert (
NumberOfNaps.auto(
child=child, now=child.birth_datetime() + datetime.timedelta(100)
)
== NumberOfNaps.TWO_TO_THREE
)
assert (
NumberOfNaps.auto(
child=child, now=child.birth_datetime() + datetime.timedelta(300)
)
== NumberOfNaps.TWO
)
assert (
NumberOfNaps.auto(
child=child, now=child.birth_datetime() + datetime.timedelta(500)
)
== NumberOfNaps.ONE_TO_TWO
)
assert (
NumberOfNaps.auto(
child=child, now=child.birth_datetime() + datetime.timedelta(800)
)
== NumberOfNaps.ONE
)

def test_predict_next_sleep(self):
child = models.Child.objects.create(
first_name="First", last_name="Last", birth_date=datetime.date(2020, 1, 1)
)

sleep = models.Sleep.objects.create(
child=child,
start=child.birth_datetime() + timezone.timedelta(days=90),
end=child.birth_datetime() + timezone.timedelta(days=90, hours=1),
)

next_sleep = child.predict_next_sleep()
self.assertEqual(next_sleep, datetime.timedelta(hours=1))


class DiaperChangeTestCase(TestCase):
def setUp(self):
Expand Down
16 changes: 16 additions & 0 deletions dashboard/templates/cards/next_sleep.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{% extends 'cards/base.html' %}
{% load duration i18n %}
{% block header %}
<a href="{% url "core:sleep-list" %}">{% trans "Next Sleep" %}</a>
{% endblock %}
{% block title %}
{% if predicted_start %}
{% blocktrans trimmed with start=predicted_start|deltasince|duration_string:'m' time=predicted_start|time %}
<div>{{ start }} from now</div>
<small>{{ time }}</small>
{% endblocktrans %}
{% else %}
{% trans "None" %}
{% endif %}
{% endblock %}
{% block content %}{{ sleep.duration|duration_string }}{% endblock %}
1 change: 1 addition & 0 deletions dashboard/templates/dashboard/child.html
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
<div id="dashboard-child"
class="row"
data-masonry='{"percentPosition": true }'>
<div class="col-sm-6 col-lg-4">{% card_next_sleep object %}</div>
<div class="col-sm-6 col-lg-4">{% card_timer_list object %}</div>
<div class="col-sm-6 col-lg-4">{% card_feeding_last object %}</div>
<div class="col-sm-6 col-lg-4">{% card_diaperchange_last object %}</div>
Expand Down
Loading

0 comments on commit 9ed4363

Please sign in to comment.