In my first post, I covered getting the basic functionality of my water tracker app up and running — a daily cup counter, localStorage persistence, and a weekly total. In this next phase of development I took things further by adding data visualisations with Chart.js and rethinking how data is structured and stored. Here's what I learned along the way.

Installing Chart.js

The first step was installation:

npm install chart.js react-chartjs-2

chart.js is the core library and react-chartjs-2 is a React wrapper that lets you use Chart.js charts as JSX components. Before you can render any chart you need to register the components you plan to use — Chart.js uses a tree-shakeable architecture, meaning it only includes what you explicitly register:

import { Chart as ChartJS, ArcElement, Tooltip, Legend } from "chart.js";
ChartJS.register(ArcElement, Tooltip, Legend);

Doughnut Chart — Showing Daily Progress

For the Counter.jsx component I added a doughnut chart to visually represent progress toward the daily goal of 8 cups. The data shape was straightforward — two values that always add up to 8:

data: [count, 8 - count]

To control the chart's size I wrapped it in a container div with a fixed height and set maintainAspectRatio: false in the options — this tells Chart.js to respect the container's dimensions rather than calculating its own:

options={{
  maintainAspectRatio: false,
}}

Drawing Text in the Centre of the Doughnut — Custom Plugins

I wanted to display the current count in the centre of the doughnut. I first looked at using annotations, which isn't the right approach. The solution was a custom Chart.js plugin and the 2D Canvas API:

const centerTextPlugin = {
  id: "centerText",
  afterDatasetsDraw(chart) {
    const { ctx, chartArea } = chart;
    const centerX = (chartArea.left + chartArea.right) / 2;
    const centerY = (chartArea.top + chartArea.bottom) / 2;
    ctx.save();
    ctx.font = "bold 42px Helvetica";
    ctx.fillStyle = "#010064";
    ctx.textAlign = "center";
    ctx.textBaseline = "middle";
    ctx.fillText(`${count}`, centerX, centerY);
    ctx.restore();
  }
};

This hooks into the Chart.js drawing lifecycle with afterDatasetsDraw and uses ctx.fillText() to paint text at the calculated centre point.

useRef — Keeping the Plugin in Sync with State

The plugin worked visually but the count wasn't updating live — only on refresh. The problem was that the plugin closes over the initial value of count when the component first renders. Chart.js holds onto the plugin object and doesn't know that React has re-rendered with a new value.

The fix was useRef. Unlike useState, updating a ref doesn't trigger a re-render — but it does give you a mutable value that persists across renders and always reflects the latest value when read:

const countRef = useRef(count);
countRef.current = count; // runs every render, keeping it in sync

Then in the plugin, instead of reading count directly:

ctx.fillText(`${countRef.current}`, centerX, centerY);

Because the plugin reads from ref.current at draw time rather than from a captured value, it always gets the latest count.

Restructuring Weekly Data

The original app stored weeklyCount as a single incrementing number, which required manual reset logic with isNewWeek(). For the bar chart I needed to know how many cups were drunk on each individual day, so the data structure needed rethinking.

The new approach uses a plain object in localStorage where each key is a date string and each value is the cup count for that day:

{
  "2026-02-23": 5,
  "2026-02-27": 3,
  "2026-02-28": 4
}

Using the date as the key (via new Date().toISOString().split('T')[0]) means there's no need for reset logic — each day naturally gets its own entry. The weekly total is then derived rather than tracked separately:

const weeklyCount = getWeeklyCount(dailyCounts);

Where getWeeklyCount filters to the current week and sums the values:

const getWeeklyCount = (dailyCounts) => {
  const monday = getMonday(new Date());
  return Object.keys(dailyCounts)
    .filter(date => new Date(date) >= monday)
    .reduce((sum, date) => sum + dailyCounts[date], 0);
};

This also means the old weeklyCount state, its useEffect, and the isNewWeek helper could all be removed — the code is significantly cleaner as a result.

One subtle bug I had to watch for: comparing dates parsed from ISO strings (which default to midnight UTC) against new Date() (which is local time) can cause timezone-related filtering issues. The fix is to normalise Monday to midnight:

const getMonday = (date) => {
  const d = new Date(date);
  const day = d.getDay();
  const diff = d.getDate() - day + (day === 0 ? -6 : 1);
  d.setDate(diff);
  d.setHours(0, 0, 0, 0); // normalise to midnight
  return d;
};

Updating State with the Functional Form of setState

When updating dailyCounts inside handleDrinkCup, I used the functional form of setDailyCounts to ensure I'm always working with the latest state value:

setDailyCounts(prev => {
  const updated = { ...prev, [todayKey]: (prev[todayKey] || 0) + 1 };
  localStorage.setItem('dailyCounts', JSON.stringify(updated));
  return updated;
});

A few things worth noting here. ...prev spreads the existing object so previous days are preserved. [todayKey] is a computed property key — the variable is used as the key name. And (prev[todayKey] || 0) + 1 increments today's count or starts it at 1 if it doesn't exist yet.

Bar Chart — Weekly Breakdown

For WeeklyTotal I added a Bar chart showing cups per day across the current week. This required the following Chart.js registrations:

import { Chart as ChartJS, CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend } from "chart.js";
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend);

The chart needs data as a flat array of 7 values (Mon–Sun). To transform dailyCounts into that shape I generated the 7 date strings for the current week using a for loop, then mapped over them to look up each day's value:

const weekDates = [];
for (let i = 0; i < 7; i++) {
  const date = new Date(monday);
  date.setDate(monday.getDate() + i);
  weekDates.push(date.toISOString().split('T')[0]);
}

const data = weekDates.map(date => dailyCounts[date] || 0);

To lock the y-axis to always show 0–8 regardless of the current values, scales goes at the top level of options — not inside plugins (a mistake I made initially):

options={{
  plugins: {
    title: { display: true, text: `You have drank ${weeklyCount} cups this week` },
    legend: { display: false },
  },
  scales: {
    y: { min: 0, max: 8 }
  }
}}

Summary

Chart.js requires registration — only import and register what you use.

useRef is the right tool when you need a value that persists across renders and is always current, but doesn't need to trigger re-renders itself.

Derive state where possibleweeklyCount as a derived value from dailyCounts is cleaner than tracking it separately.

Functional setState ensures you're always working with the latest state when updates depend on previous values.

scales and plugins are siblings in Chart.js options — a common mistake is nesting one inside the other.

Normalise dates to midnight when comparing date strings to avoid timezone edge cases.

Next up: some housekeeping (moving shared utility functions to utils.js) and thinking about what new functionality to add to the app.