weekly updates
This commit is contained in:
444
home/guide/setup.org
Executable file
444
home/guide/setup.org
Executable file
@@ -0,0 +1,444 @@
|
||||
#+TITLE: Setup
|
||||
#+OPTIONS: toc:nil num:nil
|
||||
#+COMMENTS: t
|
||||
#+SLUG: setup
|
||||
#+NO_SIDENOTES: t
|
||||
|
||||
* Introduction
|
||||
|
||||
/Last updated: <2025-12-18 Thu 22:00>/
|
||||
|
||||
See this link[fn:1] for a more generic introduction on how to publish websites using ~org-publish~.
|
||||
|
||||
This page will highlight the ~build-script.el~ used to generate this website. This entire website is generated through org files using the command:
|
||||
|
||||
#+BEGIN_SRC bash
|
||||
emacs -Q --script build-site.el
|
||||
#+END_SRC
|
||||
|
||||
* The script
|
||||
|
||||
The script is as follows, at the start I have some metadata relating to the file, followed by package management,then the declaration of variables/functions and finally the ~org-publish-project-alist~ which handles nearly all of the project generation instructions.
|
||||
|
||||
/The following code snippet will probably change often, as I can never seem to stick to a single configuration 😀/
|
||||
|
||||
#+BEGIN_SRC emacs-lisp
|
||||
;;; build-site.el --- Publish my website using org-publish -*- lexical-binding: t; -*-
|
||||
|
||||
;; Author: Zaine Qayyum <zaineulabideen@outlook.com>
|
||||
;; Created: 2025-08-08
|
||||
;; Purpose: Build and publish my static site from Org files.
|
||||
|
||||
;;; Commentary:
|
||||
;; Run this file with:
|
||||
;; emacs -Q --script build-site.el
|
||||
|
||||
;;; Code:
|
||||
|
||||
(require 'package)
|
||||
|
||||
(add-to-list 'load-path "~/master-folder/org_files/org_web/lisp/")
|
||||
|
||||
(require 'recently-updated)
|
||||
(require 'tags)
|
||||
(require 'sidenotes)
|
||||
(require 'sitemaps)
|
||||
|
||||
(setq package-user-dir (expand-file-name "./.packages"))
|
||||
(setq package-archives '(("melpa" . "https://melpa.org/packages/")
|
||||
("elpa" . "https://elpa.gnu.org/packages/")))
|
||||
|
||||
(require 'ox-publish)
|
||||
(require 'cl-lib)
|
||||
(require 'org)
|
||||
(require 'ox)
|
||||
(require 'ox-html)
|
||||
|
||||
(package-initialize)
|
||||
(unless package-archive-contents
|
||||
(package-refresh-contents))
|
||||
|
||||
(package-install 'htmlize)
|
||||
(add-to-list 'load-path "~/master-folder/org_files/org_web/")
|
||||
(require 'htmlize)
|
||||
|
||||
(defvar z-shared-head
|
||||
"
|
||||
<link rel=\"stylesheet\" href=\"/assets/styles/style.css\" />
|
||||
<link rel=\"stylesheet\" href=\"/assets/styles/bigger-picture.min.css\" />
|
||||
<link rel=\"stylesheet\" href=\"/assets/styles/comments.css\" />
|
||||
<link rel=\"stylesheet\" href=\"/assets/styles/kanban.css\" />
|
||||
<link rel=\"stylesheet\" href=\"/assets/styles/org-syntax.css\" />
|
||||
<link rel=\"stylesheet\" href=\"/assets/styles/misc.css\" />
|
||||
<link rel=\"stylesheet\" href=\"/assets/styles/toc.css\" />
|
||||
<link rel=\"stylesheet\" href=\"/assets/styles/media.css\" />
|
||||
|
||||
<script src=\"/assets/scripts/script.js\" defer></script>
|
||||
<script src=\"/assets/scripts/competency-status-board.js\" defer></script>
|
||||
<script src=\"/assets/scripts/comments.js\" defer></script>
|
||||
<script src=\"/assets/scripts/bigger-picture.min.js\" defer></script>
|
||||
<script src=\"/assets/scripts/svg-pan-zoom.min.js\" defer></script>
|
||||
<script src=\"/assets/scripts/gallery-init.js\" defer></script>
|
||||
"
|
||||
)
|
||||
|
||||
(setq org-export-global-macros
|
||||
(append
|
||||
'(("sidenote"
|
||||
. "@@html:<label for=\"sn$1\" class=\"margin-toggle sidenote-number\"></label><input type=\"checkbox\" id=\"sn$1\" class=\"margin-toggle\"/><span class=\"sidenote\">$2</span>@@")
|
||||
("epigraph" . "@@html:<div class=\"epigraph\"><blockquote>$1<footer>$2</footer></blockquote></div>@@")
|
||||
("epigraph_single" . "@@html:<div class=\"epigraph\"><blockquote>$1</blockquote></div>@@")
|
||||
("epigraph3" . "@@html:<div class=\"epigraph\"><blockquote>$1<footer>$2, <cite>$3</cite></footer></blockquote></div>@@")
|
||||
("kbd" . "@@html:<kbd>$1</kbd>@@@@latex:\\texttt{$1}@@")
|
||||
("margimg"
|
||||
. "@@html:<aside class=\"marginnote\"><figure class=\"mn-fig\"><img src=\"$1\" alt=\"$2\" class=\"mn-img\" loading=\"lazy\" decoding=\"async\"/>$3</figure></aside>@@")
|
||||
("countdown" . "@@html:<time class=\"countdown\" datetime=\"$1\" data-label=\"$2\"></time>@@")
|
||||
|
||||
|
||||
org-export-global-macros)))
|
||||
|
||||
|
||||
(defvar z-preamble
|
||||
"
|
||||
<div class=\"banner-header\">
|
||||
<a href=\"/\"> <img src=\"/assets/images/gr.png\" alt=\"Site Logo\" class=\"banner-logo\" /> </a>
|
||||
<nav>
|
||||
<a href=\"/\">Home | </a>
|
||||
<a href=\"/blogs/2025/2025-list.html \">2025 | </a>
|
||||
<a href=\"/blogs/blogs-list.html\">Blogs | </a>
|
||||
<a href=\"/posts/career/career-list.html\">Career | </a>
|
||||
<a href=\"/home/services.html\">Services</a>
|
||||
</nav>
|
||||
<button class=\"theme-toggle\" id=\"theme-toggle\" type=\"button\" aria-label=\"Toggle dark mode\">🌗 Theme</button>
|
||||
</div>
|
||||
<div id=\"updated\">Updated: %C</div>
|
||||
|
||||
"
|
||||
)
|
||||
|
||||
(defvar z-postamble
|
||||
"<footer>
|
||||
<div class=\"copyright-container\">
|
||||
<div class=\"copyright\">
|
||||
Copyright © 2022-2025 Zaine Qayyum. All rights reserved unless otherwise noted.</div></div>
|
||||
<div class=\"generated\">
|
||||
Created with %c on <a href=\"https://www.archlinux.org/\">Arch</a> <a href=\"https://www.gnu.org\">GNU</a>/<a href=\"https://www.kernel.org/\">Linux</a>
|
||||
</div>
|
||||
</footer>")
|
||||
|
||||
|
||||
|
||||
(defun z/comments-file-p (file)
|
||||
"Return non-nil if FILE has comments enabled.
|
||||
A file has comments if:
|
||||
- It has a #+COMMENTS: keyword with value \"t\" (case-insensitive)."
|
||||
(when (file-exists-p file)
|
||||
(with-temp-buffer
|
||||
(insert-file-contents file)
|
||||
(org-mode)
|
||||
(let* ((keywords (org-collect-keywords '("COMMENTS")))
|
||||
(comments (cadr (assoc "COMMENTS" keywords))))
|
||||
(and comments
|
||||
(string-match-p "^\\s-*t\\s-*$"
|
||||
(downcase comments)))))))
|
||||
|
||||
(defun z/comments-file-slug (file)
|
||||
"Return page slug from #+SLUG: or fallback to filename."
|
||||
(with-temp-buffer
|
||||
(insert-file-contents file)
|
||||
(org-mode)
|
||||
(let* ((keywords (org-collect-keywords '("SLUG")))
|
||||
(slug (cadr (assoc "SLUG" keywords))))
|
||||
(if (and slug (string-match-p "\\S-" slug))
|
||||
slug
|
||||
(file-name-base file)))))
|
||||
|
||||
(defun z/org-html-insert-comments-into-body (body backend info)
|
||||
"Insert comments section at the end of the document body."
|
||||
(when (org-export-derived-backend-p backend 'html)
|
||||
(let ((input-file (plist-get info :input-file)))
|
||||
(if (and input-file
|
||||
(z/comments-file-p input-file))
|
||||
(let* ((slug (z/comments-file-slug input-file))
|
||||
(comments-html
|
||||
(format
|
||||
"
|
||||
<section id=\"comments\" class=\"comments\" data-slug=\"%s\">
|
||||
<h2>Comments</h2>
|
||||
|
||||
<div id=\"comments-list\" class=\"comments-list\">
|
||||
<noscript>Please enable JavaScript to view comments.</noscript>
|
||||
</div>
|
||||
|
||||
<form id=\"comment-form\" class=\"comment-form\">
|
||||
<label class=\"comment-author\">
|
||||
<span>Name (optional)</span>
|
||||
<input type=\"text\" name=\"author\"/>
|
||||
</label>
|
||||
|
||||
<label class=\"comment-content\">
|
||||
<span>Your comment</span>
|
||||
<textarea name=\"content\" required></textarea>
|
||||
</label>
|
||||
|
||||
<button type=\"submit\">Post comment</button>
|
||||
</form>
|
||||
</section>
|
||||
"
|
||||
slug)))
|
||||
(concat body comments-html))
|
||||
body))))
|
||||
|
||||
|
||||
(defun z/org-html-add-body-classes (output backend info)
|
||||
"Add layout-related classes to <body> based on file metadata."
|
||||
(when (org-export-derived-backend-p backend 'html)
|
||||
(let* ((input-file (plist-get info :input-file))
|
||||
(classes
|
||||
(delq nil
|
||||
(list
|
||||
(when (and input-file
|
||||
(z/wip-file-p input-file))
|
||||
"wip")
|
||||
(when (and input-file
|
||||
(z/no-sidenotes-file-p input-file))
|
||||
"no-sidenotes")))))
|
||||
(if classes
|
||||
(replace-regexp-in-string
|
||||
"<body\\([^>]*\\)>"
|
||||
(format "<body\\1 class=\"%s\">"
|
||||
(string-join classes " "))
|
||||
output)
|
||||
output))))
|
||||
|
||||
(add-to-list 'org-export-filter-final-output-functions
|
||||
#'z/org-html-add-body-classes)
|
||||
(add-to-list 'org-export-filter-body-functions
|
||||
#'z/org-html-insert-comments-into-body)
|
||||
|
||||
|
||||
|
||||
(setq org-html-htmlize-output-type 'css)
|
||||
|
||||
(org-export-define-derived-backend 'z-html 'html
|
||||
:filters-alist '((:filter-final-output . z/insert-filetags-after-title)))
|
||||
|
||||
(defun z/org-main-prep (_project)
|
||||
(z/generate-recently-updated-org 26))
|
||||
|
||||
(defun z/z-publish-to-html (plist filename pub-dir)
|
||||
"Publish FILENAME to HTML using the z-html backend."
|
||||
(org-publish-org-to 'z-html filename ".html" plist pub-dir))
|
||||
|
||||
;; Define the publishing project
|
||||
(setq org-publish-project-alist
|
||||
`(("org-main"
|
||||
:recursive t
|
||||
:base-directory "~/master-folder/org_files/org_web/"
|
||||
:publishing-function z/z-publish-to-html
|
||||
:publishing-directory "~/master-folder/org_files/org_web/output"
|
||||
:base-extension "org"
|
||||
:auto-sitemap t
|
||||
:sitemap-filename "sitemap.org"
|
||||
:sitemap-title "Sitemap"
|
||||
:sitemap-sort-files chronologically
|
||||
:html-preamble ,z-preamble
|
||||
:html-postamble ,z-postamble
|
||||
:html-head ,z-shared-head
|
||||
:preparation-function z/org-main-prep
|
||||
)
|
||||
|
||||
("org-categories-sitemap"
|
||||
:recursive t
|
||||
:base-directory "~/master-folder/org_files/org_web/home"
|
||||
:publishing-directory "~/master-folder/org_files/org_web/output/home"
|
||||
:base-extension "org"
|
||||
:auto-sitemap t
|
||||
:sitemap-filename "categories.org"
|
||||
:sitemap-title "Categories"
|
||||
:sitemap-function z/categories-sitemap
|
||||
:html-preamble ,z-preamble
|
||||
:html-postamble ,z-postamble
|
||||
:html-head ,z-shared-head
|
||||
)
|
||||
("org-posts"
|
||||
:base-directory "~/master-folder/org_files/org_web/posts"
|
||||
:publishing-directory "~/master-folder/org_files/org_web/output/posts/"
|
||||
:recursive t
|
||||
:base-extension "org"
|
||||
:publishing-function z/z-publish-to-html
|
||||
:with-author nil
|
||||
:with-creator nil
|
||||
:html-validation-link nil
|
||||
:with-toc t
|
||||
:section-numbers t
|
||||
:html-preamble ,z-preamble
|
||||
:html-postamble ,z-postamble
|
||||
:auto-sitemap t
|
||||
:sitemap-filename "posts-list.org"
|
||||
:sitemap-title "Posts List"
|
||||
:sitemap-style list
|
||||
:sitemap-function z/posts-sitemap
|
||||
:sitemap-sort-files anti-chronologically
|
||||
:html-head ,z-shared-head
|
||||
)
|
||||
("org-blogs"
|
||||
:base-directory "~/master-folder/org_files/org_web/blogs"
|
||||
:publishing-directory "~/master-folder/org_files/org_web/output/blogs/"
|
||||
:recursive t
|
||||
:base-extension "org"
|
||||
:publishing-function z/z-publish-to-html
|
||||
:with-author nil
|
||||
:with-creator nil
|
||||
:html-validation-link nil
|
||||
:html-preamble ,z-preamble
|
||||
:html-postamble ,z-postamble
|
||||
:auto-sitemap t
|
||||
:sitemap-filename "blogs-list.org"
|
||||
:sitemap-title "Blogs List"
|
||||
:sitemap-style list
|
||||
:sitemap-function z/blogs-sitemap
|
||||
:sitemap-sort-files anti-chronologically
|
||||
:html-head ,z-shared-head
|
||||
)
|
||||
("org-books"
|
||||
:base-directory "~/master-folder/org_files/org_web/books/"
|
||||
:publishing-directory "~/master-folder/org_files/org_web/output/books/"
|
||||
:recursive t
|
||||
:base-extension "org"
|
||||
:publishing-function z/z-publish-to-html
|
||||
:with-author nil
|
||||
:with-creator nil
|
||||
:html-validation-link nil
|
||||
:html-preamble ,z-preamble
|
||||
:html-postamble ,z-postamble
|
||||
:auto-sitemap t
|
||||
:sitemap-filename "books-list.org"
|
||||
:sitemap-title "Books List"
|
||||
:sitemap-style list
|
||||
:sitemap-function z/books-sitemap
|
||||
:sitemap-sort-files anti-chronologically
|
||||
:html-head ,z-shared-head
|
||||
)
|
||||
("org-2025"
|
||||
:base-directory "~/master-folder/org_files/org_web/blogs/2025/"
|
||||
:publishing-directory "~/master-folder/org_files/org_web/output/blogs/2025/"
|
||||
:recursive t
|
||||
:base-extension "org"
|
||||
:publishing-function z/z-publish-to-html
|
||||
:with-author nil
|
||||
:with-creator nil
|
||||
:html-validation-link nil
|
||||
:html-preamble ,z-preamble
|
||||
:html-postamble ,z-postamble
|
||||
:auto-sitemap t
|
||||
:sitemap-filename "2025-list.org"
|
||||
:sitemap-title "2025 List"
|
||||
:sitemap-style list
|
||||
:sitemap-function z/2025-sitemap
|
||||
:sitemap-sort-files anti-chronologically
|
||||
:html-head ,z-shared-head
|
||||
)
|
||||
("org-career"
|
||||
:base-directory "~/master-folder/org_files/org_web/posts/career/"
|
||||
:publishing-directory "~/master-folder/org_files/org_web/output/posts/career/"
|
||||
:recursive t
|
||||
:base-extension "org"
|
||||
:publishing-function z/z-publish-to-html
|
||||
:with-author nil
|
||||
:with-creator nil
|
||||
:html-validation-link nil
|
||||
:html-preamble ,z-preamble
|
||||
:html-postamble ,z-postamble
|
||||
:auto-sitemap t
|
||||
:sitemap-filename "career-list.org"
|
||||
:sitemap-title "Career List"
|
||||
:sitemap-style list
|
||||
:sitemap-function z/career-sitemap
|
||||
:sitemap-sort-files anti-chronologically
|
||||
:html-head ,z-shared-head
|
||||
)
|
||||
("wip-pages"
|
||||
:base-directory "~/master-folder/org_files/org_web/"
|
||||
:publishing-directory "~/master-folder/org_files/org_web/output/"
|
||||
:recursive t
|
||||
:base-extension "org"
|
||||
:publishing-function z/z-publish-to-html
|
||||
:with-author nil
|
||||
:with-creator nil
|
||||
:html-validation-link nil
|
||||
:html-preamble ,z-preamble
|
||||
:html-postamble ,z-postamble
|
||||
:auto-sitemap t
|
||||
:sitemap-filename "wip.org"
|
||||
:sitemap-title "Work in progress"
|
||||
:sitemap-style list
|
||||
:sitemap-function z/wip-sitemap
|
||||
:sitemap-sort-files anti-chronologically
|
||||
:html-head ,z-shared-head
|
||||
)
|
||||
("org-tags"
|
||||
:base-directory "~/master-folder/org_files/org_web/tags"
|
||||
:publishing-directory "~/master-folder/org_files/org_web/output/tags"
|
||||
:recursive t
|
||||
:base-extension "org"
|
||||
:publishing-function org-html-publish-to-html
|
||||
:with-author nil
|
||||
:with-creator nil
|
||||
:html-preamble ,z-preamble
|
||||
:html-postamble ,z-postamble
|
||||
:html-head ,z-shared-head)
|
||||
("org-assets"
|
||||
:base-directory "~/master-folder/org_files/org_web/assets/"
|
||||
:base-extension "css\\|js\\|png\\|jpg\\|gif\\|svg\\|pdf\\|woff\\|woff2\\|ttf"
|
||||
:publishing-directory "~/master-folder/org_files/org_web/output/assets/"
|
||||
:recursive t
|
||||
:publishing-function org-publish-attachment)
|
||||
))
|
||||
|
||||
|
||||
(delete-directory "~/master-folder/org_files/org_web/output/" t)
|
||||
(delete-directory "~/master-folder/org_files/org_web/tags/" t)
|
||||
(message "Directory deleted")
|
||||
|
||||
(z/write-tag-pages)
|
||||
(org-publish-all t)
|
||||
(message "Build complete!")
|
||||
|
||||
;;; build-site.el ends here
|
||||
#+END_SRC
|
||||
|
||||
* Publish Script
|
||||
/Note: This is legacy, just keeping it here for logs/
|
||||
|
||||
As the project is hosted on Github, I have created a small script that is able to push changes to the remote repository, which Cloudflare will automatically detect and rebuild the website:
|
||||
|
||||
#+BEGIN_SRC bash
|
||||
|
||||
#!/bin/bash
|
||||
|
||||
# Exit immediately if any command fails
|
||||
set -e
|
||||
|
||||
# Define paths
|
||||
ORG_OUTPUT_DIR="$HOME/master-folder/org_files/org_web/output"
|
||||
|
||||
cd "$ORG_OUTPUT_DIR"
|
||||
|
||||
echo "HTML published to: $ORG_OUTPUT_DIR"
|
||||
|
||||
# Git commands
|
||||
echo "Adding changes to Git..."
|
||||
git add .
|
||||
|
||||
echo "Committing..."
|
||||
git commit -m "Auto-publish on $(date)" || echo "Nothing to commit."
|
||||
|
||||
echo "Pushing to GitHub..."
|
||||
git push origin main
|
||||
|
||||
echo "Done! Changes pushed and Cloudflare should rebuild the site."
|
||||
|
||||
#+END_SRC
|
||||
|
||||
[fn:1] See: [[../blogs/publish-pages.org][this link]]
|
||||
332
home/guide/wird-tracker-guide.org
Normal file
332
home/guide/wird-tracker-guide.org
Normal file
@@ -0,0 +1,332 @@
|
||||
#+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
|
||||
Reference in New Issue
Block a user