Todo: daily reminders at fixed time, no date, simplified sidebar
Deploy HouseHub / deploy (push) Successful in 2s
Deploy HouseHub / deploy (push) Successful in 2s
- Remove date field; due_time is now required - Cron repeats daily via notified_date (resets at midnight) - Remove Today/Upcoming/Overdue smart views from sidebar - Task card shows bell icon + time instead of date labels - Creation no longer sends immediate Discord notification (cron only) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
5d5ccf406d
commit
606dcbf3d7
+9
-12
@@ -1,7 +1,8 @@
|
|||||||
<?php
|
<?php
|
||||||
/**
|
/**
|
||||||
* Cron: Todo reminder notifications via Discord webhook
|
* Cron: Todo daily reminder notifications via Discord webhook
|
||||||
* Runs every minute — sends Discord alert when due_date+due_time is reached.
|
* Runs every minute — sends Discord reminder when due_time is reached.
|
||||||
|
* Repeats every day at the same time (resets at midnight via notified_date).
|
||||||
* Usage: php /opt/container/househub/cron/todo_notify.php
|
* Usage: php /opt/container/househub/cron/todo_notify.php
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -10,7 +11,7 @@ $DB_USER = getenv('DB_USER') ?: 'househub';
|
|||||||
$DB_PASS = getenv('DB_PASS') ?: 'changeme';
|
$DB_PASS = getenv('DB_PASS') ?: 'changeme';
|
||||||
$DB_ROOT = getenv('DB_ROOT_PASS') ?: 'rootchangeme';
|
$DB_ROOT = getenv('DB_ROOT_PASS') ?: 'rootchangeme';
|
||||||
|
|
||||||
// Load .env if running from CLI (not inside container)
|
// Load .env if running from CLI outside container
|
||||||
$env_file = dirname(__DIR__) . '/.env';
|
$env_file = dirname(__DIR__) . '/.env';
|
||||||
if (file_exists($env_file)) {
|
if (file_exists($env_file)) {
|
||||||
foreach (file($env_file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
|
foreach (file($env_file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
|
||||||
@@ -51,34 +52,30 @@ foreach ($families as $family_id) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip if todo tables don't exist yet
|
|
||||||
if (!$pdo->query("SHOW TABLES LIKE 'pf_todos'")->fetchColumn()) continue;
|
if (!$pdo->query("SHOW TABLES LIKE 'pf_todos'")->fetchColumn()) continue;
|
||||||
|
|
||||||
// Get Discord webhook for this family
|
|
||||||
$ws = $pdo->prepare("SELECT content FROM pf_notes WHERE note_type='todo_settings' AND reference_id='webhook_discord'");
|
$ws = $pdo->prepare("SELECT content FROM pf_notes WHERE note_type='todo_settings' AND reference_id='webhook_discord'");
|
||||||
$ws->execute();
|
$ws->execute();
|
||||||
$webhook = $ws->fetchColumn();
|
$webhook = $ws->fetchColumn();
|
||||||
if (!$webhook) continue;
|
if (!$webhook) continue;
|
||||||
|
|
||||||
// Tasks due now: due_time reached, not done, not yet notified
|
// Daily reminder: fires every day when due_time is reached, resets at midnight
|
||||||
// Handles: date+time, time only (null date = today), overdue with time
|
|
||||||
$stmt = $pdo->prepare("
|
$stmt = $pdo->prepare("
|
||||||
SELECT t.id, t.title, t.due_date, t.due_time,
|
SELECT t.id, t.title, t.due_time,
|
||||||
l.name AS list_name, l.icon AS list_icon
|
l.name AS list_name, l.icon AS list_icon
|
||||||
FROM pf_todos t
|
FROM pf_todos t
|
||||||
LEFT JOIN pf_todo_lists l ON l.id = t.list_id
|
LEFT JOIN pf_todo_lists l ON l.id = t.list_id
|
||||||
WHERE t.due_time IS NOT NULL
|
WHERE t.due_time IS NOT NULL
|
||||||
AND (t.due_date IS NULL OR t.due_date <= CURDATE())
|
|
||||||
AND t.due_time <= CURTIME()
|
AND t.due_time <= CURTIME()
|
||||||
AND t.done = 0
|
AND t.done = 0
|
||||||
AND t.notified = 0
|
AND (t.notified_date IS NULL OR t.notified_date < CURDATE())
|
||||||
");
|
");
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
|
|
||||||
foreach ($stmt->fetchAll() as $t) {
|
foreach ($stmt->fetchAll() as $t) {
|
||||||
$time = substr($t['due_time'], 0, 5);
|
$time = substr($t['due_time'], 0, 5);
|
||||||
$list = $t['list_name'] ? " _({$t['list_icon']} {$t['list_name']})_" : '';
|
$list = $t['list_name'] ? " _({$t['list_icon']} {$t['list_name']})_" : '';
|
||||||
$msg = "⏰ **Rappel** : {$t['title']}{$list} — prévu à {$time}";
|
$msg = "⏰ **Rappel** : {$t['title']}{$list} — {$time}";
|
||||||
|
|
||||||
$ch = curl_init($webhook);
|
$ch = curl_init($webhook);
|
||||||
curl_setopt_array($ch, [
|
curl_setopt_array($ch, [
|
||||||
@@ -93,7 +90,7 @@ foreach ($families as $family_id) {
|
|||||||
curl_close($ch);
|
curl_close($ch);
|
||||||
|
|
||||||
if ($ok) {
|
if ($ok) {
|
||||||
$pdo->prepare("UPDATE pf_todos SET notified=1 WHERE id=?")->execute([$t['id']]);
|
$pdo->prepare("UPDATE pf_todos SET notified_date = CURDATE() WHERE id=?")->execute([$t['id']]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -336,6 +336,7 @@ CREATE TABLE IF NOT EXISTS pf_todos (
|
|||||||
due_date DATE DEFAULT NULL,
|
due_date DATE DEFAULT NULL,
|
||||||
due_time TIME DEFAULT NULL,
|
due_time TIME DEFAULT NULL,
|
||||||
notified TINYINT(1) DEFAULT 0,
|
notified TINYINT(1) DEFAULT 0,
|
||||||
|
notified_date DATE DEFAULT NULL,
|
||||||
priority ENUM('none','low','medium','high') DEFAULT 'none',
|
priority ENUM('none','low','medium','high') DEFAULT 'none',
|
||||||
done TINYINT(1) DEFAULT 0,
|
done TINYINT(1) DEFAULT 0,
|
||||||
done_at DATETIME DEFAULT NULL,
|
done_at DATETIME DEFAULT NULL,
|
||||||
|
|||||||
@@ -122,10 +122,7 @@ if ($action === 'todos') {
|
|||||||
$new = $pdo->prepare("SELECT t.*, l.name as list_name, l.color as list_color, l.icon as list_icon FROM pf_todos t LEFT JOIN pf_todo_lists l ON l.id = t.list_id WHERE t.id=?");
|
$new = $pdo->prepare("SELECT t.*, l.name as list_name, l.color as list_color, l.icon as list_icon FROM pf_todos t LEFT JOIN pf_todo_lists l ON l.id = t.list_id WHERE t.id=?");
|
||||||
$new->execute([$id]);
|
$new->execute([$id]);
|
||||||
$row = $new->fetch();
|
$row = $new->fetch();
|
||||||
// Only notify immediately if no due_time (otherwise cron sends it at the right time)
|
// Notifications handled by cron at due_time each day
|
||||||
if (empty($d['due_time'])) {
|
|
||||||
discordNotify($pdo, "📋 **Nouvelle tâche** : " . $title . ($row['list_name'] ? " _(". $row['list_name'] .")_" : ""));
|
|
||||||
}
|
|
||||||
tOk($row);
|
tOk($row);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,8 +145,8 @@ if ($action === 'todos') {
|
|||||||
|
|
||||||
// Full update
|
// Full update
|
||||||
$title = trim($d['title'] ?? ''); if (!$title) tErr('Titre requis');
|
$title = trim($d['title'] ?? ''); if (!$title) tErr('Titre requis');
|
||||||
// Reset notified if due_time changed
|
// Reset notified_date so cron fires again today if time changed
|
||||||
$pdo->prepare("UPDATE pf_todos SET list_id=?, title=?, notes=?, due_date=?, due_time=?, priority=?, notified=0, updated_at=NOW() WHERE id=?")
|
$pdo->prepare("UPDATE pf_todos SET list_id=?, title=?, notes=?, due_date=?, due_time=?, priority=?, notified_date=NULL, updated_at=NOW() WHERE id=?")
|
||||||
->execute([$d['list_id'] ?: null, $title, $d['notes'] ?? null, $d['due_date'] ?: null, $d['due_time'] ?: null, $d['priority'] ?? 'none', $id]);
|
->execute([$d['list_id'] ?: null, $title, $d['notes'] ?? null, $d['due_date'] ?: null, $d['due_time'] ?: null, $d['priority'] ?? 'none', $id]);
|
||||||
tOk(['updated' => true]);
|
tOk(['updated' => true]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,20 +47,7 @@ async function loadSidebar(){
|
|||||||
<span>📋</span> Toutes
|
<span>📋</span> Toutes
|
||||||
<span class="todo-nav-badge">${stats.pending||0}</span>
|
<span class="todo-nav-badge">${stats.pending||0}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="todo-nav-item${currentFilter==='today'?' active':''}" onclick="setFilter('today')">
|
<div class="todo-nav-item${currentFilter==='done'?' active':''}" onclick="setFilter('done')">
|
||||||
<span>☀️</span> Aujourd'hui
|
|
||||||
<span class="todo-nav-badge${parseInt(stats.today)>0?' urgent':''}">${stats.today||0}</span>
|
|
||||||
</div>
|
|
||||||
<div class="todo-nav-item${currentFilter==='upcoming'?' active':''}" onclick="setFilter('upcoming')">
|
|
||||||
<span>📅</span> À venir
|
|
||||||
</div>`;
|
|
||||||
if(parseInt(stats.overdue)>0){
|
|
||||||
html+=`<div class="todo-nav-item${currentFilter==='overdue'?' active':''}" onclick="setFilter('overdue')">
|
|
||||||
<span>⚠️</span> En retard
|
|
||||||
<span class="todo-nav-badge urgent">${stats.overdue}</span>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
html+=`<div class="todo-nav-item${currentFilter==='done'?' active':''}" onclick="setFilter('done')">
|
|
||||||
<span>✅</span> Terminées
|
<span>✅</span> Terminées
|
||||||
<span class="todo-nav-badge">${stats.done||0}</span>
|
<span class="todo-nav-badge">${stats.done||0}</span>
|
||||||
</div>`;
|
</div>`;
|
||||||
@@ -91,11 +78,7 @@ function setFilter(f){
|
|||||||
async function loadTodos(){
|
async function loadTodos(){
|
||||||
try{
|
try{
|
||||||
let extra='';
|
let extra='';
|
||||||
if(currentFilter==='all')extra='';
|
if(currentFilter==='done')extra='&list_id=done&show_done=1';
|
||||||
else if(currentFilter==='done')extra='&list_id=done&show_done=1';
|
|
||||||
else if(currentFilter==='today')extra='&list_id=today';
|
|
||||||
else if(currentFilter==='upcoming')extra='&list_id=upcoming';
|
|
||||||
else if(currentFilter==='overdue')extra='&list_id=overdue';
|
|
||||||
else if(currentFilter.startsWith('list_'))extra='&list_id='+currentFilter.slice(5);
|
else if(currentFilter.startsWith('list_'))extra='&list_id='+currentFilter.slice(5);
|
||||||
if(showDone&¤tFilter!=='done')extra+='&show_done=1';
|
if(showDone&¤tFilter!=='done')extra+='&show_done=1';
|
||||||
|
|
||||||
@@ -109,7 +92,7 @@ async function loadTodos(){
|
|||||||
function updateHeader(){
|
function updateHeader(){
|
||||||
const el=document.getElementById('todo-header-title');
|
const el=document.getElementById('todo-header-title');
|
||||||
if(!el)return;
|
if(!el)return;
|
||||||
const map={all:'Toutes les tâches',today:"Aujourd'hui",upcoming:'À venir',done:'Tâches terminées',overdue:'En retard'};
|
const map={all:'Toutes les tâches',done:'Tâches terminées'};
|
||||||
if(map[currentFilter]){el.textContent=map[currentFilter];return;}
|
if(map[currentFilter]){el.textContent=map[currentFilter];return;}
|
||||||
if(currentFilter.startsWith('list_')){
|
if(currentFilter.startsWith('list_')){
|
||||||
const l=lists.find(x=>x.id==currentFilter.slice(5));
|
const l=lists.find(x=>x.id==currentFilter.slice(5));
|
||||||
@@ -158,8 +141,7 @@ function todoItemHtml(t){
|
|||||||
const checkClass='todo-check'+(isDone?' checked':'');
|
const checkClass='todo-check'+(isDone?' checked':'');
|
||||||
const itemClass='todo-item'+(isDone?' done':'');
|
const itemClass='todo-item'+(isDone?' done':'');
|
||||||
const dueCls=isOverdue(t.due_date)&&!isDone?' overdue':isToday(t.due_date)&&!isDone?' today':'';
|
const dueCls=isOverdue(t.due_date)&&!isDone?' overdue':isToday(t.due_date)&&!isDone?' today':'';
|
||||||
const timeLabel=t.due_time?fmtTime(t.due_time):null;
|
const dueLabel=t.due_time?('🔔 '+fmtTime(t.due_time)):null;
|
||||||
const dueLabel=t.due_date?(isToday(t.due_date)?'Aujourd\'hui'+(timeLabel?' à '+timeLabel:''):isOverdue(t.due_date)?'En retard '+fmtDate(t.due_date)+(timeLabel?' '+timeLabel:''):fmtDate(t.due_date)+(timeLabel?' à '+timeLabel:'')):null;
|
|
||||||
const priBadge=t.priority&&t.priority!=='none'?
|
const priBadge=t.priority&&t.priority!=='none'?
|
||||||
`<span class="todo-priority-badge pri-${t.priority}">${t.priority==='high'?'Urgent':t.priority==='medium'?'Normal':'Bas'}</span>`:'';
|
`<span class="todo-priority-badge pri-${t.priority}">${t.priority==='high'?'Urgent':t.priority==='medium'?'Normal':'Bas'}</span>`:'';
|
||||||
const listBadge=t.list_name?
|
const listBadge=t.list_name?
|
||||||
@@ -170,7 +152,7 @@ function todoItemHtml(t){
|
|||||||
<div class="todo-title">${escHtml(t.title)}</div>
|
<div class="todo-title">${escHtml(t.title)}</div>
|
||||||
${t.notes?`<div class="todo-notes-preview">${escHtml(t.notes)}</div>`:''}
|
${t.notes?`<div class="todo-notes-preview">${escHtml(t.notes)}</div>`:''}
|
||||||
<div class="todo-meta">
|
<div class="todo-meta">
|
||||||
${dueLabel?`<span class="todo-due${dueCls}">📅 ${escHtml(dueLabel)}</span>`:''}
|
${dueLabel?`<span class="todo-due">${escHtml(dueLabel)}</span>`:''}
|
||||||
${priBadge}${listBadge}
|
${priBadge}${listBadge}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -220,7 +202,6 @@ function openAddTodo(){
|
|||||||
document.getElementById('todo-modal-title').textContent='Nouvelle tâche';
|
document.getElementById('todo-modal-title').textContent='Nouvelle tâche';
|
||||||
document.getElementById('todo-form-title').value='';
|
document.getElementById('todo-form-title').value='';
|
||||||
document.getElementById('todo-form-notes').value='';
|
document.getElementById('todo-form-notes').value='';
|
||||||
document.getElementById('todo-form-due').value='';
|
|
||||||
document.getElementById('todo-form-time').value='';
|
document.getElementById('todo-form-time').value='';
|
||||||
document.getElementById('todo-delete-btn').style.display='none';
|
document.getElementById('todo-delete-btn').style.display='none';
|
||||||
const sel=document.getElementById('todo-form-list');
|
const sel=document.getElementById('todo-form-list');
|
||||||
@@ -240,7 +221,6 @@ async function openEditTodo(id){
|
|||||||
document.getElementById('todo-modal-title').textContent='Modifier la tâche';
|
document.getElementById('todo-modal-title').textContent='Modifier la tâche';
|
||||||
document.getElementById('todo-form-title').value=t.title;
|
document.getElementById('todo-form-title').value=t.title;
|
||||||
document.getElementById('todo-form-notes').value=t.notes||'';
|
document.getElementById('todo-form-notes').value=t.notes||'';
|
||||||
document.getElementById('todo-form-due').value=t.due_date||'';
|
|
||||||
document.getElementById('todo-form-time').value=t.due_time?t.due_time.slice(0,5):'';
|
document.getElementById('todo-form-time').value=t.due_time?t.due_time.slice(0,5):'';
|
||||||
document.getElementById('todo-delete-btn').style.display='';
|
document.getElementById('todo-delete-btn').style.display='';
|
||||||
const sel=document.getElementById('todo-form-list');
|
const sel=document.getElementById('todo-form-list');
|
||||||
@@ -262,11 +242,12 @@ function setPriority(p){
|
|||||||
async function saveTodo(){
|
async function saveTodo(){
|
||||||
const title=document.getElementById('todo-form-title').value.trim();
|
const title=document.getElementById('todo-form-title').value.trim();
|
||||||
if(!title){toast('Titre requis','error');return;}
|
if(!title){toast('Titre requis','error');return;}
|
||||||
|
const time=document.getElementById('todo-form-time').value;
|
||||||
|
if(!time){toast('Heure de rappel requise','error');return;}
|
||||||
const data={
|
const data={
|
||||||
title,
|
title,
|
||||||
notes:document.getElementById('todo-form-notes').value||null,
|
notes:document.getElementById('todo-form-notes').value||null,
|
||||||
due_date:document.getElementById('todo-form-due').value||null,
|
due_time:time,
|
||||||
due_time:document.getElementById('todo-form-time').value||null,
|
|
||||||
list_id:document.getElementById('todo-form-list').value||null,
|
list_id:document.getElementById('todo-form-list').value||null,
|
||||||
priority:selectedPriority
|
priority:selectedPriority
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -74,22 +74,17 @@ require __DIR__ . '/header.php';
|
|||||||
|
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Date limite</label>
|
<label class="form-label">Heure de rappel *</label>
|
||||||
<input type="date" id="todo-form-due" class="form-control">
|
<input type="time" id="todo-form-time" class="form-control" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Heure</label>
|
<label class="form-label">Liste</label>
|
||||||
<input type="time" id="todo-form-time" class="form-control">
|
<select id="todo-form-list" class="form-control">
|
||||||
|
<option value="">— Aucune —</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Liste</label>
|
|
||||||
<select id="todo-form-list" class="form-control">
|
|
||||||
<option value="">— Aucune —</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Priorité</label>
|
<label class="form-label">Priorité</label>
|
||||||
<div class="priority-opts">
|
<div class="priority-opts">
|
||||||
|
|||||||
Reference in New Issue
Block a user