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:
+161
-15
@@ -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` |
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user