mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-17 18:38:46 -05:00
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:
@@ -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, 15–240 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
|
||||
|
||||
@@ -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"))))
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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 ({ '&':'&', '<':'<', '>':'>', '"':'"', "'":''' })[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);
|
||||
})();
|
||||
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 (15–240, 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 user’s 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 today’s 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
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user