sessions / 3bbfa6fc3753a52084addfcfc2506be83f7ca895

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

author
Colin Powell <colin@unbl.ink>
recorded
2026-07-17 15:35:37 EDT
agent
opencode
message
fix(trends): fix fasting detection for consecutive-day gaps and add liquid fasting

prompt

Fix the fasting trend algorithm so it detects fasting days when food scrobbles are on consecutive days (e.g. 18:46 day1 to 14:05 day2 = 19h gap). The old loop-based approach missed these because it only iterated days strictly between t1 and t2. Replaced with a per-day approach that finds surrounding food scrobbles for each calendar day. Also added liquid fasting detection: full fasts where a drink (beer, wine, coffee, etc.) was consumed are now classified as liquid fasts.

diff

show whitespace

commit 3bbfa6fc3753a52084addfcfc2506be83f7ca895Author: Colin Powell <colin@unbl.ink>Date:   Fri Jul 17 15:35:37 2026 -0400    fix(trends): fix fasting detection for consecutive-day gaps and add liquid fastingNotes (sessions):    {      "version": 1,      "commit_sha": "afa54a17a14c807a3ef5a8570cf2e69cfd9d8616",      "message": "fix(trends): fix fasting detection for consecutive-day gaps and add liquid fasting",      "prompt": "Fix the fasting trend algorithm so it detects fasting days when food scrobbles are on consecutive days (e.g. 18:46 day1 to 14:05 day2 = 19h gap). The old loop-based approach missed these because it only iterated days strictly between t1 and t2. Replaced with a per-day approach that finds surrounding food scrobbles for each calendar day. Also added liquid fasting detection: full fasts where a drink (beer, wine, coffee, etc.) was consumed are now classified as liquid fasts.",      "agent": "opencode",      "author": "Colin Powell \u003ccolin@unbl.ink\u003e",      "timestamp": "2026-07-17T15:35:37.164034075-04:00",      "diff_stat": ".../apps/trends/templates/trends/_fasting.html     |  13 ++-\n vrobbler/apps/trends/trends/fasting.py             | 113 +++++++++++++++------\n 2 files changed, 89 insertions(+), 37 deletions(-)"    }diff --git a/PROJECT.org b/PROJECT.orgindex 54e1be5..8dd67fc 100644--- a/PROJECT.org+++ b/PROJECT.org@@ -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,10 @@ The Edit log form should have from top to bottom:  *** Description +** DONE Fix bug in trends and add liquid fasting :food:fasting:trends:+:PROPERTIES:+:ID:       4736676e-95b0-cb4c-0eaa-c151f3753cd0+:END: * Version 61.1 [1/1] ** DONE [#B] Add trend for fasting :food:fasting:trends: :PROPERTIES:diff --git a/vrobbler/apps/trends/templates/trends/_fasting.html b/vrobbler/apps/trends/templates/trends/_fasting.htmlindex 6ab2116..48f2ee6 100644--- a/vrobbler/apps/trends/templates/trends/_fasting.html+++ b/vrobbler/apps/trends/templates/trends/_fasting.html@@ -44,7 +44,8 @@   <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)+      Full fasting: {{ data.full_threshold_hours }}h+ gaps ({{ data.full_fasting_count }} days) &middot;+      Liquid fasting: {{ data.liquid_fasting_count }} days     </p>   </div> </div>@@ -117,9 +118,13 @@           <tr>             <td>{{ day.date }}</td>             <td>-              <span class="{% if day.type == 'full' %}text-danger{% else %}text-warning{% endif %}">-                {{ day.type|title }}-              </span>+              {% if day.type == 'full' and day.had_drink %}+                <span class="text-info">Liquid Fast</span>+              {% elif day.type == 'full' %}+                <span class="text-danger">Full Fast</span>+              {% else %}+                <span class="text-warning">Periodic Fast</span>+              {% endif %}             </td>           </tr>           {% endfor %}diff --git a/vrobbler/apps/trends/trends/fasting.py b/vrobbler/apps/trends/trends/fasting.pyindex 0f34afb..43a936f 100644--- a/vrobbler/apps/trends/trends/fasting.py+++ b/vrobbler/apps/trends/trends/fasting.py@@ -1,4 +1,3 @@-from collections import defaultdict from datetime import timedelta  from django.db.models import Q@@ -6,6 +5,14 @@ from django.utils import timezone from scrobbles.models import Scrobble  +DRINK_MEDIA_TYPES = [+    Scrobble.MediaType.DRINK,+    Scrobble.MediaType.BEER,+    Scrobble.MediaType.WINE,+    Scrobble.MediaType.COFFEE,+]++ def _food_scrobbles(user, start, end):     filters = Q(user=user, media_type=Scrobble.MediaType.FOOD)     if start:@@ -15,8 +22,16 @@ 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 _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,@@ -41,6 +56,14 @@ def _is_vacation_day(date, vacation_ranges):     return False  +def _classify_gap(gap_hours, periodic_hours, full_hours):+    if gap_hours >= full_hours:+        return "full"+    if gap_hours >= periodic_hours:+        return "periodic"+    return "normal"++ def compute_fasting(user, period="last_30"):     from trends.utils import get_date_range @@ -63,6 +86,7 @@ def compute_fasting(user, period="last_30"):             "fasting_days_count": 0,             "periodic_fasting_count": 0,             "full_fasting_count": 0,+            "liquid_fasting_count": 0,             "fasting_rate_pct": 0.0,             "fasting_days": [],             "current_streak": {"count": 0, "type": None},@@ -71,62 +95,85 @@ def compute_fasting(user, period="last_30"):             "full_threshold_hours": full_hours,         } +    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 = {}-    fasting_days_list = []+    day = period_start -    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+    while day <= period_end:+        if day in food_dates:+            day += timedelta(days=1)+            continue -        if gap_hours < periodic_hours:+        if vacation_exempt and _is_vacation_day(day, vacation_ranges):+            day += timedelta(days=1)             continue -        if gap_hours >= full_hours:-            ftype = "full"+        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:-            ftype = "periodic"+            next_time = None+            for t in food_times:+                if t.date() > day:+                    next_time = t+                    break -        day = t1.date() + timedelta(days=1)-        end_day = t2.date()+        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) -        while day < end_day:-            if vacation_exempt and _is_vacation_day(day, vacation_ranges):+        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 (ftype == "full" and existing["type"] == "periodic"):-                fasting_by_date[day] = {"date": str(day), "type": ftype}+        if existing is None or (+            fasting_type == "full" and existing["type"] == "periodic"+        ):+            fasting_by_date[day] = entry+         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")+    liquid_count = sum(+        1 for d in fasting_days_list if d["type"] == "full" and d["had_drink"]+    )     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)+    current_streak = _compute_current_streak(fasting_days_list, now.date())      return {         "total_days": total_days,         "fasting_days_count": fasting_count,         "periodic_fasting_count": periodic_count,         "full_fasting_count": full_count,+        "liquid_fasting_count": liquid_count,         "fasting_rate_pct": rate,         "fasting_days": fasting_days_list,         "current_streak": current_streak,