diff --git a/pom.xml b/pom.xml
index 9cf429c..5748a4f 100755
--- a/pom.xml
+++ b/pom.xml
@@ -42,7 +42,11 @@
org.postgresql
postgresql
- 42.7.8
+
+
+
+ org.xerial
+ sqlite-jdbc
diff --git a/src/main/java/org/zaine/app/controller/CalibreController.java b/src/main/java/org/zaine/app/controller/CalibreController.java
new file mode 100644
index 0000000..32571bf
--- /dev/null
+++ b/src/main/java/org/zaine/app/controller/CalibreController.java
@@ -0,0 +1,32 @@
+package org.zaine.app.controller;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+import org.zaine.app.dto.CalibreBookDTO;
+import org.zaine.app.service.CalibreService;
+
+import java.util.List;
+
+/**
+ * Exposes read-only Calibre library data to the frontend.
+ * Only used to populate the book picker in the Mutāla'ah modal.
+ */
+@RestController
+@RequestMapping("/api/calibre")
+public class CalibreController {
+
+ @Autowired
+ private CalibreService calibreService;
+
+ /**
+ * GET /api/calibre/books
+ * Returns all books (id, title, authors) sorted alphabetically.
+ * Used to populate the Mutāla'ah session book picker.
+ */
+ @GetMapping("/books")
+ public List getBooks() {
+ return calibreService.getAllBooks();
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/org/zaine/app/controller/MotalahController.java b/src/main/java/org/zaine/app/controller/MotalahController.java
new file mode 100644
index 0000000..0192eb8
--- /dev/null
+++ b/src/main/java/org/zaine/app/controller/MotalahController.java
@@ -0,0 +1,75 @@
+package org.zaine.app.controller;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.format.annotation.DateTimeFormat;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+import org.zaine.app.dto.MotalahSessionDTO;
+import org.zaine.app.model.MotalahSession;
+import org.zaine.app.service.MotalahService;
+
+import java.lang.System.Logger;
+import java.time.LocalDate;
+import java.util.List;
+
+@RestController
+@RequestMapping("/api/wird/motalah")
+public class MotalahController {
+
+ private static final Logger logger = System.getLogger(MotalahController.class.getName());
+
+ @Autowired
+ private MotalahService motalahService;
+
+ /**
+ * GET /api/wird/motalah
+ * All study sessions, newest first.
+ */
+ @GetMapping
+ public List getAll() {
+ logger.log(Logger.Level.INFO, "Fetching all motalah sessions");
+ return motalahService.getAll();
+ }
+
+ /**
+ * GET /api/wird/motalah/today
+ * Convenience endpoint for today's sessions only.
+ */
+ @GetMapping("/today")
+ public List getToday() {
+ return motalahService.getToday();
+ }
+
+ /**
+ * GET /api/wird/motalah/range?from=2025-01-01&to=2025-01-31
+ * Used by the trend chart and month-total stat.
+ */
+ @GetMapping("/range")
+ public List getInRange(
+ @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from,
+ @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to) {
+ return motalahService.getInRange(from, to);
+ }
+
+ /**
+ * POST /api/wird/motalah
+ * Body: { date, durationMinutes, bookIds, notes }
+ */
+ @PostMapping
+ public ResponseEntity create(@RequestBody MotalahSessionDTO dto) {
+ logger.log(Logger.Level.INFO, "Creating motalah session: {0} min on {1}",
+ dto.getDurationMinutes(), dto.getDate());
+ MotalahSession saved = motalahService.create(dto);
+ return ResponseEntity.ok(saved);
+ }
+
+ /**
+ * DELETE /api/wird/motalah/{id}
+ */
+ @DeleteMapping("/{id}")
+ public ResponseEntity delete(@PathVariable Long id) {
+ logger.log(Logger.Level.INFO, "Deleting motalah session id: {0}", id);
+ motalahService.delete(id);
+ return ResponseEntity.noContent().build();
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/org/zaine/app/controller/WirdController.java b/src/main/java/org/zaine/app/controller/WirdController.java
index 913d3ec..0dc3cdb 100644
--- a/src/main/java/org/zaine/app/controller/WirdController.java
+++ b/src/main/java/org/zaine/app/controller/WirdController.java
@@ -85,5 +85,23 @@ public class WirdController {
wirdService.deleteEntry(id);
return ResponseEntity.noContent().build();
}
+
+ /**
+ * GET /api/wird/entries/nafl/today
+ * Returns today's nafl prayer entries (convenience endpoint).
+ */
+ @GetMapping("/entries/nafl/today")
+ public List getNaflToday() {
+ return wirdService.getNaflForDate(LocalDate.now());
+ }
+
+ /**
+ * GET /api/wird/entries/khatm
+ * All khatm completions, newest first.
+ */
+ @GetMapping("/entries/khatm")
+ public List getKhatmEntries() {
+ return wirdService.getKhatmEntries();
+ }
}
diff --git a/src/main/java/org/zaine/app/dto/CalibreBookDTO.java b/src/main/java/org/zaine/app/dto/CalibreBookDTO.java
new file mode 100644
index 0000000..fdc4180
--- /dev/null
+++ b/src/main/java/org/zaine/app/dto/CalibreBookDTO.java
@@ -0,0 +1,22 @@
+package org.zaine.app.dto;
+
+/**
+ * Lightweight projection of a Calibre book — just what the
+ * Mutāla'ah book-picker needs.
+ */
+public class CalibreBookDTO {
+
+ private final long id;
+ private final String title;
+ private final String authors;
+
+ public CalibreBookDTO(long id, String title, String authors) {
+ this.id = id;
+ this.title = title;
+ this.authors = authors != null ? authors : "";
+ }
+
+ public long getId() { return id; }
+ public String getTitle() { return title; }
+ public String getAuthors() { return authors; }
+}
\ No newline at end of file
diff --git a/src/main/java/org/zaine/app/dto/MotalahSessionDTO.java b/src/main/java/org/zaine/app/dto/MotalahSessionDTO.java
new file mode 100644
index 0000000..201d67a
--- /dev/null
+++ b/src/main/java/org/zaine/app/dto/MotalahSessionDTO.java
@@ -0,0 +1,26 @@
+package org.zaine.app.dto;
+
+import java.time.LocalDate;
+import java.util.List;
+
+public class MotalahSessionDTO {
+
+ private LocalDate date;
+ private Integer durationMinutes;
+ private List bookIds;
+ private String notes;
+
+ // ── Getters & Setters ─────────────────────────────────
+
+ public LocalDate getDate() { return date; }
+ public void setDate(LocalDate date) { this.date = date; }
+
+ public Integer getDurationMinutes() { return durationMinutes; }
+ public void setDurationMinutes(Integer durationMinutes) { this.durationMinutes = durationMinutes; }
+
+ public List getBookIds() { return bookIds; }
+ public void setBookIds(List bookIds) { this.bookIds = bookIds; }
+
+ public String getNotes() { return notes; }
+ public void setNotes(String notes) { this.notes = notes; }
+}
\ No newline at end of file
diff --git a/src/main/java/org/zaine/app/model/IntegerListConverter.java b/src/main/java/org/zaine/app/model/IntegerListConverter.java
new file mode 100644
index 0000000..085f094
--- /dev/null
+++ b/src/main/java/org/zaine/app/model/IntegerListConverter.java
@@ -0,0 +1,42 @@
+package org.zaine.app.model;
+
+import jakarta.persistence.AttributeConverter;
+import jakarta.persistence.Converter;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.stream.Collectors;
+
+/**
+ * Converts between a Java List and a PostgreSQL-compatible
+ * comma-separated string that Hibernate stores in the integer[] column.
+ *
+ * Because standard JPA/Hibernate doesn't natively map PostgreSQL arrays,
+ * we serialise as "{1,2,3}" which is exactly what Postgres expects for
+ * an array literal when sent as a string parameter.
+ */
+@Converter
+public class IntegerListConverter implements AttributeConverter, String> {
+
+ @Override
+ public String convertToDatabaseColumn(List attribute) {
+ if (attribute == null || attribute.isEmpty()) return "{}";
+ return "{" + attribute.stream()
+ .map(String::valueOf)
+ .collect(Collectors.joining(",")) + "}";
+ }
+
+ @Override
+ public List convertToEntityAttribute(String dbData) {
+ if (dbData == null || dbData.isBlank() || dbData.equals("{}")) {
+ return Collections.emptyList();
+ }
+ // Strip surrounding braces: "{1,2,3}" → "1,2,3"
+ String inner = dbData.replaceAll("[{}]", "").trim();
+ if (inner.isEmpty()) return Collections.emptyList();
+ return Arrays.stream(inner.split(","))
+ .map(String::trim)
+ .map(Integer::parseInt)
+ .collect(Collectors.toList());
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/org/zaine/app/model/MotalahSession.java b/src/main/java/org/zaine/app/model/MotalahSession.java
new file mode 100644
index 0000000..938706a
--- /dev/null
+++ b/src/main/java/org/zaine/app/model/MotalahSession.java
@@ -0,0 +1,62 @@
+package org.zaine.app.model;
+
+import jakarta.persistence.*;
+import org.hibernate.annotations.JdbcTypeCode;
+import org.hibernate.type.SqlTypes;
+
+import java.time.LocalDate;
+import java.time.OffsetDateTime;
+import java.util.List;
+
+@Entity
+@Table(name = "motalah_sessions")
+public class MotalahSession {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(nullable = false)
+ private LocalDate date;
+
+ @Column(name = "duration_minutes", nullable = false)
+ private Integer durationMinutes;
+
+ /**
+ * Maps directly to PostgreSQL integer[].
+ * @JdbcTypeCode(SqlTypes.ARRAY) tells Hibernate to use the
+ * native JDBC Array binding — no custom converter needed.
+ */
+ @JdbcTypeCode(SqlTypes.ARRAY)
+ @Column(name = "book_ids", columnDefinition = "integer[]")
+ private List bookIds;
+
+ @Column
+ private String notes;
+
+ @Column(name = "created_at", nullable = false, updatable = false)
+ private OffsetDateTime createdAt;
+
+ @PrePersist
+ void onInsert() {
+ this.createdAt = OffsetDateTime.now();
+ }
+
+ // ── Getters & Setters ─────────────────────────────────
+
+ public Long getId() { return id; }
+
+ public LocalDate getDate() { return date; }
+ public void setDate(LocalDate date) { this.date = date; }
+
+ public Integer getDurationMinutes() { return durationMinutes; }
+ public void setDurationMinutes(Integer durationMinutes) { this.durationMinutes = durationMinutes; }
+
+ public List getBookIds() { return bookIds; }
+ public void setBookIds(List bookIds) { this.bookIds = bookIds; }
+
+ public String getNotes() { return notes; }
+ public void setNotes(String notes) { this.notes = notes; }
+
+ public OffsetDateTime getCreatedAt() { return createdAt; }
+}
\ No newline at end of file
diff --git a/src/main/java/org/zaine/app/repositories/MotalahSessionRepository.java b/src/main/java/org/zaine/app/repositories/MotalahSessionRepository.java
new file mode 100644
index 0000000..78f72e5
--- /dev/null
+++ b/src/main/java/org/zaine/app/repositories/MotalahSessionRepository.java
@@ -0,0 +1,25 @@
+package org.zaine.app.repositories;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+import org.zaine.app.model.MotalahSession;
+
+import java.time.LocalDate;
+import java.util.List;
+
+public interface MotalahSessionRepository extends JpaRepository {
+
+ /** All sessions newest first — used by the main list and heatmap. */
+ List findAllByOrderByDateDesc();
+
+ /** Sessions within a date range — used for month totals / trend chart. */
+ @Query("SELECT s FROM MotalahSession s WHERE s.date >= :from AND s.date <= :to ORDER BY s.date DESC")
+ List findInRange(
+ @Param("from") LocalDate from,
+ @Param("to") LocalDate to
+ );
+
+ /** Today's sessions. */
+ List findByDate(LocalDate date);
+}
\ No newline at end of file
diff --git a/src/main/java/org/zaine/app/repositories/WirdEntryRepository.java b/src/main/java/org/zaine/app/repositories/WirdEntryRepository.java
index 795b602..7306f9b 100644
--- a/src/main/java/org/zaine/app/repositories/WirdEntryRepository.java
+++ b/src/main/java/org/zaine/app/repositories/WirdEntryRepository.java
@@ -32,4 +32,6 @@ public interface WirdEntryRepository extends JpaRepository {
@Param("from") LocalDate from,
@Param("to") LocalDate to
);
+
+ List findByWirdTypeOrderByDateDesc(String wirdType);
}
diff --git a/src/main/java/org/zaine/app/service/CalibreService.java b/src/main/java/org/zaine/app/service/CalibreService.java
new file mode 100644
index 0000000..c569352
--- /dev/null
+++ b/src/main/java/org/zaine/app/service/CalibreService.java
@@ -0,0 +1,68 @@
+package org.zaine.app.service;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+import org.zaine.app.dto.CalibreBookDTO;
+
+import java.sql.DriverManager;
+import java.sql.ResultSet;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Reads book metadata directly from the Calibre SQLite database.
+ *
+ * The path is injected from application.properties:
+ * calibre.db.path=/home/zaine/master-folder/projects/calibre/library/metadata.db
+ *
+ * We open a fresh connection per call — the Calibre DB is local, small,
+ * and this service is called infrequently (page load only), so connection
+ * pooling is not worth the complexity here.
+ */
+@Service
+public class CalibreService {
+
+ private static final Logger log = LoggerFactory.getLogger(CalibreService.class);
+
+ @Value("${calibre.db.path}")
+ private String calibreDbPath;
+
+ private static final String QUERY = """
+ SELECT
+ b.id AS book_id,
+ b.title AS title,
+ GROUP_CONCAT(a.name, ', ') AS authors
+ FROM books b
+ LEFT JOIN books_authors_link bal ON b.id = bal.book
+ LEFT JOIN authors a ON bal.author = a.id
+ GROUP BY b.id
+ ORDER BY b.title COLLATE NOCASE
+ """;
+
+ public List getAllBooks() {
+ List books = new ArrayList<>();
+ String jdbcUrl = "jdbc:sqlite:" + calibreDbPath;
+
+ try (var conn = DriverManager.getConnection(jdbcUrl);
+ var stmt = conn.createStatement();
+ ResultSet rs = stmt.executeQuery(QUERY)) {
+
+ while (rs.next()) {
+ books.add(new CalibreBookDTO(
+ rs.getLong("book_id"),
+ rs.getString("title"),
+ rs.getString("authors")
+ ));
+ }
+
+ } catch (Exception e) {
+ log.error("Failed to read Calibre database at {}: {}", calibreDbPath, e.getMessage(), e);
+ // Return empty list rather than blowing up the page —
+ // the book picker will simply show nothing.
+ }
+
+ return books;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/org/zaine/app/service/MotalahService.java b/src/main/java/org/zaine/app/service/MotalahService.java
new file mode 100644
index 0000000..2cb397a
--- /dev/null
+++ b/src/main/java/org/zaine/app/service/MotalahService.java
@@ -0,0 +1,48 @@
+package org.zaine.app.service;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.zaine.app.dto.MotalahSessionDTO;
+import org.zaine.app.model.MotalahSession;
+import org.zaine.app.repositories.MotalahSessionRepository;
+
+import java.time.LocalDate;
+import java.util.Collections;
+import java.util.List;
+
+@Service
+public class MotalahService {
+
+ @Autowired
+ private MotalahSessionRepository repo;
+
+ /** All sessions, newest first. */
+ public List getAll() {
+ return repo.findAllByOrderByDateDesc();
+ }
+
+ /** Today's sessions. */
+ public List getToday() {
+ return repo.findByDate(LocalDate.now());
+ }
+
+ /** Sessions in a date range. */
+ public List getInRange(LocalDate from, LocalDate to) {
+ return repo.findInRange(from, to);
+ }
+
+ /** Persist a new session from the DTO. */
+ public MotalahSession create(MotalahSessionDTO dto) {
+ MotalahSession session = new MotalahSession();
+ session.setDate(dto.getDate() != null ? dto.getDate() : LocalDate.now());
+ session.setDurationMinutes(dto.getDurationMinutes());
+ session.setBookIds(dto.getBookIds() != null ? dto.getBookIds() : Collections.emptyList());
+ session.setNotes(dto.getNotes());
+ return repo.save(session);
+ }
+
+ /** Delete a session by id. Silently no-ops if not found. */
+ public void delete(Long id) {
+ repo.deleteById(id);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/org/zaine/app/service/WirdService.java b/src/main/java/org/zaine/app/service/WirdService.java
index 619792a..28d75b4 100644
--- a/src/main/java/org/zaine/app/service/WirdService.java
+++ b/src/main/java/org/zaine/app/service/WirdService.java
@@ -9,6 +9,7 @@ import org.zaine.app.repositories.WirdEntryRepository;
import java.time.LocalDate;
import java.util.List;
+import java.util.Set;
@Service
public class WirdService {
@@ -50,4 +51,16 @@ public class WirdService {
}
repo.deleteById(id);
}
+
+ public List getNaflForDate(LocalDate date) {
+ Set naflTypes = Set.of("salatul_tawbah", "salatul_hajaat", "tahajjud");
+ return repo.findAll()
+ .stream()
+ .filter(e -> naflTypes.contains(e.getWirdType()) && e.getDate().equals(date))
+ .collect(java.util.stream.Collectors.toList());
+ }
+
+ public List getKhatmEntries() {
+ return repo.findByWirdTypeOrderByDateDesc("khatm");
+ }
}