18 Commits
0.0.1 ... 0.1.2

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
fdbde62a25 Fix docker image name
All checks were successful
Build & Release / build-docker-image (push) Successful in 1m48s
Build & Release / deploy-to-production (push) Successful in 8s
2026-04-08 20:24:37 +02:00
36 changed files with 2304 additions and 859 deletions

View File

@@ -1,13 +1,14 @@
name: Build & Release name: Build & Release
on: on:
push: workflow_run:
tags: workflows: [ "Run Tests" ]
- '[0-9]+.[0-9]+.[0-9]+' types:
- completed
env: env:
ENDPOINT: services-3 ENDPOINT: services-3
STACK: misc STACK: misc
IMAGE: john/daily-counter IMAGE: docker/daily-counter
TAG: ${{ gitea.ref_name }} TAG: ${{ gitea.ref_name }}
CACHE_NAME: cache-python-dependencies-daily-counter CACHE_NAME: cache-python-dependencies-daily-counter
RUNNER_TOOL_CACHE: /toolcache RUNNER_TOOL_CACHE: /toolcache
@@ -15,6 +16,7 @@ env:
jobs: jobs:
build-docker-image: build-docker-image:
if: ${{ gitea.event.workflow_run.conclusion == 'success' && gitea.ref_type == 'tag' }}
runs-on: node20 runs-on: node20
container: container:
image: catthehacker/ubuntu:act-24.04 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 **/*.db
**/__pycache__/** **/__pycache__/**
.streamlit/secrets.toml .streamlit/secrets.toml
/testdb.sqlite

View File

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

View File

@@ -13,6 +13,9 @@ ENV PYTHONFAULTHANDLER=1 \
POETRY_CACHE_DIR='/var/cache/pypoetry' \ POETRY_CACHE_DIR='/var/cache/pypoetry' \
POETRY_HOME='/usr/local' POETRY_HOME='/usr/local'
RUN apk update && \
apk upgrade && \
rm -rf /var/cache/apk/*
RUN apk add --no-cache tini RUN apk add --no-cache tini
RUN pip install -Iv --no-cache-dir "poetry==${POETRY_VERSION}" 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 COPY . /app
VOLUME /app/data VOLUME /app/data
RUN touch .streamlit/secrets.toml \ EXPOSE 8501
&& toml add_section --toml-path='.streamlit/secrets.toml' 'connections.sqlite' \
&& toml set --toml-path='.streamlit/secrets.toml' 'connections.sqlite.type' 'sql' \
&& toml set --toml-path='.streamlit/secrets.toml' 'connections.sqlite.url' 'sqlite:///data/daily-counter.db'
HEALTHCHECK --interval=60s --retries=5 CMD wget -qO- http://127.0.0.1:8501/_stcore/health || exit 1 HEALTHCHECK --interval=60s --retries=5 CMD wget -qO- http://127.0.0.1:8501/_stcore/health || exit 1
ENTRYPOINT ["/sbin/tini", "--"] ENTRYPOINT ["/sbin/tini", "--"]

View File

@@ -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/) **Pre-requisites:** [Pip](https://pypi.org/project/pip/) and [Poetry](https://pypi.org/project/poetry/)
1. Clone this repository 1. Clone this repository
2. Install Poetry ``pip install -Iv --no-cache-dir "poetry==2.3.3"`` 2. Create a virtual environment with ``python3 -m venv ./.venv``
3. Install project dependencies ``poetry install`` 3. Activate virtual environment with ``source .venv/bin/activate``
4. Run database migrations ``alembic upgrade head`` 4. Install project dependencies ``poetry install``
5. Run project ``streamlit run app`` 5. Run database migrations ``alembic upgrade head``
6. Project will be running at http://localhost:8501 6. Run project ``streamlit run app``
7. Project will be running at http://localhost:8501
## Run project (Locally with Docker) ## Run project (Locally with Docker)

View File

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

View File

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

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

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 import streamlit as st
from streamlit import dialog
import queries.user as user_queries
import random
from logger import init_logger from logger import init_logger
from styles import init_styles 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_logger()
init_user()
init_styles() 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"): with st.container(width="stretch", height="stretch", horizontal_alignment="center"):
st.title("Daily Counter", width="stretch", text_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.text("Please log in to use this app", width="stretch", text_alignment="center")
st.space() st.space()
if st.button("Log in"): if st.button("Log in", width="stretch",icon=":material/login:"):
st.login() st.login()
else: if st.button("Demo", width="stretch", icon=":material/account_box:"):
counters = st.Page("pages/counters.py", title="Counters", icon=":material/update:") init_demo_user()
stats = st.Page("pages/stats.py", title="Statistics", icon=":material/chart_data:") st.rerun()
pg = st.navigation(position="top", pages=[counters, stats])
pg.run()

View File

@@ -1,6 +1,6 @@
import streamlit as st import streamlit as st
import sql from queries import crud
from user import is_logged_in
def _load_css(filepath): def _load_css(filepath):
with open(filepath) as file: with open(filepath) as file:
@@ -8,16 +8,17 @@ def _load_css(filepath):
def _load_color_selector_styles(): def _load_color_selector_styles():
colors = sql.get_colors(1) #FIXME Change to use user profile color palette if is_logged_in():
for idx, c in enumerate(colors.keys()): colors = crud.get_colors()
css_color = '#' + colors[c][0] for idx, c in enumerate(colors.keys()):
st.html(f""" css_color = '#' + colors[c][0]
<style> st.html(f"""
.st-key-color-selector label:has(> input[value='{idx}']) {{ <style>
background-color: {css_color}; .st-key-new_counter_color_selector label:has(> input[value='{idx}']) {{
}} background-color: {css_color};
</style> }}
""") </style>
""")
def init_styles(): 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 { #MainMenu {
#display: none; display: none;
} }
.stApp { .stApp {
min-width: 360px; min-width: 360px;
@@ -15,7 +15,6 @@
} }
.stPageLink a { .stPageLink a {
background: whitesmoke;
height: 40px; height: 40px;
width: 45px; width: 45px;
border: 1px solid silver; border: 1px solid silver;
@@ -34,23 +33,64 @@
border: 1px solid gray; border: 1px solid gray;
padding: 10px; padding: 10px;
border-radius: 5px; border-radius: 5px;
background-color: whitesmoke;
} }
.st-key-color-selector div[role = "radiogroup"] { .st-key-new_counter_color_selector div[role = "radiogroup"] {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
} }
.st-key-color-selector div[role = "radiogroup"] > label { .st-key-new_counter_color_selector div[role = "radiogroup"] > label {
flex: 1 flex: 1
} }
.st-key-color-selector div[role = "radiogroup"] > label > div:first-child { .st-key-new_counter_color_selector div[role = "radiogroup"] > label > div:first-child {
display: none; display: none;
} }
.st-key-color-selector div[role = "radiogroup"] > label:has(> input[tabindex="0"]) { .st-key-new_counter_color_selector div[role = "radiogroup"] > label:has(> input[tabindex="0"]) {
outline: 3px solid blue; outline: 3px solid blue;
} }
.st-key-color-selector div[role = "radiogroup"] p { .st-key-new_counter_color_selector div[role = "radiogroup"] p {
visibility: hidden; visibility: hidden;
} }
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 #!/usr/bin/env sh
STREAMLIT_SECRETS_LOCATION=".streamlit/secrets.toml" STREAMLIT_SECRETS_LOCATION=".streamlit/secrets.toml"
touch STREAMLIT_SECRETS_LOCATION
SQLITE_DATABASE="/data/daily-counter.db"
SQLITE_DATABASE_URL="sqlite://$SQLITE_DATABASE"
export DATABASE_URL="$SQLITE_DATABASE_URL"
echo "INFO [entrypoint] Using SQLite database at $SQLITE_DATABASE"
if [ "$OIDC_ENABLED" = "true" ]; then if [ "$OIDC_ENABLED" = "true" ]; then
echo "INFO [entrypoint] OIDC configuration detected. Configuring app..." echo "INFO [entrypoint] OIDC configuration detected. Configuring authentication..."
toml add_section --toml-path=$STREAMLIT_SECRETS_LOCATION 'auth' toml add_section --toml-path=$STREAMLIT_SECRETS_LOCATION 'auth'
toml set --toml-path=$STREAMLIT_SECRETS_LOCATION 'auth.redirect_uri' "$OIDC_PUBLIC_URL/oauth2callback" toml set --toml-path=$STREAMLIT_SECRETS_LOCATION 'auth.redirect_uri' "$OIDC_PUBLIC_URL/oauth2callback"
toml set --toml-path=$STREAMLIT_SECRETS_LOCATION 'auth.cookie_secret' "$OIDC_COOKIE_SECRET" toml set --toml-path=$STREAMLIT_SECRETS_LOCATION 'auth.cookie_secret' "$OIDC_COOKIE_SECRET"

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" description = "A daily counter for any habbit tracking"
version = "0.1" version = "0.1"
dynamic = ["version"] dynamic = ["version"]
requires-python = ">= 3.10" requires-python = ">=3.10,<4"
dependencies = [ dependencies = [
"alembic (==1.18.4)", "alembic (==1.18.4)",
"streamlit (==1.56.0)", "streamlit (==1.56.0)",
"toml-cli (==0.8.2)", "toml-cli (==0.8.2)",
"authlib (==1.6.9)" "authlib (==1.6.9)",
"sqlalchemy (>=2.0.49,<3.0.0)",
"pytest-alembic (>=0.12.1,<0.13.0)",
"pytest-env (>=1.6.0,<2.0.0)"
] ]
[virtualenvs] [tool.poetry]
in-project = true package-mode = false
[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"
[tool.alembic] [tool.alembic]
script_location = "%(here)s/migrations" script_location = "%(here)s/migrations"
@@ -22,5 +29,13 @@ prepend_sys_path = [
"." "."
] ]
[tool.poetry] [tool.pytest.ini_options]
package-mode = false 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)**"