# Design Notes

Background on why the `daily-dose` app/package is shaped the way it is. For instructions, see the [Tutorials](../../user-guide/tutorials/index.md) or [How-to Guides](../../user-guide/how-to/index.md). For exact signatures, see [Reference](../../reference/index.md).


# How a workout gets from source to screen

The sections below explain the decisions in this pipeline. This diagram tries to capture the whole thing:


*\[Rich HTML output -- view on the documentation site\]*


<img src="assets/daily-dose-design-decisions.png" class="img-fluid" />


# Why just two features

I built the original site using R Markdown with a page per year, a `Notes` page, and a `Dev` page. When I convert it to a Python app, I kept the two things that make the personal workout log worth opening (a random pick, and "*what did I do on this day before*") instead of rebuilding a year-by-year browser nobody was asking for.

The `Notes` (training principles) and `Dev` (untested workout drafts, never real dated entries) were dropped for the same reason: neither is a *workout*, so neither belongs in a random pick or an on-this-day match.


# One record shape for two different sources

Roughly 4 years of the history is typed markdown, with a stretch of 2020 as whiteboard photos. Rather than model those as two separate collections, I decided to tread every workout as one record with a `date` and `kind` (`"text"` or `"image"`), and whichever fields apply to the *kind*. [random_workout()](../../reference/random_workout.md#daily_dose.random_workout) and [on_this_day()](../../reference/on_this_day.md#daily_dose.on_this_day) then both operate on a single flat list without caring what's inside each record. Only the rendering layer (`workout_card()`) branches on `kind`.


# Text wins when a date has both

Some 2020 dates turned out to have *both* a photographed whiteboard and a later typed write-up of the same session. In most cases, the photo was a same-day backup, typed up afterward. Keeping both would mean the same real workout showing up twice under "On This Day," and "Random Workout" silently double-weighting those dates.

The build script resolves it by **preferring the text entry**, which is also the richer of the two because it usually includes the rep/set structure the photo doesn't make searchable.


# Recompressing the photos

The original whiteboard photos were uncompressed phone-camera PNGs, some over 10MB each, 325MB total for 120 images. I wrote `daily-dose` to be a package that can be installed, not just a static site, so carrying that weight into every install wasn't worth it.

Downscaling and re-encoding as JPEG dropped it to ~21MB, with no visible loss on what's essentially a photo of handwriting on a whiteboard.


# Shiny Core, not Express

The app is one server function wiring two buttons to two pure functions and a render call, small enough that Shiny for Python's Express terser syntax would save only a few lines. It's still built with Shiny Core, because `core.py` has no dependency on Shiny at all, and that's what makes [random_workout()](../../reference/random_workout.md#daily_dose.random_workout) and [on_this_day()](../../reference/on_this_day.md#daily_dose.on_this_day) unit-testable without spinning up a Shiny session (see `tests/test_core.py`).

That separation is why `app.py` reads as thin glue rather than as the place the logic lives.


# The parser keeps things it can't fully understand

I spent four years writing down workout, but they don't use one consistent format for exercises, reps, or footnotes. Forcing that into structured fields would risk silently mangling every entry the parser couldn't read confidently.

`body_md` keeps the original markdown for each text entry verbatim and the app renders it as-is. The only fields pulled out are the two that are consistent enough to parse reliably: the date and the done-checkbox.

(Entries originally linked out to Vimeo for a video of the workout; that account is gone and every one of those links is now dead, so the build script unwraps them back down to plain text instead of shipping broken links.)

One date heading did break this: `2022-01-29` was written indented under the entry above it (`2022-01-30`), so the original parser's heading pattern, which only matched an unindented `### YYYY-MM-DD`, missed it and folded its text into `2022-01-30`'s entry instead of starting a new one. Two things fixed it: the heading pattern now allows leading whitespace, and the parser also checks a looser, date-shaped pattern against every line, raising if something matches that looser check but not the strict one. A future heading the parser genuinely can't handle now stops the build instead of silently merging into whatever entry came before it.


# The root `app.py` *and* `requirements.txt`

`pyproject.toml` is the real source of truth for dependencies, and local development never touches either root file. They exist only because [Posit Connect Cloud](https://docs.posit.co/connect-cloud/how-to/python/shiny-python.html) deploys by convention rather than configuration.

Connect Cloud looks for an `app.py` at the repo root and installs from `requirements.txt`, full stop, with no way to point it at `src/daily_dose/app.py` or `pyproject.toml` instead.

Restructuring the package around one deployment target's assumptions didn't seem worth it. Instead, `app.py` is a small shim and `requirements.txt` holds *real* dependency lines mirroring `pyproject.toml`'s `[project.dependencies]` by hand.

The first approach was `requirements.txt` containing just `-e .`, an editable install of the package from `pyproject.toml`, which kept the two files' *content* in sync automatically. That broke in production because Connect Cloud's dependency resolver doesn't run `pip install`, so it can't interpret `-e .` and fails before installing anything at all (`Error determining requirements: / does not appear to be a Python project`).

Since `daily_dose` is never actually installed there either way, `app.py` puts `src/` on `sys.path` directly rather than relying on an install step to make the import work. The duplication is now in file *content*, not just file *existence* (a dependency added to `pyproject.toml` needs the same line added to `requirements.txt` by hand).
