Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 055c0dfe10 | |||
| 318f63657b |
@@ -62,7 +62,7 @@
|
|||||||
|
|
||||||
### Notifications
|
### Notifications
|
||||||
- Email via SMTP
|
- Email via SMTP
|
||||||
- Push notifications via ntfy, Gotify, Telegram, Discord (Shoutrrr)
|
- Push notifications via ntfy, Pushover, Gotify, Telegram, Discord & more ([Shoutrrr](https://containrrr.dev/shoutrrr/))
|
||||||
- Supports both stock warnings and intake reminders
|
- Supports both stock warnings and intake reminders
|
||||||
|
|
||||||
### Privacy & Security
|
### Privacy & Security
|
||||||
@@ -148,6 +148,54 @@ Generate secrets with: `openssl rand -hex 32`
|
|||||||
| `REMINDER_MINUTES_BEFORE` | `15` | Minutes before intake to send reminder |
|
| `REMINDER_MINUTES_BEFORE` | `15` | Minutes before intake to send reminder |
|
||||||
| `EXPIRY_WARNING_DAYS` | `30` | Days before expiry to show warning |
|
| `EXPIRY_WARNING_DAYS` | `30` | Days before expiry to show warning |
|
||||||
|
|
||||||
|
### Push Notifications (Shoutrrr)
|
||||||
|
|
||||||
|
MedAssist uses [Shoutrrr](https://containrrr.dev/shoutrrr/) for push notifications, supporting many services with a single URL format.
|
||||||
|
|
||||||
|
**Supported services:** ntfy, Pushover, Gotify, Discord, Telegram, Slack, Matrix, and [many more](https://containrrr.dev/shoutrrr/v0.8/services/overview/).
|
||||||
|
|
||||||
|
Configure push notifications in Settings → Push, or set defaults via environment variables:
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `DEFAULT_SHOUTRRR_ENABLED` | `false` | Enable push notifications by default |
|
||||||
|
| `DEFAULT_SHOUTRRR_URL` | — | Shoutrrr URL (see examples below) |
|
||||||
|
| `DEFAULT_SHOUTRRR_STOCK_REMINDERS` | `true` | Send stock warnings via push |
|
||||||
|
| `DEFAULT_SHOUTRRR_INTAKE_REMINDERS` | `true` | Send intake reminders via push |
|
||||||
|
|
||||||
|
#### URL Examples
|
||||||
|
|
||||||
|
**ntfy** (free, self-hostable):
|
||||||
|
```
|
||||||
|
ntfy://ntfy.sh/your-topic
|
||||||
|
ntfy://user:password@your-server.com/topic
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pushover** (free app for iOS/Android):
|
||||||
|
```
|
||||||
|
pushover://shoutrrr:API_TOKEN@USER_KEY/
|
||||||
|
```
|
||||||
|
Get your keys at [pushover.net](https://pushover.net/):
|
||||||
|
- **User Key**: Shown on your dashboard (top right)
|
||||||
|
- **API Token**: Create an application → copy the API Token
|
||||||
|
|
||||||
|
**Gotify** (self-hosted):
|
||||||
|
```
|
||||||
|
gotify://your-server.com/TOKEN
|
||||||
|
```
|
||||||
|
|
||||||
|
**Discord**:
|
||||||
|
```
|
||||||
|
discord://TOKEN@WEBHOOK_ID
|
||||||
|
```
|
||||||
|
|
||||||
|
**Telegram**:
|
||||||
|
```
|
||||||
|
telegram://TOKEN@telegram?chats=CHAT_ID
|
||||||
|
```
|
||||||
|
|
||||||
|
For all services and options, see the [Shoutrrr documentation](https://containrrr.dev/shoutrrr/v0.8/services/overview/).
|
||||||
|
|
||||||
# Development
|
# Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ export async function runTableMigrations(client: Client): Promise<{ success: boo
|
|||||||
`ALTER TABLE user_settings ADD COLUMN repeat_reminders_enabled integer NOT NULL DEFAULT 0`,
|
`ALTER TABLE user_settings ADD COLUMN repeat_reminders_enabled integer NOT NULL DEFAULT 0`,
|
||||||
`ALTER TABLE user_settings ADD COLUMN reminder_repeat_interval_minutes integer NOT NULL DEFAULT 30`,
|
`ALTER TABLE user_settings ADD COLUMN reminder_repeat_interval_minutes integer NOT NULL DEFAULT 30`,
|
||||||
`ALTER TABLE user_settings ADD COLUMN max_nagging_reminders integer NOT NULL DEFAULT 5`,
|
`ALTER TABLE user_settings ADD COLUMN max_nagging_reminders integer NOT NULL DEFAULT 5`,
|
||||||
|
// Added in v1.2.3 - dismiss missed doses without deducting stock
|
||||||
|
`ALTER TABLE dose_tracking ADD COLUMN dismissed integer NOT NULL DEFAULT 0`,
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const sql of alterMigrations) {
|
for (const sql of alterMigrations) {
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ export function getTableCreationSQL(): string[] {
|
|||||||
dose_id text NOT NULL,
|
dose_id text NOT NULL,
|
||||||
taken_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
taken_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||||
marked_by text,
|
marked_by text,
|
||||||
|
dismissed integer NOT NULL DEFAULT 0,
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
)`,
|
)`,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -115,4 +115,5 @@ export const doseTracking = sqliteTable("dose_tracking", {
|
|||||||
doseId: text("dose_id", { length: 255 }).notNull(), // e.g. "med-5-1-86400000-1735200000000"
|
doseId: text("dose_id", { length: 255 }).notNull(), // e.g. "med-5-1-86400000-1735200000000"
|
||||||
takenAt: integer("taken_at", { mode: "timestamp" }).notNull().default(sql`(strftime('%s','now'))`),
|
takenAt: integer("taken_at", { mode: "timestamp" }).notNull().default(sql`(strftime('%s','now'))`),
|
||||||
markedBy: text("marked_by", { length: 100 }), // null = user, "Daniel" = via share link
|
markedBy: text("marked_by", { length: 100 }), // null = user, "Daniel" = via share link
|
||||||
|
dismissed: integer("dismissed", { mode: "boolean" }).notNull().default(false), // true = missed dose acknowledged without taking
|
||||||
});
|
});
|
||||||
|
|||||||
+104
-1
@@ -2,7 +2,7 @@ import { FastifyInstance } from "fastify";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "../db/client.js";
|
import { db } from "../db/client.js";
|
||||||
import { doseTracking, shareTokens } from "../db/schema.js";
|
import { doseTracking, shareTokens } from "../db/schema.js";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and, inArray } from "drizzle-orm";
|
||||||
import { requireAuth, getAnonymousUserId } from "../plugins/auth.js";
|
import { requireAuth, getAnonymousUserId } from "../plugins/auth.js";
|
||||||
import { env } from "../plugins/env.js";
|
import { env } from "../plugins/env.js";
|
||||||
import type { AuthUser } from "../types/fastify.js";
|
import type { AuthUser } from "../types/fastify.js";
|
||||||
@@ -18,6 +18,10 @@ const shareDoseSchema = z.object({
|
|||||||
doseId: z.string().min(1, "doseId is required"),
|
doseId: z.string().min(1, "doseId is required"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const dismissDosesSchema = z.object({
|
||||||
|
doseIds: z.array(z.string().min(1)).min(1, "At least one doseId is required"),
|
||||||
|
});
|
||||||
|
|
||||||
// Helper to get user ID from request
|
// Helper to get user ID from request
|
||||||
// Returns anonymous user ID when auth is disabled
|
// Returns anonymous user ID when auth is disabled
|
||||||
async function getUserId(request: any, reply: any): Promise<number> {
|
async function getUserId(request: any, reply: any): Promise<number> {
|
||||||
@@ -57,6 +61,7 @@ export async function doseRoutes(app: FastifyInstance) {
|
|||||||
doseId: d.doseId,
|
doseId: d.doseId,
|
||||||
takenAt: d.takenAt?.getTime() ?? Date.now(),
|
takenAt: d.takenAt?.getTime() ?? Date.now(),
|
||||||
markedBy: d.markedBy,
|
markedBy: d.markedBy,
|
||||||
|
dismissed: d.dismissed ?? false,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -127,6 +132,103 @@ export async function doseRoutes(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// POST /doses/dismiss - PROTECTED: Dismiss missed doses without deducting stock
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
app.post<{ Body: z.infer<typeof dismissDosesSchema> }>(
|
||||||
|
"/doses/dismiss",
|
||||||
|
{ preHandler: requireAuth },
|
||||||
|
async (request, reply) => {
|
||||||
|
const userId = await getUserId(request, reply);
|
||||||
|
|
||||||
|
const parsed = dismissDosesSchema.safeParse(request.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
error: parsed.error.errors[0]?.message ?? "Invalid input",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { doseIds } = parsed.data;
|
||||||
|
|
||||||
|
// Insert dismissed records for each dose that doesn't exist yet
|
||||||
|
let dismissedCount = 0;
|
||||||
|
for (const doseId of doseIds) {
|
||||||
|
// Check if already exists (taken or dismissed)
|
||||||
|
const [existing] = await db.select()
|
||||||
|
.from(doseTracking)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(doseTracking.userId, userId),
|
||||||
|
eq(doseTracking.doseId, doseId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
// Already exists - update to dismissed if not already
|
||||||
|
if (!existing.dismissed) {
|
||||||
|
await db.update(doseTracking)
|
||||||
|
.set({ dismissed: true })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(doseTracking.userId, userId),
|
||||||
|
eq(doseTracking.doseId, doseId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
dismissedCount++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Create new dismissed record
|
||||||
|
await db.insert(doseTracking).values({
|
||||||
|
userId,
|
||||||
|
doseId,
|
||||||
|
markedBy: null,
|
||||||
|
dismissed: true,
|
||||||
|
});
|
||||||
|
dismissedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, dismissedCount };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// DELETE /doses/dismiss - PROTECTED: Clear all dismissed doses (un-dismiss)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
app.delete(
|
||||||
|
"/doses/dismiss",
|
||||||
|
{ preHandler: requireAuth },
|
||||||
|
async (request, reply) => {
|
||||||
|
const userId = await getUserId(request, reply);
|
||||||
|
|
||||||
|
// Delete all dismissed-only records (not taken ones)
|
||||||
|
// For taken+dismissed, just remove the dismissed flag
|
||||||
|
const dismissed = await db.select()
|
||||||
|
.from(doseTracking)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(doseTracking.userId, userId),
|
||||||
|
eq(doseTracking.dismissed, true)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const d of dismissed) {
|
||||||
|
if (d.markedBy !== null || d.takenAt) {
|
||||||
|
// This was also marked as taken - just remove dismissed flag
|
||||||
|
await db.update(doseTracking)
|
||||||
|
.set({ dismissed: false })
|
||||||
|
.where(eq(doseTracking.id, d.id));
|
||||||
|
} else {
|
||||||
|
// This was only dismissed - delete it
|
||||||
|
await db.delete(doseTracking)
|
||||||
|
.where(eq(doseTracking.id, d.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, clearedCount: dismissed.length };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// GET /share/:token/doses - PUBLIC: Get taken doses for a share link
|
// GET /share/:token/doses - PUBLIC: Get taken doses for a share link
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -151,6 +253,7 @@ export async function doseRoutes(app: FastifyInstance) {
|
|||||||
doseId: d.doseId,
|
doseId: d.doseId,
|
||||||
takenAt: d.takenAt?.getTime() ?? Date.now(),
|
takenAt: d.takenAt?.getTime() ?? Date.now(),
|
||||||
markedBy: d.markedBy,
|
markedBy: d.markedBy,
|
||||||
|
dismissed: d.dismissed ?? false,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,6 +80,45 @@ async function registerDoseRoutes(ctx: TestContext) {
|
|||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// POST /doses/dismiss - Dismiss missed doses without deducting stock
|
||||||
|
app.post<{ Body: { doseIds: string[] } }>("/doses/dismiss", async (request, reply) => {
|
||||||
|
const userId = 1;
|
||||||
|
const { doseIds } = request.body || {};
|
||||||
|
|
||||||
|
if (!doseIds || !Array.isArray(doseIds) || doseIds.length === 0) {
|
||||||
|
return reply.status(400).send({ error: "doseIds array is required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
let dismissedCount = 0;
|
||||||
|
for (const doseId of doseIds) {
|
||||||
|
// Check if already exists
|
||||||
|
const existing = await client.execute({
|
||||||
|
sql: `SELECT id, dismissed FROM dose_tracking WHERE user_id = ? AND dose_id = ?`,
|
||||||
|
args: [userId, doseId],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing.rows.length > 0) {
|
||||||
|
// Update to dismissed if not already
|
||||||
|
if (!existing.rows[0].dismissed) {
|
||||||
|
await client.execute({
|
||||||
|
sql: `UPDATE dose_tracking SET dismissed = 1 WHERE id = ?`,
|
||||||
|
args: [existing.rows[0].id],
|
||||||
|
});
|
||||||
|
dismissedCount++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Insert new dismissed record
|
||||||
|
await client.execute({
|
||||||
|
sql: `INSERT INTO dose_tracking (user_id, dose_id, dismissed) VALUES (?, ?, 1)`,
|
||||||
|
args: [userId, doseId],
|
||||||
|
});
|
||||||
|
dismissedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, dismissedCount };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -361,4 +400,101 @@ describe("Dose Tracking API", () => {
|
|||||||
expect(getResponse.json().doses[0].doseId).toBe(doseId);
|
expect(getResponse.json().doses[0].doseId).toBe(doseId);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Dismiss Doses Tests (POST /doses/dismiss)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("POST /doses/dismiss", () => {
|
||||||
|
it("should dismiss multiple doses", async () => {
|
||||||
|
const doseIds = ["1-0-1735344000000", "1-0-1735430400000"];
|
||||||
|
|
||||||
|
const response = await ctx.app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/doses/dismiss",
|
||||||
|
payload: { doseIds },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json()).toEqual({ success: true, dismissedCount: 2 });
|
||||||
|
|
||||||
|
// Verify in database
|
||||||
|
const result = await ctx.client.execute({
|
||||||
|
sql: `SELECT dose_id, dismissed FROM dose_tracking WHERE user_id = ? AND dismissed = 1`,
|
||||||
|
args: [userId],
|
||||||
|
});
|
||||||
|
expect(result.rows.length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not double-count already dismissed doses", async () => {
|
||||||
|
const doseId = "1-0-1735344000000";
|
||||||
|
|
||||||
|
// Dismiss once
|
||||||
|
await ctx.app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/doses/dismiss",
|
||||||
|
payload: { doseIds: [doseId] },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dismiss again
|
||||||
|
const response = await ctx.app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/doses/dismiss",
|
||||||
|
payload: { doseIds: [doseId] },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json()).toEqual({ success: true, dismissedCount: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject empty doseIds array", async () => {
|
||||||
|
const response = await ctx.app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/doses/dismiss",
|
||||||
|
payload: { doseIds: [] },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
expect(response.json()).toEqual({ error: "doseIds array is required" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject missing doseIds", async () => {
|
||||||
|
const response = await ctx.app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/doses/dismiss",
|
||||||
|
payload: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
expect(response.json()).toEqual({ error: "doseIds array is required" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should dismiss a dose that was already taken (convert to dismissed)", async () => {
|
||||||
|
const doseId = "1-0-1735344000000";
|
||||||
|
|
||||||
|
// First mark as taken
|
||||||
|
await ctx.app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/doses/taken",
|
||||||
|
payload: { doseId },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Then dismiss it
|
||||||
|
const response = await ctx.app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/doses/dismiss",
|
||||||
|
payload: { doseIds: [doseId] },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json()).toEqual({ success: true, dismissedCount: 1 });
|
||||||
|
|
||||||
|
// Verify it's now dismissed
|
||||||
|
const result = await ctx.client.execute({
|
||||||
|
sql: `SELECT dismissed FROM dose_tracking WHERE user_id = ? AND dose_id = ?`,
|
||||||
|
args: [userId, doseId],
|
||||||
|
});
|
||||||
|
expect(result.rows[0].dismissed).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -139,6 +139,7 @@ async function createSchema(client: Client) {
|
|||||||
dose_id text NOT NULL,
|
dose_id text NOT NULL,
|
||||||
taken_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
taken_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||||
marked_by text,
|
marked_by text,
|
||||||
|
dismissed integer NOT NULL DEFAULT 0,
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
)`,
|
)`,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -136,6 +136,7 @@ async function createSchema(client: Client) {
|
|||||||
dose_id text NOT NULL,
|
dose_id text NOT NULL,
|
||||||
taken_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
taken_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||||
marked_by text,
|
marked_by text,
|
||||||
|
dismissed integer NOT NULL DEFAULT 0,
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
)`,
|
)`,
|
||||||
];
|
];
|
||||||
|
|||||||
+122
-21
@@ -341,6 +341,10 @@ function AppContent() {
|
|||||||
const [scheduleDays, setScheduleDays] = useState<number>(30);
|
const [scheduleDays, setScheduleDays] = useState<number>(30);
|
||||||
const [showPastDays, setShowPastDays] = useState(false);
|
const [showPastDays, setShowPastDays] = useState(false);
|
||||||
const [takenDoses, setTakenDoses] = useState<Set<string>>(new Set());
|
const [takenDoses, setTakenDoses] = useState<Set<string>>(new Set());
|
||||||
|
const [dismissedDoses, setDismissedDoses] = useState<Set<string>>(new Set());
|
||||||
|
// Clear missed doses confirmation dialog
|
||||||
|
const [showClearMissedConfirm, setShowClearMissedConfirm] = useState(false);
|
||||||
|
const [clearingMissed, setClearingMissed] = useState(false);
|
||||||
// Tag input state for "Taken By" field
|
// Tag input state for "Taken By" field
|
||||||
const [takenByInput, setTakenByInput] = useState("");
|
const [takenByInput, setTakenByInput] = useState("");
|
||||||
// Share dialog state
|
// Share dialog state
|
||||||
@@ -384,7 +388,17 @@ function AppContent() {
|
|||||||
const res = await fetch("/api/doses/taken", { credentials: "include" });
|
const res = await fetch("/api/doses/taken", { credentials: "include" });
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setTakenDoses(new Set(data.doses.map((d: { doseId: string }) => d.doseId)));
|
const taken = new Set<string>();
|
||||||
|
const dismissed = new Set<string>();
|
||||||
|
for (const d of data.doses) {
|
||||||
|
if (d.dismissed) {
|
||||||
|
dismissed.add(d.doseId);
|
||||||
|
} else {
|
||||||
|
taken.add(d.doseId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setTakenDoses(taken);
|
||||||
|
setDismissedDoses(dismissed);
|
||||||
}
|
}
|
||||||
// Don't reset on error - keep current state
|
// Don't reset on error - keep current state
|
||||||
} catch {
|
} catch {
|
||||||
@@ -467,6 +481,35 @@ function AppContent() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dismiss missed doses without deducting from stock
|
||||||
|
async function dismissMissedDoses(doseIds: string[]) {
|
||||||
|
if (doseIds.length === 0) return;
|
||||||
|
|
||||||
|
setClearingMissed(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/doses/dismiss", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ doseIds }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
// Update local state - move these from neither set to dismissed set
|
||||||
|
setDismissedDoses((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
for (const id of doseIds) next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setShowClearMissedConfirm(false);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Error - dialog stays open
|
||||||
|
} finally {
|
||||||
|
setClearingMissed(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Close modal on Escape key
|
// Close modal on Escape key
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleEscape = (e: KeyboardEvent) => {
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
@@ -580,6 +623,20 @@ function AppContent() {
|
|||||||
const pastDays = useMemo(() => groupedSchedule.filter(d => d.isPast), [groupedSchedule]);
|
const pastDays = useMemo(() => groupedSchedule.filter(d => d.isPast), [groupedSchedule]);
|
||||||
const futureDays = useMemo(() => groupedSchedule.filter(d => !d.isPast).slice(0, scheduleDays), [groupedSchedule, scheduleDays]);
|
const futureDays = useMemo(() => groupedSchedule.filter(d => !d.isPast).slice(0, scheduleDays), [groupedSchedule, scheduleDays]);
|
||||||
|
|
||||||
|
// Calculate missed past dose IDs for the "Clear missed" feature
|
||||||
|
const missedPastDoseIds = useMemo(() => {
|
||||||
|
const totalPastDoses = pastDays.flatMap(d =>
|
||||||
|
d.meds.flatMap(m =>
|
||||||
|
m.doses.flatMap(dose =>
|
||||||
|
(dose.takenBy || []).length > 0
|
||||||
|
? dose.takenBy.map((p: string) => `${dose.id}-${p}`)
|
||||||
|
: [dose.id]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return totalPastDoses.filter(id => !takenDoses.has(id) && !dismissedDoses.has(id));
|
||||||
|
}, [pastDays, takenDoses, dismissedDoses]);
|
||||||
|
|
||||||
// Load medications and settings when user changes (or on initial mount)
|
// Load medications and settings when user changes (or on initial mount)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadMeds();
|
loadMeds();
|
||||||
@@ -1467,31 +1524,46 @@ function AppContent() {
|
|||||||
<div className="timeline">
|
<div className="timeline">
|
||||||
{/* Past days toggle */}
|
{/* Past days toggle */}
|
||||||
{pastDays.length > 0 && (() => {
|
{pastDays.length > 0 && (() => {
|
||||||
|
const missedCount = missedPastDoseIds.length;
|
||||||
const totalPastDoses = pastDays.flatMap(d => d.meds.flatMap(m => m.doses.flatMap(dose => (dose.takenBy || []).length > 0 ? dose.takenBy.map(p => `${dose.id}-${p}`) : [dose.id])));
|
const totalPastDoses = pastDays.flatMap(d => d.meds.flatMap(m => m.doses.flatMap(dose => (dose.takenBy || []).length > 0 ? dose.takenBy.map(p => `${dose.id}-${p}`) : [dose.id])));
|
||||||
const missedPastDoses = totalPastDoses.filter(id => !takenDoses.has(id)).length;
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="past-days-header">
|
||||||
className={`past-days-toggle ${showPastDays ? 'expanded' : ''} ${missedPastDoses > 0 ? 'has-missed' : ''}`}
|
<div
|
||||||
onClick={() => setShowPastDays(!showPastDays)}
|
className={`past-days-toggle ${showPastDays ? 'expanded' : ''} ${missedCount > 0 ? 'has-missed' : ''}`}
|
||||||
>
|
onClick={() => setShowPastDays(!showPastDays)}
|
||||||
<span className="past-days-icon">{showPastDays ? '▼' : '▶'}</span>
|
>
|
||||||
<span className="past-days-label">
|
<span className="past-days-icon">{showPastDays ? '▼' : '▶'}</span>
|
||||||
{showPastDays ? t('dashboard.schedules.hidePastDays') : t('dashboard.schedules.showPastDays')}
|
<span className="past-days-label">
|
||||||
</span>
|
{showPastDays ? t('dashboard.schedules.hidePastDays') : t('dashboard.schedules.showPastDays')}
|
||||||
<span className="past-days-count">({t('dashboard.schedules.pastDaysCount', { count: pastDays.length })})</span>
|
</span>
|
||||||
{missedPastDoses > 0 ? (
|
<span className="past-days-count">({t('dashboard.schedules.pastDaysCount', { count: pastDays.length })})</span>
|
||||||
<span className="past-days-warning" title={t('dashboard.schedules.missedDoses', { count: missedPastDoses })}>⚠️ {missedPastDoses}</span>
|
{missedCount > 0 ? (
|
||||||
) : totalPastDoses.length > 0 ? (
|
<span className="past-days-warning" title={t('dashboard.schedules.missedDoses', { count: missedCount })}>⚠️ {missedCount}</span>
|
||||||
<span className="past-days-complete" title={t('dashboard.schedules.allTaken')}>✓</span>
|
) : totalPastDoses.length > 0 ? (
|
||||||
) : null}
|
<span className="past-days-complete" title={t('dashboard.schedules.allTaken')}>✓</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{missedCount > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="clear-missed-btn"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowClearMissedConfirm(true);
|
||||||
|
}}
|
||||||
|
title={t('dashboard.schedules.clearMissed')}
|
||||||
|
>
|
||||||
|
{t('dashboard.schedules.clearMissed')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
{/* Past days (when expanded) */}
|
{/* Past days (when expanded) */}
|
||||||
{showPastDays && pastDays.map((day) => {
|
{showPastDays && pastDays.map((day) => {
|
||||||
const allDoseIds = day.meds.flatMap((item) => item.doses.flatMap((d) => (d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]));
|
const allDoseIds = day.meds.flatMap((item) => item.doses.flatMap((d) => (d.takenBy || []).length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]));
|
||||||
const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id));
|
const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id) || dismissedDoses.has(id));
|
||||||
const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length;
|
const takenCount = allDoseIds.filter((id) => takenDoses.has(id) || dismissedDoses.has(id)).length;
|
||||||
const isAutoCollapsed = true; // Past days are always auto-collapsed
|
const isAutoCollapsed = true; // Past days are always auto-collapsed
|
||||||
const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr);
|
const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr);
|
||||||
const isCollapsed = !isManuallyExpanded;
|
const isCollapsed = !isManuallyExpanded;
|
||||||
@@ -1679,6 +1751,35 @@ function AppContent() {
|
|||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Clear Missed Doses Confirmation Modal */}
|
||||||
|
{showClearMissedConfirm && (
|
||||||
|
<div className="modal-overlay" onClick={() => setShowClearMissedConfirm(false)}>
|
||||||
|
<div className="modal-content" onClick={(e) => e.stopPropagation()} style={{maxWidth: "450px"}}>
|
||||||
|
<button className="modal-close" onClick={() => setShowClearMissedConfirm(false)}>×</button>
|
||||||
|
<h2 style={{marginBottom: "16px", paddingRight: "2rem"}}>{t('dashboard.schedules.clearMissedConfirmTitle')}</h2>
|
||||||
|
<p style={{marginBottom: "24px"}}>{t('dashboard.schedules.clearMissedConfirmMessage', { count: missedPastDoseIds.length })}</p>
|
||||||
|
<div className="modal-footer" style={{padding: "1rem 0 0 0", borderTop: "none", justifyContent: "flex-end"}}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost"
|
||||||
|
onClick={() => setShowClearMissedConfirm(false)}
|
||||||
|
disabled={clearingMissed}
|
||||||
|
>
|
||||||
|
{t('dashboard.schedules.clearMissedCancel')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="primary"
|
||||||
|
onClick={() => dismissMissedDoses(missedPastDoseIds)}
|
||||||
|
disabled={clearingMissed}
|
||||||
|
>
|
||||||
|
{clearingMissed ? t('common.loading') : t('dashboard.schedules.clearMissedConfirm')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
} />
|
} />
|
||||||
|
|
||||||
@@ -2243,12 +2344,12 @@ function AppContent() {
|
|||||||
<span className="field-label">{t('settings.push.url')}</span>
|
<span className="field-label">{t('settings.push.url')}</span>
|
||||||
<div className="input-with-tooltip">
|
<div className="input-with-tooltip">
|
||||||
<input
|
<input
|
||||||
type="url"
|
type="text"
|
||||||
value={settings.shoutrrrUrl}
|
value={settings.shoutrrrUrl}
|
||||||
onChange={(e) => setSettings({ ...settings, shoutrrrUrl: e.target.value })}
|
onChange={(e) => setSettings({ ...settings, shoutrrrUrl: e.target.value })}
|
||||||
placeholder="https://ntfy.sh/your-topic"
|
placeholder={t('settings.push.urlPlaceholder')}
|
||||||
/>
|
/>
|
||||||
<span className="info-tooltip" data-tooltip={t('settings.push.supports')}>ⓘ</span>
|
<span className="info-tooltip" data-tooltip={`${t('settings.push.supports')}\n\n${t('settings.push.docsLink')}`}>ⓘ</span>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -38,7 +38,14 @@
|
|||||||
"pastDaysCount": "{{count}} Tag",
|
"pastDaysCount": "{{count}} Tag",
|
||||||
"pastDaysCount_other": "{{count}} Tage",
|
"pastDaysCount_other": "{{count}} Tage",
|
||||||
"missedDoses": "{{count}} verpasste Dosis",
|
"missedDoses": "{{count}} verpasste Dosis",
|
||||||
"missedDoses_other": "{{count}} verpasste Dosen"
|
"missedDoses_other": "{{count}} verpasste Dosen",
|
||||||
|
"clearMissed": "Verpasste löschen",
|
||||||
|
"clearMissedConfirmTitle": "Verpasste Dosen löschen?",
|
||||||
|
"clearMissedConfirmMessage": "{{count}} verpasste Dosis wird als bestätigt markiert, ohne vom Bestand abgezogen zu werden.",
|
||||||
|
"clearMissedConfirmMessage_other": "{{count}} verpasste Dosen werden als bestätigt markiert, ohne vom Bestand abgezogen zu werden.",
|
||||||
|
"clearMissedConfirm": "Ja, löschen",
|
||||||
|
"clearMissedCancel": "Abbrechen",
|
||||||
|
"clearMissedSuccess": "{{count}} verpasste Dosen gelöscht"
|
||||||
},
|
},
|
||||||
"reminders": {
|
"reminders": {
|
||||||
"active": "Automatische Erinnerungen aktiv",
|
"active": "Automatische Erinnerungen aktiv",
|
||||||
@@ -173,7 +180,9 @@
|
|||||||
},
|
},
|
||||||
"push": {
|
"push": {
|
||||||
"url": "URL",
|
"url": "URL",
|
||||||
"supports": "Unterstützt ntfy, Discord, Telegram, Slack"
|
"urlPlaceholder": "ntfy://topic oder pushover://:token@userkey/",
|
||||||
|
"supports": "Unterstützt ntfy, Pushover, Gotify, Discord, Telegram, Slack & mehr",
|
||||||
|
"docsLink": "Siehe shoutrrr.dev für alle Services"
|
||||||
},
|
},
|
||||||
"schedule": {
|
"schedule": {
|
||||||
"title": "Erinnerungsplan",
|
"title": "Erinnerungsplan",
|
||||||
|
|||||||
@@ -40,7 +40,14 @@
|
|||||||
"pastDaysCount": "{{count}} day",
|
"pastDaysCount": "{{count}} day",
|
||||||
"pastDaysCount_other": "{{count}} days",
|
"pastDaysCount_other": "{{count}} days",
|
||||||
"missedDoses": "{{count}} missed dose",
|
"missedDoses": "{{count}} missed dose",
|
||||||
"missedDoses_other": "{{count}} missed doses"
|
"missedDoses_other": "{{count}} missed doses",
|
||||||
|
"clearMissed": "Clear missed",
|
||||||
|
"clearMissedConfirmTitle": "Clear Missed Doses?",
|
||||||
|
"clearMissedConfirmMessage": "This will mark {{count}} missed dose as acknowledged without deducting from your stock.",
|
||||||
|
"clearMissedConfirmMessage_other": "This will mark {{count}} missed doses as acknowledged without deducting from your stock.",
|
||||||
|
"clearMissedConfirm": "Yes, Clear",
|
||||||
|
"clearMissedCancel": "Cancel",
|
||||||
|
"clearMissedSuccess": "Cleared {{count}} missed doses"
|
||||||
},
|
},
|
||||||
"reminders": {
|
"reminders": {
|
||||||
"active": "Automatic reminders active",
|
"active": "Automatic reminders active",
|
||||||
@@ -175,7 +182,9 @@
|
|||||||
},
|
},
|
||||||
"push": {
|
"push": {
|
||||||
"url": "URL",
|
"url": "URL",
|
||||||
"supports": "Supports ntfy, Discord, Telegram, Slack"
|
"urlPlaceholder": "ntfy://topic or pushover://:token@userkey/",
|
||||||
|
"supports": "Supports ntfy, Pushover, Gotify, Discord, Telegram, Slack & more",
|
||||||
|
"docsLink": "See shoutrrr.dev for all services"
|
||||||
},
|
},
|
||||||
"schedule": {
|
"schedule": {
|
||||||
"title": "Reminder Schedule",
|
"title": "Reminder Schedule",
|
||||||
|
|||||||
@@ -690,6 +690,35 @@ textarea.auto-resize {
|
|||||||
background: rgba(234, 179, 8, 0.08);
|
background: rgba(234, 179, 8, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Past days header container - toggle + clear button */
|
||||||
|
.past-days-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.past-days-header .past-days-toggle {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.clear-missed-btn {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
background: rgba(234, 179, 8, 0.15);
|
||||||
|
color: var(--warning);
|
||||||
|
border: 1px solid var(--warning);
|
||||||
|
border-radius: var(--btn-radius);
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: background 150ms ease, transform 100ms ease;
|
||||||
|
}
|
||||||
|
.clear-missed-btn:hover {
|
||||||
|
background: rgba(234, 179, 8, 0.25);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
.clear-missed-btn:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
/* Past day blocks styling */
|
/* Past day blocks styling */
|
||||||
.day-block.past {
|
.day-block.past {
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
|
|||||||
Reference in New Issue
Block a user