17 Commits
0.0.2 ... 0.1.1

Author SHA1 Message Date
2efe8b05b0 Require tests pass before publishing new version
All checks were successful
Run Tests / run-tests (push) Successful in 39s
2026-05-02 13:34:28 +02:00
450533414e Use python from venv
All checks were successful
Run Tests / run-tests (push) Successful in 1m15s
Build & Release / build-docker-image (push) Successful in 2m21s
Build & Release / deploy-to-production (push) Successful in 26s
2026-05-02 12:22:30 +02:00
d665d8e1a0 Revert "Use python from venv"
This reverts commit c1a6dafa9f.
2026-05-01 20:22:45 +02:00
c1a6dafa9f Use python from venv
Some checks failed
Run Tests / run-tests (push) Failing after 39s
2026-05-01 20:21:31 +02:00
0632899f7a Support demo users
Some checks failed
Run Tests / run-tests (push) Failing after 1m2s
2026-05-01 17:41:42 +02:00
a88d1b4e79 Add theme selector
Some checks failed
Run Tests / run-tests (push) Failing after 39s
2026-04-30 21:31:19 +02:00
8ae8bc7a24 Add user color settings
Some checks failed
Run Tests / run-tests (push) Failing after 1m3s
2026-04-30 14:17:09 +02:00
bd9ff7191a Add user specific profiles
Some checks failed
Run Tests / run-tests (push) Failing after 58s
2026-04-28 21:04:52 +02:00
f750cfa8e1 Cleanup config files 2026-04-27 17:23:26 +02:00
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
36 changed files with 2303 additions and 858 deletions

View File

@@ -1,8 +1,9 @@
name: Build & Release
on:
push:
tags:
- '[0-9]+.[0-9]+.[0-9]+'
workflow_run:
workflows: [ "Run Tests" ]
types:
- completed
env:
ENDPOINT: services-3
@@ -15,6 +16,7 @@ env:
jobs:
build-docker-image:
if: ${{ gitea.event.workflow_run.conclusion == 'success' && gitea.ref_type == 'tag' }}
runs-on: node20
container:
image: catthehacker/ubuntu:act-24.04

View File

@@ -0,0 +1,51 @@
name: Run Tests
on:
push: {}
workflow_dispatch:
env:
RUNNER_TOOL_CACHE: /toolcache
jobs:
run-tests:
runs-on: python
container:
image: catthehacker/ubuntu:act-24.04
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ssh-key: ${{ secrets.SSH_JOHN_PRIVATE_KEY }}
- name: Setup Python
id: setup_python
uses: actions/setup-python@v5
with:
python-version-file: 'pyproject.toml'
- name: Restore cached virtualenv
uses: actions/cache/restore@v4
with:
key: venv-${{ runner.os }}-${{ steps.setup_python.outputs.python-version }}-${{ hashFiles('pyproject.toml') }}
path: .venv
- name: Create virtual env
run: |
python3 -m venv .venv
. .venv/bin/activate
which python
python --version
python -m sqlite3 --version
- name: Install poetry
run: |
. .venv/bin/activate
pip install poetry==2.3.4
- name: Install the project dependencies
run: |
. .venv/bin/activate
poetry install
poetry env info
- name: Cache virtual env
uses: actions/cache/save@v4
with:
key: venv-${{ runner.os }}-${{ steps.setup_python.outputs.python-version }}-${{ hashFiles('pyproject.toml') }}
path: .venv
- name: Run the automated tests
run: |
. .venv/bin/activate
poetry run pytest tests

1
.gitignore vendored
View File

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

View File

@@ -1,23 +1,18 @@
[server]
port = 8501
address = "0.0.0.0"
enableStaticServing = false
[browser]
gatherUsageStats = false
[logger]
level = "info"
hideWelcomeMessage = true
[client]
toolbarMode = "viewer"
showSidebarNavigation = false
[theme]
base="light"
backgroundColor = "#eee"
secondaryBackgroundColor = "#fff"
primaryColor = "black"
baseRadius = "none"
[runner]
magicEnabled = false

View File

@@ -13,6 +13,9 @@ ENV PYTHONFAULTHANDLER=1 \
POETRY_CACHE_DIR='/var/cache/pypoetry' \
POETRY_HOME='/usr/local'
RUN apk update && \
apk upgrade && \
rm -rf /var/cache/apk/*
RUN apk add --no-cache tini
RUN pip install -Iv --no-cache-dir "poetry==${POETRY_VERSION}"
@@ -24,10 +27,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

@@ -21,11 +21,12 @@ This is a simple habit tracking web app that allows the user to track occurrence
**Pre-requisites:** [Pip](https://pypi.org/project/pip/) and [Poetry](https://pypi.org/project/poetry/)
1. Clone this repository
2. Install Poetry ``pip install -Iv --no-cache-dir "poetry==2.3.3"``
3. Install project dependencies ``poetry install``
4. Run database migrations ``alembic upgrade head``
5. Run project ``streamlit run app``
6. Project will be running at http://localhost:8501
2. Create a virtual environment with ``python3 -m venv ./.venv``
3. Activate virtual environment with ``source .venv/bin/activate``
4. Install project dependencies ``poetry install``
5. Run database migrations ``alembic upgrade head``
6. Run project ``streamlit run app``
7. Project will be running at http://localhost:8501
## Run project (Locally with Docker)

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()
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()

67
app/pages/settings.py Normal file
View File

@@ -0,0 +1,67 @@
import streamlit as st
import queries.crud as crud
import themes as th
from user import is_logged_in, is_login_enabled
st.title("Settings")
if hasattr(st.session_state, 'user_name') and st.session_state.user_name:
st.markdown(f"Currently logged in as **{st.session_state.user_name}**")
st.header("Theme")
themes = st.session_state.themes
with st.container(horizontal=True, width="stretch"):
for theme in themes.keys():
if st.button(label=themes[theme]["button_face_label"],
icon = themes[theme]["button_face_icon"],
disabled = (theme == st.session_state.current_theme),
width = "stretch"):
th.change_theme(theme)
st.rerun()
st.header("Colors")
with st.container(key="settings-color-selector"):
palettes = crud.get_color_palettes()
selected = crud.get_color_palette()
for palette in palettes.iterrows():
id = palette[1]['id']
name = palette[1]['name']
color1 = palette[1]['color1']
color2 = palette[1]['color2']
color3 = palette[1]['color3']
color4 = palette[1]['color4']
if selected == id:
with st.container(horizontal=True, key=f"settings-color-selector-selected"):
st.button(f"{name} **(selected)**", disabled=True, width="stretch", icon=":material/radio_button_checked:")
st.html(f"""
<span class="settings-color-selector-colors">
<span style="background-color:#{color1}">&nbsp;</span>
<span style="background-color:#{color2}">&nbsp;</span>
<span style="background-color:#{color3}">&nbsp;</span>
<span style="background-color:#{color4}">&nbsp;</span>
</span>
""", width=400)
else:
with st.container(horizontal=True):
if st.button(f"{name}", width="stretch", icon=":material/radio_button_unchecked:"):
crud.set_color_palette(id)
st.rerun()
st.html(f"""
<span class="settings-color-selector-colors">
<span style="background-color:#{color1}">&nbsp;</span>
<span style="background-color:#{color2}">&nbsp;</span>
<span style="background-color:#{color3}">&nbsp;</span>
<span style="background-color:#{color4}">&nbsp;</span>
</span>
""", width=400)
if is_login_enabled():
st.header("Actions")
with st.container():
if is_logged_in():
if st.button("Logout", icon=":material/logout:", width="stretch"):
st.logout()

View File

@@ -1,44 +1,92 @@
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'])
selected = counter_type.name
if selected == CounterType.SIMPLE.name:
selected = CounterType.DAILY.name
selection = st.segmented_control("Time Range", options, selection_mode="single", required=True, default=selected, 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)

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

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

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

@@ -0,0 +1,149 @@
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:
user_id = int(st.session_state.user_id)
logger.info("Adding counter %s for user %d", counter_type, user_id)
with connection().session as session:
try:
query = text('INSERT INTO counters (user_id, name, type, color) VALUES (:user, :title, :type, :color)')
session.execute(query, {
'user': user_id,
'title': title,
'type': counter_type,
'color': counter_color
})
except Exception as e:
logger.error(e)
session.rollback()
def get_counters():
user_id = int(st.session_state.user_id)
try:
return connection().query("""
SELECT id, name, type, color
FROM counters
WHERE user_id = :user
""", params={'user': user_id })
except Exception as e:
logger.error(e)
return st.dataframe()
def increment_counter(counter_id:int) -> None:
user_id = int(st.session_state.user_id)
logger.info("Incrementing counter %d for user %d", counter_id, user_id)
with connection().session as session:
try:
query = text('INSERT INTO entries (counter_id, user_id) VALUES (:id, :user)')
session.execute(query, {
'id': counter_id,
'user': user_id
})
except Exception as e:
logger.error(e)
session.rollback()
def remove_counter(counter_id:int) -> None:
user_id = int(st.session_state.user_id)
logger.info("Removing counter %d from user %d", counter_id, user_id)
with connection().session as session:
try:
query = text('DELETE FROM counters WHERE id = :id AND user_id = :user')
session.execute(query, {
'id': counter_id,
'user': user_id
})
except Exception as e:
logger.error(e)
session.rollback()
def get_counter(counter_id:int):
user_id = int(st.session_state.user_id)
try:
counters = connection().query("""
SELECT * FROM counters
WHERE id = :id AND user_id = :user
""", params={ 'id': counter_id, 'user': user_id}
)
if counters.empty:
return None
return counters.iloc[0]
except Exception as e:
logger.error(e)
return None
def get_color_palettes():
try:
return connection().query('SELECT * FROM color_palettes''')
except Exception as e:
logger.error(e)
return None
def get_color_palette() -> int:
user_id = int(st.session_state.user_id)
try:
return int(connection().query('SELECT color_palette_id FROM users WHERE id = :id''', params={'id': user_id})['color_palette_id'][0])
except Exception as e:
logger.error(e)
return None
def set_color_palette(palette_id:int):
user_id = int(st.session_state.user_id)
logger.info("Changing palette for user %d to %d", user_id, palette_id)
with connection().session as session:
try:
query = text('UPDATE users SET color_palette_id = :palette WHERE id = :user')
session.execute(query, {
'palette': palette_id,
'user': user_id
})
except Exception as e:
logger.error(e)
session.rollback()
def get_colors():
user_id = int(st.session_state.user_id)
try:
return connection().query('''
SELECT color1,color2,color3,color4,color5
FROM users u
LEFT JOIN color_palettes p ON p.id = u.color_palette_id
WHERE u.id = :id
''', params={'id': user_id})
except Exception as e:
logger.error(e)
return None
def set_theme(theme:str):
user_id = int(st.session_state.user_id)
logger.info("Changing theme for user %d to %s", user_id, theme)
with connection().session as session:
try:
query = text('UPDATE users SET theme = :theme WHERE id = :user')
session.execute(query, {
'theme': theme,
'user': user_id
})
except Exception as e:
logger.error(e)
session.rollback()
def get_theme() -> str:
user_id = int(st.session_state.user_id)
try:
return connection().query('SELECT theme FROM users u WHERE u.id = :id', params={'id': user_id})['theme'][0]
except Exception as e:
logger.error(e)
return None

View File

@@ -0,0 +1,71 @@
import logging
from queries.connection import connection
import streamlit as st
logger = logging.getLogger(__name__)
def get_all_daily_analytics(end_date:str = 'now'):
user_id = int(st.session_state.user_id)
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
WHERE user_id = :user_id
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, "user_id": user_id })
except Exception as e:
logger.error(e)
return None
def get_daily_analytics(counter_id:int, end_date:str = 'now'):
user_id = int(st.session_state.user_id)
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
and user_id = :user_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, "user_id": user_id})
except Exception as e:
logger.error(e)
return None

View File

@@ -0,0 +1,84 @@
import logging
from queries.connection import connection
import streamlit as st
logger = logging.getLogger(__name__)
def get_all_monthly_analytics(end_date:str = 'now'):
user_id = int(st.session_state.user_id)
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
WHERE user_id = :user_id
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, "user_id": user_id})
except Exception as e:
logger.error(e)
return None
def get_monthly_analytics(counter_id:int, end_date:str = 'now'):
user_id = int(st.session_state.user_id)
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
and user_id = :user_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, "user_id": user_id})
except Exception as e:
logger.error(e)
return None

40
app/queries/user.py Normal file
View File

@@ -0,0 +1,40 @@
import logging
import streamlit as st
from pandas import DataFrame
from sqlalchemy.sql import text
from streamlit.user_info import UserInfoProxy
from queries.connection import connection
logger = logging.getLogger(__name__)
def find_user_by_oidc_id(oidc_user_id):
return connection().query('SELECT * FROM users WHERE oidc_user_id = :id', params={'id': oidc_user_id})
def find_user_by_email(email):
return connection().query('SELECT * FROM users WHERE email = :email', params={'email': email})
def find_default_user():
return find_user_by_email('default')
def update_default_user(email, name, oidc_user_id):
with connection().session as session:
try:
query = text("UPDATE users SET email = :email, name = :name, oidc_user_id = :user_id WHERE email = 'default'")
session.execute(query, {'email': email, 'name': name, 'user_id': oidc_user_id})
except Exception as e:
session.rollback()
raise e
def create_user(email, name, oidc_user_id) -> DataFrame:
with connection().session as session:
try:
logger.info("Creating new user %s", email)
query = text('INSERT INTO users (email, name, oidc_user_id) VALUES (:email, :name, :user_id) RETURNING *')
return DataFrame(session.execute(query, {'email': email, 'name': name, 'user_id': oidc_user_id}))
except Exception as e:
session.rollback()
raise e

View File

@@ -0,0 +1,78 @@
import logging
import streamlit as st
from queries.connection import connection
logger = logging.getLogger(__name__)
def get_all_weekly_analytics(end_date:str = 'now'):
user_id = int(st.session_state.user_id)
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
WHERE user_id = :user_id
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, "user_id": user_id})
except Exception as e:
logger.error(e)
return None
def get_weekly_analytics(counter_id:int, end_date:str = 'now'):
user_id = int(st.session_state.user_id)
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
and user_id = :user_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, "user_id": user_id})
except Exception as e:
logger.error(e)
return None

View File

@@ -0,0 +1,78 @@
import logging
import streamlit as st
from queries.connection import connection
logger = logging.getLogger(__name__)
def get_all_yearly_analytics(end_date:str = 'now'):
user_id = int(st.session_state.user_id)
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
WHERE user_id = :user_id
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, "user_id": user_id})
except Exception as e:
logger.error(e)
return None
def get_yearly_analytics(counter_id:int, end_date:str = 'now'):
user_id = int(st.session_state.user_id)
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
and user_id = :user_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, "user_id":user_id})
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,20 +1,33 @@
import streamlit as st
from streamlit import dialog
import queries.user as user_queries
import random
from logger import init_logger
from styles import init_styles
from user import init_user, is_login_enabled, is_logged_in, init_demo_user
from themes import init_themes
init_logger()
init_user()
init_styles()
init_themes()
if st.user and not st.user.is_logged_in:
if not is_login_enabled() or is_logged_in():
counters = st.Page("pages/counters.py", title="Counters", icon=":material/update:")
stats = st.Page("pages/stats.py", title="Statistics", icon=":material/chart_data:")
settings = st.Page("pages/settings.py", title="&nbsp;", icon=":material/menu:")
pages = [counters, stats, settings]
pg = st.navigation(position="top", pages=pages)
pg.run()
else:
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"):
if st.button("Log in", width="stretch",icon=":material/login:"):
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()
if st.button("Demo", width="stretch", icon=":material/account_box:"):
init_demo_user()
st.rerun()

View File

@@ -1,6 +1,6 @@
import streamlit as st
import sql
from queries import crud
from user import is_logged_in
def _load_css(filepath):
with open(filepath) as file:
@@ -8,16 +8,17 @@ def _load_css(filepath):
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>
""")
if is_logged_in():
colors = crud.get_colors()
for idx, c in enumerate(colors.keys()):
css_color = '#' + colors[c][0]
st.html(f"""
<style>
.st-key-new_counter_color_selector label:has(> input[value='{idx}']) {{
background-color: {css_color};
}}
</style>
""")
def init_styles():

39
app/themes.py Normal file
View File

@@ -0,0 +1,39 @@
import streamlit as st
from queries import crud
from user import is_logged_in, is_login_enabled
def init_themes():
if 'themes' not in st.session_state:
st.session_state.themes = {
"light": {
"theme.base": "light",
"theme.backgroundColor": "white",
"theme.primaryColor": "#5591f5",
"theme.textColor": "#0a1464",
"button_face_label": "Light",
"button_face_icon": ":material/light_mode:"
},
"dark": {
"theme.base": "dark",
"theme.backgroundColor": "black",
"theme.primaryColor": "#c98bdb",
"theme.textColor": "white",
"button_face_label": "Dark",
"button_face_icon": ":material/dark_mode:"
}
}
if 'current_theme' not in st.session_state:
st.session_state.current_theme = 'light'
change_theme('light')
def change_theme(theme):
if is_logged_in():
crud.set_theme(theme)
st.session_state.current_theme = theme
for key, val in st.session_state.themes[theme].items():
if key.startswith("theme"):
st._config.set_option(key, val)

60
app/user.py Normal file
View File

@@ -0,0 +1,60 @@
import logging
import random
import queries.user as user_queries
import streamlit as st
from pandas import DataFrame
from streamlit.user_info import UserInfoProxy
logger = logging.getLogger(__name__)
def is_login_enabled() -> bool:
return hasattr(st, 'user') and hasattr(st.user, 'is_logged_in')
def is_demo_user() -> bool:
return hasattr(st.session_state, 'user_is_demo') and st.session_state.user_is_demo
def is_logged_in() -> bool:
return not is_login_enabled() or is_demo_user() or (is_login_enabled() and st.user.is_logged_in)
def init_user():
if not is_demo_user():
set_user_in_session(st.user)
def set_user_in_session(user: UserInfoProxy):
email = user.email if hasattr(user, "email") else None
user_id = user.sub if hasattr(user, "sub") else None
name = user.name if hasattr(user, "name") else None
user_entity = user_queries.find_user_by_oidc_id(user_id) if user_id else DataFrame()
if user_entity.empty:
user_entity = user_queries.find_user_by_email(email) if email else DataFrame()
if user_entity.empty:
user_entity = user_queries.find_default_user()
if user_entity.empty and email and name and user_id:
user_entity = user_queries.create_user(email, name, user_id)
elif name:
user_queries.update_default_user(email, name, user_id)
user_entity = user_queries.find_user_by_oidc_id(user_id)
if not user_entity.empty:
st.session_state.user_id = int(user_entity["id"][0])
st.session_state.user_name = user_entity["name"][0]
st.session_state.user_email = user_entity["email"][0]
st.session_state.user_external_id = user_entity["oidc_user_id"][0]
st.session_state.current_theme = user_entity["theme"][0]
st.session_state.user_is_demo = False
else:
logger.warning("No active user found!")
def init_demo_user():
demo_id = ''.join(random.choice('0123456789abcdefghijklmnopqrstuvwxyz') for i in range(6))
demo_user = user_queries.create_user(f"demo+{demo_id}@internal", f"Demo user {demo_id}", None)
st.session_state.user_id = int(demo_user["id"][0])
st.session_state.user_name = demo_user["name"][0]
st.session_state.user_email = demo_user["email"][0]
st.session_state.user_external_id = demo_user["oidc_user_id"][0]
st.session_state.current_theme = demo_user["theme"][0]
st.session_state.user_is_demo = True

View File

@@ -1,5 +1,5 @@
#MainMenu {
#display: none;
display: none;
}
.stApp {
min-width: 360px;
@@ -15,7 +15,6 @@
}
.stPageLink a {
background: whitesmoke;
height: 40px;
width: 45px;
border: 1px solid silver;
@@ -34,23 +33,64 @@
border: 1px solid gray;
padding: 10px;
border-radius: 5px;
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;
}
div:has(> .stToolbarActions) {
display: none;
}
.rc-overflow > .rc-overflow-item {
flex:1;
}
.rc-overflow > .rc-overflow-item:nth-child(3) {
margin-left: auto;
flex: 0;
}
.rc-overflow > .rc-overflow-item:nth-child(3) > div {
width: 33px;
}
.rc-overflow > .rc-overflow-item:nth-child(3) a {
border-radius: 13px;
}
.rc-overflow > .rc-overflow-item-rest {
display: none;
}
.st-key-settings-color-selector {
gap: 3px;
}
.settings-color-selector-colors > span {
display:inline-block;
width:2.5em;
height:2.5em;
}
.st-key-settings-color-selector button {
border: 0;
background-color: transparent;
}
.st-key-settings-color-selector button > div {
justify-content: left;
}
.settings-color-selector-colors {
float: right;
}
.st-key-settings-color-selector-selected .settings-color-selector-colors > span {
opacity: 0.5;
}

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"

View File

@@ -0,0 +1,56 @@
"""add user id
Revision ID: d9faf8fb8642
Revises: 4ee21f978e6c
Create Date: 2026-04-27 17:24:17.892586
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'd9faf8fb8642'
down_revision: Union[str, Sequence[str], None] = '4ee21f978e6c'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
users = op.create_table(
"users",
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
sa.Column("email", sa.String, nullable=False),
sa.Column("name", sa.String),
sa.Column("oidc_user_id", sa.Integer)
)
op.bulk_insert(users, [ { "email": "default" } ])
with op.batch_alter_table("counters") as batch_op:
batch_op.add_column(sa.Column("user_id", sa.Integer, insert_default=1))
batch_op.create_foreign_key("fk_counters_user_id",
referent_table="users",
remote_cols=['id'],
local_cols=['user_id'])
with op.batch_alter_table("entries") as batch_op:
batch_op.add_column(sa.Column("user_id", sa.Integer, insert_default=1))
batch_op.create_foreign_key("fk_entries_user_id",
referent_table="users",
remote_cols=['id'],
local_cols=['user_id'])
def downgrade() -> None:
with op.batch_alter_table("counters") as batch_op:
batch_op.drop_constraint("fk_counters_user_id", type_="foreignkey")
batch_op.drop_column('user_id')
with op.batch_alter_table("entries") as batch_op:
batch_op.drop_constraint('fk_entries_user_id', type_='foreignkey')
batch_op.drop_column('user_id')
op.drop_table("users")

View File

@@ -0,0 +1,55 @@
"""personalized colors
Revision ID: 720abfadcd44
Revises: d9faf8fb8642
Create Date: 2026-04-30 10:48:24.595774
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '720abfadcd44'
down_revision: Union[str, Sequence[str], None] = 'd9faf8fb8642'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.drop_table("color_palettes")
color_palette = op.create_table(
"color_palettes",
sa.Column('id', sa.Integer, primary_key=True, autoincrement=True),
sa.Column('name', sa.String, nullable=False),
sa.Column('color1', sa.String(6), nullable=False),
sa.Column('color2', sa.String(6), nullable=False),
sa.Column('color3', sa.String(6), nullable=False),
sa.Column('color4', sa.String(6), nullable=False),
sa.Column('color5', sa.String(6), nullable=False),
)
op.bulk_insert(color_palette, [
{"name": "Flames", "color1": "F2F3AE", "color2": "EDD382", "color3": "FC9E4F", "color4": "FF521B", "color5": "020122"},
{"name": "Water", "color1": "2B4141", "color2": "0EB1D2", "color3": "34E4EA", "color4": "8AB9B5", "color5": "C8C2AE"},
{"name": "Nature", "color1": "181F1C", "color2": "274029", "color3": "315C2B", "color4": "60712F", "color5": "9EA93F"},
{"name": "Mellow", "color1": "A3A380", "color2": "D6CE93", "color3": "EFEBCE", "color4": "D8A48F", "color5": "BB8588"},
{"name": "Light Blue", "color1": "32292F", "color2": "99E1D9", "color3": "F0F7F4", "color4": "70ABAF", "color5": "705D56"}
])
with op.batch_alter_table("users") as batch_op:
batch_op.add_column(sa.Column("color_palette_id", sa.Integer, nullable=False, server_default="1"))
batch_op.create_foreign_key("fk_users_color_palettes_id",
referent_table="color_palettes",
local_cols=["color_palette_id"],
remote_cols=["id"])
def downgrade() -> None:
with op.batch_alter_table("users") as batch_op:
batch_op.drop_constraint("fk_users_color_palettes_id", type_="foreignkey")
batch_op.drop_column("color_palette_id")
op.drop_column("color_palettes", "name")

View File

@@ -0,0 +1,25 @@
"""theme
Revision ID: 8be315e8a5dc
Revises: 720abfadcd44
Create Date: 2026-04-30 18:12:43.026620
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '8be315e8a5dc'
down_revision: Union[str, Sequence[str], None] = '720abfadcd44'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('users', sa.Column('theme', sa.String(), nullable=False, server_default='light'))
def downgrade() -> None:
op.drop_column('users', 'theme')

1261
poetry.lock generated

File diff suppressed because it is too large Load Diff

3
poetry.toml Normal file
View File

@@ -0,0 +1,3 @@
[virtualenvs]
in-project = true
path = ".venv"

View File

@@ -3,16 +3,23 @@ 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)"
]
[virtualenvs]
in-project = true
[tool.poetry]
package-mode = false
[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"
[tool.alembic]
script_location = "%(here)s/migrations"
@@ -22,5 +29,13 @@ prepend_sys_path = [
"."
]
[tool.poetry]
package-mode = false
[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"
]

45
tests/conftest.py Normal file
View File

@@ -0,0 +1,45 @@
import logging
import os
import pytest
from alembic import config
from pytest_alembic.config import Config
from streamlit.testing.v1 import AppTest
import streamlit as st
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:
logger.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(autouse=True)
def user_config():
logger.info("Setting up test user")
st.user = None
st.session_state.user_id = 1
st.session_state.user_name = "Test User"
st.session_state.user_email = "test@test.local"
st.session_state.user_external_id = "111-2222-3333"
st.session_state.current_theme = "light"
@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,59 @@
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
def test_get_color_palettes():
palettes = crud.get_color_palettes()
assert len(palettes) == 5
assert palettes['name'][0] == "Flames"
def test_get_user_colors():
palettes = crud.get_color_palettes()
palette_id = crud.get_color_palette()
assert palette_id == 1
assert palettes.loc[palettes['id'] == palette_id]["color1"][0] == 'F2F3AE'
crud.set_color_palette(2)
palettes = crud.get_color_palettes()
palette_id = crud.get_color_palette()
assert palette_id == 2
assert palettes.loc[palettes['id'] == palette_id]["color1"][1] == '2B4141'

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, user_id, "timestamp", increment)
VALUES
(1, 1, date('2026-06-15'), 1),
(1, 1, date(date('2026-06-15'), '-1 days'), 2),
(1, 1, date(date('2026-06-15'), '-3 days'), 3),
(2, 1, date('2026-06-15'), 2),
(2, 1, date(date('2026-06-15'), '-1 days'), 4),
(2, 1, date(date('2026-06-15'), '-3 days'), 6)
""")
session.execute(query)
session.commit()
stats = daily_stats.get_all_daily_analytics('2026-06-15')
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, user_id, "timestamp", increment)
VALUES
(1, 1, date('2026-06-15'), 1),
(1, 1, date(date('2026-06-15'), '-1 days'), 2),
(1, 1, date(date('2026-06-15'), '-3 days'), 3)
""")
session.execute(query)
session.commit()
stats = daily_stats.get_daily_analytics(1, '2026-06-15')
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, user_id, "timestamp", increment)
VALUES
(1, 1, date('2026-06-15'), 1),
(1, 1, date(date('2026-06-15'), 'start of month', '-1 month'), 2),
(1, 1, date(date('2026-06-15'), 'start of month', '-3 months'), 3),
(2, 1, date('2026-06-15'), 2),
(2, 1, date(date('2026-06-15'), 'start of month', '-2 months'), 4),
(2, 1, date(date('2026-06-15'), 'start of month', '-3 months'), 6)
""")
session.execute(query)
session.commit()
stats = monthly_stats.get_all_monthly_analytics('2026-06-15')
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, user_id, "timestamp", increment)
VALUES
(1, 1, date('2026-06-15'), 1),
(1, 1, date(date('2026-06-15'), '-1 months'), 2),
(1, 1, date(date('2026-06-15'), '-3 months'), 3)
""")
session.execute(query)
session.commit()
stats = monthly_stats.get_monthly_analytics(1, '2026-06-15')
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, user_id, "timestamp", increment)
VALUES
(1, 1, date('2026-06-15'), 1),
(1, 1, date(date('2026-06-15'), '-1 year'), 2),
(1, 1, date(date('2026-06-15'), '-3 years'), 3),
(2, 1, date('2026-06-15'), 2),
(2, 1, date(date('2026-06-15'), '-2 years'), 4),
(2, 1, date(date('2026-06-15'), '-3 years'), 6)
""")
session.execute(query)
session.commit()
stats = yearly_stats.get_all_yearly_analytics('2026-06-15')
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, user_id, "timestamp", increment)
VALUES
(1, 1, date('2026-06-15'), 1),
(1, 1, date(date('2026-06-15'), '-1 years'), 2),
(1, 1, date(date('2026-06-15'), '-3 years'), 3)
""")
session.execute(query)
session.commit()
stats = yearly_stats.get_yearly_analytics(1, '2026-06-15')
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, user_id, "timestamp", increment)
VALUES
(1, 1, date('2026-06-15'), 1),
(1, 1, date(date('2026-06-15'), '-7 days'), 2),
(1, 1, date(date('2026-06-15'), '-21 days'), 3),
(2, 1, date('2026-06-15'), 2),
(2, 1, date(date('2026-06-15'), '-14 days'), 4),
(2, 1, date(date('2026-06-15'), '-21 days'), 6)
""")
session.execute(query)
session.commit()
stats = weekly_stats.get_all_weekly_analytics('2026-06-15')
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, user_id, "timestamp", increment)
VALUES
(1, 1, date('2026-06-15'), 1),
(1, 1, date(date('2026-06-15'), '-7 days'), 2),
(1, 1, date(date('2026-06-15'), '-21 days'), 3)
""")
session.execute(query)
session.commit()
stats = weekly_stats.get_weekly_analytics(1, '2026-06-15')
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,56 @@
import streamlit
import queries.user as user_query
import user
def test_get_default_user():
users = user_query.find_default_user()
assert len(users) == 1
assert users["email"][0] == "default"
def test_update_default_user_and_find_user():
user_query.update_default_user(email="test@testbase.com", name="Test User", oidc_user_id="1111-2222-3333")
users = user_query.find_default_user()
assert len(users) == 0
users = user_query.find_user_by_oidc_id("1111-2222-3333")
assert len(users) == 1
assert users["email"][0] == "test@testbase.com"
assert users["name"][0] == "Test User"
assert users["oidc_user_id"][0] == "1111-2222-3333"
users = user_query.find_user_by_email("test@testbase.com")
assert len(users) == 1
assert users["email"][0] == "test@testbase.com"
assert users["name"][0] == "Test User"
assert users["oidc_user_id"][0] == "1111-2222-3333"
def test_add_user():
user_query.create_user(email="test@testbase.com", name="Test User", oidc_user_id="333-4444-5555")
users = user_query.find_user_by_oidc_id("333-4444-5555")
assert len(users) == 1
assert users["email"][0] == "test@testbase.com"
assert users["name"][0] == "Test User"
assert users["oidc_user_id"][0] == "333-4444-5555"
users = user_query.find_user_by_email("test@testbase.com")
assert len(users) == 1
assert users["email"][0] == "test@testbase.com"
assert users["name"][0] == "Test User"
assert users["oidc_user_id"][0] == "333-4444-5555"
def test_update_user_in_session():
userInfo = lambda: None
userInfo.email ="test@testbase.com"
userInfo.name = "Test User"
userInfo.sub = "1111-2222-3333"
user.set_user_in_session(userInfo)
state = streamlit.session_state
assert state.user_id == 1
assert state.user_name == userInfo.name
assert state.user_email == userInfo.email
assert state.user_external_id == userInfo.sub

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

View File

@@ -0,0 +1,35 @@
import queries.crud as crud
def test_change_theme(app):
app.run()
app.switch_page("pages/settings.py").run()
assert app.session_state.current_theme == "light", "Light theme should be default"
assert app.button[0].label == "Light"
assert app.button[0].disabled == True, "Light theme should be selected"
assert app.button[1].label == "Dark"
assert app.button[1].disabled == False, "Dark theme should be de-selected"
app.button[1].click().run()
assert "dark" == crud.get_theme()
assert app.button[0].disabled == False, "Light theme should be de-selected"
assert app.button[1].disabled == True, "Dark theme should be selected"
def test_change_color_palette(app):
app.run()
app.switch_page("pages/settings.py").run()
assert app.button[2].disabled == True, "First palette should be selected"
assert app.button[2].label == "Flames **(selected)**"
app.button[3].click().run()
assert app.button[2].disabled == False, "First palette should be de-selected"
assert app.button[2].label == "Flames"
assert app.button[3].disabled == True, "Second palette should be selected"
assert app.button[3].label == "Water **(selected)**"