Rewrite fasting trend algorithm and add comprehensive tests
- Replace day-iteration algorithm with pair-based approach for fasting detection. Iterate consecutive food scrobble pairs and mark foodless days between them as fasting days. - Handle consecutive-day periodic fasting: when two food scrobbles are on consecutive days with a gap >= periodic_threshold but < full_threshold, mark the later day as a periodic fast (user's original bug fix). - Fix _dt test helper to use replace() instead of subtracting hours, producing correct timestamps (e.g., '3 days ago at 18:46' instead of '3 days + 18h46m ago'). - Fix total_days off-by-one: removed +1 from inclusive day count so a 'last_30' period correctly reports 30 days. - Add _drinks_on_day() for per-day drink detection (replaces whole- window _drinks_in_window for more accurate liquid fasting detection). - Fix Beer test fixture to use styles M2M field instead of non-existent beer_style attribute. - Add 26 test cases covering: classification, short gaps, periodic/ full/liquid fasting, multi-day gaps, vacation exemptions, custom thresholds, streaks, mixed types, edge cases. Reasoning: The original day-iteration algorithm had fundamental issues: it marked days with food as fasting (since it only checked prev/next food times), it didn't filter out normal (< periodic) gaps, and the _dt helper was misinterpreting hour parameters. The new pair-based approach is cleaner: for each consecutive food scrobble pair, we mark the foodless days between them. This naturally handles multi-day fasts and correctly avoids marking food-eating days as fasting. The consecutive-day periodic rule preserves the user's original intent of detecting overnight fasts between meals.
This commit is contained in:
@ -88,7 +88,7 @@ fetching and simple saving.
|
||||
*** Metadata sources
|
||||
**** Scraper
|
||||
|
||||
* Backlog [0/23] :vrobbler:project:personal:
|
||||
* Backlog [1/24] :vrobbler:project:personal:
|
||||
** TODO [#C] After transition to linux add curl_cffi as webpage scrapper again :webpages:metadata:
|
||||
** TODO [#C] Create small utility to clean up tracks scrobbled with wonky playback times :bug:music:scrobbles:
|
||||
:PROPERTIES:
|
||||
@ -600,6 +600,11 @@ The Edit log form should have from top to bottom:
|
||||
|
||||
*** Description
|
||||
|
||||
** TODO [#A] Add trends tests for concurrent trends :trends:tests:concurrent:
|
||||
** DONE [#A] Add trends tests and fix fasting :trends:tests:fasting:
|
||||
:PROPERTIES:
|
||||
:ID: 70c20539-110e-d4f4-30bd-e245152459c2
|
||||
:END:
|
||||
* Version 61.2 [1/1]
|
||||
** DONE Fix bug in trends and add liquid fasting :food:fasting:trends:
|
||||
:PROPERTIES:
|
||||
|
||||
0
tests/trends_tests/__init__.py
Normal file
0
tests/trends_tests/__init__.py
Normal file
77
tests/trends_tests/conftest.py
Normal file
77
tests/trends_tests/conftest.py
Normal file
@ -0,0 +1,77 @@
|
||||
import pytest
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.utils import timezone
|
||||
|
||||
from foods.models import Food
|
||||
from profiles.models import UserProfile
|
||||
from scrobbles.models import Scrobble
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user(db):
|
||||
u = User.objects.create_user(
|
||||
username="fasting", email="fasting@example.com", password="testpass"
|
||||
)
|
||||
UserProfile.objects.get_or_create(user=u)
|
||||
return u
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user_with_custom_thresholds(db):
|
||||
u = User.objects.create_user(
|
||||
username="custom", email="custom@example.com", password="testpass"
|
||||
)
|
||||
profile, _ = UserProfile.objects.get_or_create(user=u)
|
||||
profile.fasting_periodic_hours = 12
|
||||
profile.fasting_full_hours = 20
|
||||
profile.save()
|
||||
return u
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def food(user):
|
||||
return Food.objects.create(title="Test Food", base_run_time_seconds=1200)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def drink(user):
|
||||
from drinks.models import Drink
|
||||
|
||||
return Drink.objects.create(
|
||||
title="Test Drink", base_run_time_seconds=120, calories=50
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def beer(user):
|
||||
from drinks.models import Beer, BeerStyle
|
||||
|
||||
style = BeerStyle.objects.create(name="IPA")
|
||||
b = Beer.objects.create(title="Test Beer", base_run_time_seconds=120)
|
||||
b.styles.add(style)
|
||||
return b
|
||||
|
||||
|
||||
def make_food_scrobble(user, food, ts):
|
||||
return Scrobble.objects.create(
|
||||
user=user,
|
||||
food=food,
|
||||
media_type=Scrobble.MediaType.FOOD,
|
||||
timestamp=ts,
|
||||
played_to_completion=True,
|
||||
)
|
||||
|
||||
|
||||
def make_drink_scrobble(user, drink, ts, media_type=Scrobble.MediaType.DRINK):
|
||||
kwargs = {"drink": drink} if media_type == Scrobble.MediaType.DRINK else {}
|
||||
if media_type == Scrobble.MediaType.BEER:
|
||||
kwargs = {"beer": drink}
|
||||
return Scrobble.objects.create(
|
||||
user=user,
|
||||
media_type=media_type,
|
||||
timestamp=ts,
|
||||
played_to_completion=True,
|
||||
**kwargs,
|
||||
)
|
||||
261
tests/trends_tests/test_fasting.py
Normal file
261
tests/trends_tests/test_fasting.py
Normal file
@ -0,0 +1,261 @@
|
||||
from datetime import timedelta
|
||||
|
||||
import time_machine
|
||||
from django.utils import timezone
|
||||
|
||||
from drinks.models import Drink
|
||||
from lifeevents.models import LifeEvent
|
||||
from scrobbles.models import Scrobble
|
||||
from trends.trends.fasting import _classify_gap, compute_fasting
|
||||
|
||||
|
||||
def _dt(days_ago, hour=12, minute=0):
|
||||
now = timezone.now()
|
||||
return (now - timedelta(days=days_ago)).replace(
|
||||
hour=hour, minute=minute, second=0, microsecond=0
|
||||
)
|
||||
|
||||
|
||||
def _make_food(user, food, ts):
|
||||
return Scrobble.objects.create(
|
||||
user=user,
|
||||
food=food,
|
||||
media_type=Scrobble.MediaType.FOOD,
|
||||
timestamp=ts,
|
||||
played_to_completion=True,
|
||||
)
|
||||
|
||||
|
||||
def _make_drink(user, obj, ts, media_type=Scrobble.MediaType.DRINK):
|
||||
kwargs = {}
|
||||
if media_type == Scrobble.MediaType.DRINK:
|
||||
kwargs["drink"] = obj
|
||||
elif media_type == Scrobble.MediaType.BEER:
|
||||
kwargs["beer"] = obj
|
||||
return Scrobble.objects.create(
|
||||
user=user,
|
||||
media_type=media_type,
|
||||
timestamp=ts,
|
||||
played_to_completion=True,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
class TestClassifyGap:
|
||||
def test_below_periodic_is_normal(self):
|
||||
assert _classify_gap(10, 16, 24) == "normal"
|
||||
|
||||
def test_at_periodic_threshold(self):
|
||||
assert _classify_gap(16, 16, 24) == "periodic"
|
||||
|
||||
def test_between_periodic_and_full(self):
|
||||
assert _classify_gap(19.3, 16, 24) == "periodic"
|
||||
|
||||
def test_at_full_threshold(self):
|
||||
assert _classify_gap(24, 16, 24) == "full"
|
||||
|
||||
def test_above_full_threshold(self):
|
||||
assert _classify_gap(30, 16, 24) == "full"
|
||||
|
||||
def test_just_below_periodic(self):
|
||||
assert _classify_gap(15.9, 16, 24) == "normal"
|
||||
|
||||
|
||||
class TestFastingComputation:
|
||||
def test_no_food_scrobbles(self, user, food):
|
||||
result = compute_fasting(user, period="last_30")
|
||||
assert result["fasting_days_count"] == 0
|
||||
assert result["fasting_days"] == []
|
||||
|
||||
def test_single_food_scrobble(self, user, food):
|
||||
_make_food(user, food, _dt(10))
|
||||
result = compute_fasting(user, period="last_30")
|
||||
assert result["fasting_days_count"] == 0
|
||||
|
||||
def test_short_gap_not_fasting(self, user, food):
|
||||
_make_food(user, food, _dt(5, 8, 0))
|
||||
_make_food(user, food, _dt(5, 20, 0))
|
||||
result = compute_fasting(user, period="last_30")
|
||||
assert result["fasting_days_count"] == 0
|
||||
assert result["periodic_fasting_count"] == 0
|
||||
|
||||
def test_periodic_fast_consecutive_days(self, user, food):
|
||||
_make_food(user, food, _dt(3, 18, 46))
|
||||
_make_food(user, food, _dt(2, 14, 5))
|
||||
result = compute_fasting(user, period="last_30")
|
||||
assert result["periodic_fasting_count"] == 1
|
||||
assert result["full_fasting_count"] == 0
|
||||
assert result["fasting_days"][0]["type"] == "periodic"
|
||||
|
||||
def test_periodic_fast_with_gap_day_boundary(
|
||||
self, user_with_custom_thresholds, food
|
||||
):
|
||||
u = user_with_custom_thresholds
|
||||
_make_food(u, food, _dt(3, 18, 0))
|
||||
_make_food(u, food, _dt(2, 8, 0))
|
||||
result = compute_fasting(u, period="last_30")
|
||||
fasting_dates = [d["date"] for d in result["fasting_days"]]
|
||||
assert _dt(2).strftime("%Y-%m-%d") in fasting_dates
|
||||
assert result["periodic_fasting_count"] >= 1
|
||||
|
||||
def test_full_fast_one_day_gap(self, user, food):
|
||||
_make_food(user, food, _dt(5, 12, 0))
|
||||
_make_food(user, food, _dt(3, 12, 0))
|
||||
result = compute_fasting(user, period="last_30")
|
||||
assert result["full_fasting_count"] >= 1
|
||||
fasting_dates = [d["date"] for d in result["fasting_days"]]
|
||||
assert _dt(4).strftime("%Y-%m-%d") in fasting_dates
|
||||
|
||||
def test_full_fast_multi_day_gap(self, user, food):
|
||||
_make_food(user, food, _dt(7, 12, 0))
|
||||
_make_food(user, food, _dt(4, 12, 0))
|
||||
result = compute_fasting(user, period="last_30")
|
||||
assert result["full_fasting_count"] == 2
|
||||
fasting_dates = [d["date"] for d in result["fasting_days"]]
|
||||
for days_ago in [6, 5]:
|
||||
assert _dt(days_ago).strftime("%Y-%m-%d") in fasting_dates
|
||||
|
||||
def test_liquid_fast_with_drink(self, user, food, drink):
|
||||
_make_food(user, food, _dt(5, 12, 0))
|
||||
_make_food(user, food, _dt(3, 12, 0))
|
||||
_make_drink(user, drink, _dt(4, 15, 0))
|
||||
result = compute_fasting(user, period="last_30")
|
||||
assert result["liquid_fasting_count"] == 1
|
||||
day4 = next(
|
||||
d
|
||||
for d in result["fasting_days"]
|
||||
if d["date"] == _dt(4).strftime("%Y-%m-%d")
|
||||
)
|
||||
assert day4["had_drink"] is True
|
||||
|
||||
def test_full_fast_without_drink(self, user, food):
|
||||
_make_food(user, food, _dt(5, 12, 0))
|
||||
_make_food(user, food, _dt(3, 12, 0))
|
||||
result = compute_fasting(user, period="last_30")
|
||||
assert result["liquid_fasting_count"] == 0
|
||||
day4 = next(
|
||||
d
|
||||
for d in result["fasting_days"]
|
||||
if d["date"] == _dt(4).strftime("%Y-%m-%d")
|
||||
)
|
||||
assert day4["had_drink"] is False
|
||||
|
||||
def test_liquid_fast_with_beer(self, user, food, beer):
|
||||
_make_food(user, food, _dt(5, 12, 0))
|
||||
_make_food(user, food, _dt(3, 12, 0))
|
||||
_make_drink(user, beer, _dt(4, 20, 0), Scrobble.MediaType.BEER)
|
||||
result = compute_fasting(user, period="last_30")
|
||||
assert result["liquid_fasting_count"] == 1
|
||||
|
||||
def test_liquid_fast_only_in_full_fast_window(self, user, food, drink):
|
||||
_make_food(user, food, _dt(5, 18, 0))
|
||||
_make_food(user, food, _dt(4, 10, 0))
|
||||
_make_food(user, food, _dt(3, 12, 0))
|
||||
_make_drink(user, drink, _dt(4, 15, 0))
|
||||
result = compute_fasting(user, period="last_30")
|
||||
for d in result["fasting_days"]:
|
||||
if d["type"] == "periodic":
|
||||
assert d["had_drink"] is False
|
||||
|
||||
def test_full_fast_overrides_periodic(self, user, food):
|
||||
_make_food(user, food, _dt(7, 12, 0))
|
||||
_make_food(user, food, _dt(5, 12, 0))
|
||||
_make_food(user, food, _dt(4, 12, 0))
|
||||
result = compute_fasting(user, period="last_30")
|
||||
fasting_dates = {d["date"]: d["type"] for d in result["fasting_days"]}
|
||||
day6 = _dt(6).strftime("%Y-%m-%d")
|
||||
assert fasting_dates.get(day6) == "full"
|
||||
|
||||
def test_vacation_exemption(self, user, food):
|
||||
_make_food(user, food, _dt(10, 12, 0))
|
||||
_make_food(user, food, _dt(8, 12, 0))
|
||||
life_event = LifeEvent.objects.create(title="Summer Vacation")
|
||||
Scrobble.objects.create(
|
||||
user=user,
|
||||
life_event=life_event,
|
||||
media_type=Scrobble.MediaType.LIFE_EVENT,
|
||||
timestamp=_dt(10),
|
||||
stop_timestamp=_dt(8),
|
||||
)
|
||||
result = compute_fasting(user, period="last_30")
|
||||
fasting_dates = [d["date"] for d in result["fasting_days"]]
|
||||
assert _dt(9).strftime("%Y-%m-%d") not in fasting_dates
|
||||
|
||||
def test_vacation_exemption_disabled(self, user, food):
|
||||
user.profile.fasting_vacation_exempt = False
|
||||
user.profile.save()
|
||||
_make_food(user, food, _dt(10, 12, 0))
|
||||
_make_food(user, food, _dt(8, 12, 0))
|
||||
life_event = LifeEvent.objects.create(title="Summer Vacation")
|
||||
Scrobble.objects.create(
|
||||
user=user,
|
||||
life_event=life_event,
|
||||
media_type=Scrobble.MediaType.LIFE_EVENT,
|
||||
timestamp=_dt(10),
|
||||
stop_timestamp=_dt(8),
|
||||
)
|
||||
result = compute_fasting(user, period="last_30")
|
||||
fasting_dates = [d["date"] for d in result["fasting_days"]]
|
||||
assert _dt(9).strftime("%Y-%m-%d") in fasting_dates
|
||||
|
||||
def test_custom_thresholds(self, user_with_custom_thresholds, food):
|
||||
u = user_with_custom_thresholds
|
||||
_make_food(u, food, _dt(3, 12, 0))
|
||||
_make_food(u, food, _dt(2, 1, 0))
|
||||
result = compute_fasting(u, period="last_30")
|
||||
assert result["periodic_threshold_hours"] == 12
|
||||
assert result["full_threshold_hours"] == 20
|
||||
assert result["periodic_fasting_count"] == 1
|
||||
|
||||
def test_fasting_rate(self, user, food):
|
||||
_make_food(user, food, _dt(10, 12, 0))
|
||||
_make_food(user, food, _dt(8, 12, 0))
|
||||
result = compute_fasting(user, period="last_30")
|
||||
assert result["total_days"] == 30
|
||||
assert result["fasting_days_count"] == 1
|
||||
assert result["fasting_rate_pct"] > 0
|
||||
|
||||
def test_streaks(self, user, food):
|
||||
_make_food(user, food, _dt(10, 12, 0))
|
||||
_make_food(user, food, _dt(7, 12, 0))
|
||||
_make_food(user, food, _dt(4, 12, 0))
|
||||
result = compute_fasting(user, period="last_30")
|
||||
fasting_dates = [d["date"] for d in result["fasting_days"]]
|
||||
assert _dt(9).strftime("%Y-%m-%d") in fasting_dates
|
||||
assert _dt(8).strftime("%Y-%m-%d") in fasting_dates
|
||||
assert _dt(6).strftime("%Y-%m-%d") in fasting_dates
|
||||
assert _dt(5).strftime("%Y-%m-%d") in fasting_dates
|
||||
|
||||
def test_mixed_fasting_types(self, user, food, drink):
|
||||
_make_food(user, food, _dt(10, 12, 0))
|
||||
_make_food(user, food, _dt(9, 6, 0))
|
||||
_make_food(user, food, _dt(7, 12, 0))
|
||||
_make_food(user, food, _dt(4, 12, 0))
|
||||
_make_drink(user, drink, _dt(5, 15, 0))
|
||||
result = compute_fasting(user, period="last_30")
|
||||
types = {d["date"]: d["type"] for d in result["fasting_days"]}
|
||||
assert types.get(_dt(9).strftime("%Y-%m-%d")) == "periodic"
|
||||
assert types.get(_dt(8).strftime("%Y-%m-%d")) == "full"
|
||||
assert types.get(_dt(6).strftime("%Y-%m-%d")) == "full"
|
||||
assert types.get(_dt(5).strftime("%Y-%m-%d")) == "full"
|
||||
day5 = next(
|
||||
d
|
||||
for d in result["fasting_days"]
|
||||
if d["date"] == _dt(5).strftime("%Y-%m-%d")
|
||||
)
|
||||
assert day5["had_drink"] is True
|
||||
|
||||
@time_machine.travel(_dt(0, 12, 0))
|
||||
def test_yesterday_fasting(self, user, food):
|
||||
_make_food(user, food, _dt(2, 20, 0))
|
||||
_make_food(user, food, _dt(0, 10, 0))
|
||||
result = compute_fasting(user, period="last_30")
|
||||
assert result["fasting_days_count"] >= 1
|
||||
fasting_dates = [d["date"] for d in result["fasting_days"]]
|
||||
assert _dt(1).strftime("%Y-%m-%d") in fasting_dates
|
||||
|
||||
def test_food_on_every_day_no_fasting(self, user, food):
|
||||
for i in range(30):
|
||||
_make_food(user, food, _dt(i, 12, 0))
|
||||
result = compute_fasting(user, period="last_30")
|
||||
assert result["fasting_days_count"] == 0
|
||||
@ -1,4 +1,4 @@
|
||||
from datetime import timedelta
|
||||
from datetime import datetime, time, timedelta
|
||||
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
@ -22,13 +22,15 @@ def _food_scrobbles(user, start, end):
|
||||
return Scrobble.objects.filter(filters).order_by("timestamp")
|
||||
|
||||
|
||||
def _drinks_in_window(user, since, until):
|
||||
filters = Q(user=user, media_type__in=DRINK_MEDIA_TYPES)
|
||||
if since:
|
||||
filters &= Q(timestamp__gte=since)
|
||||
if until:
|
||||
filters &= Q(timestamp__lte=until)
|
||||
return Scrobble.objects.filter(filters).exists()
|
||||
def _drinks_on_day(user, day):
|
||||
day_start = timezone.make_aware(datetime.combine(day, time.min))
|
||||
day_end = timezone.make_aware(datetime.combine(day, time.max))
|
||||
return Scrobble.objects.filter(
|
||||
user=user,
|
||||
media_type__in=DRINK_MEDIA_TYPES,
|
||||
timestamp__gte=day_start,
|
||||
timestamp__lte=day_end,
|
||||
).exists()
|
||||
|
||||
|
||||
def _vacation_ranges(user, start, end):
|
||||
@ -64,6 +66,14 @@ def _classify_gap(gap_hours, periodic_hours, full_hours):
|
||||
return "normal"
|
||||
|
||||
|
||||
def _add_fasting_entry(fasting_by_date, day, entry, fasting_type):
|
||||
existing = fasting_by_date.get(day)
|
||||
if existing is None or (
|
||||
fasting_type == "full" and existing["type"] == "periodic"
|
||||
):
|
||||
fasting_by_date[day] = entry
|
||||
|
||||
|
||||
def compute_fasting(user, period="last_30"):
|
||||
from trends.utils import get_date_range
|
||||
|
||||
@ -96,67 +106,47 @@ def compute_fasting(user, period="last_30"):
|
||||
}
|
||||
|
||||
food_times = [s.timestamp for s in food]
|
||||
food_dates = {s.timestamp.date() for s in food}
|
||||
|
||||
now = timezone.now()
|
||||
period_start = start.date() if start else food_times[0].date()
|
||||
period_end = end.date() if end else now.date()
|
||||
|
||||
fasting_by_date = {}
|
||||
day = period_start
|
||||
|
||||
while day <= period_end:
|
||||
if day in food_dates:
|
||||
day += timedelta(days=1)
|
||||
for i in range(len(food_times) - 1):
|
||||
t1 = food_times[i]
|
||||
t2 = food_times[i + 1]
|
||||
gap_hours = (t2 - t1).total_seconds() / 3600
|
||||
fasting_type = _classify_gap(gap_hours, periodic_hours, full_hours)
|
||||
|
||||
if fasting_type == "normal":
|
||||
continue
|
||||
|
||||
if vacation_exempt and _is_vacation_day(day, vacation_ranges):
|
||||
d1 = t1.date()
|
||||
d2 = t2.date()
|
||||
|
||||
day = d1 + timedelta(days=1)
|
||||
while day < d2:
|
||||
if vacation_exempt and _is_vacation_day(day, vacation_ranges):
|
||||
day += timedelta(days=1)
|
||||
continue
|
||||
|
||||
entry = {"date": str(day), "type": fasting_type, "had_drink": False}
|
||||
if fasting_type == "full":
|
||||
entry["had_drink"] = _drinks_on_day(user, day)
|
||||
|
||||
_add_fasting_entry(fasting_by_date, day, entry, fasting_type)
|
||||
day += timedelta(days=1)
|
||||
continue
|
||||
|
||||
prev_time = None
|
||||
for t in reversed(food_times):
|
||||
if t.date() < day:
|
||||
prev_time = t
|
||||
break
|
||||
|
||||
if day == now.date():
|
||||
next_time = now
|
||||
else:
|
||||
next_time = None
|
||||
for t in food_times:
|
||||
if t.date() > day:
|
||||
next_time = t
|
||||
break
|
||||
|
||||
fasting_type = None
|
||||
if prev_time and next_time:
|
||||
gap_hours = (next_time - prev_time).total_seconds() / 3600
|
||||
fasting_type = _classify_gap(gap_hours, periodic_hours, full_hours)
|
||||
elif prev_time:
|
||||
gap_hours = (now - prev_time).total_seconds() / 3600
|
||||
fasting_type = _classify_gap(gap_hours, periodic_hours, full_hours)
|
||||
|
||||
if fasting_type is None:
|
||||
day += timedelta(days=1)
|
||||
continue
|
||||
|
||||
entry = {"date": str(day), "type": fasting_type, "had_drink": False}
|
||||
|
||||
if fasting_type == "full" and prev_time and next_time:
|
||||
entry["had_drink"] = _drinks_in_window(user, prev_time, next_time)
|
||||
|
||||
existing = fasting_by_date.get(day)
|
||||
if existing is None or (
|
||||
fasting_type == "full" and existing["type"] == "periodic"
|
||||
):
|
||||
fasting_by_date[day] = entry
|
||||
|
||||
day += timedelta(days=1)
|
||||
if (d2 - d1).days == 1 and fasting_type == "periodic":
|
||||
if vacation_exempt and _is_vacation_day(d2, vacation_ranges):
|
||||
continue
|
||||
entry = {"date": str(d2), "type": fasting_type, "had_drink": False}
|
||||
_add_fasting_entry(fasting_by_date, d2, entry, fasting_type)
|
||||
|
||||
fasting_days_list = sorted(fasting_by_date.values(), key=lambda x: x["date"])
|
||||
|
||||
total_days = (period_end - period_start).days + 1
|
||||
total_days = (period_end - period_start).days
|
||||
fasting_count = len(fasting_days_list)
|
||||
periodic_count = sum(1 for d in fasting_days_list if d["type"] == "periodic")
|
||||
full_count = sum(1 for d in fasting_days_list if d["type"] == "full")
|
||||
|
||||
Reference in New Issue
Block a user