← All work CODE · 2025

I Built a Full-Stack Health Tracker. For One Very Specific Metric.

The appliction that you may need only on certain ocassions.

An ASP.NET Core MVC application with authentication, analytics, and a calendar - built for the one health metric nobody talks about, but everybody has.

I Built a Full-Stack Health Tracker. For One Very Specific Metric.

Let's get something out of the way first: the Bristol Stool Scale is a clinically validated medical tool. It appears in gastroenterology literature. It has seven types, a Wikipedia article, and a real diagnostic purpose. This project takes that seriously. The project name, not so much.

If you have a digestive condition such as Crohn's, IBS, or coeliac disease, or even just a complicated relationship with coffee and oat milk, logging your daily readings against a consistent scale is genuinely useful data. Every existing app for this is either a £9.99-a-month subscription wrapped in wellness language, or a spreadsheet someone posted to Reddit in 2019. Neither felt right, so I built a third option.

What It Does

BowelMovementTracker is a focused, full-stack web application for logging, dating, and analysing bowel movements classified against the Bristol Stool Scale, with optional dietary flags for coffee and milk consumption. A per-user diary stores individual log entries. An analytics dashboard provides four Chart.js visualisations across selectable time windows: 7 days, 30 days, and 1 year. A pure-CSS interactive calendar lets you browse and inspect history by month. Cookie authentication keeps each user's data behind their own login.

That is the feature set. No gamification, no streak counters, no AI insights. Just a clean log and the data to read it.

The Stack

Layer Technology Notes
Framework ASP.NET Core MVC (.NET 10) Server-rendered Razor views throughout
ORM Entity Framework Core Code-first, migrations applied on startup
Database SQL Server newsequentialid() for all primary keys
Auth Cookie Authentication + Identity PasswordHasher HttpOnly, SameSite=Strict, 7-day sliding expiry
Frontend Bootstrap 5 + Chart.js + Razor Dark mode via data-bs-theme, no build step
Hosting Azure App Service + Azure SQL Free tier. Fully functional at zero cost.

App Demo

The Data Model

Three entities. One user owns one diary. One diary holds many logs. Cascade deletes on both foreign keys, so removing a user cleans up everything downstream without leaving orphaned records.

Entity Key Fields Relation
User GUID PK · Email (unique, 50 chars) · PasswordHash (128 chars) 1 to 1 Diary
Diary GUID PK · DiaryUserIdentifier (FK) 1 to many Logs
Log GUID PK · BristolType (enum int) · DateTime · CoffeeConsumed · MilkConsumed · Notes (512) Child of Diary

All primary keys are generated by newsequentialid() on the SQL Server side. Sequential enough to avoid index fragmentation, and opaque enough that clients cannot enumerate records by incrementing an integer.

Four Things Worth Talking About

1. The Enum With a Lot to Say

Every log entry is classified using the Bristol Stool Scale, represented as a C# enum with self-documenting names and integer backing values that match the clinical scale directly.

public enum BristolStoolScale
{
    Type1SeparateHardLumps   = 1,  // Severe constipation
    Type2SausageShapedLumpy  = 2,  // Mild constipation
    Type3SausageWithCracks   = 3,  // Normal
    Type4SausageSmoothSoft   = 4,  // Normal (Ideal)
    Type5SoftBlobsClearEdges = 5,  // Lacking fibre
    Type6FluffyPiecesMushy   = 6,  // Mild diarrhoea
    Type7WateryNoSolidPieces = 7   // Severe diarrhoea
}

Because the integer values are the actual clinical scores, casting anywhere in the analytics pipeline is zero-overhead and semantically correct. Computing the average Bristol score for a date range is literally logs.Average(x => (int)x.BowelMovementType). Filtering for diarrhoea-range entries is x => (int)x.BowelMovementType >= 5. No lookup tables, no magic numbers scattered across controllers. The enum does the work.

2. The Ownership Guard

Routes in this application are GUID-scoped: /{userid:guid}/Analytics, /{userid:guid?}/Calendar. That raises an obvious question: what stops an authenticated user from swapping someone else's GUID into the URL and reading their data?

The answer is a small injected service behind the IGuard interface, called at the top of every user-scoped controller action before any database query runs.

public IActionResult? ValidateOrRedirect(Guid requestedUserId)
{
    // Pull the authenticated user's ID from their cookie claims
    var loggedInUserIdStr = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;

    // Safety catch: malformed or missing claim
    if (!Guid.TryParse(loggedInUserIdStr, out Guid loggedInUserId))
        return new UnauthorizedResult();

    // URL ID does not match session ID — this is 403, not 404
    if (requestedUserId != loggedInUserId)
        return new ForbidResult();

    return null; // Ownership confirmed. Proceed.
}

Each controller action calls this first and short-circuits on a non-null return. The interface keeps ownership logic in one place, making it injectable and independently testable. There is also a deliberate choice in the implementation: if an unauthenticated user somehow reaches this service without the [Authorize] attribute in place, it throws InvalidOperationException rather than silently redirecting. That is intentional. It means you missed the attribute, and you should find out loudly at development time rather than quietly in production.

3. The Analytics ViewModel

Rather than spreading calculation logic across controller actions, the AnalyticsViewModel handles its own computations. The controller loads all logs, calls ApplyDashboardDateFilter() once with the requested time window, and passes the model to the view. The view reads computed properties from a pre-filtered list it did not have to build itself.

The average time-between-logs calculation uses a telescoping sum rather than iterating adjacent pairs:

public TimeSpan GetFilteredAverageLogTimeSpan()
{
    if (FilteredDashboardLogs.Count < 2) return TimeSpan.Zero;

    var minDate = FilteredDashboardLogs.Min(l => l.DateTime);
    var maxDate = FilteredDashboardLogs.Max(l => l.DateTime);

    // (Max − Min) / (Count − 1) — skips iterating adjacent pairs entirely
    var totalSpan = maxDate - minDate ?? TimeSpan.Zero;
    var averageTicks = Math.Abs(totalSpan.Ticks / (FilteredDashboardLogs.Count - 1));

    return TimeSpan.FromTicks(averageTicks);
}

The reasoning: the average interval between N events is the total span divided by (N minus 1) intervals. That only needs two passes over the list with Min and Max rather than sorting and pairing. At the scale of a personal diary the difference is trivial, but the intention is to keep the logic clear and avoid unnecessary complexity.

4. The Pure-CSS Calendar

The calendar view has no JavaScript. Month navigation uses standard form posts. Expanding a day cell to show log history is driven entirely by a hidden radio input and the CSS :checked pseudo-class. The part worth noting is the edge detection: a popout panel expanding from a corner or edge cell should not bleed outside the calendar grid.

/* Right-edge cells (Thu, Fri, Sat): expand leftward instead */
.day-wrapper:nth-of-type(7n) .day-popout,
.day-wrapper:nth-of-type(7n-1) .day-popout,
.day-wrapper:nth-of-type(7n-2) .day-popout {
    left: auto;
    right: -2px;
    transform-origin: right top;
    flex-direction: row-reverse;
}

/* Bottom two rows: expand upwards */
.day-wrapper:nth-last-of-type(-n+14) .day-popout {
    top: auto;
    bottom: -2px;
    transform-origin: left bottom;
}

/* Corner cells: expand up and left simultaneously */
.day-wrapper:nth-last-of-type(-n+14):nth-of-type(7n) .day-popout,
.day-wrapper:nth-last-of-type(-n+14):nth-of-type(7n-1) .day-popout,
.day-wrapper:nth-last-of-type(-n+14):nth-of-type(7n-2) .day-popout {
    transform-origin: right bottom;
}

nth-of-type(7n) selects every seventh wrapper (Saturday). nth-last-of-type(-n+14) selects the last fourteen wrappers, which are the final two rows. Combining the two selectors handles the corner cases without a single getBoundingClientRect() call at runtime.

Honest Shortcomings

A few things that are not finished yet, because pretending otherwise would be misleading.

Registration is locked to a hardcoded email allowlist. The route and logic exist and work, but adding a new user currently requires editing source code and redeploying. An admin interface is planned. This was a deliberate shortcut to get core tracking functionality live first.

No account deletion via the UI. Individual log entries can be deleted. Removing an account requires a direct database query. This will be fixed in a future update.

Database.Migrate() runs on startup. This is a practical convenience for single-instance Azure deployment and works fine for the personal-use case the app is built for. In a multi-instance production environment it would be a race condition and would need replacing with a proper migration step in the pipeline before being used at that scale.

Get the Code

The repository is open. If you are setting it up locally, read the README before touching the database. There is a manual step required to create an initial user until the account creation UI is shipped.

github.com/MateuszPodeszwa/BowelMovementTracker

Issues are read. If you have a digestive condition and a specific feature would genuinely help, open one.