diff
ignore whitespace
commit 9393ae133cf457453258699e24175d2cbe9dd1d6Author: Colin Powell <colin@unbl.ink>Date: Fri Jul 17 15:02:15 2026 -0400 feat(trends): add fasting trendNotes (sessions): { "version": 1, "commit_sha": "9393ae133cf457453258699e24175d2cbe9dd1d6", "message": "feat(trends): add fasting trend", "prompt": "Implement fasting trends as described in PROJECT.org task a290b64d. Detect fasting days by analyzing gaps between consecutive Food scrobbles. Add periodic (16h+) and full (24h+) fasting classification, vacation exemption via LifeEvent title matching, configurable thresholds, streaks, and a template with summary cards and tables.", "agent": "opencode", "author": "Colin Powell \u003ccolin@unbl.ink\u003e", "timestamp": "2026-07-17T15:02:15.353583878-04:00", "diff_stat": "PROJECT.org | 18 ++\n vrobbler/apps/profiles/forms.py | 3 +\n .../0045_userprofile_fasting_settings.py | 35 ++++\n vrobbler/apps/profiles/models.py | 13 ++\n .../apps/trends/templates/trends/_fasting.html | 133 ++++++++++++++\n .../apps/trends/templates/trends/trend_detail.html | 3 +\n vrobbler/apps/trends/trends/__init__.py | 2 +\n vrobbler/apps/trends/trends/fasting.py | 204 +++++++++++++++++++++\n vrobbler/apps/trends/utils.py | 1 +\n vrobbler/apps/trends/views.py | 5 +\n vrobbler/templates/profiles/settings_form.html | 5 +\n 11 files changed, 422 insertions(+)" }diff --git a/PROJECT.org b/PROJECT.orgindex 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 Speciesdiff --git a/vrobbler/apps/profiles/forms.py b/vrobbler/apps/profiles/forms.pyindex 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.pynew file mode 100644index 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.pyindex 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.htmlnew file mode 100644index 0000000..6ab2116--- /dev/null+++ b/vrobbler/apps/trends/templates/trends/_fasting.html@@ -0,0 +1,133 @@+<div class="row mb-3">+ <div class="col-md-3 mb-2">+ <div class="card text-center">+ <div class="card-body">+ <h6 class="card-title text-muted">Total Days</h6>+ <p class="card-text display-6">{{ data.total_days }}</p>+ </div>+ </div>+ </div>+ <div class="col-md-3 mb-2">+ <div class="card text-center">+ <div class="card-body">+ <h6 class="card-title text-muted">Fasting Days</h6>+ <p class="card-text display-6">{{ data.fasting_days_count }}</p>+ </div>+ </div>+ </div>+ <div class="col-md-3 mb-2">+ <div class="card text-center">+ <div class="card-body">+ <h6 class="card-title text-muted">Fasting Rate</h6>+ <p class="card-text display-6">{{ data.fasting_rate_pct }}%</p>+ </div>+ </div>+ </div>+ <div class="col-md-3 mb-2">+ <div class="card text-center">+ <div class="card-body">+ <h6 class="card-title text-muted">Current Streak</h6>+ <p class="card-text display-6">+ {% if data.current_streak.count > 0 %}+ {{ data.current_streak.count }}+ <small class="text-muted fs-6">{{ data.current_streak.type }}</small>+ {% else %}+ —+ {% endif %}+ </p>+ </div>+ </div>+ </div>+</div>++<div class="row mb-3">+ <div class="col-12">+ <p class="text-muted">+ 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)+ </p>+ </div>+</div>++{% if data.current_streak.count > 0 %}+<div class="row mb-3">+ <div class="col-12">+ <div class="alert alert-info">+ <strong>Current streak:</strong>+ {{ data.current_streak.count }} consecutive+ <span class="{% if data.current_streak.type == 'full' %}text-danger{% else %}text-warning{% endif %}">+ {{ data.current_streak.type }}+ </span>+ fasting day{{ data.current_streak.count|pluralize }}.+ </div>+ </div>+</div>+{% endif %}++{% if data.longest_streaks %}+<div class="row mb-3">+ <div class="col-12">+ <h5>Longest Streaks</h5>+ <div class="table-responsive">+ <table class="table table-striped table-sm">+ <thead>+ <tr>+ <th>#</th>+ <th>Type</th>+ <th class="text-end">Length</th>+ <th>Start</th>+ <th>End</th>+ </tr>+ </thead>+ <tbody>+ {% for streak in data.longest_streaks %}+ <tr>+ <td>{{ forloop.counter }}</td>+ <td>+ <span class="{% if streak.type == 'full' %}text-danger{% else %}text-warning{% endif %}">+ {{ streak.type|title }}+ </span>+ </td>+ <td class="text-end">{{ streak.count }} day{{ streak.count|pluralize }}</td>+ <td>{{ streak.start }}</td>+ <td>{{ streak.end }}</td>+ </tr>+ {% endfor %}+ </tbody>+ </table>+ </div>+ </div>+</div>+{% endif %}++{% if data.fasting_days %}+<div class="row">+ <div class="col-12">+ <h5>Fasting Days</h5>+ <div class="table-responsive" style="max-height: 400px; overflow-y: auto;">+ <table class="table table-striped table-sm">+ <thead>+ <tr>+ <th>Date</th>+ <th>Type</th>+ </tr>+ </thead>+ <tbody>+ {% for day in data.fasting_days %}+ <tr>+ <td>{{ day.date }}</td>+ <td>+ <span class="{% if day.type == 'full' %}text-danger{% else %}text-warning{% endif %}">+ {{ day.type|title }}+ </span>+ </td>+ </tr>+ {% endfor %}+ </tbody>+ </table>+ </div>+ </div>+</div>+{% else %}+<p class="text-muted">No fasting days detected for this period.</p>+{% endif %}diff --git a/vrobbler/apps/trends/templates/trends/trend_detail.html b/vrobbler/apps/trends/templates/trends/trend_detail.htmlindex 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__.pyindex 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.pynew file mode 100644index 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.pyindex 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.pyindex 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.htmlindex 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 }} </p>+ {% elif field.name == "fasting_vacation_exempt" %}+ <p class="checkbox-row">+ {{ field }}+ {{ field.label_tag }}+ </p> {% elif field.name == "home_scrobble_limit" %} <p> {{ field.label_tag }}