feat: add break and end-of-day smart reminders with idle toasts and push job

Extend smart notifications with break-interval and end-of-day wrap-up
kinds, user settings, migration 154, idle.js toasts, and a 15-minute
scheduler job for optional Web Push. Document new kinds and env defaults.
This commit is contained in:
Dries Peeters
2026-05-15 08:54:52 +02:00
parent 09146fcd2b
commit 154d3a5db6
12 changed files with 727 additions and 8 deletions
+4
View File
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- **Smart reminders: break, end-of-day, and idle toasts** — Extends smart in-app notifications with optional **break reminder** (Pomodoro-style nudge every N minutes while a timer runs, 15240 min) and **end-of-day wrap-up** (hours logged today in a configurable hour window). New kinds `break_reminder` and `end_of_day_reminder` in `NotificationService`; user prefs under **Settings → Notifications**; migration `154_add_smart_notify_break_and_eod`. [`app/static/idle.js`](app/static/idle.js) shows blue/purple/green toasts for no-tracking, break, and end-of-day (alongside existing idle stop-timer prompt). APScheduler job `smart_reminder_push` (every 15 min) sends browser push for eligible users when VAPID and push subscriptions are available. Env default `SMART_NOTIFY_END_OF_DAY_AT` (`17:00`). See [docs/features/SMART_NOTIFICATIONS.md](docs/features/SMART_NOTIFICATIONS.md).
## [5.5.7] - 2026-05-14
### Fixed
+1
View File
@@ -315,6 +315,7 @@ class Config:
SMART_NOTIFY_NO_TRACKING_AFTER = os.getenv("SMART_NOTIFY_NO_TRACKING_AFTER", "16:00").strip()
SMART_NOTIFY_SUMMARY_AT = os.getenv("SMART_NOTIFY_SUMMARY_AT", "18:00").strip()
SMART_NOTIFY_LONG_TIMER_HOURS = float(os.getenv("SMART_NOTIFY_LONG_TIMER_HOURS", "4"))
SMART_NOTIFY_END_OF_DAY_AT = os.getenv("SMART_NOTIFY_END_OF_DAY_AT", "17:00").strip()
# Fire time-based kinds only during the first N minutes of the configured hour (same idea as email remind-to-log).
SMART_NOTIFY_SCHEDULER_SLOT_MINUTES = max(1, min(59, int(os.getenv("SMART_NOTIFY_SCHEDULER_SLOT_MINUTES", "30"))))
+4
View File
@@ -62,6 +62,10 @@ class User(UserMixin, db.Model):
smart_notify_browser = db.Column(db.Boolean, default=False, nullable=False)
smart_notify_no_tracking_after = db.Column(db.String(5), nullable=True) # HH:MM override; null = use app config
smart_notify_summary_at = db.Column(db.String(5), nullable=True) # HH:MM override; null = use app config
smart_notify_break_reminder = db.Column(db.Boolean, default=False, nullable=False)
smart_notify_break_interval_minutes = db.Column(db.Integer, default=60, nullable=False)
smart_notify_end_of_day = db.Column(db.Boolean, default=False, nullable=False)
smart_notify_end_of_day_time = db.Column(db.String(5), nullable=True) # HH:MM; null = use app config
timezone = db.Column(db.String(50), nullable=True) # User-specific timezone override
date_format = db.Column(db.String(20), default=None, nullable=True) # None = use system default
time_format = db.Column(db.String(10), default=None, nullable=True) # None = use system default
@@ -11,6 +11,6 @@ class UserSmartNotificationDismissal(db.Model):
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
local_date = db.Column(db.String(10), nullable=False) # YYYY-MM-DD in user's timezone
local_date = db.Column(db.String(64), nullable=False) # YYYY-MM-DD in user's timezone, or bucket key
kind = db.Column(db.String(32), nullable=False)
dismissed_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
+10
View File
@@ -72,9 +72,12 @@ def settings():
current_user.smart_notify_long_timer = "smart_notify_long_timer" in request.form
current_user.smart_notify_daily_summary = "smart_notify_daily_summary" in request.form
current_user.smart_notify_browser = "smart_notify_browser" in request.form
current_user.smart_notify_break_reminder = "smart_notify_break_reminder" in request.form
current_user.smart_notify_end_of_day = "smart_notify_end_of_day" in request.form
for form_key, attr in (
("smart_notify_no_tracking_after", "smart_notify_no_tracking_after"),
("smart_notify_summary_at", "smart_notify_summary_at"),
("smart_notify_end_of_day_time", "smart_notify_end_of_day_time"),
):
raw = (request.form.get(form_key) or "").strip()
if raw and len(raw) <= 5:
@@ -84,6 +87,13 @@ def settings():
setattr(current_user, attr, None)
else:
setattr(current_user, attr, None)
try:
raw_interval = (request.form.get("smart_notify_break_interval_minutes") or "").strip()
if raw_interval:
parsed_interval = int(raw_interval)
current_user.smart_notify_break_interval_minutes = max(15, min(240, parsed_interval))
except (TypeError, ValueError):
pass
# Profile information
full_name = request.form.get("full_name", "").strip()
+108 -2
View File
@@ -16,6 +16,8 @@ from app.utils.db import safe_commit
KIND_NO_TRACKING = "no_tracking_today"
KIND_LONG_TIMER = "timer_running_long"
KIND_DAILY_SUMMARY = "daily_summary"
KIND_BREAK_REMINDER = "break_reminder"
KIND_END_OF_DAY = "end_of_day_reminder"
_HHMM_RE = re.compile(r"^([01]?\d|2[0-3]):([0-5]\d)$")
@@ -116,14 +118,60 @@ def _in_hour_slot(user_local_now: datetime, target_hour: int, slot_minutes: int)
return user_local_now.hour == target_hour and user_local_now.minute < slot_minutes
def _bucket_marker_exists(user_id: int, kind: str, bucket_key: str) -> bool:
"""True if a UserSmartNotificationDismissal row already records this bucket (i.e. already fired)."""
return (
UserSmartNotificationDismissal.query.filter_by(user_id=user_id, kind=kind, local_date=bucket_key)
.with_entities(UserSmartNotificationDismissal.id)
.first()
is not None
)
def _record_bucket_marker(user_id: int, kind: str, bucket_key: str) -> bool:
"""Insert a marker row so this bucket only fires once. Returns True on success.
Idempotent: if the row already exists (race), returns False so the caller does not
emit a duplicate notification on this request.
"""
try:
db.session.add(
UserSmartNotificationDismissal(
user_id=user_id,
local_date=bucket_key,
kind=kind,
dismissed_at=datetime.utcnow(),
)
)
return bool(safe_commit())
except Exception:
try:
db.session.rollback()
except Exception:
pass
return False
class NotificationService:
"""Builds smart notification payloads for the authenticated user."""
_PRIORITY = {KIND_LONG_TIMER: 0, KIND_NO_TRACKING: 1, KIND_DAILY_SUMMARY: 2}
_PRIORITY = {
KIND_LONG_TIMER: 0,
KIND_BREAK_REMINDER: 1,
KIND_NO_TRACKING: 1,
KIND_DAILY_SUMMARY: 2,
KIND_END_OF_DAY: 3,
}
@classmethod
def dismiss(cls, user, kind: str, local_date: str) -> bool:
if kind not in (KIND_NO_TRACKING, KIND_LONG_TIMER, KIND_DAILY_SUMMARY):
if kind not in (
KIND_NO_TRACKING,
KIND_LONG_TIMER,
KIND_DAILY_SUMMARY,
KIND_BREAK_REMINDER,
KIND_END_OF_DAY,
):
return False
if not local_date or len(local_date) != 10:
return False
@@ -154,6 +202,7 @@ class NotificationService:
default_nudge = (cfg.get("SMART_NOTIFY_NO_TRACKING_AFTER") or "16:00").strip()
default_summary = (cfg.get("SMART_NOTIFY_SUMMARY_AT") or "18:00").strip()
default_end_of_day = (cfg.get("SMART_NOTIFY_END_OF_DAY_AT") or "17:00").strip()
meta_base = {
"max_per_day": max_per,
@@ -171,6 +220,7 @@ class NotificationService:
"enabled": False,
"no_tracking_after": (getattr(user, "smart_notify_no_tracking_after", None) or default_nudge),
"summary_at": (getattr(user, "smart_notify_summary_at", None) or default_summary),
"end_of_day_at": (getattr(user, "smart_notify_end_of_day_time", None) or default_end_of_day),
"browser_push": bool(getattr(user, "smart_notify_browser", False)),
},
}
@@ -184,10 +234,13 @@ class NotificationService:
nudge_t = parse_hhmm(getattr(user, "smart_notify_no_tracking_after", None)) or parse_hhmm(default_nudge)
summary_t = parse_hhmm(getattr(user, "smart_notify_summary_at", None)) or parse_hhmm(default_summary)
end_of_day_t = parse_hhmm(getattr(user, "smart_notify_end_of_day_time", None)) or parse_hhmm(default_end_of_day)
if not nudge_t:
nudge_t = (16, 0)
if not summary_t:
summary_t = (18, 0)
if not end_of_day_t:
end_of_day_t = (17, 0)
meta = {
**meta_base,
@@ -195,6 +248,7 @@ class NotificationService:
"enabled": True,
"no_tracking_after": f"{nudge_t[0]:02d}:{nudge_t[1]:02d}",
"summary_at": f"{summary_t[0]:02d}:{summary_t[1]:02d}",
"end_of_day_at": f"{end_of_day_t[0]:02d}:{end_of_day_t[1]:02d}",
"browser_push": bool(getattr(user, "smart_notify_browser", False)),
}
@@ -256,6 +310,58 @@ class NotificationService:
}
)
# Break reminder: nudge once per interval bucket while a timer is running
if (
getattr(user, "smart_notify_break_reminder", False)
and KIND_BREAK_REMINDER not in dismissed
and active_timer is not None
and active_timer.start_time is not None
):
try:
raw_interval = int(getattr(user, "smart_notify_break_interval_minutes", 60) or 60)
except (TypeError, ValueError):
raw_interval = 60
interval_minutes = max(15, min(240, raw_interval))
start_u = _entry_start_as_utc_aware(active_timer.start_time)
if start_u:
elapsed_minutes = (now_utc - start_u).total_seconds() / 60.0
if elapsed_minutes >= interval_minutes:
bucket = int(elapsed_minutes // interval_minutes)
bucket_key = f"break_{active_timer.id}_{bucket}"
if not _bucket_marker_exists(user.id, KIND_BREAK_REMINDER, bucket_key):
if _record_bucket_marker(user.id, KIND_BREAK_REMINDER, bucket_key):
elapsed_h = int(elapsed_minutes // 60)
elapsed_m = int(elapsed_minutes - elapsed_h * 60)
candidates.append(
{
"kind": KIND_BREAK_REMINDER,
"title": "Time for a break",
"message": (
f"You've been tracking for {elapsed_h}h {elapsed_m}m. "
f"Consider taking a short break."
),
"type": "info",
"priority": "normal",
"action": {"label": "Pause timer", "url": "/timer/pause"},
}
)
# End-of-day reminder (time slot)
if getattr(user, "smart_notify_end_of_day", False) and KIND_END_OF_DAY not in dismissed:
if _in_hour_slot(user_local_now, end_of_day_t[0], slot_minutes):
candidates.append(
{
"kind": KIND_END_OF_DAY,
"title": "End of day",
"message": (
f"It's nearly end of day. You've logged {hours_today:.1f}h today."
),
"type": "info",
"priority": "normal",
"action": {"label": "View today's entries", "url": "/time-entries"},
}
)
candidates.sort(key=lambda n: cls._PRIORITY.get(n["kind"], 99))
notifications = candidates[:max_per]
+221
View File
@@ -95,9 +95,230 @@
const stopTs = Date.now() - idleFor;
showIdlePrompt(stopTs);
}
// Break reminder follows the active timer state; check on every tick.
try { checkBreakNudge(active); } catch(e) {}
}
setInterval(tick, CHECK_INTERVAL_MS);
// ---------------------------------------------------------------------------
// Smart reminder toasts (no-timer nudge, break reminder, end-of-day reminder)
// ---------------------------------------------------------------------------
const REMINDER_POLL_MS = 5 * 60 * 1000; // 5 minutes
let noTimerNudgeShown = false;
let endOfDayNudgeShown = false;
let breakNudgeShown = false;
let activeReminderToast = null;
let lastTimerIdForBreak = null;
let lastNotificationsFetch = { at: 0, payload: null };
let lastResetDay = new Date().toDateString();
function resetReminderFlagsIfNewDay(){
const today = new Date().toDateString();
if (today !== lastResetDay){
lastResetDay = today;
noTimerNudgeShown = false;
endOfDayNudgeShown = false;
breakNudgeShown = false;
}
}
function todayIsoLocal(){
const d = new Date();
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return y + '-' + m + '-' + day;
}
async function fetchNotifications(forceRefresh){
const now = Date.now();
if (!forceRefresh && lastNotificationsFetch.payload && (now - lastNotificationsFetch.at) < REMINDER_POLL_MS){
return lastNotificationsFetch.payload;
}
try {
const r = await fetch('/api/notifications', { headers: { 'Accept': 'application/json' } });
if (!r.ok) return null;
const j = await r.json();
lastNotificationsFetch = { at: now, payload: j };
return j;
} catch(e){ return null; }
}
function findNotification(payload, kind){
if (!payload || !Array.isArray(payload.notifications)) return null;
return payload.notifications.find(function(n){ return n && n.kind === kind; }) || null;
}
async function dismissNotification(kind, localDate){
try {
await fetch('/api/notifications/dismiss', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ kind: kind, local_date: localDate || todayIsoLocal() })
});
} catch(e) {}
}
function getToastContainer(){
return document.getElementById('toast-notification-container') ||
document.getElementById('flash-messages-container') || document.body;
}
// Pre-built class strings per color so Tailwind's content scan sees literal classes.
// The shape mirrors showIdlePrompt() exactly: just amber → blue/purple/green.
const TOAST_CLASSES = {
blue: {
wrap: 'flex items-center gap-3 p-4 bg-blue-100 dark:bg-blue-900/30 border border-blue-300 dark:border-blue-700 rounded-lg shadow-lg pointer-events-auto',
body: 'flex-1 text-sm text-blue-900 dark:text-blue-100',
primary: 'px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm font-medium',
secondary: 'px-3 py-1.5 bg-blue-200 dark:bg-blue-800 hover:bg-blue-300 dark:hover:bg-blue-700 text-blue-900 dark:text-blue-100 rounded text-sm font-medium',
link: 'px-3 py-1.5 text-blue-700 dark:text-blue-300 hover:underline text-sm'
},
purple: {
wrap: 'flex items-center gap-3 p-4 bg-purple-100 dark:bg-purple-900/30 border border-purple-300 dark:border-purple-700 rounded-lg shadow-lg pointer-events-auto',
body: 'flex-1 text-sm text-purple-900 dark:text-purple-100',
primary: 'px-3 py-1.5 bg-purple-600 hover:bg-purple-700 text-white rounded text-sm font-medium',
secondary: 'px-3 py-1.5 bg-purple-200 dark:bg-purple-800 hover:bg-purple-300 dark:hover:bg-purple-700 text-purple-900 dark:text-purple-100 rounded text-sm font-medium',
link: 'px-3 py-1.5 text-purple-700 dark:text-purple-300 hover:underline text-sm'
},
green: {
wrap: 'flex items-center gap-3 p-4 bg-green-100 dark:bg-green-900/30 border border-green-300 dark:border-green-700 rounded-lg shadow-lg pointer-events-auto',
body: 'flex-1 text-sm text-green-900 dark:text-green-100',
primary: 'px-3 py-1.5 bg-green-600 hover:bg-green-700 text-white rounded text-sm font-medium',
secondary: 'px-3 py-1.5 bg-green-200 dark:bg-green-800 hover:bg-green-300 dark:hover:bg-green-700 text-green-900 dark:text-green-100 rounded text-sm font-medium',
link: 'px-3 py-1.5 text-green-700 dark:text-green-300 hover:underline text-sm'
}
};
function buildReminderToast(colorKey, message, buttons, autoDismissMs, onClose){
const cls = TOAST_CLASSES[colorKey] || TOAST_CLASSES.blue;
const toastEl = document.createElement('div');
toastEl.className = cls.wrap;
let html = '<div class="' + cls.body + '">' + message + '</div>';
html += '<div class="flex gap-2">';
buttons.forEach(function(b, idx){
const klass = b.style === 'secondary' ? cls.secondary : (b.style === 'link' ? cls.link : cls.primary);
html += '<button class="' + klass + '" data-act="' + idx + '">' + b.label + '</button>';
});
html += '</div>';
toastEl.innerHTML = html;
function close(){
try { toastEl.remove(); } catch(e){}
if (activeReminderToast === toastEl) activeReminderToast = null;
if (typeof onClose === 'function') onClose();
}
buttons.forEach(function(b, idx){
const btn = toastEl.querySelector('[data-act="' + idx + '"]');
if (btn) btn.addEventListener('click', function(){
close();
try { if (typeof b.onClick === 'function') b.onClick(); } catch(e){}
});
});
getToastContainer().appendChild(toastEl);
activeReminderToast = toastEl;
if (autoDismissMs > 0){
setTimeout(function(){ if (document.body.contains(toastEl)) close(); }, autoDismissMs);
}
return toastEl;
}
function escapeHtml(s){
return String(s || '').replace(/[&<>"']/g, function(c){
return ({ '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;' })[c];
});
}
async function checkNoTimerAndEndOfDayNudges(){
resetReminderFlagsIfNewDay();
if (noTimerNudgeShown && endOfDayNudgeShown) return;
if (activeReminderToast) return;
const payload = await fetchNotifications(false);
if (!payload) return;
// No-timer nudge
if (!noTimerNudgeShown && !activeReminderToast){
const note = findNotification(payload, 'no_tracking_today');
if (note){
noTimerNudgeShown = true;
const msg = escapeHtml(note.message || 'You have not tracked anything today.');
buildReminderToast(
'blue',
msg,
[
{ label: (window.i18n?.messages?.startTimer || 'Start timer'), style: 'primary', onClick: function(){ window.location.href = '/'; } },
{ label: (window.i18n?.messages?.dismiss || 'Dismiss'), style: 'link', onClick: function(){ dismissNotification('no_tracking_today'); } }
],
30000
);
}
}
// End-of-day reminder
if (!endOfDayNudgeShown && !activeReminderToast){
const note = findNotification(payload, 'end_of_day_reminder');
if (note){
endOfDayNudgeShown = true;
const msg = escapeHtml(note.message || "It's nearly end of day.");
buildReminderToast(
'green',
msg,
[
{ label: (window.i18n?.messages?.viewEntries || 'View entries'), style: 'primary', onClick: function(){ window.location.href = '/time-entries'; } },
{ label: (window.i18n?.messages?.dismiss || 'Dismiss'), style: 'link', onClick: function(){ dismissNotification('end_of_day_reminder'); } }
],
60000
);
}
}
}
async function checkBreakNudge(activeTimer){
resetReminderFlagsIfNewDay();
// Reset the per-timer flag whenever the active timer changes (new session = new reminders).
const tid = activeTimer && activeTimer.id;
if (tid !== lastTimerIdForBreak){
lastTimerIdForBreak = tid;
breakNudgeShown = false;
}
if (breakNudgeShown || activeReminderToast || !activeTimer) return;
const payload = await fetchNotifications(false);
if (!payload) return;
const note = findNotification(payload, 'break_reminder');
if (!note) return;
breakNudgeShown = true;
const msg = escapeHtml(note.message || 'Time for a break.');
buildReminderToast(
'purple',
msg,
[
{
label: (window.i18n?.messages?.pauseTimer || 'Pause timer'),
style: 'primary',
onClick: async function(){
try { await fetch('/timer/pause', { method: 'POST' }); } catch(e){}
location.reload();
}
},
{
label: (window.i18n?.messages?.snooze15 || 'Snooze 15 min'),
style: 'secondary',
onClick: function(){
// Client-side snooze: keep the per-timer flag set for 15 minutes so the
// server-emitted notification is suppressed locally, then re-arm.
breakNudgeShown = true;
setTimeout(function(){ breakNudgeShown = false; }, 15 * 60 * 1000);
}
},
{ label: (window.i18n?.messages?.dismiss || 'Dismiss'), style: 'link', onClick: function(){ dismissNotification('break_reminder'); } }
],
45000
);
}
setInterval(checkNoTimerAndEndOfDayNudges, REMINDER_POLL_MS);
setTimeout(checkNoTimerAndEndOfDayNudges, 5000);
})();
+39
View File
@@ -174,6 +174,31 @@
class="h-5 w-5 text-blue-600 focus:ring-blue-500 border-gray-300 rounded flex-shrink-0">
<span class="ml-3 block text-sm text-gray-700 dark:text-gray-300">{{ _('End-of-day summary (logged hours today)') }}</span>
</label>
<label for="smart_notify_break_reminder" class="flex items-center min-h-[40px] cursor-pointer rounded-lg px-2 -mx-2 hover:bg-gray-50 dark:hover:bg-gray-700/50">
<input type="checkbox" id="smart_notify_break_reminder" name="smart_notify_break_reminder"
{% if user.smart_notify_break_reminder %}checked{% endif %}
class="h-5 w-5 text-blue-600 focus:ring-blue-500 border-gray-300 rounded flex-shrink-0">
<span class="ml-3 block text-sm text-gray-700 dark:text-gray-300">{{ _('Remind me to take a break every N minutes while a timer is running') }}</span>
</label>
<div class="ml-8 mt-1 {% if not user.smart_notify_break_reminder %}hidden{% endif %}" id="smart_notify_break_interval_wrap">
<label for="smart_notify_break_interval_minutes" class="block text-xs text-gray-500 dark:text-gray-400 mb-1">{{ _('Break interval (minutes)') }}</label>
<input type="number" id="smart_notify_break_interval_minutes" name="smart_notify_break_interval_minutes"
min="15" max="240" step="15"
value="{{ user.smart_notify_break_interval_minutes or 60 }}"
class="w-32 px-2 py-1.5 border border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-700 dark:text-white text-sm">
</div>
<label for="smart_notify_end_of_day" class="flex items-center min-h-[40px] cursor-pointer rounded-lg px-2 -mx-2 hover:bg-gray-50 dark:hover:bg-gray-700/50">
<input type="checkbox" id="smart_notify_end_of_day" name="smart_notify_end_of_day"
{% if user.smart_notify_end_of_day %}checked{% endif %}
class="h-5 w-5 text-blue-600 focus:ring-blue-500 border-gray-300 rounded flex-shrink-0">
<span class="ml-3 block text-sm text-gray-700 dark:text-gray-300">{{ _('Remind me at end of day to wrap up') }}</span>
</label>
<div class="ml-8 mt-1 {% if not user.smart_notify_end_of_day %}hidden{% endif %}" id="smart_notify_end_of_day_wrap">
<label for="smart_notify_end_of_day_time" class="block text-xs text-gray-500 dark:text-gray-400 mb-1">{{ _('End of day time (your timezone)') }}</label>
<input type="time" id="smart_notify_end_of_day_time" name="smart_notify_end_of_day_time"
value="{{ user.smart_notify_end_of_day_time or '17:00' }}"
class="w-32 px-2 py-1.5 border border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-700 dark:text-white text-sm">
</div>
<label for="smart_notify_browser" class="flex items-center min-h-[40px] cursor-pointer rounded-lg px-2 -mx-2 hover:bg-gray-50 dark:hover:bg-gray-700/50">
<input type="checkbox" id="smart_notify_browser" name="smart_notify_browser"
{% if user.smart_notify_browser %}checked{% endif %}
@@ -607,6 +632,17 @@ function toggleOvertimeMode() {
document.getElementById('overtime-how-weekly').style.display = isWeekly ? 'block' : 'none';
}
// Toggle visibility of conditional smart-notify sub-inputs
function bindToggleWrap(toggleId, wrapId){
const toggle = document.getElementById(toggleId);
const wrap = document.getElementById(wrapId);
if (!toggle || !wrap) return;
toggle.addEventListener('change', function(){
if (this.checked) wrap.classList.remove('hidden');
else wrap.classList.add('hidden');
});
}
// Initialize on page load
document.addEventListener('DOMContentLoaded', function() {
toggleRoundingOptions();
@@ -618,6 +654,9 @@ document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('input[name="overtime_calculation_mode"]').forEach(function(radio) {
radio.addEventListener('change', toggleOvertimeMode);
});
bindToggleWrap('smart_notify_break_reminder', 'smart_notify_break_interval_wrap');
bindToggleWrap('smart_notify_end_of_day', 'smart_notify_end_of_day_wrap');
});
</script>
{% endblock %}
+152
View File
@@ -591,6 +591,31 @@ def register_scheduled_tasks(scheduler, app=None):
)
logger.info("Registered remind-to-log task")
# Smart reminder browser push notifications every 15 minutes
def send_smart_reminder_push_with_app():
app_instance = app
if app_instance is None:
try:
app_instance = current_app._get_current_object()
except RuntimeError:
logger.error("No app instance available for smart reminder push")
return
with app_instance.app_context():
try:
send_smart_reminder_push_notifications()
except Exception as e:
logger.warning("Smart reminder push job failed: %s", e)
scheduler.add_job(
func=send_smart_reminder_push_with_app,
trigger="interval",
minutes=15,
id="smart_reminder_push",
name="Send smart reminder browser push notifications",
replace_existing=True,
)
logger.info("Registered smart reminder push task")
# Base telemetry heartbeat (daily) always-on minimal install footprint
def send_base_telemetry_heartbeat_with_app():
app_instance = app
@@ -641,6 +666,133 @@ def register_scheduled_tasks(scheduler, app=None):
logger.error(f"Error registering scheduled tasks: {e}")
def send_smart_reminder_push_notifications():
"""Send browser push notifications for actionable smart reminders.
For every active user that has smart notifications enabled AND has opted into
one of the new reminder kinds, ask :class:`NotificationService` what they would
see right now and push any actionable ``info``/``warning`` entries to their
registered push subscriptions. Degrades silently if the push_notifications
blueprint is absent or pywebpush is not installed.
"""
with current_app.app_context():
try:
from app.routes import push_notifications as _push_bp_mod # noqa: F401
except Exception:
logger.debug("push_notifications blueprint not available; skipping smart reminder push job")
return 0
try:
from app.models import PushSubscription
except Exception:
logger.debug("PushSubscription model not available; skipping smart reminder push job")
return 0
try:
from app.services.notification_service import NotificationService
except Exception:
logger.debug("NotificationService not available; skipping smart reminder push job")
return 0
try:
users = User.query.filter(
User.is_active == True,
User.smart_notifications_enabled == True,
db.or_(
User.smart_notify_break_reminder == True,
User.smart_notify_end_of_day == True,
User.smart_notify_no_tracking == True,
),
).all()
except Exception as e:
logger.warning("Could not query users for smart reminder push: %s", e)
return 0
if not users:
return 0
sent = 0
for user in users:
try:
payload = NotificationService.build_for_user(user)
notifications = (payload or {}).get("notifications") or []
if not notifications:
continue
subscriptions = PushSubscription.get_user_subscriptions(user.id)
if not subscriptions:
logger.debug("User %s has no push subscriptions; skipping", user.username)
continue
for note in notifications:
ntype = (note.get("type") or "").lower()
if ntype not in ("warning", "info"):
continue
_delivered = _deliver_push_to_subscriptions(user, subscriptions, note)
if _delivered:
sent += _delivered
logger.debug("Smart reminder push processed for %s", user.username)
except Exception as e:
logger.warning("Smart reminder push failed for user %s: %s", getattr(user, "username", user.id), e)
return sent
def _deliver_push_to_subscriptions(user, subscriptions, note) -> int:
"""Send a single notification to all of the user's push subscriptions.
Uses pywebpush when available and VAPID keys are configured; otherwise logs at
debug and reports zero deliveries so the scheduler job can keep going.
"""
try:
from pywebpush import WebPushException, webpush # type: ignore
except Exception:
logger.debug("pywebpush not installed; smart reminder push skipped for %s", user.username)
return 0
cfg = current_app.config
vapid_private = (cfg.get("VAPID_PRIVATE_KEY") or "").strip()
vapid_public = (cfg.get("VAPID_PUBLIC_KEY") or "").strip()
vapid_claims_email = (cfg.get("VAPID_CONTACT_EMAIL") or cfg.get("MAIL_DEFAULT_SENDER") or "").strip()
if not vapid_private or not vapid_public:
logger.debug("VAPID keys not configured; smart reminder push skipped for %s", user.username)
return 0
import json as _json
body = {
"kind": note.get("kind"),
"title": note.get("title") or "TimeTracker",
"message": note.get("message") or "",
"type": note.get("type") or "info",
"action": note.get("action") or None,
}
delivered = 0
for sub in subscriptions:
try:
webpush(
subscription_info={"endpoint": sub.endpoint, "keys": sub.keys or {}},
data=_json.dumps(body),
vapid_private_key=vapid_private,
vapid_claims={"sub": f"mailto:{vapid_claims_email}" if vapid_claims_email else "mailto:noreply@example.invalid"},
)
try:
sub.update_last_used()
except Exception:
pass
delivered += 1
except WebPushException as e:
status = getattr(getattr(e, "response", None), "status_code", None)
if status in (404, 410):
try:
db.session.delete(sub)
db.session.commit()
except Exception:
db.session.rollback()
else:
logger.warning("Web push failed for %s (endpoint=%s): %s", user.username, sub.endpoint[:60], e)
except Exception as e:
logger.warning("Web push failed for %s (endpoint=%s): %s", user.username, sub.endpoint[:60], e)
return delivered
def process_remind_to_log():
"""Send end-of-day reminder to log time to users who have it enabled and have not logged (or logged very little) today.
+37 -4
View File
@@ -6,7 +6,13 @@ Session-based reminders to improve daily tracking habits. Separate from **email*
1. Open **Settings → Notifications**.
2. Under **In-app reminders (toasts)**, turn on **Enable smart notifications on this device**.
3. Choose which kinds to show (no-tracking nudge, long timer, daily summary) and optionally **browser notifications** (requires permission in the browser).
3. Choose which kinds to show:
- No-tracking nudge (configurable hour window)
- Long-running timer alert
- Daily summary (hours logged today)
- **Break reminder** — while a timer is running, nudge every N minutes (15240, default 60)
- **End-of-day wrap-up** — reminder near your configured end-of-day time with hours logged today
4. Optionally enable **browser notifications** (requires permission in the browser). When push subscriptions exist and VAPID keys are configured, a background job can deliver the same actionable reminders via Web Push (see below).
Optional **HH:MM** overrides apply to the **hour** used for time-window checks (same idea as the email reminder: the app uses the first `SMART_NOTIFY_SCHEDULER_SLOT_MINUTES` of that local hour). If left blank, server defaults from configuration apply.
@@ -17,7 +23,15 @@ Optional **HH:MM** overrides apply to the **hour** used for time-window checks (
| `GET` | `/api/notifications` | Returns `{ "notifications": [...], "meta": { ... } }` when the feature is enabled for the user; empty list when disabled. |
| `POST` | `/api/notifications/dismiss` | JSON body: `{ "kind": "<kind>", "local_date": "YYYY-MM-DD" }`. Omit `local_date` to use the server-derived “today” in the users timezone. |
Stable `kind` values: `no_tracking_today`, `timer_running_long`, `daily_summary`.
Stable `kind` values:
| Kind | When it fires |
|------|----------------|
| `no_tracking_today` | In the no-tracking hour window, no completed entries today, no active timer |
| `timer_running_long` | Active timer exceeds `SMART_NOTIFY_LONG_TIMER_HOURS` |
| `daily_summary` | In the summary hour window |
| `break_reminder` | Break reminder enabled, active timer running ≥ interval; once per interval bucket per timer |
| `end_of_day_reminder` | End-of-day reminder enabled, within the end-of-day hour window |
`GET /api/summary/today` uses the same **user-local calendar day** as the notification service (for totals of **completed** entries).
@@ -30,13 +44,32 @@ All optional; defaults are defined on `Config` in [`app/config.py`](../../app/co
| `SMART_NOTIFY_MAX_PER_DAY` | Max notifications returned per request (default 2). |
| `SMART_NOTIFY_NO_TRACKING_AFTER` | Default `HH:MM` hour for the no-tracking nudge (default `16:00`). |
| `SMART_NOTIFY_SUMMARY_AT` | Default `HH:MM` hour for the daily summary window (default `18:00`). |
| `SMART_NOTIFY_END_OF_DAY_AT` | Default `HH:MM` hour for the end-of-day wrap-up window (default `17:00`). |
| `SMART_NOTIFY_LONG_TIMER_HOURS` | Hours after which an active timer triggers the long-timer alert (default `4`). |
| `SMART_NOTIFY_SCHEDULER_SLOT_MINUTES` | Length of the firing window at the start of the configured hour (default `30`). |
Per-user overrides in **Settings**: `smart_notify_no_tracking_after`, `smart_notify_summary_at`, `smart_notify_end_of_day_time`, `smart_notify_break_interval_minutes`.
## Database
- Migration **`150_add_smart_notifications`**: new columns on `users`, table `user_smart_notification_dismissals`.
- Migration **`150_add_smart_notifications`**: smart notification columns on `users`, table `user_smart_notification_dismissals`.
- Migration **`154_add_smart_notify_break_and_eod`**: `smart_notify_break_reminder`, `smart_notify_break_interval_minutes`, `smart_notify_end_of_day`, `smart_notify_end_of_day_time` on `users`; widens `user_smart_notification_dismissals.local_date` to 64 characters so break reminders can store interval bucket keys (`break_<timer_id>_<bucket>`).
## Frontend
[`app/static/smart-notifications.js`](../../app/static/smart-notifications.js) polls `/api/notifications` on an interval and shows results via `toastManager`. Dismissals are sent when the toast closes (including auto-dismiss). [`app/static/toast-notifications.js`](../../app/static/toast-notifications.js) implements the optional `onDismiss` hook on `toastManager.show`.
Two complementary clients:
1. **[`app/static/smart-notifications.js`](../../app/static/smart-notifications.js)** — Polls `/api/notifications` on an interval (default 10 minutes) and shows server-driven toasts via `toastManager`. Dismissals are sent when the toast closes (including auto-dismiss). Optional browser notifications when enabled and permission granted.
2. **[`app/static/idle.js`](../../app/static/idle.js)** — Idle detection (stop timer when inactive) plus additional reminder toasts:
- **No timer** (`no_tracking_today`) — blue toast every 5 minutes when eligible; “Start timer” / dismiss (dismiss calls API with todays date).
- **Break** (`break_reminder`) — purple toast checked each minute while a timer runs; “Pause timer”, “Snooze 15 min” (client-side), dismiss.
- **End of day** (`end_of_day_reminder`) — green toast every 5 minutes when eligible; “View entries” / dismiss.
Only one reminder toast is shown at a time. `/api/notifications` is fetched at most once per 5-minute window (shared cache). Flags reset at local midnight.
[`app/static/toast-notifications.js`](../../app/static/toast-notifications.js) implements the optional `onDismiss` hook on `toastManager.show`.
## Background push (optional)
When the push notifications blueprint is loaded and users have `smart_notifications_enabled` plus at least one of break, end-of-day, or no-tracking reminders, APScheduler runs **`send_smart_reminder_push_notifications`** every 15 minutes ([`app/utils/scheduled_tasks.py`](../../app/utils/scheduled_tasks.py), job id `smart_reminder_push`). It calls `NotificationService.build_for_user` and sends `info` / `warning` payloads to registered push subscriptions via **pywebpush** when `VAPID_PUBLIC_KEY` and `VAPID_PRIVATE_KEY` are set. Missing push module, pywebpush, or VAPID configuration is skipped without error.
@@ -0,0 +1,140 @@
"""Smart notifications: break reminder + end-of-day reminder preferences.
Adds four columns to the users table and widens the local_date column on
user_smart_notification_dismissals so that internal bucket markers (used to
fire the break reminder once per interval) fit alongside regular YYYY-MM-DD
dismissals.
Revision ID: 154_add_smart_notify_break_and_eod
Revises: 153_add_user_auth_provider
Create Date: 2026-05-15
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "154_add_smart_notify_break_and_eod"
down_revision = "153_add_user_auth_provider"
branch_labels = None
depends_on = None
def _has_column(inspector, table_name: str, column_name: str) -> bool:
try:
return column_name in {c["name"] for c in inspector.get_columns(table_name)}
except Exception:
return False
def _column_length(inspector, table_name: str, column_name: str):
try:
for c in inspector.get_columns(table_name):
if c["name"] == column_name:
t = c.get("type")
return getattr(t, "length", None)
except Exception:
return None
return None
def upgrade():
bind = op.get_bind()
inspector = inspect(bind)
if "users" in inspector.get_table_names():
if not _has_column(inspector, "users", "smart_notify_break_reminder"):
op.add_column(
"users",
sa.Column("smart_notify_break_reminder", sa.Boolean(), nullable=False, server_default="0"),
)
if not _has_column(inspector, "users", "smart_notify_break_interval_minutes"):
op.add_column(
"users",
sa.Column(
"smart_notify_break_interval_minutes",
sa.Integer(),
nullable=False,
server_default="60",
),
)
if not _has_column(inspector, "users", "smart_notify_end_of_day"):
op.add_column(
"users",
sa.Column("smart_notify_end_of_day", sa.Boolean(), nullable=False, server_default="0"),
)
if not _has_column(inspector, "users", "smart_notify_end_of_day_time"):
op.add_column(
"users",
sa.Column("smart_notify_end_of_day_time", sa.String(length=5), nullable=True),
)
# Drop the server_defaults now that existing rows are backfilled
for col in (
"smart_notify_break_reminder",
"smart_notify_break_interval_minutes",
"smart_notify_end_of_day",
):
try:
op.alter_column("users", col, server_default=None)
except Exception:
pass
# Widen local_date on user_smart_notification_dismissals to fit bucket markers
# like "break_<timer_id>_<bucket>" used by KIND_BREAK_REMINDER.
if "user_smart_notification_dismissals" in inspector.get_table_names():
current_len = _column_length(inspector, "user_smart_notification_dismissals", "local_date")
if current_len is None or current_len < 64:
try:
op.alter_column(
"user_smart_notification_dismissals",
"local_date",
existing_type=sa.String(length=current_len or 10),
type_=sa.String(length=64),
existing_nullable=False,
)
except Exception:
# SQLite or other backends may not support alter; recreate as fallback
with op.batch_alter_table("user_smart_notification_dismissals") as batch_op:
batch_op.alter_column(
"local_date",
existing_type=sa.String(length=current_len or 10),
type_=sa.String(length=64),
existing_nullable=False,
)
def downgrade():
bind = op.get_bind()
inspector = inspect(bind)
if "user_smart_notification_dismissals" in inspector.get_table_names():
current_len = _column_length(inspector, "user_smart_notification_dismissals", "local_date")
if current_len and current_len > 10:
try:
op.alter_column(
"user_smart_notification_dismissals",
"local_date",
existing_type=sa.String(length=current_len),
type_=sa.String(length=10),
existing_nullable=False,
)
except Exception:
with op.batch_alter_table("user_smart_notification_dismissals") as batch_op:
batch_op.alter_column(
"local_date",
existing_type=sa.String(length=current_len),
type_=sa.String(length=10),
existing_nullable=False,
)
if "users" not in inspector.get_table_names():
return
for name in (
"smart_notify_end_of_day_time",
"smart_notify_end_of_day",
"smart_notify_break_interval_minutes",
"smart_notify_break_reminder",
):
if _has_column(inspector, "users", name):
op.drop_column("users", name)
+10 -1
View File
@@ -3,10 +3,19 @@ const defaultTheme = require('tailwindcss/defaultTheme');
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: 'class',
safelist: ['pb-safe'],
safelist: [
'pb-safe',
// Reminder toast classes built dynamically by app/static/idle.js
// (file is not in tailwind content scan; safelist guarantees they ship)
{ pattern: /^bg-(blue|purple|green|amber)-(100|200|300|600|700|800|900)$/, variants: ['hover', 'dark', 'dark:hover'] },
{ pattern: /^bg-(blue|purple|green|amber)-900\/30$/, variants: ['dark'] },
{ pattern: /^text-(blue|purple|green|amber)-(100|300|700|900)$/, variants: ['dark'] },
{ pattern: /^border-(blue|purple|green|amber)-(300|700)$/, variants: ['dark'] },
],
content: [
'./app/templates/**/*.html',
'./app/static/src/**/*.js',
'./app/static/idle.js',
],
theme: {
extend: {