Compare commits
10
Commits
57937d986d
...
4426b0a380
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4426b0a380 | ||
|
|
26ef999c17 | ||
|
|
2f445bdc91 | ||
|
|
ce62493057 | ||
|
|
965c47d66a | ||
|
|
13c00e9322 | ||
|
|
a35ba756ca | ||
|
|
c2459c000e | ||
|
|
daa7eb1a0d | ||
|
|
96c4b541f9 |
No files matched your search
@@ -9,26 +9,23 @@ on:
|
|||||||
- main
|
- main
|
||||||
|
|
||||||
env:
|
env:
|
||||||
REGISTRY: ghcr.io
|
REGISTRY: git.necloud.us
|
||||||
IMAGE_NAME: ${{ github.repository }}
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-push:
|
build-and-push:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Log in to GitHub Container Registry
|
- name: Log in to Gitea Container Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ github.actor }}
|
username: ${{ secrets.REGISTRY_USER }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
- name: Extract metadata
|
- name: Extract metadata
|
||||||
id: meta
|
id: meta
|
||||||
+4
-5
@@ -1,18 +1,17 @@
|
|||||||
FROM node:20-alpine AS build
|
FROM node:22-alpine AS build
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm ci
|
RUN npm install
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM node:20-alpine
|
FROM node:22-alpine
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm ci --omit=dev
|
RUN npm install --omit=dev
|
||||||
COPY server.js ./
|
COPY server.js ./
|
||||||
COPY --from=build /app/build ./build
|
COPY --from=build /app/build ./build
|
||||||
|
|
||||||
ENV DATA_DIR=/data
|
ENV DATA_DIR=/data
|
||||||
VOLUME ["/data"]
|
|
||||||
EXPOSE 4000
|
EXPOSE 4000
|
||||||
CMD ["node", "server.js"]
|
CMD ["node", "server.js"]
|
||||||
@@ -1,16 +1,26 @@
|
|||||||
# Prayer Journal
|
# Prayer Journal
|
||||||
|
|
||||||
A todo-list style prayer app. Add prayer requests, record how God answered them, filter between active and answered, and tag entries for organization. Data persists to a mounted Docker volume.
|
A todo-list style prayer app. Add prayer requests, record how God answered them, filter between active and answered, tag entries for organization, log individual prayer sessions, and receive push notifications on your phone.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Prayer requests** — add a person, request text, and tags
|
||||||
|
- **Answers** — record how God answered and mark prayers complete
|
||||||
|
- **Prayer log** — tap "I Prayed This" to log each session with optional notes
|
||||||
|
- **Calendar view** — see your prayer activity by day
|
||||||
|
- **Search** — full-text search across requests, answers, and tags
|
||||||
|
- **Export** — download your journal as a Word (.docx) document
|
||||||
|
- **Push notifications** — daily digest and per-prayer reminders via ntfy
|
||||||
|
|
||||||
## Running with Docker
|
## Running with Docker
|
||||||
|
|
||||||
### Pull the pre-built image (recommended)
|
### Quick start (pull pre-built image)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
The `docker-compose.yml` pulls `ghcr.io/nmemmert/prayer:latest` automatically. Open [http://localhost:3000](http://localhost:3000).
|
Open [http://localhost:3000](http://localhost:3000).
|
||||||
|
|
||||||
### Build locally
|
### Build locally
|
||||||
|
|
||||||
@@ -20,66 +30,87 @@ docker compose up --build -d
|
|||||||
|
|
||||||
### Data persistence
|
### Data persistence
|
||||||
|
|
||||||
Prayer data is stored in `/data/prayers.json` inside the container, mounted as the named Docker volume `prayer-data`. Data survives container restarts and image updates.
|
All data is stored in the bind-mounted `/data` directory:
|
||||||
|
|
||||||
To inspect or back up your data:
|
| File | Contents |
|
||||||
|
|------|----------|
|
||||||
|
| `prayers.json` | All prayer requests and logs |
|
||||||
|
| `settings.json` | Notification settings (ntfy topic, times) |
|
||||||
|
|
||||||
```bash
|
The default host path is `/media/ZimaOS-HD/AppData/Prayer/Data`. Change the `volumes` line in `docker-compose.yml` to match your server.
|
||||||
# Print current prayers
|
|
||||||
docker compose exec prayer cat /data/prayers.json
|
|
||||||
|
|
||||||
# Copy to host
|
### Timezone
|
||||||
docker cp $(docker compose ps -q prayer):/data/prayers.json ./prayers-backup.json
|
|
||||||
```
|
|
||||||
|
|
||||||
To wipe all data:
|
Notification times (daily digest, reminders) are evaluated in the container's timezone. Set the `TZ` environment variable in `docker-compose.yml` to your local timezone so times fire when you expect:
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose down -v
|
|
||||||
```
|
|
||||||
|
|
||||||
### Using a host-bind mount instead of a named volume
|
|
||||||
|
|
||||||
Replace the `volumes` block in `docker-compose.yml`:
|
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
volumes:
|
environment:
|
||||||
- ./prayer-data:/data
|
- TZ=America/Chicago # or America/New_York, Europe/London, etc.
|
||||||
|
```
|
||||||
|
|
||||||
|
Restart the container after changing the timezone:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Push Notifications (ntfy)
|
||||||
|
|
||||||
|
Prayer Journal sends notifications via [ntfy.sh](https://ntfy.sh) — a free, open-source push service that works without an account.
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
1. **Install the ntfy app** on your phone — search "ntfy" on the App Store or Google Play, or visit [ntfy.sh](https://ntfy.sh)
|
||||||
|
2. **Open the app** and add a subscription to your chosen topic name (e.g. `my-prayers-7x3q9`). Pick something unique and hard to guess — anyone who knows the topic name can subscribe and read your notifications, so avoid obvious names like `prayer`.
|
||||||
|
3. **Open Prayer Journal** in the browser and click **🔔 Notifications** in the header
|
||||||
|
4. **Enter the same topic name** and configure:
|
||||||
|
- **Daily digest time** — receive a list of all active prayers at this time each day (leave blank to disable)
|
||||||
|
- **Per-prayer reminder time** — the time of day individual prayer reminders fire
|
||||||
|
5. **Click "Send test"** to verify everything is working
|
||||||
|
|
||||||
|
### Per-prayer reminders
|
||||||
|
|
||||||
|
On any active prayer card, expand it and use the **🔔 Remind me** row to pick which days of the week you want a reminder for that prayer. The reminder fires at the per-prayer reminder time configured in settings.
|
||||||
|
|
||||||
|
### No account required
|
||||||
|
|
||||||
|
ntfy.sh is a free public service — no sign-up needed. The app POSTs to `https://ntfy.sh/<your-topic>` and your phone subscribes to the same URL. Self-hosting is optional.
|
||||||
|
|
||||||
|
### Self-hosted ntfy
|
||||||
|
|
||||||
|
If you run your own ntfy server, enter the full URL as the topic:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://your-ntfy-server.example.com/my-topic
|
||||||
```
|
```
|
||||||
|
|
||||||
## CI / CD
|
## CI / CD
|
||||||
|
|
||||||
Pushes to `main` trigger a GitHub Actions workflow (`.github/workflows/docker.yml`) that:
|
Pushes to `main` trigger a GitHub Actions workflow (`.github/workflows/docker.yml`) that:
|
||||||
|
|
||||||
1. Builds the Docker image (multi-stage: Node 20 → Node 20 slim)
|
1. Builds the Docker image (multi-stage: Node 22 build → Node 22 runtime)
|
||||||
2. Pushes to GitHub Container Registry (`ghcr.io/nmemmert/prayer`)
|
2. Pushes to GitHub Container Registry (`ghcr.io/nmemmert/prayer`)
|
||||||
3. Tags the image as `latest`, the branch name, and the short commit SHA
|
3. Tags the image as `latest`, the branch name, and the short commit SHA
|
||||||
|
|
||||||
Pull requests run the build step only (no push).
|
The `GITHUB_TOKEN` secret is used automatically — no additional secrets required.
|
||||||
|
|
||||||
The `GITHUB_TOKEN` secret is used automatically — no additional secrets are required.
|
> **Note:** Commits made by Claude will appear as "Unverified" on GitHub because they are not GPG-signed. This is expected and does not affect functionality.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
npm start # React dev server on :3000 (uses localStorage, no backend)
|
npm start # React dev server on :3000
|
||||||
node server.js # API server on :4000 (reads/writes data/prayers.json)
|
node server.js # API + notification server on :4000
|
||||||
```
|
|
||||||
|
|
||||||
For full local dev with the backend:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
node server.js &
|
|
||||||
REACT_APP_API_BASE=http://localhost:4000 npm start
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
| Layer | Technology |
|
| Layer | Technology |
|
||||||
|----------|-----------------------------------|
|
|-------|------------|
|
||||||
| Frontend | React 18, TypeScript |
|
| Frontend | React 18, TypeScript |
|
||||||
| Backend | Node.js, Express |
|
| Backend | Node.js, Express |
|
||||||
| Data | JSON file on a Docker volume |
|
| Notifications | ntfy.sh (via node-cron + fetch) |
|
||||||
| Images | GitHub Container Registry (ghcr) |
|
| Data | JSON files on a bind-mounted host directory |
|
||||||
| CI/CD | GitHub Actions |
|
| Images | GitHub Container Registry (ghcr) |
|
||||||
|
| CI/CD | GitHub Actions |
|
||||||
@@ -6,10 +6,19 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "3000:4000"
|
- "3000:4000"
|
||||||
volumes:
|
volumes:
|
||||||
|
# Bind mount to host path — data survives container recreation.
|
||||||
|
# ZimaOS default: /media/ZimaOS-HD/AppData/Prayer/Data
|
||||||
|
# Change this path to match your host setup.
|
||||||
- /media/ZimaOS-HD/AppData/Prayer/Data:/data
|
- /media/ZimaOS-HD/AppData/Prayer/Data:/data
|
||||||
environment:
|
environment:
|
||||||
- DATA_DIR=/data
|
- DATA_DIR=/data
|
||||||
- PORT=4000
|
- PORT=4000
|
||||||
|
# Optional: set your timezone so daily-digest and reminder times
|
||||||
|
# fire at the correct local time. Examples:
|
||||||
|
# TZ=America/Chicago
|
||||||
|
# TZ=America/New_York
|
||||||
|
# TZ=Europe/London
|
||||||
|
- TZ=America/Chicago
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "-qO-", "http://localhost:4000/api/prayers"]
|
test: ["CMD", "wget", "-qO-", "http://localhost:4000/api/prayers"]
|
||||||
|
|||||||
Generated
+1294
-3
File diff suppressed because it is too large.
Load diff
+6
-3
@@ -3,6 +3,12 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"cors": "^2.8.6",
|
||||||
|
"docx": "^9.7.1",
|
||||||
|
"express": "^5.2.1",
|
||||||
|
"node-cron": "^4.6.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
"@testing-library/dom": "^10.4.1",
|
"@testing-library/dom": "^10.4.1",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
@@ -11,9 +17,6 @@
|
|||||||
"@types/node": "^16.18.126",
|
"@types/node": "^16.18.126",
|
||||||
"@types/react": "^19.2.17",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"cors": "^2.8.6",
|
|
||||||
"docx": "^9.7.1",
|
|
||||||
"express": "^5.2.1",
|
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.7",
|
||||||
"react-scripts": "5.0.1",
|
"react-scripts": "5.0.1",
|
||||||
|
|||||||
@@ -3,16 +3,16 @@ const cors = require('cors');
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { randomUUID } = require('crypto');
|
const { randomUUID } = require('crypto');
|
||||||
|
const cron = require('node-cron');
|
||||||
const {
|
const {
|
||||||
Document, Packer, Paragraph, TextRun, HeadingLevel,
|
Document, Packer, Paragraph, TextRun, HeadingLevel,
|
||||||
AlignmentType, BorderStyle, ShadingType, TableRow, TableCell,
|
AlignmentType, BorderStyle, PageBreak, Footer, PageNumber,
|
||||||
Table, WidthType, PageBreak, Header, Footer, PageNumber,
|
|
||||||
NumberFormat,
|
|
||||||
} = require('docx');
|
} = require('docx');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
|
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
|
||||||
const DATA_FILE = path.join(DATA_DIR, 'prayers.json');
|
const DATA_FILE = path.join(DATA_DIR, 'prayers.json');
|
||||||
|
const SETTINGS_FILE = path.join(DATA_DIR, 'settings.json');
|
||||||
const PORT = process.env.PORT || 4000;
|
const PORT = process.env.PORT || 4000;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -42,10 +42,90 @@ function writePrayers(prayers) {
|
|||||||
fs.writeFileSync(DATA_FILE, JSON.stringify(prayers, null, 2));
|
fs.writeFileSync(DATA_FILE, JSON.stringify(prayers, null, 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readSettings() {
|
||||||
|
if (!fs.existsSync(SETTINGS_FILE)) return {};
|
||||||
|
try { return JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8')); } catch { return {}; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeSettings(s) {
|
||||||
|
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||||
|
fs.writeFileSync(SETTINGS_FILE, JSON.stringify(s, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
function fmtDate(iso) {
|
function fmtDate(iso) {
|
||||||
return iso ? new Date(iso).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }) : '';
|
return iso ? new Date(iso).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }) : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function sendNtfy(topic, title, message, tags = []) {
|
||||||
|
if (!topic) return;
|
||||||
|
const url = topic.startsWith('http') ? topic : `https://ntfy.sh/${topic}`;
|
||||||
|
// HTTP headers must be ASCII-only; replace non-ASCII characters
|
||||||
|
const safeTitle = title.replace(/[^\x00-\x7F]/g, '-');
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Title': safeTitle,
|
||||||
|
'Content-Type': 'text/plain',
|
||||||
|
...(tags.length ? { 'Tags': tags.join(',') } : {}),
|
||||||
|
},
|
||||||
|
body: message,
|
||||||
|
});
|
||||||
|
if (!res.ok) console.error('ntfy error:', res.status, await res.text());
|
||||||
|
else console.log(`ntfy sent: "${title}"`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('ntfy send failed:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendDailyDigest() {
|
||||||
|
const settings = readSettings();
|
||||||
|
if (!settings.ntfyTopic) return;
|
||||||
|
const prayers = readPrayers().filter(p => p.status === 'active');
|
||||||
|
if (!prayers.length) return;
|
||||||
|
const lines = prayers.map((p, i) => {
|
||||||
|
const who = p.person ? `${p.person}: ` : '';
|
||||||
|
return `${i + 1}. ${who}${p.request}`;
|
||||||
|
});
|
||||||
|
await sendNtfy(
|
||||||
|
settings.ntfyTopic,
|
||||||
|
`Prayer Journal -${prayers.length} active prayer${prayers.length !== 1 ? 's' : ''}`,
|
||||||
|
lines.join('\n'),
|
||||||
|
['pray', 'raised_hands']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkPrayerReminders() {
|
||||||
|
const settings = readSettings();
|
||||||
|
if (!settings.ntfyTopic) return;
|
||||||
|
const prayers = readPrayers().filter(p => p.status === 'active' && p.reminderDays && p.reminderDays.length);
|
||||||
|
if (!prayers.length) return;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const dayName = ['sun','mon','tue','wed','thu','fri','sat'][now.getDay()];
|
||||||
|
const currentHHMM = `${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}`;
|
||||||
|
const reminderTime = settings.reminderTime || '08:00';
|
||||||
|
if (currentHHMM !== reminderTime) return;
|
||||||
|
|
||||||
|
for (const prayer of prayers) {
|
||||||
|
if ((prayer.reminderDays || []).includes(dayName)) {
|
||||||
|
const title = prayer.person ? `Praying for ${prayer.person}` : 'Prayer reminder';
|
||||||
|
await sendNtfy(settings.ntfyTopic, title, prayer.request, ['pray', 'raised_hands']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cron: every minute checks whether it's time for digest or per-prayer reminders
|
||||||
|
cron.schedule('* * * * *', async () => {
|
||||||
|
const settings = readSettings();
|
||||||
|
if (!settings.ntfyTopic) return;
|
||||||
|
const now = new Date();
|
||||||
|
const hhmm = `${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}`;
|
||||||
|
if (settings.dailyDigestTime && hhmm === settings.dailyDigestTime) await sendDailyDigest();
|
||||||
|
if (settings.reminderTime) await checkPrayerReminders();
|
||||||
|
});
|
||||||
|
console.log('Notification cron scheduled.');
|
||||||
|
|
||||||
function prayerToSection(prayer, index) {
|
function prayerToSection(prayer, index) {
|
||||||
const paras = [];
|
const paras = [];
|
||||||
|
|
||||||
@@ -94,6 +174,33 @@ function prayerToSection(prayer, index) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Notes (if present)
|
||||||
|
if (prayer.notes && prayer.notes.trim()) {
|
||||||
|
paras.push(new Paragraph({
|
||||||
|
children: [new TextRun({ text: 'Notes', bold: true, size: 20, color: '5A5A2A' })],
|
||||||
|
spacing: { after: 60 },
|
||||||
|
}));
|
||||||
|
paras.push(new Paragraph({
|
||||||
|
children: [new TextRun({ text: prayer.notes, size: 22 })],
|
||||||
|
spacing: { after: 160 },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prayer log (if present)
|
||||||
|
if (prayer.prayedLog && prayer.prayedLog.length > 0) {
|
||||||
|
paras.push(new Paragraph({
|
||||||
|
children: [new TextRun({ text: `Prayer Log (${prayer.prayedLog.length} session${prayer.prayedLog.length !== 1 ? 's' : ''})`, bold: true, size: 20, color: '4B6FA8' })],
|
||||||
|
spacing: { after: 60 },
|
||||||
|
}));
|
||||||
|
const sorted = [...prayer.prayedLog].sort((a, b) => new Date(b.date) - new Date(a.date));
|
||||||
|
sorted.forEach(entry => {
|
||||||
|
const runs = [new TextRun({ text: fmtDate(entry.date), bold: true, size: 19, color: '555555' })];
|
||||||
|
if (entry.note) runs.push(new TextRun({ text: ` — ${entry.note}`, size: 19, color: '666666' }));
|
||||||
|
paras.push(new Paragraph({ children: runs, spacing: { after: 60 } }));
|
||||||
|
});
|
||||||
|
paras.push(new Paragraph({ children: [], spacing: { after: 80 } }));
|
||||||
|
}
|
||||||
|
|
||||||
// Divider paragraph with bottom border
|
// Divider paragraph with bottom border
|
||||||
paras.push(new Paragraph({
|
paras.push(new Paragraph({
|
||||||
children: [],
|
children: [],
|
||||||
@@ -104,6 +211,27 @@ function prayerToSection(prayer, index) {
|
|||||||
return paras;
|
return paras;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
app.get('/api/settings', (req, res) => {
|
||||||
|
res.json(readSettings());
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/settings', (req, res) => {
|
||||||
|
try {
|
||||||
|
const updated = { ...readSettings(), ...req.body };
|
||||||
|
writeSettings(updated);
|
||||||
|
res.json(updated);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: 'Could not save settings.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/notifications/test', async (req, res) => {
|
||||||
|
const settings = readSettings();
|
||||||
|
if (!settings.ntfyTopic) return res.status(400).json({ error: 'No ntfy topic configured.' });
|
||||||
|
await sendNtfy(settings.ntfyTopic, 'Prayer Journal -Test', 'Notifications are working! 🙏', ['white_check_mark']);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/api/export', (req, res) => {
|
app.get('/api/export', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const all = readPrayers();
|
const all = readPrayers();
|
||||||
|
|||||||
+149
@@ -630,6 +630,22 @@ mark.highlight {
|
|||||||
padding: 0.1rem 0.4rem;
|
padding: 0.1rem 0.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Notes section */
|
||||||
|
.notes-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes-text {
|
||||||
|
font-size: 0.93rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
/* Prayer log section inside card */
|
/* Prayer log section inside card */
|
||||||
.prayed-log-section {
|
.prayed-log-section {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -896,3 +912,136 @@ mark.highlight {
|
|||||||
font-size: 0.92rem;
|
font-size: 0.92rem;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Settings panel */
|
||||||
|
.settings-btn {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-panel {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 1.25rem;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-title {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: normal;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-desc {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-desc a { color: var(--primary); }
|
||||||
|
|
||||||
|
.settings-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-input {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-input:focus { border-color: var(--primary); }
|
||||||
|
|
||||||
|
.settings-hint {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: 0;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-msg {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--answered);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reminder day picker */
|
||||||
|
.reminder-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reminder-label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-picker {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-btn {
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.12s, color 0.12s, border-color 0.12s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-btn:hover {
|
||||||
|
border-color: var(--primary);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-btn.active {
|
||||||
|
background: var(--primary);
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
+199
-14
@@ -1,11 +1,23 @@
|
|||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import { usePrayers } from './usePrayers';
|
import { usePrayers } from './usePrayers';
|
||||||
import { Prayer, PrayedEntry } from './types';
|
import { useSettings } from './useSettings';
|
||||||
|
import { Prayer, PrayedEntry, AppSettings } from './types';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
|
||||||
type Filter = 'active' | 'answered' | 'all';
|
type Filter = 'active' | 'answered' | 'all';
|
||||||
type Tab = 'prayers' | 'calendar';
|
type Tab = 'prayers' | 'calendar';
|
||||||
|
|
||||||
|
const DOT_PALETTE = [
|
||||||
|
'#c07a3a', '#3d6b52', '#6b4545', '#456b8a',
|
||||||
|
'#7a5c8a', '#8a7a3a', '#3a6b6b', '#8a4a6b',
|
||||||
|
];
|
||||||
|
|
||||||
|
function prayerColor(id: string): string {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < id.length; i++) hash = (hash * 31 + id.charCodeAt(i)) & 0xffffffff;
|
||||||
|
return DOT_PALETTE[Math.abs(hash) % DOT_PALETTE.length];
|
||||||
|
}
|
||||||
|
|
||||||
function normalize(s: string) {
|
function normalize(s: string) {
|
||||||
return s.toLowerCase().trim();
|
return s.toLowerCase().trim();
|
||||||
}
|
}
|
||||||
@@ -21,6 +33,9 @@ function matches(prayer: Prayer, query: string) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DAYS = ['sun','mon','tue','wed','thu','fri','sat'] as const;
|
||||||
|
const DAY_LABELS: Record<string, string> = { sun:'Su', mon:'Mo', tue:'Tu', wed:'We', thu:'Th', fri:'Fr', sat:'Sa' };
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const {
|
const {
|
||||||
prayers,
|
prayers,
|
||||||
@@ -33,9 +48,13 @@ export default function App() {
|
|||||||
archivePrayer,
|
archivePrayer,
|
||||||
deletePrayer,
|
deletePrayer,
|
||||||
updateRequest,
|
updateRequest,
|
||||||
|
updateNotes,
|
||||||
logPrayed,
|
logPrayed,
|
||||||
|
updateReminderDays,
|
||||||
} = usePrayers();
|
} = usePrayers();
|
||||||
|
|
||||||
|
const { settings, saveSettings, testNotification } = useSettings();
|
||||||
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
const [tab, setTab] = useState<Tab>('prayers');
|
const [tab, setTab] = useState<Tab>('prayers');
|
||||||
const [filter, setFilter] = useState<Filter>('active');
|
const [filter, setFilter] = useState<Filter>('active');
|
||||||
const [activeTag, setActiveTag] = useState<string | null>(null);
|
const [activeTag, setActiveTag] = useState<string | null>(null);
|
||||||
@@ -126,8 +145,19 @@ export default function App() {
|
|||||||
<header className="header">
|
<header className="header">
|
||||||
<h1>Prayer Journal</h1>
|
<h1>Prayer Journal</h1>
|
||||||
<p className="subtitle">Bring your requests. Record His faithfulness.</p>
|
<p className="subtitle">Bring your requests. Record His faithfulness.</p>
|
||||||
|
<button className="btn ghost settings-btn" onClick={() => setShowSettings(s => !s)} title="Notification settings">
|
||||||
|
🔔 {showSettings ? 'Close settings' : 'Notifications'}
|
||||||
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{showSettings && (
|
||||||
|
<SettingsPanel
|
||||||
|
settings={settings}
|
||||||
|
onSave={saveSettings}
|
||||||
|
onTest={testNotification}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="tab-bar">
|
<div className="tab-bar">
|
||||||
<button
|
<button
|
||||||
className={`tab-btn ${tab === 'prayers' ? 'active' : ''}`}
|
className={`tab-btn ${tab === 'prayers' ? 'active' : ''}`}
|
||||||
@@ -249,6 +279,7 @@ export default function App() {
|
|||||||
editingAnswer={editingAnswer?.id === prayer.id ? editingAnswer.text : null}
|
editingAnswer={editingAnswer?.id === prayer.id ? editingAnswer.text : null}
|
||||||
activeTag={activeTag}
|
activeTag={activeTag}
|
||||||
searchQuery={search}
|
searchQuery={search}
|
||||||
|
ntfyConfigured={!!settings.ntfyTopic}
|
||||||
onTagClick={handleTagClick}
|
onTagClick={handleTagClick}
|
||||||
onToggle={() => setExpandedId(expandedId === prayer.id ? null : prayer.id)}
|
onToggle={() => setExpandedId(expandedId === prayer.id ? null : prayer.id)}
|
||||||
onStartAnswer={() => {
|
onStartAnswer={() => {
|
||||||
@@ -262,7 +293,9 @@ export default function App() {
|
|||||||
onArchive={() => archivePrayer(prayer.id)}
|
onArchive={() => archivePrayer(prayer.id)}
|
||||||
onDelete={() => deletePrayer(prayer.id)}
|
onDelete={() => deletePrayer(prayer.id)}
|
||||||
onEditRequest={text => updateRequest(prayer.id, text)}
|
onEditRequest={text => updateRequest(prayer.id, text)}
|
||||||
onLogPrayed={(entry) => logPrayed(prayer.id, entry)}
|
onLogPrayed={entry => logPrayed(prayer.id, entry)}
|
||||||
|
onSaveNotes={notes => updateNotes(prayer.id, notes)}
|
||||||
|
onUpdateReminderDays={days => updateReminderDays(prayer.id, days)}
|
||||||
formatDate={formatDate}
|
formatDate={formatDate}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -290,12 +323,90 @@ function highlight(text: string, query: string) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SettingsPanel({
|
||||||
|
settings,
|
||||||
|
onSave,
|
||||||
|
onTest,
|
||||||
|
}: {
|
||||||
|
settings: AppSettings;
|
||||||
|
onSave: (s: Partial<AppSettings>) => Promise<AppSettings>;
|
||||||
|
onTest: () => Promise<void>;
|
||||||
|
}) {
|
||||||
|
const [topic, setTopic] = useState(settings.ntfyTopic || '');
|
||||||
|
const [digest, setDigest] = useState(settings.dailyDigestTime || '');
|
||||||
|
const [remTime, setRemTime] = useState(settings.reminderTime || '08:00');
|
||||||
|
const [msg, setMsg] = useState('');
|
||||||
|
const [testing, setTesting] = useState(false);
|
||||||
|
|
||||||
|
async function handleSave(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
await onSave({ ntfyTopic: topic.trim() || undefined, dailyDigestTime: digest || undefined, reminderTime: remTime || '08:00' });
|
||||||
|
setMsg('Settings saved.');
|
||||||
|
} catch { setMsg('Failed to save.'); }
|
||||||
|
setTimeout(() => setMsg(''), 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTest() {
|
||||||
|
setTesting(true);
|
||||||
|
try { await onTest(); setMsg('Test notification sent!'); }
|
||||||
|
catch (err: any) { setMsg(err.message); }
|
||||||
|
setTesting(false);
|
||||||
|
setTimeout(() => setMsg(''), 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="settings-panel">
|
||||||
|
<h2 className="settings-title">Notification Settings</h2>
|
||||||
|
<p className="settings-desc">
|
||||||
|
Notifications are sent via <a href="https://ntfy.sh" target="_blank" rel="noreferrer">ntfy.sh</a> — a free, open-source push service.
|
||||||
|
Install the ntfy app on your phone and subscribe to your topic to receive notifications.
|
||||||
|
</p>
|
||||||
|
<form onSubmit={handleSave} className="settings-form">
|
||||||
|
<label className="settings-label">
|
||||||
|
ntfy topic
|
||||||
|
<input
|
||||||
|
className="settings-input"
|
||||||
|
placeholder="e.g. my-prayer-journal-abc123"
|
||||||
|
value={topic}
|
||||||
|
onChange={e => setTopic(e.target.value)}
|
||||||
|
/>
|
||||||
|
<span className="settings-hint">Pick a unique topic name. Anyone who knows it can subscribe, so make it hard to guess.</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="settings-label">
|
||||||
|
Daily digest time
|
||||||
|
<input type="time" className="settings-input" value={digest} onChange={e => setDigest(e.target.value)} />
|
||||||
|
<span className="settings-hint">Receive a summary of all active prayers at this time each day. Leave blank to disable.</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="settings-label">
|
||||||
|
Per-prayer reminder time
|
||||||
|
<input type="time" className="settings-input" value={remTime} onChange={e => setRemTime(e.target.value)} />
|
||||||
|
<span className="settings-hint">Time of day reminders fire for individual prayers that have reminder days set.</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="settings-actions">
|
||||||
|
<button className="btn btn-primary btn-sm" type="submit">Save</button>
|
||||||
|
{topic && (
|
||||||
|
<button type="button" className="btn btn-sm" onClick={handleTest} disabled={testing}>
|
||||||
|
{testing ? 'Sending…' : 'Send test'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{msg && <span className="settings-msg">{msg}</span>}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface CardProps {
|
interface CardProps {
|
||||||
prayer: Prayer;
|
prayer: Prayer;
|
||||||
expanded: boolean;
|
expanded: boolean;
|
||||||
editingAnswer: string | null;
|
editingAnswer: string | null;
|
||||||
activeTag: string | null;
|
activeTag: string | null;
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
|
ntfyConfigured: boolean;
|
||||||
onTagClick: (tag: string) => void;
|
onTagClick: (tag: string) => void;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
onStartAnswer: () => void;
|
onStartAnswer: () => void;
|
||||||
@@ -307,6 +418,8 @@ interface CardProps {
|
|||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
onEditRequest: (text: string) => void;
|
onEditRequest: (text: string) => void;
|
||||||
onLogPrayed: (entry: PrayedEntry) => void;
|
onLogPrayed: (entry: PrayedEntry) => void;
|
||||||
|
onSaveNotes: (notes: string) => void;
|
||||||
|
onUpdateReminderDays: (days: string[]) => void;
|
||||||
formatDate: (iso: string) => string;
|
formatDate: (iso: string) => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,6 +429,7 @@ function PrayerCard({
|
|||||||
editingAnswer,
|
editingAnswer,
|
||||||
activeTag,
|
activeTag,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
|
ntfyConfigured,
|
||||||
onTagClick,
|
onTagClick,
|
||||||
onToggle,
|
onToggle,
|
||||||
onStartAnswer,
|
onStartAnswer,
|
||||||
@@ -326,11 +440,14 @@ function PrayerCard({
|
|||||||
onArchive,
|
onArchive,
|
||||||
onDelete,
|
onDelete,
|
||||||
onLogPrayed,
|
onLogPrayed,
|
||||||
|
onSaveNotes,
|
||||||
|
onUpdateReminderDays,
|
||||||
formatDate,
|
formatDate,
|
||||||
}: CardProps) {
|
}: CardProps) {
|
||||||
const [logDate, setLogDate] = useState(new Date().toISOString().slice(0, 10));
|
const [logDate, setLogDate] = useState(new Date().toISOString().slice(0, 10));
|
||||||
const [logNote, setLogNote] = useState('');
|
const [logNote, setLogNote] = useState('');
|
||||||
const [showLogForm, setShowLogForm] = useState(false);
|
const [showLogForm, setShowLogForm] = useState(false);
|
||||||
|
const [editingNotes, setEditingNotes] = useState<string | null>(null);
|
||||||
|
|
||||||
function handleLogSubmit(e: React.FormEvent) {
|
function handleLogSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -339,15 +456,29 @@ function PrayerCard({
|
|||||||
setShowLogForm(false);
|
setShowLogForm(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleNotesSave() {
|
||||||
|
if (editingNotes === null) return;
|
||||||
|
onSaveNotes(editingNotes);
|
||||||
|
setEditingNotes(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleDay(day: string) {
|
||||||
|
const current = prayer.reminderDays || [];
|
||||||
|
const next = current.includes(day) ? current.filter(d => d !== day) : [...current, day];
|
||||||
|
onUpdateReminderDays(next);
|
||||||
|
}
|
||||||
|
|
||||||
const sortedLog = [...(prayer.prayedLog ?? [])].sort(
|
const sortedLog = [...(prayer.prayedLog ?? [])].sort(
|
||||||
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()
|
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const color = prayerColor(prayer.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li className={`prayer-card ${prayer.status}`}>
|
<li className={`prayer-card ${prayer.status}`} style={{ borderLeftColor: color }}>
|
||||||
<div className="card-top" onClick={onToggle}>
|
<div className="card-top" onClick={onToggle}>
|
||||||
<div className="card-main">
|
<div className="card-main">
|
||||||
<span className={`status-dot ${prayer.status}`} />
|
<span className="status-dot" style={{ background: color }} />
|
||||||
<div className="card-text">
|
<div className="card-text">
|
||||||
{prayer.person && (
|
{prayer.person && (
|
||||||
<p className="person-name">{highlight(prayer.person, searchQuery)}</p>
|
<p className="person-name">{highlight(prayer.person, searchQuery)}</p>
|
||||||
@@ -411,14 +542,44 @@ function PrayerCard({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Notes section */}
|
||||||
|
<div className="notes-section">
|
||||||
|
<div className="prayed-log-header">
|
||||||
|
<label className="answer-label">Notes</label>
|
||||||
|
{editingNotes === null && (
|
||||||
|
<button className="btn btn-sm" onClick={() => setEditingNotes(prayer.notes ?? '')}>
|
||||||
|
{prayer.notes ? 'Edit' : '+ Add notes'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{editingNotes !== null ? (
|
||||||
|
<>
|
||||||
|
<textarea
|
||||||
|
className="log-note-input"
|
||||||
|
placeholder="Scripture, updates, mid-prayer thoughts…"
|
||||||
|
value={editingNotes}
|
||||||
|
onChange={e => setEditingNotes(e.target.value)}
|
||||||
|
rows={4}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div className="card-actions">
|
||||||
|
<button className="btn btn-primary btn-sm" onClick={handleNotesSave}>Save</button>
|
||||||
|
<button className="btn btn-sm" onClick={() => setEditingNotes(null)}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : prayer.notes ? (
|
||||||
|
<p className="notes-text">{prayer.notes}</p>
|
||||||
|
) : (
|
||||||
|
<p className="prayed-log-empty">No notes yet.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Prayer log section */}
|
||||||
<div className="prayed-log-section">
|
<div className="prayed-log-section">
|
||||||
<div className="prayed-log-header">
|
<div className="prayed-log-header">
|
||||||
<label className="answer-label">Prayer Log</label>
|
<label className="answer-label">Prayer Log</label>
|
||||||
{!showLogForm && (
|
{!showLogForm && (
|
||||||
<button
|
<button className="btn btn-sm btn-primary" onClick={() => setShowLogForm(true)}>
|
||||||
className="btn btn-sm btn-primary"
|
|
||||||
onClick={() => setShowLogForm(true)}
|
|
||||||
>
|
|
||||||
+ I Prayed This
|
+ I Prayed This
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -469,6 +630,24 @@ function PrayerCard({
|
|||||||
<button className="btn btn-sm ghost" onClick={onArchive}>Archive</button>
|
<button className="btn btn-sm ghost" onClick={onArchive}>Archive</button>
|
||||||
<button className="btn btn-sm ghost danger" onClick={onDelete}>Delete</button>
|
<button className="btn btn-sm ghost danger" onClick={onDelete}>Delete</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{ntfyConfigured && prayer.status === 'active' && (
|
||||||
|
<div className="reminder-row">
|
||||||
|
<span className="reminder-label">🔔 Remind me</span>
|
||||||
|
<div className="day-picker">
|
||||||
|
{DAYS.map(d => (
|
||||||
|
<button
|
||||||
|
key={d}
|
||||||
|
type="button"
|
||||||
|
className={`day-btn ${(prayer.reminderDays || []).includes(d) ? 'active' : ''}`}
|
||||||
|
onClick={() => toggleDay(d)}
|
||||||
|
>
|
||||||
|
{DAY_LABELS[d]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
@@ -552,19 +731,25 @@ function CalendarView({ prayers, formatDate }: { prayers: Prayer[]; formatDate:
|
|||||||
day === today.getDate() && month === today.getMonth() && year === today.getFullYear();
|
day === today.getDate() && month === today.getMonth() && year === today.getFullYear();
|
||||||
const isSelected = selectedDate === dateStr;
|
const isSelected = selectedDate === dateStr;
|
||||||
|
|
||||||
|
const uniqueColors = entries
|
||||||
|
? Array.from(new Map(entries.map(e => [e.prayer.id, prayerColor(e.prayer.id)])).values())
|
||||||
|
: [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={day}
|
key={day}
|
||||||
className={`cal-cell ${isToday ? 'cal-today' : ''} ${isSelected ? 'cal-selected' : ''} ${entries ? 'cal-has-prayers' : ''}`}
|
className={`cal-cell ${isToday ? 'cal-today' : ''} ${isSelected ? 'cal-selected' : ''} ${entries ? 'cal-has-prayers' : ''}`}
|
||||||
onClick={() => setSelectedDate(isSelected ? null : dateStr)}
|
onClick={() => entries && setSelectedDate(isSelected ? null : dateStr)}
|
||||||
>
|
>
|
||||||
<span className="cal-day-num">{day}</span>
|
<span className="cal-day-num">{day}</span>
|
||||||
{entries && (
|
{uniqueColors.length > 0 && (
|
||||||
<span className="cal-dot-row">
|
<span className="cal-dot-row">
|
||||||
{entries.slice(0, 3).map((_, di) => (
|
{uniqueColors.slice(0, 5).map((color, di) => (
|
||||||
<span key={di} className="cal-dot" />
|
<span key={di} className="cal-dot" style={{ background: color }} />
|
||||||
))}
|
))}
|
||||||
{entries.length > 3 && <span className="cal-dot-more">+{entries.length - 3}</span>}
|
{uniqueColors.length > 5 && (
|
||||||
|
<span className="cal-dot-more">+{uniqueColors.length - 5}</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -582,7 +767,7 @@ function CalendarView({ prayers, formatDate }: { prayers: Prayer[]; formatDate:
|
|||||||
) : (
|
) : (
|
||||||
<ul className="cal-detail-list">
|
<ul className="cal-detail-list">
|
||||||
{selectedEntries.map(({ prayer, entry }, i) => (
|
{selectedEntries.map(({ prayer, entry }, i) => (
|
||||||
<li key={i} className="cal-detail-entry">
|
<li key={i} className="cal-detail-entry" style={{ borderLeftColor: prayerColor(prayer.id) }}>
|
||||||
<div className="cal-detail-prayer">
|
<div className="cal-detail-prayer">
|
||||||
{prayer.person && <span className="person-name">{prayer.person} — </span>}
|
{prayer.person && <span className="person-name">{prayer.person} — </span>}
|
||||||
<span className="request-text">{prayer.request}</span>
|
<span className="request-text">{prayer.request}</span>
|
||||||
|
|||||||
@@ -8,9 +8,17 @@ export interface Prayer {
|
|||||||
person: string;
|
person: string;
|
||||||
request: string;
|
request: string;
|
||||||
answer: string;
|
answer: string;
|
||||||
|
notes?: string;
|
||||||
status: 'active' | 'answered' | 'archived';
|
status: 'active' | 'answered' | 'archived';
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
answeredAt?: string;
|
answeredAt?: string;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
prayedLog?: PrayedEntry[];
|
prayedLog?: PrayedEntry[];
|
||||||
|
reminderDays?: string[]; // e.g. ['mon', 'wed', 'fri']
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppSettings {
|
||||||
|
ntfyTopic?: string;
|
||||||
|
dailyDigestTime?: string; // HH:MM
|
||||||
|
reminderTime?: string; // HH:MM — time per-prayer reminders fire
|
||||||
}
|
}
|
||||||
@@ -65,6 +65,9 @@ export function usePrayers() {
|
|||||||
const updateRequest = useCallback((id: string, request: string) =>
|
const updateRequest = useCallback((id: string, request: string) =>
|
||||||
patch(id, { request }), [patch]);
|
patch(id, { request }), [patch]);
|
||||||
|
|
||||||
|
const updateNotes = useCallback((id: string, notes: string) =>
|
||||||
|
patch(id, { notes }), [patch]);
|
||||||
|
|
||||||
const logPrayed = useCallback(async (id: string, entry: PrayedEntry) => {
|
const logPrayed = useCallback(async (id: string, entry: PrayedEntry) => {
|
||||||
const prayer = prayers.find(p => p.id === id);
|
const prayer = prayers.find(p => p.id === id);
|
||||||
if (!prayer) return;
|
if (!prayer) return;
|
||||||
@@ -72,6 +75,9 @@ export function usePrayers() {
|
|||||||
await patch(id, { prayedLog });
|
await patch(id, { prayedLog });
|
||||||
}, [prayers, patch]);
|
}, [prayers, patch]);
|
||||||
|
|
||||||
|
const updateReminderDays = useCallback((id: string, reminderDays: string[]) =>
|
||||||
|
patch(id, { reminderDays }), [patch]);
|
||||||
|
|
||||||
const deletePrayer = useCallback(async (id: string) => {
|
const deletePrayer = useCallback(async (id: string) => {
|
||||||
await fetch(`${API}/${id}`, { method: 'DELETE' }).then(checkResponse);
|
await fetch(`${API}/${id}`, { method: 'DELETE' }).then(checkResponse);
|
||||||
setPrayers(prev => prev.filter(p => p.id !== id));
|
setPrayers(prev => prev.filter(p => p.id !== id));
|
||||||
@@ -88,6 +94,8 @@ export function usePrayers() {
|
|||||||
archivePrayer,
|
archivePrayer,
|
||||||
deletePrayer,
|
deletePrayer,
|
||||||
updateRequest,
|
updateRequest,
|
||||||
|
updateNotes,
|
||||||
logPrayed,
|
logPrayed,
|
||||||
|
updateReminderDays,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { AppSettings } from './types';
|
||||||
|
|
||||||
|
const API = '/api/settings';
|
||||||
|
|
||||||
|
export function useSettings() {
|
||||||
|
const [settings, setSettings] = useState<AppSettings>({});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(API)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(setSettings)
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const saveSettings = useCallback(async (updates: Partial<AppSettings>) => {
|
||||||
|
const res = await fetch(API, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(updates),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to save settings');
|
||||||
|
const updated = await res.json();
|
||||||
|
setSettings(updated);
|
||||||
|
return updated;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const testNotification = useCallback(async () => {
|
||||||
|
const res = await fetch('/api/notifications/test', { method: 'POST' });
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(body.error || 'Test failed');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { settings, loading, saveSettings, testNotification };
|
||||||
|
}
|
||||||
Reference in new issue
Block a user