Files
org_web/home/guide/wird-tracker-guide.org
Zaine bb299b2f98
Some checks failed
Build Org Website / build (push) Has been cancelled
gitea runners
2026-05-06 14:55:10 +01:00

12 KiB
Executable File
Raw Blame History

Wird Tracker — Technical Guide

Overview

The wird tracker is a full-stack feature built on top of the existing org-publish site. It consists of four layers:

  • Database — a PostgreSQL table (wird_entries) storing every log event
  • Backend — a Spring Boot controller (WirdController) exposing a REST API at /api/wird/
  • Frontend JSwird-tracker.js handles all rendering and API calls
  • Org pagewird-tracker.org defines the HTML structure via #+BEGIN_EXPORT html

The page has no sidenotes (#+NO_SIDENOTES: t), so body.no-sidenotes in styles.css automatically widens the content area. wird-tracker.css complements styles.css and defers to its CSS variables, so dark mode works without any extra work.

File Locations

File Where it lives Purpose
wird-tracker.org your org source directory page structure, HTML injection
wird-tracker.css static/css/wird-tracker.css component styles
wird-tracker.js static/js/wird-tracker.js all frontend logic
WirdEntry.java src/.../model/WirdEntry.java JPA entity
CreateWirdEntryDTO.java src/.../dto/CreateWirdEntryDTO.java request body shape
WirdEntryRepository.java src/.../repository/WirdEntryRepository.java Spring Data queries
WirdService.java src/.../service/WirdService.java business logic
WirdController.java src/.../controller/WirdController.java REST endpoints
wird_schema.sql wherever you keep your SQL scripts initial DB setup

Database Schema

Tables

The main table is wird_entries. Each row is a single log event — not one row per day. This means you can log 200 durood in the morning and 300 in the evening; they aggregate to 500 on the frontend.

CREATE TABLE wird_entries (
    id         BIGSERIAL      PRIMARY KEY,
    wird_type  VARCHAR        NOT NULL,
    date       DATE           NOT NULL DEFAULT CURRENT_DATE,
    value      NUMERIC(10,2)  NOT NULL CHECK (value >= 0),
    notes      TEXT,
    created_at TIMESTAMPTZ    NOT NULL DEFAULT NOW()
);

The wird_targets table stores daily minimums per wird type. It has an effective_from column so you can change targets over time without losing history.

CREATE TABLE wird_targets (
    id             SERIAL    PRIMARY KEY,
    wird_type      VARCHAR   NOT NULL,
    target         NUMERIC   NOT NULL,
    effective_from DATE      NOT NULL DEFAULT CURRENT_DATE,
    UNIQUE (wird_type, effective_from)
);

There is also a convenience view wird_daily_totals which aggregates entries per day per type. It is not queried by the backend currently but is useful for ad-hoc psql inspection.

Note on the Enum

The schema was originally written with a PostgreSQL CREATE TYPE wird_type AS ENUM. This was dropped in favour of plain VARCHAR because Hibernate 6 cannot bind a String to a Postgres enum column without a custom PGobject converter, which itself has compatibility issues with Hibernate 6's type system. VARCHAR with application-level validation is simpler and equally safe.

REST API

All endpoints are under /api/wird/.

Method Path Description
GET /api/wird/entries All entries, newest first
GET /api/wird/entries/today Today's entries only
GET /api/wird/entries/range Entries between ?from=YYYY-MM-DD&to=...
GET /api/wird/entries/type/:t Entries for one type in a date range
POST /api/wird/entries Create a new entry

The POST body shape is:

{
  "wirdType": "durood",
  "date":     "2026-03-19",
  "value":    500,
  "notes":    "after fajr"
}

All fields except notes are required. date defaults to today on the backend if omitted, but the frontend always sends it explicitly.

Frontend Architecture

Key constants

At the top of wird-tracker.js there are two objects you will edit most often:

const DAILY_WIRD = {
  durood:       { label: "Durood",       unit: "count", target: 500 },
  istighfar:    { label: "Istighfar",    unit: "count", target: 200 },
  quran:        { label: "Qurʾān",       unit: "juz",   target: 3   },
  muraqabah:    { label: "Murāqabah",    unit: "min",   target: 20  },
  wuqoof_qalbi: { label: "Wuqūf Qalbī", unit: "min",   target: 15  },
};

const MEETING_CYCLE_DAYS = 21;

DAILY_WIRD drives the today cards, the progress bar count, the trend chart dropdown, and the history table. WERD_META is a superset of DAILY_WIRD that also includes shaykh_meeting — used for labelling history entries and formatting values.

Rendering pipeline

On page load, the sequence is:

  1. loadAll() — fetches /api/wird/entries, populates allEntries
  2. buildTodayMap() — aggregates today's entries into todayMap (type → total)
  3. buildMeetingLog() — filters allEntries to attended shaykh meetings
  4. renderToday() — draws the 5 wird cards and progress bar
  5. renderMeetingPanel() — draws the meeting status, next due date, cycle table
  6. renderHistory() — populates the history table (meetings excluded)
  7. renderChart() — draws the Chart.js trend line for the selected wird

After any POST (new entry), steps 27 all re-run so the page updates without a reload.

Shaykh meeting cycle logic

buildCycleInsights() works by anchoring cycles to the date of your very first logged meeting and walking forward in 21-day windows until today. For each window it checks whether any attended meeting falls within it. This means the cycle boundaries are stable — they do not shift when you log a new meeting. If you want cycles to reset from the most recent meeting instead, change the anchor line:

// Current: anchored to first ever meeting
const first = allMeetingDates[0];

// Alternative: rolling window from most recent
const first = addDays(allMeetingDates[allMeetingDates.length - 1], 0);

How To: Common Tasks

Change a daily target

Targets are currently hard-coded in DAILY_WIRD in wird-tracker.js. Change the target value for the relevant entry:

durood: { label: "Durood", unit: "count", target: 700 },

If you want targets to come from the database instead (so you can change them without redeploying), the wird_targets table already supports this. You would need to add a /api/wird/targets endpoint in WirdController and fetch it in loadAll(), then replace the hard-coded target values with the fetched ones.

Add a new wird type

There are four places to update:

  1. wird-tracker.js — add an entry to DAILY_WIRD:
tawbah: { label: "Tawbah", unit: "count", target: 100 },
  1. wird-tracker.org — add an <option> to the modal <select>:
<option value="tawbah">Tawbah</option>
  1. wird-tracker.org — add an <option> to the trend chart <select>:
<option value="tawbah">Tawbah</option>
  1. wird_targets table — insert a default target (optional but tidy):
INSERT INTO wird_targets (wird_type, target)
VALUES ('tawbah', 100);

No backend changes are needed — WirdController accepts any string as wirdType and stores it as-is.

Change the shaykh meeting cycle length

One line in wird-tracker.js:

const MEETING_CYCLE_DAYS = 21;  // change to e.g. 14

Edit or delete an entry

There is currently no edit/delete UI. You can do it directly in psql:

-- Find the entry
SELECT * FROM wird_entries
WHERE wird_type = 'durood' AND date = '2026-03-19'
ORDER BY created_at DESC;

-- Delete by id
DELETE FROM wird_entries WHERE id = 42;

-- Correct a value
UPDATE wird_entries SET value = 350 WHERE id = 42;

If you want a delete button in the UI, the backend needs a DELETE endpoint:

@DeleteMapping("/entries/{id}")
public ResponseEntity<Void> deleteEntry(@PathVariable Long id) {
    repo.deleteById(id);
    return ResponseEntity.noContent().build();
}

Then in the JS, add a delete button to each history row and call:

await fetch(`${API}/entries/${id}`, { method: "DELETE" });

Inspect data directly

The wird_daily_totals view is useful for quick summaries:

-- Today's totals
SELECT wird_type, total, log_count
FROM wird_daily_totals
WHERE date = CURRENT_DATE;

-- Last 7 days of durood
SELECT date, total
FROM wird_daily_totals
WHERE wird_type = 'durood'
  AND date >= CURRENT_DATE - INTERVAL '7 days'
ORDER BY date DESC;

-- Check whether you met target each day
SELECT date, wird_type, total,
       CASE WHEN total >= t.target THEN 'met' ELSE 'missed' END AS status
FROM wird_daily_totals w
JOIN wird_targets t USING (wird_type)
WHERE t.effective_from = (
    SELECT MAX(effective_from) FROM wird_targets t2
    WHERE t2.wird_type = w.wird_type
      AND t2.effective_from <= w.date
)
ORDER BY date DESC, wird_type;

Extensibility Notes

Adding a weekly/monthly summary endpoint

The backend is structured to make this easy. Add a method to WirdEntryRepository using a @Query and expose it via a new @GetMapping in WirdController. The JS can then call it and render an additional panel without touching anything else.

Making targets configurable via the DB

The wird_targets table already has effective_from, which means you can version targets over time. A query like the one in the last section above shows the pattern for joining targets to entries correctly — finding the most recent target that was in effect on a given date.

Adding authentication

Currently the API has no auth — it is assumed the page is on a personal/private site. If you ever need to restrict writes, the cleanest approach given the existing Spring Boot setup is to add a simple API key check in a HandlerInterceptor that only applies to POST and DELETE methods on /api/wird/.

Porting the frontend to a proper framework

The JS is a self-contained IIFE with no build step, which suits the org-publish workflow. If you ever move to a build pipeline, the logic maps cleanly onto a React component tree: <TodayPanel>, <MeetingPanel>, <TrendChart>, <HistoryTable> — each taking allEntries as a prop and deriving their state from it.

Deployment Checklist

When you deploy changes, the steps depend on what you changed:

Changed file Action needed
wird-tracker.org Re-run org-publish; the HTML will be regenerated
wird-tracker.css Copy to static/css/; hard-refresh browser cache
wird-tracker.js Copy to static/js/; hard-refresh browser cache
Any *.java file Rebuild and restart the Spring Boot jar
SQL schema changes Run the migration manually in psql; restart Spring Boot

For CSS/JS cache busting during development, append a query string to the <link> and <script> tags in the #+BEGIN_EXPORT html block:

<link rel="stylesheet" href="/css/wird-tracker.css?v=2">
<script src="/js/wird-tracker.js?v=2"></script>