Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 450533414e | |||
| d665d8e1a0 | |||
| c1a6dafa9f | |||
| 0632899f7a | |||
| a88d1b4e79 | |||
| 8ae8bc7a24 | |||
| bd9ff7191a | |||
| f750cfa8e1 | |||
| ddbf567a19 | |||
| cab4ca25ee | |||
| 89125782d7 | |||
| 545841561f | |||
| d84a0eed3f | |||
| a0bdf9e37e | |||
| 0cd500e9f2 | |||
| c14a86b190 |
50
.gitea/workflows/test.yaml
Normal file
50
.gitea/workflows/test.yaml
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
name: Run Tests
|
||||||
|
on:
|
||||||
|
push: {}
|
||||||
|
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
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -3,4 +3,5 @@
|
|||||||
.venv
|
.venv
|
||||||
**/*.db
|
**/*.db
|
||||||
**/__pycache__/**
|
**/__pycache__/**
|
||||||
.streamlit/secrets.toml
|
.streamlit/secrets.toml
|
||||||
|
/testdb.sqlite
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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", "--"]
|
||||||
|
|||||||
11
README.md
11
README.md
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -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
67
app/pages/settings.py
Normal 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}"> </span>
|
||||||
|
<span style="background-color:#{color2}"> </span>
|
||||||
|
<span style="background-color:#{color3}"> </span>
|
||||||
|
<span style="background-color:#{color4}"> </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}"> </span>
|
||||||
|
<span style="background-color:#{color2}"> </span>
|
||||||
|
<span style="background-color:#{color3}"> </span>
|
||||||
|
<span style="background-color:#{color4}"> </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()
|
||||||
@@ -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
14
app/queries/connection.py
Normal 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
149
app/queries/crud.py
Normal 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
|
||||||
71
app/queries/daily_stats.py
Normal file
71
app/queries/daily_stats.py
Normal 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
|
||||||
84
app/queries/monthly_stats.py
Normal file
84
app/queries/monthly_stats.py
Normal 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
40
app/queries/user.py
Normal 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
|
||||||
78
app/queries/weekly_stats.py
Normal file
78
app/queries/weekly_stats.py
Normal 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
|
||||||
78
app/queries/yearly_stats.py
Normal file
78
app/queries/yearly_stats.py
Normal 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
|
||||||
229
app/sql.py
229
app/sql.py
@@ -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
|
|
||||||
@@ -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=" ", 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()
|
|
||||||
|
|||||||
@@ -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
39
app/themes.py
Normal 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
60
app/user.py
Normal 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
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
56
migrations/versions/20260427172417_user_id.py
Normal file
56
migrations/versions/20260427172417_user_id.py
Normal 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")
|
||||||
|
|
||||||
55
migrations/versions/20260430104824_colors_.py
Normal file
55
migrations/versions/20260430104824_colors_.py
Normal 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")
|
||||||
25
migrations/versions/20260430181243_theme.py
Normal file
25
migrations/versions/20260430181243_theme.py
Normal 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
1261
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
3
poetry.toml
Normal file
3
poetry.toml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[virtualenvs]
|
||||||
|
in-project = true
|
||||||
|
path = ".venv"
|
||||||
@@ -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
45
tests/conftest.py
Normal 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)
|
||||||
59
tests/database/crud_db_test.py
Normal file
59
tests/database/crud_db_test.py
Normal 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'
|
||||||
|
|
||||||
195
tests/database/stats_db_test.py
Normal file
195
tests/database/stats_db_test.py
Normal 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
|
||||||
56
tests/database/user_db_test.py
Normal file
56
tests/database/user_db_test.py
Normal 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
|
||||||
78
tests/ui/counters_ui_test.py
Normal file
78
tests/ui/counters_ui_test.py
Normal 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
|
||||||
35
tests/ui/settings_ui_test.py
Normal file
35
tests/ui/settings_ui_test.py
Normal 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)**"
|
||||||
Reference in New Issue
Block a user