345 lines
13 KiB
Python
345 lines
13 KiB
Python
import os
|
|
import platform
|
|
from datetime import timedelta
|
|
|
|
from django import get_version as django_version
|
|
from django.contrib import messages
|
|
from django.contrib.auth import login
|
|
from django.db.models import Avg, Count, Q, Sum
|
|
from django.db.models.functions import Coalesce
|
|
from django.shortcuts import get_object_or_404, redirect, render
|
|
from django.urls import reverse
|
|
from django.utils import timezone
|
|
|
|
from .forms import MomentumEntryForm, SignUpForm
|
|
from .models import Category, MomentumEntry
|
|
|
|
|
|
APP_NAME = "Momentum Atlas"
|
|
APP_TAGLINE = "A polished personal dashboard for tracking focus, energy, and small wins."
|
|
|
|
|
|
def _entries_for_request(request):
|
|
entries = MomentumEntry.objects.select_related("category")
|
|
if request.user.is_authenticated:
|
|
return entries.filter(user=request.user)
|
|
return entries.filter(user__isnull=True)
|
|
|
|
|
|
def _categories_for_request(request):
|
|
if request.user.is_authenticated:
|
|
entry_filter = Q(entries__user=request.user)
|
|
else:
|
|
entry_filter = Q(entries__user__isnull=True)
|
|
return Category.objects.annotate(entry_total=Count("entries", filter=entry_filter))
|
|
|
|
|
|
def _build_weekly_trend(entries):
|
|
today = timezone.localdate()
|
|
start_date = today - timedelta(days=6)
|
|
trend_source = (
|
|
entries.filter(entry_date__gte=start_date, entry_date__lte=today)
|
|
.values("entry_date")
|
|
.annotate(
|
|
avg_focus=Coalesce(Avg("focus_score"), 0.0),
|
|
avg_energy=Coalesce(Avg("energy_score"), 0.0),
|
|
total_minutes=Coalesce(Sum("deep_work_minutes"), 0),
|
|
)
|
|
)
|
|
by_date = {row["entry_date"]: row for row in trend_source}
|
|
|
|
trend = []
|
|
for offset in range(7):
|
|
day = start_date + timedelta(days=offset)
|
|
row = by_date.get(day, {})
|
|
focus = float(row.get("avg_focus") or 0)
|
|
energy = float(row.get("avg_energy") or 0)
|
|
minutes = int(row.get("total_minutes") or 0)
|
|
focus_level = int(round((focus / 10) * 10) * 10) if focus else 0
|
|
energy_level = int(round((energy / 10) * 10) * 10) if energy else 0
|
|
trend.append(
|
|
{
|
|
"date": day,
|
|
"label": day.strftime("%a"),
|
|
"focus": round(focus, 1),
|
|
"energy": round(energy, 1),
|
|
"minutes": minutes,
|
|
"focus_level": max(0, min(100, focus_level)),
|
|
"energy_level": max(0, min(100, energy_level)),
|
|
}
|
|
)
|
|
return trend
|
|
|
|
|
|
def _build_weekly_summary(weekly_trend):
|
|
check_in_days = sum(1 for day in weekly_trend if day["focus"] or day["energy"] or day["minutes"])
|
|
total_minutes = sum(day["minutes"] for day in weekly_trend)
|
|
strongest_day = max(
|
|
weekly_trend,
|
|
key=lambda day: (day["focus"] + day["energy"], day["minutes"]),
|
|
default=None,
|
|
)
|
|
strongest_has_data = bool(
|
|
strongest_day and (strongest_day["focus"] or strongest_day["energy"] or strongest_day["minutes"])
|
|
)
|
|
strongest_score = round(((strongest_day["focus"] + strongest_day["energy"]) / 2), 1) if strongest_has_data else 0
|
|
return {
|
|
"check_in_days": check_in_days,
|
|
"total_minutes": total_minutes,
|
|
"strongest_label": strongest_day["label"] if strongest_has_data else "No data yet",
|
|
"strongest_score": strongest_score,
|
|
}
|
|
|
|
|
|
def _build_history_overview(entries):
|
|
totals = entries.aggregate(
|
|
total_entries=Count("id"),
|
|
avg_focus=Coalesce(Avg("focus_score"), 0.0),
|
|
avg_energy=Coalesce(Avg("energy_score"), 0.0),
|
|
total_minutes=Coalesce(Sum("deep_work_minutes"), 0),
|
|
)
|
|
avg_focus = float(totals["avg_focus"] or 0)
|
|
avg_energy = float(totals["avg_energy"] or 0)
|
|
avg_momentum = round((avg_focus + avg_energy) / 2, 1) if totals["total_entries"] else 0
|
|
|
|
ordered_dates = []
|
|
seen_dates = set()
|
|
for entry_date in entries.values_list("entry_date", flat=True):
|
|
if entry_date not in seen_dates:
|
|
ordered_dates.append(entry_date)
|
|
seen_dates.add(entry_date)
|
|
|
|
streak = 0
|
|
previous_date = None
|
|
for entry_date in ordered_dates:
|
|
if previous_date is None:
|
|
streak = 1
|
|
previous_date = entry_date
|
|
continue
|
|
if previous_date - timedelta(days=1) == entry_date:
|
|
streak += 1
|
|
previous_date = entry_date
|
|
continue
|
|
break
|
|
|
|
top_category = (
|
|
entries.values("category__name")
|
|
.annotate(total=Count("id"))
|
|
.order_by("-total", "category__name")
|
|
.first()
|
|
)
|
|
|
|
latest_entry = entries.first()
|
|
return {
|
|
"total_entries": totals["total_entries"],
|
|
"avg_focus": round(avg_focus, 1),
|
|
"avg_energy": round(avg_energy, 1),
|
|
"avg_momentum": avg_momentum,
|
|
"total_minutes": int(totals["total_minutes"] or 0),
|
|
"streak": streak if totals["total_entries"] else 0,
|
|
"latest_entry": latest_entry,
|
|
"top_category": top_category["category__name"] if top_category else "No category yet",
|
|
}
|
|
|
|
|
|
def _build_recent_activity(entries, limit=7):
|
|
recent_entries = list(entries[:limit])
|
|
if not recent_entries:
|
|
return []
|
|
|
|
recent_entries.reverse()
|
|
max_minutes = max((entry.deep_work_minutes for entry in recent_entries), default=0)
|
|
activity = []
|
|
for entry in recent_entries:
|
|
momentum = float(entry.momentum_score)
|
|
minutes_width = 0
|
|
if entry.deep_work_minutes and max_minutes:
|
|
minutes_width = max(14, int(round((entry.deep_work_minutes / max_minutes) * 100)))
|
|
activity.append(
|
|
{
|
|
"entry": entry,
|
|
"focus_width": entry.focus_score * 10,
|
|
"energy_width": entry.energy_score * 10,
|
|
"momentum_width": int(round(momentum * 10)),
|
|
"minutes_width": minutes_width,
|
|
}
|
|
)
|
|
return activity
|
|
|
|
|
|
def _build_category_breakdown(entries):
|
|
grouped = list(
|
|
entries.values("category__name", "category__slug", "category__accent_color")
|
|
.annotate(
|
|
entry_total=Count("id"),
|
|
avg_focus=Coalesce(Avg("focus_score"), 0.0),
|
|
avg_energy=Coalesce(Avg("energy_score"), 0.0),
|
|
total_minutes=Coalesce(Sum("deep_work_minutes"), 0),
|
|
)
|
|
.order_by("-entry_total", "category__name")[:4]
|
|
)
|
|
total_entries = sum(item["entry_total"] for item in grouped) or 1
|
|
|
|
breakdown = []
|
|
for item in grouped:
|
|
avg_momentum = round((float(item["avg_focus"] or 0) + float(item["avg_energy"] or 0)) / 2, 1)
|
|
breakdown.append(
|
|
{
|
|
"name": item["category__name"],
|
|
"slug": item["category__slug"],
|
|
"accent_color": item["category__accent_color"] or "#0F766E",
|
|
"entry_total": item["entry_total"],
|
|
"total_minutes": int(item["total_minutes"] or 0),
|
|
"avg_momentum": avg_momentum,
|
|
"share_percent": max(8, int(round((item["entry_total"] / total_entries) * 100))),
|
|
"momentum_width": max(8, int(round(avg_momentum * 10))) if avg_momentum else 0,
|
|
}
|
|
)
|
|
return breakdown
|
|
|
|
|
|
def _dashboard_context(request):
|
|
entries = _entries_for_request(request)
|
|
recent_entries = entries[:6]
|
|
last_30_days = timezone.localdate() - timedelta(days=29)
|
|
stats_window = entries.filter(entry_date__gte=last_30_days)
|
|
totals = stats_window.aggregate(
|
|
total_entries=Count("id"),
|
|
avg_focus=Coalesce(Avg("focus_score"), 0.0),
|
|
avg_energy=Coalesce(Avg("energy_score"), 0.0),
|
|
total_minutes=Coalesce(Sum("deep_work_minutes"), 0),
|
|
)
|
|
active_days = stats_window.values("entry_date").distinct().count()
|
|
top_category = (
|
|
stats_window.values("category__name")
|
|
.annotate(total=Count("id"))
|
|
.order_by("-total", "category__name")
|
|
.first()
|
|
)
|
|
weekly_trend = _build_weekly_trend(entries)
|
|
|
|
focus_average = round(float(totals["avg_focus"] or 0), 1)
|
|
energy_average = round(float(totals["avg_energy"] or 0), 1)
|
|
if focus_average >= 8:
|
|
spotlight = "Your recent focus trend is excellent—keep protecting that deep-work time."
|
|
elif focus_average >= 6:
|
|
spotlight = "Momentum is building. A little more consistency could turn this into a real streak."
|
|
elif totals["total_entries"]:
|
|
spotlight = "A reset week might help—shrink the task list and aim for one clear win each day."
|
|
else:
|
|
spotlight = "Your dashboard will start filling in as soon as you save the first check-in."
|
|
|
|
return {
|
|
"recent_entries": recent_entries,
|
|
"categories": _categories_for_request(request),
|
|
"weekly_trend": weekly_trend,
|
|
"weekly_summary": _build_weekly_summary(weekly_trend),
|
|
"is_demo_mode": not request.user.is_authenticated,
|
|
"stats": {
|
|
"total_entries": totals["total_entries"],
|
|
"avg_focus": focus_average,
|
|
"avg_energy": energy_average,
|
|
"total_minutes": totals["total_minutes"],
|
|
"active_days": active_days,
|
|
"top_category": top_category["category__name"] if top_category else "No category yet",
|
|
"spotlight": spotlight,
|
|
},
|
|
}
|
|
|
|
|
|
def home(request):
|
|
host_name = request.get_host().lower()
|
|
agent_brand = "AppWizzy" if host_name == "appwizzy.com" else "Flatlogic"
|
|
now = timezone.now()
|
|
|
|
if request.method == "POST":
|
|
if not request.user.is_authenticated:
|
|
messages.info(request, "Create a free account or log in to save personal check-ins.")
|
|
return redirect(f"{reverse('login')}?next={reverse('home')}")
|
|
|
|
form = MomentumEntryForm(request.POST)
|
|
if form.is_valid():
|
|
entry = form.save(commit=False)
|
|
entry.user = request.user
|
|
entry.save()
|
|
messages.success(request, "Momentum captured. Your new private check-in is ready.")
|
|
return redirect(f"{entry.get_absolute_url()}?created=1")
|
|
messages.error(request, "Please fix the form errors and try again.")
|
|
else:
|
|
form = MomentumEntryForm()
|
|
|
|
context = {
|
|
"project_name": APP_NAME,
|
|
"agent_brand": agent_brand,
|
|
"django_version": django_version(),
|
|
"python_version": platform.python_version(),
|
|
"current_time": now,
|
|
"host_name": host_name,
|
|
"project_description": os.getenv("PROJECT_DESCRIPTION", APP_TAGLINE),
|
|
"project_image_url": os.getenv("PROJECT_IMAGE_URL", ""),
|
|
"page_title": f"{APP_NAME} | Daily focus dashboard",
|
|
"meta_description": "Track daily focus, energy, and deep-work minutes in a polished Python dashboard.",
|
|
"form": form,
|
|
**_dashboard_context(request),
|
|
}
|
|
return render(request, "core/index.html", context)
|
|
|
|
|
|
def entry_list(request):
|
|
selected_slug = request.GET.get("category", "")
|
|
entries = _entries_for_request(request)
|
|
if selected_slug:
|
|
entries = entries.filter(category__slug=selected_slug)
|
|
|
|
context = {
|
|
"page_title": f"All check-ins | {APP_NAME}",
|
|
"meta_description": "Browse recent check-ins and filter your momentum history by category.",
|
|
"entries": entries,
|
|
"categories": _categories_for_request(request),
|
|
"selected_slug": selected_slug,
|
|
"is_demo_mode": not request.user.is_authenticated,
|
|
"history_overview": _build_history_overview(entries),
|
|
"recent_activity": _build_recent_activity(entries),
|
|
"category_breakdown": _build_category_breakdown(entries),
|
|
}
|
|
return render(request, "core/entry_list.html", context)
|
|
|
|
|
|
def entry_detail(request, pk):
|
|
scoped_entries = _entries_for_request(request)
|
|
entry = get_object_or_404(scoped_entries, pk=pk)
|
|
related_entries = scoped_entries.filter(category=entry.category).exclude(pk=entry.pk)[:3]
|
|
context = {
|
|
"page_title": f"{entry.title} | {APP_NAME}",
|
|
"meta_description": entry.takeaway,
|
|
"entry": entry,
|
|
"related_entries": related_entries,
|
|
"created": request.GET.get("created") == "1",
|
|
"is_demo_mode": not request.user.is_authenticated,
|
|
}
|
|
return render(request, "core/entry_detail.html", context)
|
|
|
|
|
|
def signup(request):
|
|
if request.user.is_authenticated:
|
|
return redirect("home")
|
|
|
|
if request.method == "POST":
|
|
form = SignUpForm(request.POST)
|
|
if form.is_valid():
|
|
user = form.save()
|
|
login(request, user)
|
|
messages.success(request, "Your account is ready. You can now save private momentum entries.")
|
|
return redirect(request.POST.get("next") or "home")
|
|
messages.error(request, "Please fix the sign-up form and try again.")
|
|
else:
|
|
form = SignUpForm()
|
|
|
|
context = {
|
|
"page_title": f"Create account | {APP_NAME}",
|
|
"meta_description": "Create a private Momentum Atlas account to save personal check-ins.",
|
|
"form": form,
|
|
"next_url": request.GET.get("next") or request.POST.get("next") or reverse("home"),
|
|
}
|
|
return render(request, "core/signup.html", context)
|