Auto commit: 2026-04-16T11:25:44.037Z

This commit is contained in:
Flatlogic Bot 2026-04-16 11:25:44 +00:00
parent e0e36aaad0
commit 3a44f34cf9
24 changed files with 518 additions and 51 deletions

View File

@ -18,8 +18,10 @@ from django.contrib import admin
from django.urls import include, path from django.urls import include, path
from django.conf import settings from django.conf import settings
from django.conf.urls.static import static from django.conf.urls.static import static
from django.views.generic.base import RedirectView
urlpatterns = [ urlpatterns = [
path("favicon.ico", RedirectView.as_view(url=settings.STATIC_URL + "images/favicon.svg", permanent=False)),
path("admin/", admin.site.urls), path("admin/", admin.site.urls),
path("", include("core.urls")), path("", include("core.urls")),
] ]

View File

@ -14,12 +14,13 @@ class CategoryAdmin(admin.ModelAdmin):
class MomentumEntryAdmin(admin.ModelAdmin): class MomentumEntryAdmin(admin.ModelAdmin):
list_display = ( list_display = (
"title", "title",
"user",
"entry_date", "entry_date",
"category", "category",
"focus_score", "focus_score",
"energy_score", "energy_score",
"deep_work_minutes", "deep_work_minutes",
) )
list_filter = ("category", "entry_date") list_filter = ("user", "category", "entry_date")
search_fields = ("title", "takeaway", "reflection") search_fields = ("title", "takeaway", "reflection", "user__username")
date_hierarchy = "entry_date" date_hierarchy = "entry_date"

View File

@ -1,4 +1,6 @@
from django import forms from django import forms
from django.contrib.auth.forms import AuthenticationForm, UserCreationForm
from django.contrib.auth.models import User
from django.utils import timezone from django.utils import timezone
from .models import MomentumEntry from .models import MomentumEntry
@ -64,3 +66,39 @@ class MomentumEntryForm(forms.ModelForm):
if len(takeaway.split()) < 3: if len(takeaway.split()) < 3:
raise forms.ValidationError("Write a short sentence with at least three words.") raise forms.ValidationError("Write a short sentence with at least three words.")
return takeaway return takeaway
class LoginForm(AuthenticationForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["username"].help_text = "Use the username you chose when creating your account."
self.fields["password"].help_text = "Enter the same password you used during sign up."
for field in self.fields.values():
field.widget.attrs["class"] = f"{field.widget.attrs.get('class', '')} form-control".strip()
field.widget.attrs.setdefault("autocomplete", "off")
class SignUpForm(UserCreationForm):
email = forms.EmailField(required=False)
class Meta:
model = User
fields = ("username", "email", "password1", "password2")
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["username"].help_text = "Pick a simple username for your private dashboard."
self.fields["email"].help_text = "Optional, but useful if you later add notifications."
self.fields["password1"].help_text = "Use at least 8 characters so your account is harder to guess."
self.fields["password2"].help_text = "Type the same password again to confirm it."
for name, field in self.fields.items():
field.widget.attrs["class"] = f"{field.widget.attrs.get('class', '')} form-control".strip()
if name == "username":
field.widget.attrs.setdefault("autofocus", True)
field.widget.attrs.setdefault("autocomplete", "off")
def save(self, commit=True):
user = super().save(commit=False)
user.email = self.cleaned_data.get("email", "")
if commit:
user.save()
return user

View File

@ -0,0 +1,21 @@
# Generated by Django 5.2.7 on 2026-04-16 11:22
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0002_seed_demo_data'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='momentumentry',
name='user',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='momentum_entries', to=settings.AUTH_USER_MODEL),
),
]

View File

@ -1,3 +1,4 @@
from django.conf import settings
from django.core.validators import MaxValueValidator, MinValueValidator from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
@ -17,6 +18,13 @@ class Category(models.Model):
class MomentumEntry(models.Model): class MomentumEntry(models.Model):
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="momentum_entries",
null=True,
blank=True,
)
category = models.ForeignKey(Category, on_delete=models.PROTECT, related_name="entries") category = models.ForeignKey(Category, on_delete=models.PROTECT, related_name="entries")
title = models.CharField(max_length=120) title = models.CharField(max_length=120)
entry_date = models.DateField() entry_date = models.DateField()

View File

@ -17,6 +17,7 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Manrope:wght@600;700;800&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Manrope:wght@600;700;800&display=swap" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<link rel="icon" type="image/svg+xml" href="{% static 'images/favicon.svg' %}?v={{ deployment_timestamp }}">
<link rel="stylesheet" href="{% static 'css/custom.css' %}?v={{ deployment_timestamp }}"> <link rel="stylesheet" href="{% static 'css/custom.css' %}?v={{ deployment_timestamp }}">
{% block head %}{% endblock %} {% block head %}{% endblock %}
</head> </head>

View File

@ -6,6 +6,14 @@
{% block content %} {% block content %}
<div class="subpage-shell"> <div class="subpage-shell">
<div class="container py-5"> <div class="container py-5">
{% if messages %}
<div class="message-stack mb-4">
{% for message in messages %}
<div class="alert alert-{{ message.tags|default:'info' }} custom-alert mb-0" role="alert">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
{% if created %} {% if created %}
<div class="alert custom-alert alert-success mb-4" role="alert"> <div class="alert custom-alert alert-success mb-4" role="alert">
Nice—your check-in was saved successfully. This detail page is the confirmation step of the MVP workflow. Nice—your check-in was saved successfully. This detail page is the confirmation step of the MVP workflow.
@ -52,8 +60,12 @@
<h2>Keep the loop going</h2> <h2>Keep the loop going</h2>
</div> </div>
<div class="d-grid gap-3 mb-4"> <div class="d-grid gap-3 mb-4">
{% if request.user.is_authenticated %}
<a class="btn btn-accent" href="{% url 'home' %}#check-in">Log another day</a> <a class="btn btn-accent" href="{% url 'home' %}#check-in">Log another day</a>
<a class="btn btn-ghost" href="{% url 'entry_list' %}">View all entries</a> {% else %}
<a class="btn btn-accent" href="{% url 'signup' %}">Create your account</a>
{% endif %}
<a class="btn btn-ghost" href="{% url 'entry_list' %}">{% if is_demo_mode %}View demo entries{% else %}View all entries{% endif %}</a>
<a class="btn btn-ghost" href="/admin/">Open admin</a> <a class="btn btn-ghost" href="/admin/">Open admin</a>
</div> </div>
<h3 class="h5">More in {{ entry.category.name }}</h3> <h3 class="h5">More in {{ entry.category.name }}</h3>

View File

@ -6,15 +6,32 @@
{% block content %} {% block content %}
<div class="subpage-shell"> <div class="subpage-shell">
<div class="container py-5"> <div class="container py-5">
{% if messages %}
<div class="message-stack mb-4">
{% for message in messages %}
<div class="alert alert-{{ message.tags|default:'info' }} custom-alert mb-0" role="alert">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
<div class="subpage-header glass-panel mb-4"> <div class="subpage-header glass-panel mb-4">
<div> <div>
<span class="eyebrow">History</span> <span class="eyebrow">History</span>
<h1>All momentum entries</h1> <h1>{% if is_demo_mode %}Demo momentum entries{% else %}Your momentum entries{% endif %}</h1>
<p>Filter by category and open any check-in for its confirmation/detail view.</p> <p>{% if is_demo_mode %}Browse the seeded sample history, then create an account when you want private tracking.{% else %}Filter your private check-ins by category and open any one for its detail view.{% endif %}</p>
</div> </div>
<div class="d-flex flex-wrap gap-2"> <div class="d-flex flex-wrap gap-2 align-items-center">
<a class="btn btn-ghost" href="{% url 'home' %}">Back to dashboard</a> <a class="btn btn-ghost" href="{% url 'home' %}">Back to dashboard</a>
{% if request.user.is_authenticated %}
<a class="btn btn-accent" href="{% url 'home' %}#check-in">New check-in</a> <a class="btn btn-accent" href="{% url 'home' %}#check-in">New check-in</a>
<form method="post" action="{% url 'logout' %}" class="inline-form">
{% csrf_token %}
<button class="btn btn-ghost" type="submit">Log out</button>
</form>
{% else %}
<a class="btn btn-ghost" href="{% url 'login' %}">Log in</a>
<a class="btn btn-accent" href="{% url 'signup' %}">Create account</a>
{% endif %}
</div> </div>
</div> </div>
@ -47,9 +64,13 @@
</div> </div>
{% else %} {% else %}
<div class="glass-panel empty-state"> <div class="glass-panel empty-state">
<h2>No entries for this filter</h2> <h2>{% if is_demo_mode %}No demo entries for this filter{% else %}No private entries for this filter{% endif %}</h2>
<p>Try a different category or create a new check-in from the dashboard.</p> <p>{% if is_demo_mode %}Try a different category or create an account to start tracking your own days.{% else %}Try a different category or create a new check-in from the dashboard.{% endif %}</p>
{% if request.user.is_authenticated %}
<a class="btn btn-accent mt-3" href="{% url 'home' %}#check-in">Create an entry</a> <a class="btn btn-accent mt-3" href="{% url 'home' %}#check-in">Create an entry</a>
{% else %}
<a class="btn btn-accent mt-3" href="{% url 'signup' %}">Create your account</a>
{% endif %}
</div> </div>
{% endif %} {% endif %}
</div> </div>

View File

@ -14,9 +14,21 @@
</button> </button>
<div class="collapse navbar-collapse" id="mainNav"> <div class="collapse navbar-collapse" id="mainNav">
<ul class="navbar-nav ms-auto align-items-lg-center gap-lg-2"> <ul class="navbar-nav ms-auto align-items-lg-center gap-lg-2">
<li class="nav-item"><a class="nav-link" href="#check-in">New check-in</a></li> <li class="nav-item"><a class="nav-link" href="#check-in">{% if request.user.is_authenticated %}New check-in{% else %}Get started{% endif %}</a></li>
<li class="nav-item"><a class="nav-link" href="#weekly-trend">Weekly trend</a></li> <li class="nav-item"><a class="nav-link" href="#weekly-trend">Weekly trend</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'entry_list' %}">All entries</a></li> <li class="nav-item"><a class="nav-link" href="{% url 'entry_list' %}">{% if request.user.is_authenticated %}My entries{% else %}Demo entries{% endif %}</a></li>
{% if request.user.is_authenticated %}
<li class="nav-item"><span class="nav-user">{{ request.user.username }}</span></li>
<li class="nav-item">
<form method="post" action="{% url 'logout' %}" class="nav-form">
{% csrf_token %}
<button type="submit" class="nav-link nav-pill nav-button">Log out</button>
</form>
</li>
{% else %}
<li class="nav-item"><a class="nav-link" href="{% url 'login' %}">Log in</a></li>
<li class="nav-item"><a class="nav-link nav-pill" href="{% url 'signup' %}">Sign up</a></li>
{% endif %}
<li class="nav-item"><a class="nav-link nav-pill" href="/admin/">Admin</a></li> <li class="nav-item"><a class="nav-link nav-pill" href="/admin/">Admin</a></li>
</ul> </ul>
</div> </div>
@ -29,12 +41,23 @@
<div class="container py-5 position-relative"> <div class="container py-5 position-relative">
<div class="row align-items-center g-5"> <div class="row align-items-center g-5">
<div class="col-lg-6"> <div class="col-lg-6">
<span class="eyebrow">Interesting Python project · daily tracker MVP</span> <span class="eyebrow">{% if request.user.is_authenticated %}Private momentum tracker{% else %}Interesting Python project · try the demo{% endif %}</span>
<h1 class="hero-title">Track your momentum like a product, not a spreadsheet.</h1> <h1 class="hero-title">Track your momentum like a product, not a spreadsheet.</h1>
<p class="hero-copy">Log a single daily check-in, watch your focus and energy trend over time, and turn a simple Django app into a polished personal dashboard you can actually share.</p> <p class="hero-copy">
{% if request.user.is_authenticated %}
Welcome back, {{ request.user.username }}. Your entries are now private to your account, so this dashboard reflects your own focus, energy, and small wins.
{% else %}
Explore the tracker with demo data first, then create a free account when you are ready to save private daily check-ins.
{% endif %}
</p>
<div class="hero-actions d-flex flex-wrap gap-3"> <div class="hero-actions d-flex flex-wrap gap-3">
{% if request.user.is_authenticated %}
<a class="btn btn-accent btn-lg" href="#check-in">Log todays momentum</a> <a class="btn btn-accent btn-lg" href="#check-in">Log todays momentum</a>
<a class="btn btn-ghost btn-lg" href="{% url 'entry_list' %}">Browse history</a> <a class="btn btn-ghost btn-lg" href="{% url 'entry_list' %}">Browse your history</a>
{% else %}
<a class="btn btn-accent btn-lg" href="{% url 'signup' %}">Create free account</a>
<a class="btn btn-ghost btn-lg" href="{% url 'entry_list' %}">Browse demo history</a>
{% endif %}
</div> </div>
<div class="hero-meta d-flex flex-wrap gap-4 mt-4"> <div class="hero-meta d-flex flex-wrap gap-4 mt-4">
<div> <div>
@ -49,7 +72,7 @@
</div> </div>
<div class="col-lg-6"> <div class="col-lg-6">
<div class="glass-panel insight-panel"> <div class="glass-panel insight-panel">
<div class="panel-label">30-day snapshot</div> <div class="panel-label">{% if is_demo_mode %}Demo 30-day snapshot{% else %}Your 30-day snapshot{% endif %}</div>
<div class="row g-3"> <div class="row g-3">
<div class="col-6"> <div class="col-6">
<article class="metric-card"> <article class="metric-card">
@ -105,10 +128,17 @@
<div class="col-xl-7"> <div class="col-xl-7">
<div class="section-heading"> <div class="section-heading">
<span class="eyebrow">Workflow widget</span> <span class="eyebrow">Workflow widget</span>
<h2>Capture one meaningful check-in.</h2> <h2>{% if request.user.is_authenticated %}Capture one meaningful check-in.{% else %}Create your account to start saving check-ins.{% endif %}</h2>
<p>Each entry becomes a usable artifact: you log the day, land on a confirmation detail view, and keep building a real momentum history.</p> <p>
{% if request.user.is_authenticated %}
Each entry becomes a private artifact: you log the day, land on a confirmation detail view, and build your own momentum history.
{% else %}
You can already browse the demo dashboard. Once you sign up, new entries will belong only to your account.
{% endif %}
</p>
</div> </div>
<div class="glass-panel form-panel"> <div class="glass-panel form-panel">
{% if request.user.is_authenticated %}
<form method="post" novalidate> <form method="post" novalidate>
{% csrf_token %} {% csrf_token %}
<div class="row g-3"> <div class="row g-3">
@ -132,6 +162,19 @@
<span class="form-hint mb-0">Default categories are ready, and you can manage them later from Django Admin.</span> <span class="form-hint mb-0">Default categories are ready, and you can manage them later from Django Admin.</span>
</div> </div>
</form> </form>
{% else %}
<div class="auth-callout">
<div class="chip-row mb-3">
<span class="trend-chip">Private entries</span>
<span class="trend-chip">Login + signup ready</span>
</div>
<p class="mb-4">This prevents your personal notes from mixing with the demo sample data. It is a common first step when an app starts storing user-specific information.</p>
<div class="d-flex flex-wrap gap-3">
<a class="btn btn-accent btn-lg" href="{% url 'signup' %}">Create account</a>
<a class="btn btn-ghost btn-lg" href="{% url 'login' %}">Log in</a>
</div>
</div>
{% endif %}
</div> </div>
</div> </div>
<div class="col-xl-5"> <div class="col-xl-5">
@ -142,7 +185,7 @@
<div class="stack-grid"> <div class="stack-grid">
<article class="glass-panel feature-card"> <article class="glass-panel feature-card">
<h3>Create</h3> <h3>Create</h3>
<p>Submit a structured daily entry with validation, categories, and tangible metrics.</p> <p>{% if request.user.is_authenticated %}Submit a structured daily entry with validation, categories, and personal ownership.{% else %}Create an account in a few seconds so the app can keep your data separate from demo content.{% endif %}</p>
</article> </article>
<article class="glass-panel feature-card"> <article class="glass-panel feature-card">
<h3>Confirm</h3> <h3>Confirm</h3>
@ -163,9 +206,9 @@
<div class="d-flex flex-wrap justify-content-between align-items-end gap-3 mb-4"> <div class="d-flex flex-wrap justify-content-between align-items-end gap-3 mb-4">
<div class="section-heading compact mb-0"> <div class="section-heading compact mb-0">
<span class="eyebrow">7-day insight</span> <span class="eyebrow">7-day insight</span>
<h2>Focus and energy trend</h2> <h2>{% if is_demo_mode %}Demo focus and energy trend{% else %}Your focus and energy trend{% endif %}</h2>
</div> </div>
<a class="text-link" href="{% url 'entry_list' %}">Open full history</a> <a class="text-link" href="{% url 'entry_list' %}">{% if is_demo_mode %}Open demo history{% else %}Open your history{% endif %}</a>
</div> </div>
<div class="glass-panel chart-panel"> <div class="glass-panel chart-panel">
<div class="trend-chart"> <div class="trend-chart">
@ -190,9 +233,9 @@
<div class="d-flex flex-wrap justify-content-between align-items-end gap-3 mb-4"> <div class="d-flex flex-wrap justify-content-between align-items-end gap-3 mb-4">
<div class="section-heading compact mb-0"> <div class="section-heading compact mb-0">
<span class="eyebrow">Recent check-ins</span> <span class="eyebrow">Recent check-ins</span>
<h2>Momentum history preview</h2> <h2>{% if is_demo_mode %}Demo momentum history preview{% else %}Your momentum history preview{% endif %}</h2>
</div> </div>
<a class="text-link" href="{% url 'entry_list' %}">See every entry</a> <a class="text-link" href="{% url 'entry_list' %}">{% if is_demo_mode %}See every demo entry{% else %}See every entry{% endif %}</a>
</div> </div>
{% if recent_entries %} {% if recent_entries %}
<div class="row g-4"> <div class="row g-4">
@ -216,8 +259,14 @@
</div> </div>
{% else %} {% else %}
<div class="glass-panel empty-state"> <div class="glass-panel empty-state">
<h3>No entries yet</h3> <h3>{% if request.user.is_authenticated %}No private entries yet{% else %}No demo entries yet{% endif %}</h3>
<p>Start with one check-in above and this dashboard will instantly become your first useful Python product demo.</p> <p>
{% if request.user.is_authenticated %}
Start with one check-in above and this dashboard will instantly become your own useful Python product demo.
{% else %}
Create an account to start filling this dashboard with your own progress history.
{% endif %}
</p>
</div> </div>
{% endif %} {% endif %}
</div> </div>

View File

@ -0,0 +1,58 @@
{% extends "base.html" %}
{% block title %}{{ page_title }}{% endblock %}
{% block meta_description %}{{ meta_description }}{% endblock %}
{% block content %}
<div class="subpage-shell auth-shell">
<div class="container py-5">
{% if messages %}
<div class="message-stack mb-4">
{% for message in messages %}
<div class="alert alert-{{ message.tags|default:'info' }} custom-alert mb-0" role="alert">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
<div class="row justify-content-center">
<div class="col-lg-7 col-xl-6">
<div class="glass-panel auth-card">
<span class="eyebrow">Create account</span>
<h1>Start your private tracker</h1>
<p>This is the simple user system behind the app: once you sign up, each new entry is stored under your account instead of the public demo sample.</p>
<form method="post" novalidate>
{% csrf_token %}
<input type="hidden" name="next" value="{{ next_url }}">
<div class="row g-3">
{% for field in form %}
<div class="col-12 {% if field.name == 'username' or field.name == 'email' %}col-md-6{% endif %}">
<label class="form-label" for="{{ field.id_for_label }}">{{ field.label }}</label>
{{ field }}
{% if field.help_text %}
<div class="form-hint">{{ field.help_text }}</div>
{% endif %}
{% if field.errors %}
<div class="invalid-feedback d-block">
{% for error in field.errors %}{{ error }}{% endfor %}
</div>
{% endif %}
</div>
{% endfor %}
</div>
{% if form.non_field_errors %}
<div class="invalid-feedback d-block mt-3">
{% for error in form.non_field_errors %}{{ error }}{% endfor %}
</div>
{% endif %}
<div class="d-flex flex-wrap gap-3 align-items-center mt-4">
<button type="submit" class="btn btn-accent btn-lg">Create account</button>
<a class="btn btn-ghost btn-lg" href="{% url 'login' %}{% if next_url %}?next={{ next_url|urlencode }}{% endif %}">I already have an account</a>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,58 @@
{% extends "base.html" %}
{% block title %}{{ page_title|default:"Log in | Momentum Atlas" }}{% endblock %}
{% block meta_description %}Log in to Momentum Atlas and continue your private daily tracking workflow.{% endblock %}
{% block content %}
<div class="subpage-shell auth-shell">
<div class="container py-5">
{% if messages %}
<div class="message-stack mb-4">
{% for message in messages %}
<div class="alert alert-{{ message.tags|default:'info' }} custom-alert mb-0" role="alert">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
<div class="row justify-content-center">
<div class="col-lg-6 col-xl-5">
<div class="glass-panel auth-card">
<span class="eyebrow">Account access</span>
<h1>Log in to your dashboard</h1>
<p>Once logged in, new check-ins belong to your account and only you will see them.</p>
<form method="post" novalidate>
{% csrf_token %}
{% for field in form %}
<div class="mb-3">
<label class="form-label" for="{{ field.id_for_label }}">{{ field.label }}</label>
{{ field }}
{% if field.help_text %}
<div class="form-hint">{{ field.help_text }}</div>
{% endif %}
{% if field.errors %}
<div class="invalid-feedback d-block">
{% for error in field.errors %}{{ error }}{% endfor %}
</div>
{% endif %}
</div>
{% endfor %}
{% if form.non_field_errors %}
<div class="invalid-feedback d-block mb-3">
{% for error in form.non_field_errors %}{{ error }}{% endfor %}
</div>
{% endif %}
{% if next %}<input type="hidden" name="next" value="{{ next }}">{% endif %}
<button type="submit" class="btn btn-accent btn-lg w-100">Log in</button>
</form>
<div class="auth-footer">
<span>New here?</span>
<a class="text-link" href="{% url 'signup' %}{% if next %}?next={{ next|urlencode }}{% endif %}">Create your free account</a>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -1,9 +1,22 @@
from django.contrib.auth import views as auth_views
from django.urls import path from django.urls import path
from .views import entry_detail, entry_list, home from .forms import LoginForm
from .views import entry_detail, entry_list, home, signup
urlpatterns = [ urlpatterns = [
path("", home, name="home"), path("", home, name="home"),
path("entries/", entry_list, name="entry_list"), path("entries/", entry_list, name="entry_list"),
path("entries/<int:pk>/", entry_detail, name="entry_detail"), path("entries/<int:pk>/", entry_detail, name="entry_detail"),
path(
"login/",
auth_views.LoginView.as_view(
template_name="registration/login.html",
redirect_authenticated_user=True,
authentication_form=LoginForm,
),
name="login",
),
path("logout/", auth_views.LogoutView.as_view(next_page="home"), name="logout"),
path("signup/", signup, name="signup"),
] ]

View File

@ -4,12 +4,14 @@ from datetime import timedelta
from django import get_version as django_version from django import get_version as django_version
from django.contrib import messages from django.contrib import messages
from django.db.models import Avg, Count, Sum from django.contrib.auth import login
from django.db.models import Avg, Count, Q, Sum
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from .forms import MomentumEntryForm from .forms import MomentumEntryForm, SignUpForm
from .models import Category, MomentumEntry from .models import Category, MomentumEntry
@ -17,6 +19,21 @@ APP_NAME = "Momentum Atlas"
APP_TAGLINE = "A polished personal dashboard for tracking focus, energy, and small wins." 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): def _build_weekly_trend(entries):
today = timezone.localdate() today = timezone.localdate()
start_date = today - timedelta(days=6) start_date = today - timedelta(days=6)
@ -54,8 +71,8 @@ def _build_weekly_trend(entries):
return trend return trend
def _dashboard_context(): def _dashboard_context(request):
entries = MomentumEntry.objects.select_related("category") entries = _entries_for_request(request)
recent_entries = entries[:6] recent_entries = entries[:6]
last_30_days = timezone.localdate() - timedelta(days=29) last_30_days = timezone.localdate() - timedelta(days=29)
stats_window = entries.filter(entry_date__gte=last_30_days) stats_window = entries.filter(entry_date__gte=last_30_days)
@ -80,13 +97,16 @@ def _dashboard_context():
spotlight = "Your recent focus trend is excellent—keep protecting that deep-work time." spotlight = "Your recent focus trend is excellent—keep protecting that deep-work time."
elif focus_average >= 6: elif focus_average >= 6:
spotlight = "Momentum is building. A little more consistency could turn this into a real streak." spotlight = "Momentum is building. A little more consistency could turn this into a real streak."
else: elif totals["total_entries"]:
spotlight = "A reset week might help—shrink the task list and aim for one clear win each day." 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 { return {
"recent_entries": recent_entries, "recent_entries": recent_entries,
"categories": Category.objects.all(), "categories": _categories_for_request(request),
"weekly_trend": weekly_trend, "weekly_trend": weekly_trend,
"is_demo_mode": not request.user.is_authenticated,
"stats": { "stats": {
"total_entries": totals["total_entries"], "total_entries": totals["total_entries"],
"avg_focus": focus_average, "avg_focus": focus_average,
@ -105,10 +125,16 @@ def home(request):
now = timezone.now() now = timezone.now()
if request.method == "POST": 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) form = MomentumEntryForm(request.POST)
if form.is_valid(): if form.is_valid():
entry = form.save() entry = form.save(commit=False)
messages.success(request, "Momentum captured. Your new check-in is ready.") 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") return redirect(f"{entry.get_absolute_url()}?created=1")
messages.error(request, "Please fix the form errors and try again.") messages.error(request, "Please fix the form errors and try again.")
else: else:
@ -126,14 +152,14 @@ def home(request):
"page_title": f"{APP_NAME} | Daily focus dashboard", "page_title": f"{APP_NAME} | Daily focus dashboard",
"meta_description": "Track daily focus, energy, and deep-work minutes in a polished Python dashboard.", "meta_description": "Track daily focus, energy, and deep-work minutes in a polished Python dashboard.",
"form": form, "form": form,
**_dashboard_context(), **_dashboard_context(request),
} }
return render(request, "core/index.html", context) return render(request, "core/index.html", context)
def entry_list(request): def entry_list(request):
selected_slug = request.GET.get("category", "") selected_slug = request.GET.get("category", "")
entries = MomentumEntry.objects.select_related("category") entries = _entries_for_request(request)
if selected_slug: if selected_slug:
entries = entries.filter(category__slug=selected_slug) entries = entries.filter(category__slug=selected_slug)
@ -141,24 +167,47 @@ def entry_list(request):
"page_title": f"All check-ins | {APP_NAME}", "page_title": f"All check-ins | {APP_NAME}",
"meta_description": "Browse recent check-ins and filter your momentum history by category.", "meta_description": "Browse recent check-ins and filter your momentum history by category.",
"entries": entries, "entries": entries,
"categories": Category.objects.annotate(entry_total=Count("entries")), "categories": _categories_for_request(request),
"selected_slug": selected_slug, "selected_slug": selected_slug,
"is_demo_mode": not request.user.is_authenticated,
} }
return render(request, "core/entry_list.html", context) return render(request, "core/entry_list.html", context)
def entry_detail(request, pk): def entry_detail(request, pk):
entry = get_object_or_404(MomentumEntry.objects.select_related("category"), pk=pk) scoped_entries = _entries_for_request(request)
related_entries = ( entry = get_object_or_404(scoped_entries, pk=pk)
MomentumEntry.objects.select_related("category") related_entries = scoped_entries.filter(category=entry.category).exclude(pk=entry.pk)[:3]
.filter(category=entry.category)
.exclude(pk=entry.pk)[:3]
)
context = { context = {
"page_title": f"{entry.title} | {APP_NAME}", "page_title": f"{entry.title} | {APP_NAME}",
"meta_description": entry.takeaway, "meta_description": entry.takeaway,
"entry": entry, "entry": entry,
"related_entries": related_entries, "related_entries": related_entries,
"created": request.GET.get("created") == "1", "created": request.GET.get("created") == "1",
"is_demo_mode": not request.user.is_authenticated,
} }
return render(request, "core/entry_detail.html", context) 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)

View File

@ -87,6 +87,27 @@ p {
padding: 0.55rem 1rem !important; padding: 0.55rem 1rem !important;
} }
.nav-user {
display: inline-flex;
align-items: center;
min-height: 2.75rem;
padding: 0.55rem 0.9rem;
border-radius: 999px;
background: rgba(255, 255, 255, 0.14);
color: #ffffff;
font-weight: 700;
}
.nav-form,
.inline-form {
margin: 0;
}
.nav-button {
background: transparent;
cursor: pointer;
}
.hero-section { .hero-section {
position: relative; position: relative;
padding: 1rem 0 4rem; padding: 1rem 0 4rem;
@ -206,7 +227,8 @@ p {
} }
.chip-row, .chip-row,
.filter-row { .filter-row,
.message-stack {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.75rem; gap: 0.75rem;
@ -255,7 +277,8 @@ p {
.section-heading h2, .section-heading h2,
.subpage-header h1, .subpage-header h1,
.detail-panel h1 { .detail-panel h1,
.auth-card h1 {
font-size: clamp(2rem, 3vw, 3rem); font-size: clamp(2rem, 3vw, 3rem);
margin-bottom: 0.8rem; margin-bottom: 0.8rem;
} }
@ -269,12 +292,14 @@ p {
.detail-panel, .detail-panel,
.sidebar-panel, .sidebar-panel,
.subpage-header, .subpage-header,
.empty-state { .empty-state,
.auth-card {
padding: 2rem; padding: 2rem;
} }
.form-panel .form-control, .form-panel .form-control,
.form-panel .form-select { .form-panel .form-select,
.auth-card .form-control {
border-radius: 16px; border-radius: 16px;
border: 1px solid rgba(148, 163, 184, 0.35); border: 1px solid rgba(148, 163, 184, 0.35);
padding: 0.9rem 1rem; padding: 0.9rem 1rem;
@ -345,6 +370,27 @@ p {
margin-bottom: 0.6rem; margin-bottom: 0.6rem;
} }
.auth-callout {
display: grid;
gap: 0.75rem;
}
.auth-shell {
padding: 1rem 0 3rem;
}
.auth-card {
max-width: 100%;
}
.auth-footer {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
margin-top: 1.25rem;
}
.trend-chart { .trend-chart {
display: grid; display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr)); grid-template-columns: repeat(7, minmax(0, 1fr));
@ -534,7 +580,12 @@ p {
.sidebar-panel, .sidebar-panel,
.subpage-header, .subpage-header,
.empty-state, .empty-state,
.insight-panel { .insight-panel,
.auth-card {
padding: 1.4rem; padding: 1.4rem;
} }
.nav-user {
width: fit-content;
}
} }

17
static/images/favicon.svg Normal file
View File

@ -0,0 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Momentum Atlas favicon">
<defs>
<linearGradient id="bg" x1="8" y1="6" x2="56" y2="58" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#0F766E"/>
<stop offset="1" stop-color="#F97316"/>
</linearGradient>
<linearGradient id="glow" x1="12" y1="14" x2="52" y2="50" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#ffffff" stop-opacity="0.38"/>
<stop offset="1" stop-color="#ffffff" stop-opacity="0.08"/>
</linearGradient>
</defs>
<rect x="4" y="4" width="56" height="56" rx="18" fill="url(#bg)"/>
<rect x="8" y="8" width="48" height="48" rx="14" fill="url(#glow)"/>
<path d="M18 40 L27 31 L33 36 L46 22" fill="none" stroke="#FFFAF4" stroke-linecap="round" stroke-linejoin="round" stroke-width="5"/>
<circle cx="46" cy="22" r="4" fill="#F59E0B" stroke="#FFFAF4" stroke-width="2"/>
<path d="M18 46 H46" stroke="#FFFAF4" stroke-linecap="round" stroke-opacity="0.45" stroke-width="3"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -87,6 +87,27 @@ p {
padding: 0.55rem 1rem !important; padding: 0.55rem 1rem !important;
} }
.nav-user {
display: inline-flex;
align-items: center;
min-height: 2.75rem;
padding: 0.55rem 0.9rem;
border-radius: 999px;
background: rgba(255, 255, 255, 0.14);
color: #ffffff;
font-weight: 700;
}
.nav-form,
.inline-form {
margin: 0;
}
.nav-button {
background: transparent;
cursor: pointer;
}
.hero-section { .hero-section {
position: relative; position: relative;
padding: 1rem 0 4rem; padding: 1rem 0 4rem;
@ -206,7 +227,8 @@ p {
} }
.chip-row, .chip-row,
.filter-row { .filter-row,
.message-stack {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.75rem; gap: 0.75rem;
@ -255,7 +277,8 @@ p {
.section-heading h2, .section-heading h2,
.subpage-header h1, .subpage-header h1,
.detail-panel h1 { .detail-panel h1,
.auth-card h1 {
font-size: clamp(2rem, 3vw, 3rem); font-size: clamp(2rem, 3vw, 3rem);
margin-bottom: 0.8rem; margin-bottom: 0.8rem;
} }
@ -269,12 +292,14 @@ p {
.detail-panel, .detail-panel,
.sidebar-panel, .sidebar-panel,
.subpage-header, .subpage-header,
.empty-state { .empty-state,
.auth-card {
padding: 2rem; padding: 2rem;
} }
.form-panel .form-control, .form-panel .form-control,
.form-panel .form-select { .form-panel .form-select,
.auth-card .form-control {
border-radius: 16px; border-radius: 16px;
border: 1px solid rgba(148, 163, 184, 0.35); border: 1px solid rgba(148, 163, 184, 0.35);
padding: 0.9rem 1rem; padding: 0.9rem 1rem;
@ -345,6 +370,27 @@ p {
margin-bottom: 0.6rem; margin-bottom: 0.6rem;
} }
.auth-callout {
display: grid;
gap: 0.75rem;
}
.auth-shell {
padding: 1rem 0 3rem;
}
.auth-card {
max-width: 100%;
}
.auth-footer {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
margin-top: 1.25rem;
}
.trend-chart { .trend-chart {
display: grid; display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr)); grid-template-columns: repeat(7, minmax(0, 1fr));
@ -534,7 +580,12 @@ p {
.sidebar-panel, .sidebar-panel,
.subpage-header, .subpage-header,
.empty-state, .empty-state,
.insight-panel { .insight-panel,
.auth-card {
padding: 1.4rem; padding: 1.4rem;
} }
.nav-user {
width: fit-content;
}
} }

View File

@ -0,0 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Momentum Atlas favicon">
<defs>
<linearGradient id="bg" x1="8" y1="6" x2="56" y2="58" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#0F766E"/>
<stop offset="1" stop-color="#F97316"/>
</linearGradient>
<linearGradient id="glow" x1="12" y1="14" x2="52" y2="50" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#ffffff" stop-opacity="0.38"/>
<stop offset="1" stop-color="#ffffff" stop-opacity="0.08"/>
</linearGradient>
</defs>
<rect x="4" y="4" width="56" height="56" rx="18" fill="url(#bg)"/>
<rect x="8" y="8" width="48" height="48" rx="14" fill="url(#glow)"/>
<path d="M18 40 L27 31 L33 36 L46 22" fill="none" stroke="#FFFAF4" stroke-linecap="round" stroke-linejoin="round" stroke-width="5"/>
<circle cx="46" cy="22" r="4" fill="#F59E0B" stroke="#FFFAF4" stroke-width="2"/>
<path d="M18 46 H46" stroke="#FFFAF4" stroke-linecap="round" stroke-opacity="0.45" stroke-width="3"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB