Push-Erinnerungen: Widget, Backend-Scheduler, Pushover-Versand jede Minute
This commit is contained in:
@@ -313,6 +313,20 @@ if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='boa
|
|||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Push-Zeitplaner ───────────────────────────────────────────────────────────
|
||||||
|
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='push_schedules'").get()) {
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE push_schedules (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
scheduled_at DATETIME NOT NULL,
|
||||||
|
sent INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME DEFAULT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Link-Liste ────────────────────────────────────────────────────────────────
|
// ── Link-Liste ────────────────────────────────────────────────────────────────
|
||||||
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='link_list'").get()) {
|
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='link_list'").get()) {
|
||||||
db.exec(`
|
db.exec(`
|
||||||
|
|||||||
@@ -68,3 +68,33 @@ app.use(express.static(PUBLIC));
|
|||||||
app.get('*', (_req, res) => res.sendFile(path.join(PUBLIC, 'index.html')));
|
app.get('*', (_req, res) => res.sendFile(path.join(PUBLIC, 'index.html')));
|
||||||
|
|
||||||
app.listen(4000, () => console.log('🚀 Dicken Dock läuft auf Port 4000'));
|
app.listen(4000, () => console.log('🚀 Dicken Dock läuft auf Port 4000'));
|
||||||
|
|
||||||
|
// ── Push-Zeitplaner Hintergrund-Job ──────────────────────────────────────────
|
||||||
|
const db = require('./db');
|
||||||
|
|
||||||
|
async function sendPushoverMsg(userKey, appToken, message) {
|
||||||
|
try {
|
||||||
|
await fetch('https://api.pushover.net/1/messages.json', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams({ token: appToken, user: userKey, title: 'DickenDock Erinnerung', message }),
|
||||||
|
});
|
||||||
|
} catch(e) { console.error('Pushover Fehler:', e.message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const due = db.prepare(`
|
||||||
|
SELECT s.*, p.user_key, p.app_token
|
||||||
|
FROM push_schedules s
|
||||||
|
JOIN pushover_settings p ON p.user_id = s.user_id
|
||||||
|
WHERE s.sent = 0 AND s.scheduled_at <= datetime('now','localtime')
|
||||||
|
`).all();
|
||||||
|
|
||||||
|
for (const row of due) {
|
||||||
|
await sendPushoverMsg(row.user_key, row.app_token, row.message);
|
||||||
|
db.prepare('UPDATE push_schedules SET sent=1 WHERE id=?').run(row.id);
|
||||||
|
console.log(`✓ Push gesendet an user ${row.user_id}: ${row.message}`);
|
||||||
|
}
|
||||||
|
} catch(e) { console.error('Push-Job Fehler:', e.message); }
|
||||||
|
}, 60 * 1000); // jede Minute
|
||||||
|
|||||||
@@ -206,4 +206,38 @@ router.post('/board/:id/promote', authenticate, (req, res) => {
|
|||||||
res.json(updated);
|
res.json(updated);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Push-Zeitplaner ───────────────────────────────────────────────────────────
|
||||||
|
router.get('/push-schedules', authenticate, (req, res) => {
|
||||||
|
const rows = db.prepare(`
|
||||||
|
SELECT * FROM push_schedules
|
||||||
|
WHERE user_id=? AND sent=0 AND scheduled_at > datetime('now','localtime')
|
||||||
|
ORDER BY scheduled_at ASC
|
||||||
|
`).all(uid(req));
|
||||||
|
res.json(rows);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/push-schedules', authenticate, (req, res) => {
|
||||||
|
const { message, scheduled_at } = req.body;
|
||||||
|
if (!message?.trim() || !scheduled_at) return res.status(400).json({ error: 'Nachricht und Zeitpunkt erforderlich' });
|
||||||
|
const r = db.prepare(`
|
||||||
|
INSERT INTO push_schedules (user_id, message, scheduled_at, created_at)
|
||||||
|
VALUES (?, ?, ?, datetime('now','localtime'))
|
||||||
|
`).run(uid(req), message.trim(), scheduled_at);
|
||||||
|
res.json(db.prepare('SELECT * FROM push_schedules WHERE id=?').get(r.lastInsertRowid));
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/push-schedules/:id', authenticate, (req, res) => {
|
||||||
|
const row = db.prepare('SELECT * FROM push_schedules WHERE id=? AND user_id=?').get(req.params.id, uid(req));
|
||||||
|
if (!row) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||||
|
const { message, scheduled_at } = req.body;
|
||||||
|
db.prepare('UPDATE push_schedules SET message=?, scheduled_at=?, sent=0 WHERE id=?')
|
||||||
|
.run(message ?? row.message, scheduled_at ?? row.scheduled_at, row.id);
|
||||||
|
res.json(db.prepare('SELECT * FROM push_schedules WHERE id=?').get(row.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/push-schedules/:id', authenticate, (req, res) => {
|
||||||
|
db.prepare('DELETE FROM push_schedules WHERE id=? AND user_id=?').run(req.params.id, uid(req));
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -990,6 +990,131 @@ function BoardModal({ user, toast, onClose, onRead }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ── Push-Zeitplaner Widget ────────────────────────────────────────────────────
|
||||||
|
function PushScheduler({ toast }) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [schedules, setSchedules] = useState([]);
|
||||||
|
const [msg, setMsg] = useState('');
|
||||||
|
const [dt, setDt] = useState('');
|
||||||
|
const [editId, setEditId] = useState(null);
|
||||||
|
const [editMsg, setEditMsg] = useState('');
|
||||||
|
const [editDt, setEditDt] = useState('');
|
||||||
|
const [hasPushover,setHasPushover]= useState(null); // null=unbekannt
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
api('/dashboard/push-schedules').then(setSchedules).catch(()=>{});
|
||||||
|
if (hasPushover === null)
|
||||||
|
api('/tools/nachrichten/pushover').then(d=>setHasPushover(!!(d.user_key&&d.app_token))).catch(()=>{});
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
// Vorauswahl: jetzt + 1 Stunde
|
||||||
|
const defaultDt = () => {
|
||||||
|
const d = new Date(Date.now() + 60*60*1000);
|
||||||
|
return d.toISOString().slice(0,16);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fmtDt = s => { if (!s) return ''; const d = new Date(s.replace(' ','T')); return isNaN(d)?s:d.toLocaleString('de-DE',{day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'}); };
|
||||||
|
|
||||||
|
const add = async () => {
|
||||||
|
if (!msg.trim()) { toast('Nachricht eingeben','error'); return; }
|
||||||
|
if (!dt) { toast('Datum und Uhrzeit wählen','error'); return; }
|
||||||
|
try {
|
||||||
|
const r = await api('/dashboard/push-schedules',{body:{message:msg.trim(),scheduled_at:dt}});
|
||||||
|
setSchedules(p=>[...p,r].sort((a,b)=>a.scheduled_at.localeCompare(b.scheduled_at)));
|
||||||
|
setMsg(''); setDt(''); toast('Erinnerung gespeichert ✓');
|
||||||
|
} catch(e){ toast(e.message,'error'); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const del = async id => {
|
||||||
|
try { await api(`/dashboard/push-schedules/${id}`,{method:'DELETE'}); setSchedules(p=>p.filter(s=>s.id!==id)); }
|
||||||
|
catch(e){ toast(e.message,'error'); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveEdit = async () => {
|
||||||
|
try {
|
||||||
|
const r = await api(`/dashboard/push-schedules/${editId}`,{method:'PUT',body:{message:editMsg,scheduled_at:editDt}});
|
||||||
|
setSchedules(p=>p.map(s=>s.id===editId?r:s).sort((a,b)=>a.scheduled_at.localeCompare(b.scheduled_at)));
|
||||||
|
setEditId(null); toast('Gespeichert ✓');
|
||||||
|
} catch(e){ toast(e.message,'error'); }
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{...S.card, marginBottom:10}}>
|
||||||
|
<button onClick={()=>setIsOpen(v=>!v)} style={{
|
||||||
|
width:'100%',background:'transparent',border:'none',cursor:'pointer',
|
||||||
|
display:'flex',alignItems:'center',padding:0,gap:8}}>
|
||||||
|
<span style={{fontSize:13}}>🔔</span>
|
||||||
|
<span style={{...S.head,marginBottom:0,flex:1,textAlign:'left'}}>PUSH-ERINNERUNGEN</span>
|
||||||
|
{schedules.length>0&&!isOpen&&(
|
||||||
|
<span style={{color:'rgba(78,205,196,0.7)',fontFamily:'monospace',fontSize:9}}>{schedules.length} aktiv</span>
|
||||||
|
)}
|
||||||
|
<span style={{color:'rgba(255,255,255,0.4)',fontSize:11,
|
||||||
|
transform:isOpen?'rotate(90deg)':'rotate(0)',transition:'transform 0.2s'}}>▶</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div style={{marginTop:12}}>
|
||||||
|
{hasPushover===false && (
|
||||||
|
<div style={{background:'rgba(255,230,109,0.07)',border:'1px solid rgba(255,230,109,0.2)',
|
||||||
|
borderRadius:7,padding:'8px 10px',color:'#ffe66d',fontFamily:'monospace',fontSize:10,
|
||||||
|
marginBottom:10,lineHeight:1.6}}>
|
||||||
|
⚠ Kein Pushover eingerichtet. Bitte erst unter Einstellungen → Profil → Pushover konfigurieren.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Neue Erinnerung */}
|
||||||
|
<div style={{marginBottom:14}}>
|
||||||
|
<input value={msg} onChange={e=>setMsg(e.target.value)}
|
||||||
|
placeholder="Nachricht…" style={{...S.inp,marginBottom:6,fontSize:13}}
|
||||||
|
onKeyDown={e=>e.key==='Enter'&&add()}/>
|
||||||
|
<div style={{display:'flex',gap:6}}>
|
||||||
|
<input type="datetime-local" value={dt||defaultDt()}
|
||||||
|
onChange={e=>setDt(e.target.value)}
|
||||||
|
style={{...S.inp,flex:1,fontSize:12,colorScheme:'dark'}}/>
|
||||||
|
<button onClick={add} style={{...S.btn('#4ecdc4'),padding:'0 14px',flexShrink:0}}>+</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Liste */}
|
||||||
|
{schedules.length===0 ? (
|
||||||
|
<div style={{color:'rgba(255,255,255,0.25)',fontFamily:'monospace',fontSize:11}}>Keine aktiven Erinnerungen.</div>
|
||||||
|
) : schedules.map(s=>(
|
||||||
|
<div key={s.id} style={{padding:'8px 0',borderBottom:'1px solid rgba(255,255,255,0.05)'}}>
|
||||||
|
{editId===s.id ? (
|
||||||
|
<div>
|
||||||
|
<input value={editMsg} onChange={e=>setEditMsg(e.target.value)}
|
||||||
|
style={{...S.inp,marginBottom:6,fontSize:13}} autoFocus/>
|
||||||
|
<div style={{display:'flex',gap:6}}>
|
||||||
|
<input type="datetime-local" value={editDt} onChange={e=>setEditDt(e.target.value)}
|
||||||
|
style={{...S.inp,flex:1,fontSize:12,colorScheme:'dark'}}/>
|
||||||
|
<button onClick={saveEdit} style={{...S.btn('#4ecdc4'),padding:'0 10px'}}>✓</button>
|
||||||
|
<button onClick={()=>setEditId(null)} style={{...S.btn('#ff6b9d',true),padding:'0 10px'}}>✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{display:'flex',alignItems:'center',gap:8}}>
|
||||||
|
<div style={{flex:1,minWidth:0}}>
|
||||||
|
<div style={{color:'#fff',fontFamily:'monospace',fontSize:12,
|
||||||
|
overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap'}}>{s.message}</div>
|
||||||
|
<div style={{color:'#4ecdc4',fontFamily:'monospace',fontSize:10,marginTop:2}}>
|
||||||
|
🕐 {fmtDt(s.scheduled_at)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={()=>{setEditId(s.id);setEditMsg(s.message);setEditDt(s.scheduled_at?.slice(0,16)||'');}}
|
||||||
|
style={{...S.btn('#4ecdc4',true),padding:'3px 8px',fontSize:11,flexShrink:0}}>✎</button>
|
||||||
|
<button onClick={()=>del(s.id)}
|
||||||
|
style={{...S.btn('#ff6b9d',true),padding:'3px 8px',fontSize:11,flexShrink:0}}>✕</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function Dashboard({ toast, mobile, setActive, unreadMsgs=0, unreadBoard=0, unreadPromoted=0, onBoardRead, user }) {
|
function Dashboard({ toast, mobile, setActive, unreadMsgs=0, unreadBoard=0, unreadPromoted=0, onBoardRead, user }) {
|
||||||
const [now, setNow] = useState(new Date());
|
const [now, setNow] = useState(new Date());
|
||||||
const [showBoard, setShowBoard] = useState(false);
|
const [showBoard, setShowBoard] = useState(false);
|
||||||
@@ -1013,6 +1138,7 @@ function Dashboard({ toast, mobile, setActive, unreadMsgs=0, unreadBoard=0, unre
|
|||||||
fontFamily:'monospace', marginTop:2, flexShrink:0 }}>↺</button>
|
fontFamily:'monospace', marginTop:2, flexShrink:0 }}>↺</button>
|
||||||
</div>
|
</div>
|
||||||
<QuickLinks toast={toast} mobile={mobile} />
|
<QuickLinks toast={toast} mobile={mobile} />
|
||||||
|
<PushScheduler toast={toast}/>
|
||||||
<CalendarWidget/>
|
<CalendarWidget/>
|
||||||
<BestellStats/>
|
<BestellStats/>
|
||||||
<TodoList toast={toast} />
|
<TodoList toast={toast} />
|
||||||
|
|||||||
Reference in New Issue
Block a user