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:
parent
294e93d1b7
commit
7265866fe2
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
40
app/db.py
40
app/db.py
|
|
@ -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)
|
||||
|
|
|
|||
78
app/main.py
78
app/main.py
|
|
@ -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():
|
||||
return render_template('index.html')
|
||||
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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) }}
|
||||
|
|
|
|||
|
|
@ -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) }}
|
||||
|
|
|
|||
|
|
@ -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) }}
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
@ -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()
|
||||
|
|
@ -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 ###
|
||||
Loading…
Reference in New Issue