diff --git a/PROJECT.org b/PROJECT.org index e2aa0b4..d845d94 100644 --- a/PROJECT.org +++ b/PROJECT.org @@ -601,6 +601,24 @@ The Edit log form should have from top to bottom: *** Description +** TODO [#B] Add trend for fasting :food:fasting:trends: +:PROPERTIES: +:ID: a290b64d-14ad-49c2-addc-0d149c0c38fc +:END: + +*** Description + +Could we add a trend for number of fasting days over the time period? This would +look for the gap between the last and next Food scrobble and if it's ever longer +than a specified seconds configuration setting we'd mark it as a fasting day. +There can be "Periodic Fasting" which is defined by default as 16 hours or "Full +Fasting" which is designed as a 24 hour period without fasting. + +Fasting checking should also be skipped if it's during a LifeEvent of type +Vacation as vacations should be exempt. This exemption should be a userprofile +setting so folks can turn it on or off, and if it's changed the next trend +rebuild should consider that. + * Version 61.0 [3/3] ** DONE [#B] Try adding a nature app with Observations and Species diff --git a/vrobbler/apps/profiles/forms.py b/vrobbler/apps/profiles/forms.py index 60f4c38..ffbcccb 100644 --- a/vrobbler/apps/profiles/forms.py +++ b/vrobbler/apps/profiles/forms.py @@ -49,6 +49,9 @@ class UserProfileForm(forms.ModelForm): "default_water_size", "water_reminder_enabled", "trends_disabled", + "fasting_vacation_exempt", + "fasting_periodic_hours", + "fasting_full_hours", ] widgets = { "lastfm_password": forms.PasswordInput(render_value=True), diff --git a/vrobbler/apps/profiles/migrations/0045_userprofile_fasting_settings.py b/vrobbler/apps/profiles/migrations/0045_userprofile_fasting_settings.py new file mode 100644 index 0000000..9d1784c --- /dev/null +++ b/vrobbler/apps/profiles/migrations/0045_userprofile_fasting_settings.py @@ -0,0 +1,35 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("profiles", "0044_userprofile_inaturalist_auto_import_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="userprofile", + name="fasting_vacation_exempt", + field=models.BooleanField( + default=True, + help_text="Skip fasting detection during Vacation life events", + ), + ), + migrations.AddField( + model_name="userprofile", + name="fasting_periodic_hours", + field=models.IntegerField( + default=16, + help_text="Hour gap threshold for periodic (intermittent) fasting", + ), + ), + migrations.AddField( + model_name="userprofile", + name="fasting_full_hours", + field=models.IntegerField( + default=24, + help_text="Hour gap threshold for full-day fasting", + ), + ), + ] diff --git a/vrobbler/apps/profiles/models.py b/vrobbler/apps/profiles/models.py index b02254d..f40ac22 100644 --- a/vrobbler/apps/profiles/models.py +++ b/vrobbler/apps/profiles/models.py @@ -154,6 +154,19 @@ class UserProfile(TimeStampedModel): help_text="List of trend slugs the user has disabled", ) + fasting_vacation_exempt = models.BooleanField( + default=True, + help_text="Skip fasting detection during Vacation life events", + ) + fasting_periodic_hours = models.IntegerField( + default=16, + help_text="Hour gap threshold for periodic (intermittent) fasting", + ) + fasting_full_hours = models.IntegerField( + default=24, + help_text="Hour gap threshold for full-day fasting", + ) + inaturalist_username = models.CharField(max_length=255, blank=True, null=True) inaturalist_user_id = models.IntegerField(blank=True, null=True) inaturalist_auto_import = models.BooleanField(default=False) diff --git a/vrobbler/apps/trends/templates/trends/_fasting.html b/vrobbler/apps/trends/templates/trends/_fasting.html new file mode 100644 index 0000000..6ab2116 --- /dev/null +++ b/vrobbler/apps/trends/templates/trends/_fasting.html @@ -0,0 +1,133 @@ +
+
+
+
+
Total Days
+

{{ data.total_days }}

+
+
+
+
+
+
+
Fasting Days
+

{{ data.fasting_days_count }}

+
+
+
+
+
+
+
Fasting Rate
+

{{ data.fasting_rate_pct }}%

+
+
+
+
+
+
+
Current Streak
+

+ {% if data.current_streak.count > 0 %} + {{ data.current_streak.count }} + {{ data.current_streak.type }} + {% else %} + — + {% endif %} +

+
+
+
+
+ +
+
+

+ Periodic fasting: {{ data.periodic_threshold_hours }}h+ gaps ({{ data.periodic_fasting_count }} days) · + Full fasting: {{ data.full_threshold_hours }}h+ gaps ({{ data.full_fasting_count }} days) +

+
+
+ +{% if data.current_streak.count > 0 %} +
+
+
+ Current streak: + {{ data.current_streak.count }} consecutive + + {{ data.current_streak.type }} + + fasting day{{ data.current_streak.count|pluralize }}. +
+
+
+{% endif %} + +{% if data.longest_streaks %} +
+
+
Longest Streaks
+
+ + + + + + + + + + + + {% for streak in data.longest_streaks %} + + + + + + + + {% endfor %} + +
#TypeLengthStartEnd
{{ forloop.counter }} + + {{ streak.type|title }} + + {{ streak.count }} day{{ streak.count|pluralize }}{{ streak.start }}{{ streak.end }}
+
+
+
+{% endif %} + +{% if data.fasting_days %} +
+
+
Fasting Days
+
+ + + + + + + + + {% for day in data.fasting_days %} + + + + + {% endfor %} + +
DateType
{{ day.date }} + + {{ day.type|title }} + +
+
+
+
+{% else %} +

No fasting days detected for this period.

+{% endif %} diff --git a/vrobbler/apps/trends/templates/trends/trend_detail.html b/vrobbler/apps/trends/templates/trends/trend_detail.html index 20b028d..5f3ce9c 100644 --- a/vrobbler/apps/trends/templates/trends/trend_detail.html +++ b/vrobbler/apps/trends/templates/trends/trend_detail.html @@ -97,6 +97,9 @@ {% elif trend.slug == "mood-weather" %} {% include "trends/_mood_weather.html" %} +{% elif trend.slug == "fasting" %} + {% include "trends/_fasting.html" %} + {% endif %} {% endif %} {% endblock %} diff --git a/vrobbler/apps/trends/trends/__init__.py b/vrobbler/apps/trends/trends/__init__.py index 2149e33..403d759 100644 --- a/vrobbler/apps/trends/trends/__init__.py +++ b/vrobbler/apps/trends/trends/__init__.py @@ -7,6 +7,7 @@ from trends.trends.concurrent import ( compute_concurrent_listening, compute_concurrent_reading, ) +from trends.trends.fasting import compute_fasting from trends.trends.mood import ( compute_mood_by_time, compute_mood_distribution, @@ -50,3 +51,4 @@ compute_time_of_day_categories = register("time-of-day-categories")( ) compute_trending_up = register("trending-up")(compute_trending_up) compute_weekly_rhythm = register("weekly-rhythm")(compute_weekly_rhythm) +compute_fasting = register("fasting")(compute_fasting) diff --git a/vrobbler/apps/trends/trends/fasting.py b/vrobbler/apps/trends/trends/fasting.py new file mode 100644 index 0000000..0f34afb --- /dev/null +++ b/vrobbler/apps/trends/trends/fasting.py @@ -0,0 +1,204 @@ +from collections import defaultdict +from datetime import timedelta + +from django.db.models import Q +from django.utils import timezone +from scrobbles.models import Scrobble + + +def _food_scrobbles(user, start, end): + filters = Q(user=user, media_type=Scrobble.MediaType.FOOD) + if start: + filters &= Q(timestamp__gte=start) + if end: + filters &= Q(timestamp__lte=end) + return Scrobble.objects.filter(filters).order_by("timestamp") + + +def _vacation_ranges(user, start, end): + """Return list of (start, end) tuples for Vacation life events.""" + filters = Q( + user=user, + media_type=Scrobble.MediaType.LIFE_EVENT, + life_event__title__icontains="vacation", + ) + if start: + filters &= Q(timestamp__gte=start) + if end: + filters &= Q(timestamp__lte=end) + ranges = [] + for s in Scrobble.objects.filter(filters).order_by("timestamp"): + vacation_start = s.timestamp + vacation_end = s.stop_timestamp or (s.timestamp + timedelta(hours=12)) + ranges.append((vacation_start.date(), vacation_end.date())) + return ranges + + +def _is_vacation_day(date, vacation_ranges): + for v_start, v_end in vacation_ranges: + if v_start <= date <= v_end: + return True + return False + + +def compute_fasting(user, period="last_30"): + from trends.utils import get_date_range + + start, end = get_date_range(period) + + profile = user.profile + periodic_hours = profile.fasting_periodic_hours or 16 + full_hours = profile.fasting_full_hours or 24 + vacation_exempt = profile.fasting_vacation_exempt + + vacation_ranges = [] + if vacation_exempt: + vacation_ranges = _vacation_ranges(user, start, end) + + food = list(_food_scrobbles(user, start, end)) + + if len(food) < 2: + return { + "total_days": 0, + "fasting_days_count": 0, + "periodic_fasting_count": 0, + "full_fasting_count": 0, + "fasting_rate_pct": 0.0, + "fasting_days": [], + "current_streak": {"count": 0, "type": None}, + "longest_streaks": [], + "periodic_threshold_hours": periodic_hours, + "full_threshold_hours": full_hours, + } + + fasting_by_date = {} + fasting_days_list = [] + + for i in range(len(food) - 1): + t1 = food[i].timestamp + t2 = food[i + 1].timestamp + gap = t2 - t1 + gap_hours = gap.total_seconds() / 3600 + + if gap_hours < periodic_hours: + continue + + if gap_hours >= full_hours: + ftype = "full" + else: + ftype = "periodic" + + day = t1.date() + timedelta(days=1) + end_day = t2.date() + + while day < end_day: + if vacation_exempt and _is_vacation_day(day, vacation_ranges): + day += timedelta(days=1) + continue + + existing = fasting_by_date.get(day) + if existing is None or (ftype == "full" and existing["type"] == "periodic"): + fasting_by_date[day] = {"date": str(day), "type": ftype} + day += timedelta(days=1) + + fasting_days_list = sorted(fasting_by_date.values(), key=lambda x: x["date"]) + + now = timezone.now().date() + if start: + period_start = start.date() + else: + period_start = food[0].timestamp.date() if food else now + if end: + period_end = end.date() + else: + period_end = now + + total_days = (period_end - period_start).days + 1 + 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") + rate = round((fasting_count / total_days) * 100, 1) if total_days > 0 else 0.0 + + longest_streaks = _compute_streaks(fasting_days_list) + current_streak = _compute_current_streak(fasting_days_list, now) + + return { + "total_days": total_days, + "fasting_days_count": fasting_count, + "periodic_fasting_count": periodic_count, + "full_fasting_count": full_count, + "fasting_rate_pct": rate, + "fasting_days": fasting_days_list, + "current_streak": current_streak, + "longest_streaks": longest_streaks, + "periodic_threshold_hours": periodic_hours, + "full_threshold_hours": full_hours, + } + + +def _compute_streaks(fasting_days): + if not fasting_days: + return [] + + streaks = [] + streak_start = fasting_days[0]["date"] + streak_type = fasting_days[0]["type"] + streak_count = 1 + + for i in range(1, len(fasting_days)): + prev = timezone.datetime.strptime( + fasting_days[i - 1]["date"], "%Y-%m-%d" + ).date() + curr = timezone.datetime.strptime(fasting_days[i]["date"], "%Y-%m-%d").date() + gap = (curr - prev).days + + if gap == 1 and fasting_days[i]["type"] == streak_type: + streak_count += 1 + else: + streaks.append( + { + "count": streak_count, + "type": streak_type, + "start": streak_start, + "end": fasting_days[i - 1]["date"], + } + ) + streak_start = fasting_days[i]["date"] + streak_type = fasting_days[i]["type"] + streak_count = 1 + + streaks.append( + { + "count": streak_count, + "type": streak_type, + "start": streak_start, + "end": fasting_days[-1]["date"], + } + ) + + streaks.sort(key=lambda x: x["count"], reverse=True) + return streaks[:10] + + +def _compute_current_streak(fasting_days, today): + if not fasting_days: + return {"count": 0, "type": None} + + streak_type = fasting_days[-1]["type"] + count = 0 + check_date = today + + date_set = {d["date"]: d["type"] for d in fasting_days} + + while True: + date_str = check_date.strftime("%Y-%m-%d") + if date_str in date_set and date_set[date_str] == streak_type: + count += 1 + check_date -= timedelta(days=1) + else: + break + + if count == 0: + return {"count": 0, "type": None} + + return {"count": count, "type": streak_type} diff --git a/vrobbler/apps/trends/utils.py b/vrobbler/apps/trends/utils.py index 3fbf292..31f1e12 100644 --- a/vrobbler/apps/trends/utils.py +++ b/vrobbler/apps/trends/utils.py @@ -19,6 +19,7 @@ TIME_BOUND_TRENDS = { "activity-distribution", "concurrent-reading", "concurrent-listening", + "fasting", "mood-by-time", "mood-distribution", "mood-streaks", diff --git a/vrobbler/apps/trends/views.py b/vrobbler/apps/trends/views.py index 5a5298f..96a8a9c 100644 --- a/vrobbler/apps/trends/views.py +++ b/vrobbler/apps/trends/views.py @@ -73,6 +73,11 @@ TREND_METADATA = { "description": "Which days of the week see the most scrobble activity?", "icon": "📅", }, + "fasting": { + "title": "Fasting Trends", + "description": "Track your fasting patterns based on gaps between food scrobbles.", + "icon": "🍽️", + }, } diff --git a/vrobbler/templates/profiles/settings_form.html b/vrobbler/templates/profiles/settings_form.html index 66a74f7..5624643 100644 --- a/vrobbler/templates/profiles/settings_form.html +++ b/vrobbler/templates/profiles/settings_form.html @@ -135,6 +135,11 @@ {{ field }} {{ field.label_tag }}

+ {% elif field.name == "fasting_vacation_exempt" %} +

+ {{ field }} + {{ field.label_tag }} +

{% elif field.name == "home_scrobble_limit" %}

{{ field.label_tag }}