From 9ccb5b1f0f6950f151c27d5912d8c1faa33769b9 Mon Sep 17 00:00:00 2001 From: Daniel Volz Date: Sat, 27 Dec 2025 15:01:54 +0100 Subject: [PATCH] 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. --- .github/copilot-instructions.md | 176 ++++- backend/src/routes/medications.ts | 68 +- backend/src/routes/share.ts | 14 +- .../src/services/intake-reminder-scheduler.ts | 24 +- backend/src/services/reminder-scheduler.ts | 22 +- frontend/src/App.tsx | 605 ++++++++++++------ frontend/src/i18n/de.json | 8 +- frontend/src/i18n/en.json | 8 +- frontend/src/styles.css | 79 ++- 9 files changed, 727 insertions(+), 277 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 3e9afd6..ef1544f 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -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` | diff --git a/backend/src/routes/medications.ts b/backend/src/routes/medications.ts index ddce6e5..ae40064 100644 --- a/backend/src/routes/medications.ts +++ b/backend/src/routes/medications.ts @@ -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)); diff --git a/backend/src/routes/share.ts b/backend/src/routes/share.ts index 73ee0db..a4db41b 100644 --- a/backend/src/routes/share.ts +++ b/backend/src/routes/share.ts @@ -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, }; }); diff --git a/backend/src/services/intake-reminder-scheduler.ts b/backend/src/services/intake-reminder-scheduler.ts index ad78cf3..7b72759 100644 --- a/backend/src/services/intake-reminder-scheduler.ts +++ b/backend/src/services/intake-reminder-scheduler.ts @@ -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); } diff --git a/backend/src/services/reminder-scheduler.ts b/backend/src/services/reminder-scheduler.ts index 36d5cca..142bec1 100644 --- a/backend/src/services/reminder-scheduler.ts +++ b/backend/src/services/reminder-scheduler.ts @@ -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) { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 800005c..ccb76eb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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(null); const [scheduleDays, setScheduleDays] = useState(30); + const [showPastDays, setShowPastDays] = useState(false); const [takenDoses, setTakenDoses] = useState>(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 }>(); + const days = new Map }>(); 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() {
- {groupedSchedule.map((day) => { + {/* Past days toggle */} + {pastDays.length > 0 && ( +
setShowPastDays(!showPastDays)} + > + {showPastDays ? '▼' : '▶'} + + {showPastDays ? t('dashboard.schedules.hidePastDays') : t('dashboard.schedules.showPastDays')} + + ({t('dashboard.schedules.pastDaysCount', { count: pastDays.length })}) +
+ )} + {/* 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 ( +
+
toggleDayCollapse(day.dateStr, isAutoCollapsed)} + title={isCollapsed ? t('common.expand') : t('common.collapse')} + > + {isCollapsed ? "▶" : "▼"} + {day.dateStr} + + {allDayTaken ? ( + ✓ {t('dashboard.schedules.allTaken')} + ) : ( + {takenCount}/{allDoseIds.length} + )} + +
+ {!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 ( +
+
+
{item.medName}{med?.intakeRemindersEnabled && 🔔}
+
+ {item.total} {t('common.pills')} {t('common.total')} +
+
+
+ {item.doses.map((dose) => { + const isTaken = takenDoses.has(dose.id); + return ( +
+ {dose.timeStr} + {dose.usage} {dose.usage !== 1 ? t('common.pills') : t('common.pill')}{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`}{med?.takenBy && {t('dose.takenBy')} setSelectedUser(med.takenBy!)}>{med.takenBy}} + {isTaken ? ( + + ) : ( + + )} +
+ ); + })} +
+
+ ); + })} +
+ ); + })} + {/* 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 ( -
+
toggleDayCollapse(day.dateStr, isAutoCollapsed)} @@ -1175,10 +1252,10 @@ function AppContent() {
-
- {med.slices.map((s, idx) => ( -
- {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)} +
+ {med.blisters.map((s, idx) => ( +
+ {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)}
))}
@@ -1245,39 +1322,39 @@ function AppContent() { /> -
+
-

{t('form.slices.title')}

-
- - -
+

{t('form.blisters.title')}

+
+ + +
- {form.slices.map((s, idx) => ( -
-
+ {form.blisters.map((s, idx) => ( +
+
- {form.slices.length > 1 && ( - + {form.blisters.length > 1 && ( + )}
))} @@ -1730,8 +1807,86 @@ function AppContent() {
- {groupedSchedule.map((day) => ( -
+ {/* Past days toggle */} + {pastDays.length > 0 && ( +
setShowPastDays(!showPastDays)} + > + {showPastDays ? '▼' : '▶'} + + {showPastDays ? t('dashboard.schedules.hidePastDays') : t('dashboard.schedules.showPastDays')} + + ({t('dashboard.schedules.pastDaysCount', { count: pastDays.length })}) +
+ )} + {/* 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 ( +
+
toggleDayCollapse(day.dateStr, true)} + title={isCollapsed ? t('common.expand') : t('common.collapse')} + > + {isCollapsed ? "▶" : "▼"} + {day.dateStr} + + {allDayTaken ? ( + ✓ {t('dashboard.schedules.allTaken')} + ) : ( + {takenCount}/{allDoseIds.length} + )} + +
+ {!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 ( +
+
+
{item.medName}{med?.intakeRemindersEnabled && 🔔}
+
+ {item.total} {t('common.pills')} {t('common.total')} +
+
+
+ {item.doses.map((dose) => { + const isTaken = takenDoses.has(dose.id); + return ( +
+ {dose.timeStr} + {dose.usage} {dose.usage !== 1 ? t('common.pills') : t('common.pill')}{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`}{med?.takenBy && {t('dose.takenBy')} setSelectedUser(med.takenBy!)}>{med.takenBy}} + {isTaken ? ( + + ) : ( + + )} +
+ ); + })} +
+
+ ); + })} +
+ ); + })} + {/* 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 ( +
{day.dateStr}
{day.meds.map((item) => { const medCoverage = coverageByMed[item.medName]; @@ -1775,7 +1930,7 @@ function AppContent() { ); })}
- ))} + );})}
@@ -1858,15 +2013,15 @@ function AppContent() { })()}
- {selectedMed.slices.length > 0 && ( + {selectedMed.blisters.length > 0 && (

{t('modal.intakeSchedule')} {selectedMed.intakeRemindersEnabled && 🔔}

- {selectedMed.slices.map((slice, idx) => ( + {selectedMed.blisters.map((blister, idx) => (
- {slice.usage} {slice.usage !== 1 ? t('common.pills') : t('common.pill')}{selectedMed.pillWeightMg && ` (${slice.usage * selectedMed.pillWeightMg} mg)`} - {t('form.slices.every')} {slice.every} {slice.every !== 1 ? t('common.days') : t('common.day')} - {t('modal.at')} {new Date(slice.start).toLocaleTimeString(i18n.language, { hour: "2-digit", minute: "2-digit" })} + {blister.usage} {blister.usage !== 1 ? t('common.pills') : t('common.pill')}{selectedMed.pillWeightMg && ` (${blister.usage * selectedMed.pillWeightMg} mg)`} + {t('form.blisters.every')} {blister.every} {blister.every !== 1 ? t('common.days') : t('common.day')} + {t('modal.at')} {new Date(blister.start).toLocaleTimeString(i18n.language, { hour: "2-digit", minute: "2-digit" })}
))}
@@ -1912,7 +2067,7 @@ function AppContent() { - {selectedMed.slices.length > 0 && ( + {selectedMed.blisters.length > 0 && ( @@ -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(null); const [takenDoses, setTakenDoses] = useState>(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>(new Set()); const [manuallyExpandedDays, setManuallyExpandedDays] = useState>(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(); 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 (
@@ -2764,94 +2921,180 @@ function SharedSchedule() { {schedule.length === 0 ? (

{t('share.noSchedule')}

) : ( - 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 ( -
-
toggleDayCollapse(day.dateStr, isAutoCollapsed)} - title={isCollapsed ? t('common.expand') : t('common.collapse')} - > - {isCollapsed ? "▶" : "▼"} - {day.dateStr} - - {allDayTaken ? ( - ✓ {t('dashboard.schedules.allTaken')} - ) : ( - {takenCount}/{allDoseIds.length} - )} - -
- {!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 ( -
-
-
- med?.imageUrl && setLightboxImage({ url: med.imageUrl, name: med.name })} - > - - - {item.medName} - {med?.genericName && ({med.genericName})} -
-
- {item.total} {t('common.pills')} {t('common.total')} -
-
-
- {item.doses.map((dose) => { - const isTaken = takenDoses.has(dose.id); - const isOverdue = dose.when < Date.now() && !isTaken; - // 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 ( -
- {dose.timeStr} - - {dose.usage} {dose.usage !== 1 ? t('common.pills') : t('common.pill')} - {med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`} - - {isTaken ? ( - - ) : ( - - )} -
- ); - })} -
-
- ); - })} + <> + {/* Past days toggle */} + {pastDays.length > 0 && ( +
setShowPastDays(!showPastDays)} + > + {showPastDays ? '▼' : '▶'} + + {showPastDays ? t('dashboard.schedules.hidePastDays') : t('dashboard.schedules.showPastDays')} + + ({t('dashboard.schedules.pastDaysCount', { count: pastDays.length })})
- ); - }) + )} + {/* 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 ( +
+
toggleDayCollapse(day.dateStr, true)} + title={isCollapsed ? t('common.expand') : t('common.collapse')} + > + {isCollapsed ? "▶" : "▼"} + {day.dateStr} + + {allDayTaken ? ( + ✓ {t('dashboard.schedules.allTaken')} + ) : ( + {takenCount}/{allDoseIds.length} + )} + +
+ {!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 ( +
+
+
+ med?.imageUrl && setLightboxImage({ url: med.imageUrl, name: med.name })} + > + + + {item.medName} + {med?.genericName && ({med.genericName})} +
+
+ {item.total} {t('common.pills')} {t('common.total')} +
+
+
+ {item.doses.map((dose) => { + const isTaken = takenDoses.has(dose.id); + return ( +
+ {dose.timeStr} + + {dose.usage} {dose.usage !== 1 ? t('common.pills') : t('common.pill')} + {med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`} + + {isTaken ? ( + + ) : ( + + )} +
+ ); + })} +
+
+ ); + })} +
+ ); + })} + {/* 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 ( +
+
toggleDayCollapse(day.dateStr, isAutoCollapsed)} + title={isCollapsed ? t('common.expand') : t('common.collapse')} + > + {isCollapsed ? "▶" : "▼"} + {day.dateStr} + + {allDayTaken ? ( + ✓ {t('dashboard.schedules.allTaken')} + ) : ( + {takenCount}/{allDoseIds.length} + )} + +
+ {!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 ( +
+
+
+ med?.imageUrl && setLightboxImage({ url: med.imageUrl, name: med.name })} + > + + + {item.medName} + {med?.genericName && ({med.genericName})} +
+
+ {item.total} {t('common.pills')} {t('common.total')} +
+
+
+ {item.doses.map((dose) => { + const isTaken = takenDoses.has(dose.id); + const isOverdue = dose.when < Date.now() && !isTaken; + // 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 ( +
+ {dose.timeStr} + + {dose.usage} {dose.usage !== 1 ? t('common.pills') : t('common.pill')} + {med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`} + + {isTaken ? ( + + ) : ( + + )} +
+ ); + })} +
+
+ ); + })} +
+ ); + })} + )}
diff --git a/frontend/src/i18n/de.json b/frontend/src/i18n/de.json index 68e4823..31f34e1 100644 --- a/frontend/src/i18n/de.json +++ b/frontend/src/i18n/de.json @@ -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", diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index e4a29da..661c1ad 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -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", diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 9f4e5ca..7f99ced 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -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;