Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions factorialhr_analysis/factorialhr_analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,6 @@ def frontend_exception_handler(exc: Exception) -> None:

app.add_page(pages.index_page, route=routes.INDEX)
app.add_page(pages.working_time_verification_page, route=routes.VERIFICATION_ROUTE)
app.add_page(pages.projects_page, route=routes.PROJECTS_ROUTE)
app.add_page(pages.authorize_oauth_page, route=routes.OAUTH_AUTHORIZE_ROUTE)
app.add_page(pages.start_oauth_process, route=routes.OAUTH_START_ROUTE)
9 changes: 8 additions & 1 deletion factorialhr_analysis/pages/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
from factorialhr_analysis.pages.index_page import index_page
from factorialhr_analysis.pages.oauth_page import authorize_oauth_page, start_oauth_process
from factorialhr_analysis.pages.projects_page import projects_page
from factorialhr_analysis.pages.working_time_verification_page import working_time_verification_page

__all__ = ['authorize_oauth_page', 'index_page', 'start_oauth_process', 'working_time_verification_page']
__all__ = [
'authorize_oauth_page',
'index_page',
'projects_page',
'start_oauth_process',
'working_time_verification_page',
]
150 changes: 150 additions & 0 deletions factorialhr_analysis/pages/projects_page.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
"""Projects page for displaying project time calculations."""

import reflex as rx

from factorialhr_analysis import components, templates
from factorialhr_analysis.states.project_state import ProjectSettingsState, ProjectState


@rx.memo
def render_date_inputs() -> rx.Component:
"""Render the date input form."""
return rx.hstack(
rx.hstack(
rx.text('Start date'),
rx.input(
type='date',
name='start_date',
value=ProjectSettingsState.start_date,
on_change=ProjectSettingsState.set_start_date,
),
align='center',
spacing='1',
min_width='max-content',
),
rx.hstack(
rx.text('End date'),
rx.input(
type='date',
name='end_date',
value=ProjectSettingsState.end_date,
on_change=ProjectSettingsState.set_end_date,
),
align='center',
spacing='1',
min_width='max-content',
),
rx.checkbox(
'Only active projects',
default_checked=ProjectSettingsState.active,
on_change=ProjectSettingsState.set_active,
),
rx.cond(
ProjectSettingsState.date_error,
rx.tooltip(
rx.button('Calculate', disabled=True),
content='End date must be after start date.',
),
rx.button(
'Calculate',
loading=ProjectState.is_loading_projects_times | ProjectState.is_loading_unrelated_minutes,
on_click=ProjectState.calculate,
),
),
spacing='3',
align='center',
width='100%',
)


@rx.memo
def render_imputed_minutes_chart() -> rx.Component:
"""Render vertical bar chart for imputed minutes with different colors per project."""
return rx.cond(
ProjectState.project_times_data.length() > 0,
rx.vstack(
rx.heading('Project Time by Project', size='4'),
rx.recharts.bar_chart(
rx.recharts.bar(
data_key='total',
name='Total Minutes',
),
rx.recharts.x_axis(type_='number'),
rx.recharts.y_axis(data_key='project', type_='category'),
rx.recharts.graphing_tooltip(),
rx.recharts.legend(),
data=ProjectState.project_times_data,
layout='vertical',
width='100%',
height=ProjectState.chart_height,
margin={'top': 20, 'right': 20, 'left': 150, 'bottom': 20},
),
width='100%',
spacing='4',
),
rx.text('No data available. Click Calculate to load project data.'),
)


@rx.memo
def render_unrelated_minutes_chart() -> rx.Component:
"""Render bar chart for unrelated minutes."""
return rx.cond(
ProjectState.unrelated_minutes_data.length() > 0,
rx.vstack(
rx.heading('Unrelated Minutes by Employee', size='4'),
rx.text(
rx.cond(
ProjectState.total_unrelated_minutes > 0,
f'Total unrelated minutes: {ProjectState.total_unrelated_minutes}',
'Total unrelated minutes: 0',
),
size='5',
weight='bold',
),
rx.recharts.bar_chart(
rx.recharts.bar(
data_key='minutes',
# fill='#82ca9d',
name='Unrelated Minutes',
),
rx.recharts.x_axis(data_key='employee'),
rx.recharts.y_axis(),
rx.recharts.graphing_tooltip(),
rx.recharts.legend(),
data=ProjectState.unrelated_minutes_data,
width='100%',
height=400,
),
width='100%',
spacing='4',
),
rx.text('No data available. Click Calculate to load unrelated minutes data.'),
)


@components.requires_authentication
@templates.template
def projects_page() -> rx.Component:
"""Projects page showing project time calculations."""
return rx.vstack(
rx.heading('Project Time Analysis', size='6', weight='bold'),
render_date_inputs(),
rx.cond(
ProjectState.error_message.is_not_none(),
rx.callout.root(
rx.callout.icon(),
rx.callout.text(ProjectState.error_message),
color_scheme='red',
),
rx.fragment(),
),
rx.vstack(
render_imputed_minutes_chart(),
render_unrelated_minutes_chart(),
spacing='6',
width='100%',
),
width='100%',
spacing='4',
)
1 change: 1 addition & 0 deletions factorialhr_analysis/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@
OAUTH_START_ROUTE = '/oauth/start'
OAUTH_AUTHORIZE_ROUTE = '/oauth/authorize'
VERIFICATION_ROUTE = '/verification'
PROJECTS_ROUTE = '/projects'
3 changes: 2 additions & 1 deletion factorialhr_analysis/states/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from factorialhr_analysis.states.data_state import DataState
from factorialhr_analysis.states.oauth_state import OAuthSessionState
from factorialhr_analysis.states.project_state import ProjectSettingsState, ProjectState

__all__ = ['DataState', 'OAuthSessionState']
__all__ = ['DataState', 'OAuthSessionState', 'ProjectSettingsState', 'ProjectState']
34 changes: 30 additions & 4 deletions factorialhr_analysis/states/data_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import datetime
import logging
from collections.abc import Mapping

import anyio
import factorialhr
Expand All @@ -13,10 +14,13 @@
class DataState(rx.State):
"""State for managing data."""

_employees: dict[int, factorialhr.Employee] = {} # noqa: RUF012
_teams: dict[int, factorialhr.Team] = {} # noqa: RUF012
_shifts: dict[int, factorialhr.AttendanceShift] = {} # noqa: RUF012
_employees: Mapping[int, factorialhr.Employee] = {}
_teams: Mapping[int, factorialhr.Team] = {}
_shifts: Mapping[int, factorialhr.AttendanceShift] = {}
_credentials: factorialhr.Credentials | None = None
_time_records: Mapping[int, factorialhr.TimeRecord] = {}
_projects: Mapping[int, factorialhr.Project] = {}
_project_workers: Mapping[int, factorialhr.ProjectWorker] = {}

is_loading: rx.Field[bool] = rx.field(default=False)
last_updated: rx.Field[datetime.datetime | None] = rx.field(default=None)
Expand Down Expand Up @@ -47,6 +51,22 @@ async def _load_credentials(self, api_client: factorialhr.ApiClient):
async with self:
self._credentials = next(iter(credentials.data()), None)

async def _load_time_records(self, api_client: factorialhr.ApiClient):
# all time records are obtained in a single page and therefore requires a high timeout
time_records = await factorialhr.TimeRecordEndpoint(api_client).all(timeout=100)
async with self:
self._time_records = {record.id: record for record in time_records.data()}

async def _load_projects(self, api_client: factorialhr.ApiClient):
projects = await factorialhr.ProjectEndpoint(api_client).all()
async with self:
self._projects = {project.id: project for project in projects.data()}

async def _load_project_workers(self, api_client: factorialhr.ApiClient):
project_workers = await factorialhr.ProjectWorkerEndpoint(api_client).all()
async with self:
self._project_workers = {project_worker.id: project_worker for project_worker in project_workers.data()}

@rx.event
async def refresh_data(self): # noqa: ANN201
"""Refresh the data."""
Expand All @@ -68,13 +88,16 @@ async def poll_data(self):
auth = (await self.get_state(states.OAuthSessionState)).get_auth()
try:
async with (
factorialhr.ApiClient(constants.ENVIRONMENT_URL, auth=auth) as client, # pyright: ignore[reportArgumentType]
factorialhr.ApiClient(constants.ENVIRONMENT_URL, auth=auth) as client,
anyio.create_task_group() as tg,
):
tg.start_soon(self._load_teams, client)
tg.start_soon(self._load_employees, client)
tg.start_soon(self._load_shifts, client)
tg.start_soon(self._load_credentials, client)
tg.start_soon(self._load_time_records, client)
tg.start_soon(self._load_projects, client)
tg.start_soon(self._load_project_workers, client)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Stale Data After Clear: Inconsistent Refresh State

The DataState.clear() method doesn't reset the newly added _time_records, _projects, and _project_workers fields. This can leave stale data in these fields when refresh_data() is called, leading to an inconsistent state.

Additional Locations (1)

Fix in Cursor Fix in Web

except Exception:
logging.getLogger(__name__).exception('error loading data')
raise
Expand All @@ -93,3 +116,6 @@ def clear(self):
self._teams.clear()
self._shifts.clear()
self._credentials = None
self._time_records.clear()
self._projects.clear()
self._project_workers.clear()
Loading