sessions / 9393ae133cf457453258699e24175d2cbe9dd1d6

/home/powellc/src/code.unbl.ink/secstate/vrobbler

author
Colin Powell <colin@unbl.ink>
recorded
2026-07-17 15:02:15 EDT
agent
opencode
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.

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 %}+            &mdash;+          {% 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) &middot;+      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 }}