Initial import
This commit is contained in:
40
app/enums.py
Normal file
40
app/enums.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from enum import IntEnum, Enum
|
||||
|
||||
|
||||
class CounterType(IntEnum):
|
||||
SIMPLE = 1
|
||||
DAILY = 2
|
||||
WEEKLY = 3
|
||||
MONTHLY = 4
|
||||
YEARLY = 5
|
||||
|
||||
def current_unit_text(self):
|
||||
match self:
|
||||
case CounterType.DAILY:
|
||||
return 'today'
|
||||
case CounterType.WEEKLY:
|
||||
return 'this week'
|
||||
case CounterType.MONTHLY:
|
||||
return 'this month'
|
||||
case CounterType.YEARLY:
|
||||
return 'this year'
|
||||
case _:
|
||||
return 'times'
|
||||
|
||||
def previous_unit_text(self):
|
||||
match self:
|
||||
case CounterType.DAILY:
|
||||
return 'yesterday'
|
||||
case CounterType.WEEKLY:
|
||||
return 'last week'
|
||||
case CounterType.MONTHLY:
|
||||
return 'last month'
|
||||
case CounterType.YEARLY:
|
||||
return 'last year'
|
||||
case _:
|
||||
return 'times'
|
||||
|
||||
|
||||
class Tabs(Enum):
|
||||
COUNTERS ="Counters"
|
||||
STATISTICS = "Stats"
|
||||
8
app/logger.py
Normal file
8
app/logger.py
Normal file
@@ -0,0 +1,8 @@
|
||||
import logging
|
||||
import sys
|
||||
|
||||
def init_logger() -> None:
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||
stream=sys.stdout)
|
||||
96
app/pages/counters.py
Normal file
96
app/pages/counters.py
Normal file
@@ -0,0 +1,96 @@
|
||||
import streamlit as st
|
||||
|
||||
import sql
|
||||
from enums import CounterType
|
||||
|
||||
@st.dialog("Add New Counter", icon=":material/add_box:")
|
||||
def _add_counter():
|
||||
colors = sql.get_colors(1)
|
||||
with st.form(key="add_counter", border=False, clear_on_submit=True):
|
||||
title = st.text_input("Title:")
|
||||
counter_type_name = st.selectbox("Type", options=[e.name for e in CounterType])
|
||||
color = st.radio("Color",
|
||||
key="color-selector",
|
||||
width="stretch",
|
||||
options=[colors[key][0] for key in colors],
|
||||
format_func=lambda c: f"#{c}")
|
||||
with st.container(horizontal=True, width="stretch", horizontal_alignment="center"):
|
||||
if st.form_submit_button(label="Create", icon=":material/save:"):
|
||||
sql.create_counter(title, CounterType[counter_type_name], color)
|
||||
st.rerun()
|
||||
|
||||
|
||||
@st.dialog("Remove Counter", icon=":material/delete:")
|
||||
def _remove_counter(counter_id:int):
|
||||
with st.form(key="remove_counter", border=False, clear_on_submit=True):
|
||||
st.subheader("Are you sure?")
|
||||
with st.container(horizontal=True, width="stretch", horizontal_alignment="center"):
|
||||
if st.form_submit_button("Confirm", icon=":material/delete:"):
|
||||
sql.remove_counter(counter_id)
|
||||
st.rerun()
|
||||
|
||||
df = sql.get_counters()
|
||||
|
||||
with st.container(key="counter-table"):
|
||||
for counter_id, name, counter_type_str, color in zip(df['id'], df['name'], df['type'], df['color']):
|
||||
with st.container(width="stretch", key=f"counter_{counter_id}"):
|
||||
with st.container(horizontal=True, width="stretch"):
|
||||
st.header(f":material/calendar_clock: {name}", width="stretch")
|
||||
|
||||
if st.button("", icon=":material/exposure_plus_1:", key=f"increment_counter_{counter_id}"):
|
||||
sql.increment_counter(counter_id)
|
||||
st.rerun()
|
||||
|
||||
if st.button("", icon=":material/delete_forever:", key=f"remove_counter_{counter_id}"):
|
||||
_remove_counter(counter_id)
|
||||
|
||||
with st.container(horizontal=True, width="stretch"):
|
||||
|
||||
counter_type = CounterType(counter_type_str)
|
||||
stats_current_unit = counter_type.current_unit_text()
|
||||
stats_prev_unit = counter_type.previous_unit_text()
|
||||
match counter_type:
|
||||
case CounterType.DAILY.value | CounterType.SIMPLE.value:
|
||||
stats = sql.get_daily_analytics(counter_id)
|
||||
stats_current = stats.iloc[0]["count"]
|
||||
stats_prev = stats.iloc[1]["count"]
|
||||
|
||||
case CounterType.WEEKLY.value:
|
||||
stats = sql.get_weekly_analytics(counter_id)
|
||||
stats_current = stats.iloc[-1]["count"]
|
||||
stats_prev = stats.iloc[-2]["count"]
|
||||
|
||||
case CounterType.MONTHLY.value:
|
||||
stats = sql.get_monthly_analytics(counter_id)
|
||||
stats_current = stats.iloc[-1]["count"]
|
||||
stats_prev = stats.iloc[-2]["count"]
|
||||
|
||||
case CounterType.YEARLY.value:
|
||||
stats = sql.get_yearly_analytics(counter_id)
|
||||
stats_current = stats.iloc[-1]["count"]
|
||||
stats_prev = stats.iloc[-2]["count"]
|
||||
|
||||
if counter_type is CounterType.SIMPLE.value:
|
||||
st.markdown(f"**{stats_current} {stats_current_unit}**")
|
||||
else:
|
||||
st.markdown(f"""
|
||||
**{stats_current} {stats_current_unit}**
|
||||
*{stats_prev} {stats_prev_unit}*
|
||||
""")
|
||||
|
||||
with st.container(horizontal=True, width="stretch", horizontal_alignment="right"):
|
||||
st.page_link("pages/stats.py", icon=":material/bar_chart:", icon_position="right", label="", query_params={"counter_id": str(counter_id)})
|
||||
|
||||
st.html(f"""
|
||||
<style>
|
||||
div:has(> .st-key-counter_{counter_id}) {{
|
||||
background-color: {color};
|
||||
}}
|
||||
</style>
|
||||
""")
|
||||
|
||||
if st.button("Add Counter", width="stretch", icon=":material/add_box:"):
|
||||
_add_counter()
|
||||
|
||||
|
||||
|
||||
44
app/pages/stats.py
Normal file
44
app/pages/stats.py
Normal file
@@ -0,0 +1,44 @@
|
||||
import logging
|
||||
import streamlit as st
|
||||
import json
|
||||
import sql
|
||||
import pandas as pd
|
||||
|
||||
from enums import CounterType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if "counter_id" in st.query_params.keys():
|
||||
counter_id = int(st.query_params["counter_id"])
|
||||
df = sql.get_counter(counter_id)
|
||||
|
||||
st.header('Counter: ' + df['name'])
|
||||
|
||||
color ='#' + df['color']
|
||||
match df['type']:
|
||||
case CounterType.DAILY.value | CounterType.SIMPLE.value:
|
||||
st.bar_chart(sql.get_daily_analytics(int(df['id'])), x="date", y="count", color=color)
|
||||
case CounterType.WEEKLY.value:
|
||||
st.bar_chart(sql.get_weekly_analytics(int(df['id'])), x="week", y="count", color=color)
|
||||
case CounterType.MONTHLY.value:
|
||||
st.bar_chart(sql.get_monthly_analytics(int(df['id'])), x="month", y="count", color=color)
|
||||
case CounterType.YEARLY.value:
|
||||
st.bar_chart(sql.get_yearly_analytics(int(df['id'])), x="year", y="count", color=color)
|
||||
|
||||
else:
|
||||
|
||||
st.header("Statistics")
|
||||
|
||||
entries = sql.get_analytics()
|
||||
entries_norm = pd.json_normalize(entries.counters.apply(json.loads)).fillna(0)
|
||||
entries_full = pd.concat([entries, entries_norm], axis=1).drop(['counters'], axis=1)
|
||||
|
||||
selected_counters = [c for c in entries_full.columns if c != "date"]
|
||||
all_counters = sql.get_counters()
|
||||
colors = all_counters.loc[all_counters['name'].isin(selected_counters), ["name", "color"]]
|
||||
colors.name = colors.name.astype("category")
|
||||
colors.name = colors.name.cat.set_categories(selected_counters)
|
||||
colors = colors.sort_values(["name"])
|
||||
colors = colors.color.apply(lambda c: "#" + c).tolist()
|
||||
|
||||
st.bar_chart(entries_full, x="date", x_label="Date", y_label="Count", color=colors)
|
||||
229
app/sql.py
Normal file
229
app/sql.py
Normal file
@@ -0,0 +1,229 @@
|
||||
import logging
|
||||
|
||||
import streamlit as st
|
||||
|
||||
from sqlalchemy.sql import text
|
||||
from enums import CounterType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
connection = st.connection("sqlite")
|
||||
with connection.session as configure_session:
|
||||
configure_session.execute(text('PRAGMA foreign_keys=ON'))
|
||||
|
||||
|
||||
def create_counter(title:str, counter_type:CounterType, counter_color) -> None:
|
||||
with connection.session as session:
|
||||
try:
|
||||
query = text('INSERT INTO counters (name, type, color) VALUES (:title, :type, :color)')
|
||||
session.execute(query, {'title': title, 'type': counter_type, 'color': counter_color})
|
||||
session.commit()
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
session.rollback()
|
||||
|
||||
|
||||
def get_counters():
|
||||
try:
|
||||
return connection.query('SELECT id, name, type, color FROM counters', ttl=0)
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
return st.dataframe()
|
||||
|
||||
def increment_counter(counter_id:int) -> None:
|
||||
with connection.session as session:
|
||||
try:
|
||||
query = text('INSERT INTO entries (counter_id) VALUES (:id)')
|
||||
session.execute(query, {'id': counter_id})
|
||||
session.commit()
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
session.rollback()
|
||||
|
||||
|
||||
def remove_counter(counter_id:int) -> None:
|
||||
with connection.session as session:
|
||||
try:
|
||||
query = text('DELETE FROM counters WHERE id = :id')
|
||||
session.execute(query, {'id': counter_id})
|
||||
session.commit()
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
session.rollback()
|
||||
|
||||
|
||||
def get_counter(counter_id:int):
|
||||
try:
|
||||
return connection.query('SELECT * FROM counters WHERE id = :id', params={'id': counter_id}, ttl=0).iloc[0]
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
return None
|
||||
|
||||
|
||||
def get_analytics(end_date:str = 'now'):
|
||||
try:
|
||||
return connection.query('''
|
||||
WITH RECURSIVE timeseries(d) AS (
|
||||
VALUES(date(:end_date))
|
||||
UNION ALL
|
||||
SELECT date(d, '-1 day') as d
|
||||
FROM timeseries
|
||||
WHERE d > date(:end_date, '-30 days')
|
||||
),
|
||||
stats AS (
|
||||
SELECT
|
||||
date(timestamp) as d,
|
||||
counter_id,
|
||||
sum(increment) as count
|
||||
FROM entries
|
||||
group by counter_id, date(timestamp)
|
||||
)
|
||||
select
|
||||
s.d as date,
|
||||
case
|
||||
when counter_id is null then json_object()
|
||||
else json_group_object(name, count)
|
||||
end as counters
|
||||
FROM timeseries s
|
||||
left outer join stats t on s.d = t.d
|
||||
left join counters c on t.counter_id = c.id
|
||||
GROUP by s.d
|
||||
''', params={"end_date": end_date}, ttl=0)
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
return None
|
||||
|
||||
def get_daily_analytics(counter_id:int, end_date:str = 'now'):
|
||||
try:
|
||||
return connection.query('''
|
||||
WITH RECURSIVE timeseries(d) AS (
|
||||
VALUES(date(:end_date))
|
||||
UNION ALL
|
||||
SELECT date(d, '-1 day') as d
|
||||
FROM timeseries
|
||||
WHERE d > date(:end_date, '-7 days')
|
||||
),
|
||||
stats AS (
|
||||
SELECT
|
||||
date(timestamp) as d,
|
||||
sum(increment) as count
|
||||
FROM entries
|
||||
where counter_id = :id
|
||||
group by date(timestamp)
|
||||
)
|
||||
SELECT
|
||||
t.d as "date",
|
||||
coalesce(s.count, 0) as count
|
||||
FROM timeseries as t
|
||||
LEFT JOIN stats as s on s.d = t.d
|
||||
''', params={'id': counter_id, "end_date": end_date}, ttl=0)
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
return None
|
||||
|
||||
|
||||
def get_weekly_analytics(counter_id:int, end_date:str = 'now'):
|
||||
try:
|
||||
return connection.query('''
|
||||
WITH RECURSIVE timeseries(d) AS (
|
||||
VALUES(date(:end_date, 'weekday 0'))
|
||||
UNION ALL
|
||||
SELECT date(d, '-7 day')
|
||||
FROM timeseries
|
||||
WHERE d > date(:end_date, '-30 days')
|
||||
),
|
||||
weeks AS (
|
||||
SELECT strftime('%W',d) as w
|
||||
FROM timeseries
|
||||
),
|
||||
stats AS (
|
||||
SELECT
|
||||
strftime('%W', timestamp) as w,
|
||||
sum(increment) as count
|
||||
FROM entries
|
||||
where counter_id = :id
|
||||
group by strftime('%W', timestamp)
|
||||
)
|
||||
SELECT
|
||||
w.w as "week",
|
||||
coalesce(s.count, 0) as count
|
||||
FROM weeks as w
|
||||
LEFT JOIN stats as s on s.w = w.w
|
||||
''', params={'id': counter_id, "end_date": end_date}, ttl=0)
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
return None
|
||||
|
||||
def get_monthly_analytics(counter_id:int, end_date:str = 'now'):
|
||||
try:
|
||||
return connection.query('''
|
||||
WITH RECURSIVE timeseries(d) AS (
|
||||
VALUES( date(:end_date, 'start of year'))
|
||||
UNION ALL
|
||||
SELECT date(d, '+1 month')
|
||||
FROM timeseries
|
||||
WHERE d < date(:end_date, '-1 month')
|
||||
),
|
||||
months AS (
|
||||
SELECT
|
||||
strftime('%m',d) as m,
|
||||
strftime('%Y',d) as y
|
||||
FROM timeseries
|
||||
),
|
||||
stats AS (
|
||||
SELECT
|
||||
strftime('%m', timestamp) as m,
|
||||
strftime('%Y', timestamp) as y,
|
||||
sum(increment) as count
|
||||
FROM entries
|
||||
where counter_id = :id
|
||||
group by strftime('%m', timestamp), strftime('%Y', timestamp)
|
||||
)
|
||||
SELECT
|
||||
concat(m.m,', ',m.y) as "month",
|
||||
coalesce(s.count, 0) as count
|
||||
FROM months as m
|
||||
LEFT JOIN stats as s on s.m = m.m and s.y = m.y
|
||||
''', params={'id': counter_id, "end_date": end_date}, ttl=0)
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
return None
|
||||
|
||||
def get_yearly_analytics(counter_id:int, end_date:str = 'now'):
|
||||
try:
|
||||
return connection.query('''
|
||||
WITH RECURSIVE timeseries(d) AS (
|
||||
VALUES( date(:end_date, 'start of year', '-4 years'))
|
||||
UNION ALL
|
||||
SELECT date(d, '+1 year')
|
||||
FROM timeseries
|
||||
WHERE d < date(:end_date, '-1 year')
|
||||
),
|
||||
years AS (
|
||||
SELECT strftime('%Y',d) as y
|
||||
FROM timeseries
|
||||
),
|
||||
stats AS (
|
||||
SELECT
|
||||
strftime('%Y', timestamp) as y,
|
||||
sum(increment) as count
|
||||
FROM entries
|
||||
where counter_id = :id
|
||||
group by strftime('%Y', timestamp)
|
||||
)
|
||||
SELECT
|
||||
m.y as "year",
|
||||
coalesce(s.count, 0) as count
|
||||
FROM years as m
|
||||
LEFT JOIN stats as s on s.y = m.y
|
||||
''', params={'id': counter_id, "end_date": end_date}, ttl=0)
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
return None
|
||||
|
||||
def get_colors(palette_id:int):
|
||||
try:
|
||||
return connection.query('''SELECT color1,color2,color3,color4,color5 FROM color_palettes WHERE id = :id''', params={'id': palette_id})
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
return None
|
||||
20
app/streamlit_app.py
Normal file
20
app/streamlit_app.py
Normal file
@@ -0,0 +1,20 @@
|
||||
import streamlit as st
|
||||
|
||||
from logger import init_logger
|
||||
from styles import init_styles
|
||||
|
||||
init_logger()
|
||||
init_styles()
|
||||
|
||||
if st.user and not st.user.is_logged_in:
|
||||
with st.container(width="stretch", height="stretch", horizontal_alignment="center"):
|
||||
st.title("Daily Counter", width="stretch", text_alignment="center")
|
||||
st.text("Please log in to use this app", width="stretch", text_alignment="center")
|
||||
st.space()
|
||||
if st.button("Log in"):
|
||||
st.login()
|
||||
else:
|
||||
counters = st.Page("pages/counters.py", title="Counters", icon=":material/update:")
|
||||
stats = st.Page("pages/stats.py", title="Statistics", icon=":material/chart_data:")
|
||||
pg = st.navigation(position="top", pages=[counters, stats])
|
||||
pg.run()
|
||||
25
app/styles.py
Normal file
25
app/styles.py
Normal file
@@ -0,0 +1,25 @@
|
||||
import streamlit as st
|
||||
import sql
|
||||
|
||||
|
||||
def _load_css(filepath):
|
||||
with open(filepath) as file:
|
||||
st.html(f"<style>{file.read()}</style>")
|
||||
|
||||
|
||||
def _load_color_selector_styles():
|
||||
colors = sql.get_colors(1) #FIXME Change to use user profile color palette
|
||||
for idx, c in enumerate(colors.keys()):
|
||||
css_color = '#' + colors[c][0]
|
||||
st.html(f"""
|
||||
<style>
|
||||
.st-key-color-selector label:has(> input[value='{idx}']) {{
|
||||
background-color: {css_color};
|
||||
}}
|
||||
</style>
|
||||
""")
|
||||
|
||||
|
||||
def init_styles():
|
||||
_load_css("css/theme.css")
|
||||
_load_color_selector_styles()
|
||||
Reference in New Issue
Block a user