Auto commit: 2026-04-16T11:25:44.037Z
This commit is contained in:
parent
e0e36aaad0
commit
3a44f34cf9
Binary file not shown.
@ -18,8 +18,10 @@ from django.contrib import admin
|
||||
from django.urls import include, path
|
||||
from django.conf import settings
|
||||
from django.conf.urls.static import static
|
||||
from django.views.generic.base import RedirectView
|
||||
|
||||
urlpatterns = [
|
||||
path("favicon.ico", RedirectView.as_view(url=settings.STATIC_URL + "images/favicon.svg", permanent=False)),
|
||||
path("admin/", admin.site.urls),
|
||||
path("", include("core.urls")),
|
||||
]
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -14,12 +14,13 @@ class CategoryAdmin(admin.ModelAdmin):
|
||||
class MomentumEntryAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"title",
|
||||
"user",
|
||||
"entry_date",
|
||||
"category",
|
||||
"focus_score",
|
||||
"energy_score",
|
||||
"deep_work_minutes",
|
||||
)
|
||||
list_filter = ("category", "entry_date")
|
||||
search_fields = ("title", "takeaway", "reflection")
|
||||
list_filter = ("user", "category", "entry_date")
|
||||
search_fields = ("title", "takeaway", "reflection", "user__username")
|
||||
date_hierarchy = "entry_date"
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
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 .models import MomentumEntry
|
||||
@ -64,3 +66,39 @@ class MomentumEntryForm(forms.ModelForm):
|
||||
if len(takeaway.split()) < 3:
|
||||
raise forms.ValidationError("Write a short sentence with at least three words.")
|
||||
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
|
||||
|
||||
21
core/migrations/0003_momentumentry_user.py
Normal file
21
core/migrations/0003_momentumentry_user.py
Normal 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),
|
||||
),
|
||||
]
|
||||
Binary file not shown.
@ -1,3 +1,4 @@
|
||||
from django.conf import settings
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
@ -17,6 +18,13 @@ class Category(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")
|
||||
title = models.CharField(max_length=120)
|
||||
entry_date = models.DateField()
|
||||
|
||||
@ -17,6 +17,7 @@
|
||||
<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://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 }}">
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
|
||||
@ -6,6 +6,14 @@
|
||||
{% block content %}
|
||||
<div class="subpage-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 %}
|
||||
|
||||
{% if created %}
|
||||
<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.
|
||||
@ -52,8 +60,12 @@
|
||||
<h2>Keep the loop going</h2>
|
||||
</div>
|
||||
<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-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>
|
||||
</div>
|
||||
<h3 class="h5">More in {{ entry.category.name }}</h3>
|
||||
|
||||
@ -6,15 +6,32 @@
|
||||
{% block content %}
|
||||
<div class="subpage-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="subpage-header glass-panel mb-4">
|
||||
<div>
|
||||
<span class="eyebrow">History</span>
|
||||
<h1>All momentum entries</h1>
|
||||
<p>Filter by category and open any check-in for its confirmation/detail view.</p>
|
||||
<h1>{% if is_demo_mode %}Demo momentum entries{% else %}Your momentum entries{% endif %}</h1>
|
||||
<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 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>
|
||||
{% if request.user.is_authenticated %}
|
||||
<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>
|
||||
|
||||
@ -47,9 +64,13 @@
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="glass-panel empty-state">
|
||||
<h2>No entries for this filter</h2>
|
||||
<p>Try a different category or create a new check-in from the dashboard.</p>
|
||||
<h2>{% if is_demo_mode %}No demo entries for this filter{% else %}No private entries for this filter{% endif %}</h2>
|
||||
<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>
|
||||
{% else %}
|
||||
<a class="btn btn-accent mt-3" href="{% url 'signup' %}">Create your account</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@ -14,9 +14,21 @@
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="mainNav">
|
||||
<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="{% 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>
|
||||
</ul>
|
||||
</div>
|
||||
@ -29,12 +41,23 @@
|
||||
<div class="container py-5 position-relative">
|
||||
<div class="row align-items-center g-5">
|
||||
<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>
|
||||
<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">
|
||||
{% if request.user.is_authenticated %}
|
||||
<a class="btn btn-accent btn-lg" href="#check-in">Log today’s 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 class="hero-meta d-flex flex-wrap gap-4 mt-4">
|
||||
<div>
|
||||
@ -49,7 +72,7 @@
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<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="col-6">
|
||||
<article class="metric-card">
|
||||
@ -105,10 +128,17 @@
|
||||
<div class="col-xl-7">
|
||||
<div class="section-heading">
|
||||
<span class="eyebrow">Workflow widget</span>
|
||||
<h2>Capture one meaningful check-in.</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>
|
||||
<h2>{% if request.user.is_authenticated %}Capture one meaningful check-in.{% else %}Create your account to start saving check-ins.{% endif %}</h2>
|
||||
<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 class="glass-panel form-panel">
|
||||
{% if request.user.is_authenticated %}
|
||||
<form method="post" novalidate>
|
||||
{% csrf_token %}
|
||||
<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>
|
||||
</div>
|
||||
</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 class="col-xl-5">
|
||||
@ -142,7 +185,7 @@
|
||||
<div class="stack-grid">
|
||||
<article class="glass-panel feature-card">
|
||||
<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 class="glass-panel feature-card">
|
||||
<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="section-heading compact mb-0">
|
||||
<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>
|
||||
<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 class="glass-panel chart-panel">
|
||||
<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="section-heading compact mb-0">
|
||||
<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>
|
||||
<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>
|
||||
{% if recent_entries %}
|
||||
<div class="row g-4">
|
||||
@ -216,8 +259,14 @@
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="glass-panel empty-state">
|
||||
<h3>No entries yet</h3>
|
||||
<p>Start with one check-in above and this dashboard will instantly become your first useful Python product demo.</p>
|
||||
<h3>{% if request.user.is_authenticated %}No private entries yet{% else %}No demo entries yet{% endif %}</h3>
|
||||
<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>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
58
core/templates/core/signup.html
Normal file
58
core/templates/core/signup.html
Normal 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 %}
|
||||
58
core/templates/registration/login.html
Normal file
58
core/templates/registration/login.html
Normal 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 %}
|
||||
15
core/urls.py
15
core/urls.py
@ -1,9 +1,22 @@
|
||||
from django.contrib.auth import views as auth_views
|
||||
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 = [
|
||||
path("", home, name="home"),
|
||||
path("entries/", entry_list, name="entry_list"),
|
||||
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"),
|
||||
]
|
||||
|
||||
@ -4,12 +4,14 @@ from datetime import timedelta
|
||||
|
||||
from django import get_version as django_version
|
||||
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.shortcuts import get_object_or_404, redirect, render
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from .forms import MomentumEntryForm
|
||||
from .forms import MomentumEntryForm, SignUpForm
|
||||
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."
|
||||
|
||||
|
||||
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)
|
||||
@ -54,8 +71,8 @@ def _build_weekly_trend(entries):
|
||||
return trend
|
||||
|
||||
|
||||
def _dashboard_context():
|
||||
entries = MomentumEntry.objects.select_related("category")
|
||||
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)
|
||||
@ -80,13 +97,16 @@ def _dashboard_context():
|
||||
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."
|
||||
else:
|
||||
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": Category.objects.all(),
|
||||
"categories": _categories_for_request(request),
|
||||
"weekly_trend": weekly_trend,
|
||||
"is_demo_mode": not request.user.is_authenticated,
|
||||
"stats": {
|
||||
"total_entries": totals["total_entries"],
|
||||
"avg_focus": focus_average,
|
||||
@ -105,10 +125,16 @@ def home(request):
|
||||
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()
|
||||
messages.success(request, "Momentum captured. Your new check-in is ready.")
|
||||
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:
|
||||
@ -126,14 +152,14 @@ def home(request):
|
||||
"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(),
|
||||
**_dashboard_context(request),
|
||||
}
|
||||
return render(request, "core/index.html", context)
|
||||
|
||||
|
||||
def entry_list(request):
|
||||
selected_slug = request.GET.get("category", "")
|
||||
entries = MomentumEntry.objects.select_related("category")
|
||||
entries = _entries_for_request(request)
|
||||
if 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}",
|
||||
"meta_description": "Browse recent check-ins and filter your momentum history by category.",
|
||||
"entries": entries,
|
||||
"categories": Category.objects.annotate(entry_total=Count("entries")),
|
||||
"categories": _categories_for_request(request),
|
||||
"selected_slug": selected_slug,
|
||||
"is_demo_mode": not request.user.is_authenticated,
|
||||
}
|
||||
return render(request, "core/entry_list.html", context)
|
||||
|
||||
|
||||
def entry_detail(request, pk):
|
||||
entry = get_object_or_404(MomentumEntry.objects.select_related("category"), pk=pk)
|
||||
related_entries = (
|
||||
MomentumEntry.objects.select_related("category")
|
||||
.filter(category=entry.category)
|
||||
.exclude(pk=entry.pk)[:3]
|
||||
)
|
||||
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)
|
||||
|
||||
@ -87,6 +87,27 @@ p {
|
||||
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 {
|
||||
position: relative;
|
||||
padding: 1rem 0 4rem;
|
||||
@ -206,7 +227,8 @@ p {
|
||||
}
|
||||
|
||||
.chip-row,
|
||||
.filter-row {
|
||||
.filter-row,
|
||||
.message-stack {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
@ -255,7 +277,8 @@ p {
|
||||
|
||||
.section-heading h2,
|
||||
.subpage-header h1,
|
||||
.detail-panel h1 {
|
||||
.detail-panel h1,
|
||||
.auth-card h1 {
|
||||
font-size: clamp(2rem, 3vw, 3rem);
|
||||
margin-bottom: 0.8rem;
|
||||
}
|
||||
@ -269,12 +292,14 @@ p {
|
||||
.detail-panel,
|
||||
.sidebar-panel,
|
||||
.subpage-header,
|
||||
.empty-state {
|
||||
.empty-state,
|
||||
.auth-card {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.form-panel .form-control,
|
||||
.form-panel .form-select {
|
||||
.form-panel .form-select,
|
||||
.auth-card .form-control {
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
padding: 0.9rem 1rem;
|
||||
@ -345,6 +370,27 @@ p {
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
@ -534,7 +580,12 @@ p {
|
||||
.sidebar-panel,
|
||||
.subpage-header,
|
||||
.empty-state,
|
||||
.insight-panel {
|
||||
.insight-panel,
|
||||
.auth-card {
|
||||
padding: 1.4rem;
|
||||
}
|
||||
|
||||
.nav-user {
|
||||
width: fit-content;
|
||||
}
|
||||
}
|
||||
|
||||
17
static/images/favicon.svg
Normal file
17
static/images/favicon.svg
Normal 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 |
@ -87,6 +87,27 @@ p {
|
||||
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 {
|
||||
position: relative;
|
||||
padding: 1rem 0 4rem;
|
||||
@ -206,7 +227,8 @@ p {
|
||||
}
|
||||
|
||||
.chip-row,
|
||||
.filter-row {
|
||||
.filter-row,
|
||||
.message-stack {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
@ -255,7 +277,8 @@ p {
|
||||
|
||||
.section-heading h2,
|
||||
.subpage-header h1,
|
||||
.detail-panel h1 {
|
||||
.detail-panel h1,
|
||||
.auth-card h1 {
|
||||
font-size: clamp(2rem, 3vw, 3rem);
|
||||
margin-bottom: 0.8rem;
|
||||
}
|
||||
@ -269,12 +292,14 @@ p {
|
||||
.detail-panel,
|
||||
.sidebar-panel,
|
||||
.subpage-header,
|
||||
.empty-state {
|
||||
.empty-state,
|
||||
.auth-card {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.form-panel .form-control,
|
||||
.form-panel .form-select {
|
||||
.form-panel .form-select,
|
||||
.auth-card .form-control {
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
padding: 0.9rem 1rem;
|
||||
@ -345,6 +370,27 @@ p {
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
@ -534,7 +580,12 @@ p {
|
||||
.sidebar-panel,
|
||||
.subpage-header,
|
||||
.empty-state,
|
||||
.insight-panel {
|
||||
.insight-panel,
|
||||
.auth-card {
|
||||
padding: 1.4rem;
|
||||
}
|
||||
|
||||
.nav-user {
|
||||
width: fit-content;
|
||||
}
|
||||
}
|
||||
|
||||
17
staticfiles/images/favicon.svg
Normal file
17
staticfiles/images/favicon.svg
Normal 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 |
Loading…
x
Reference in New Issue
Block a user