feat: implement per-person dose tracking and update migration process
- Enhanced the database migration process to ensure compatibility with existing production databases, including detailed steps for adding/modifying columns. - Updated the client-side logic to support tracking doses taken by multiple users, including changes to the data structure and UI components. - Added new styles for per-person dose tracking to improve user experience and visual clarity.
This commit is contained in:
@@ -207,29 +207,80 @@ Example: `5-0-1735344000000` = Medication 5, Blister 0, timestamp
|
||||
- **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)
|
||||
## ⚠️⚠️⚠️ Database Migrations (ABSOLUTELY CRITICAL) ⚠️⚠️⚠️
|
||||
|
||||
**When adding/modifying database columns or tables, ALWAYS do ALL of the following:**
|
||||
**THIS IS NON-NEGOTIABLE: ALL database changes MUST work for EXISTING production databases!**
|
||||
|
||||
1. **Update schema**: `backend/src/db/schema.ts`
|
||||
2. **Create migration file**: `backend/src/db/migrations/XXXX_description.sql`
|
||||
```sql
|
||||
-- Example: Adding a new column
|
||||
ALTER TABLE medications ADD COLUMN new_column TEXT;
|
||||
```
|
||||
3. **Update journal**: `backend/src/db/migrations/meta/_journal.json`
|
||||
```json
|
||||
{ "idx": X, "version": 1, "when": TIMESTAMP, "tag": "XXXX_description", "breakpoint": false }
|
||||
```
|
||||
4. **Update migrate.ts**: `backend/src/db/migrate.ts`
|
||||
- This file contains `CREATE TABLE IF NOT EXISTS` statements for fresh database starts
|
||||
- Add new columns to the relevant table or add new tables
|
||||
- Without this, fresh installs will be missing the new columns/tables!
|
||||
Users update their Docker containers and expect the app to work with their existing data. If migrations don't run automatically, the app crashes with `SQLITE_ERROR: no such column` errors.
|
||||
|
||||
**Why this matters**:
|
||||
- Migration SQL files: Required for upgrading existing databases
|
||||
- migrate.ts: Required for fresh database starts (creates tables with all columns)
|
||||
- Forgetting either causes `SQLITE_ERROR: no such column` errors
|
||||
### The Migration System
|
||||
|
||||
The app uses **auto-migrations at startup** in `backend/src/db/client.ts`. This file:
|
||||
1. Creates tables if they don't exist (fresh install)
|
||||
2. Runs `ALTER TABLE ADD COLUMN` for each new column (existing databases)
|
||||
3. Ignores "duplicate column" errors (migration already applied)
|
||||
|
||||
### When adding/modifying database columns or tables, ALWAYS do ALL of the following:
|
||||
|
||||
#### 1. Update schema: `backend/src/db/schema.ts`
|
||||
```typescript
|
||||
// Add the new column to the Drizzle schema
|
||||
stockCalculationMode: text("stock_calculation_mode").notNull().default("automatic"),
|
||||
```
|
||||
|
||||
#### 2. Update client.ts TABLE CREATION: `backend/src/db/client.ts`
|
||||
Find the `CREATE TABLE IF NOT EXISTS` statement and add the new column:
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS user_settings (
|
||||
...
|
||||
stock_calculation_mode text NOT NULL DEFAULT 'automatic', -- ADD THIS LINE
|
||||
...
|
||||
)
|
||||
```
|
||||
**This is for FRESH installs** - new databases get all columns from the start.
|
||||
|
||||
#### 3. Update client.ts MIGRATIONS ARRAY: `backend/src/db/client.ts`
|
||||
Add an entry to the `migrations` array:
|
||||
```typescript
|
||||
const migrations = [
|
||||
...existing migrations...
|
||||
{ name: "user_settings_stock_calculation_mode", sql: "ALTER TABLE user_settings ADD COLUMN stock_calculation_mode TEXT NOT NULL DEFAULT 'automatic'" },
|
||||
];
|
||||
```
|
||||
**This is for EXISTING databases** - the ALTER TABLE adds the column to old databases.
|
||||
|
||||
#### 4. Create migration SQL file (for documentation): `backend/src/db/migrations/XXXX_description.sql`
|
||||
```sql
|
||||
-- Add stock calculation mode setting
|
||||
ALTER TABLE user_settings ADD COLUMN stock_calculation_mode TEXT NOT NULL DEFAULT 'automatic';
|
||||
```
|
||||
|
||||
#### 5. Update journal: `backend/src/db/migrations/meta/_journal.json`
|
||||
```json
|
||||
{ "idx": X, "version": 1, "when": TIMESTAMP, "tag": "XXXX_description", "breakpoint": false }
|
||||
```
|
||||
|
||||
#### 6. Update migrate.ts: `backend/src/db/migrate.ts`
|
||||
Add the column to the `CREATE TABLE` statement AND to the `migrations` array.
|
||||
|
||||
### ⚠️ CRITICAL CHECKLIST - DO NOT SKIP ANY STEP:
|
||||
|
||||
| Step | File | Purpose | If Missing |
|
||||
|------|------|---------|------------|
|
||||
| 1 | `schema.ts` | Drizzle ORM knows about column | TypeScript errors |
|
||||
| 2 | `client.ts` (CREATE TABLE) | Fresh installs have column | Fresh installs crash |
|
||||
| 3 | `client.ts` (migrations array) | Existing DBs get column | **PRODUCTION CRASHES** |
|
||||
| 4 | `migrations/*.sql` | Documentation | None (but keep for history) |
|
||||
| 5 | `_journal.json` | Migration tracking | None (but keep for history) |
|
||||
| 6 | `migrate.ts` | CLI migration tool | CLI tool fails |
|
||||
|
||||
**Step 3 is the most critical!** Without it, users who update their Docker container will get `SQLITE_ERROR: no such column` and the app will not start.
|
||||
|
||||
### Testing Migrations
|
||||
|
||||
Before pushing changes:
|
||||
1. Test with fresh database: Delete `backend/data/medassist-ng.db` and restart
|
||||
2. Test with existing database: Keep old DB and restart - new columns should be added automatically
|
||||
|
||||
## File Locations
|
||||
|
||||
|
||||
@@ -78,6 +78,7 @@ async function runMigrations() {
|
||||
name text NOT NULL,
|
||||
generic_name text,
|
||||
taken_by text,
|
||||
taken_by_json text NOT NULL DEFAULT '[]',
|
||||
count integer NOT NULL DEFAULT 0,
|
||||
strips integer NOT NULL DEFAULT 0,
|
||||
pack_count integer NOT NULL DEFAULT 1,
|
||||
@@ -114,6 +115,7 @@ async function runMigrations() {
|
||||
high_stock_days integer NOT NULL DEFAULT 180,
|
||||
expiry_warning_days integer NOT NULL DEFAULT 90,
|
||||
language text NOT NULL DEFAULT 'en',
|
||||
stock_calculation_mode text NOT NULL DEFAULT 'automatic',
|
||||
last_auto_email_sent text,
|
||||
last_notification_type text,
|
||||
last_notification_channel text,
|
||||
@@ -137,6 +139,7 @@ async function runMigrations() {
|
||||
taken_by text NOT NULL,
|
||||
schedule_days integer NOT NULL DEFAULT 30,
|
||||
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||
expires_at integer,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS dose_tracking (
|
||||
@@ -167,10 +170,13 @@ async function runMigrations() {
|
||||
{ name: "intake_reminders_enabled", sql: "ALTER TABLE medications ADD COLUMN intake_reminders_enabled INTEGER NOT NULL DEFAULT 0" },
|
||||
{ name: "pill_weight_mg", sql: "ALTER TABLE medications ADD COLUMN pill_weight_mg REAL" },
|
||||
{ name: "taken_by", sql: "ALTER TABLE medications ADD COLUMN taken_by TEXT" },
|
||||
{ name: "taken_by_json", sql: "ALTER TABLE medications ADD COLUMN taken_by_json TEXT NOT NULL DEFAULT '[]'" },
|
||||
{ name: "users_email", sql: "ALTER TABLE users ADD COLUMN email TEXT" },
|
||||
{ name: "users_avatar_url", sql: "ALTER TABLE users ADD COLUMN avatar_url TEXT" },
|
||||
{ name: "users_oidc_subject", sql: "ALTER TABLE users ADD COLUMN oidc_subject TEXT" },
|
||||
{ name: "user_settings_expiry_warning_days", sql: "ALTER TABLE user_settings ADD COLUMN expiry_warning_days INTEGER NOT NULL DEFAULT 90" },
|
||||
{ name: "user_settings_stock_calculation_mode", sql: "ALTER TABLE user_settings ADD COLUMN stock_calculation_mode TEXT NOT NULL DEFAULT 'automatic'" },
|
||||
{ name: "share_tokens_expires_at", sql: "ALTER TABLE share_tokens ADD COLUMN expires_at INTEGER" },
|
||||
];
|
||||
|
||||
for (const migration of migrations) {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { createClient } from "@libsql/client";
|
||||
import dotenv from "dotenv";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
dotenv.config({ path: process.env.DOTENV_PATH || ".env" });
|
||||
|
||||
@@ -117,6 +119,52 @@ async function main() {
|
||||
await client.execute(stmt);
|
||||
}
|
||||
|
||||
console.log("Base tables created. Running migrations for existing databases...");
|
||||
|
||||
// Run incremental migrations for existing databases
|
||||
// These ALTER TABLE statements are safe to run multiple times (they'll fail silently if column exists)
|
||||
const migrations = [
|
||||
// 0003: Add image_url to medications
|
||||
{ name: "0003_add_image_url", sql: "ALTER TABLE medications ADD COLUMN image_url text" },
|
||||
// 0004: Add expiry_date to medications
|
||||
{ name: "0004_add_expiry_date", sql: "ALTER TABLE medications ADD COLUMN expiry_date text" },
|
||||
// 0005: Add notes to medications
|
||||
{ name: "0005_add_notes", sql: "ALTER TABLE medications ADD COLUMN notes text" },
|
||||
// 0006: Add generic_name to medications
|
||||
{ name: "0006_add_generic_name", sql: "ALTER TABLE medications ADD COLUMN generic_name text" },
|
||||
// 0007: Add intake_reminders_enabled to medications
|
||||
{ name: "0007_add_intake_reminders", sql: "ALTER TABLE medications ADD COLUMN intake_reminders_enabled integer NOT NULL DEFAULT 0" },
|
||||
// 0008: Add pill_weight_mg to medications
|
||||
{ name: "0008_add_pill_weight", sql: "ALTER TABLE medications ADD COLUMN pill_weight_mg integer" },
|
||||
// 0009: Add taken_by to medications
|
||||
{ name: "0009_add_taken_by", sql: "ALTER TABLE medications ADD COLUMN taken_by text" },
|
||||
// 0012: Add avatar_url to users
|
||||
{ name: "0012_add_user_avatar", sql: "ALTER TABLE users ADD COLUMN avatar_url text" },
|
||||
// 0013: Add oidc_subject to users
|
||||
{ name: "0013_add_oidc_subject", sql: "ALTER TABLE users ADD COLUMN oidc_subject text" },
|
||||
// 0014: Add stock_calculation_mode to user_settings
|
||||
{ name: "0014_add_stock_calculation_mode", sql: "ALTER TABLE user_settings ADD COLUMN stock_calculation_mode text NOT NULL DEFAULT 'automatic'" },
|
||||
// 0015: Add expires_at to share_tokens
|
||||
{ name: "0015_add_share_token_expiry", sql: "ALTER TABLE share_tokens ADD COLUMN expires_at integer" },
|
||||
// 0016: Add taken_by_json to medications
|
||||
{ name: "0016_taken_by_json_array", sql: "ALTER TABLE medications ADD COLUMN taken_by_json text NOT NULL DEFAULT '[]'" },
|
||||
];
|
||||
|
||||
for (const migration of migrations) {
|
||||
try {
|
||||
await client.execute(migration.sql);
|
||||
console.log(`Migration ${migration.name}: applied`);
|
||||
} catch (err: unknown) {
|
||||
// Ignore "duplicate column" errors - means migration was already applied
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
if (errorMessage.includes("duplicate column") || errorMessage.includes("already exists")) {
|
||||
console.log(`Migration ${migration.name}: already applied (skipped)`);
|
||||
} else {
|
||||
console.error(`Migration ${migration.name}: failed - ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log("Database setup complete!");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
+145
-50
@@ -535,13 +535,13 @@ function AppContent() {
|
||||
};
|
||||
|
||||
const groupedSchedule = useMemo(() => {
|
||||
type DoseInfo = { id: string; timeStr: string; when: number; usage: number };
|
||||
type DoseInfo = { id: string; timeStr: string; when: number; usage: number; takenBy: string[] };
|
||||
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), 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 });
|
||||
medEntry.doses.push({ id: event.id, timeStr: event.timeStr, when: event.when, usage: event.usage, takenBy: event.takenBy || [] });
|
||||
medEntry.lastWhen = Math.max(medEntry.lastWhen, event.when);
|
||||
day.meds.set(event.medName, medEntry);
|
||||
days.set(event.dateStr, day);
|
||||
@@ -1327,7 +1327,7 @@ function AppContent() {
|
||||
<div className="timeline">
|
||||
{/* Past days toggle */}
|
||||
{pastDays.length > 0 && (() => {
|
||||
const totalPastDoses = pastDays.flatMap(d => d.meds.flatMap(m => m.doses.map(dose => dose.id)));
|
||||
const totalPastDoses = pastDays.flatMap(d => d.meds.flatMap(m => m.doses.flatMap(dose => dose.takenBy.length > 0 ? dose.takenBy.map(p => `${dose.id}-${p}`) : [dose.id])));
|
||||
const missedPastDoses = totalPastDoses.filter(id => !takenDoses.has(id)).length;
|
||||
return (
|
||||
<div
|
||||
@@ -1349,7 +1349,7 @@ function AppContent() {
|
||||
})()}
|
||||
{/* Past days (when expanded) */}
|
||||
{showPastDays && pastDays.map((day) => {
|
||||
const allDoseIds = day.meds.flatMap((item) => item.doses.map((d) => d.id));
|
||||
const allDoseIds = day.meds.flatMap((item) => item.doses.flatMap((d) => d.takenBy.length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]));
|
||||
const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id));
|
||||
const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length;
|
||||
const isAutoCollapsed = true; // Past days are always auto-collapsed
|
||||
@@ -1376,7 +1376,10 @@ function AppContent() {
|
||||
</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));
|
||||
const medCov = coverageByMed[item.medName];
|
||||
const isEmpty = medCov ? medCov.medsLeft <= 0 : false;
|
||||
const itemDoseIds = item.doses.flatMap((d) => d.takenBy.length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]);
|
||||
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
|
||||
return (
|
||||
<div key={`${day.dateStr}-${item.medName}`} className={`time-row ${allTaken ? "taken" : ""}`}>
|
||||
<div className="time-main">
|
||||
@@ -1387,15 +1390,23 @@ function AppContent() {
|
||||
</div>
|
||||
<div className="doses-col">
|
||||
{item.doses.map((dose) => {
|
||||
const isTaken = takenDoses.has(dose.id);
|
||||
// If no takenBy, show single checkbox; otherwise show one per person
|
||||
const people = dose.takenBy.length > 0 ? dose.takenBy : [null];
|
||||
return (
|
||||
<div key={dose.id} className={`dose-item past ${isTaken ? "taken" : ""}`}>
|
||||
<div key={dose.id} className="dose-item past">
|
||||
<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 && med.takenBy.length > 0 && <span className="taken-by-inline"> {t('dose.takenBy')} {med.takenBy.map((person, i) => (<span key={person}>{i > 0 && ", "}<span className="taken-by-name clickable" onClick={() => setSelectedUser(person)}>{person}</span></span>))}</span>}</span>
|
||||
<span className="dose-usage">{dose.usage} {dose.usage !== 1 ? t('common.pills') : t('common.pill')}{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`}</span>
|
||||
<div className="dose-checks">
|
||||
{people.map((person) => {
|
||||
const personDoseId = person ? `${dose.id}-${person}` : dose.id;
|
||||
const isTaken = takenDoses.has(personDoseId);
|
||||
return (
|
||||
<div key={personDoseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
|
||||
{person && <span className="person-name clickable" onClick={() => setSelectedUser(person)}>{person}</span>}
|
||||
{isTaken ? (
|
||||
<button className="dose-btn undo" onClick={() => undoDoseTaken(dose.id)} title={t('common.undo')}>↩</button>
|
||||
<button className="dose-btn undo" onClick={() => undoDoseTaken(personDoseId)} title={t('common.undo')}>↩</button>
|
||||
) : (
|
||||
<button className="dose-btn take" onClick={() => markDoseTaken(dose.id)} title={t('dose.markAsTaken')}>✓</button>
|
||||
<button className="dose-btn take" onClick={() => markDoseTaken(personDoseId)} title={t('dose.markAsTaken')} disabled={isEmpty}>✓</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -1405,12 +1416,16 @@ function AppContent() {
|
||||
);
|
||||
})}
|
||||
</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 allDoseIds = day.meds.flatMap((item) => item.doses.flatMap((d) => d.takenBy.length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]));
|
||||
const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id));
|
||||
const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length;
|
||||
|
||||
@@ -1460,12 +1475,14 @@ function AppContent() {
|
||||
const medCoverage = coverageByMed[item.medName];
|
||||
const med = meds.find(m => m.name === item.medName);
|
||||
const depletionTime = depletionByMed[item.medName];
|
||||
const isEmpty = medCoverage ? medCoverage.medsLeft <= 0 : false;
|
||||
// Check if this dose is scheduled after medication runs out
|
||||
const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime;
|
||||
const status = willBeOutOfStock
|
||||
? { className: "danger", label: "status.outOfStock" }
|
||||
: medCoverage ? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings) : null;
|
||||
const allTaken = item.doses.every((d) => takenDoses.has(d.id));
|
||||
const itemDoseIds = item.doses.flatMap((d) => d.takenBy.length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]);
|
||||
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
|
||||
return (
|
||||
<div key={`${day.dateStr}-${item.medName}`} className={`time-row ${allTaken ? "taken" : ""}`}>
|
||||
<div className="time-main">
|
||||
@@ -1479,7 +1496,6 @@ function AppContent() {
|
||||
</div>
|
||||
<div className="doses-col">
|
||||
{item.doses.map((dose) => {
|
||||
const isTaken = takenDoses.has(dose.id);
|
||||
const isOverdue = dose.when < Date.now();
|
||||
// Only disable doses on future DAYS, not later today
|
||||
const doseDate = new Date(dose.when);
|
||||
@@ -1487,14 +1503,23 @@ function AppContent() {
|
||||
const todayMidnight = new Date();
|
||||
todayMidnight.setHours(0, 0, 0, 0);
|
||||
const isFutureDose = doseDate.getTime() > todayMidnight.getTime();
|
||||
// If no takenBy, show single checkbox; otherwise show one per person
|
||||
const people = dose.takenBy.length > 0 ? dose.takenBy : [null];
|
||||
return (
|
||||
<div key={dose.id} className={`dose-item ${isTaken ? "taken" : ""} ${isOverdue ? "overdue" : ""} ${isFutureDose ? "future" : ""}`}>
|
||||
<div key={dose.id} className={`dose-item ${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)`}{med?.takenBy && med.takenBy.length > 0 && <span className="taken-by-inline"> {t('dose.takenBy')} {med.takenBy.map((person, i) => (<span key={person}>{i > 0 && ", "}<span className="taken-by-name clickable" onClick={() => setSelectedUser(person)}>{person}</span></span>))}</span>}</span>
|
||||
<span className="dose-usage">{dose.usage} {dose.usage !== 1 ? t('common.pills') : t('common.pill')}{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`}</span>
|
||||
<div className="dose-checks">
|
||||
{people.map((person) => {
|
||||
const personDoseId = person ? `${dose.id}-${person}` : dose.id;
|
||||
const isTaken = takenDoses.has(personDoseId);
|
||||
return (
|
||||
<div key={personDoseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
|
||||
{person && <span className="person-name clickable" onClick={() => setSelectedUser(person)}>{person}</span>}
|
||||
{isTaken ? (
|
||||
<button className="dose-btn undo" onClick={() => undoDoseTaken(dose.id)} title={t('common.undo')}>↩</button>
|
||||
<button className="dose-btn undo" onClick={() => undoDoseTaken(personDoseId)} title={t('common.undo')}>↩</button>
|
||||
) : (
|
||||
<button className="dose-btn take" onClick={() => markDoseTaken(dose.id)} title={t('dose.markAsTaken')} disabled={isFutureDose}>✓</button>
|
||||
<button className="dose-btn take" onClick={() => markDoseTaken(personDoseId)} title={t('dose.markAsTaken')} disabled={isFutureDose || isEmpty}>✓</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -1504,6 +1529,10 @@ function AppContent() {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -2186,7 +2215,7 @@ function AppContent() {
|
||||
<div className="timeline">
|
||||
{/* Past days toggle */}
|
||||
{pastDays.length > 0 && (() => {
|
||||
const totalPastDoses = pastDays.flatMap(d => d.meds.flatMap(m => m.doses.map(dose => dose.id)));
|
||||
const totalPastDoses = pastDays.flatMap(d => d.meds.flatMap(m => m.doses.flatMap(dose => dose.takenBy.length > 0 ? dose.takenBy.map(p => `${dose.id}-${p}`) : [dose.id])));
|
||||
const missedPastDoses = totalPastDoses.filter(id => !takenDoses.has(id)).length;
|
||||
return (
|
||||
<div
|
||||
@@ -2204,7 +2233,7 @@ function AppContent() {
|
||||
})()}
|
||||
{/* Past days (when expanded) */}
|
||||
{showPastDays && pastDays.map((day) => {
|
||||
const allDoseIds = day.meds.flatMap((item) => item.doses.map((d) => d.id));
|
||||
const allDoseIds = day.meds.flatMap((item) => item.doses.flatMap((d) => d.takenBy.length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]));
|
||||
const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id));
|
||||
const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length;
|
||||
const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr);
|
||||
@@ -2230,7 +2259,10 @@ function AppContent() {
|
||||
</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));
|
||||
const medCov = coverageByMed[item.medName];
|
||||
const isEmpty = medCov ? medCov.medsLeft <= 0 : false;
|
||||
const itemDoseIds = item.doses.flatMap((d) => d.takenBy.length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]);
|
||||
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
|
||||
return (
|
||||
<div key={`${day.dateStr}-${item.medName}`} className={`time-row ${allTaken ? "taken" : ""}`}>
|
||||
<div className="time-main">
|
||||
@@ -2241,15 +2273,23 @@ function AppContent() {
|
||||
</div>
|
||||
<div className="doses-col">
|
||||
{item.doses.map((dose) => {
|
||||
const isTaken = takenDoses.has(dose.id);
|
||||
// If no takenBy, show single checkbox; otherwise show one per person
|
||||
const people = dose.takenBy.length > 0 ? dose.takenBy : [null];
|
||||
return (
|
||||
<div key={dose.id} className={`dose-item past ${isTaken ? "taken" : ""}`}>
|
||||
<div key={dose.id} className="dose-item past">
|
||||
<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 && med.takenBy.length > 0 && <span className="taken-by-inline"> {t('dose.takenBy')} {med.takenBy.map((person, i) => (<span key={person}>{i > 0 && ", "}<span className="taken-by-name clickable" onClick={() => setSelectedUser(person)}>{person}</span></span>))}</span>}</span>
|
||||
<span className="dose-usage">{dose.usage} {dose.usage !== 1 ? t('common.pills') : t('common.pill')}{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`}</span>
|
||||
<div className="dose-checks">
|
||||
{people.map((person) => {
|
||||
const personDoseId = person ? `${dose.id}-${person}` : dose.id;
|
||||
const isTaken = takenDoses.has(personDoseId);
|
||||
return (
|
||||
<div key={personDoseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
|
||||
{person && <span className="person-name clickable" onClick={() => setSelectedUser(person)}>{person}</span>}
|
||||
{isTaken ? (
|
||||
<button className="dose-btn undo" onClick={() => undoDoseTaken(dose.id)} title={t('common.undo')}>↩</button>
|
||||
<button className="dose-btn undo" onClick={() => undoDoseTaken(personDoseId)} title={t('common.undo')}>↩</button>
|
||||
) : (
|
||||
<button className="dose-btn take" onClick={() => markDoseTaken(dose.id)} title={t('dose.markAsTaken')}>✓</button>
|
||||
<button className="dose-btn take" onClick={() => markDoseTaken(personDoseId)} disabled={isEmpty} title={t('dose.markAsTaken')}>✓</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -2259,6 +2299,10 @@ function AppContent() {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* Current and future days */}
|
||||
@@ -2273,6 +2317,7 @@ function AppContent() {
|
||||
<div className="day-divider">{day.dateStr}</div>
|
||||
{day.meds.map((item) => {
|
||||
const medCoverage = coverageByMed[item.medName];
|
||||
const isEmpty = medCoverage ? medCoverage.medsLeft <= 0 : false;
|
||||
const med = meds.find(m => m.name === item.medName);
|
||||
const depletionTime = depletionByMed[item.medName];
|
||||
// Check if this dose is scheduled after medication runs out
|
||||
@@ -2280,7 +2325,8 @@ function AppContent() {
|
||||
const status = willBeOutOfStock
|
||||
? { className: "danger", label: "status.outOfStock" }
|
||||
: medCoverage ? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings) : null;
|
||||
const allTaken = item.doses.every((d) => takenDoses.has(d.id));
|
||||
const itemDoseIds = item.doses.flatMap((d) => d.takenBy.length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]);
|
||||
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
|
||||
return (
|
||||
<div key={`${day.dateStr}-${item.medName}`} className={`time-row ${allTaken ? "taken" : ""}`}>
|
||||
<div className="time-main">
|
||||
@@ -2294,16 +2340,26 @@ function AppContent() {
|
||||
</div>
|
||||
<div className="doses-col">
|
||||
{item.doses.map((dose) => {
|
||||
const isTaken = takenDoses.has(dose.id);
|
||||
const isOverdue = !isTaken && dose.when < Date.now();
|
||||
const people = dose.takenBy.length > 0 ? dose.takenBy : [null];
|
||||
const now = Date.now();
|
||||
const dayStart = new Date(day.date).setHours(0, 0, 0, 0);
|
||||
const isPastDay = dayStart < new Date().setHours(0, 0, 0, 0);
|
||||
return (
|
||||
<div key={dose.id} className={`dose-item ${isTaken ? "taken" : ""} ${isOverdue ? "overdue" : ""}`}>
|
||||
<div key={dose.id} className="dose-item">
|
||||
<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 && med.takenBy.length > 0 && <span className="taken-by-inline"> {t('dose.takenBy')} {med.takenBy.map((person, i) => (<span key={person}>{i > 0 && ", "}<span className="taken-by-name clickable" onClick={() => setSelectedUser(person)}>{person}</span></span>))}</span>}</span>
|
||||
<span className="dose-usage">{dose.usage} {dose.usage !== 1 ? t('common.pills') : t('common.pill')}{med?.pillWeightMg && ` (${dose.usage * med.pillWeightMg} mg)`}</span>
|
||||
<div className="dose-checks">
|
||||
{people.map((person) => {
|
||||
const personDoseId = person ? `${dose.id}-${person}` : dose.id;
|
||||
const isTaken = takenDoses.has(personDoseId);
|
||||
const isOverdue = !isTaken && dose.when < now && !isPastDay;
|
||||
return (
|
||||
<div key={personDoseId} className={`dose-person ${isTaken ? "taken" : ""} ${isOverdue ? "overdue" : ""}`}>
|
||||
{person && <span className="person-name clickable" onClick={() => setSelectedUser(person)}>{person}</span>}
|
||||
{isTaken ? (
|
||||
<button className="dose-btn undo" onClick={() => undoDoseTaken(dose.id)} title={t('common.undo')}>↩</button>
|
||||
<button className="dose-btn undo" onClick={() => undoDoseTaken(personDoseId)} title={t('common.undo')}>↩</button>
|
||||
) : (
|
||||
<button className="dose-btn take" onClick={() => markDoseTaken(dose.id)} title={t('dose.markAsTaken')}>✓</button>
|
||||
<button className="dose-btn take" onClick={() => markDoseTaken(personDoseId)} disabled={isEmpty} title={t('dose.markAsTaken')}>✓</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -2313,6 +2369,10 @@ function AppContent() {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);})}
|
||||
</div>
|
||||
</article>
|
||||
@@ -2912,7 +2972,7 @@ END:VCALENDAR`;
|
||||
}
|
||||
|
||||
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 events: Array<{ id: string; medName: string; timeStr: string; dateStr: string; usage: number; when: number; isPast: boolean; takenBy: string[] }> = [];
|
||||
if (!Array.isArray(meds)) return { events, groups: [] };
|
||||
|
||||
const now = new Date();
|
||||
@@ -2932,6 +2992,7 @@ function buildSchedulePreview(meds: Medication[], locale: string, includePast: b
|
||||
events.push({
|
||||
id: `${med.id}-${idx}-${whenMs}`,
|
||||
medName: med.name,
|
||||
takenBy: med.takenBy || [],
|
||||
usage: blister.usage,
|
||||
when: whenMs,
|
||||
isPast,
|
||||
@@ -3050,22 +3111,24 @@ function calculateCoverage(
|
||||
|
||||
if (stockCalculationMode === "automatic") {
|
||||
// Automatic mode: calculate consumed based on schedule since start date
|
||||
// Multiply by personCount since each person takes the medication
|
||||
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;
|
||||
const occurrences = Math.floor((now - start) / period) + 1; // include today if started
|
||||
consumed += occurrences * s.usage;
|
||||
consumed += occurrences * s.usage * personCount;
|
||||
});
|
||||
} else {
|
||||
// Manual mode: count only doses marked as taken for this medication
|
||||
// Dose IDs follow pattern: "{medicationId}-{blisterIndex}-{timestampMs}"
|
||||
// Dose IDs follow pattern: "{medicationId}-{blisterIndex}-{timestampMs}" or "{medicationId}-{blisterIndex}-{timestampMs}-{person}"
|
||||
takenDoses.forEach((doseId) => {
|
||||
const parts = doseId.split("-");
|
||||
if (parts.length >= 3) {
|
||||
const medId = parseInt(parts[0], 10);
|
||||
const blisterIdx = parseInt(parts[1], 10);
|
||||
if (medId === m.id && m.blisters[blisterIdx]) {
|
||||
// Each taken dose (regardless of person) consumes the usage amount
|
||||
consumed += m.blisters[blisterIdx].usage;
|
||||
}
|
||||
}
|
||||
@@ -3586,12 +3649,18 @@ function SharedSchedule() {
|
||||
const depletion: Record<string, number | null> = {};
|
||||
|
||||
// Calculate total pills taken per medication from takenDoses
|
||||
// Each person's taken dose counts separately toward pills consumed
|
||||
const takenByMed: Record<string, number> = {};
|
||||
for (const dose of schedule.flatMap(d => d.meds.flatMap(m => m.doses))) {
|
||||
if (takenDoses.has(dose.id)) {
|
||||
// Check all person-specific dose IDs for this dose
|
||||
const people = dose.takenBy.length > 0 ? dose.takenBy : [null];
|
||||
for (const person of people) {
|
||||
const doseId = person ? `${dose.id}-${person}` : dose.id;
|
||||
if (takenDoses.has(doseId)) {
|
||||
takenByMed[dose.medName] = (takenByMed[dose.medName] || 0) + dose.usage;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const med of data.medications) {
|
||||
const totalCount = med.count ?? 0;
|
||||
@@ -3710,7 +3779,7 @@ function SharedSchedule() {
|
||||
<>
|
||||
{/* Past days toggle */}
|
||||
{pastDays.length > 0 && (() => {
|
||||
const totalPastDoses = pastDays.flatMap(d => d.meds.flatMap(m => m.doses.map(dose => dose.id)));
|
||||
const totalPastDoses = pastDays.flatMap(d => d.meds.flatMap(m => m.doses.flatMap(dose => dose.takenBy.length > 0 ? dose.takenBy.map(p => `${dose.id}-${p}`) : [dose.id])));
|
||||
const missedPastDoses = totalPastDoses.filter(id => !takenDoses.has(id)).length;
|
||||
return (
|
||||
<div
|
||||
@@ -3732,7 +3801,7 @@ function SharedSchedule() {
|
||||
})()}
|
||||
{/* Past days (when expanded) */}
|
||||
{showPastDays && pastDays.map((day) => {
|
||||
const allDoseIds = day.meds.flatMap((item) => item.doses.map((d) => d.id));
|
||||
const allDoseIds = day.meds.flatMap((item) => item.doses.flatMap((d) => d.takenBy.length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]));
|
||||
const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id));
|
||||
const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length;
|
||||
const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr);
|
||||
@@ -3761,6 +3830,7 @@ function SharedSchedule() {
|
||||
{!isCollapsed && day.meds.map((item) => {
|
||||
const med = data.medications.find(m => m.name === item.medName);
|
||||
const medCoverage = coverageByMed[item.medName];
|
||||
const isEmpty = medCoverage ? medCoverage.medsLeft <= 0 : false;
|
||||
const depletionTime = depletionByMed[item.medName];
|
||||
const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime;
|
||||
|
||||
@@ -3779,7 +3849,8 @@ function SharedSchedule() {
|
||||
}
|
||||
}
|
||||
|
||||
const allTaken = item.doses.every((d) => takenDoses.has(d.id));
|
||||
const itemDoseIds = item.doses.flatMap((d) => d.takenBy.length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]);
|
||||
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
|
||||
return (
|
||||
<div key={`${day.dateStr}-${item.medName}`} className={`time-row ${allTaken ? "taken" : ""}`}>
|
||||
<div className="time-main">
|
||||
@@ -3800,18 +3871,25 @@ function SharedSchedule() {
|
||||
</div>
|
||||
<div className="doses-col">
|
||||
{item.doses.map((dose) => {
|
||||
const isTaken = takenDoses.has(dose.id);
|
||||
const people = dose.takenBy.length > 0 ? dose.takenBy : [null];
|
||||
return (
|
||||
<div key={dose.id} className={`dose-item past ${isTaken ? "taken" : ""}`}>
|
||||
<div key={dose.id} className="dose-item past">
|
||||
<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>
|
||||
<div className="dose-checks">
|
||||
{people.map((person) => {
|
||||
const personDoseId = person ? `${dose.id}-${person}` : dose.id;
|
||||
const isTaken = takenDoses.has(personDoseId);
|
||||
return (
|
||||
<div key={personDoseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
|
||||
{person && <span className="person-name">{person}</span>}
|
||||
{isTaken ? (
|
||||
<button className="dose-btn undo" onClick={() => undoDoseTaken(dose.id)} title={t('common.undo')}>↩</button>
|
||||
<button className="dose-btn undo" onClick={() => undoDoseTaken(personDoseId)} title={t('common.undo')}>↩</button>
|
||||
) : (
|
||||
<button className="dose-btn take" onClick={() => markDoseTaken(dose.id)} title={t('dose.markAsTaken')}>✓</button>
|
||||
<button className="dose-btn take" onClick={() => markDoseTaken(personDoseId)} disabled={isEmpty} title={t('dose.markAsTaken')}>✓</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -3821,12 +3899,16 @@ function SharedSchedule() {
|
||||
);
|
||||
})}
|
||||
</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 allDoseIds = day.meds.flatMap((item) => item.doses.flatMap((d) => d.takenBy.length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]));
|
||||
const allDayTaken = allDoseIds.length > 0 && allDoseIds.every((id) => takenDoses.has(id));
|
||||
const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length;
|
||||
|
||||
@@ -3866,6 +3948,7 @@ function SharedSchedule() {
|
||||
{!isCollapsed && day.meds.map((item) => {
|
||||
const med = data.medications.find(m => m.name === item.medName);
|
||||
const medCoverage = coverageByMed[item.medName];
|
||||
const isEmpty = medCoverage ? medCoverage.medsLeft <= 0 : false;
|
||||
const depletionTime = depletionByMed[item.medName];
|
||||
const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime;
|
||||
|
||||
@@ -3884,7 +3967,8 @@ function SharedSchedule() {
|
||||
}
|
||||
}
|
||||
|
||||
const allTaken = item.doses.every((d) => takenDoses.has(d.id));
|
||||
const itemDoseIds = item.doses.flatMap((d) => d.takenBy.length > 0 ? d.takenBy.map((p) => `${d.id}-${p}`) : [d.id]);
|
||||
const allTaken = itemDoseIds.every((id) => takenDoses.has(id));
|
||||
return (
|
||||
<div key={`${day.dateStr}-${item.medName}`} className={`time-row ${allTaken ? "taken" : ""}`}>
|
||||
<div className="time-main">
|
||||
@@ -3905,8 +3989,7 @@ function SharedSchedule() {
|
||||
</div>
|
||||
<div className="doses-col">
|
||||
{item.doses.map((dose) => {
|
||||
const isTaken = takenDoses.has(dose.id);
|
||||
const isOverdue = dose.when < Date.now() && !isTaken;
|
||||
const people = dose.takenBy.length > 0 ? dose.takenBy : [null];
|
||||
// Only disable doses on future DAYS, not later today
|
||||
const doseDate = new Date(dose.when);
|
||||
doseDate.setHours(0, 0, 0, 0);
|
||||
@@ -3914,16 +3997,24 @@ function SharedSchedule() {
|
||||
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" : ""}`}>
|
||||
<div key={dose.id} className={`dose-item ${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>
|
||||
<div className="dose-checks">
|
||||
{people.map((person) => {
|
||||
const personDoseId = person ? `${dose.id}-${person}` : dose.id;
|
||||
const isTaken = takenDoses.has(personDoseId);
|
||||
const isOverdue = dose.when < Date.now() && !isTaken && !isFutureDose;
|
||||
return (
|
||||
<div key={personDoseId} className={`dose-person ${isTaken ? "taken" : ""} ${isOverdue ? "overdue" : ""}`}>
|
||||
{person && <span className="person-name">{person}</span>}
|
||||
{isTaken ? (
|
||||
<button className="dose-btn undo" onClick={() => undoDoseTaken(dose.id)} title={t('common.undo')}>↩</button>
|
||||
<button className="dose-btn undo" onClick={() => undoDoseTaken(personDoseId)} title={t('common.undo')}>↩</button>
|
||||
) : (
|
||||
<button className="dose-btn take" onClick={() => markDoseTaken(dose.id)} title={t('dose.markAsTaken')} disabled={isFutureDose}>✓</button>
|
||||
<button className="dose-btn take" onClick={() => markDoseTaken(personDoseId)} title={t('dose.markAsTaken')} disabled={isFutureDose || isEmpty}>✓</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -3933,6 +4024,10 @@ function SharedSchedule() {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
|
||||
@@ -907,6 +907,62 @@ textarea.auto-resize {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
/* Per-person dose tracking */
|
||||
.dose-checks {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.dose-person {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.dose-person.taken {
|
||||
background: var(--success-bg);
|
||||
}
|
||||
|
||||
.dose-person.overdue {
|
||||
background: var(--warning-bg);
|
||||
}
|
||||
|
||||
.dose-person .person-name {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
min-width: 60px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.dose-person .person-name.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dose-person .person-name.clickable:hover {
|
||||
color: var(--primary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.dose-person.taken .person-name {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.dose-person .dose-btn {
|
||||
margin-left: 0;
|
||||
width: 24px;
|
||||
min-width: 24px;
|
||||
height: 24px;
|
||||
min-height: 24px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.time-row.taken {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user