From b0f26b1e66ebfa29373281533cf12d0995ab3f19 Mon Sep 17 00:00:00 2001 From: Daniel Volz Date: Fri, 26 Dec 2025 21:06:03 +0100 Subject: [PATCH] feat(share): implement share functionality for medication schedules with token-based access --- backend/src/db/migrate.ts | 10 + backend/src/db/schema.ts | 12 + backend/src/index.ts | 2 + backend/src/routes/share.ts | 152 ++++++++++++ frontend/src/App.tsx | 476 ++++++++++++++++++++++++++++++++++-- frontend/src/i18n/de.json | 18 ++ frontend/src/i18n/en.json | 18 ++ frontend/src/styles.css | 215 ++++++++++++++++ 8 files changed, 887 insertions(+), 16 deletions(-) create mode 100644 backend/src/routes/share.ts diff --git a/backend/src/db/migrate.ts b/backend/src/db/migrate.ts index 5bb7d61..0ab5aa3 100644 --- a/backend/src/db/migrate.ts +++ b/backend/src/db/migrate.ts @@ -83,6 +83,16 @@ async function main() { created_at integer NOT NULL DEFAULT (strftime('%s','now')), FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); + + CREATE TABLE IF NOT EXISTS share_tokens ( + id integer PRIMARY KEY AUTOINCREMENT, + user_id integer NOT NULL, + token text NOT NULL UNIQUE, + taken_by text NOT NULL, + schedule_days integer NOT NULL DEFAULT 30, + created_at integer NOT NULL DEFAULT (strftime('%s','now')), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); `; // Execute each statement separately diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 14ce7dc..8b89a17 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -87,3 +87,15 @@ export const refreshTokens = sqliteTable("refresh_tokens", { revoked: integer("revoked", { mode: "boolean" }).notNull().default(false), createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`), }); + +// ============================================================================= +// Share Tokens - For public schedule sharing by takenBy person +// ============================================================================= +export const shareTokens = sqliteTable("share_tokens", { + id: integer("id").primaryKey({ autoIncrement: true }), + userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), + token: text("token", { length: 64 }).notNull().unique(), + takenBy: text("taken_by", { length: 100 }).notNull(), + scheduleDays: integer("schedule_days").notNull().default(30), + createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`), +}); diff --git a/backend/src/index.ts b/backend/src/index.ts index 49c19c0..0cb5f93 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -16,6 +16,7 @@ import { authRoutes } from "./routes/auth.js"; 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 { startReminderScheduler } from "./services/reminder-scheduler.js"; import { startIntakeReminderScheduler } from "./services/intake-reminder-scheduler.js"; @@ -99,6 +100,7 @@ await app.register(authRoutes); await app.register(medicationRoutes); await app.register(settingsRoutes); await app.register(plannerRoutes); +await app.register(shareRoutes); const start = async () => { try { diff --git a/backend/src/routes/share.ts b/backend/src/routes/share.ts new file mode 100644 index 0000000..12069a2 --- /dev/null +++ b/backend/src/routes/share.ts @@ -0,0 +1,152 @@ +import { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { randomBytes } from "crypto"; +import { db } from "../db/client.js"; +import { medications, shareTokens } from "../db/schema.js"; +import { eq, and } from "drizzle-orm"; +import { requireAuth, optionalAuth } from "../plugins/auth.js"; +import type { AuthUser } from "../types/fastify.js"; + +// ============================================================================= +// Validation Schemas +// ============================================================================= +const createShareSchema = z.object({ + takenBy: z.string().min(1, "takenBy is required"), + scheduleDays: z.number().int().min(1).max(365).default(30), +}); + +// ============================================================================= +// Share Routes +// ============================================================================= +export async function shareRoutes(app: FastifyInstance) { + // --------------------------------------------------------------------------- + // GET /share/:token - PUBLIC: Get shared schedule by token + // --------------------------------------------------------------------------- + app.get<{ Params: { token: string } }>("/share/:token", 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 medications for this user filtered by takenBy + const meds = await db.select().from(medications).where( + and( + eq(medications.userId, share.userId), + eq(medications.takenBy, share.takenBy) + ) + ); + + // Parse slices and build schedule data + const medicationsWithSlices = meds.map((med) => { + let slices: { usage: number; every: number; start: string }[] = []; + try { + const usageArr = JSON.parse(med.usageJson || "[]"); + const everyArr = JSON.parse(med.everyJson || "[]"); + const startArr = JSON.parse(med.startJson || "[]"); + slices = usageArr.map((usage: number, i: number) => ({ + usage, + every: everyArr[i] ?? 1, + start: startArr[i] ?? new Date().toISOString(), + })); + } catch { + slices = []; + } + + return { + id: med.id, + name: med.name, + genericName: med.genericName, + pillWeightMg: med.pillWeightMg, + imageUrl: med.imageUrl, + slices, + }; + }); + + return { + takenBy: share.takenBy, + scheduleDays: share.scheduleDays, + medications: medicationsWithSlices, + }; + }); + + // --------------------------------------------------------------------------- + // POST /share - PROTECTED: Create a new share link + // --------------------------------------------------------------------------- + app.post<{ Body: z.infer }>( + "/share", + { 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 = createShareSchema.safeParse(request.body); + if (!parsed.success) { + return reply.status(400).send({ + error: parsed.error.errors[0]?.message ?? "Invalid input", + code: "VALIDATION_ERROR", + }); + } + + const { takenBy, scheduleDays } = parsed.data; + + // Check if user has medications for this takenBy + const [existingMed] = await db.select().from(medications).where( + and( + eq(medications.userId, authUser.id), + eq(medications.takenBy, takenBy) + ) + ); + + if (!existingMed) { + return reply.status(400).send({ + error: "No medications found for this person", + code: "NO_MEDICATIONS", + }); + } + + // Generate unique token (8 bytes = 16 hex chars) + const token = randomBytes(8).toString("hex"); + + // Create share token + await db.insert(shareTokens).values({ + userId: authUser.id, + token, + takenBy, + scheduleDays, + }); + + return { + token, + shareUrl: `/share/${token}`, + }; + } + ); + + // --------------------------------------------------------------------------- + // GET /share/people - PROTECTED: Get list of unique takenBy values + // --------------------------------------------------------------------------- + app.get( + "/share/people", + { 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 all unique takenBy values for this user + const meds = await db.select({ takenBy: medications.takenBy }) + .from(medications) + .where(eq(medications.userId, authUser.id)); + + const uniquePeople = [...new Set(meds.map((m) => m.takenBy).filter(Boolean))] as string[]; + + return { people: uniquePeople }; + } + ); +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index dbdaf1a..d1ad91a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from "react"; -import { Routes, Route, useNavigate, useLocation, Navigate } from "react-router-dom"; +import { Routes, Route, useNavigate, useLocation, Navigate, useParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { AuthProvider, useAuth, AuthPage, UserProfile } from "./components/Auth"; @@ -85,7 +85,12 @@ type Coverage = { export default function App() { return ( - + + {/* Public share route - accessible without auth */} + } /> + {/* All other routes go through AppRouter */} + } /> + ); } @@ -221,6 +226,14 @@ function AppContent() { const [selectedUser, setSelectedUser] = useState(null); const [scheduleDays, setScheduleDays] = useState(30); const [takenDoses, setTakenDoses] = useState>(new Set()); + // Share dialog state + const [showShareDialog, setShowShareDialog] = useState(false); + const [sharePeople, setSharePeople] = useState([]); + const [shareSelectedPerson, setShareSelectedPerson] = useState(""); + const [shareSelectedDays, setShareSelectedDays] = useState(30); + const [shareGenerating, setShareGenerating] = useState(false); + const [shareLink, setShareLink] = useState(null); + const [shareCopied, setShareCopied] = useState(false); // Load user-specific scheduleDays and takenDoses when user changes useEffect(() => { @@ -627,6 +640,66 @@ function AppContent() { } } + // Share dialog functions + async function openShareDialog() { + setShowShareDialog(true); + setShareLink(null); + setShareCopied(false); + setShareSelectedPerson(""); + setShareSelectedDays(30); + + // Get unique takenBy people from medications + const uniquePeople = [...new Set(meds.map(m => m.takenBy).filter(Boolean))] as string[]; + setSharePeople(uniquePeople); + if (uniquePeople.length > 0) { + setShareSelectedPerson(uniquePeople[0]); + } + } + + async function generateShareLink() { + if (!shareSelectedPerson) return; + setShareGenerating(true); + setShareCopied(false); + + try { + const res = await fetch("/api/share", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + takenBy: shareSelectedPerson, + scheduleDays: shareSelectedDays, + }), + }); + + if (res.ok) { + const data = await res.json(); + const fullUrl = `${window.location.origin}/share/${data.token}`; + setShareLink(fullUrl); + } else { + const err = await res.json(); + alert(err.error || "Failed to generate share link"); + } + } catch { + alert("Failed to generate share link"); + } finally { + setShareGenerating(false); + } + } + + function copyShareLink() { + if (shareLink) { + navigator.clipboard.writeText(shareLink); + setShareCopied(true); + setTimeout(() => setShareCopied(false), 2000); + } + } + + function closeShareDialog() { + setShowShareDialog(false); + setShareLink(null); + setShareCopied(false); + } + const [theme, setTheme] = useState<"light" | "dark">(() => { if (typeof window !== "undefined") { return (localStorage.getItem("theme") as "light" | "dark") || "dark"; @@ -832,20 +905,27 @@ function AppContent() {
-

navigate("/schedule")}>{t('dashboard.schedules.title')}

- +

{t('dashboard.schedules.title')}

+
+ {meds.some(m => m.takenBy) && ( + + )} + +
{groupedSchedule.map((day) => ( @@ -1713,6 +1793,82 @@ function AppContent() {
)} + + {/* Share Dialog Modal */} + {showShareDialog && ( +
+
e.stopPropagation()}> + + +
+

🔗 {t('share.title')}

+

{t('share.description')}

+
+ + {sharePeople.length === 0 ? ( +
+

{t('share.noPeople')}

+
+ ) : shareLink ? ( +
+

{t('share.linkGenerated')}

+
+ (e.target as HTMLInputElement).select()} + /> + +
+ {shareCopied && {t('share.copied')}} +
+ + +
+
+ ) : ( +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ )} +
+
+ )} ); } @@ -2108,3 +2264,291 @@ function MedicationAvatar({ name, imageUrl, size = "sm" }: { name: string; image } return
{initials}
; } + +// ============================================================================= +// Shared Schedule Component - Public view for shared schedules +// ============================================================================= +type SharedMedication = { + id: number; + name: string; + genericName?: string | null; + pillWeightMg?: number | null; + imageUrl?: string | null; + slices: Slice[]; +}; + +type SharedScheduleData = { + takenBy: string; + scheduleDays: number; + medications: SharedMedication[]; +}; + +function SharedSchedule() { + const { token } = useParams<{ token: string }>(); + const { t, i18n } = useTranslation(); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [takenDoses, setTakenDoses] = useState>(new Set()); + const [lightboxImage, setLightboxImage] = useState<{ url: string; name: string } | null>(null); + + // Close lightbox on Escape key + useEffect(() => { + function handleKeyDown(e: KeyboardEvent) { + if (e.key === "Escape" && lightboxImage) { + setLightboxImage(null); + } + } + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [lightboxImage]); + + // Load taken doses from localStorage + 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))); + } + } catch { + setTakenDoses(new Set()); + } + } + }, [token]); + + function markDoseTaken(doseId: string) { + 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; + }); + } + + function undoDoseTaken(doseId: string) { + 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; + }); + } + + useEffect(() => { + async function fetchData() { + if (!token) { + setError("Invalid link"); + setLoading(false); + return; + } + + try { + const res = await fetch(`/api/share/${token}`); + if (res.ok) { + const json = await res.json(); + setData(json); + } else { + setError("Share link not found or expired"); + } + } catch { + setError("Failed to load schedule"); + } finally { + setLoading(false); + } + } + fetchData(); + }, [token]); + + // Build schedule from medications + const schedule = useMemo(() => { + if (!data) return []; + + const now = Date.now(); + // Start from 7 days ago to show past doses + const startTime = now - 7 * 24 * 60 * 60 * 1000; + const endTime = now + data.scheduleDays * 24 * 60 * 60 * 1000; + const doses: { id: string; when: number; medName: string; usage: number; timeStr: string }[] = []; + + for (const med of data.medications) { + for (const slice of med.slices) { + const startDate = new Date(slice.start); + const intervalMs = slice.every * 24 * 60 * 60 * 1000; + let t = startDate.getTime(); + + // Move to first occurrence >= startTime + if (t < startTime) { + const elapsed = startTime - t; + const periods = Math.floor(elapsed / intervalMs); + t += periods * intervalMs; + if (t < startTime) t += intervalMs; + } + + while (t <= endTime) { + const d = new Date(t); + // Generate unique dose ID + const doseId = `share-${med.id}-${slice.usage}-${slice.every}-${t}`; + doses.push({ + id: doseId, + when: t, + medName: med.name, + usage: slice.usage, + timeStr: d.toLocaleTimeString(i18n.language, { hour: "2-digit", minute: "2-digit" }), + }); + t += intervalMs; + } + } + } + + doses.sort((a, b) => a.when - b.when); + + // Group by date + const grouped: { dateStr: string; date: Date; meds: { medName: string; total: number; lastWhen: number; doses: typeof doses }[] }[] = []; + const byDate = new Map(); + + for (const dose of doses) { + const dateKey = new Date(dose.when).toLocaleDateString(i18n.language, { + weekday: "long", + day: "2-digit", + month: "short", + }); + if (!byDate.has(dateKey)) byDate.set(dateKey, []); + byDate.get(dateKey)!.push(dose); + } + + for (const [dateStr, dayDoses] of byDate) { + const byMed = new Map(); + for (const dose of dayDoses) { + if (!byMed.has(dose.medName)) byMed.set(dose.medName, []); + byMed.get(dose.medName)!.push(dose); + } + const meds = Array.from(byMed.entries()).map(([medName, medDoses]) => ({ + medName, + total: medDoses.reduce((sum, d) => sum + d.usage, 0), + lastWhen: Math.max(...medDoses.map(d => d.when)), + doses: medDoses, + })); + grouped.push({ dateStr, date: new Date(dayDoses[0].when), meds }); + } + + return grouped; + }, [data, i18n.language]); + + if (loading) { + return ( +
+
+

💊 MedAssist

+

{t('common.loading')}

+
+
+ ); + } + + if (error || !data) { + return ( +
+
+

💊 MedAssist

+

{error || "Unknown error"}

+
+
+ ); + } + + return ( +
+
+
+

💊 {t('share.scheduleFor')} {data.takenBy}

+

+ {t('share.period')}: {data.scheduleDays === 30 ? t('dashboard.schedules.1month') : data.scheduleDays === 90 ? t('dashboard.schedules.3months') : t('dashboard.schedules.6months')} +

+
+ +
+ {schedule.length === 0 ? ( +

{t('share.noSchedule')}

+ ) : ( + schedule.map((day) => ( +
+
{day.dateStr}
+ {day.meds.map((item) => { + const med = data.medications.find(m => m.name === item.medName); + const allTaken = item.doses.every((d) => takenDoses.has(d.id)); + return ( +
+
+
+ med?.imageUrl && setLightboxImage({ url: med.imageUrl, name: med.name })} + > + + + {item.medName} + {med?.genericName && ({med.genericName})} +
+
+ {item.total} {t('common.pills')} {t('common.total')} +
+
+
+ {item.doses.map((dose) => { + const isTaken = takenDoses.has(dose.id); + const isOverdue = dose.when < Date.now() && !isTaken; + return ( +
+ {dose.timeStr} + + {dose.usage} {dose.usage !== 1 ? t('common.pills') : t('common.pill')} + {med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`} + + {isTaken ? ( + + ) : ( + + )} +
+ ); + })} +
+
+ ); + })} +
+ )) + )} +
+ +
+

{t('share.generatedBy')} MedAssist

+
+
+ + {/* Image Lightbox */} + {lightboxImage && ( +
setLightboxImage(null)}> + + {lightboxImage.name} e.stopPropagation()} + /> +
+ )} +
+ ); +} diff --git a/frontend/src/i18n/de.json b/frontend/src/i18n/de.json index 88377d6..88d19b8 100644 --- a/frontend/src/i18n/de.json +++ b/frontend/src/i18n/de.json @@ -275,5 +275,23 @@ "fullBlisters": "volle Blister", "inBlister": "in 1 Blister", "total": "gesamt" + }, + "share": { + "button": "Teilen", + "title": "Zeitplan teilen", + "description": "Generiere einen geheimen Link, um den Medikamentenplan für eine bestimmte Person zu teilen. Jeder mit diesem Link kann den Zeitplan sehen.", + "selectPerson": "Person auswählen", + "selectPeriod": "Zeitraum auswählen", + "generateLink": "Link generieren", + "generating": "Wird generiert...", + "generateAnother": "Weiteren Link generieren", + "linkGenerated": "Teilen-Link erstellt!", + "copyLink": "Link kopieren", + "copied": "In Zwischenablage kopiert!", + "noPeople": "Keine Medikamente mit 'Eingenommen von' zugewiesen. Füge zuerst eine Person zu einem Medikament hinzu.", + "scheduleFor": "Zeitplan für", + "period": "Zeitraum", + "noSchedule": "Keine geplanten Einnahmen gefunden.", + "generatedBy": "Erstellt von" } } diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index 5e6968f..1c8388c 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -277,5 +277,23 @@ "fullBlisters": "full blisters", "inBlister": "in 1 blister", "total": "total" + }, + "share": { + "button": "Share", + "title": "Share Schedule", + "description": "Generate a secret link to share the medication schedule for a specific person. Anyone with this link can view the schedule.", + "selectPerson": "Select person", + "selectPeriod": "Select time period", + "generateLink": "Generate Link", + "generating": "Generating...", + "generateAnother": "Generate another link", + "linkGenerated": "Share link generated!", + "copyLink": "Copy Link", + "copied": "Copied to clipboard!", + "noPeople": "No medications with 'Taken by' assigned. Add a person to a medication first.", + "scheduleFor": "Schedule for", + "period": "Period", + "noSchedule": "No scheduled doses found.", + "generatedBy": "Generated by" } } diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 2f92f3a..ba0a1e2 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -402,6 +402,8 @@ textarea { .time-row:last-child { border-bottom: none; padding-bottom: 0; } .time-main { display: flex; flex-direction: column; gap: 0.4rem; } .time-main .med-name { font-size: 1rem; font-weight: 600; color: var(--text-primary); margin: 0; } +.time-main .med-name span.clickable { cursor: pointer; } +.time-main .med-name span.clickable:hover .med-avatar { transform: scale(1.1); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); } .time-col { display: flex; align-items: center; justify-content: flex-start; } .time-chip { display: inline-flex; @@ -2401,3 +2403,216 @@ h3 .reminder-icon.info-tooltip { max-width: 480px; padding: 1.5rem; } + +/* ============================================================================= + Share Dialog + ============================================================================= */ +.share-dialog-modal { + max-width: 480px; + padding: 1.5rem; +} + +.share-dialog-header { + text-align: center; + margin-bottom: 1.5rem; +} + +.share-dialog-header h2 { + font-size: 1.25rem; + margin-bottom: 0.5rem; +} + +.share-dialog-description { + color: var(--text-secondary); + font-size: 0.875rem; + line-height: 1.5; +} + +.share-dialog-empty { + text-align: center; + padding: 2rem; + color: var(--text-secondary); +} + +.share-dialog-form .form-group { + margin-bottom: 1rem; +} + +.share-dialog-form label { + display: block; + font-size: 0.875rem; + font-weight: 500; + margin-bottom: 0.5rem; + color: var(--text-primary); +} + +.share-dialog-form select { + width: 100%; + padding: 0.75rem; + border: 1px solid var(--border-primary); + border-radius: 8px; + background: var(--bg-input); + color: var(--text-primary); + font-size: 1rem; +} + +.share-dialog-footer { + display: flex; + gap: 0.75rem; + margin-top: 1.5rem; + justify-content: flex-end; +} + +.share-dialog-result { + text-align: center; +} + +.share-success { + color: var(--success); + font-weight: 500; + margin-bottom: 1rem; +} + +.share-link-box { + display: flex; + gap: 0.5rem; + margin-bottom: 0.5rem; +} + +.share-link-input { + flex: 1; + padding: 0.75rem; + border: 1px solid var(--border-primary); + border-radius: 8px; + background: var(--bg-input); + color: var(--text-primary); + font-size: 0.875rem; + font-family: monospace; +} + +.btn-copy { + padding: 0.75rem 1rem; + border: 1px solid var(--border-primary); + border-radius: 8px; + background: var(--bg-tertiary); + color: var(--text-primary); + cursor: pointer; + transition: all 0.2s ease; +} + +.btn-copy:hover { + background: var(--accent-bg); + border-color: var(--accent); +} + +.share-copied-hint { + color: var(--success); + font-size: 0.875rem; +} + +.share-btn { + font-size: 0.875rem; + padding: 0.375rem 0.75rem; +} + +.card-head-actions { + display: flex; + gap: 0.75rem; + align-items: center; +} + +/* ============================================================================= + Shared Schedule Page (Public) + ============================================================================= */ +.shared-schedule-page { + min-height: 100vh; + background: var(--bg-gradient); + padding: 2rem; +} + +.shared-schedule-container { + max-width: 800px; + margin: 0 auto; +} + +.shared-schedule-loading, +.shared-schedule-error { + text-align: center; + padding: 4rem 2rem; +} + +.shared-schedule-loading h1, +.shared-schedule-error h1 { + font-size: 2rem; + margin-bottom: 1rem; +} + +.shared-schedule-error .error-message { + color: var(--danger); + font-size: 1.125rem; +} + +.shared-schedule-header { + text-align: center; + margin-bottom: 2rem; + padding-bottom: 1.5rem; + border-bottom: 1px solid var(--border-primary); +} + +.shared-schedule-header h1 { + font-size: 1.75rem; + margin-bottom: 0.5rem; +} + +.shared-schedule-period { + color: var(--text-secondary); + font-size: 1rem; +} + +.shared-timeline { + background: var(--bg-secondary); + border-radius: 12px; + padding: 1.5rem; + border: 1px solid var(--border-primary); +} + +.shared-schedule-empty { + text-align: center; + padding: 2rem; + color: var(--text-secondary); +} + +.shared-dose { + background: transparent; + border: none; + padding: 0.5rem 0; +} + +.med-generic-inline { + font-size: 0.875rem; + color: var(--text-secondary); + margin-left: 0.5rem; +} + +.shared-schedule-footer { + text-align: center; + margin-top: 2rem; + padding-top: 1.5rem; + border-top: 1px solid var(--border-primary); + color: var(--text-secondary); + font-size: 0.875rem; +} + +@media (max-width: 600px) { + .shared-schedule-page { + padding: 1rem; + } + + .shared-schedule-header h1 { + font-size: 1.25rem; + } + + .shared-timeline { + padding: 1rem; + } +}