fix(trends): fix fasting detection for consecutive-day gaps and add liquid fasting
This commit is contained in:
@ -88,7 +88,7 @@ fetching and simple saving.
|
|||||||
*** Metadata sources
|
*** Metadata sources
|
||||||
**** Scraper
|
**** 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] 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:
|
** TODO [#C] Create small utility to clean up tracks scrobbled with wonky playback times :bug:music:scrobbles:
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
@ -600,6 +600,10 @@ The Edit log form should have from top to bottom:
|
|||||||
|
|
||||||
*** Description
|
*** 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]
|
* Version 61.1 [1/1]
|
||||||
** DONE [#B] Add trend for fasting :food:fasting:trends:
|
** DONE [#B] Add trend for fasting :food:fasting:trends:
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
|
|||||||
@ -44,7 +44,8 @@
|
|||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<p class="text-muted">
|
<p class="text-muted">
|
||||||
Periodic fasting: {{ data.periodic_threshold_hours }}h+ gaps ({{ data.periodic_fasting_count }} days) ·
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -117,9 +118,13 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td>{{ day.date }}</td>
|
<td>{{ day.date }}</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="{% if day.type == 'full' %}text-danger{% else %}text-warning{% endif %}">
|
{% if day.type == 'full' and day.had_drink %}
|
||||||
{{ day.type|title }}
|
<span class="text-info">Liquid Fast</span>
|
||||||
</span>
|
{% elif day.type == 'full' %}
|
||||||
|
<span class="text-danger">Full Fast</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-warning">Periodic Fast</span>
|
||||||
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
from collections import defaultdict
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
@ -6,6 +5,14 @@ from django.utils import timezone
|
|||||||
from scrobbles.models import Scrobble
|
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):
|
def _food_scrobbles(user, start, end):
|
||||||
filters = Q(user=user, media_type=Scrobble.MediaType.FOOD)
|
filters = Q(user=user, media_type=Scrobble.MediaType.FOOD)
|
||||||
if start:
|
if start:
|
||||||
@ -15,8 +22,16 @@ def _food_scrobbles(user, start, end):
|
|||||||
return Scrobble.objects.filter(filters).order_by("timestamp")
|
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):
|
def _vacation_ranges(user, start, end):
|
||||||
"""Return list of (start, end) tuples for Vacation life events."""
|
|
||||||
filters = Q(
|
filters = Q(
|
||||||
user=user,
|
user=user,
|
||||||
media_type=Scrobble.MediaType.LIFE_EVENT,
|
media_type=Scrobble.MediaType.LIFE_EVENT,
|
||||||
@ -41,6 +56,14 @@ def _is_vacation_day(date, vacation_ranges):
|
|||||||
return False
|
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"):
|
def compute_fasting(user, period="last_30"):
|
||||||
from trends.utils import get_date_range
|
from trends.utils import get_date_range
|
||||||
|
|
||||||
@ -63,6 +86,7 @@ def compute_fasting(user, period="last_30"):
|
|||||||
"fasting_days_count": 0,
|
"fasting_days_count": 0,
|
||||||
"periodic_fasting_count": 0,
|
"periodic_fasting_count": 0,
|
||||||
"full_fasting_count": 0,
|
"full_fasting_count": 0,
|
||||||
|
"liquid_fasting_count": 0,
|
||||||
"fasting_rate_pct": 0.0,
|
"fasting_rate_pct": 0.0,
|
||||||
"fasting_days": [],
|
"fasting_days": [],
|
||||||
"current_streak": {"count": 0, "type": None},
|
"current_streak": {"count": 0, "type": None},
|
||||||
@ -71,62 +95,85 @@ def compute_fasting(user, period="last_30"):
|
|||||||
"full_threshold_hours": full_hours,
|
"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_by_date = {}
|
||||||
fasting_days_list = []
|
day = period_start
|
||||||
|
|
||||||
for i in range(len(food) - 1):
|
while day <= period_end:
|
||||||
t1 = food[i].timestamp
|
if day in food_dates:
|
||||||
t2 = food[i + 1].timestamp
|
day += timedelta(days=1)
|
||||||
gap = t2 - t1
|
|
||||||
gap_hours = gap.total_seconds() / 3600
|
|
||||||
|
|
||||||
if gap_hours < periodic_hours:
|
|
||||||
continue
|
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):
|
if vacation_exempt and _is_vacation_day(day, vacation_ranges):
|
||||||
day += timedelta(days=1)
|
day += timedelta(days=1)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
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:
|
||||||
|
next_time = None
|
||||||
|
for t in food_times:
|
||||||
|
if t.date() > day:
|
||||||
|
next_time = t
|
||||||
|
break
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
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)
|
existing = fasting_by_date.get(day)
|
||||||
if existing is None or (ftype == "full" and existing["type"] == "periodic"):
|
if existing is None or (
|
||||||
fasting_by_date[day] = {"date": str(day), "type": ftype}
|
fasting_type == "full" and existing["type"] == "periodic"
|
||||||
|
):
|
||||||
|
fasting_by_date[day] = entry
|
||||||
|
|
||||||
day += timedelta(days=1)
|
day += timedelta(days=1)
|
||||||
|
|
||||||
fasting_days_list = sorted(fasting_by_date.values(), key=lambda x: x["date"])
|
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
|
total_days = (period_end - period_start).days + 1
|
||||||
fasting_count = len(fasting_days_list)
|
fasting_count = len(fasting_days_list)
|
||||||
periodic_count = sum(1 for d in fasting_days_list if d["type"] == "periodic")
|
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")
|
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
|
rate = round((fasting_count / total_days) * 100, 1) if total_days > 0 else 0.0
|
||||||
|
|
||||||
longest_streaks = _compute_streaks(fasting_days_list)
|
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 {
|
return {
|
||||||
"total_days": total_days,
|
"total_days": total_days,
|
||||||
"fasting_days_count": fasting_count,
|
"fasting_days_count": fasting_count,
|
||||||
"periodic_fasting_count": periodic_count,
|
"periodic_fasting_count": periodic_count,
|
||||||
"full_fasting_count": full_count,
|
"full_fasting_count": full_count,
|
||||||
|
"liquid_fasting_count": liquid_count,
|
||||||
"fasting_rate_pct": rate,
|
"fasting_rate_pct": rate,
|
||||||
"fasting_days": fasting_days_list,
|
"fasting_days": fasting_days_list,
|
||||||
"current_streak": current_streak,
|
"current_streak": current_streak,
|
||||||
|
|||||||
Reference in New Issue
Block a user