Fix bugs, add auto-refresh, quick-complete tasks, and every-N-day routines

- Fix bot auth: merge duplicate on_ready handlers so session restore runs (#13)
- Fix push notifications: pass Uint8Array directly as applicationServerKey (#6)
- Show specific conflict reason on schedule save instead of generic error (#17)
- Add inline checkmark button to complete tasks on routines timeline (#18)
- Add visibility-change + 60s polling auto-refresh to routines, meds, tasks (#15)
- Add every-N-day routine scheduling: schema, API, scheduler, and UI (#16)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-19 19:04:52 -06:00
parent 24a1d18b25
commit ecb79af44e
10 changed files with 288 additions and 96 deletions

View File

@@ -661,17 +661,20 @@ def register(app):
continue continue
steps = postgres.select("routine_steps", where={"routine_id": r["id"]}) steps = postgres.select("routine_steps", where={"routine_id": r["id"]})
total_duration = sum(s.get("duration_minutes") or 0 for s in steps) total_duration = sum(s.get("duration_minutes") or 0 for s in steps)
result.append( entry = {
{ "routine_id": r["id"],
"routine_id": r["id"], "routine_name": r.get("name", ""),
"routine_name": r.get("name", ""), "routine_icon": r.get("icon", ""),
"routine_icon": r.get("icon", ""), "days": sched.get("days", []),
"days": sched.get("days", []), "time": sched.get("time"),
"time": sched.get("time"), "remind": sched.get("remind", True),
"remind": sched.get("remind", True), "total_duration_minutes": total_duration,
"total_duration_minutes": total_duration, "frequency": sched.get("frequency", "weekly"),
} }
) if sched.get("frequency") == "every_n_days":
entry["interval_days"] = sched.get("interval_days")
entry["start_date"] = str(sched.get("start_date")) if sched.get("start_date") else None
result.append(entry)
return flask.jsonify(result), 200 return flask.jsonify(result), 200
def _get_routine_duration_minutes(routine_id): def _get_routine_duration_minutes(routine_id):
@@ -745,7 +748,10 @@ def register(app):
@app.route("/api/routines/<routine_id>/schedule", methods=["PUT"]) @app.route("/api/routines/<routine_id>/schedule", methods=["PUT"])
def api_setRoutineSchedule(routine_id): def api_setRoutineSchedule(routine_id):
"""Set when this routine should run. Body: {days: ["mon","tue",...], time: "08:00", remind: true}""" """Set when this routine should run.
Body: {days, time, remind, frequency?, interval_days?, start_date?}
frequency: 'weekly' (default, uses days) or 'every_n_days' (uses interval_days + start_date)
"""
user_uuid = _auth(flask.request) user_uuid = _auth(flask.request)
if not user_uuid: if not user_uuid:
return flask.jsonify({"error": "unauthorized"}), 401 return flask.jsonify({"error": "unauthorized"}), 401
@@ -758,15 +764,18 @@ def register(app):
if not data: if not data:
return flask.jsonify({"error": "missing body"}), 400 return flask.jsonify({"error": "missing body"}), 400
# Check for schedule conflicts frequency = data.get("frequency", "weekly")
new_days = data.get("days", [])
new_time = data.get("time") # Check for schedule conflicts (only for weekly — interval conflicts checked at reminder time)
has_conflict, conflict_msg = _check_schedule_conflicts( if frequency == "weekly":
user_uuid, new_days, new_time, exclude_routine_id=routine_id, new_days = data.get("days", [])
new_routine_id=routine_id, new_time = data.get("time")
) has_conflict, conflict_msg = _check_schedule_conflicts(
if has_conflict: user_uuid, new_days, new_time, exclude_routine_id=routine_id,
return flask.jsonify({"error": conflict_msg}), 409 new_routine_id=routine_id,
)
if has_conflict:
return flask.jsonify({"error": conflict_msg}), 409
existing = postgres.select_one("routine_schedules", {"routine_id": routine_id}) existing = postgres.select_one("routine_schedules", {"routine_id": routine_id})
schedule_data = { schedule_data = {
@@ -774,6 +783,9 @@ def register(app):
"days": json.dumps(data.get("days", [])), "days": json.dumps(data.get("days", [])),
"time": data.get("time"), "time": data.get("time"),
"remind": data.get("remind", True), "remind": data.get("remind", True),
"frequency": frequency,
"interval_days": data.get("interval_days"),
"start_date": data.get("start_date"),
} }
if existing: if existing:
result = postgres.update( result = postgres.update(

View File

@@ -663,14 +663,6 @@ def _restore_sessions_from_cache():
print(f"Restored {restored} user session(s) from cache") print(f"Restored {restored} user session(s) from cache")
@client.event
async def on_ready():
print(f"Bot logged in as {client.user}")
loadCache()
_restore_sessions_from_cache()
backgroundLoop.start()
@client.event @client.event
async def on_message(message): async def on_message(message):
if message.author == client.user: if message.author == client.user:
@@ -866,6 +858,7 @@ async def on_ready():
print(f"Bot logged in as {client.user}", flush=True) print(f"Bot logged in as {client.user}", flush=True)
print(f"Connected to {len(client.guilds)} guilds", flush=True) print(f"Connected to {len(client.guilds)} guilds", flush=True)
loadCache() loadCache()
_restore_sessions_from_cache()
backgroundLoop.start() backgroundLoop.start()
presenceTrackingLoop.start() presenceTrackingLoop.start()
print(f"[DEBUG] Presence tracking loop started", flush=True) print(f"[DEBUG] Presence tracking loop started", flush=True)

View File

@@ -74,7 +74,10 @@ CREATE TABLE IF NOT EXISTS routine_schedules (
routine_id UUID REFERENCES routines(id) ON DELETE CASCADE, routine_id UUID REFERENCES routines(id) ON DELETE CASCADE,
days JSON DEFAULT '[]', days JSON DEFAULT '[]',
time VARCHAR(5), time VARCHAR(5),
remind BOOLEAN DEFAULT FALSE remind BOOLEAN DEFAULT FALSE,
frequency VARCHAR(20) DEFAULT 'weekly',
interval_days INTEGER,
start_date DATE
); );
CREATE TABLE IF NOT EXISTS routine_session_notes ( CREATE TABLE IF NOT EXISTS routine_session_notes (
@@ -314,3 +317,8 @@ CREATE TABLE IF NOT EXISTS tasks (
); );
CREATE INDEX IF NOT EXISTS idx_tasks_user_scheduled ON tasks(user_uuid, scheduled_datetime); CREATE INDEX IF NOT EXISTS idx_tasks_user_scheduled ON tasks(user_uuid, scheduled_datetime);
CREATE INDEX IF NOT EXISTS idx_tasks_pending ON tasks(status) WHERE status = 'pending'; CREATE INDEX IF NOT EXISTS idx_tasks_pending ON tasks(status) WHERE status = 'pending';
-- Add every-N-day scheduling to routine_schedules (run once on existing DBs)
ALTER TABLE routine_schedules ADD COLUMN IF NOT EXISTS frequency VARCHAR(20) DEFAULT 'weekly';
ALTER TABLE routine_schedules ADD COLUMN IF NOT EXISTS interval_days INTEGER;
ALTER TABLE routine_schedules ADD COLUMN IF NOT EXISTS start_date DATE;

View File

@@ -108,6 +108,8 @@ def check_medication_reminders():
def check_routine_reminders(): def check_routine_reminders():
"""Check for scheduled routines due now and send notifications.""" """Check for scheduled routines due now and send notifications."""
try: try:
from datetime import date as date_type
schedules = postgres.select("routine_schedules", where={"remind": True}) schedules = postgres.select("routine_schedules", where={"remind": True})
for schedule in schedules: for schedule in schedules:
@@ -117,13 +119,30 @@ def check_routine_reminders():
now = _user_now_for(routine["user_uuid"]) now = _user_now_for(routine["user_uuid"])
current_time = now.strftime("%H:%M") current_time = now.strftime("%H:%M")
current_day = now.strftime("%a").lower() today = now.date()
if current_time != schedule.get("time"): if current_time != schedule.get("time"):
continue continue
days = schedule.get("days", [])
if current_day not in days: frequency = schedule.get("frequency", "weekly")
continue if frequency == "every_n_days":
start = schedule.get("start_date")
interval = schedule.get("interval_days")
if start and interval:
start_d = (
start
if isinstance(start, date_type)
else datetime.strptime(str(start), "%Y-%m-%d").date()
)
if (today - start_d).days < 0 or (today - start_d).days % interval != 0:
continue
else:
continue
else:
current_day = now.strftime("%a").lower()
days = schedule.get("days", [])
if current_day not in days:
continue
user_settings = notifications.getNotificationSettings(routine["user_uuid"]) user_settings = notifications.getNotificationSettings(routine["user_uuid"])
if user_settings: if user_settings:

View File

@@ -91,24 +91,36 @@ export default function MedicationsPage() {
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [tick, setTick] = useState(0); const [tick, setTick] = useState(0);
const fetchData = async () => {
try {
const [medsData, todayData, adherenceData] = await Promise.all([
api.medications.list(),
api.medications.getToday().catch(() => []),
api.medications.getAdherence(30).catch(() => []),
]);
setMedications(medsData);
setTodayMeds(todayData);
setAdherence(adherenceData);
} catch (err) {
console.error('Failed to fetch medications:', err);
}
};
useEffect(() => { useEffect(() => {
const fetchData = async () => { fetchData().finally(() => setIsLoading(false));
try { }, []);
const [medsData, todayData, adherenceData] = await Promise.all([
api.medications.list(), // Re-fetch when tab becomes visible or every 60s
api.medications.getToday().catch(() => []), useEffect(() => {
api.medications.getAdherence(30).catch(() => []), const onVisible = () => {
]); if (document.visibilityState === 'visible') fetchData();
setMedications(medsData); };
setTodayMeds(todayData); document.addEventListener('visibilitychange', onVisible);
setAdherence(adherenceData); const poll = setInterval(fetchData, 60_000);
} catch (err) { return () => {
console.error('Failed to fetch medications:', err); document.removeEventListener('visibilitychange', onVisible);
} finally { clearInterval(poll);
setIsLoading(false);
}
}; };
fetchData();
}, []); }, []);
// Auto-refresh grouping every 60s // Auto-refresh grouping every 60s

View File

@@ -29,6 +29,9 @@ interface Schedule {
days: string[]; days: string[];
time: string; time: string;
remind: boolean; remind: boolean;
frequency?: string;
interval_days?: number;
start_date?: string;
} }
const ICONS = ['✨', '🌅', '🌙', '☀️', '💪', '🧘', '📚', '🍳', '🏃', '💼', '🎯', '⭐', '🔥', '💤', '🧠', '☕', '🍎', '💧', '🍀', '🎵', '📝', '🚴', '🏋️', '🚶', '👀', '🛡️', '😊', '😔']; const ICONS = ['✨', '🌅', '🌙', '☀️', '💪', '🧘', '📚', '🍳', '🏃', '💼', '🎯', '⭐', '🔥', '💤', '🧠', '☕', '🍎', '💧', '🍀', '🎵', '📝', '🚴', '🏋️', '🚶', '👀', '🛡️', '😊', '😔'];
@@ -77,6 +80,9 @@ export default function RoutineDetailPage() {
const [editDays, setEditDays] = useState<string[]>(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']); const [editDays, setEditDays] = useState<string[]>(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']);
const [editTime, setEditTime] = useState('08:00'); const [editTime, setEditTime] = useState('08:00');
const [editRemind, setEditRemind] = useState(true); const [editRemind, setEditRemind] = useState(true);
const [editFrequency, setEditFrequency] = useState<'weekly' | 'every_n_days'>('weekly');
const [editIntervalDays, setEditIntervalDays] = useState(2);
const [editStartDate, setEditStartDate] = useState(() => new Date().toISOString().split('T')[0]);
const [showScheduleEditor, setShowScheduleEditor] = useState(false); const [showScheduleEditor, setShowScheduleEditor] = useState(false);
useEffect(() => { useEffect(() => {
@@ -99,6 +105,9 @@ export default function RoutineDetailPage() {
setEditDays(scheduleData.days || []); setEditDays(scheduleData.days || []);
setEditTime(scheduleData.time || '08:00'); setEditTime(scheduleData.time || '08:00');
setEditRemind(scheduleData.remind ?? true); setEditRemind(scheduleData.remind ?? true);
setEditFrequency((scheduleData.frequency as 'weekly' | 'every_n_days') || 'weekly');
setEditIntervalDays(scheduleData.interval_days || 2);
setEditStartDate(scheduleData.start_date || new Date().toISOString().split('T')[0]);
} else { } else {
setEditDays(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']); setEditDays(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']);
if (isNewRoutine) { if (isNewRoutine) {
@@ -162,13 +171,27 @@ export default function RoutineDetailPage() {
const handleSaveSchedule = async () => { const handleSaveSchedule = async () => {
try { try {
if (editDays.length > 0) { const hasSchedule = editFrequency === 'every_n_days' || editDays.length > 0;
await api.routines.setSchedule(routineId, { if (hasSchedule) {
const schedulePayload = {
days: editDays, days: editDays,
time: editTime || '08:00', time: editTime || '08:00',
remind: editRemind, remind: editRemind,
frequency: editFrequency,
...(editFrequency === 'every_n_days' && {
interval_days: editIntervalDays,
start_date: editStartDate,
}),
};
await api.routines.setSchedule(routineId, schedulePayload);
setSchedule({
days: editDays,
time: editTime || '08:00',
remind: editRemind,
frequency: editFrequency,
interval_days: editFrequency === 'every_n_days' ? editIntervalDays : undefined,
start_date: editFrequency === 'every_n_days' ? editStartDate : undefined,
}); });
setSchedule({ days: editDays, time: editTime || '08:00', remind: editRemind });
} else if (schedule) { } else if (schedule) {
await api.routines.deleteSchedule(routineId); await api.routines.deleteSchedule(routineId);
setSchedule(null); setSchedule(null);
@@ -176,7 +199,7 @@ export default function RoutineDetailPage() {
setShowScheduleEditor(false); setShowScheduleEditor(false);
} catch (err) { } catch (err) {
console.error('Failed to save schedule:', err); console.error('Failed to save schedule:', err);
alert('Failed to save schedule. Please try again.'); alert((err as Error).message || 'Failed to save schedule. Please try again.');
} }
}; };
@@ -462,56 +485,108 @@ export default function RoutineDetailPage() {
{showScheduleEditor ? ( {showScheduleEditor ? (
<> <>
{/* Quick select */} {/* Frequency selector */}
<div className="flex gap-2 mb-3"> <div className="flex gap-2 mb-3">
<button <button
type="button" type="button"
onClick={() => setEditDays(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'])} onClick={() => setEditFrequency('weekly')}
className={`px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors ${ className={`flex-1 px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
editDays.length === 7 ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600' editFrequency === 'weekly' ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600'
}`} }`}
> >
Every day Weekly
</button> </button>
<button <button
type="button" type="button"
onClick={() => setEditDays(['mon', 'tue', 'wed', 'thu', 'fri'])} onClick={() => setEditFrequency('every_n_days')}
className={`px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors ${ className={`flex-1 px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
editDays.length === 5 && !editDays.includes('sat') && !editDays.includes('sun') ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600' editFrequency === 'every_n_days' ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600'
}`} }`}
> >
Weekdays Every N Days
</button>
<button
type="button"
onClick={() => setEditDays(['sat', 'sun'])}
className={`px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors ${
editDays.length === 2 && editDays.includes('sat') && editDays.includes('sun') ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600'
}`}
>
Weekends
</button> </button>
</div> </div>
<div className="mb-3"> {editFrequency === 'every_n_days' ? (
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Days</label> <div className="mb-3 space-y-3">
<div className="flex gap-2 flex-wrap"> <div>
{DAY_OPTIONS.map((day) => ( <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Repeat every</label>
<div className="flex items-center gap-2">
<input
type="number"
min={2}
max={365}
value={editIntervalDays}
onChange={(e) => setEditIntervalDays(Math.max(2, Number(e.target.value)))}
className="w-20 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 outline-none"
/>
<span className="text-sm text-gray-600 dark:text-gray-400">days</span>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Starting from</label>
<input
type="date"
value={editStartDate}
onChange={(e) => setEditStartDate(e.target.value)}
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 outline-none"
/>
</div>
</div>
) : (
<>
{/* Quick select */}
<div className="flex gap-2 mb-3">
<button <button
key={day.value}
type="button" type="button"
onClick={() => toggleDay(day.value)} onClick={() => setEditDays(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'])}
className={`px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${ className={`px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors ${
editDays.includes(day.value) editDays.length === 7 ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600'
? 'bg-indigo-600 text-white border-indigo-600'
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600'
}`} }`}
> >
{day.label} Every day
</button> </button>
))} <button
</div> type="button"
</div> onClick={() => setEditDays(['mon', 'tue', 'wed', 'thu', 'fri'])}
className={`px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors ${
editDays.length === 5 && !editDays.includes('sat') && !editDays.includes('sun') ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600'
}`}
>
Weekdays
</button>
<button
type="button"
onClick={() => setEditDays(['sat', 'sun'])}
className={`px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors ${
editDays.length === 2 && editDays.includes('sat') && editDays.includes('sun') ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600'
}`}
>
Weekends
</button>
</div>
<div className="mb-3">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Days</label>
<div className="flex gap-2 flex-wrap">
{DAY_OPTIONS.map((day) => (
<button
key={day.value}
type="button"
onClick={() => toggleDay(day.value)}
className={`px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
editDays.includes(day.value)
? 'bg-indigo-600 text-white border-indigo-600'
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600'
}`}
>
{day.label}
</button>
))}
</div>
</div>
</>
)}
<div className="mb-3"> <div className="mb-3">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Time</label> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Time</label>
<input <input
@@ -545,10 +620,14 @@ export default function RoutineDetailPage() {
setEditDays(schedule.days); setEditDays(schedule.days);
setEditTime(schedule.time); setEditTime(schedule.time);
setEditRemind(schedule.remind); setEditRemind(schedule.remind);
setEditFrequency((schedule.frequency as 'weekly' | 'every_n_days') || 'weekly');
setEditIntervalDays(schedule.interval_days || 2);
setEditStartDate(schedule.start_date || new Date().toISOString().split('T')[0]);
} else { } else {
setEditDays(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']); setEditDays(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']);
setEditTime('08:00'); setEditTime('08:00');
setEditRemind(true); setEditRemind(true);
setEditFrequency('weekly');
} }
}} }}
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300" className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300"
@@ -563,10 +642,12 @@ export default function RoutineDetailPage() {
</button> </button>
</div> </div>
</> </>
) : schedule && schedule.days.length > 0 ? ( ) : schedule && (schedule.days.length > 0 || schedule.frequency === 'every_n_days') ? (
<> <>
<p className="text-gray-700 dark:text-gray-300"> <p className="text-gray-700 dark:text-gray-300">
{formatDays(schedule.days)} at {schedule.time} {schedule.frequency === 'every_n_days'
? `Every ${schedule.interval_days} days at ${schedule.time}`
: `${formatDays(schedule.days)} at ${schedule.time}`}
</p> </p>
{schedule.remind && ( {schedule.remind && (
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">Reminders on</p> <p className="text-sm text-gray-500 dark:text-gray-400 mt-1">Reminders on</p>

View File

@@ -21,6 +21,9 @@ interface ScheduleEntry {
time: string; time: string;
remind: boolean; remind: boolean;
total_duration_minutes: number; total_duration_minutes: number;
frequency?: string;
interval_days?: number;
start_date?: string;
} }
interface TodaysMedication { interface TodaysMedication {
@@ -230,7 +233,15 @@ export default function RoutinesPage() {
const dayKey = getDayKey(selectedDate); const dayKey = getDayKey(selectedDate);
const scheduledForDay = allSchedules const scheduledForDay = allSchedules
.filter((s) => s.days.includes(dayKey)) .filter((s) => {
if (s.frequency === 'every_n_days') {
if (!s.interval_days || !s.start_date) return false;
const start = new Date(s.start_date + 'T00:00:00');
const diffDays = Math.round((selectedDate.getTime() - start.getTime()) / 86400000);
return diffDays >= 0 && diffDays % s.interval_days === 0;
}
return s.days.includes(dayKey);
})
.sort((a, b) => timeToMinutes(a.time) - timeToMinutes(b.time)); .sort((a, b) => timeToMinutes(a.time) - timeToMinutes(b.time));
const tasksForDay = allTasks.filter((t) => { const tasksForDay = allTasks.filter((t) => {
@@ -433,7 +444,7 @@ export default function RoutinesPage() {
setUndoAction(null); setUndoAction(null);
}; };
useEffect(() => { const fetchAllData = () =>
Promise.all([ Promise.all([
api.routines.list(), api.routines.list(),
api.routines.listAllSchedules(), api.routines.listAllSchedules(),
@@ -446,8 +457,10 @@ export default function RoutinesPage() {
setTodayMeds(meds); setTodayMeds(meds);
setAllTasks(tasks); setAllTasks(tasks);
}) })
.catch(() => {}) .catch(() => {});
.finally(() => setIsLoading(false));
useEffect(() => {
fetchAllData().finally(() => setIsLoading(false));
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -459,6 +472,19 @@ export default function RoutinesPage() {
return () => clearInterval(timer); return () => clearInterval(timer);
}, []); }, []);
// Re-fetch when tab becomes visible or every 60s
useEffect(() => {
const onVisible = () => {
if (document.visibilityState === 'visible') fetchAllData();
};
document.addEventListener('visibilitychange', onVisible);
const poll = setInterval(fetchAllData, 60_000);
return () => {
document.removeEventListener('visibilitychange', onVisible);
clearInterval(poll);
};
}, []);
useEffect(() => { useEffect(() => {
if (!isLoading && isToday && timelineRef.current) { if (!isLoading && isToday && timelineRef.current) {
const scrollTarget = nowTopPx - window.innerHeight / 3; const scrollTarget = nowTopPx - window.innerHeight / 3;
@@ -471,6 +497,18 @@ export default function RoutinesPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [isLoading, isToday]); }, [isLoading, isToday]);
const handleCompleteTask = async (taskId: string) => {
try {
await api.tasks.update(taskId, { status: 'completed' });
setAllTasks((prev) =>
prev.map((t) => (t.id === taskId ? { ...t, status: 'completed' } : t))
);
} catch (err) {
console.error('Failed to complete task:', err);
setError(err instanceof Error ? err.message : 'Failed to complete task');
}
};
const handleStartRoutine = async (routineId: string) => { const handleStartRoutine = async (routineId: string) => {
try { try {
await api.sessions.start(routineId); await api.sessions.start(routineId);
@@ -838,10 +876,20 @@ export default function RoutinesPage() {
{task.description && ` · ${task.description}`} {task.description && ` · ${task.description}`}
</p> </p>
</div> </div>
{isPast && ( {isPast ? (
<span className="text-green-600 flex-shrink-0"> <span className="text-green-600 flex-shrink-0">
<CheckIcon size={16} /> <CheckIcon size={16} />
</span> </span>
) : (
<button
onClick={(e) => {
e.stopPropagation();
handleCompleteTask(task.id);
}}
className="bg-green-600 text-white p-1 rounded-lg flex-shrink-0"
>
<CheckIcon size={14} />
</button>
)} )}
</div> </div>
</div> </div>

View File

@@ -53,6 +53,19 @@ export default function TasksPage() {
loadTasks(showCompleted ? 'all' : 'pending'); loadTasks(showCompleted ? 'all' : 'pending');
}, [showCompleted]); }, [showCompleted]);
// Re-fetch when tab becomes visible or every 60s
useEffect(() => {
const onVisible = () => {
if (document.visibilityState === 'visible') loadTasks(showCompleted ? 'all' : 'pending');
};
document.addEventListener('visibilitychange', onVisible);
const poll = setInterval(() => loadTasks(showCompleted ? 'all' : 'pending'), 60_000);
return () => {
document.removeEventListener('visibilitychange', onVisible);
clearInterval(poll);
};
}, [showCompleted]);
const handleMarkDone = async (task: Task) => { const handleMarkDone = async (task: Task) => {
try { try {
await api.tasks.update(task.id, { status: 'completed' }); await api.tasks.update(task.id, { status: 'completed' });

View File

@@ -65,7 +65,7 @@ export default function PushNotificationToggle() {
const { public_key } = await api.notifications.getVapidPublicKey(); const { public_key } = await api.notifications.getVapidPublicKey();
const sub = await reg.pushManager.subscribe({ const sub = await reg.pushManager.subscribe({
userVisibleOnly: true, userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(public_key).buffer as ArrayBuffer, applicationServerKey: urlBase64ToUint8Array(public_key) as BufferSource,
}); });
const subJson = sub.toJSON(); const subJson = sub.toJSON();

View File

@@ -323,12 +323,15 @@ export const api = {
days: string[]; days: string[];
time: string; time: string;
remind: boolean; remind: boolean;
frequency?: string;
interval_days?: number;
start_date?: string;
}>(`/api/routines/${routineId}/schedule`, { method: 'GET' }); }>(`/api/routines/${routineId}/schedule`, { method: 'GET' });
}, },
setSchedule: async ( setSchedule: async (
routineId: string, routineId: string,
data: { days: string[]; time: string; remind?: boolean } data: { days: string[]; time: string; remind?: boolean; frequency?: string; interval_days?: number; start_date?: string }
) => { ) => {
return request<{ id: string }>(`/api/routines/${routineId}/schedule`, { return request<{ id: string }>(`/api/routines/${routineId}/schedule`, {
method: 'PUT', method: 'PUT',
@@ -352,6 +355,9 @@ export const api = {
time: string; time: string;
remind: boolean; remind: boolean;
total_duration_minutes: number; total_duration_minutes: number;
frequency?: string;
interval_days?: number;
start_date?: string;
}>>('/api/routines/schedules', { method: 'GET' }); }>>('/api/routines/schedules', { method: 'GET' });
}, },