8 Commits
0.0.2 ... 0.0.5

Author SHA1 Message Date
ddbf567a19 Replace concat() with || due to sqlite3 limitation
All checks were successful
Run Tests / run-tests (push) Successful in 59s
Build & Release / build-docker-image (push) Successful in 2m12s
Build & Release / deploy-to-production (push) Successful in 8s
2026-04-25 19:07:33 +02:00
cab4ca25ee Add database tests
Some checks failed
Run Tests / run-tests (push) Failing after 31s
2026-04-25 18:52:34 +02:00
89125782d7 Consolidate pytest.ini with pyproject.toml & add test
Some checks failed
Run Tests / run-tests (push) Failing after 1m6s
Build & Release / build-docker-image (push) Successful in 4m35s
Build & Release / deploy-to-production (push) Successful in 25s
2026-04-25 13:06:29 +02:00
545841561f Add test runner
All checks were successful
Run Tests / run-tests (push) Successful in 31s
2026-04-25 12:11:27 +02:00
d84a0eed3f Add tests and fix issues 2026-04-25 10:38:38 +02:00
a0bdf9e37e Support all time resolutions on all counter views 2026-04-21 13:02:10 +02:00
0cd500e9f2 remove broken pwa feature and fix weekly analytics counter 2026-04-20 15:36:26 +02:00
c14a86b190 Add PWA manifest and SW
All checks were successful
Build & Release / build-docker-image (push) Successful in 1m51s
Build & Release / deploy-to-production (push) Successful in 8s
2026-04-10 21:08:45 +02:00
24 changed files with 1623 additions and 818 deletions

View File

@@ -0,0 +1,23 @@
name: Run Tests
on:
push: {}
env:
RUNNER_TOOL_CACHE: /toolcache
jobs:
run-tests:
runs-on: python
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ssh-key: ${{ secrets.SSH_JOHN_PRIVATE_KEY }}
- name: Install poetry
run: |
python3 -m pip install poetry==2.3.4
- name: Install the project dependencies
run: |
python3 -m poetry install
python3 -m poetry env info
- name: Run the automated tests
run: |
python3 -m poetry run python -m pytest tests

3
.gitignore vendored
View File

@@ -3,4 +3,5 @@
.venv .venv
**/*.db **/*.db
**/__pycache__/** **/__pycache__/**
.streamlit/secrets.toml .streamlit/secrets.toml
/testdb.sqlite

View File

@@ -1,12 +1,14 @@
[server] [server]
port = 8501 port = 8501
address = "0.0.0.0" address = "0.0.0.0"
enableStaticServing = false
[browser] [browser]
gatherUsageStats = false gatherUsageStats = false
[logger] [logger]
level = "info" level = "info"
hideWelcomeMessage = true
[client] [client]
toolbarMode = "viewer" toolbarMode = "viewer"

View File

@@ -24,10 +24,7 @@ RUN poetry install --only=main --no-interaction --no-ansi
COPY . /app COPY . /app
VOLUME /app/data VOLUME /app/data
RUN touch .streamlit/secrets.toml \ EXPOSE 8501
&& toml add_section --toml-path='.streamlit/secrets.toml' 'connections.sqlite' \
&& toml set --toml-path='.streamlit/secrets.toml' 'connections.sqlite.type' 'sql' \
&& toml set --toml-path='.streamlit/secrets.toml' 'connections.sqlite.url' 'sqlite:///data/daily-counter.db'
HEALTHCHECK --interval=60s --retries=5 CMD wget -qO- http://127.0.0.1:8501/_stcore/health || exit 1 HEALTHCHECK --interval=60s --retries=5 CMD wget -qO- http://127.0.0.1:8501/_stcore/health || exit 1
ENTRYPOINT ["/sbin/tini", "--"] ENTRYPOINT ["/sbin/tini", "--"]

View File

@@ -1,8 +1,5 @@
import logging import logging
import sys
def init_logger() -> None: def init_logger() -> None:
logging.basicConfig( level = logging.INFO
level=logging.INFO, logging.basicConfig(level=level, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
stream=sys.stdout)

View File

@@ -1,35 +1,40 @@
import streamlit as st import streamlit as st
import sql from streamlit import dialog
from tomlkit import key
from queries import crud, daily_stats, weekly_stats, monthly_stats, yearly_stats
from enums import CounterType from enums import CounterType
@st.dialog("Add New Counter", icon=":material/add_box:") @dialog("Add New Counter", icon=":material/add_box:")
def _add_counter(): def _add_counter():
colors = sql.get_colors(1) colors = crud.get_colors(1)
with st.form(key="add_counter", border=False, clear_on_submit=True): with st.form(key="add_counter", border=False, clear_on_submit=True):
title = st.text_input("Title:") title = st.text_input("Title:", key="new_counter_title")
counter_type_name = st.selectbox("Type", options=[e.name for e in CounterType]) counter_type_name = st.selectbox("Type", options=[e.name for e in CounterType], key="new_counter_type")
color = st.radio("Color", selected_color = st.radio("Color",
key="color-selector", key="new_counter_color_selector",
width="stretch", width="stretch",
options=[colors[key][0] for key in colors], options=[colors[key][0] for key in colors],
format_func=lambda c: f"#{c}") format_func=lambda c: f"#{c}")
with st.container(horizontal=True, width="stretch", horizontal_alignment="center"): with st.container(horizontal=True, width="stretch", horizontal_alignment="center"):
if st.form_submit_button(label="Create", icon=":material/save:"): if st.form_submit_button(label="Create", icon=":material/save:", key="create_counter_submit_btn"):
sql.create_counter(title, CounterType[counter_type_name], color) if not title:
raise ValueError("Title cannot be empty")
crud.create_counter(title, CounterType[counter_type_name], selected_color)
st.rerun() st.rerun()
@st.dialog("Remove Counter", icon=":material/delete:") @dialog("Remove Counter", icon=":material/delete:")
def _remove_counter(counter_id:int): def _remove_counter(remove_counter_id:int):
with st.form(key="remove_counter", border=False, clear_on_submit=True): with st.form(key="remove_counter", border=False, clear_on_submit=True):
st.subheader("Are you sure?") st.subheader("Are you sure?")
with st.container(horizontal=True, width="stretch", horizontal_alignment="center"): with st.container(horizontal=True, width="stretch", horizontal_alignment="center"):
if st.form_submit_button("Confirm", icon=":material/delete:"): if st.form_submit_button("Confirm", icon=":material/delete:", key="remove_counter_submit_btn"):
sql.remove_counter(counter_id) crud.remove_counter(remove_counter_id)
st.rerun() st.rerun()
df = sql.get_counters() df = crud.get_counters()
with st.container(key="counter-table"): with st.container(key="counter-table"):
for counter_id, name, counter_type_str, color in zip(df['id'], df['name'], df['type'], df['color']): for counter_id, name, counter_type_str, color in zip(df['id'], df['name'], df['type'], df['color']):
@@ -38,7 +43,7 @@ with st.container(key="counter-table"):
st.header(f":material/calendar_clock: {name}", 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}"): if st.button("", icon=":material/exposure_plus_1:", key=f"increment_counter_{counter_id}"):
sql.increment_counter(counter_id) crud.increment_counter(counter_id)
st.rerun() st.rerun()
if st.button("", icon=":material/delete_forever:", key=f"remove_counter_{counter_id}"): if st.button("", icon=":material/delete_forever:", key=f"remove_counter_{counter_id}"):
@@ -51,35 +56,36 @@ with st.container(key="counter-table"):
stats_prev_unit = counter_type.previous_unit_text() stats_prev_unit = counter_type.previous_unit_text()
match counter_type: match counter_type:
case CounterType.DAILY.value | CounterType.SIMPLE.value: case CounterType.DAILY.value | CounterType.SIMPLE.value:
stats = sql.get_daily_analytics(counter_id) stats = daily_stats.get_daily_analytics(counter_id)
stats_current = stats.iloc[0]["count"] stats_current = stats.iloc[0]["count"]
stats_prev = stats.iloc[1]["count"] stats_prev = stats.iloc[1]["count"]
case CounterType.WEEKLY.value: case CounterType.WEEKLY.value:
stats = sql.get_weekly_analytics(counter_id) stats = weekly_stats.get_weekly_analytics(counter_id)
stats_current = stats.iloc[-1]["count"] stats_current = stats.iloc[0]["count"]
stats_prev = stats.iloc[-2]["count"] stats_prev = stats.iloc[1]["count"]
case CounterType.MONTHLY.value: case CounterType.MONTHLY.value:
stats = sql.get_monthly_analytics(counter_id) stats = monthly_stats.get_monthly_analytics(counter_id)
stats_current = stats.iloc[-1]["count"] stats_current = stats.iloc[-1]["count"]
stats_prev = stats.iloc[-2]["count"] stats_prev = stats.iloc[-2]["count"]
case CounterType.YEARLY.value: case CounterType.YEARLY.value:
stats = sql.get_yearly_analytics(counter_id) stats = yearly_stats.get_yearly_analytics(counter_id)
stats_current = stats.iloc[-1]["count"] stats_current = stats.iloc[-1]["count"]
stats_prev = stats.iloc[-2]["count"] stats_prev = stats.iloc[-2]["count"]
if counter_type is CounterType.SIMPLE.value: if counter_type is CounterType.SIMPLE.value:
st.markdown(f"**{stats_current} {stats_current_unit}**") st.markdown(f"**{stats_current} {stats_current_unit}**")
else: else:
st.markdown(f""" st.markdown(f"**{stats_current} {stats_current_unit}** *{stats_prev} {stats_prev_unit}*")
**{stats_current} {stats_current_unit}**
*{stats_prev} {stats_prev_unit}*
""")
with st.container(horizontal=True, width="stretch", horizontal_alignment="right"): 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.page_link("pages/stats.py",
icon=":material/bar_chart:",
icon_position="right",
label="",
query_params={"counter_id": str(counter_id)})
st.html(f""" st.html(f"""
<style> <style>
@@ -89,7 +95,7 @@ with st.container(key="counter-table"):
</style> </style>
""") """)
if st.button("Add Counter", width="stretch", icon=":material/add_box:"): if st.button("Add Counter", width="stretch", icon=":material/add_box:", key="new_counter_button"):
_add_counter() _add_counter()

View File

@@ -1,44 +1,89 @@
import enum
import logging import logging
import streamlit as st import streamlit as st
import json import json
import sql
import pandas as pd import pandas as pd
from enums import CounterType from enums import CounterType
from enum import StrEnum
from queries import crud, daily_stats, weekly_stats, monthly_stats, yearly_stats
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
counter_type_names = ([e.name for e in CounterType])
options = counter_type_names
options.remove(CounterType.SIMPLE.name)
if "counter_id" in st.query_params.keys(): if "counter_id" in st.query_params.keys():
'''
Show specific Counter analytics where the counter id is passed as query parameter "counter_id".
'''
counter_id = int(st.query_params["counter_id"]) counter_id = int(st.query_params["counter_id"])
df = sql.get_counter(counter_id) df = crud.get_counter(counter_id)
st.header('Counter: ' + df['name']) counter_type_id = df['type'] - 1
counter_type = [e for e in CounterType][counter_type_id]
counter_color ='#' + df['color']
with st.container(horizontal_alignment="right", vertical_alignment="bottom", horizontal=True):
st.header('Counter: ' + df['name'])
selection = st.segmented_control("Time Range", options, selection_mode="single", required=True, default=counter_type.name, label_visibility="hidden")
match getattr(CounterType, selection):
case CounterType.DAILY:
st.bar_chart(daily_stats.get_daily_analytics(counter_id), x="date", y="count", color=counter_color)
case CounterType.WEEKLY:
st.bar_chart(weekly_stats.get_weekly_analytics(counter_id), x="week", y="count", color=counter_color)
case CounterType.MONTHLY:
st.bar_chart(monthly_stats.get_monthly_analytics(counter_id), x="month", y="count", color=counter_color)
case CounterType.YEARLY:
st.bar_chart(yearly_stats.get_yearly_analytics(counter_id), x="year", y="count", color=counter_color)
case _:
logger.error(f"Unknown selection: {selection}")
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: else:
st.header("Statistics") '''
By default, if no counter id is passed then show all counters in a a stacked graph
'''
with st.container(horizontal_alignment="right", vertical_alignment="bottom", horizontal=True):
st.header("Statistics")
selection = st.segmented_control("Time range", options, selection_mode="single", default=f"{CounterType.DAILY.name}", required=True, label_visibility="hidden")
selectedRange = getattr(CounterType, selection)
match getattr(CounterType, selection):
case CounterType.DAILY:
unit = 'date'
unit_label = 'Date'
entries = daily_stats.get_all_daily_analytics()
case CounterType.WEEKLY:
unit = 'week'
unit_label ='Week'
entries = weekly_stats.get_all_weekly_analytics()
case CounterType.MONTHLY:
unit = 'month'
unit_label = 'Month'
entries = monthly_stats.get_all_monthly_analytics()
case CounterType.YEARLY:
unit = 'year'
unit_label = 'Year'
entries = yearly_stats.get_all_yearly_analytics()
case _:
logger.error(f"Unknown selection: {selection}")
entries = sql.get_analytics()
entries_norm = pd.json_normalize(entries.counters.apply(json.loads)).fillna(0) 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) 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"] selected_counters = [c for c in entries_full.columns if c != unit]
all_counters = sql.get_counters() all_counters = crud.get_counters()
colors = all_counters.loc[all_counters['name'].isin(selected_counters), ["name", "color"]] colors = all_counters.loc[all_counters['name'].isin(selected_counters), ["name", "color"]]
colors.name = colors.name.astype("category") colors.name = colors.name.astype("category")
colors.name = colors.name.cat.set_categories(selected_counters) colors.name = colors.name.cat.set_categories(selected_counters)
colors = colors.sort_values(["name"]) colors = colors.sort_values(["name"])
colors = colors.color.apply(lambda c: "#" + c).tolist() colors = colors.color.apply(lambda c: "#" + c).tolist()
st.bar_chart(entries_full, x="date", x_label="Date", y_label="Count", color=colors) st.bar_chart(entries_full, x=unit, x_label=unit_label, y_label="Count", color=colors)

13
app/queries/connection.py Normal file
View File

@@ -0,0 +1,13 @@
from os import getenv
import streamlit as st
from sqlalchemy.sql import text
from streamlit.connections import BaseConnection
def connection() -> BaseConnection:
_connection = st.connection("sql", url=getenv('DATABASE_URL'))
with _connection.session as configured_session:
configured_session.execute(text('PRAGMA foreign_keys=ON'))
return _connection

62
app/queries/crud.py Normal file
View File

@@ -0,0 +1,62 @@
import logging
import streamlit as st
from sqlalchemy.sql import text
from queries.connection import connection
from enums import CounterType
logger = logging.getLogger(__name__)
def create_counter(title:str, counter_type:CounterType, counter_color) -> None:
logger.info("Adding counter %s", counter_type)
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:
logger.info("Incrementing counter %s", counter_id)
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:
logger.info("Removing counter %s", counter_id)
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_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

View File

@@ -0,0 +1,66 @@
import logging
from queries.connection import connection
logger = logging.getLogger(__name__)
def get_all_daily_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

View File

@@ -0,0 +1,79 @@
import logging
from queries.connection import connection
logger = logging.getLogger(__name__)
def get_all_monthly_analytics(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') as d
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,
counter_id,
sum(increment) as count
FROM entries
group by counter_id, strftime('%m', timestamp), strftime('%Y', timestamp)
)
select
m.m || ', ' || m.y as "month",
case
when counter_id is null then json_object()
else json_group_object(name, count)
end as counters
FROM months m
left outer join stats t on m.m = t.m and m.y = t.y
left join counters c on t.counter_id = c.id
GROUP by m.m, m.y
''', params={"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
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

View File

@@ -0,0 +1,73 @@
import logging
from queries.connection import connection
logger = logging.getLogger(__name__)
def get_all_weekly_analytics(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') as d
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,
counter_id,
sum(increment) as count
FROM entries
group by counter_id, strftime('%W', timestamp)
)
select
s.w as week,
case
when counter_id is null then json_object()
else json_group_object(name, count)
end as counters
FROM weeks s
left outer join stats t on s.w = t.w
left join counters c on t.counter_id = c.id
GROUP by s.w
''', params={"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

View File

@@ -0,0 +1,73 @@
import logging
from queries.connection import connection
logger = logging.getLogger(__name__)
def get_all_yearly_analytics(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') as d
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,
counter_id,
sum(increment) as count
FROM entries
group by counter_id, strftime('%Y', timestamp)
)
select
y.y as "year",
case
when counter_id is null then json_object()
else json_group_object(name, count)
end as counters
FROM years y
left outer join stats t on y.y = t.y
left join counters c on t.counter_id = c.id
GROUP by y.y
''', params={"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

View File

@@ -1,229 +0,0 @@
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

View File

@@ -1,18 +1,19 @@
import streamlit as st import streamlit as st
import logging
from logger import init_logger from logger import init_logger
from styles import init_styles from styles import init_styles
init_logger() init_logger()
init_styles() init_styles()
if st.user and not st.user.is_logged_in: if hasattr(st, 'user') and hasattr(st.user, 'is_logged_in'):
with st.container(width="stretch", height="stretch", horizontal_alignment="center"): if not st.user.is_logged_in:
st.title("Daily Counter", width="stretch", text_alignment="center") with st.container(width="stretch", height="stretch", horizontal_alignment="center"):
st.text("Please log in to use this app", width="stretch", text_alignment="center") st.title("Daily Counter", width="stretch", text_alignment="center")
st.space() st.text("Please log in to use this app", width="stretch", text_alignment="center")
if st.button("Log in"): st.space()
st.login() if st.button("Log in"):
st.login()
else: else:
counters = st.Page("pages/counters.py", title="Counters", icon=":material/update:") counters = st.Page("pages/counters.py", title="Counters", icon=":material/update:")
stats = st.Page("pages/stats.py", title="Statistics", icon=":material/chart_data:") stats = st.Page("pages/stats.py", title="Statistics", icon=":material/chart_data:")

View File

@@ -1,6 +1,6 @@
import streamlit as st import streamlit as st
import sql import queries
from queries import crud
def _load_css(filepath): def _load_css(filepath):
with open(filepath) as file: with open(filepath) as file:
@@ -8,12 +8,12 @@ def _load_css(filepath):
def _load_color_selector_styles(): def _load_color_selector_styles():
colors = sql.get_colors(1) #FIXME Change to use user profile color palette colors = crud.get_colors(1) #FIXME Change to use user profile color palette
for idx, c in enumerate(colors.keys()): for idx, c in enumerate(colors.keys()):
css_color = '#' + colors[c][0] css_color = '#' + colors[c][0]
st.html(f""" st.html(f"""
<style> <style>
.st-key-color-selector label:has(> input[value='{idx}']) {{ .st-key-new_counter_color_selector label:has(> input[value='{idx}']) {{
background-color: {css_color}; background-color: {css_color};
}} }}
</style> </style>

View File

@@ -37,20 +37,20 @@
background-color: whitesmoke; background-color: whitesmoke;
} }
.st-key-color-selector div[role = "radiogroup"] { .st-key-new_counter_color_selector div[role = "radiogroup"] {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
} }
.st-key-color-selector div[role = "radiogroup"] > label { .st-key-new_counter_color_selector div[role = "radiogroup"] > label {
flex: 1 flex: 1
} }
.st-key-color-selector div[role = "radiogroup"] > label > div:first-child { .st-key-new_counter_color_selector div[role = "radiogroup"] > label > div:first-child {
display: none; display: none;
} }
.st-key-color-selector div[role = "radiogroup"] > label:has(> input[tabindex="0"]) { .st-key-new_counter_color_selector div[role = "radiogroup"] > label:has(> input[tabindex="0"]) {
outline: 3px solid blue; outline: 3px solid blue;
} }
.st-key-color-selector div[role = "radiogroup"] p { .st-key-new_counter_color_selector div[role = "radiogroup"] p {
visibility: hidden; visibility: hidden;
} }

View File

@@ -1,9 +1,15 @@
#!/usr/bin/env sh #!/usr/bin/env sh
STREAMLIT_SECRETS_LOCATION=".streamlit/secrets.toml" STREAMLIT_SECRETS_LOCATION=".streamlit/secrets.toml"
touch STREAMLIT_SECRETS_LOCATION
SQLITE_DATABASE="/data/daily-counter.db"
SQLITE_DATABASE_URL="sqlite://$SQLITE_DATABASE"
export DATABASE_URL="$SQLITE_DATABASE_URL"
echo "INFO [entrypoint] Using SQLite database at $SQLITE_DATABASE"
if [ "$OIDC_ENABLED" = "true" ]; then if [ "$OIDC_ENABLED" = "true" ]; then
echo "INFO [entrypoint] OIDC configuration detected. Configuring app..." echo "INFO [entrypoint] OIDC configuration detected. Configuring authentication..."
toml add_section --toml-path=$STREAMLIT_SECRETS_LOCATION 'auth' toml add_section --toml-path=$STREAMLIT_SECRETS_LOCATION 'auth'
toml set --toml-path=$STREAMLIT_SECRETS_LOCATION 'auth.redirect_uri' "$OIDC_PUBLIC_URL/oauth2callback" toml set --toml-path=$STREAMLIT_SECRETS_LOCATION 'auth.redirect_uri' "$OIDC_PUBLIC_URL/oauth2callback"
toml set --toml-path=$STREAMLIT_SECRETS_LOCATION 'auth.cookie_secret' "$OIDC_COOKIE_SECRET" toml set --toml-path=$STREAMLIT_SECRETS_LOCATION 'auth.cookie_secret' "$OIDC_COOKIE_SECRET"

1245
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,16 +3,24 @@ name = "daily-counter"
description = "A daily counter for any habbit tracking" description = "A daily counter for any habbit tracking"
version = "0.1" version = "0.1"
dynamic = ["version"] dynamic = ["version"]
requires-python = ">= 3.10" requires-python = ">=3.10,<4"
dependencies = [ dependencies = [
"alembic (==1.18.4)", "alembic (==1.18.4)",
"streamlit (==1.56.0)", "streamlit (==1.56.0)",
"toml-cli (==0.8.2)", "toml-cli (==0.8.2)",
"authlib (==1.6.9)" "authlib (==1.6.9)",
"sqlalchemy (>=2.0.49,<3.0.0)",
"pytest-alembic (>=0.12.1,<0.13.0)",
"pytest-env (>=1.6.0,<2.0.0)"
] ]
[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"
[virtualenvs] [virtualenvs]
in-project = true in-project = true
create = true
[tool.alembic] [tool.alembic]
script_location = "%(here)s/migrations" script_location = "%(here)s/migrations"
@@ -23,4 +31,21 @@ prepend_sys_path = [
] ]
[tool.poetry] [tool.poetry]
package-mode = false package-mode = false
[tool.poetry.group.dev.dependencies]
pytest = ">=9.0"
[tool.poetry.dependencies]
python = ">=3.10,<4"
[tool.pytest.ini_options]
log_cli = true
log_cli_level = "WARNING"
log_cli_format = "%(asctime)s [%(levelname)8s] %(message)s (%(filename)s:%(lineno)s)"
log_cli_date_format="%Y-%m-%d %H:%M:%S"
pythonpath = "./app"
env = [
"DATABASE_FILE=testdb.sqlite",
"DATABASE_URL=sqlite:///testdb.sqlite?cache=shared"
]

34
tests/conftest.py Normal file
View File

@@ -0,0 +1,34 @@
import logging
import os
import pytest
from alembic import config
from pytest_alembic.config import Config
from streamlit.testing.v1 import AppTest
logger = logging.getLogger(__name__)
@pytest.fixture(autouse=True)
def setup_database(alembic_runner):
logger.info("Running database migrations")
alembic_runner.migrate_up_to('heads')
yield
logger.info("Resetting database")
alembic_runner.migrate_down_to('base')
@pytest.fixture
def alembic_config() -> Config:
logging.info("Setting up alembic config")
alembic_cfg = config.Config(toml_file="pyproject.toml")
alembic_cfg.set_main_option("sqlalchemy.url", os.getenv("DATABASE_URL", ""))
return Config(alembic_config=alembic_cfg)
@pytest.fixture
def app() -> AppTest:
return AppTest.from_file("app/streamlit_app.py")
def delete_database():
file = os.getenv("DATABASE_FILE")
if file and os.path.isfile(file):
logger.info(f"Deleting database file {file}")
os.remove(file)

View File

@@ -0,0 +1,39 @@
import queries.crud as crud
import queries.daily_stats as stats
from enums import CounterType
def test_create_counter():
crud.create_counter("Test", CounterType.SIMPLE, "020122")
counters = crud.get_counters()
assert len(counters) == 1
assert counters["id"][0] == 1
assert counters["name"][0] == "Test"
assert counters["type"][0] == CounterType.SIMPLE
assert counters["color"][0] == '020122'
def test_remove_counter():
crud.create_counter("Test", CounterType.SIMPLE, "020122")
assert len(crud.get_counters()) == 1
crud.remove_counter(1)
assert len(crud.get_counters()) == 0
def test_increment_counter():
crud.create_counter("Test", CounterType.SIMPLE, "020122")
assert len(crud.get_counters()) == 1
crud.increment_counter(1)
daily_stats = stats.get_daily_analytics(1)
assert daily_stats["count"][0] == 1
crud.increment_counter(1)
daily_stats = stats.get_daily_analytics(1)
assert daily_stats["count"][0] == 2

View File

@@ -0,0 +1,195 @@
import json
import queries.crud as crud
import queries.daily_stats as daily_stats
import queries.monthly_stats as monthly_stats
import queries.yearly_stats as yearly_stats
import queries.weekly_stats as weekly_stats
from enums import CounterType
from queries.connection import connection
from sqlalchemy.sql.expression import text
def test_all_daily_stats():
crud.create_counter("Test", CounterType.SIMPLE, "020122")
crud.create_counter("Test2", CounterType.SIMPLE, "020122")
with connection().session as session:
query = text("""
INSERT INTO entries (counter_id, "timestamp", increment)
VALUES
(1, date(), 1),
(1, date(date(), '-1 days'), 2),
(1, date(date(), '-3 days'), 3),
(2, date(), 2),
(2, date(date(), '-1 days'), 4),
(2, date(date(), '-3 days'), 6)
""")
session.execute(query)
session.commit()
stats = daily_stats.get_all_daily_analytics()
assert json.loads(stats[::-1]["counters"].iloc[0])["Test"] == 1
assert json.loads(stats[::-1]["counters"].iloc[0])["Test2"] == 2
assert json.loads(stats[::-1]["counters"].iloc[1])["Test"] == 2
assert json.loads(stats[::-1]["counters"].iloc[1])["Test2"] == 4
assert len(json.loads(stats[::-1]["counters"].iloc[2]).keys()) == 0
assert json.loads(stats[::-1]["counters"].iloc[3])["Test"] == 3
assert json.loads(stats[::-1]["counters"].iloc[3])["Test2"] == 6
def test_daily_stats():
crud.create_counter("Test", CounterType.SIMPLE, "020122")
with connection().session as session:
query = text("""
INSERT INTO entries (counter_id, "timestamp", increment)
VALUES
(1, date(), 1),
(1, date(date(), '-1 days'), 2),
(1, date(date(), '-3 days'), 3)
""")
session.execute(query)
session.commit()
stats = daily_stats.get_daily_analytics(1)
assert stats["count"][0] == 1
assert stats["count"][1] == 2
assert stats["count"][2] == 0
assert stats["count"][3] == 3
def test_all_monthly_stats():
crud.create_counter("Test", CounterType.SIMPLE, "020122")
crud.create_counter("Test2", CounterType.SIMPLE, "020122")
with connection().session as session:
query = text("""
INSERT INTO entries (counter_id, "timestamp", increment)
VALUES
(1, date(), 1),
(1, date(date(), '-1 month'), 2),
(1, date(date(), '-3 months'), 3),
(2, date(), 2),
(2, date(date(), '-2 months'), 4),
(2, date(date(), '-3 months'), 6)
""")
session.execute(query)
session.commit()
stats = monthly_stats.get_all_monthly_analytics()
assert json.loads(stats[::-1]["counters"].iloc[0])["Test"] == 1
assert json.loads(stats[::-1]["counters"].iloc[0])["Test2"] == 2
assert json.loads(stats[::-1]["counters"].iloc[1])["Test"] == 2
assert json.loads(stats[::-1]["counters"].iloc[2])["Test2"] == 4
assert json.loads(stats[::-1]["counters"].iloc[3])["Test"] == 3
assert json.loads(stats[::-1]["counters"].iloc[3])["Test2"] == 6
def test_monthly_stats():
crud.create_counter("Test", CounterType.SIMPLE, "020122")
with connection().session as session:
query = text("""
INSERT INTO entries (counter_id, "timestamp", increment)
VALUES
(1, date(), 1),
(1, date(date(), '-1 months'), 2),
(1, date(date(), '-3 months'), 3)
""")
session.execute(query)
session.commit()
stats = monthly_stats.get_monthly_analytics(1)
assert stats[::-1]["count"].iloc[0] == 1
assert stats[::-1]["count"].iloc[1] == 2
assert stats[::-1]["count"].iloc[2] == 0
assert stats[::-1]["count"].iloc[3] == 3
def test_all_yearly_stats():
crud.create_counter("Test", CounterType.SIMPLE, "020122")
crud.create_counter("Test2", CounterType.SIMPLE, "020122")
with connection().session as session:
query = text("""
INSERT INTO entries (counter_id, "timestamp", increment)
VALUES
(1, date(), 1),
(1, date(date(), '-1 year'), 2),
(1, date(date(), '-3 years'), 3),
(2, date(), 2),
(2, date(date(), '-2 years'), 4),
(2, date(date(), '-3 years'), 6)
""")
session.execute(query)
session.commit()
stats = yearly_stats.get_all_yearly_analytics()
assert json.loads(stats[::-1]["counters"].iloc[0])["Test"] == 1
assert json.loads(stats[::-1]["counters"].iloc[0])["Test2"] == 2
assert json.loads(stats[::-1]["counters"].iloc[1])["Test"] == 2
assert json.loads(stats[::-1]["counters"].iloc[2])["Test2"] == 4
assert json.loads(stats[::-1]["counters"].iloc[3])["Test"] == 3
assert json.loads(stats[::-1]["counters"].iloc[3])["Test2"] == 6
def test_yearly_stats():
crud.create_counter("Test", CounterType.SIMPLE, "020122")
with connection().session as session:
query = text("""
INSERT INTO entries (counter_id, "timestamp", increment)
VALUES
(1, date(), 1),
(1, date(date(), '-1 years'), 2),
(1, date(date(), '-3 years'), 3)
""")
session.execute(query)
session.commit()
stats = yearly_stats.get_yearly_analytics(1)
assert stats[::-1]["count"].iloc[0] == 1
assert stats[::-1]["count"].iloc[1] == 2
assert stats[::-1]["count"].iloc[2] == 0
assert stats[::-1]["count"].iloc[3] == 3
def test_all_weekly_stats():
crud.create_counter("Test", CounterType.SIMPLE, "020122")
crud.create_counter("Test2", CounterType.SIMPLE, "020122")
with connection().session as session:
query = text("""
INSERT INTO entries (counter_id, "timestamp", increment)
VALUES
(1, date(), 1),
(1, date(date(), '-7 days'), 2),
(1, date(date(), '-21 days'), 3),
(2, date(), 2),
(2, date(date(), '-14 days'), 4),
(2, date(date(), '-21 days'), 6)
""")
session.execute(query)
session.commit()
stats = weekly_stats.get_all_weekly_analytics()
assert json.loads(stats[::-1]["counters"].iloc[0])["Test"] == 1
assert json.loads(stats[::-1]["counters"].iloc[0])["Test2"] == 2
assert json.loads(stats[::-1]["counters"].iloc[1])["Test"] == 2
assert json.loads(stats[::-1]["counters"].iloc[2])["Test2"] == 4
assert json.loads(stats[::-1]["counters"].iloc[3])["Test"] == 3
assert json.loads(stats[::-1]["counters"].iloc[3])["Test2"] == 6
def test_weekly_stats():
crud.create_counter("Test", CounterType.SIMPLE, "020122")
with connection().session as session:
query = text("""
INSERT INTO entries (counter_id, "timestamp", increment)
VALUES
(1, date(), 1),
(1, date(date(), '-7 days'), 2),
(1, date(date(), '-21 days'), 3)
""")
session.execute(query)
session.commit()
stats = weekly_stats.get_weekly_analytics(1)
assert stats["count"][0] == 1
assert stats["count"][1] == 2
assert stats["count"][2] == 0
assert stats["count"][3] == 3

View File

@@ -0,0 +1,78 @@
import queries.crud
from enums import CounterType
def test_initial_state(app):
app.run()
assert not app.exception
assert not app.error
assert len(app.header) == 0 # No counter currently present
def test_add_counter(app):
app.run()
# Open new counter dialog
app.button(key="new_counter_button").click().run()
# Fill in details and submit
app.text_input(key="new_counter_title").set_value("Walk")
app.selectbox(key='new_counter_type').select(CounterType.DAILY.name)
app.radio(key='new_counter_color_selector').set_value("020122")
app.button(key="create_counter_submit_btn").click()
app.run()
assert not app.exception
assert not app.error
assert len(app.text_input) == 0 # dialog closed, back in the main screen
# Simulate button listener due to bug https://github.com/streamlit/streamlit/issues/9786
queries.crud.create_counter("Walk", CounterType.DAILY, "020122")
app.run()
assert len(app.header) == 1 # A new counter was added
assert app.header[0].value == ":material/calendar_clock: Walk"
def test_remove_counter(app):
# Create a counter to remove
queries.crud.create_counter("Remove me", CounterType.SIMPLE, "020122")
app.run()
assert not app.exception
assert not app.error
assert len(app.header) == 1 # One counter exists
# Remove the counter
app.button("remove_counter_1").click().run()
# Confirmation
assert app.subheader[0].value == 'Are you sure?'
app.button(key="remove_counter_submit_btn").click()
app.run()
assert len(app.text_input) == 0 # dialog closed, back in the main screen
# Simulate button listener due to bug https://github.com/streamlit/streamlit/issues/9786
queries.crud.remove_counter(1)
app.run()
assert len(app.header) == 0 # No counter exists
def test_increment_counter(app):
# Create a counter to increment
queries.crud.create_counter("Remove me", CounterType.SIMPLE, "020122")
app.run()
assert not app.exception
assert not app.error
assert len(app.header) == 1 # One counter exists
assert "0 times" in app.markdown[0].value # Counter is 0
app.button(key="increment_counter_1").click().run()
assert "1 times" in app.markdown[0].value # Counter is 1