From 9393ae133cf457453258699e24175d2cbe9dd1d6 Mon Sep 17 00:00:00 2001
From: Colin Powell
Date: Fri, 17 Jul 2026 15:02:15 -0400
Subject: [PATCH] feat(trends): add fasting trend
---
PROJECT.org | 18 ++
vrobbler/apps/profiles/forms.py | 3 +
.../0045_userprofile_fasting_settings.py | 35 +++
vrobbler/apps/profiles/models.py | 13 ++
.../trends/templates/trends/_fasting.html | 133 ++++++++++++
.../trends/templates/trends/trend_detail.html | 3 +
vrobbler/apps/trends/trends/__init__.py | 2 +
vrobbler/apps/trends/trends/fasting.py | 204 ++++++++++++++++++
vrobbler/apps/trends/utils.py | 1 +
vrobbler/apps/trends/views.py | 5 +
.../templates/profiles/settings_form.html | 5 +
11 files changed, 422 insertions(+)
create mode 100644 vrobbler/apps/profiles/migrations/0045_userprofile_fasting_settings.py
create mode 100644 vrobbler/apps/trends/templates/trends/_fasting.html
create mode 100644 vrobbler/apps/trends/trends/fasting.py
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
+
+
+
+
+ | # |
+ Type |
+ Length |
+ Start |
+ End |
+
+
+
+ {% for streak in data.longest_streaks %}
+
+ | {{ forloop.counter }} |
+
+
+ {{ streak.type|title }}
+
+ |
+ {{ streak.count }} day{{ streak.count|pluralize }} |
+ {{ streak.start }} |
+ {{ streak.end }} |
+
+ {% endfor %}
+
+
+
+
+
+{% endif %}
+
+{% if data.fasting_days %}
+
+
+
Fasting Days
+
+
+
+
+ | Date |
+ Type |
+
+
+
+ {% for day in data.fasting_days %}
+
+ | {{ day.date }} |
+
+
+ {{ day.type|title }}
+
+ |
+
+ {% endfor %}
+
+
+
+
+
+{% 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 }}