diff --git a/backend/src/db/migrations/0011_add_dose_tracking.sql b/backend/src/db/migrations/0011_add_dose_tracking.sql new file mode 100644 index 0000000..ce49d4a --- /dev/null +++ b/backend/src/db/migrations/0011_add_dose_tracking.sql @@ -0,0 +1,11 @@ +-- Dose tracking table for syncing taken doses between users and share links +CREATE TABLE IF NOT EXISTS dose_tracking ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + dose_id TEXT NOT NULL, + taken_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), + marked_by TEXT +); + +-- Index for fast lookups by user and dose +CREATE INDEX IF NOT EXISTS idx_dose_tracking_user_dose ON dose_tracking(user_id, dose_id); diff --git a/backend/src/db/migrations/meta/_journal.json b/backend/src/db/migrations/meta/_journal.json index 9eed08d..aa39f2f 100644 --- a/backend/src/db/migrations/meta/_journal.json +++ b/backend/src/db/migrations/meta/_journal.json @@ -10,6 +10,7 @@ { "idx": 7, "version": 1, "when": 1735300000, "tag": "0007_add_intake_reminders", "breakpoint": false }, { "idx": 8, "version": 1, "when": 1735400000, "tag": "0008_add_pill_weight", "breakpoint": false }, { "idx": 9, "version": 1, "when": 1735500000, "tag": "0009_add_taken_by", "breakpoint": false }, - { "idx": 10, "version": 1, "when": 1735600000, "tag": "0010_add_user_settings", "breakpoint": false } + { "idx": 10, "version": 1, "when": 1735600000, "tag": "0010_add_user_settings", "breakpoint": false }, + { "idx": 11, "version": 1, "when": 1735700000, "tag": "0011_add_dose_tracking", "breakpoint": false } ] } diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 8b89a17..3434367 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -99,3 +99,14 @@ export const shareTokens = sqliteTable("share_tokens", { scheduleDays: integer("schedule_days").notNull().default(30), createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`), }); + +// ============================================================================= +// Dose Tracking - Tracks when doses are marked as taken +// ============================================================================= +export const doseTracking = sqliteTable("dose_tracking", { + id: integer("id").primaryKey({ autoIncrement: true }), + userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), + doseId: text("dose_id", { length: 255 }).notNull(), // e.g. "med-5-1-86400000-1735200000000" + takenAt: integer("taken_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`), + markedBy: text("marked_by", { length: 100 }), // null = user, "Daniel" = via share link +}); diff --git a/backend/src/index.ts b/backend/src/index.ts index 0cb5f93..cda3f54 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -17,6 +17,7 @@ import { medicationRoutes } from "./routes/medications.js"; import { settingsRoutes } from "./routes/settings.js"; import { plannerRoutes } from "./routes/planner.js"; import { shareRoutes } from "./routes/share.js"; +import { doseRoutes } from "./routes/doses.js"; import { startReminderScheduler } from "./services/reminder-scheduler.js"; import { startIntakeReminderScheduler } from "./services/intake-reminder-scheduler.js"; @@ -101,6 +102,7 @@ await app.register(medicationRoutes); await app.register(settingsRoutes); await app.register(plannerRoutes); await app.register(shareRoutes); +await app.register(doseRoutes); const start = async () => { try { diff --git a/backend/src/routes/doses.ts b/backend/src/routes/doses.ts new file mode 100644 index 0000000..e3583e9 --- /dev/null +++ b/backend/src/routes/doses.ts @@ -0,0 +1,237 @@ +import { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { db } from "../db/client.js"; +import { doseTracking, shareTokens } from "../db/schema.js"; +import { eq, and, gte } from "drizzle-orm"; +import { requireAuth } from "../plugins/auth.js"; +import type { AuthUser } from "../types/fastify.js"; + +// ============================================================================= +// Validation Schemas +// ============================================================================= +const markDoseSchema = z.object({ + doseId: z.string().min(1, "doseId is required"), +}); + +const shareDoseSchema = z.object({ + doseId: z.string().min(1, "doseId is required"), +}); + +// ============================================================================= +// Dose Tracking Routes +// ============================================================================= +export async function doseRoutes(app: FastifyInstance) { + // --------------------------------------------------------------------------- + // GET /doses/taken - PROTECTED: Get all taken doses for the user + // --------------------------------------------------------------------------- + app.get( + "/doses/taken", + { preHandler: requireAuth }, + async (request, reply) => { + const authUser = request.user as unknown as AuthUser | null; + if (!authUser) { + return reply.status(401).send({ error: "Not authenticated" }); + } + + // Get doses from last 30 days (to avoid loading too much data) + const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + + const doses = await db.select() + .from(doseTracking) + .where( + and( + eq(doseTracking.userId, authUser.id), + gte(doseTracking.takenAt, thirtyDaysAgo) + ) + ); + + return { + doses: doses.map((d) => ({ + doseId: d.doseId, + takenAt: d.takenAt?.getTime() ?? Date.now(), + markedBy: d.markedBy, + })), + }; + } + ); + + // --------------------------------------------------------------------------- + // POST /doses/taken - PROTECTED: Mark a dose as taken + // --------------------------------------------------------------------------- + app.post<{ Body: z.infer }>( + "/doses/taken", + { preHandler: requireAuth }, + async (request, reply) => { + const authUser = request.user as unknown as AuthUser | null; + if (!authUser) { + return reply.status(401).send({ error: "Not authenticated" }); + } + + const parsed = markDoseSchema.safeParse(request.body); + if (!parsed.success) { + return reply.status(400).send({ + error: parsed.error.errors[0]?.message ?? "Invalid input", + }); + } + + const { doseId } = parsed.data; + + // Check if already marked + const [existing] = await db.select() + .from(doseTracking) + .where( + and( + eq(doseTracking.userId, authUser.id), + eq(doseTracking.doseId, doseId) + ) + ); + + if (existing) { + return { success: true, message: "Already marked" }; + } + + // Insert new record + await db.insert(doseTracking).values({ + userId: authUser.id, + doseId, + markedBy: null, // Marked by the user themselves + }); + + return { success: true }; + } + ); + + // --------------------------------------------------------------------------- + // DELETE /doses/taken/:doseId - PROTECTED: Unmark a dose + // --------------------------------------------------------------------------- + app.delete<{ Params: { doseId: string } }>( + "/doses/taken/:doseId", + { preHandler: requireAuth }, + async (request, reply) => { + const authUser = request.user as unknown as AuthUser | null; + if (!authUser) { + return reply.status(401).send({ error: "Not authenticated" }); + } + + const { doseId } = request.params; + + await db.delete(doseTracking).where( + and( + eq(doseTracking.userId, authUser.id), + eq(doseTracking.doseId, doseId) + ) + ); + + return { success: true }; + } + ); + + // --------------------------------------------------------------------------- + // GET /share/:token/doses - PUBLIC: Get taken doses for a share link + // --------------------------------------------------------------------------- + app.get<{ Params: { token: string } }>( + "/share/:token/doses", + async (request, reply) => { + const { token } = request.params; + + // Find share token + const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token)); + if (!share) { + return reply.notFound("Share link not found"); + } + + // Get doses from last 30 days + const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + + const doses = await db.select() + .from(doseTracking) + .where( + and( + eq(doseTracking.userId, share.userId), + gte(doseTracking.takenAt, thirtyDaysAgo) + ) + ); + + return { + doses: doses.map((d) => ({ + doseId: d.doseId, + takenAt: d.takenAt?.getTime() ?? Date.now(), + markedBy: d.markedBy, + })), + }; + } + ); + + // --------------------------------------------------------------------------- + // POST /share/:token/doses - PUBLIC: Mark a dose as taken via share link + // --------------------------------------------------------------------------- + app.post<{ Params: { token: string }; Body: z.infer }>( + "/share/:token/doses", + async (request, reply) => { + const { token } = request.params; + + const parsed = shareDoseSchema.safeParse(request.body); + if (!parsed.success) { + return reply.status(400).send({ + error: parsed.error.errors[0]?.message ?? "Invalid input", + }); + } + + const { doseId } = parsed.data; + + // Find share token + const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token)); + if (!share) { + return reply.notFound("Share link not found"); + } + + // Check if already marked + const [existing] = await db.select() + .from(doseTracking) + .where( + and( + eq(doseTracking.userId, share.userId), + eq(doseTracking.doseId, doseId) + ) + ); + + if (existing) { + return { success: true, message: "Already marked" }; + } + + // Insert new record - marked by the takenBy person + await db.insert(doseTracking).values({ + userId: share.userId, + doseId, + markedBy: share.takenBy, // e.g. "Daniel" + }); + + return { success: true }; + } + ); + + // --------------------------------------------------------------------------- + // DELETE /share/:token/doses/:doseId - PUBLIC: Unmark a dose via share link + // --------------------------------------------------------------------------- + app.delete<{ Params: { token: string; doseId: string } }>( + "/share/:token/doses/:doseId", + async (request, reply) => { + const { token, doseId } = request.params; + + // Find share token + const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token)); + if (!share) { + return reply.notFound("Share link not found"); + } + + await db.delete(doseTracking).where( + and( + eq(doseTracking.userId, share.userId), + eq(doseTracking.doseId, doseId) + ) + ); + + return { success: true }; + } + ); +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d1ad91a..22d47f3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -241,45 +241,72 @@ function AppContent() { const storedDays = localStorage.getItem(userStorageKey(user.id, "scheduleDays")); setScheduleDays(storedDays ? Number(storedDays) : 30); - try { - const storedDoses = localStorage.getItem(userStorageKey(user.id, "takenDoses")); - if (storedDoses) { - const parsed = JSON.parse(storedDoses); - const weekAgo = Date.now() - 7 * 24 * 60 * 60 * 1000; - const filtered = parsed.filter((item: { id: string; timestamp: number }) => item.timestamp > weekAgo); - setTakenDoses(new Set(filtered.map((item: { id: string }) => item.id))); - } else { + // Load taken doses from server + async function loadTakenDoses() { + try { + const res = await fetch("/api/doses/taken", { credentials: "include" }); + if (res.ok) { + const data = await res.json(); + setTakenDoses(new Set(data.doses.map((d: { doseId: string }) => d.doseId))); + } else { + setTakenDoses(new Set()); + } + } catch { setTakenDoses(new Set()); } - } catch { - setTakenDoses(new Set()); } + loadTakenDoses(); } }, [user?.id]); - function markDoseTaken(doseId: string) { + async function markDoseTaken(doseId: string) { + // Optimistic update setTakenDoses((prev) => { const next = new Set(prev); next.add(doseId); - // Persist with timestamp for cleanup - const items = Array.from(next).map((id) => ({ id, timestamp: Date.now() })); - if (user?.id) { - localStorage.setItem(userStorageKey(user.id, "takenDoses"), JSON.stringify(items)); - } return next; }); + + // Send to server + try { + await fetch("/api/doses/taken", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ doseId }), + }); + } catch { + // Revert on error + setTakenDoses((prev) => { + const next = new Set(prev); + next.delete(doseId); + return next; + }); + } } - function undoDoseTaken(doseId: string) { + async function undoDoseTaken(doseId: string) { + // Optimistic update setTakenDoses((prev) => { const next = new Set(prev); next.delete(doseId); - const items = Array.from(next).map((id) => ({ id, timestamp: Date.now() })); - if (user?.id) { - localStorage.setItem(userStorageKey(user.id, "takenDoses"), JSON.stringify(items)); - } return next; }); + + // Send to server + try { + await fetch(`/api/doses/taken/${encodeURIComponent(doseId)}`, { + method: "DELETE", + credentials: "include", + }); + } catch { + // Revert on error + setTakenDoses((prev) => { + const next = new Set(prev); + next.add(doseId); + return next; + }); + } } // Close modal on Escape key @@ -2303,47 +2330,72 @@ function SharedSchedule() { return () => window.removeEventListener("keydown", handleKeyDown); }, [lightboxImage]); - // Load taken doses from localStorage + // Load taken doses from server useEffect(() => { if (token) { - try { - const storedDoses = localStorage.getItem(`share_${token}_takenDoses`); - if (storedDoses) { - const parsed = JSON.parse(storedDoses); - // Clean up old doses (older than 7 days) - const weekAgo = Date.now() - 7 * 24 * 60 * 60 * 1000; - const filtered = parsed.filter((item: { id: string; timestamp: number }) => item.timestamp > weekAgo); - setTakenDoses(new Set(filtered.map((item: { id: string }) => item.id))); + async function loadTakenDoses() { + try { + const res = await fetch(`/api/share/${token}/doses`); + if (res.ok) { + const data = await res.json(); + setTakenDoses(new Set(data.doses.map((d: { doseId: string }) => d.doseId))); + } else { + setTakenDoses(new Set()); + } + } catch { + setTakenDoses(new Set()); } - } catch { - setTakenDoses(new Set()); } + loadTakenDoses(); } }, [token]); - function markDoseTaken(doseId: string) { + async function markDoseTaken(doseId: string) { + // Optimistic update setTakenDoses((prev) => { const next = new Set(prev); next.add(doseId); - // Persist with timestamp for cleanup - const items = Array.from(next).map((id) => ({ id, timestamp: Date.now() })); - if (token) { - localStorage.setItem(`share_${token}_takenDoses`, JSON.stringify(items)); - } return next; }); + + // Send to server + try { + await fetch(`/api/share/${token}/doses`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ doseId }), + }); + } catch { + // Revert on error + setTakenDoses((prev) => { + const next = new Set(prev); + next.delete(doseId); + return next; + }); + } } - function undoDoseTaken(doseId: string) { + async function undoDoseTaken(doseId: string) { + // Optimistic update setTakenDoses((prev) => { const next = new Set(prev); next.delete(doseId); - const items = Array.from(next).map((id) => ({ id, timestamp: Date.now() })); - if (token) { - localStorage.setItem(`share_${token}_takenDoses`, JSON.stringify(items)); - } return next; }); + + // Send to server + try { + await fetch(`/api/share/${token}/doses/${encodeURIComponent(doseId)}`, { + method: "DELETE", + }); + } catch { + // Revert on error + setTakenDoses((prev) => { + const next = new Set(prev); + next.add(doseId); + return next; + }); + } } useEffect(() => { @@ -2382,7 +2434,7 @@ function SharedSchedule() { const doses: { id: string; when: number; medName: string; usage: number; timeStr: string }[] = []; for (const med of data.medications) { - for (const slice of med.slices) { + med.slices.forEach((slice, sliceIdx) => { const startDate = new Date(slice.start); const intervalMs = slice.every * 24 * 60 * 60 * 1000; let t = startDate.getTime(); @@ -2397,8 +2449,8 @@ function SharedSchedule() { while (t <= endTime) { const d = new Date(t); - // Generate unique dose ID - const doseId = `share-${med.id}-${slice.usage}-${slice.every}-${t}`; + // Generate dose ID matching Dashboard format: ${med.id}-${sliceIdx}-${whenMs} + const doseId = `${med.id}-${sliceIdx}-${t}`; doses.push({ id: doseId, when: t, @@ -2408,7 +2460,7 @@ function SharedSchedule() { }); t += intervalMs; } - } + }); } doses.sort((a, b) => a.when - b.when);