7 Commits
0.0.3 ... 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
27 changed files with 1622 additions and 858 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

1
.gitignore vendored
View File

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

View File

@@ -1,7 +1,7 @@
[server]
port = 8501
address = "0.0.0.0"
enableStaticServing = true
enableStaticServing = false
[browser]
gatherUsageStats = false

View File

@@ -24,10 +24,7 @@ RUN poetry install --only=main --no-interaction --no-ansi
COPY . /app
VOLUME /app/data
RUN touch .streamlit/secrets.toml \
&& 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'
EXPOSE 8501
HEALTHCHECK --interval=60s --retries=5 CMD wget -qO- http://127.0.0.1:8501/_stcore/health || exit 1
ENTRYPOINT ["/sbin/tini", "--"]

View File

@@ -1,21 +0,0 @@
import streamlit as st
init = st.components.v2.component(
name="pwa",
isolate_styles=False,
js = """
export default function() {
// Manifest
const link = document.createElement("link");
link.rel = "manifest";
link.href = "./app/static/manifest.json";
document.head.appendChild(link);
// Service Worker
navigator.serviceWorker.register('./app/static/service-worker.js')
.then(reg => console.log('SW registered', reg))
.catch(err => console.log('SW registration failed', err));
}
""",
)

View File

@@ -1,8 +1,5 @@
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)
level = logging.INFO
logging.basicConfig(level=level, format="%(asctime)s %(levelname)s %(name)s: %(message)s")

View File

@@ -1,35 +1,40 @@
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
@st.dialog("Add New Counter", icon=":material/add_box:")
@dialog("Add New Counter", icon=":material/add_box:")
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):
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",
title = st.text_input("Title:", key="new_counter_title")
counter_type_name = st.selectbox("Type", options=[e.name for e in CounterType], key="new_counter_type")
selected_color = st.radio("Color",
key="new_counter_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)
if st.form_submit_button(label="Create", icon=":material/save:", key="create_counter_submit_btn"):
if not title:
raise ValueError("Title cannot be empty")
crud.create_counter(title, CounterType[counter_type_name], selected_color)
st.rerun()
@st.dialog("Remove Counter", icon=":material/delete:")
def _remove_counter(counter_id:int):
@dialog("Remove Counter", icon=":material/delete:")
def _remove_counter(remove_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)
if st.form_submit_button("Confirm", icon=":material/delete:", key="remove_counter_submit_btn"):
crud.remove_counter(remove_counter_id)
st.rerun()
df = sql.get_counters()
df = crud.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']):
@@ -38,7 +43,7 @@ with st.container(key="counter-table"):
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)
crud.increment_counter(counter_id)
st.rerun()
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()
match counter_type:
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_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"]
stats = weekly_stats.get_weekly_analytics(counter_id)
stats_current = stats.iloc[0]["count"]
stats_prev = stats.iloc[1]["count"]
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_prev = stats.iloc[-2]["count"]
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_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}*
""")
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.page_link("pages/stats.py",
icon=":material/bar_chart:",
icon_position="right",
label="",
query_params={"counter_id": str(counter_id)})
st.html(f"""
<style>
@@ -89,7 +95,7 @@ with st.container(key="counter-table"):
</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()

View File

@@ -1,44 +1,89 @@
import enum
import logging
import streamlit as st
import json
import sql
import pandas as pd
from enums import CounterType
from enum import StrEnum
from queries import crud, daily_stats, weekly_stats, monthly_stats, yearly_stats
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():
'''
Show specific Counter analytics where the counter id is passed as query parameter "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:
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_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()
selected_counters = [c for c in entries_full.columns if c != unit]
all_counters = crud.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)
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,8 +0,0 @@
{
"short_name": "Daily Counter",
"name": "Daily Counter",
"start_url": ".",
"display": "standalone",
"theme_color": "white",
"background_color": "white"
}

View File

@@ -1,7 +0,0 @@
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request).then(response => {
return response || fetch(event.request);
})
);
});

View File

@@ -1,21 +1,19 @@
import streamlit as st
import logging
from logger import init_logger
from styles import init_styles
from components import pwa
pwa.init()
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()
if hasattr(st, 'user') and hasattr(st.user, 'is_logged_in'):
if 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:")

View File

@@ -1,6 +1,6 @@
import streamlit as st
import sql
import queries
from queries import crud
def _load_css(filepath):
with open(filepath) as file:
@@ -8,12 +8,12 @@ def _load_css(filepath):
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()):
css_color = '#' + colors[c][0]
st.html(f"""
<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};
}}
</style>

View File

@@ -37,20 +37,20 @@
background-color: whitesmoke;
}
.st-key-color-selector div[role = "radiogroup"] {
.st-key-new_counter_color_selector div[role = "radiogroup"] {
display: flex;
flex-direction: row;
}
.st-key-color-selector div[role = "radiogroup"] > label {
.st-key-new_counter_color_selector div[role = "radiogroup"] > label {
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;
}
.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;
}
.st-key-color-selector div[role = "radiogroup"] p {
.st-key-new_counter_color_selector div[role = "radiogroup"] p {
visibility: hidden;
}

View File

@@ -1,9 +1,15 @@
#!/usr/bin/env sh
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
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 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"

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"
version = "0.1"
dynamic = ["version"]
requires-python = ">= 3.10"
requires-python = ">=3.10,<4"
dependencies = [
"alembic (==1.18.4)",
"streamlit (==1.56.0)",
"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]
in-project = true
create = true
[tool.alembic]
script_location = "%(here)s/migrations"
@@ -24,3 +32,20 @@ prepend_sys_path = [
[tool.poetry]
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