feat: add ntfy push notifications for daily digest and per-prayer reminders
- Server-side cron (node-cron) checks every minute for scheduled notifications - Daily digest: sends all active prayers as a summary at a configured time - Per-prayer reminders: pick days of the week per prayer; fires at configured time - Settings panel in UI to configure ntfy topic, digest time, reminder time - Test notification button to verify ntfy is working - Settings stored in data/settings.json (persisted alongside prayers)
This commit is contained in:
8 files changed
+444
-12
No files matched your search
Generated
+29
-1
@@ -10,7 +10,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cors": "^2.8.6",
|
"cors": "^2.8.6",
|
||||||
"docx": "^9.7.1",
|
"docx": "^9.7.1",
|
||||||
"express": "^5.2.1"
|
"express": "^5.2.1",
|
||||||
|
"node-cron": "^4.6.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@testing-library/dom": "^10.4.1",
|
"@testing-library/dom": "^10.4.1",
|
||||||
@@ -12613,6 +12614,15 @@
|
|||||||
"tslib": "^2.0.3"
|
"tslib": "^2.0.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-cron": {
|
||||||
|
"version": "4.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.6.0.tgz",
|
||||||
|
"integrity": "sha512-Si/bzYiKRHOB8/a99T2+SDGN582ONDMSTlJr5oCkT6GtnqPjZ2s10eoQRYkW9ZHwjVxONL+W8Fb+qR0AHMQsdg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-exports-info": {
|
"node_modules/node-exports-info": {
|
||||||
"version": "1.6.2",
|
"version": "1.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.2.tgz",
|
||||||
@@ -17097,6 +17107,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tailwindcss/node_modules/yaml": {
|
||||||
|
"version": "2.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
|
||||||
|
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
|
"bin": {
|
||||||
|
"yaml": "bin.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14.6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/eemeli"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tapable": {
|
"node_modules/tapable": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
|
||||||
|
|||||||
+2
-1
@@ -5,7 +5,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cors": "^2.8.6",
|
"cors": "^2.8.6",
|
||||||
"docx": "^9.7.1",
|
"docx": "^9.7.1",
|
||||||
"express": "^5.2.1"
|
"express": "^5.2.1",
|
||||||
|
"node-cron": "^4.6.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@testing-library/dom": "^10.4.1",
|
"@testing-library/dom": "^10.4.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,88 @@ 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}`;
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Title': title,
|
||||||
|
'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 = [];
|
||||||
|
|
||||||
@@ -131,6 +209,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();
|
||||||
|
|||||||
+133
@@ -912,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);
|
||||||
|
}
|
||||||
+128
-7
@@ -1,6 +1,7 @@
|
|||||||
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';
|
||||||
@@ -32,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,
|
||||||
@@ -46,8 +50,11 @@ export default function App() {
|
|||||||
updateRequest,
|
updateRequest,
|
||||||
updateNotes,
|
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);
|
||||||
@@ -138,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' : ''}`}
|
||||||
@@ -261,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={() => {
|
||||||
@@ -276,6 +295,7 @@ export default function App() {
|
|||||||
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)}
|
onSaveNotes={notes => updateNotes(prayer.id, notes)}
|
||||||
|
onUpdateReminderDays={days => updateReminderDays(prayer.id, days)}
|
||||||
formatDate={formatDate}
|
formatDate={formatDate}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -303,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;
|
||||||
@@ -321,6 +419,7 @@ interface CardProps {
|
|||||||
onEditRequest: (text: string) => void;
|
onEditRequest: (text: string) => void;
|
||||||
onLogPrayed: (entry: PrayedEntry) => void;
|
onLogPrayed: (entry: PrayedEntry) => void;
|
||||||
onSaveNotes: (notes: string) => void;
|
onSaveNotes: (notes: string) => void;
|
||||||
|
onUpdateReminderDays: (days: string[]) => void;
|
||||||
formatDate: (iso: string) => string;
|
formatDate: (iso: string) => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -330,6 +429,7 @@ function PrayerCard({
|
|||||||
editingAnswer,
|
editingAnswer,
|
||||||
activeTag,
|
activeTag,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
|
ntfyConfigured,
|
||||||
onTagClick,
|
onTagClick,
|
||||||
onToggle,
|
onToggle,
|
||||||
onStartAnswer,
|
onStartAnswer,
|
||||||
@@ -341,6 +441,7 @@ function PrayerCard({
|
|||||||
onDelete,
|
onDelete,
|
||||||
onLogPrayed,
|
onLogPrayed,
|
||||||
onSaveNotes,
|
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));
|
||||||
@@ -361,6 +462,12 @@ function PrayerCard({
|
|||||||
setEditingNotes(null);
|
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()
|
||||||
);
|
);
|
||||||
@@ -472,10 +579,7 @@ function PrayerCard({
|
|||||||
<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>
|
||||||
)}
|
)}
|
||||||
@@ -526,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>
|
||||||
@@ -539,7 +661,7 @@ function CalendarView({ prayers, formatDate }: { prayers: Prayer[]; formatDate:
|
|||||||
const [selectedDate, setSelectedDate] = useState<string | null>(null);
|
const [selectedDate, setSelectedDate] = useState<string | null>(null);
|
||||||
|
|
||||||
const prayedByDate = useMemo(() => {
|
const prayedByDate = useMemo(() => {
|
||||||
const map = new Map<string, Array<{ prayer: Prayer; entry: PrayedEntry }>>() ;
|
const map = new Map<string, Array<{ prayer: Prayer; entry: PrayedEntry }>>();
|
||||||
prayers.forEach(prayer => {
|
prayers.forEach(prayer => {
|
||||||
(prayer.prayedLog ?? []).forEach(entry => {
|
(prayer.prayedLog ?? []).forEach(entry => {
|
||||||
const key = entry.date.slice(0, 10);
|
const key = entry.date.slice(0, 10);
|
||||||
@@ -609,7 +731,6 @@ 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;
|
||||||
|
|
||||||
// Deduplicate colors so each prayer shows one dot per day
|
|
||||||
const uniqueColors = entries
|
const uniqueColors = entries
|
||||||
? Array.from(new Map(entries.map(e => [e.prayer.id, prayerColor(e.prayer.id)])).values())
|
? Array.from(new Map(entries.map(e => [e.prayer.id, prayerColor(e.prayer.id)])).values())
|
||||||
: [];
|
: [];
|
||||||
|
|||||||
@@ -14,4 +14,11 @@ export interface Prayer {
|
|||||||
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
|
||||||
}
|
}
|
||||||
@@ -75,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));
|
||||||
@@ -93,5 +96,6 @@ export function usePrayers() {
|
|||||||
updateRequest,
|
updateRequest,
|
||||||
updateNotes,
|
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