feat(trends): add fasting trend
This commit is contained in:
18
PROJECT.org
18
PROJECT.org
@ -601,6 +601,24 @@ The Edit log form should have from top to bottom:
|
|||||||
*** Description
|
*** 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]
|
* Version 61.0 [3/3]
|
||||||
** DONE [#B] Try adding a nature app with Observations and Species
|
** DONE [#B] Try adding a nature app with Observations and Species
|
||||||
|
|||||||
@ -49,6 +49,9 @@ class UserProfileForm(forms.ModelForm):
|
|||||||
"default_water_size",
|
"default_water_size",
|
||||||
"water_reminder_enabled",
|
"water_reminder_enabled",
|
||||||
"trends_disabled",
|
"trends_disabled",
|
||||||
|
"fasting_vacation_exempt",
|
||||||
|
"fasting_periodic_hours",
|
||||||
|
"fasting_full_hours",
|
||||||
]
|
]
|
||||||
widgets = {
|
widgets = {
|
||||||
"lastfm_password": forms.PasswordInput(render_value=True),
|
"lastfm_password": forms.PasswordInput(render_value=True),
|
||||||
|
|||||||
@ -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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -154,6 +154,19 @@ class UserProfile(TimeStampedModel):
|
|||||||
help_text="List of trend slugs the user has disabled",
|
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_username = models.CharField(max_length=255, blank=True, null=True)
|
||||||
inaturalist_user_id = models.IntegerField(blank=True, null=True)
|
inaturalist_user_id = models.IntegerField(blank=True, null=True)
|
||||||
inaturalist_auto_import = models.BooleanField(default=False)
|
inaturalist_auto_import = models.BooleanField(default=False)
|
||||||
|
|||||||
133
vrobbler/apps/trends/templates/trends/_fasting.html
Normal file
133
vrobbler/apps/trends/templates/trends/_fasting.html
Normal file
@ -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 %}
|
||||||
@ -97,6 +97,9 @@
|
|||||||
{% elif trend.slug == "mood-weather" %}
|
{% elif trend.slug == "mood-weather" %}
|
||||||
{% include "trends/_mood_weather.html" %}
|
{% include "trends/_mood_weather.html" %}
|
||||||
|
|
||||||
|
{% elif trend.slug == "fasting" %}
|
||||||
|
{% include "trends/_fasting.html" %}
|
||||||
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@ -7,6 +7,7 @@ from trends.trends.concurrent import (
|
|||||||
compute_concurrent_listening,
|
compute_concurrent_listening,
|
||||||
compute_concurrent_reading,
|
compute_concurrent_reading,
|
||||||
)
|
)
|
||||||
|
from trends.trends.fasting import compute_fasting
|
||||||
from trends.trends.mood import (
|
from trends.trends.mood import (
|
||||||
compute_mood_by_time,
|
compute_mood_by_time,
|
||||||
compute_mood_distribution,
|
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_trending_up = register("trending-up")(compute_trending_up)
|
||||||
compute_weekly_rhythm = register("weekly-rhythm")(compute_weekly_rhythm)
|
compute_weekly_rhythm = register("weekly-rhythm")(compute_weekly_rhythm)
|
||||||
|
compute_fasting = register("fasting")(compute_fasting)
|
||||||
|
|||||||
204
vrobbler/apps/trends/trends/fasting.py
Normal file
204
vrobbler/apps/trends/trends/fasting.py
Normal file
@ -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}
|
||||||
@ -19,6 +19,7 @@ TIME_BOUND_TRENDS = {
|
|||||||
"activity-distribution",
|
"activity-distribution",
|
||||||
"concurrent-reading",
|
"concurrent-reading",
|
||||||
"concurrent-listening",
|
"concurrent-listening",
|
||||||
|
"fasting",
|
||||||
"mood-by-time",
|
"mood-by-time",
|
||||||
"mood-distribution",
|
"mood-distribution",
|
||||||
"mood-streaks",
|
"mood-streaks",
|
||||||
|
|||||||
@ -73,6 +73,11 @@ TREND_METADATA = {
|
|||||||
"description": "Which days of the week see the most scrobble activity?",
|
"description": "Which days of the week see the most scrobble activity?",
|
||||||
"icon": "📅",
|
"icon": "📅",
|
||||||
},
|
},
|
||||||
|
"fasting": {
|
||||||
|
"title": "Fasting Trends",
|
||||||
|
"description": "Track your fasting patterns based on gaps between food scrobbles.",
|
||||||
|
"icon": "🍽️",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -135,6 +135,11 @@
|
|||||||
{{ field }}
|
{{ field }}
|
||||||
{{ field.label_tag }}
|
{{ field.label_tag }}
|
||||||
</p>
|
</p>
|
||||||
|
{% elif field.name == "fasting_vacation_exempt" %}
|
||||||
|
<p class="checkbox-row">
|
||||||
|
{{ field }}
|
||||||
|
{{ field.label_tag }}
|
||||||
|
</p>
|
||||||
{% elif field.name == "home_scrobble_limit" %}
|
{% elif field.name == "home_scrobble_limit" %}
|
||||||
<p>
|
<p>
|
||||||
{{ field.label_tag }}
|
{{ field.label_tag }}
|
||||||
|
|||||||
Reference in New Issue
Block a user