333 lines
12 KiB
Org Mode
333 lines
12 KiB
Org Mode
#+TITLE: Wird Tracker — Technical Guide
|
||
#+OPTIONS: toc:t num:t
|
||
#+SLUG: wird-tracker-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 JS* — ~wird-tracker.js~ handles all rendering and API calls
|
||
- *Org page* — ~wird-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.
|
||
|
||
#+BEGIN_SRC sql
|
||
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()
|
||
);
|
||
#+END_SRC
|
||
|
||
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.
|
||
|
||
#+BEGIN_SRC sql
|
||
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)
|
||
);
|
||
#+END_SRC
|
||
|
||
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:
|
||
|
||
#+BEGIN_SRC json
|
||
{
|
||
"wirdType": "durood",
|
||
"date": "2026-03-19",
|
||
"value": 500,
|
||
"notes": "after fajr"
|
||
}
|
||
#+END_SRC
|
||
|
||
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:
|
||
|
||
#+BEGIN_SRC javascript
|
||
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;
|
||
#+END_SRC
|
||
|
||
~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 2–7 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:
|
||
|
||
#+BEGIN_SRC javascript
|
||
// Current: anchored to first ever meeting
|
||
const first = allMeetingDates[0];
|
||
|
||
// Alternative: rolling window from most recent
|
||
const first = addDays(allMeetingDates[allMeetingDates.length - 1], 0);
|
||
#+END_SRC
|
||
|
||
* 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:
|
||
|
||
#+BEGIN_SRC javascript
|
||
durood: { label: "Durood", unit: "count", target: 700 },
|
||
#+END_SRC
|
||
|
||
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~:
|
||
|
||
#+BEGIN_SRC javascript
|
||
tawbah: { label: "Tawbah", unit: "count", target: 100 },
|
||
#+END_SRC
|
||
|
||
2. *~wird-tracker.org~* — add an ~<option>~ to the modal ~<select>~:
|
||
|
||
#+BEGIN_SRC html
|
||
<option value="tawbah">Tawbah</option>
|
||
#+END_SRC
|
||
|
||
3. *~wird-tracker.org~* — add an ~<option>~ to the trend chart ~<select>~:
|
||
|
||
#+BEGIN_SRC html
|
||
<option value="tawbah">Tawbah</option>
|
||
#+END_SRC
|
||
|
||
4. *~wird_targets~ table* — insert a default target (optional but tidy):
|
||
|
||
#+BEGIN_SRC sql
|
||
INSERT INTO wird_targets (wird_type, target)
|
||
VALUES ('tawbah', 100);
|
||
#+END_SRC
|
||
|
||
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~:
|
||
|
||
#+BEGIN_SRC javascript
|
||
const MEETING_CYCLE_DAYS = 21; // change to e.g. 14
|
||
#+END_SRC
|
||
|
||
** Edit or delete an entry
|
||
|
||
There is currently no edit/delete UI. You can do it directly in psql:
|
||
|
||
#+BEGIN_SRC sql
|
||
-- 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;
|
||
#+END_SRC
|
||
|
||
If you want a delete button in the UI, the backend needs a ~DELETE~
|
||
endpoint:
|
||
|
||
#+BEGIN_SRC java
|
||
@DeleteMapping("/entries/{id}")
|
||
public ResponseEntity<Void> deleteEntry(@PathVariable Long id) {
|
||
repo.deleteById(id);
|
||
return ResponseEntity.noContent().build();
|
||
}
|
||
#+END_SRC
|
||
|
||
Then in the JS, add a delete button to each history row and call:
|
||
|
||
#+BEGIN_SRC javascript
|
||
await fetch(`${API}/entries/${id}`, { method: "DELETE" });
|
||
#+END_SRC
|
||
|
||
** Inspect data directly
|
||
|
||
The ~wird_daily_totals~ view is useful for quick summaries:
|
||
|
||
#+BEGIN_SRC sql
|
||
-- 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;
|
||
#+END_SRC
|
||
|
||
* 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:
|
||
|
||
#+BEGIN_SRC html
|
||
<link rel="stylesheet" href="/css/wird-tracker.css?v=2">
|
||
<script src="/js/wird-tracker.js?v=2"></script>
|
||
#+END_SRC
|