feat(share): implement share functionality for medication schedules with token-based access
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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`),
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<typeof createShareSchema> }>(
|
||||
"/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 };
|
||||
}
|
||||
);
|
||||
}
|
||||
+460
-16
@@ -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 (
|
||||
<AuthProvider>
|
||||
<AppRouter />
|
||||
<Routes>
|
||||
{/* Public share route - accessible without auth */}
|
||||
<Route path="/share/:token" element={<SharedSchedule />} />
|
||||
{/* All other routes go through AppRouter */}
|
||||
<Route path="*" element={<AppRouter />} />
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
@@ -221,6 +226,14 @@ function AppContent() {
|
||||
const [selectedUser, setSelectedUser] = useState<string | null>(null);
|
||||
const [scheduleDays, setScheduleDays] = useState<number>(30);
|
||||
const [takenDoses, setTakenDoses] = useState<Set<string>>(new Set());
|
||||
// Share dialog state
|
||||
const [showShareDialog, setShowShareDialog] = useState(false);
|
||||
const [sharePeople, setSharePeople] = useState<string[]>([]);
|
||||
const [shareSelectedPerson, setShareSelectedPerson] = useState<string>("");
|
||||
const [shareSelectedDays, setShareSelectedDays] = useState<number>(30);
|
||||
const [shareGenerating, setShareGenerating] = useState(false);
|
||||
const [shareLink, setShareLink] = useState<string | null>(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() {
|
||||
<section className="grid">
|
||||
<article className="card">
|
||||
<div className="card-head">
|
||||
<h2 className="clickable" onClick={() => navigate("/schedule")}>{t('dashboard.schedules.title')}</h2>
|
||||
<select
|
||||
className="schedule-days-select"
|
||||
value={scheduleDays}
|
||||
onChange={(e) => {
|
||||
const val = Number(e.target.value);
|
||||
setScheduleDays(val);
|
||||
if (user?.id) localStorage.setItem(userStorageKey(user.id, "scheduleDays"), String(val));
|
||||
}}
|
||||
>
|
||||
<option value={30}>{t('dashboard.schedules.1month')}</option>
|
||||
<option value={90}>{t('dashboard.schedules.3months')}</option>
|
||||
<option value={180}>{t('dashboard.schedules.6months')}</option>
|
||||
</select>
|
||||
<h2>{t('dashboard.schedules.title')}</h2>
|
||||
<div className="card-head-actions">
|
||||
{meds.some(m => m.takenBy) && (
|
||||
<button className="ghost share-btn" onClick={openShareDialog} title={t('share.button')}>
|
||||
🔗 {t('share.button')}
|
||||
</button>
|
||||
)}
|
||||
<select
|
||||
className="schedule-days-select"
|
||||
value={scheduleDays}
|
||||
onChange={(e) => {
|
||||
const val = Number(e.target.value);
|
||||
setScheduleDays(val);
|
||||
if (user?.id) localStorage.setItem(userStorageKey(user.id, "scheduleDays"), String(val));
|
||||
}}
|
||||
>
|
||||
<option value={30}>{t('dashboard.schedules.1month')}</option>
|
||||
<option value={90}>{t('dashboard.schedules.3months')}</option>
|
||||
<option value={180}>{t('dashboard.schedules.6months')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="timeline">
|
||||
{groupedSchedule.map((day) => (
|
||||
@@ -1713,6 +1793,82 @@ function AppContent() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Share Dialog Modal */}
|
||||
{showShareDialog && (
|
||||
<div className="modal-overlay" onClick={closeShareDialog}>
|
||||
<div className="modal-content share-dialog-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<button className="modal-close" onClick={closeShareDialog}>×</button>
|
||||
|
||||
<div className="share-dialog-header">
|
||||
<h2>🔗 {t('share.title')}</h2>
|
||||
<p className="share-dialog-description">{t('share.description')}</p>
|
||||
</div>
|
||||
|
||||
{sharePeople.length === 0 ? (
|
||||
<div className="share-dialog-empty">
|
||||
<p>{t('share.noPeople')}</p>
|
||||
</div>
|
||||
) : shareLink ? (
|
||||
<div className="share-dialog-result">
|
||||
<p className="share-success">{t('share.linkGenerated')}</p>
|
||||
<div className="share-link-box">
|
||||
<input
|
||||
type="text"
|
||||
value={shareLink}
|
||||
readOnly
|
||||
className="share-link-input"
|
||||
onClick={(e) => (e.target as HTMLInputElement).select()}
|
||||
/>
|
||||
<button className="btn-copy" onClick={copyShareLink}>
|
||||
{shareCopied ? "✓" : "📋"}
|
||||
</button>
|
||||
</div>
|
||||
{shareCopied && <span className="share-copied-hint">{t('share.copied')}</span>}
|
||||
<div className="share-dialog-footer">
|
||||
<button className="ghost" onClick={() => { setShareLink(null); setShareCopied(false); }}>
|
||||
{t('share.generateAnother')}
|
||||
</button>
|
||||
<button onClick={closeShareDialog}>{t('common.close')}</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="share-dialog-form">
|
||||
<div className="form-group">
|
||||
<label>{t('share.selectPerson')}</label>
|
||||
<select
|
||||
value={shareSelectedPerson}
|
||||
onChange={(e) => setShareSelectedPerson(e.target.value)}
|
||||
>
|
||||
{sharePeople.map((person) => (
|
||||
<option key={person} value={person}>{person}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>{t('share.selectPeriod')}</label>
|
||||
<select
|
||||
value={shareSelectedDays}
|
||||
onChange={(e) => setShareSelectedDays(Number(e.target.value))}
|
||||
>
|
||||
<option value={30}>{t('dashboard.schedules.1month')}</option>
|
||||
<option value={90}>{t('dashboard.schedules.3months')}</option>
|
||||
<option value={180}>{t('dashboard.schedules.6months')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="share-dialog-footer">
|
||||
<button className="ghost" onClick={closeShareDialog}>{t('common.cancel')}</button>
|
||||
<button onClick={generateShareLink} disabled={shareGenerating || !shareSelectedPerson}>
|
||||
{shareGenerating ? t('share.generating') : t('share.generateLink')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -2108,3 +2264,291 @@ function MedicationAvatar({ name, imageUrl, size = "sm" }: { name: string; image
|
||||
}
|
||||
return <div className={`${sizeClass} med-avatar-initials`}>{initials}</div>;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 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<SharedScheduleData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [takenDoses, setTakenDoses] = useState<Set<string>>(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<string, typeof doses>();
|
||||
|
||||
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<string, typeof doses>();
|
||||
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 (
|
||||
<div className="shared-schedule-page">
|
||||
<div className="shared-schedule-loading">
|
||||
<h1>💊 MedAssist</h1>
|
||||
<p>{t('common.loading')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="shared-schedule-page">
|
||||
<div className="shared-schedule-error">
|
||||
<h1>💊 MedAssist</h1>
|
||||
<p className="error-message">{error || "Unknown error"}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="shared-schedule-page">
|
||||
<div className="shared-schedule-container">
|
||||
<header className="shared-schedule-header">
|
||||
<h1>💊 {t('share.scheduleFor')} {data.takenBy}</h1>
|
||||
<p className="shared-schedule-period">
|
||||
{t('share.period')}: {data.scheduleDays === 30 ? t('dashboard.schedules.1month') : data.scheduleDays === 90 ? t('dashboard.schedules.3months') : t('dashboard.schedules.6months')}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="timeline">
|
||||
{schedule.length === 0 ? (
|
||||
<p className="shared-schedule-empty">{t('share.noSchedule')}</p>
|
||||
) : (
|
||||
schedule.map((day) => (
|
||||
<div key={day.dateStr} className="day-block">
|
||||
<div className="day-divider">{day.dateStr}</div>
|
||||
{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 (
|
||||
<div key={`${day.dateStr}-${item.medName}`} className={`time-row ${allTaken ? "taken" : ""}`}>
|
||||
<div className="time-main">
|
||||
<div className="med-name">
|
||||
<span
|
||||
className={med?.imageUrl ? 'clickable' : ''}
|
||||
onClick={() => med?.imageUrl && setLightboxImage({ url: med.imageUrl, name: med.name })}
|
||||
>
|
||||
<MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" />
|
||||
</span>
|
||||
<span className="med-name-text">{item.medName}</span>
|
||||
{med?.genericName && <span className="med-generic-inline">({med.genericName})</span>}
|
||||
</div>
|
||||
<div className="tag-row">
|
||||
<span className="tag subtle">{item.total} {t('common.pills')} {t('common.total')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="doses-col">
|
||||
{item.doses.map((dose) => {
|
||||
const isTaken = takenDoses.has(dose.id);
|
||||
const isOverdue = dose.when < Date.now() && !isTaken;
|
||||
return (
|
||||
<div key={dose.id} className={`dose-item ${isTaken ? "taken" : ""} ${isOverdue ? "overdue" : ""}`}>
|
||||
<span className="dose-time">{dose.timeStr}</span>
|
||||
<span className="dose-usage">
|
||||
{dose.usage} {dose.usage !== 1 ? t('common.pills') : t('common.pill')}
|
||||
{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`}
|
||||
</span>
|
||||
{isTaken ? (
|
||||
<button className="dose-btn undo" onClick={() => undoDoseTaken(dose.id)} title={t('common.undo')}>↩</button>
|
||||
) : (
|
||||
<button className="dose-btn take" onClick={() => markDoseTaken(dose.id)} title={t('dose.markAsTaken')}>✓</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer className="shared-schedule-footer">
|
||||
<p>{t('share.generatedBy')} MedAssist</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
{/* Image Lightbox */}
|
||||
{lightboxImage && (
|
||||
<div className="lightbox-overlay" onClick={() => setLightboxImage(null)}>
|
||||
<button className="lightbox-close" onClick={() => setLightboxImage(null)}>×</button>
|
||||
<img
|
||||
src={`/api/images/${lightboxImage.url}`}
|
||||
alt={lightboxImage.name}
|
||||
className="lightbox-image"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user