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:
@@ -661,17 +661,20 @@ def register(app):
|
||||
continue
|
||||
steps = postgres.select("routine_steps", where={"routine_id": r["id"]})
|
||||
total_duration = sum(s.get("duration_minutes") or 0 for s in steps)
|
||||
result.append(
|
||||
{
|
||||
"routine_id": r["id"],
|
||||
"routine_name": r.get("name", ""),
|
||||
"routine_icon": r.get("icon", ""),
|
||||
"days": sched.get("days", []),
|
||||
"time": sched.get("time"),
|
||||
"remind": sched.get("remind", True),
|
||||
"total_duration_minutes": total_duration,
|
||||
}
|
||||
)
|
||||
entry = {
|
||||
"routine_id": r["id"],
|
||||
"routine_name": r.get("name", ""),
|
||||
"routine_icon": r.get("icon", ""),
|
||||
"days": sched.get("days", []),
|
||||
"time": sched.get("time"),
|
||||
"remind": sched.get("remind", True),
|
||||
"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
|
||||
|
||||
def _get_routine_duration_minutes(routine_id):
|
||||
@@ -745,7 +748,10 @@ def register(app):
|
||||
|
||||
@app.route("/api/routines/<routine_id>/schedule", methods=["PUT"])
|
||||
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)
|
||||
if not user_uuid:
|
||||
return flask.jsonify({"error": "unauthorized"}), 401
|
||||
@@ -758,15 +764,18 @@ def register(app):
|
||||
if not data:
|
||||
return flask.jsonify({"error": "missing body"}), 400
|
||||
|
||||
# Check for schedule conflicts
|
||||
new_days = data.get("days", [])
|
||||
new_time = data.get("time")
|
||||
has_conflict, conflict_msg = _check_schedule_conflicts(
|
||||
user_uuid, new_days, new_time, exclude_routine_id=routine_id,
|
||||
new_routine_id=routine_id,
|
||||
)
|
||||
if has_conflict:
|
||||
return flask.jsonify({"error": conflict_msg}), 409
|
||||
frequency = data.get("frequency", "weekly")
|
||||
|
||||
# Check for schedule conflicts (only for weekly — interval conflicts checked at reminder time)
|
||||
if frequency == "weekly":
|
||||
new_days = data.get("days", [])
|
||||
new_time = data.get("time")
|
||||
has_conflict, conflict_msg = _check_schedule_conflicts(
|
||||
user_uuid, new_days, new_time, exclude_routine_id=routine_id,
|
||||
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})
|
||||
schedule_data = {
|
||||
@@ -774,6 +783,9 @@ def register(app):
|
||||
"days": json.dumps(data.get("days", [])),
|
||||
"time": data.get("time"),
|
||||
"remind": data.get("remind", True),
|
||||
"frequency": frequency,
|
||||
"interval_days": data.get("interval_days"),
|
||||
"start_date": data.get("start_date"),
|
||||
}
|
||||
if existing:
|
||||
result = postgres.update(
|
||||
|
||||
@@ -663,14 +663,6 @@ def _restore_sessions_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
|
||||
async def on_message(message):
|
||||
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"Connected to {len(client.guilds)} guilds", flush=True)
|
||||
loadCache()
|
||||
_restore_sessions_from_cache()
|
||||
backgroundLoop.start()
|
||||
presenceTrackingLoop.start()
|
||||
print(f"[DEBUG] Presence tracking loop started", flush=True)
|
||||
|
||||
@@ -74,7 +74,10 @@ CREATE TABLE IF NOT EXISTS routine_schedules (
|
||||
routine_id UUID REFERENCES routines(id) ON DELETE CASCADE,
|
||||
days JSON DEFAULT '[]',
|
||||
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 (
|
||||
@@ -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_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;
|
||||
|
||||
@@ -108,6 +108,8 @@ def check_medication_reminders():
|
||||
def check_routine_reminders():
|
||||
"""Check for scheduled routines due now and send notifications."""
|
||||
try:
|
||||
from datetime import date as date_type
|
||||
|
||||
schedules = postgres.select("routine_schedules", where={"remind": True})
|
||||
|
||||
for schedule in schedules:
|
||||
@@ -117,13 +119,30 @@ def check_routine_reminders():
|
||||
|
||||
now = _user_now_for(routine["user_uuid"])
|
||||
current_time = now.strftime("%H:%M")
|
||||
current_day = now.strftime("%a").lower()
|
||||
today = now.date()
|
||||
|
||||
if current_time != schedule.get("time"):
|
||||
continue
|
||||
days = schedule.get("days", [])
|
||||
if current_day not in days:
|
||||
continue
|
||||
|
||||
frequency = schedule.get("frequency", "weekly")
|
||||
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"])
|
||||
if user_settings:
|
||||
|
||||
@@ -91,24 +91,36 @@ export default function MedicationsPage() {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
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(() => {
|
||||
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);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
fetchData().finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
// Re-fetch when tab becomes visible or every 60s
|
||||
useEffect(() => {
|
||||
const onVisible = () => {
|
||||
if (document.visibilityState === 'visible') fetchData();
|
||||
};
|
||||
document.addEventListener('visibilitychange', onVisible);
|
||||
const poll = setInterval(fetchData, 60_000);
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', onVisible);
|
||||
clearInterval(poll);
|
||||
};
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
// Auto-refresh grouping every 60s
|
||||
|
||||
@@ -29,6 +29,9 @@ interface Schedule {
|
||||
days: string[];
|
||||
time: string;
|
||||
remind: boolean;
|
||||
frequency?: string;
|
||||
interval_days?: number;
|
||||
start_date?: string;
|
||||
}
|
||||
|
||||
const ICONS = ['✨', '🌅', '🌙', '☀️', '💪', '🧘', '📚', '🍳', '🏃', '💼', '🎯', '⭐', '🔥', '💤', '🧠', '☕', '🍎', '💧', '🍀', '🎵', '📝', '🚴', '🏋️', '🚶', '👀', '🛡️', '😊', '😔'];
|
||||
@@ -77,6 +80,9 @@ export default function RoutineDetailPage() {
|
||||
const [editDays, setEditDays] = useState<string[]>(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']);
|
||||
const [editTime, setEditTime] = useState('08:00');
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -99,6 +105,9 @@ export default function RoutineDetailPage() {
|
||||
setEditDays(scheduleData.days || []);
|
||||
setEditTime(scheduleData.time || '08:00');
|
||||
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 {
|
||||
setEditDays(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']);
|
||||
if (isNewRoutine) {
|
||||
@@ -162,13 +171,27 @@ export default function RoutineDetailPage() {
|
||||
|
||||
const handleSaveSchedule = async () => {
|
||||
try {
|
||||
if (editDays.length > 0) {
|
||||
await api.routines.setSchedule(routineId, {
|
||||
const hasSchedule = editFrequency === 'every_n_days' || editDays.length > 0;
|
||||
if (hasSchedule) {
|
||||
const schedulePayload = {
|
||||
days: editDays,
|
||||
time: editTime || '08:00',
|
||||
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) {
|
||||
await api.routines.deleteSchedule(routineId);
|
||||
setSchedule(null);
|
||||
@@ -176,7 +199,7 @@ export default function RoutineDetailPage() {
|
||||
setShowScheduleEditor(false);
|
||||
} catch (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 ? (
|
||||
<>
|
||||
{/* Quick select */}
|
||||
{/* Frequency selector */}
|
||||
<div className="flex gap-2 mb-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditDays(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'])}
|
||||
className={`px-3 py-1.5 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'
|
||||
onClick={() => setEditFrequency('weekly')}
|
||||
className={`flex-1 px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
|
||||
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
|
||||
type="button"
|
||||
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'
|
||||
onClick={() => setEditFrequency('every_n_days')}
|
||||
className={`flex-1 px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
|
||||
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
|
||||
</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
|
||||
Every N Days
|
||||
</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) => (
|
||||
{editFrequency === 'every_n_days' ? (
|
||||
<div className="mb-3 space-y-3">
|
||||
<div>
|
||||
<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
|
||||
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'
|
||||
onClick={() => setEditDays(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'])}
|
||||
className={`px-3 py-1.5 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'
|
||||
}`}
|
||||
>
|
||||
{day.label}
|
||||
Every day
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
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">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Time</label>
|
||||
<input
|
||||
@@ -545,10 +620,14 @@ export default function RoutineDetailPage() {
|
||||
setEditDays(schedule.days);
|
||||
setEditTime(schedule.time);
|
||||
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 {
|
||||
setEditDays(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']);
|
||||
setEditTime('08:00');
|
||||
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"
|
||||
@@ -563,10 +642,12 @@ export default function RoutineDetailPage() {
|
||||
</button>
|
||||
</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">
|
||||
{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>
|
||||
{schedule.remind && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">Reminders on</p>
|
||||
|
||||
@@ -21,6 +21,9 @@ interface ScheduleEntry {
|
||||
time: string;
|
||||
remind: boolean;
|
||||
total_duration_minutes: number;
|
||||
frequency?: string;
|
||||
interval_days?: number;
|
||||
start_date?: string;
|
||||
}
|
||||
|
||||
interface TodaysMedication {
|
||||
@@ -230,7 +233,15 @@ export default function RoutinesPage() {
|
||||
const dayKey = getDayKey(selectedDate);
|
||||
|
||||
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));
|
||||
|
||||
const tasksForDay = allTasks.filter((t) => {
|
||||
@@ -433,7 +444,7 @@ export default function RoutinesPage() {
|
||||
setUndoAction(null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAllData = () =>
|
||||
Promise.all([
|
||||
api.routines.list(),
|
||||
api.routines.listAllSchedules(),
|
||||
@@ -446,8 +457,10 @@ export default function RoutinesPage() {
|
||||
setTodayMeds(meds);
|
||||
setAllTasks(tasks);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setIsLoading(false));
|
||||
.catch(() => {});
|
||||
|
||||
useEffect(() => {
|
||||
fetchAllData().finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -459,6 +472,19 @@ export default function RoutinesPage() {
|
||||
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(() => {
|
||||
if (!isLoading && isToday && timelineRef.current) {
|
||||
const scrollTarget = nowTopPx - window.innerHeight / 3;
|
||||
@@ -471,6 +497,18 @@ export default function RoutinesPage() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [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) => {
|
||||
try {
|
||||
await api.sessions.start(routineId);
|
||||
@@ -838,10 +876,20 @@ export default function RoutinesPage() {
|
||||
{task.description && ` · ${task.description}`}
|
||||
</p>
|
||||
</div>
|
||||
{isPast && (
|
||||
{isPast ? (
|
||||
<span className="text-green-600 flex-shrink-0">
|
||||
<CheckIcon size={16} />
|
||||
</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>
|
||||
|
||||
@@ -53,6 +53,19 @@ export default function TasksPage() {
|
||||
loadTasks(showCompleted ? 'all' : 'pending');
|
||||
}, [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) => {
|
||||
try {
|
||||
await api.tasks.update(task.id, { status: 'completed' });
|
||||
|
||||
@@ -65,7 +65,7 @@ export default function PushNotificationToggle() {
|
||||
const { public_key } = await api.notifications.getVapidPublicKey();
|
||||
const sub = await reg.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(public_key).buffer as ArrayBuffer,
|
||||
applicationServerKey: urlBase64ToUint8Array(public_key) as BufferSource,
|
||||
});
|
||||
|
||||
const subJson = sub.toJSON();
|
||||
|
||||
@@ -323,12 +323,15 @@ export const api = {
|
||||
days: string[];
|
||||
time: string;
|
||||
remind: boolean;
|
||||
frequency?: string;
|
||||
interval_days?: number;
|
||||
start_date?: string;
|
||||
}>(`/api/routines/${routineId}/schedule`, { method: 'GET' });
|
||||
},
|
||||
|
||||
setSchedule: async (
|
||||
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`, {
|
||||
method: 'PUT',
|
||||
@@ -352,6 +355,9 @@ export const api = {
|
||||
time: string;
|
||||
remind: boolean;
|
||||
total_duration_minutes: number;
|
||||
frequency?: string;
|
||||
interval_days?: number;
|
||||
start_date?: string;
|
||||
}>>('/api/routines/schedules', { method: 'GET' });
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user