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) ·- 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) ·+ 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,