Add past days toggle and update terminology for blisters

- Added translations for showing/hiding past days and past days count in German and English.
- Renamed "slices" to "blisters" in both translation files.
- Updated CSS styles to reflect the change from slices to blisters, including layout and hover effects.
- Introduced new styles for past days toggle button and past day blocks.
This commit is contained in:
Daniel Volz
2025-12-27 15:01:54 +01:00
parent d69c38e141
commit 9ccb5b1f0f
9 changed files with 727 additions and 277 deletions
+161 -15
View File
@@ -8,6 +8,7 @@ MedAssist-ng is a **medication tracking and planning app** with a monorepo struc
- **Frontend**: React 18 + Vite + TypeScript at `frontend/`
- **Database**: SQLite with migrations in `backend/src/db/migrations/`
- **Deployment**: Docker Compose with separate dev containers
- **i18n**: English (en) and German (de) via react-i18next
### Data Flow
```
@@ -37,33 +38,174 @@ cd backend && npm run migrate
## Key Patterns
### Backend Routes (`backend/src/routes/`)
- Routes register directly on app without `/api` prefix
- Use Fastify's type-safe body/params: `app.put<{ Body: MyType }>()`
- Settings: notification config → JSON file (`data/notification-settings.json`), SMTP → `.env`
| Route File | Endpoints |
|------------|-----------|
| `auth.ts` | `/auth/login`, `/auth/register`, `/auth/logout`, `/auth/refresh`, `/auth/me` |
| `medications.ts` | CRUD `/medications`, `/medications/:id/image` |
| `doses.ts` | `/doses/taken` - track dose intake |
| `planner.ts` | `/medications/usage` - calculate usage for date range |
| `settings.ts` | `/settings` - user settings CRUD |
| `share.ts` | `/share` - create share tokens, `/share/:token` - public access |
| `health.ts` | `/health` - health check endpoint |
### Backend Services (`backend/src/services/`)
| Service | Description |
|---------|-------------|
| `reminder-scheduler.ts` | Stock reminder emails/push notifications |
| `intake-reminder-scheduler.ts` | Intake reminder notifications |
### Frontend (`frontend/src/App.tsx`)
- Single-file React app with all components and state
- Uses React Router for navigation (`/dashboard`, `/medications`, `/planner`, `/settings`)
- Uses React Router for navigation
- API calls use `/api/` prefix (proxied by Vite)
- Medication scheduling logic with "slices" (usage patterns)
- Medication scheduling logic with intake schedules (multiple time entries per medication)
### Database Schema (`backend/src/db/schema.ts`)
- `medications`: tracks count, strips, pack inventory, usage schedules as JSON
- `users`, `refreshTokens`: JWT auth with rotating refresh tokens
- `settings`: legacy table (SMTP now from `.env`, notifications from JSON file)
## Frontend Components & Views
### Settings Architecture
```
SMTP config: .env file (read-only in UI, loaded via env_file in docker-compose)
Notifications: data/notification-settings.json (editable via UI)
### Routes / Pages
| Route | Description |
|-------|-------------|
| `/dashboard` | Main view with Coverage Cards + Upcoming Schedules timeline |
| `/medications` | Medications list + New/Edit form with all fields |
| `/planner` | Usage planner - calculate needed pills for date range |
| `/settings` | App settings: notifications, email, thresholds, language |
| `/schedule` | Full schedule view (simplified, no coverage cards) |
| `/share/:token` | Public share link for "taken by" user schedule |
### Key React Components (in App.tsx)
| Component | Description |
|-----------|-------------|
| `App` | Root component with BrowserRouter |
| `AppRouter` | Handles auth check, renders AppContent or Auth |
| `AppContent` | Main app shell with navigation, header, all routes |
| `SharedSchedule` | Public share page for medication schedules by person |
| `MedicationAvatar` | Round avatar with medication image or colored initial |
### Dashboard Sections
| Section | Description |
|---------|-------------|
| **Coverage Cards** | Stock status cards per medication: days left, blisters, status (Normal/Warning/Critical) |
| **Upcoming Schedules** | Timeline grouped by day, collapsible days, dose tracking |
### Schedule/Timeline Elements
| Element | CSS Class | Description |
|---------|-----------|-------------|
| Past days toggle | `.past-days-toggle` | Click to show/hide past days |
| Day container | `.day-block` | Container for one day, collapsible |
| Today highlight | `.day-block.today` | Blue border/background for current day |
| Past day | `.day-block.past` | Dashed border, reduced opacity |
| All taken | `.day-block.all-taken` | Green styling when all doses taken |
| Day header | `.day-divider` | Date header with collapse toggle arrow |
| Collapse icon | `.day-collapse-icon` | ▶/▼ arrow for expand/collapse |
| Day summary | `.day-summary` | Shows "X/Y" doses taken or "✓ All taken" |
| Medication row | `.time-row` | One medication's doses for that day |
| Dose item | `.dose-item` | Individual dose with time, amount, take/undo button |
| Dose taken | `.dose-item.taken` | Green background when dose is marked taken |
| Dose overdue | `.dose-item.overdue` | Styling for past untaken doses |
| Dose future | `.dose-item.future` | Disabled button for future days |
### Medication Form (New/Edit)
| Field | Description |
|-------|-------------|
| Commercial Name | Main medication name (required) |
| Generic Name | Scientific/generic name (optional) |
| Taken By | Person taking the medication (optional, enables filtering/sharing) |
| Packs | Number of full packs |
| Blisters per Pack | Strips/blisters in each pack |
| Pills per Blister | Tablets per strip |
| Loose Pills | Extra pills not in blisters |
| Pill Weight (mg) | Weight per pill for dose calculation display |
| Expiry Date | Medication expiration |
| Notes | Free text notes |
| Image Upload | Medication photo (preview for new, direct upload for edit) |
| **Intake Schedule** | One or more intake entries defining usage pattern |
### Intake Schedule
Each blister defines a recurring intake:
- **Usage (Pills)**: How many pills per dose
- **Every (Days)**: Interval (1 = daily, 7 = weekly)
- **Start (Date/Time)**: When the schedule starts (determines past/future doses)
- **Remind checkbox**: Enable intake reminders (🔔)
### Modals
| Modal | Trigger | Content |
|-------|---------|---------|
| Medication Detail | Click on coverage card or medication row | Full medication info, stock, schedule preview, edit/delete/ICS buttons |
| Image Lightbox | Click medication image | Full-size medication image |
| Share Dialog | "Share" button on schedules | Generate share link for specific "taken by" person |
| User Schedule Filter | Click on "taken by" badge | Filter schedule by person |
### Settings Sections
| Section | Settings |
|---------|----------|
| General | Language toggle (EN/DE) |
| Stock Thresholds | Warning days, critical days, expiry warning days |
| Email Notifications | Enable, email address, stock/intake toggles |
| Push Notifications (Shoutrrr) | Enable, URL (ntfy/gotify/etc), stock/intake toggles |
| Reminder Settings | Days before, repeat daily |
| SMTP | Email config (read-only from .env) |
## Database Schema (`backend/src/db/schema.ts`)
| Table | Description |
|-------|-------------|
| `users` | User accounts with password hash, auth provider, timestamps |
| `medications` | Per-user medications with inventory, schedules as JSON arrays |
| `userSettings` | Per-user settings: notifications, thresholds, language |
| `refreshTokens` | JWT refresh tokens for auth rotation |
| `shareTokens` | Public share links by takenBy person |
| `doseTracking` | Tracks when doses are marked as taken |
### Key Medication Fields
```typescript
{
name, genericName, takenBy, // Identity
packCount, stripsPerPack, tabsPerStrip, looseTablets, // Inventory
count, strips, stripSize, // Derived/legacy
pillWeightMg, // For mg display
usageJson, everyJson, startJson, // Intake schedules as JSON arrays
imageUrl, expiryDate, notes, // Optional metadata
intakeRemindersEnabled // Per-med reminder toggle
}
```
### Dose ID Format
Dose IDs follow the pattern: `{medicationId}-{blisterIndex}-{timestampMs}`
Example: `5-0-1735344000000` = Medication 5, Blister 0, timestamp
## State Management (AppContent)
### Key State Variables
| State | Purpose |
|-------|---------|
| `meds` | Array of all user's medications |
| `form` | Current medication form data |
| `editingId` | ID of medication being edited (null for new) |
| `pendingImage` / `pendingImagePreview` | Image upload for new medications |
| `settings` / `savedSettings` | User settings current vs saved |
| `scheduleDays` | How many days to show (30/90/180) |
| `showPastDays` | Toggle for past days visibility |
| `takenDoses` | Set of dose IDs that are marked taken |
| `manuallyCollapsedDays` / `manuallyExpandedDays` | Day collapse state |
| `selectedMed` | Medication shown in detail modal |
| `selectedUser` | Filter schedule by "taken by" person |
### Key Computed Values (useMemo)
| Value | Purpose |
|-------|---------|
| `schedule` | All scheduled events from `buildSchedulePreview()` |
| `groupedSchedule` | Events grouped by day |
| `pastDays` / `futureDays` | Split groupedSchedule by today |
| `coverage` | Stock coverage calculations |
| `coverageByMed` / `depletionByMed` | Coverage lookups |
## Conventions
- **TypeScript**: Strict mode, ESM modules (`"type": "module"`)
- **Styling**: CSS custom properties in `frontend/src/styles.css`, dark/light theme via `data-theme`
- **API responses**: Return objects directly, Fastify serializes to JSON
- **Environment**: Copy `.env.example``.env`, secrets must be 10+ chars
- **i18n**: All UI text via `t('key')` function, translations in `frontend/src/i18n/*.json`
## ⚠️ Database Migrations (CRITICAL)
@@ -82,8 +224,6 @@ Notifications: data/notification-settings.json (editable via UI)
**Why this matters**: The dev database might get updated manually, but production will break without proper migration files. This causes `SQLITE_ERROR: no such column` errors in prod.
**Migration naming**: `0001_add_strips.sql`, `0002_pack_inventory.sql`, `0003_add_image_url.sql`
## File Locations
| Purpose | Location |
@@ -91,8 +231,14 @@ Notifications: data/notification-settings.json (editable via UI)
| Backend entry | `backend/src/index.ts` |
| Database schema | `backend/src/db/schema.ts` |
| Migrations | `backend/src/db/migrations/*.sql` |
| Migration journal | `backend/src/db/migrations/meta/_journal.json` |
| Backend routes | `backend/src/routes/*.ts` |
| Backend services | `backend/src/services/*.ts` |
| Frontend app | `frontend/src/App.tsx` |
| Frontend auth | `frontend/src/components/Auth.tsx` |
| Styles | `frontend/src/styles.css` |
| i18n English | `frontend/src/i18n/en.json` |
| i18n German | `frontend/src/i18n/de.json` |
| Docker prod | `docker-compose.yml` |
| Docker dev | `docker-compose.dev.yml` |
| Env template | `.env.example` |
+34 -34
View File
@@ -12,7 +12,7 @@ import type { AuthUser } from "../types/fastify.js";
const IMAGES_DIR = resolve(process.cwd(), "data/images");
const sliceSchema = z.object({
const blisterSchema = z.object({
usage: z.number().nonnegative(),
every: z.number().int().min(1),
start: z.string().datetime(),
@@ -30,24 +30,24 @@ const medicationSchema = z.object({
expiryDate: z.string().nullable().optional(),
notes: z.string().max(500).nullable().optional(),
intakeRemindersEnabled: z.boolean().default(false),
slices: z.array(sliceSchema).min(1).max(12),
blisters: z.array(blisterSchema).min(1).max(12),
});
function zipSlices(usage: number[], every: number[], start: string[]) {
function zipBlisters(usage: number[], every: number[], start: string[]) {
const len = Math.min(usage.length, every.length, start.length);
const slices: Array<{ usage: number; every: number; start: string }> = [];
const blisters: Array<{ usage: number; every: number; start: string }> = [];
for (let i = 0; i < len; i++) {
slices.push({ usage: usage[i], every: every[i], start: start[i] });
blisters.push({ usage: usage[i], every: every[i], start: start[i] });
}
return slices;
return blisters;
}
function parseSlices(row: typeof medications.$inferSelect) {
function parseBlisters(row: typeof medications.$inferSelect) {
try {
const usage = JSON.parse(row.usageJson) as number[];
const every = JSON.parse(row.everyJson) as number[];
const start = JSON.parse(row.startJson) as string[];
return zipSlices(usage, every, start);
return zipBlisters(usage, every, start);
} catch (err) {
return [];
}
@@ -90,7 +90,7 @@ export async function medicationRoutes(app: FastifyInstance) {
tabsPerStrip: row.tabsPerStrip ?? row.stripSize ?? 1,
looseTablets: row.looseTablets ?? 0,
pillWeightMg: row.pillWeightMg,
slices: parseSlices(row),
blisters: parseBlisters(row),
imageUrl: row.imageUrl,
expiryDate: row.expiryDate,
notes: row.notes,
@@ -104,10 +104,10 @@ export async function medicationRoutes(app: FastifyInstance) {
if (!parsed.success) return reply.status(400).send(parsed.error.format());
const userId = getUserId(req, reply);
const { name, genericName, takenBy, packCount, stripsPerPack, tabsPerStrip, looseTablets, pillWeightMg, expiryDate, notes, intakeRemindersEnabled, slices } = parsed.data;
const usageJson = JSON.stringify(slices.map((s) => s.usage));
const everyJson = JSON.stringify(slices.map((s) => s.every));
const startJson = JSON.stringify(slices.map((s) => s.start));
const { name, genericName, takenBy, packCount, stripsPerPack, tabsPerStrip, looseTablets, pillWeightMg, expiryDate, notes, intakeRemindersEnabled, blisters } = parsed.data;
const usageJson = JSON.stringify(blisters.map((s) => s.usage));
const everyJson = JSON.stringify(blisters.map((s) => s.every));
const startJson = JSON.stringify(blisters.map((s) => s.start));
const derivedCount = deriveTotalTablets(packCount, stripsPerPack, tabsPerStrip, looseTablets);
@@ -148,7 +148,7 @@ export async function medicationRoutes(app: FastifyInstance) {
tabsPerStrip: inserted.tabsPerStrip,
looseTablets: inserted.looseTablets,
pillWeightMg: inserted.pillWeightMg,
slices,
blisters,
imageUrl: inserted.imageUrl,
expiryDate: inserted.expiryDate,
notes: inserted.notes,
@@ -169,10 +169,10 @@ export async function medicationRoutes(app: FastifyInstance) {
const [existing] = await db.select().from(medications).where(and(eq(medications.id, idNum), eq(medications.userId, userId)));
if (!existing) return reply.notFound();
const { name, genericName, takenBy, packCount, stripsPerPack, tabsPerStrip, looseTablets, pillWeightMg, expiryDate, notes, intakeRemindersEnabled, slices } = parsed.data;
const usageJson = JSON.stringify(slices.map((s) => s.usage));
const everyJson = JSON.stringify(slices.map((s) => s.every));
const startJson = JSON.stringify(slices.map((s) => s.start));
const { name, genericName, takenBy, packCount, stripsPerPack, tabsPerStrip, looseTablets, pillWeightMg, expiryDate, notes, intakeRemindersEnabled, blisters } = parsed.data;
const usageJson = JSON.stringify(blisters.map((s) => s.usage));
const everyJson = JSON.stringify(blisters.map((s) => s.every));
const startJson = JSON.stringify(blisters.map((s) => s.start));
const derivedCount = deriveTotalTablets(packCount, stripsPerPack, tabsPerStrip, looseTablets);
@@ -216,7 +216,7 @@ export async function medicationRoutes(app: FastifyInstance) {
tabsPerStrip: result[0].tabsPerStrip,
looseTablets: result[0].looseTablets,
pillWeightMg: result[0].pillWeightMg,
slices,
blisters,
imageUrl: result[0].imageUrl,
expiryDate: result[0].expiryDate,
notes: result[0].notes,
@@ -313,8 +313,8 @@ export async function medicationRoutes(app: FastifyInstance) {
const now = new Date();
const payload = rows.map((row) => {
const slices = parseSlices(row);
const usageTotal = calculateUsageInRange(slices, start, end);
const blisters = parseBlisters(row);
const usageTotal = calculateUsageInRange(blisters, start, end);
const tabsPerStrip = row.tabsPerStrip ?? row.stripSize ?? 1;
const packCount = row.packCount ?? 1;
const stripsPerPack = row.stripsPerPack ?? row.strips ?? 1;
@@ -323,13 +323,13 @@ export async function medicationRoutes(app: FastifyInstance) {
// Calculate consumption up to now (same logic as frontend)
let consumedUntilNow = 0;
slices.forEach((slice) => {
const sliceStart = new Date(slice.start);
if (Number.isNaN(sliceStart.getTime()) || sliceStart > now) return;
blisters.forEach((blister) => {
const blisterStart = new Date(blister.start);
if (Number.isNaN(blisterStart.getTime()) || blisterStart > now) return;
const msPerDay = 86400000;
const period = Math.max(1, slice.every) * msPerDay;
const occurrences = Math.floor((now.getTime() - sliceStart.getTime()) / period) + 1;
consumedUntilNow += occurrences * slice.usage;
const period = Math.max(1, blister.every) * msPerDay;
const occurrences = Math.floor((now.getTime() - blisterStart.getTime()) / period) + 1;
consumedUntilNow += occurrences * blister.usage;
});
const currentPills = Math.max(0, originalTotalPills - consumedUntilNow);
@@ -365,14 +365,14 @@ export async function medicationRoutes(app: FastifyInstance) {
});
}
function calculateUsageInRange(slices: Array<{ usage: number; every: number; start: string }>, start: Date, end: Date) {
function calculateUsageInRange(blisters: Array<{ usage: number; every: number; start: string }>, start: Date, end: Date) {
let total = 0;
slices.forEach((slice) => {
const sliceStart = new Date(slice.start);
if (Number.isNaN(sliceStart.getTime())) return;
// iterate occurrences from sliceStart up to end
for (let dt = new Date(sliceStart); dt < end; dt.setDate(dt.getDate() + slice.every)) {
if (dt >= start && dt < end) total += slice.usage;
blisters.forEach((blister) => {
const blisterStart = new Date(blister.start);
if (Number.isNaN(blisterStart.getTime())) return;
// iterate occurrences from blisterStart up to end
for (let dt = new Date(blisterStart); dt < end; dt.setDate(dt.getDate() + blister.every)) {
if (dt >= start && dt < end) total += blister.usage;
}
});
return Number(total.toFixed(2));
+7 -7
View File
@@ -56,20 +56,20 @@ export async function shareRoutes(app: FastifyInstance) {
)
);
// Parse slices and build schedule data
const medicationsWithSlices = meds.map((med) => {
let slices: { usage: number; every: number; start: string }[] = [];
// Parse blisters and build schedule data
const medicationsWithBlisters = meds.map((med) => {
let blisters: { 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) => ({
blisters = usageArr.map((usage: number, i: number) => ({
usage,
every: everyArr[i] ?? 1,
start: startArr[i] ?? new Date().toISOString(),
}));
} catch {
slices = [];
blisters = [];
}
return {
@@ -78,14 +78,14 @@ export async function shareRoutes(app: FastifyInstance) {
genericName: med.genericName,
pillWeightMg: med.pillWeightMg,
imageUrl: med.imageUrl,
slices,
blisters,
};
});
return {
takenBy: share.takenBy,
scheduleDays: share.scheduleDays,
medications: medicationsWithSlices,
medications: medicationsWithBlisters,
};
});
@@ -8,7 +8,7 @@ import { getAllUserSettings, sendShoutrrrNotification, type UserSettings } from
import { getTranslations, t, getDateLocale, type Language } from "../i18n/translations.js";
import { getReminderState, updateReminderSentTime } from "./reminder-scheduler.js";
type Slice = { usage: number; every: number; start: string };
type Blister = { usage: number; every: number; start: string };
type IntakeReminderState = {
sentReminders: string[]; // Array of "medName:timestamp" to track sent reminders
@@ -42,17 +42,17 @@ function saveIntakeReminderState(state: IntakeReminderState): void {
writeFileSync(intakeReminderStateFile, JSON.stringify(state, null, 2));
}
function parseSlices(row: { usageJson: string; everyJson: string; startJson: string }): Slice[] {
function parseBlisters(row: { usageJson: string; everyJson: string; startJson: string }): Blister[] {
try {
const usage = JSON.parse(row.usageJson) as number[];
const every = JSON.parse(row.everyJson) as number[];
const start = JSON.parse(row.startJson) as string[];
const len = Math.min(usage.length, every.length, start.length);
const slices: Slice[] = [];
const blisters: Blister[] = [];
for (let i = 0; i < len; i++) {
slices.push({ usage: usage[i], every: every[i], start: start[i] });
blisters.push({ usage: usage[i], every: every[i], start: start[i] });
}
return slices;
return blisters;
} catch {
return [];
}
@@ -67,7 +67,7 @@ type UpcomingIntake = {
pillWeightMg: number | null;
};
function getUpcomingIntakes(medName: string, slices: Slice[], minutesBefore: number, takenBy: string | null, pillWeightMg: number | null, locale: string): UpcomingIntake[] {
function getUpcomingIntakes(medName: string, blisters: Blister[], minutesBefore: number, takenBy: string | null, pillWeightMg: number | null, locale: string): UpcomingIntake[] {
const now = Date.now();
// Window to detect if "now" is the right time to send reminder
// We check if the notify time (intake - 15min) falls within current minute ±1
@@ -76,9 +76,9 @@ function getUpcomingIntakes(medName: string, slices: Slice[], minutesBefore: num
const upcoming: UpcomingIntake[] = [];
for (const slice of slices) {
const startTime = new Date(slice.start).getTime();
const intervalMs = slice.every * 24 * 60 * 60 * 1000;
for (const blister of blisters) {
const startTime = new Date(blister.start).getTime();
const intervalMs = blister.every * 24 * 60 * 60 * 1000;
if (intervalMs <= 0) continue;
@@ -112,7 +112,7 @@ function getUpcomingIntakes(medName: string, slices: Slice[], minutesBefore: num
const intakeDate = new Date(nextTime);
upcoming.push({
medName,
usage: slice.usage,
usage: blister.usage,
intakeTime: intakeDate,
intakeTimeStr: intakeDate.toLocaleTimeString(locale, {
hour: "2-digit",
@@ -303,8 +303,8 @@ async function checkAndSendIntakeRemindersForUser(
// Find all upcoming intakes across all medications for this user
for (const med of medsWithReminders) {
const slices = parseSlices(med);
const upcoming = getUpcomingIntakes(med.name, slices, REMINDER_MINUTES_BEFORE, med.takenBy, med.pillWeightMg, locale);
const blisters = parseBlisters(med);
const upcoming = getUpcomingIntakes(med.name, blisters, REMINDER_MINUTES_BEFORE, med.takenBy, med.pillWeightMg, locale);
allUpcoming.push(...upcoming);
}
+11 -11
View File
@@ -7,7 +7,7 @@ import { resolve } from "path";
import { loadUserSettings, getAllUserSettings, sendShoutrrrNotification, type UserSettings } from "../routes/settings.js";
import { getTranslations, t, getDateLocale, type Language } from "../i18n/translations.js";
type Slice = { usage: number; every: number; start: string };
type Blister = { usage: number; every: number; start: string };
type ReminderState = {
lastAutoEmailSent: string | null; // ISO date string
@@ -172,28 +172,28 @@ export function updateReminderSentTime(type: "stock" | "intake" = "stock", chann
});
}
function parseSlices(row: { usageJson: string; everyJson: string; startJson: string }): Slice[] {
function parseBlisters(row: { usageJson: string; everyJson: string; startJson: string }): Blister[] {
try {
const usage = JSON.parse(row.usageJson) as number[];
const every = JSON.parse(row.everyJson) as number[];
const start = JSON.parse(row.startJson) as string[];
const len = Math.min(usage.length, every.length, start.length);
const slices: Slice[] = [];
const blisters: Blister[] = [];
for (let i = 0; i < len; i++) {
slices.push({ usage: usage[i], every: every[i], start: start[i] });
blisters.push({ usage: usage[i], every: every[i], start: start[i] });
}
return slices;
return blisters;
} catch {
return [];
}
}
function calculateDailyUsage(slices: Slice[]): number {
return slices.reduce((sum, s) => sum + s.usage / s.every, 0);
function calculateDailyUsage(blisters: Blister[]): number {
return blisters.reduce((sum, s) => sum + s.usage / s.every, 0);
}
function calculateDepletionInfo(med: { count: number; slices: Slice[] }, language: Language): { daysLeft: number | null; depletionDate: string | null } {
const dailyUsage = calculateDailyUsage(med.slices);
function calculateDepletionInfo(med: { count: number; blisters: Blister[] }, language: Language): { daysLeft: number | null; depletionDate: string | null } {
const dailyUsage = calculateDailyUsage(med.blisters);
if (dailyUsage <= 0) return { daysLeft: null, depletionDate: null };
const daysLeft = Math.floor(med.count / dailyUsage);
@@ -220,8 +220,8 @@ async function getMedicationsNeedingReminder(userId: number, reminderDaysBefore:
const lowStock: LowStockItem[] = [];
for (const row of rows) {
const slices = parseSlices(row);
const { daysLeft, depletionDate } = calculateDepletionInfo({ count: row.count, slices }, language);
const blisters = parseBlisters(row);
const { daysLeft, depletionDate } = calculateDepletionInfo({ count: row.count, blisters }, language);
// Check if medication runs out within reminderDaysBefore days
if (daysLeft !== null && daysLeft <= reminderDaysBefore) {
+424 -181
View File
@@ -3,7 +3,7 @@ import { Routes, Route, useNavigate, useLocation, Navigate, useParams } from "re
import { useTranslation } from "react-i18next";
import { AuthProvider, useAuth, AuthPage, UserProfile } from "./components/Auth";
type Slice = {
type Blister = {
usage: number;
every: number;
start: string;
@@ -22,7 +22,7 @@ type Medication = {
tabsPerStrip?: number;
looseTablets?: number;
pillWeightMg?: number | null;
slices: Slice[];
blisters: Blister[];
imageUrl?: string | null;
expiryDate?: string | null;
notes?: string | null;
@@ -42,7 +42,7 @@ type PlannerRow = {
enough: boolean;
};
type FormSlice = { usage: string; every: string; start: string };
type FormBlister = { usage: string; every: string; start: string };
type FormState = {
name: string;
@@ -56,12 +56,12 @@ type FormState = {
expiryDate: string;
notes: string;
intakeRemindersEnabled: boolean;
slices: FormSlice[];
blisters: FormBlister[];
};
const defaultSlice = (): FormSlice => ({ usage: "1", every: "1", start: toInputValue(new Date().toISOString()) });
const defaultBlister = (): FormBlister => ({ usage: "1", every: "1", start: toInputValue(new Date().toISOString()) });
const defaultForm = (): FormState => ({ name: "", genericName: "", takenBy: "", packCount: "1", stripsPerPack: "1", tabsPerStrip: "1", looseTablets: "0", pillWeightMg: "", expiryDate: "", notes: "", intakeRemindersEnabled: false, slices: [defaultSlice()] });
const defaultForm = (): FormState => ({ name: "", genericName: "", takenBy: "", packCount: "1", stripsPerPack: "1", tabsPerStrip: "1", looseTablets: "0", pillWeightMg: "", expiryDate: "", notes: "", intakeRemindersEnabled: false, blisters: [defaultBlister()] });
const todayIso = () => new Date().toISOString();
const plusDaysIso = (days: number) => {
@@ -264,6 +264,7 @@ function AppContent() {
const [showImageLightbox, setShowImageLightbox] = useState(false);
const [selectedUser, setSelectedUser] = useState<string | null>(null);
const [scheduleDays, setScheduleDays] = useState<number>(30);
const [showPastDays, setShowPastDays] = useState(false);
const [takenDoses, setTakenDoses] = useState<Set<string>>(new Set());
// Share dialog state
const [showShareDialog, setShowShareDialog] = useState(false);
@@ -393,16 +394,16 @@ function AppContent() {
settings.shoutrrrEnabled !== savedSettings.shoutrrrEnabled ||
settings.shoutrrrUrl !== savedSettings.shoutrrrUrl;
const schedule = useMemo(() => buildSchedulePreview(meds, i18n.language), [meds, i18n.language]);
const schedule = useMemo(() => buildSchedulePreview(meds, i18n.language, true), [meds, i18n.language]);
const totalTablets = useMemo(() => deriveTotal(form), [form]);
const coverage = useMemo(() => calculateCoverage(meds, schedule.events, i18n.language, settings.reminderDaysBefore), [meds, schedule.events, i18n.language, settings.reminderDaysBefore]);
const depletionByMed = useMemo(() => Object.fromEntries(coverage.all.map((c) => [c.name, c.depletionTime])), [coverage.all]);
const coverageByMed = useMemo(() => Object.fromEntries(coverage.all.map((c) => [c.name, c])), [coverage.all]);
const groupedSchedule = useMemo(() => {
type DoseInfo = { id: string; timeStr: string; when: number; usage: number };
const days = new Map<string, { dateStr: string; date: Date; meds: Map<string, { medName: string; total: number; doses: DoseInfo[]; lastWhen: number }> }>();
const days = new Map<string, { dateStr: string; date: Date; isPast: boolean; meds: Map<string, { medName: string; total: number; doses: DoseInfo[]; lastWhen: number }> }>();
schedule.events.slice(0, 2000).forEach((event) => {
const day = days.get(event.dateStr) ?? { dateStr: event.dateStr, date: new Date(event.when), meds: new Map() };
const day = days.get(event.dateStr) ?? { dateStr: event.dateStr, date: new Date(event.when), isPast: event.isPast, meds: new Map() };
const medEntry = day.meds.get(event.medName) ?? { medName: event.medName, total: 0, doses: [], lastWhen: event.when };
medEntry.total += event.usage;
medEntry.doses.push({ id: event.id, timeStr: event.timeStr, when: event.when, usage: event.usage });
@@ -410,8 +411,11 @@ function AppContent() {
day.meds.set(event.medName, medEntry);
days.set(event.dateStr, day);
});
return Array.from(days.values()).map((d) => ({ dateStr: d.dateStr, date: d.date, meds: Array.from(d.meds.values()) })).slice(0, scheduleDays);
}, [schedule.events, scheduleDays]);
return Array.from(days.values()).map((d) => ({ dateStr: d.dateStr, date: d.date, isPast: d.isPast, meds: Array.from(d.meds.values()) }));
}, [schedule.events]);
const pastDays = useMemo(() => groupedSchedule.filter(d => d.isPast), [groupedSchedule]);
const futureDays = useMemo(() => groupedSchedule.filter(d => !d.isPast).slice(0, scheduleDays), [groupedSchedule, scheduleDays]);
useEffect(() => {
loadMeds();
@@ -625,20 +629,20 @@ function AppContent() {
loadMeds();
}
function setSliceValue(idx: number, field: keyof FormSlice, value: string) {
function setBlisterValue(idx: number, field: keyof FormBlister, value: string) {
setForm((prev) => {
const next = [...prev.slices];
const next = [...prev.blisters];
next[idx] = { ...next[idx], [field]: value };
return { ...prev, slices: next };
return { ...prev, blisters: next };
});
}
function addSlice() {
setForm((prev) => ({ ...prev, slices: [...prev.slices, defaultSlice()] }));
function addBlister() {
setForm((prev) => ({ ...prev, blisters: [...prev.blisters, defaultBlister()] }));
}
function removeSlice(idx: number) {
setForm((prev) => ({ ...prev, slices: prev.slices.filter((_, i) => i !== idx) }));
function removeBlister(idx: number) {
setForm((prev) => ({ ...prev, blisters: prev.blisters.filter((_, i) => i !== idx) }));
}
function startEdit(med: Medication) {
@@ -655,7 +659,7 @@ function AppContent() {
expiryDate: med.expiryDate ? med.expiryDate.slice(0, 10) : "",
notes: med.notes ?? "",
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
slices: med.slices.map((s) => ({ usage: String(s.usage), every: String(s.every), start: toInputValue(s.start) })),
blisters: med.blisters.map((s) => ({ usage: String(s.usage), every: String(s.every), start: toInputValue(s.start) })),
});
}
@@ -687,7 +691,7 @@ function AppContent() {
expiryDate: form.expiryDate || null,
notes: form.notes.trim() || null,
intakeRemindersEnabled: form.intakeRemindersEnabled,
slices: form.slices.map((s) => ({ usage: Number(s.usage) || 0, every: Math.max(1, Number(s.every) || 1), start: toIsoString(s.start) })),
blisters: form.blisters.map((s) => ({ usage: Number(s.usage) || 0, every: Math.max(1, Number(s.every) || 1), start: toIsoString(s.start) })),
};
const method = editingId ? "PUT" : "POST";
@@ -1054,7 +1058,80 @@ function AppContent() {
</div>
</div>
<div className="timeline">
{groupedSchedule.map((day) => {
{/* Past days toggle */}
{pastDays.length > 0 && (
<div
className={`past-days-toggle ${showPastDays ? 'expanded' : ''}`}
onClick={() => setShowPastDays(!showPastDays)}
>
<span className="past-days-icon">{showPastDays ? '▼' : '▶'}</span>
<span className="past-days-label">
{showPastDays ? t('dashboard.schedules.hidePastDays') : t('dashboard.schedules.showPastDays')}
</span>
<span className="past-days-count">({t('dashboard.schedules.pastDaysCount', { count: pastDays.length })})</span>
</div>
)}
{/* Past days (when expanded) */}
{showPastDays && pastDays.map((day) => {
const allDoseIds = day.meds.flatMap((item) => item.doses.map((d) => d.id));
const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id));
const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length;
const isAutoCollapsed = true; // Past days are always auto-collapsed
const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr);
const isCollapsed = !isManuallyExpanded;
return (
<div key={day.dateStr} className={`day-block past ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : ""}`}>
<div
className="day-divider clickable"
onClick={() => toggleDayCollapse(day.dateStr, isAutoCollapsed)}
title={isCollapsed ? t('common.expand') : t('common.collapse')}
>
<span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span>
<span className="day-date">{day.dateStr}</span>
<span className="day-summary">
{allDayTaken ? (
<span className="day-complete"> {t('dashboard.schedules.allTaken')}</span>
) : (
<span className="day-progress">{takenCount}/{allDoseIds.length}</span>
)}
</span>
</div>
{!isCollapsed && day.meds.map((item) => {
const med = meds.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"><MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" /><span className="med-name-text">{item.medName}</span>{med?.intakeRemindersEnabled && <span className="reminder-icon info-tooltip" data-tooltip={t('tooltips.intakeReminders')}>🔔</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);
return (
<div key={dose.id} className={`dose-item past ${isTaken ? "taken" : ""}`}>
<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)`}{med?.takenBy && <span className="taken-by-inline"> {t('dose.takenBy')} <span className="taken-by-name clickable" onClick={() => setSelectedUser(med.takenBy!)}>{med.takenBy}</span></span>}</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>
);
})}
{/* Current and future days */}
{futureDays.map((day) => {
// Check if all doses in this day are taken (auto-collapse)
const allDoseIds = day.meds.flatMap((item) => item.doses.map((d) => d.id));
const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id));
@@ -1074,7 +1151,7 @@ function AppContent() {
const isCollapsed = isAutoCollapsed ? !isManuallyExpanded : isManuallyCollapsed;
return (
<div key={day.dateStr} className={`day-block ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : ""}`}>
<div key={day.dateStr} className={`day-block ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : ""} ${isToday ? "today" : ""}`}>
<div
className="day-divider clickable"
onClick={() => toggleDayCollapse(day.dateStr, isAutoCollapsed)}
@@ -1175,10 +1252,10 @@ function AppContent() {
<button className="ghost danger" onClick={() => deleteMed(med.id)}>{t('common.delete')}</button>
</div>
</div>
<div className="slice-list">
{med.slices.map((s, idx) => (
<div key={`${med.id}-${idx}`} className="slice-row-simple">
{s.usage} {s.usage === 1 ? t('common.pill') : t('common.pills')} · {t('form.slices.every')} {s.every} {s.every === 1 ? t('common.day') : t('common.days')} · {t('form.slices.from')} {formatDateTime(s.start, i18n.language)}
<div className="blister-list">
{med.blisters.map((s, idx) => (
<div key={`${med.id}-${idx}`} className="blister-row-simple">
{s.usage} {s.usage === 1 ? t('common.pill') : t('common.pills')} · {t('form.blisters.every')} {s.every} {s.every === 1 ? t('common.day') : t('common.days')} · {t('form.blisters.from')} {formatDateTime(s.start, i18n.language)}
</div>
))}
</div>
@@ -1245,39 +1322,39 @@ function AppContent() {
/>
</label>
<div className="full slices">
<div className="full blisters">
<div className="card-head">
<h3>{t('form.slices.title')}</h3>
<div className="slices-actions">
<label className="inline-checkbox" title={t('form.slices.remindTooltip')}>
<input
type="checkbox"
checked={form.intakeRemindersEnabled}
onChange={(e) => setForm(prev => ({ ...prev, intakeRemindersEnabled: e.target.checked }))}
/>
<span>🔔 {t('form.slices.remind')}</span>
</label>
<button type="button" className="ghost" onClick={addSlice}>+ {t('form.slices.addIntake')}</button>
</div>
<h3>{t('form.blisters.title')}</h3>
<div className="blisters-actions">
<label className="inline-checkbox" title={t('form.blisters.remindTooltip')}>
<input
type="checkbox"
checked={form.intakeRemindersEnabled}
onChange={(e) => setForm(prev => ({ ...prev, intakeRemindersEnabled: e.target.checked }))}
/>
<span>🔔 {t('form.blisters.remind')}</span>
</label>
<button type="button" className="ghost" onClick={addBlister}>+ {t('form.blisters.addIntake')}</button>
</div>
</div>
{form.slices.map((s, idx) => (
<div key={idx} className="slice-row">
<div className="slice-inputs">
{form.blisters.map((s, idx) => (
<div key={idx} className="blister-row">
<div className="blister-inputs">
<label>
{t('form.slices.usage')}
<input type="number" min="0" step="0.1" value={s.usage} onChange={(e) => setSliceValue(idx, "usage", e.target.value)} />
{t('form.blisters.usage')}
<input type="number" min="0" step="0.1" value={s.usage} onChange={(e) => setBlisterValue(idx, "usage", e.target.value)} />
</label>
<label>
{t('form.slices.everyDays')}
<input type="number" min="1" value={s.every} onChange={(e) => setSliceValue(idx, "every", e.target.value)} />
{t('form.blisters.everyDays')}
<input type="number" min="1" value={s.every} onChange={(e) => setBlisterValue(idx, "every", e.target.value)} />
</label>
<label>
{t('form.slices.start')}
<input type="datetime-local" step="60" value={s.start} onChange={(e) => setSliceValue(idx, "start", e.target.value)} />
{t('form.blisters.start')}
<input type="datetime-local" step="60" value={s.start} onChange={(e) => setBlisterValue(idx, "start", e.target.value)} />
</label>
</div>
{form.slices.length > 1 && (
<button type="button" className="ghost" onClick={() => removeSlice(idx)}>{t('common.remove')}</button>
{form.blisters.length > 1 && (
<button type="button" className="ghost" onClick={() => removeBlister(idx)}>{t('common.remove')}</button>
)}
</div>
))}
@@ -1730,8 +1807,86 @@ function AppContent() {
</select>
</div>
<div className="timeline">
{groupedSchedule.map((day) => (
<div key={day.dateStr} className="day-block">
{/* Past days toggle */}
{pastDays.length > 0 && (
<div
className={`past-days-toggle ${showPastDays ? 'expanded' : ''}`}
onClick={() => setShowPastDays(!showPastDays)}
>
<span className="past-days-icon">{showPastDays ? '▼' : '▶'}</span>
<span className="past-days-label">
{showPastDays ? t('dashboard.schedules.hidePastDays') : t('dashboard.schedules.showPastDays')}
</span>
<span className="past-days-count">({t('dashboard.schedules.pastDaysCount', { count: pastDays.length })})</span>
</div>
)}
{/* Past days (when expanded) */}
{showPastDays && pastDays.map((day) => {
const allDoseIds = day.meds.flatMap((item) => item.doses.map((d) => d.id));
const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id));
const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length;
const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr);
const isCollapsed = !isManuallyExpanded;
return (
<div key={day.dateStr} className={`day-block past ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : ""}`}>
<div
className="day-divider clickable"
onClick={() => toggleDayCollapse(day.dateStr, true)}
title={isCollapsed ? t('common.expand') : t('common.collapse')}
>
<span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span>
<span className="day-date">{day.dateStr}</span>
<span className="day-summary">
{allDayTaken ? (
<span className="day-complete"> {t('dashboard.schedules.allTaken')}</span>
) : (
<span className="day-progress">{takenCount}/{allDoseIds.length}</span>
)}
</span>
</div>
{!isCollapsed && day.meds.map((item) => {
const med = meds.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"><MedicationAvatar name={item.medName} imageUrl={med?.imageUrl} size="sm" /><span className="med-name-text">{item.medName}</span>{med?.intakeRemindersEnabled && <span className="reminder-icon info-tooltip" data-tooltip={t('tooltips.intakeReminders')}>🔔</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);
return (
<div key={dose.id} className={`dose-item past ${isTaken ? "taken" : ""}`}>
<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)`}{med?.takenBy && <span className="taken-by-inline"> {t('dose.takenBy')} <span className="taken-by-name clickable" onClick={() => setSelectedUser(med.takenBy!)}>{med.takenBy}</span></span>}</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>
);
})}
{/* Current and future days */}
{futureDays.map((day) => {
const today = new Date();
today.setHours(0, 0, 0, 0);
const dayDate = new Date(day.date);
dayDate.setHours(0, 0, 0, 0);
const isToday = dayDate.getTime() === today.getTime();
return (
<div key={day.dateStr} className={`day-block ${isToday ? "today" : ""}`}>
<div className="day-divider">{day.dateStr}</div>
{day.meds.map((item) => {
const medCoverage = coverageByMed[item.medName];
@@ -1775,7 +1930,7 @@ function AppContent() {
);
})}
</div>
))}
);})}
</div>
</article>
</section>
@@ -1858,15 +2013,15 @@ function AppContent() {
})()}
</div>
{selectedMed.slices.length > 0 && (
{selectedMed.blisters.length > 0 && (
<div className="med-detail-section">
<h3>{t('modal.intakeSchedule')} {selectedMed.intakeRemindersEnabled && <span className="reminder-icon info-tooltip" data-tooltip={t('tooltips.intakeReminders')}>🔔</span>}</h3>
<div className="med-detail-schedules">
{selectedMed.slices.map((slice, idx) => (
{selectedMed.blisters.map((blister, idx) => (
<div key={idx} className="med-schedule-item">
<span className="med-schedule-usage">{slice.usage} {slice.usage !== 1 ? t('common.pills') : t('common.pill')}{selectedMed.pillWeightMg && ` (${slice.usage * selectedMed.pillWeightMg} mg)`}</span>
<span className="med-schedule-freq">{t('form.slices.every')} {slice.every} {slice.every !== 1 ? t('common.days') : t('common.day')}</span>
<span className="med-schedule-time">{t('modal.at')} {new Date(slice.start).toLocaleTimeString(i18n.language, { hour: "2-digit", minute: "2-digit" })}</span>
<span className="med-schedule-usage">{blister.usage} {blister.usage !== 1 ? t('common.pills') : t('common.pill')}{selectedMed.pillWeightMg && ` (${blister.usage * selectedMed.pillWeightMg} mg)`}</span>
<span className="med-schedule-freq">{t('form.blisters.every')} {blister.every} {blister.every !== 1 ? t('common.days') : t('common.day')}</span>
<span className="med-schedule-time">{t('modal.at')} {new Date(blister.start).toLocaleTimeString(i18n.language, { hour: "2-digit", minute: "2-digit" })}</span>
</div>
))}
</div>
@@ -1912,7 +2067,7 @@ function AppContent() {
<button className="ghost" onClick={() => { setSelectedMed(null); setShowImageLightbox(false); }}>
{t('common.close')}
</button>
{selectedMed.slices.length > 0 && (
{selectedMed.blisters.length > 0 && (
<button className="ghost" onClick={() => generateICS(selectedMed)} title={t('modal.exportTooltip')}>
📅 {t('modal.exportCalendar')}
</button>
@@ -2111,12 +2266,12 @@ function generateICS(med: Medication) {
return date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
};
const events = med.slices.map((slice, idx) => {
const start = new Date(slice.start);
const events = med.blisters.map((blister, idx) => {
const start = new Date(blister.start);
const end = new Date(start.getTime() + 15 * 60 * 1000); // 15 min duration
const interval = slice.every;
const interval = blister.every;
const pillInfo = `${slice.usage} pill${slice.usage !== 1 ? 's' : ''}${med.pillWeightMg ? ` (${slice.usage * med.pillWeightMg} mg)` : ''}`;
const pillInfo = `${blister.usage} pill${blister.usage !== 1 ? 's' : ''}${med.pillWeightMg ? ` (${blister.usage * med.pillWeightMg} mg)` : ''}`;
const summary = `💊 ${med.name} - ${pillInfo}`;
const description = [
`Medication: ${med.name}`,
@@ -2163,26 +2318,28 @@ END:VCALENDAR`;
URL.revokeObjectURL(url);
}
function buildSchedulePreview(meds: Medication[], locale: string) {
const events: Array<{ id: string; medName: string; timeStr: string; dateStr: string; usage: number; when: number }> = [];
function buildSchedulePreview(meds: Medication[], locale: string, includePast: boolean = false) {
const events: Array<{ id: string; medName: string; timeStr: string; dateStr: string; usage: number; when: number; isPast: boolean }> = [];
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()); // Midnight today
const end = new Date();
end.setDate(end.getDate() + 180); // 6 months horizon
meds.forEach((med) => {
med.slices.forEach((slice, idx) => {
const start = new Date(slice.start);
med.blisters.forEach((blister, idx) => {
const start = new Date(blister.start);
if (Number.isNaN(start.getTime())) return;
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + slice.every)) {
// Include all doses from today onwards (even past ones from today)
if (d < todayStart) continue;
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + blister.every)) {
const isPast = d < todayStart;
// Skip past events unless includePast is true
if (isPast && !includePast) continue;
const whenMs = d.getTime();
events.push({
id: `${med.id}-${idx}-${whenMs}`,
medName: med.name,
usage: slice.usage,
usage: blister.usage,
when: whenMs,
isPast,
timeStr: d.toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" }),
dateStr: d.toLocaleDateString(locale, { weekday: "short", day: "2-digit", month: "short" }),
});
@@ -2198,7 +2355,7 @@ function buildSchedulePreview(meds: Medication[], locale: string) {
return t.getFullYear() === n.getFullYear() && t.getMonth() === n.getMonth() && t.getDate() === n.getDate();
}).length;
return { events, today: todayCount, nextThree: events.length, totalSlices: meds.reduce((acc, m) => acc + m.slices.length, 0) };
return { events, today: todayCount, nextThree: events.length, totalBlisters: meds.reduce((acc, m) => acc + m.blisters.length, 0) };
}
function formatNumber(value: number | null) {
@@ -2283,10 +2440,10 @@ function calculateCoverage(meds: Medication[], events: Array<{ medName: string;
const now = Date.now();
const coverage: Coverage[] = meds.map((m) => {
const dailyRate = m.slices.reduce((sum, s) => sum + (s.every > 0 ? s.usage / s.every : 0), 0);
const dailyRate = m.blisters.reduce((sum, s) => sum + (s.every > 0 ? s.usage / s.every : 0), 0);
let consumed = 0;
m.slices.forEach((s) => {
m.blisters.forEach((s) => {
const start = new Date(s.start).getTime();
if (Number.isNaN(start) || start > now) return;
const period = Math.max(1, s.every) * MS_PER_DAY;
@@ -2483,7 +2640,7 @@ type SharedMedication = {
genericName?: string | null;
pillWeightMg?: number | null;
imageUrl?: string | null;
slices: Slice[];
blisters: Blister[];
};
type SharedScheduleData = {
@@ -2500,6 +2657,7 @@ function SharedSchedule() {
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);
const [showPastDays, setShowPastDays] = useState(false);
// Collapsed days state for SharedSchedule (token-specific localStorage)
const [manuallyCollapsedDays, setManuallyCollapsedDays] = useState<Set<string>>(new Set());
const [manuallyExpandedDays, setManuallyExpandedDays] = useState<Set<string>>(new Set());
@@ -2659,34 +2817,30 @@ function SharedSchedule() {
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 todayStart = new Date();
todayStart.setHours(0, 0, 0, 0);
const todayStartTime = todayStart.getTime();
const endTime = now + data.scheduleDays * 24 * 60 * 60 * 1000;
const doses: { id: string; when: number; medName: string; usage: number; timeStr: string }[] = [];
const doses: { id: string; when: number; medName: string; usage: number; timeStr: string; isPast: boolean }[] = [];
for (const med of data.medications) {
med.slices.forEach((slice, sliceIdx) => {
const startDate = new Date(slice.start);
const intervalMs = slice.every * 24 * 60 * 60 * 1000;
med.blisters.forEach((blister, blisterIdx) => {
const startDate = new Date(blister.start);
const intervalMs = blister.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;
}
// Start from the very first dose (blister start)
while (t <= endTime) {
const d = new Date(t);
// Generate dose ID matching Dashboard format: ${med.id}-${sliceIdx}-${whenMs}
const doseId = `${med.id}-${sliceIdx}-${t}`;
const isPast = t < todayStartTime;
// Generate dose ID matching Dashboard format: ${med.id}-${blisterIdx}-${whenMs}
const doseId = `${med.id}-${blisterIdx}-${t}`;
doses.push({
id: doseId,
when: t,
medName: med.name,
usage: slice.usage,
usage: blister.usage,
isPast,
timeStr: d.toLocaleTimeString(i18n.language, { hour: "2-digit", minute: "2-digit" }),
});
t += intervalMs;
@@ -2697,7 +2851,7 @@ function SharedSchedule() {
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 grouped: { dateStr: string; date: Date; isPast: boolean; meds: { medName: string; total: number; lastWhen: number; doses: typeof doses }[] }[] = [];
const byDate = new Map<string, typeof doses>();
for (const dose of doses) {
@@ -2722,12 +2876,15 @@ function SharedSchedule() {
lastWhen: Math.max(...medDoses.map(d => d.when)),
doses: medDoses,
}));
grouped.push({ dateStr, date: new Date(dayDoses[0].when), meds });
grouped.push({ dateStr, date: new Date(dayDoses[0].when), isPast: dayDoses[0].isPast, meds });
}
return grouped;
}, [data, i18n.language]);
const pastDays = useMemo(() => schedule.filter(d => d.isPast), [schedule]);
const futureDays = useMemo(() => schedule.filter(d => !d.isPast), [schedule]);
if (loading) {
return (
<div className="shared-schedule-page">
@@ -2764,94 +2921,180 @@ function SharedSchedule() {
{schedule.length === 0 ? (
<p className="shared-schedule-empty">{t('share.noSchedule')}</p>
) : (
schedule.map((day) => {
// Check if all doses in this day are taken (auto-collapse)
const allDoseIds = day.meds.flatMap((item) => item.doses.map((d) => d.id));
const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id));
const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length;
// Check if this is today, past, or future
const today = new Date();
today.setHours(0, 0, 0, 0);
const dayDate = new Date(day.date);
dayDate.setHours(0, 0, 0, 0);
const isToday = dayDate.getTime() === today.getTime();
// Determine if day should be collapsed: only today is expanded by default
const isAutoCollapsed = allDayTaken || !isToday;
const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr);
const isManuallyCollapsed = manuallyCollapsedDays.has(day.dateStr);
const isCollapsed = isAutoCollapsed ? !isManuallyExpanded : isManuallyCollapsed;
return (
<div key={day.dateStr} className={`day-block ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : ""}`}>
<div
className="day-divider clickable"
onClick={() => toggleDayCollapse(day.dateStr, isAutoCollapsed)}
title={isCollapsed ? t('common.expand') : t('common.collapse')}
>
<span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span>
<span className="day-date">{day.dateStr}</span>
<span className="day-summary">
{allDayTaken ? (
<span className="day-complete"> {t('dashboard.schedules.allTaken')}</span>
) : (
<span className="day-progress">{takenCount}/{allDoseIds.length}</span>
)}
</span>
</div>
{!isCollapsed && 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;
// Only disable doses on future DAYS, not later today
const doseDate = new Date(dose.when);
doseDate.setHours(0, 0, 0, 0);
const todayMidnight = new Date();
todayMidnight.setHours(0, 0, 0, 0);
const isFutureDose = doseDate.getTime() > todayMidnight.getTime();
return (
<div key={dose.id} className={`dose-item ${isTaken ? "taken" : ""} ${isOverdue ? "overdue" : ""} ${isFutureDose ? "future" : ""}`}>
<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')} disabled={isFutureDose}></button>
)}
</div>
);
})}
</div>
</div>
);
})}
<>
{/* Past days toggle */}
{pastDays.length > 0 && (
<div
className={`past-days-toggle ${showPastDays ? 'expanded' : ''}`}
onClick={() => setShowPastDays(!showPastDays)}
>
<span className="past-days-icon">{showPastDays ? '▼' : '▶'}</span>
<span className="past-days-label">
{showPastDays ? t('dashboard.schedules.hidePastDays') : t('dashboard.schedules.showPastDays')}
</span>
<span className="past-days-count">({t('dashboard.schedules.pastDaysCount', { count: pastDays.length })})</span>
</div>
);
})
)}
{/* Past days (when expanded) */}
{showPastDays && pastDays.map((day) => {
const allDoseIds = day.meds.flatMap((item) => item.doses.map((d) => d.id));
const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id));
const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length;
const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr);
const isCollapsed = !isManuallyExpanded;
return (
<div key={day.dateStr} className={`day-block past ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : ""}`}>
<div
className="day-divider clickable"
onClick={() => toggleDayCollapse(day.dateStr, true)}
title={isCollapsed ? t('common.expand') : t('common.collapse')}
>
<span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span>
<span className="day-date">{day.dateStr}</span>
<span className="day-summary">
{allDayTaken ? (
<span className="day-complete"> {t('dashboard.schedules.allTaken')}</span>
) : (
<span className="day-progress">{takenCount}/{allDoseIds.length}</span>
)}
</span>
</div>
{!isCollapsed && 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);
return (
<div key={dose.id} className={`dose-item past ${isTaken ? "taken" : ""}`}>
<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>
);
})}
{/* Current and future days */}
{futureDays.map((day) => {
// Check if all doses in this day are taken (auto-collapse)
const allDoseIds = day.meds.flatMap((item) => item.doses.map((d) => d.id));
const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id));
const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length;
// Check if this is today
const today = new Date();
today.setHours(0, 0, 0, 0);
const dayDate = new Date(day.date);
dayDate.setHours(0, 0, 0, 0);
const isToday = dayDate.getTime() === today.getTime();
// Determine if day should be collapsed: only today is expanded by default
const isAutoCollapsed = allDayTaken || !isToday;
const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr);
const isManuallyCollapsed = manuallyCollapsedDays.has(day.dateStr);
const isCollapsed = isAutoCollapsed ? !isManuallyExpanded : isManuallyCollapsed;
return (
<div key={day.dateStr} className={`day-block ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : ""} ${isToday ? "today" : ""}`}>
<div
className="day-divider clickable"
onClick={() => toggleDayCollapse(day.dateStr, isAutoCollapsed)}
title={isCollapsed ? t('common.expand') : t('common.collapse')}
>
<span className="day-collapse-icon">{isCollapsed ? "▶" : "▼"}</span>
<span className="day-date">{day.dateStr}</span>
<span className="day-summary">
{allDayTaken ? (
<span className="day-complete"> {t('dashboard.schedules.allTaken')}</span>
) : (
<span className="day-progress">{takenCount}/{allDoseIds.length}</span>
)}
</span>
</div>
{!isCollapsed && 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;
// Only disable doses on future DAYS, not later today
const doseDate = new Date(dose.when);
doseDate.setHours(0, 0, 0, 0);
const todayMidnight = new Date();
todayMidnight.setHours(0, 0, 0, 0);
const isFutureDose = doseDate.getTime() > todayMidnight.getTime();
return (
<div key={dose.id} className={`dose-item ${isTaken ? "taken" : ""} ${isOverdue ? "overdue" : ""} ${isFutureDose ? "future" : ""}`}>
<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')} disabled={isFutureDose}></button>
)}
</div>
);
})}
</div>
</div>
);
})}
</div>
);
})}
</>
)}
</div>
+6 -2
View File
@@ -32,7 +32,11 @@
"1month": "1 Monat",
"3months": "3 Monate",
"6months": "6 Monate",
"allTaken": "Alle eingenommen"
"allTaken": "Alle eingenommen",
"showPastDays": "Vergangene Tage anzeigen",
"hidePastDays": "Vergangene Tage ausblenden",
"pastDaysCount": "{{count}} vergangener Tag",
"pastDaysCount_other": "{{count}} vergangene Tage"
},
"reminders": {
"active": "Automatische Erinnerungen aktiv",
@@ -106,7 +110,7 @@
"weight": "z.B. 240",
"notes": "z.B. Mit Essen einnehmen, Alkohol vermeiden... (optional)"
},
"slices": {
"blisters": {
"title": "Einnahmeplan",
"remind": "Erinnern",
"remindTooltip": "Erhalte eine Benachrichtigung 15 Minuten vor jeder geplanten Einnahme",
+6 -2
View File
@@ -34,7 +34,11 @@
"1month": "1 month",
"3months": "3 months",
"6months": "6 months",
"allTaken": "All taken"
"allTaken": "All taken",
"showPastDays": "Show past days",
"hidePastDays": "Hide past days",
"pastDaysCount": "{{count}} past day",
"pastDaysCount_other": "{{count}} past days"
},
"reminders": {
"active": "Automatic reminders active",
@@ -108,7 +112,7 @@
"weight": "e.g. 240",
"notes": "e.g. Take with food, avoid alcohol... (optional)"
},
"slices": {
"blisters": {
"title": "Intake schedule",
"remind": "Remind",
"remindTooltip": "Receive a notification 15 minutes before each scheduled intake",
+66 -13
View File
@@ -231,8 +231,8 @@ body {
.muted { color: var(--text-secondary); font-size: 0.95rem; }
.small { font-size: 0.9rem; }
.slice-list { display: flex; flex-direction: column; gap: 0.4rem; margin-top: 0.5rem; width: 100%; }
.slice-row-simple {
.blister-list { display: flex; flex-direction: column; gap: 0.4rem; margin-top: 0.5rem; width: 100%; }
.blister-row-simple {
color: var(--text-muted);
font-size: 0.9rem;
padding: 0.6rem 0.75rem 0.6rem 1rem;
@@ -243,7 +243,7 @@ body {
width: 100%;
transition: background 200ms ease, border-color 200ms ease;
}
.slice-row-simple:hover {
.blister-row-simple:hover {
background: linear-gradient(90deg, rgba(47, 134, 246, 0.18) 0%, var(--bg-input) 100%);
border-left-color: var(--accent-light);
}
@@ -276,18 +276,18 @@ body {
.med-header { flex-direction: column; }
.med-actions { align-self: flex-start; }
}
.slice-list { display: flex; flex-wrap: wrap; gap: 0.35rem; margin-top: 0.6rem; }
.slice-pill { display: flex; gap: 0.4rem; flex-wrap: wrap; }
.slice-row { display: flex; flex-direction: column; gap: 0.75rem; background: var(--bg-tertiary); border: 1px solid var(--border-primary); padding: 1rem; border-radius: 8px; margin-bottom: 0.65rem; transition: background 200ms ease; }
[data-theme=\"light\"] .slice-row { background: var(--bg-tertiary); }
.slice-row .slice-inputs { display: grid; grid-template-columns: 1fr 1fr 2fr; gap: 1rem; align-items: end; }
.slice-row button { align-self: flex-end; width: auto; }
.slice-row:last-child { margin-bottom: 0; }
.slices h3 { margin: 0; }
.blister-list { display: flex; flex-wrap: wrap; gap: 0.35rem; margin-top: 0.6rem; }
.blister-pill { display: flex; gap: 0.4rem; flex-wrap: wrap; }
.blister-row { display: flex; flex-direction: column; gap: 0.75rem; background: var(--bg-tertiary); border: 1px solid var(--border-primary); padding: 1rem; border-radius: 8px; margin-bottom: 0.65rem; transition: background 200ms ease; }
[data-theme=\"light\"] .blister-row { background: var(--bg-tertiary); }
.blister-row .blister-inputs { display: grid; grid-template-columns: 1fr 1fr 2fr; gap: 1rem; align-items: end; }
.blister-row button { align-self: flex-end; width: auto; }
.blister-row:last-child { margin-bottom: 0; }
.blisters h3 { margin: 0; }
.gap { gap: 0.6rem; }
/* Slices header actions */
.slices-actions {
/* Blisters header actions */
.blisters-actions {
display: flex;
align-items: center;
gap: 0.75rem;
@@ -387,9 +387,62 @@ textarea {
.align-end { display: flex; justify-content: flex-end; gap: 0.75rem; }
.timeline { display: flex; flex-direction: column; gap: 1rem; }
/* Past days toggle button */
.past-days-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
background: var(--bg-tertiary);
border: 1px dashed var(--border-primary);
border-radius: 12px;
cursor: pointer;
user-select: none;
color: var(--text-secondary);
font-size: 0.9rem;
transition: all 0.2s ease;
}
.past-days-toggle:hover {
background: var(--bg-secondary);
border-color: var(--accent);
color: var(--text-primary);
}
.past-days-toggle.expanded {
border-style: solid;
border-color: var(--accent);
background: rgba(47, 134, 246, 0.05);
}
.past-days-icon {
font-size: 0.7rem;
opacity: 0.6;
}
.past-days-label {
font-weight: 500;
}
.past-days-count {
opacity: 0.6;
font-size: 0.85rem;
}
/* Past day blocks styling */
.day-block.past {
opacity: 0.7;
border-style: dashed;
}
.day-block.past .day-divider {
color: var(--text-secondary);
}
.dose-item.past {
opacity: 0.8;
}
.day-block { border: 1px solid var(--border-primary); border-radius: 16px; padding: 1rem 1.25rem; background: var(--bg-secondary); box-shadow: 0 8px 32px var(--shadow); transition: background 200ms ease, border-color 200ms ease; }
.day-block.collapsed { padding-bottom: 0.75rem; }
.day-block.today { border-color: var(--accent); border-width: 2px; background: linear-gradient(135deg, rgba(47, 134, 246, 0.08) 0%, rgba(47, 134, 246, 0.02) 100%); box-shadow: 0 8px 32px var(--shadow), 0 0 0 1px rgba(47, 134, 246, 0.1); }
.day-block.today .day-divider { color: var(--accent); }
.day-block.all-taken { border-color: rgba(57, 217, 138, 0.3); }
.day-block.today.all-taken { border-color: var(--success); background: linear-gradient(135deg, rgba(57, 217, 138, 0.08) 0%, rgba(57, 217, 138, 0.02) 100%); }
.day-divider {
margin: 0 0 0.75rem;
padding-bottom: 0.5rem;