Implement accomplishment logging functionality

Allow browsing historical accomplishments.

Created a timeutils module, which contains all time-related logic, to
allow easier extension later (for when I'll be implementing timezones
and custom day-start hours)

show today's accomplishements on the main page

extract main.handle_accomplishment_submission function

handle adding new accomplishments to the database

add header and add accomplishment UI

extract .card css component

add Accomplishment model

Co-authored-by: Wojciech Kwolek <wojciech@kwolek.xyz>
Reviewed-on: https://git.r23s.eu/wojciech/doneth.at-backend/pulls/1
This commit is contained in:
wojciech 2020-08-29 17:41:18 +00:00
parent 294e93d1b7
commit 7265866fe2
13 changed files with 423 additions and 20 deletions

View File

@ -4,6 +4,7 @@ from flask import Flask
def create_app():
app = Flask(__name__)
app.config['SECRET_KEY'] = "raleicuu0Engohh3iageephoh3looge0okupha2omeiph7Nooyeey1tiewooxu7phaeshi0ohlaaThai2eth1oapong5iroo4fieleekaidohmoh1eYahjei9Yi6aema"
app.config['SQLALCHEMY_ECHO'] = True
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///app.db'
from . import db

View File

@ -40,10 +40,55 @@ form input[type=password]:focus {
}
form input[type=submit] {
@apply bg-blue-500 text-white font-bold py-2 px-4 rounded w-full mt-2;
@apply font-bold py-2 px-4 rounded w-full mt-2;
}
form input[type=submit]:hover {
form.auth-form input[type=submit] {
@apply bg-blue-500 text-white;
}
.green-btn {
@apply bg-green-500 text-white;
}
.accomplishment .difficulty {
@apply font-bold;
}
.accomplishment .difficulty-easy {
@apply text-green-700;
}
.accomplishment .difficulty-medium {
@apply text-orange-700;
}
.accomplishment .difficulty-hard {
@apply text-red-700;
}
.green-btn:hover {
@apply bg-green-700;
}
.orange-btn {
@apply bg-orange-500 text-white;
}
.orange-btn:hover {
@apply bg-orange-700;
}
.red-btn {
@apply bg-red-500 text-white;
}
.red-btn:hover {
@apply bg-red-700;
}
form.auth-form input[type=submit]:hover {
@apply bg-blue-700;
}
@ -59,3 +104,10 @@ form input[type=submit]:focus {
@apply text-blue-500;
}
.card {
@apply bg-white shadow-md rounded px-8 pt-6 pb-6 mb-4 mt-4;
}
.auth-form.card {
@apply pb-4;
}

View File

@ -3,6 +3,9 @@ from flask_sqlalchemy import SQLAlchemy
from flask_login import UserMixin
from flask_bcrypt import generate_password_hash, check_password_hash
from flask_migrate import Migrate
from datetime import datetime, timedelta
from . import timeutils
db = SQLAlchemy()
migrate = Migrate()
@ -22,6 +25,9 @@ class User(UserMixin, db.Model):
last_login = db.Column(db.DateTime, index=False,
unique=False, nullable=True)
accomplishments = db.relationship(
'Accomplishment', backref='user', lazy=True)
def set_password(self, password):
self.password = generate_password_hash(password)
@ -30,3 +36,37 @@ class User(UserMixin, db.Model):
def __repr__(self):
return '<User {}>'.format(self.username)
class Accomplishment(db.Model):
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
time = db.Column(db.DateTime(), nullable=False,
default=db.func.current_timestamp())
text = db.Column(db.String(128), nullable=False)
difficulty = db.Column(db.Integer)
@property
def difficulty_class(self):
if self.difficulty <= 5:
return "easy"
if self.difficulty <= 10:
return "medium"
return "hard"
@staticmethod
def get_time_range(user_id, start, end):
return Accomplishment.query.filter(
Accomplishment.time >= start, Accomplishment.time < end, Accomplishment.user_id == user_id).all()
@staticmethod
def get_day(user_id, day):
# TODO: allow setting custom "start of day" hour
start = timeutils.day(day)
end = timeutils.day_after(day)
return Accomplishment.get_time_range(user_id, start, end)
@staticmethod
def get_today(user_id):
today = datetime.now()
return Accomplishment.get_day(user_id, today)

View File

@ -1,8 +1,78 @@
from flask import Blueprint, render_template
from . import timeutils
from flask import Blueprint, render_template, redirect, url_for
from flask_login import current_user
from flask_wtf import FlaskForm
from wtforms import StringField, SubmitField
from wtforms.validators import DataRequired, Length
from .db import db, Accomplishment
from datetime import datetime, timedelta
main = Blueprint('main', __name__)
@main.route('/')
def index():
class NewAccomplishementForm(FlaskForm):
text = StringField('Accomplishment', validators=[
DataRequired(), Length(max=128)])
submit_5 = SubmitField('5 XP')
submit_10 = SubmitField('10 XP')
submit_15 = SubmitField('15 XP')
def handle_accomplishment_submission(form):
accomplishment = Accomplishment()
accomplishment.user_id = current_user.id
accomplishment.text = form.text.data
accomplishment.difficulty = 5
if form.submit_10.data:
accomplishment.difficulty = 10
elif form.submit_15.data:
accomplishment.difficulty = 15
# the timestamp should be set by the database
db.session.add(accomplishment)
db.session.commit()
return redirect(url_for('main.index'))
@main.route('/', defaults={'day': 'today'}, methods=['GET', 'POST'])
@main.route('/day/<day>')
def index(day):
day_datetime = None
day_string = None
is_today = False
if day == "today":
day_datetime = timeutils.today()
day_string = "Today"
is_today = True
else:
day_datetime = timeutils.from_str(day)
if timeutils.is_today(day_datetime):
return redirect('/')
day_string = timeutils.as_fancy_str(day_datetime)
if not current_user.is_authenticated:
return render_template('index.html')
form = NewAccomplishementForm()
if form.validate_on_submit():
return handle_accomplishment_submission(form)
accomplishments = list(reversed(Accomplishment.get_day(
current_user.id, day_datetime)))
total = sum(a.difficulty for a in accomplishments)
tomorrow = timeutils.day_after(day_datetime)
yesterday = timeutils.day_before(day_datetime)
if timeutils.is_future(tomorrow):
tomorrow = None
return render_template(
'main/app.html',
form=form,
day=day_string,
accomplishments=accomplishments,
total=total,
tomorrow=timeutils.as_str(tomorrow),
yesterday=timeutils.as_str(yesterday),
is_today=is_today,
)

View File

@ -22083,12 +22083,6 @@ form input[type=password]:focus {
}
form input[type=submit] {
--bg-opacity: 1;
background-color: #4299e1;
background-color: rgba(66, 153, 225, var(--bg-opacity));
--text-opacity: 1;
color: #fff;
color: rgba(255, 255, 255, var(--text-opacity));
font-weight: 700;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
@ -22099,7 +22093,83 @@ form input[type=submit] {
margin-top: 0.5rem;
}
form input[type=submit]:hover {
form.auth-form input[type=submit] {
--bg-opacity: 1;
background-color: #4299e1;
background-color: rgba(66, 153, 225, var(--bg-opacity));
--text-opacity: 1;
color: #fff;
color: rgba(255, 255, 255, var(--text-opacity));
}
.green-btn {
--bg-opacity: 1;
background-color: #48bb78;
background-color: rgba(72, 187, 120, var(--bg-opacity));
--text-opacity: 1;
color: #fff;
color: rgba(255, 255, 255, var(--text-opacity));
}
.accomplishment .difficulty {
font-weight: 700;
}
.accomplishment .difficulty-easy {
--text-opacity: 1;
color: #2f855a;
color: rgba(47, 133, 90, var(--text-opacity));
}
.accomplishment .difficulty-medium {
--text-opacity: 1;
color: #c05621;
color: rgba(192, 86, 33, var(--text-opacity));
}
.accomplishment .difficulty-hard {
--text-opacity: 1;
color: #c53030;
color: rgba(197, 48, 48, var(--text-opacity));
}
.green-btn:hover {
--bg-opacity: 1;
background-color: #2f855a;
background-color: rgba(47, 133, 90, var(--bg-opacity));
}
.orange-btn {
--bg-opacity: 1;
background-color: #ed8936;
background-color: rgba(237, 137, 54, var(--bg-opacity));
--text-opacity: 1;
color: #fff;
color: rgba(255, 255, 255, var(--text-opacity));
}
.orange-btn:hover {
--bg-opacity: 1;
background-color: #c05621;
background-color: rgba(192, 86, 33, var(--bg-opacity));
}
.red-btn {
--bg-opacity: 1;
background-color: #f56565;
background-color: rgba(245, 101, 101, var(--bg-opacity));
--text-opacity: 1;
color: #fff;
color: rgba(255, 255, 255, var(--text-opacity));
}
.red-btn:hover {
--bg-opacity: 1;
background-color: #c53030;
background-color: rgba(197, 48, 48, var(--bg-opacity));
}
form.auth-form input[type=submit]:hover {
--bg-opacity: 1;
background-color: #2b6cb0;
background-color: rgba(43, 108, 176, var(--bg-opacity));
@ -22123,6 +22193,24 @@ form input[type=submit]:focus {
color: rgba(66, 153, 225, var(--text-opacity));
}
.card {
--bg-opacity: 1;
background-color: #fff;
background-color: rgba(255, 255, 255, var(--bg-opacity));
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
border-radius: 0.25rem;
padding-left: 2rem;
padding-right: 2rem;
padding-top: 1.5rem;
padding-bottom: 1.5rem;
margin-bottom: 1rem;
margin-top: 1rem;
}
.auth-form.card {
padding-bottom: 1rem;
}
@media (min-width: 640px) {
.sm\:container {
width: 100%;
@ -107634,4 +107722,3 @@ form input[type=submit]:focus {
animation: bounce 1s infinite;
}
}

View File

@ -13,6 +13,17 @@
<body>
{% block body %}
<div class="content container mx-auto">
<div class="card {% block header_width %}max-w-lg{% endblock %} mx-auto text-center ">
<h1 class="text-5xl">DoneTh.at</h1>
{% block header_user %}
{% if current_user.is_authenticated %}
<p>Hi <span class="font-mono">{{ current_user.username }}</span>!</p>
<p>
<a class="link text-xs" href="{{ url_for('auth.logout') }}">Log out.</a>
</p>
{% endif %}
{% endblock %}
</div>
{% block content %}
{% endblock %}
</div>

View File

@ -1,8 +1,9 @@
{% extends "_skel.html" %}
{% block header_width %}max-w-xs{% endblock %}
{% from "_formhelpers.html" import render_field %}
{% block content %}
<div class="w-full max-w-xs mx-auto">
<form method="POST" class="bg-white shadow-md rounded px-8 pt-6 pb-4 mb-4 mt-4">
<form method="POST" class="card auth-form">
{{ form.csrf_token }}
{{ render_field(form.username) }}
{{ render_field(form.password) }}

View File

@ -1,8 +1,10 @@
{% extends "_skel.html" %}
{% block header_width %}max-w-xs{% endblock %}
{% block header_user %}{% endblock %}
{% from "_formhelpers.html" import render_field %}
{% block content %}
<div class="w-full max-w-xs mx-auto">
<form method="POST" class="bg-white shadow-md rounded px-8 pt-6 pb-4 mb-4 mt-4">
<form method="POST" class="card auth-form">
<h3>Are you sure you want to log out?</h3>
{{ form.csrf_token }}
{{ render_field(form.submit, False) }}

View File

@ -1,8 +1,9 @@
{% extends "_skel.html" %}
{% block header_width %}max-w-xs{% endblock %}
{% from "_formhelpers.html" import render_field %}
{% block content %}
<div class="w-full max-w-xs mx-auto">
<form method="POST" class="bg-white shadow-md rounded px-8 pt-6 pb-4 mb-4 mt-4">
<form method="POST" class="card auth-form">
{{ form.csrf_token }}
{{ render_field(form.username) }}
{{ render_field(form.password) }}

View File

@ -1,3 +1,8 @@
{% if current_user.is_authenticated %}
Hi {{ current_user.username }}! <a href="{{ url_for('auth.logout') }}">Log out.</a>
{% endif %}
{% extends "_skel.html" %}
{% block content %}
<div class="card max-w-lg mx-auto">
{% if current_user.is_authenticated %}
Hi {{ current_user.username }}! <a href="{{ url_for('auth.logout') }}">Log out.</a>
{% endif %}
</div>
{% endblock %}

View File

@ -0,0 +1,45 @@
{% extends "_skel.html" %}
{% block content %}
<div class="max-w-lg mx-auto card">
<form method="POST" action="{{ url_for('main.index') }}">
{{ form.csrf_token }}
{{ form.text(placeholder="What did you accomplish today?", class_="placeholder-black", autofocus=True) }}
<div class="flex">
{{ form.submit_5(class_="w-1/3 mr-1 green-btn") }}
{{ form.submit_10(class_="w-1/3 mx-1 orange-btn") }}
{{ form.submit_15(class_="w-1/3 ml-1 red-btn") }}
</div>
</form>
</div>
<div class="max-w-lg mx-auto card">
<h3 class="text-2xl">{{ day }}</h3>
{% for accomplishment in accomplishments %}
<div class="flex justify-between my-1 ml-2 accomplishment">
<div>{{ accomplishment.text }}</div>
<div class="difficulty-{{ accomplishment.difficulty_class }} difficulty">{{ accomplishment.difficulty }} XP
</div>
</div>
<hr>
{% else %}
<div class="my-1 ml-2 text-sm accomplishment">
{% if false %}
<!-- TODO: random text if no accomplishments -->{% endif %}
{% if is_today %}
<p>No accomplishments today... yet!</p>
{% else %}
<p>Nothing logged that day... but it's okay to take a break!</p>
{% endif %}
</div>
{% endfor %}
<div class="flex justify-end my-1 ml-2 accomplishment">
<div><span class="pr-1 text-xs text-gray-700">total:</span> <span class="difficulty">{{ total }} XP</div>
</div>
<div class="flex justify-between mt-2 text-sm accomplishment">
<div><a href="{{ url_for('main.index', day=yesterday) }}" class="text-blue-700">Previous day</a></div>
{% if tomorrow %}<a href="{{ url_for('main.index', day=tomorrow) }}" class="text-blue-700">Next day</a>
</div>{% endif %}
</div>
</div>
</div>
{% endblock %}

52
app/timeutils.py Normal file
View File

@ -0,0 +1,52 @@
"""
The timeutils module is supposed to be where ALL time related logic goes.
This is meant to ease handling timezones and custom day-start-hours later.
"""
from datetime import datetime, timedelta
# TODO: make it all custom-day-start-hour aware
def from_str(string):
return datetime.strptime(string, "%Y-%m-%d")
def as_str(day_):
if day_ is None:
return None
return day(day_).strftime("%Y-%m-%d")
def _suffix(d):
return 'th' if 11 <= d <= 13 else {1: 'st', 2: 'nd', 3: 'rd'}.get(d % 10, 'th')
def as_fancy_str(day_):
if day_ is None:
return None
return day_.strftime("%B {S}, %Y").replace('{S}', str(day_.day) + _suffix(day_.day))
def day(timestamp):
return datetime(timestamp.year, timestamp.month, timestamp.day)
def today():
return day(datetime.now())
def day_after(day_):
return day(day_) + timedelta(days=1)
def day_before(day_):
return day(day_) - timedelta(days=1)
def is_future(day_):
return day(day_) > today()
def is_today(day_):
return day(day_) == today()

View File

@ -0,0 +1,36 @@
"""Add Accomplishement model
Revision ID: cd3b1ad8c50b
Revises: 8b55f5add4f1
Create Date: 2020-08-29 14:20:28.454292
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'cd3b1ad8c50b'
down_revision = '8b55f5add4f1'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('accomplishment',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('time', sa.DateTime(), nullable=False),
sa.Column('text', sa.String(length=128), nullable=False),
sa.Column('difficulty', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('accomplishment')
# ### end Alembic commands ###